feat(dependencies): RFC 9253 per-endpoint blocking + status lifecycle categories#19
feat(dependencies): RFC 9253 per-endpoint blocking + status lifecycle categories#19renatomen wants to merge 24 commits into
Conversation
Add an optional StatusCategory (planned | in-progress | completed) to StatusConfig, kept in sync with the load-bearing isCompleted flag rather than replacing it. A load-time normalization backfills category=completed for already-completed statuses and leaves everything else uncategorized (no guess). StatusManager gains isStarted/isFinished/getCategory so the dependency cache can evaluate start-anchored constraints.
Replace the binary completed toggle in the status config card with a category dropdown (Uncategorized / Planned / In progress / Completed) and show the chosen category on the card pill, mirroring how the completed badge rendered before. Selecting Completed keeps isCompleted in sync; migrated statuses render a neutral Uncategorized nudge. Adds category pill styles and the i18n strings.
- Reconcile the isCompleted <-> category invariant in both directions in normalizeStatusCategories, so a completed status can never read as finished-but-not-started once the lifecycle predicates gain consumers. - Fold the duplicated categoryOptions strings into the shared badges keys the pill already uses; drop the orphaned completed toggle strings. - Cover the migration through the real settings load path, and add the completed-with-stale-category reconciliation case. - Record the status settings change in the unreleased changelog.
… Completed) Drop the fourth 'Uncategorized' state per the product decision. Every status now carries one of three lifecycle categories, guaranteed at runtime: the load-time normalizer defaults any non-completed status to Not started, and the two settings write sites (add-status button, createDefaultStatus) seed a category so nothing uncategorized is reachable. Relabel the enum's user-facing strings to Not started / Started / Completed; isCompleted stays the derived Completed mirror.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0717329e2e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| customStatuses: normalizeStatusCategories( | ||
| loadedData?.customStatuses || DEFAULT_SETTINGS.customStatuses | ||
| ), |
There was a problem hiding this comment.
Persist normalized lifecycle categories during migration
For existing settings, this produces the new categories only in memory, but does not mark the load as migrated in shouldPersistMigratedSettings. Consequently, a vault whose only outdated setting is a legacy customStatuses array keeps writing no category values to data.json unless the user later changes and saves a setting; the category migration is repeated on every load and the newly introduced lifecycle data is never actually persisted. Include a category-migration condition in the persistence flag.
Useful? React with 👍 / 👎.
…cyCache Replace the reltype-blind completion-only gating with RFC 9253 per-endpoint constraints: FS/SS gate a task's start, FF/SF gate its finish, and each edge releases when its predecessor reaches its own endpoint (completed for FINISH*, started for START*). The cache stores each edge's reltype and both the finished and started lifecycle per path, computing startBlocked/finishBlocked on demand; the relationship fingerprint keys on reltype so a reltype-only edit re-indexes, and the file signature keys on the per-endpoint blocked membership so a predecessor's lifecycle move re-fires the change event. Exposes isTaskStartBlocked/isTaskFinishBlocked plus reverse per-endpoint accessors.
Add startBlocked/finishBlocked and the reverse isBlockingStart/isBlockingFinish to TaskInfo, sourced from the cache's per-endpoint accessors at both assembly sites (TaskManager, bases/helpers); isBlocked stays as their OR for existing consumers. The cache-absent fallback approximates start/finish-blocked from the task's own edge reltypes (existence-based). This is the contract U5 (surfaces) and U8 (Bases) consume.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3bada0226e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| edges = new Map(); | ||
| this.edgeReltypes.set(dependentPath, edges); | ||
| } | ||
| edges.set(blockingPath, reltype); |
There was a problem hiding this comment.
Preserve all relation types for a predecessor
When a task has multiple dependency entries targeting the same predecessor with distinct relationship types, this map retains only the last one. Such entries are supported by the context-menu deduplication key, which includes reltype; for example, an unfinished predecessor with both FINISHTOSTART and FINISHTOFINISH edges should block both endpoints, but whichever entry appears last causes the other endpoint to be reported as unblocked. Store a collection of relation types per predecessor (or otherwise evaluate every normalized entry) instead of overwriting it.
Useful? React with 👍 / 👎.
…accessors The calendar-filter test mocks dependencyCache with only the legacy accessors; TaskManager's assembly now also calls isTaskStartBlocked/isTaskFinishBlocked and the reverse dependent-path accessors (U4), so the partial mock threw and the task dropped out of the filtered set. Add the missing methods to the mock.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad0cfb0b79
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| categorySelect.addEventListener("change", () => { | ||
| const value = categorySelect.value as StatusCategory; | ||
| status.category = value; | ||
| status.isCompleted = value === "completed"; |
There was a problem hiding this comment.
Refresh dependency state after category changes
When a user changes a non-completed status from Not started to Started in the settings UI, this only updates StatusManager; the dependency cache is not rebuilt because its settings snapshot does not include customStatuses. As a result, the cached startedStatusByPath remains false for tasks using that status, so existing STARTTOSTART and STARTTOFINISH dependencies remain blocked until a task frontmatter change or plugin reload rebuilds the cache.
Useful? React with 👍 / 👎.
| private isEdgeReleased(predecessorPath: string, reltype: TaskDependencyRelType): boolean { | ||
| return reltypeReleasedByPredecessorFinish(reltype) | ||
| ? (this.completedStatusByPath.get(predecessorPath) ?? false) | ||
| : (this.startedStatusByPath.get(predecessorPath) ?? false); |
There was a problem hiding this comment.
Resolve lifecycle state for non-task predecessors
A dependency may resolve to any markdown file, but index construction only populates lifecycle maps for files identified as tasks. Therefore, on an initial cache build, a task blocked by a non-task note whose frontmatter has a completed/started status reaches this fallback and is treated as unreleased; FINISHTOSTART/FINISHTOFINISH edges remain blocked even though the predecessor status satisfies them. Read and cache the predecessor lifecycle on demand (as the previous completion path did), or index resolved non-task predecessors as well.
Useful? React with 👍 / 👎.
Wire the task-card secondary badge, the metadata pill, and the editor widget to the computed start/finish-blocked state instead of raw blocked-by existence. A task reads "Blocked · start" when it cannot begin and "Blocked · finish" when it can begin but cannot complete; start wins over finish so the "can I act now?" signal stays unambiguous. The badge and pill count only currently-constraining predecessors, so a released edge (started SS, completed FS) drops the blocked label and shows as a listed-but-satisfied dependency rather than "Blocked (1)". DependencyCache gains getStart/FinishBlockingPredecessorPaths (the forward constraining view); taskCardRelationships.resolveBlockedConstraint is the shared honest-signal resolver every surface reads.
…modal Add per-row relationship controls to the task edit modal's Blocked-by and Blocking lists (gated by a new enableAdvancedDependencyTypes setting, off by default): a reltype dropdown, a structured lag (number + unit composed to an ISO-8601 duration), and a plain-language summary that names both concrete tasks so the direction can't be read backwards. Blocking rows source their real reltype/gap from the blocked task's own blockedBy (the canonical location) instead of assuming the default, and editing a Blocking edge writes the change back there — TaskService gains a "modify" action alongside add/remove, and the edit-change diff now reports modified edges. An exotic stored lag stays read-only so an edit never silently rewrites it. The row layout stacks on narrow and mobile widths.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6c7a3b27af
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Relationships widget defaults (unified subtasks, projects, and dependencies) | ||
| showRelationships: true, | ||
| relationshipsPosition: "bottom", | ||
| enableAdvancedDependencyTypes: false, |
There was a problem hiding this comment.
Expose the advanced relationship toggle
enableAdvancedDependencyTypes defaults to false, and at this revision its only other source references are its type declaration and the read in TaskModal; no settings control or documented configuration path can set it to true. Consequently, normal users can never reveal the newly announced reltype/lag controls, so every dependency remains locked to the default UI unless they manually edit plugin data. Add a user-accessible setting (or enable the controls by default) before advertising this capability.
Useful? React with 👍 / 👎.
| return reltypeReleasedByPredecessorFinish(reltype) | ||
| ? (this.completedStatusByPath.get(predecessorPath) ?? false) | ||
| : (this.startedStatusByPath.get(predecessorPath) ?? false); |
There was a problem hiding this comment.
Emit dependent updates when a predecessor starts
For STARTTOSTART and STARTTOFINISH edges, this new branch releases dependents when the predecessor changes from Planned to In progress. However, emitTaskUpdateEvents in src/services/task-service/taskPropertyChangeSideEffects.ts still refreshes dependent task cards only when isCompleted changes, so this transition emits no update for the affected successors. Open task cards/Bases can therefore continue showing “Blocked · start/finish” until another refresh occurs; treat a started-state transition like the existing completion transition when notifying dependent tasks.
Useful? React with 👍 / 👎.
Surface a toggle for enableAdvancedDependencyTypes under a new Dependencies group in the Features tab, mirroring the existing feature-toggle pattern. Off by default; turning it on reveals the reltype/lag authoring controls in the task edit modal.
…t filter limit TaskNotes-rendered Bases views (Task List, Kanban, cards) already carry the computed start/finish-blocked state from the cache. Add coverage for those fields, and document why a native .base filter on blocked-by stays existence-only: a filter formula evaluates one note's frontmatter and cannot read a predecessor's status, so it cannot tell a released edge from a live one. Update the dependencies docs to describe per-endpoint blocking and point users to a TaskNotes-rendered view for constraint- aware display.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c112e849f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const { start, finish } = this.computeBlockedDependents(taskPath); | ||
| return Array.from(new Set([...start, ...finish])); |
There was a problem hiding this comment.
Retain released START dependents in the blocking editor*
For a STARTTOSTART or STARTTOFINISH edge, once the predecessor enters an in-progress status, this returns no dependent paths because the edge is released. TaskManager assigns this result to task.blocking, and TaskEditModal builds its “Blocking” list from that field, so opening the predecessor no longer shows the still-persisted relationship and gives users no way to edit or remove it. Keep an all-dependents accessor for relationship management, while using the release-aware accessors only for the blocking signal.
Useful? React with 👍 / 👎.
- composeDependencyGap: reject a sub-1 value (was composing a "P0D" no-op) and an absurd value that would serialize in scientific notation; the lag input gains step/max so fractional entry is prevented at the source. - StatusManager.isStarted: treat a completed status as started via isCompletedStatus, robust to a status config that was not normalized. - Extract deriveBlockingState so the TaskManager and Bases assembly sites share one per-endpoint derivation instead of duplicating it verbatim. - A broken-link (unresolved) blocker now holds the card pessimistically blocked rather than reading as a satisfied "Dependencies" relationship. - Note the last-wins reltype for a hand-authored duplicate edge. Tests: blocking-edge modify detection in the edit-change diff, reverse accessors dropping a released successor, gap edge cases, and the unresolved-blocker state. Changelog: flag that a custom in-progress status must be categorized Started to release start-based dependencies.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5caf3d2bec
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const { start, finish } = this.computeBlockedDependents(taskPath); | ||
| return Array.from(new Set([...start, ...finish])); |
There was a problem hiding this comment.
Preserve released dependents for completion notifications
When a predecessor is marked completed through the property-change path, emitTaskUpdateEvents calls cacheManager.getBlockedTaskPaths after the cache has incorporated the new lifecycle state. This accessor now excludes every edge released by that completion, so finish-to-start and finish-to-finish successors are absent and receive no EVENT_TASK_UPDATED; their open cards/Bases can retain the old blocked state until another refresh. Keep a non-release-filtered dependent accessor for notifications, while retaining this release-aware result for the blocking UI.
Useful? React with 👍 / 👎.
…ders The blocked-by and blocking relationship lists tracked their expanded state only in the DOM, so any card re-render (a related task update, a view refresh) collapsed them with nothing to restore. Move that state into ExpandedProjectsService alongside the subtasks expansion and re-open the list on render when the service says it was open.
… types Non-gating guidance so users configure statuses for the feature to work: - A status-readiness summary under Advanced Dependencies in Features shows the per-category counts and whether start-based (start-to-start / start-to-finish) dependencies can release, since those need a Started status to release before completion. - An inline advisory in the status settings flags a missing category while advanced types are on, at the point statuses are edited. Enablement is never blocked: a degraded state is safe (start-based edges just fall back to releasing on completion), so this guides rather than gates.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8eb7de5d95
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const resolved = resolveDependencyEntry(this.app, path, dep); | ||
| if (resolved?.path && this.isValidFile(resolved.path)) { | ||
| this.addDependencyLink(path, resolved.path, blockingTasks); | ||
| this.addDependencyLink(path, resolved.path, dep.reltype, blockingTasks); | ||
| } |
There was a problem hiding this comment.
Apply dependency lag when computing blocking state
For every nonzero lag entered in the new controls (for example P2D), the cache stores only dep.reltype here and isEdgeReleased subsequently checks only the predecessor's started/completed state. Thus a finish-to-start edge with a two-day lag is released immediately when its predecessor completes, exactly like an edge with no lag; the newly exposed lag is persisted but has no effect on the advertised dependency constraint.
Useful? React with 👍 / 👎.
When a task gates a successor's finish, the blocking toggle tooltip now reads "Blocking N tasks: X to start, Y to finish" instead of a flat count, mirroring the per-endpoint blocked-by badge. A finish-to-start-only case (no finish-gated successors) keeps the plain summary.
Obsidian tooltips render with white-space: pre-wrap, so put the start and finish totals each on their own line for readability.
…ncy quick path Eight plain-language menu titles (four types x blocking/blocked-by) for the context-menu type-before-task step: readable arrow names plus a direction-specific clause, no raw FS/SS/FF/SF codes.
…u quick path When advanced dependency types are enabled, Dependencies -> Blocking / Blocked by lists the four relationship types before the task picker, and the edge is created with the chosen type. The shared task picker is reused unchanged and toggle-off behavior is identical to before. The reltype-aware add is extracted into taskContextMenuDependencies.ts so the threading is unit-tested.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b9ad5ee40
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| categorySelect.addEventListener("change", () => { | ||
| const value = categorySelect.value as StatusCategory; | ||
| status.category = value; | ||
| status.isCompleted = value === "completed"; | ||
| const metaContainer = statusCard?.querySelector(".tasknotes-settings__card-meta"); | ||
| if (metaContainer) { | ||
| metaContainer.empty(); | ||
| if (status.isCompleted) { | ||
| metaContainer.appendChild( | ||
| createStatusBadge( | ||
| translate("settings.taskProperties.taskStatuses.badges.completed"), | ||
| "completed" | ||
| ) | ||
| ); | ||
| } | ||
| metaContainer.appendChild(createCategoryBadge(status.category, translate)); | ||
| } | ||
| save(); |
There was a problem hiding this comment.
Rebuild task indexes when lifecycle categories change
When a user changes a status category to or from completed, this handler saves the setting but the settings lifecycle does not treat customStatuses as cache-affecting, so TaskManager.updateConfig() is not called. Any already-built filter indexes retain their old isCompleted values (used by getOverdueTasks and completed/active task queries), causing tasks with that status to remain incorrectly included or excluded until a frontmatter change or reload rebuilds the indexes.
Useful? React with 👍 / 👎.
…methods The card now calls isBlockingExpanded / isBlockedByExpanded / setBlockingExpanded / setBlockedByExpanded (added with expansion persistence in ee33f38), but these two suites still mocked only isExpanded/toggle, so they threw on CI and failed the coverage gate. Complete the mocks to match the service.
… module addReltypeMenuItems lived on TaskContextMenu where the Obsidian Menu made it awkward to unit-test, leaving the new menu-wiring below the new-code coverage bar. Move it into taskContextMenuDependencies.ts behind a minimal Menu seam and cover the type ordering, side-specific titles, and reltype threading.
…lazy build getStartBlockingPredecessorPaths / getFinishBlockingPredecessorPaths and the build-indexes-on-first-query guard were unexercised, holding the branch's new-code coverage under the 80% gate. Add two DependencyCache cases so they run.
The blocked-by / blocking sub-list was rebuilt (clear + refetch + re-render) on every card render via restoreRelationshipExpansion, so a Bases TaskList / Kanban card recreated on EVERY_TASK_UPDATED re-fired the async expansion and oscillated open/closed on frontmatter edits. Expansion is now DOM-only: built when the user expands, not restored on later renders (matching main's behavior).
…stence With the render-time restore gone, ExpandedProjectsService's blocked/blocking expansion tracking had only dead reads and write-only setters. Remove the two sets, their four accessors, and the rename/clear plumbing, plus the setter call sites in the toggle handlers. Blocked/blocking expansion is DOM-only now, so the handlers no longer need a plugin reference. Deletes the obsolete service test.
Activating a Bases leaf re-fires the onDataUpdated lifecycle with unchanged data, and the base scheduled a full re-render (empty the items container and recreate every card) unconditionally. That tore down a card's expanded blocked-by / blocking sub-list on a mere click or focus change, since the expansion is DOM-only and not restored on re-render. Gate the re-render on a data signature the same way CalendarView already does: when the signature is unchanged since the last render, skip. Implemented on the base so both TaskListView and KanbanView are covered.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9d8be168f4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
Add the selected dependency after deduplicating legacy entries
When the existing blockedBy array already contains duplicate or invalid entries, adding a distinct new task can make combined.length equal existing.length because normalization removes an old entry while adding the new one. This early return then drops the user's requested dependency entirely (for example, two identical legacy blockers plus one newly selected blocker), rather than persisting the normalized list. Compare the actual normalized entries or the selected dependency's presence instead of only comparing lengths.
Useful? React with 👍 / 👎.
|



Summary
Implements RFC 9253 per-endpoint task dependencies on top of an always-on status
lifecycle. A dependency edge now carries a relationship type — finish-to-start,
start-to-start, finish-to-finish, start-to-finish — and blocking is evaluated
per endpoint: FS/SS gate a task's start, FF/SF gate its finish. An edge
releases the moment the predecessor reaches the point the relationship requires —
started for a start-to-* edge, completed for a finish-to-* edge — so a task
reads "Blocked · start" or "Blocked · finish" only while it is genuinely
constrained.
This is a prototype for maintainer review; do not merge.
What's included
Started / Completed), shown as a pill in the status settings, replacing the
previous binary "Completed" toggle. Existing completed statuses stay Completed;
the rest default to Not started. Load-time migration reconciles the
isCompleted ⇄ categoryinvariant both ways and is idempotent.DependencyCache, threaded ontoTaskInfoas
startBlocked/finishBlocked(+ reverse) through both assembly sites(native and Bases-rendered).
and CodeMirror editor widget all show the computed state (red "Blocked · start",
amber "Blocked · finish", muted "Dependencies" when released). The count reflects
only currently-constraining predecessors, so a released edge is no longer counted
as blocking.
per dependency from both the "Blocked by" and "Blocking" lists, with a
plain-language summary naming both tasks so direction is unambiguous. Editing a
Blocking edge writes the change to the blocked task's own frontmatter (the
canonical location). Row layout stacks on narrow/mobile widths.
gates the authoring controls.
.baseblocked-by filters remain existence-only (documented — a per-note filterformula cannot read a predecessor's status).
Verification
identically on
main(dateUtils, ics-export, ICSNoteService, issues384/385/810/1108/2006).
blocked→released transition, both-sided authoring persistence, the mobile modal
layout, and the Features toggle).
/ maintainability) found no P0/P1 issues; the P2/P3 findings are addressed in the
final
fix(review)commit (gap edge cases, anisStartedrobustness fallback, ashared
deriveBlockingStateto remove duplication, and pessimistic handling ofbroken-link blockers).
Notes for the maintainer (product decisions)
opt-in. This is deliberate — the lifecycle also underpins future group-by-status
— but it means a custom "in progress" status must be categorized Started for
start-based dependencies to release when it begins (flagged in the changelog).
gapfield isauthored and persisted, but release is currently purely status-based — a 2-day
lag does not hold a successor for 2 days after the predecessor finishes.
Open question: keep it for RFC completeness/interop, hide it until
lag-aware scheduling exists, or implement date-based lag.