@pentoshi/clai 3.9.1 → 3.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/compaction-attempt.d.ts +22 -0
- package/dist/agent/compaction-attempt.js +69 -0
- package/dist/agent/compaction-attempt.js.map +1 -0
- package/dist/agent/compaction-summary.d.ts +27 -0
- package/dist/agent/compaction-summary.js +62 -0
- package/dist/agent/compaction-summary.js.map +1 -1
- package/dist/agent/context-breakdown.d.ts +2 -0
- package/dist/agent/context-breakdown.js +37 -0
- package/dist/agent/context-breakdown.js.map +1 -1
- package/dist/agent/context-manager.d.ts +7 -1
- package/dist/agent/context-manager.js +64 -15
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/continue-orient.d.ts +8 -0
- package/dist/agent/continue-orient.js +26 -3
- package/dist/agent/continue-orient.js.map +1 -1
- package/dist/agent/durable-envelope.d.ts +39 -0
- package/dist/agent/durable-envelope.js +195 -0
- package/dist/agent/durable-envelope.js.map +1 -0
- package/dist/agent/events.d.ts +21 -0
- package/dist/agent/loop-guard.d.ts +13 -5
- package/dist/agent/loop-guard.js +114 -1
- package/dist/agent/loop-guard.js.map +1 -1
- package/dist/agent/outcomes.d.ts +29 -0
- package/dist/agent/outcomes.js +118 -3
- package/dist/agent/outcomes.js.map +1 -1
- package/dist/agent/plan-decision.js +8 -3
- package/dist/agent/plan-decision.js.map +1 -1
- package/dist/agent/plan-tool.d.ts +5 -0
- package/dist/agent/plan-tool.js +166 -6
- package/dist/agent/plan-tool.js.map +1 -1
- package/dist/agent/reliability-policy.d.ts +11 -6
- package/dist/agent/reliability-policy.js +19 -13
- package/dist/agent/reliability-policy.js.map +1 -1
- package/dist/agent/request-budget.d.ts +24 -0
- package/dist/agent/request-budget.js +59 -0
- package/dist/agent/request-budget.js.map +1 -0
- package/dist/agent/responder-context.js +2 -2
- package/dist/agent/responder-context.js.map +1 -1
- package/dist/agent/responder-parent.d.ts +30 -0
- package/dist/agent/responder-parent.js +82 -0
- package/dist/agent/responder-parent.js.map +1 -0
- package/dist/agent/runner.d.ts +14 -1
- package/dist/agent/runner.js +850 -237
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/scope-context.d.ts +5 -0
- package/dist/agent/scope-context.js +7 -1
- package/dist/agent/scope-context.js.map +1 -1
- package/dist/agent/session-policy.js +1 -0
- package/dist/agent/session-policy.js.map +1 -1
- package/dist/agent/stop-summary.js +13 -7
- package/dist/agent/stop-summary.js.map +1 -1
- package/dist/agent/stream-recovery.d.ts +83 -0
- package/dist/agent/stream-recovery.js +270 -0
- package/dist/agent/stream-recovery.js.map +1 -0
- package/dist/agent/task-analyzer.js +7 -3
- package/dist/agent/task-analyzer.js.map +1 -1
- package/dist/agent/task-evidence.js +2 -0
- package/dist/agent/task-evidence.js.map +1 -1
- package/dist/agent/task-sync.d.ts +11 -0
- package/dist/agent/task-sync.js +31 -0
- package/dist/agent/task-sync.js.map +1 -1
- package/dist/agent/tool-call-parser.d.ts +6 -13
- package/dist/agent/tool-call-parser.js +186 -82
- package/dist/agent/tool-call-parser.js.map +1 -1
- package/dist/agent/tool-history.d.ts +8 -2
- package/dist/agent/tool-history.js +8 -1
- package/dist/agent/tool-history.js.map +1 -1
- package/dist/agent/turn-outcome.d.ts +4 -1
- package/dist/agent/turn-outcome.js +16 -2
- package/dist/agent/turn-outcome.js.map +1 -1
- package/dist/app/adapters/agent-event-adapter.js +27 -0
- package/dist/app/adapters/agent-event-adapter.js.map +1 -1
- package/dist/app/adapters/current-agent-adapter.js +1 -0
- package/dist/app/adapters/current-agent-adapter.js.map +1 -1
- package/dist/app/adapters/current-jobs-adapter.js +4 -3
- package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
- package/dist/app/adapters/current-store-adapter.js +4 -3
- package/dist/app/adapters/current-store-adapter.js.map +1 -1
- package/dist/app/adapters/current-updates-adapter.d.ts +1 -1
- package/dist/app/adapters/current-updates-adapter.js +34 -5
- package/dist/app/adapters/current-updates-adapter.js.map +1 -1
- package/dist/app/adapters/quiet-meta-tools.js +2 -1
- package/dist/app/adapters/quiet-meta-tools.js.map +1 -1
- package/dist/app/controllers/job-controller.d.ts +4 -3
- package/dist/app/controllers/job-controller.js +9 -6
- package/dist/app/controllers/job-controller.js.map +1 -1
- package/dist/app/controllers/plan-controller.js +14 -0
- package/dist/app/controllers/plan-controller.js.map +1 -1
- package/dist/app/controllers/responder-settlement.js +2 -2
- package/dist/app/controllers/responder-settlement.js.map +1 -1
- package/dist/app/controllers/session-compact-helper.d.ts +23 -1
- package/dist/app/controllers/session-compact-helper.js +117 -7
- package/dist/app/controllers/session-compact-helper.js.map +1 -1
- package/dist/app/controllers/session-context-usage.d.ts +24 -0
- package/dist/app/controllers/session-context-usage.js +89 -0
- package/dist/app/controllers/session-context-usage.js.map +1 -0
- package/dist/app/controllers/session-controller.d.ts +18 -17
- package/dist/app/controllers/session-controller.js +127 -119
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/controllers/session-prompt-queue.d.ts +3 -0
- package/dist/app/controllers/session-prompt-queue.js +20 -1
- package/dist/app/controllers/session-prompt-queue.js.map +1 -1
- package/dist/app/controllers/session-responder.js +16 -6
- package/dist/app/controllers/session-responder.js.map +1 -1
- package/dist/app/controllers/session-turn-request.d.ts +18 -0
- package/dist/app/controllers/session-turn-request.js +31 -0
- package/dist/app/controllers/session-turn-request.js.map +1 -0
- package/dist/app/controllers/turn-continuation.d.ts +14 -0
- package/dist/app/controllers/turn-continuation.js +46 -0
- package/dist/app/controllers/turn-continuation.js.map +1 -0
- package/dist/app/events/app-event.d.ts +21 -0
- package/dist/app/events/app-event.js +1 -0
- package/dist/app/events/app-event.js.map +1 -1
- package/dist/app/ports/agent-port.d.ts +7 -0
- package/dist/app/ports/jobs-port.d.ts +4 -3
- package/dist/app/ports/persistence-port.d.ts +11 -0
- package/dist/app/ports/updates-port.d.ts +8 -0
- package/dist/attachments/service.js +13 -2
- package/dist/attachments/service.js.map +1 -1
- package/dist/commands/providers.js +13 -10
- package/dist/commands/providers.js.map +1 -1
- package/dist/commands/update.d.ts +4 -0
- package/dist/commands/update.js +14 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/llm/adapters/anthropic-tools.d.ts +14 -1
- package/dist/llm/adapters/anthropic-tools.js +36 -5
- package/dist/llm/adapters/anthropic-tools.js.map +1 -1
- package/dist/llm/adapters/gemini-tools.d.ts +3 -1
- package/dist/llm/adapters/gemini-tools.js +5 -2
- package/dist/llm/adapters/gemini-tools.js.map +1 -1
- package/dist/llm/adapters/ollama-tools.js +8 -3
- package/dist/llm/adapters/ollama-tools.js.map +1 -1
- package/dist/llm/adapters/openai-tools.js +8 -1
- package/dist/llm/adapters/openai-tools.js.map +1 -1
- package/dist/llm/agentrouter.js +2 -0
- package/dist/llm/agentrouter.js.map +1 -1
- package/dist/llm/anthropic.d.ts +8 -0
- package/dist/llm/anthropic.js +68 -9
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/aws-mantle.js +60 -12
- package/dist/llm/aws-mantle.js.map +1 -1
- package/dist/llm/bynara.js +2 -0
- package/dist/llm/bynara.js.map +1 -1
- package/dist/llm/capabilities.d.ts +7 -0
- package/dist/llm/capabilities.js +22 -0
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/gemini.d.ts +3 -0
- package/dist/llm/gemini.js +71 -11
- package/dist/llm/gemini.js.map +1 -1
- package/dist/llm/groq.d.ts +20 -1
- package/dist/llm/groq.js +32 -4
- package/dist/llm/groq.js.map +1 -1
- package/dist/llm/http.d.ts +29 -0
- package/dist/llm/http.js +122 -29
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/kimchi.js +2 -0
- package/dist/llm/kimchi.js.map +1 -1
- package/dist/llm/nvidia.js +2 -0
- package/dist/llm/nvidia.js.map +1 -1
- package/dist/llm/ollama.d.ts +5 -0
- package/dist/llm/ollama.js +53 -6
- package/dist/llm/ollama.js.map +1 -1
- package/dist/llm/openai.js +2 -0
- package/dist/llm/openai.js.map +1 -1
- package/dist/llm/openrouter.js +2 -0
- package/dist/llm/openrouter.js.map +1 -1
- package/dist/llm/qwen-cloud.js +2 -0
- package/dist/llm/qwen-cloud.js.map +1 -1
- package/dist/llm/router.d.ts +16 -0
- package/dist/llm/router.js +79 -23
- package/dist/llm/router.js.map +1 -1
- package/dist/llm/sampling.d.ts +32 -0
- package/dist/llm/sampling.js +48 -0
- package/dist/llm/sampling.js.map +1 -0
- package/dist/llm/stream-progress.d.ts +4 -0
- package/dist/llm/stream-progress.js +43 -0
- package/dist/llm/stream-progress.js.map +1 -0
- package/dist/llm/system-messages.d.ts +29 -0
- package/dist/llm/system-messages.js +51 -0
- package/dist/llm/system-messages.js.map +1 -0
- package/dist/llm/token-usage.d.ts +4 -1
- package/dist/llm/token-usage.js +56 -4
- package/dist/llm/token-usage.js.map +1 -1
- package/dist/llm/tool-protocol.js +30 -0
- package/dist/llm/tool-protocol.js.map +1 -1
- package/dist/os/process-tree.d.ts +15 -0
- package/dist/os/process-tree.js +77 -0
- package/dist/os/process-tree.js.map +1 -0
- package/dist/prompts/embedded.js +1 -1
- package/dist/prompts/embedded.js.map +1 -1
- package/dist/prompts/system.agent.md +13 -12
- package/dist/repl/prompt-line.d.ts +5 -0
- package/dist/repl/prompt-line.js +7 -1
- package/dist/repl/prompt-line.js.map +1 -1
- package/dist/repl.js +61 -41
- package/dist/repl.js.map +1 -1
- package/dist/safety/classifier.js +98 -44
- package/dist/safety/classifier.js.map +1 -1
- package/dist/safety/engagement-policy.d.ts +8 -0
- package/dist/safety/engagement-policy.js +52 -7
- package/dist/safety/engagement-policy.js.map +1 -1
- package/dist/safety/patterns.d.ts +44 -0
- package/dist/safety/patterns.js +168 -37
- package/dist/safety/patterns.js.map +1 -1
- package/dist/store/config.d.ts +9 -1
- package/dist/store/config.js +51 -1
- package/dist/store/config.js.map +1 -1
- package/dist/store/history-index.d.ts +12 -0
- package/dist/store/history-index.js +47 -18
- package/dist/store/history-index.js.map +1 -1
- package/dist/store/history.d.ts +5 -2
- package/dist/store/history.js +153 -62
- package/dist/store/history.js.map +1 -1
- package/dist/store/plan.d.ts +81 -5
- package/dist/store/plan.js +406 -46
- package/dist/store/plan.js.map +1 -1
- package/dist/store/redaction-cache.d.ts +12 -0
- package/dist/store/redaction-cache.js +51 -0
- package/dist/store/redaction-cache.js.map +1 -0
- package/dist/store/responder-settlement.d.ts +5 -1
- package/dist/store/responder-settlement.js +42 -30
- package/dist/store/responder-settlement.js.map +1 -1
- package/dist/store/scope.js +36 -27
- package/dist/store/scope.js.map +1 -1
- package/dist/store/task-transitions.d.ts +19 -0
- package/dist/store/task-transitions.js +46 -0
- package/dist/store/task-transitions.js.map +1 -0
- package/dist/tools/capabilities.js +33 -18
- package/dist/tools/capabilities.js.map +1 -1
- package/dist/tools/command-intent.d.ts +24 -0
- package/dist/tools/command-intent.js +158 -4
- package/dist/tools/command-intent.js.map +1 -1
- package/dist/tools/definitions.d.ts +5 -1
- package/dist/tools/definitions.js +162 -21
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/fs.d.ts +16 -0
- package/dist/tools/fs.js +122 -46
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/image.js +1 -1
- package/dist/tools/image.js.map +1 -1
- package/dist/tools/jobs.d.ts +78 -4
- package/dist/tools/jobs.js +542 -115
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/net-ping-sweep.d.ts +3 -1
- package/dist/tools/net-ping-sweep.js +64 -26
- package/dist/tools/net-ping-sweep.js.map +1 -1
- package/dist/tools/nmap-runner.js +5 -2
- package/dist/tools/nmap-runner.js.map +1 -1
- package/dist/tools/pdf.js +50 -7
- package/dist/tools/pdf.js.map +1 -1
- package/dist/tools/registry.js +154 -96
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shell.d.ts +24 -2
- package/dist/tools/shell.js +113 -28
- package/dist/tools/shell.js.map +1 -1
- package/dist/tools/tool-types.d.ts +2 -0
- package/dist/tools/validate.d.ts +8 -5
- package/dist/tools/validate.js +177 -7
- package/dist/tools/validate.js.map +1 -1
- package/dist/tools/wordlists.js +69 -17
- package/dist/tools/wordlists.js.map +1 -1
- package/dist/tui/state.d.ts +4 -0
- package/dist/tui/state.js +88 -2
- package/dist/tui/state.js.map +1 -1
- package/dist/tui-v2/actions/keymap.js +3 -8
- package/dist/tui-v2/actions/keymap.js.map +1 -1
- package/dist/tui-v2/app/App.js +56 -5
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/commands/config-commands.js +5 -2
- package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +3 -0
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/app/plan-lifecycle.d.ts +1 -1
- package/dist/tui-v2/app/plan-lifecycle.js +2 -1
- package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
- package/dist/tui-v2/components/composer/composer-input-box.d.ts +1 -1
- package/dist/tui-v2/components/composer/composer-input-box.js +12 -3
- package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
- package/dist/tui-v2/components/jobs/jobs-panel.js +19 -6
- package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
- package/dist/tui-v2/components/plan/plan-view.js +9 -6
- package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.d.ts +15 -0
- package/dist/tui-v2/components/status/status-line.js +33 -25
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.js +17 -9
- package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.js +49 -22
- package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/intro-card.js +3 -0
- package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
- package/dist/tui-v2/components/transcript/thinking-block.js +2 -1
- package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-view.js +27 -2
- package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
- package/dist/tui-v2/components/transcript/use-transcript-selection.js +57 -6
- package/dist/tui-v2/components/transcript/use-transcript-selection.js.map +1 -1
- package/dist/tui-v2/composer/composer-editor.js +20 -12
- package/dist/tui-v2/composer/composer-editor.js.map +1 -1
- package/dist/tui-v2/rendering/incremental-strip.d.ts +11 -0
- package/dist/tui-v2/rendering/incremental-strip.js +89 -0
- package/dist/tui-v2/rendering/incremental-strip.js.map +1 -0
- package/dist/tui-v2/rendering/plan-view.d.ts +12 -0
- package/dist/tui-v2/rendering/plan-view.js +71 -2
- package/dist/tui-v2/rendering/plan-view.js.map +1 -1
- package/dist/tui-v2/rendering/streaming-markdown.d.ts +25 -0
- package/dist/tui-v2/rendering/streaming-markdown.js +108 -0
- package/dist/tui-v2/rendering/streaming-markdown.js.map +1 -0
- package/dist/tui-v2/rendering/thinking-tail.d.ts +5 -0
- package/dist/tui-v2/rendering/thinking-tail.js +26 -0
- package/dist/tui-v2/rendering/thinking-tail.js.map +1 -0
- package/dist/tui-v2/state/transcript-hydrate.js +6 -2
- package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
- package/dist/tui-v2/state/transcript-reducer.js +146 -25
- package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
- package/dist/tui-v2/state/transcript-types.d.ts +6 -0
- package/dist/tui-v2/state/transcript-types.js +17 -0
- package/dist/tui-v2/state/transcript-types.js.map +1 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.js.map +1 -1
- package/dist/ui/intro-header.d.ts +1 -0
- package/dist/ui/intro-header.js +10 -13
- package/dist/ui/intro-header.js.map +1 -1
- package/dist/ui/markdown.js +9 -2
- package/dist/ui/markdown.js.map +1 -1
- package/dist/ui/mentions.d.ts +5 -0
- package/dist/ui/mentions.js +66 -7
- package/dist/ui/mentions.js.map +1 -1
- package/dist/ui/thinking.d.ts +5 -1
- package/dist/ui/thinking.js +3 -2
- package/dist/ui/thinking.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/package.json +2 -1
package/dist/store/plan.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile, rm, stat, } from "node:fs/promises";
|
|
2
2
|
import { fixOwner, fixOwnerSync, handlePermissionError, safeExists } from "../os/permissions.js";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { getConfig } from "./config.js";
|
|
6
6
|
import { getPlanDir } from "./paths.js";
|
|
7
|
+
import { evaluateTaskTransition } from "./task-transitions.js";
|
|
7
8
|
import { applyPlanOperation, validatePlanDag, } from "../agent/task-plan.js";
|
|
8
9
|
/**
|
|
9
10
|
* Session-scoped plan + task persistence.
|
|
@@ -86,6 +87,14 @@ async function loadDatabase() {
|
|
|
86
87
|
updated_at TEXT NOT NULL
|
|
87
88
|
);
|
|
88
89
|
`);
|
|
90
|
+
// A real version column so writes can compare-and-set. Legacy
|
|
91
|
+
// rows default to 1 and are corrected on the first successful mutation.
|
|
92
|
+
try {
|
|
93
|
+
cachedDb.exec("ALTER TABLE plans ADD COLUMN version INTEGER NOT NULL DEFAULT 1;");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Column already exists.
|
|
97
|
+
}
|
|
89
98
|
return cachedDb;
|
|
90
99
|
}
|
|
91
100
|
catch (err) {
|
|
@@ -124,37 +133,59 @@ export function tasksFromTitles(titles) {
|
|
|
124
133
|
}));
|
|
125
134
|
}
|
|
126
135
|
/**
|
|
127
|
-
* Heal dependency edges
|
|
136
|
+
* Heal dependency edges without inventing scheduling.
|
|
128
137
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
138
|
+
* Only genuinely broken edges are removed: self-references, ids that no longer
|
|
139
|
+
* exist, and edges that would close a cycle. Valid forward references are kept
|
|
140
|
+
* so an authored DAG keeps its parallelism. `dependencies: []` is an explicit
|
|
141
|
+
* statement of independence and is never replaced; only a legacy row with no
|
|
142
|
+
* dependency field at all falls back to the previous foreground task, and a
|
|
143
|
+
* responder child never becomes a blocker.
|
|
133
144
|
*
|
|
134
145
|
* Returns true when any task's dependencies changed.
|
|
135
146
|
*/
|
|
136
147
|
export function normalizeTaskDependencies(tasks) {
|
|
137
148
|
let changed = false;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
149
|
+
const known = new Set(tasks.map((task) => task.id));
|
|
150
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
151
|
+
const wouldCycle = (from, to) => {
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
const walk = (id) => {
|
|
154
|
+
if (id === from)
|
|
155
|
+
return true;
|
|
156
|
+
if (seen.has(id))
|
|
157
|
+
return false;
|
|
158
|
+
seen.add(id);
|
|
159
|
+
return (byId.get(id)?.dependencies ?? []).some(walk);
|
|
160
|
+
};
|
|
161
|
+
return walk(to);
|
|
162
|
+
};
|
|
163
|
+
for (const [index, task] of tasks.entries()) {
|
|
164
|
+
const declared = task.dependencies;
|
|
165
|
+
const cleaned = [];
|
|
166
|
+
for (const dependency of declared ?? []) {
|
|
167
|
+
if (dependency === task.id || !known.has(dependency))
|
|
168
|
+
continue;
|
|
169
|
+
if (cleaned.includes(dependency))
|
|
170
|
+
continue;
|
|
171
|
+
if (wouldCycle(task.id, dependency))
|
|
172
|
+
continue;
|
|
173
|
+
cleaned.push(dependency);
|
|
174
|
+
}
|
|
175
|
+
let next = cleaned;
|
|
176
|
+
if (declared === undefined && !task.responderOwned && cleaned.length === 0) {
|
|
177
|
+
const previousForeground = tasks
|
|
178
|
+
.slice(0, index)
|
|
179
|
+
.reverse()
|
|
180
|
+
.find((candidate) => !candidate.responderOwned);
|
|
181
|
+
if (previousForeground && !wouldCycle(task.id, previousForeground.id)) {
|
|
182
|
+
next = [previousForeground.id];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const previous = declared ?? [];
|
|
186
|
+
if (declared === undefined ||
|
|
187
|
+
previous.length !== next.length ||
|
|
188
|
+
previous.some((id, position) => id !== next[position])) {
|
|
158
189
|
task.dependencies = next;
|
|
159
190
|
changed = true;
|
|
160
191
|
}
|
|
@@ -233,20 +264,202 @@ export function patchPlanMeta(plan, patch) {
|
|
|
233
264
|
plan.meta = { ...(plan.meta ?? {}), ...patch };
|
|
234
265
|
return plan;
|
|
235
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Every plan write is serialized in-process.
|
|
269
|
+
*
|
|
270
|
+
* The JSONL backend rewrites the whole file (read → modify → write) and the
|
|
271
|
+
* SQLite backend used an unconditional UPSERT, so two concurrent writers (a
|
|
272
|
+
* foreground task transition and an asynchronous responder settlement) could
|
|
273
|
+
* each save their own `v+1` derived from the same base version. The later write
|
|
274
|
+
* silently reverted the earlier one — reopening completed parents or re-yellowing
|
|
275
|
+
* settled children.
|
|
276
|
+
*/
|
|
277
|
+
let planWriteQueue = Promise.resolve();
|
|
278
|
+
let planWriteDepth = 0;
|
|
279
|
+
function enqueuePlanWrite(task) {
|
|
280
|
+
// Re-entrancy: a queued mutation may load a plan that heals itself through
|
|
281
|
+
// savePlan. Waiting on the queue from inside the queue would deadlock.
|
|
282
|
+
if (planWriteDepth > 0)
|
|
283
|
+
return task();
|
|
284
|
+
const tracked = async () => {
|
|
285
|
+
planWriteDepth += 1;
|
|
286
|
+
try {
|
|
287
|
+
return await task();
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
planWriteDepth -= 1;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const run = planWriteQueue.then(tracked, tracked);
|
|
294
|
+
// Keep the chain alive regardless of individual failures.
|
|
295
|
+
planWriteQueue = run.then(() => undefined, () => undefined);
|
|
296
|
+
return run;
|
|
297
|
+
}
|
|
298
|
+
const jsonlLockDir = `${jsonlFile}.lock`;
|
|
299
|
+
const JSONL_LOCK_STALE_MS = 10_000;
|
|
300
|
+
let jsonlLockDepth = 0;
|
|
301
|
+
/** Cross-process advisory lock for the JSONL fallback (atomic mkdir). */
|
|
302
|
+
async function withJsonlLock(task) {
|
|
303
|
+
if (jsonlLockDepth > 0)
|
|
304
|
+
return task();
|
|
305
|
+
const deadline = Date.now() + 5_000;
|
|
306
|
+
try {
|
|
307
|
+
await mkdir(dirname(jsonlFile), { recursive: true });
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Directory creation is retried by appendJsonl.
|
|
311
|
+
}
|
|
312
|
+
for (;;) {
|
|
313
|
+
try {
|
|
314
|
+
await mkdir(jsonlLockDir);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
if (err?.code !== "EEXIST")
|
|
319
|
+
throw err;
|
|
320
|
+
// Break a stale lock left behind by a crashed process.
|
|
321
|
+
try {
|
|
322
|
+
const info = await stat(jsonlLockDir);
|
|
323
|
+
if (Date.now() - info.mtimeMs > JSONL_LOCK_STALE_MS) {
|
|
324
|
+
await rm(jsonlLockDir, { recursive: true, force: true });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (Date.now() > deadline)
|
|
332
|
+
break; // proceed rather than deadlock
|
|
333
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
jsonlLockDepth += 1;
|
|
338
|
+
return await task();
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
jsonlLockDepth -= 1;
|
|
342
|
+
await rm(jsonlLockDir, { recursive: true, force: true }).catch(() => undefined);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Persist unconditionally (no version check). Used for whole-plan replacement
|
|
347
|
+
* (creation, approval of a freshly built plan, migrations). Concurrent
|
|
348
|
+
* transitions must use {@link mutatePlan} instead.
|
|
349
|
+
*/
|
|
236
350
|
export async function savePlan(plan) {
|
|
237
351
|
plan.updatedAt = new Date().toISOString();
|
|
238
352
|
if (getConfig().privateMode)
|
|
239
353
|
return; // never persist in private mode
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
354
|
+
await enqueuePlanWrite(async () => {
|
|
355
|
+
const db = await loadDatabase();
|
|
356
|
+
if (db) {
|
|
357
|
+
writePlanRowSqlite(db, plan);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
await withJsonlLock(() => appendJsonl(plan));
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function writePlanRowSqlite(db, plan) {
|
|
364
|
+
db.prepare(`INSERT INTO plans (session_id, goal, detail, tasks_json, status, kind, created_at, updated_at, version)
|
|
365
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
244
366
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
245
367
|
goal=excluded.goal, detail=excluded.detail, tasks_json=excluded.tasks_json,
|
|
246
|
-
status=excluded.status, kind=excluded.kind, updated_at=excluded.updated_at
|
|
247
|
-
|
|
368
|
+
status=excluded.status, kind=excluded.kind, updated_at=excluded.updated_at,
|
|
369
|
+
version=excluded.version`).run(plan.sessionId, plan.goal, plan.detail, serializeTasksPayload(plan), plan.status, plan.kind, plan.createdAt, plan.updatedAt, plan.version ?? 1);
|
|
370
|
+
}
|
|
371
|
+
/** Compare-and-set write: only replaces a row still at `expectedVersion`. */
|
|
372
|
+
function casPlanRowSqlite(db, plan, expectedVersion) {
|
|
373
|
+
const info = db
|
|
374
|
+
.prepare(`UPDATE plans SET goal=?, detail=?, tasks_json=?, status=?, kind=?, updated_at=?, version=?
|
|
375
|
+
WHERE session_id=? AND version=?`)
|
|
376
|
+
.run(plan.goal, plan.detail, serializeTasksPayload(plan), plan.status, plan.kind, plan.updatedAt, plan.version ?? expectedVersion + 1, plan.sessionId, expectedVersion);
|
|
377
|
+
return (info?.changes ?? 0) > 0;
|
|
378
|
+
}
|
|
379
|
+
const PLAN_MUTATION_RETRIES = 5;
|
|
380
|
+
/**
|
|
381
|
+
* The authoritative plan mutation boundary.
|
|
382
|
+
*
|
|
383
|
+
* Loads the plan fresh, runs `reducer` on it, enforces domain invariants, then
|
|
384
|
+
* persists with a version compare-and-set. On a CAS conflict the reducer is
|
|
385
|
+
* re-run against the newer state (reducers must therefore be idempotent and
|
|
386
|
+
* expressed as "apply this transition", not "write this snapshot").
|
|
387
|
+
*
|
|
388
|
+
* Return `false` from the reducer to abort without writing.
|
|
389
|
+
*/
|
|
390
|
+
export async function mutatePlan(sessionId, reducer, opts) {
|
|
391
|
+
if (getConfig().privateMode) {
|
|
392
|
+
// Nothing is persisted in private mode; apply to a transient copy so
|
|
393
|
+
// callers still see a consistent in-memory result.
|
|
394
|
+
const plan = await loadPlan(sessionId);
|
|
395
|
+
if (!plan)
|
|
396
|
+
return { ok: false, reason: "missing-plan" };
|
|
397
|
+
if (reducer(plan) === false)
|
|
398
|
+
return { ok: false, reason: "no-change" };
|
|
399
|
+
const repairs = enforcePlanInvariants(plan);
|
|
400
|
+
plan.version = (plan.version ?? 1) + 1;
|
|
401
|
+
plan.updatedAt = new Date().toISOString();
|
|
402
|
+
return { ok: true, plan, ...(repairs.length ? { repairs } : {}) };
|
|
248
403
|
}
|
|
249
|
-
|
|
404
|
+
const retries = opts?.retries ?? PLAN_MUTATION_RETRIES;
|
|
405
|
+
return enqueuePlanWrite(async () => {
|
|
406
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
407
|
+
const current = await loadPlan(sessionId);
|
|
408
|
+
if (!current)
|
|
409
|
+
return { ok: false, reason: "missing-plan" };
|
|
410
|
+
const baseVersion = current.version ?? 1;
|
|
411
|
+
if (opts?.expectedVersion !== undefined &&
|
|
412
|
+
opts.expectedVersion !== baseVersion) {
|
|
413
|
+
return { ok: false, reason: "version-conflict" };
|
|
414
|
+
}
|
|
415
|
+
const draft = clonePlan(current);
|
|
416
|
+
if (reducer(draft) === false) {
|
|
417
|
+
return { ok: false, reason: "no-change", plan: current };
|
|
418
|
+
}
|
|
419
|
+
const repairs = enforcePlanInvariants(draft);
|
|
420
|
+
const validation = validateSessionPlan(draft);
|
|
421
|
+
if (!validation.ok) {
|
|
422
|
+
return { ok: false, reason: "invalid", plan: current };
|
|
423
|
+
}
|
|
424
|
+
draft.version = baseVersion + 1;
|
|
425
|
+
draft.updatedAt = new Date().toISOString();
|
|
426
|
+
const db = await loadDatabase();
|
|
427
|
+
if (db) {
|
|
428
|
+
if (casPlanRowSqlite(db, draft, baseVersion)) {
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
plan: draft,
|
|
432
|
+
...(repairs.length ? { repairs } : {}),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
continue; // another writer won; re-run the reducer on fresh state
|
|
436
|
+
}
|
|
437
|
+
const committed = await withJsonlLock(async () => {
|
|
438
|
+
const stored = (await readAllJsonl()).find((candidate) => candidate.sessionId === sessionId);
|
|
439
|
+
if ((stored?.version ?? 1) !== baseVersion)
|
|
440
|
+
return false;
|
|
441
|
+
await appendJsonl(draft);
|
|
442
|
+
return true;
|
|
443
|
+
});
|
|
444
|
+
if (committed) {
|
|
445
|
+
return { ok: true, plan: draft, ...(repairs.length ? { repairs } : {}) };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { ok: false, reason: "version-conflict" };
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
function clonePlan(plan) {
|
|
452
|
+
return {
|
|
453
|
+
...plan,
|
|
454
|
+
tasks: plan.tasks.map((task) => ({
|
|
455
|
+
...task,
|
|
456
|
+
dependencies: [...(task.dependencies ?? [])],
|
|
457
|
+
resourceLocks: [...(task.resourceLocks ?? [])],
|
|
458
|
+
...(task.evidence ? { evidence: { ...task.evidence } } : {}),
|
|
459
|
+
...(task.aliases ? { aliases: [...task.aliases] } : {}),
|
|
460
|
+
})),
|
|
461
|
+
...(plan.meta ? { meta: { ...plan.meta } } : {}),
|
|
462
|
+
};
|
|
250
463
|
}
|
|
251
464
|
async function appendJsonl(plan) {
|
|
252
465
|
try {
|
|
@@ -283,6 +496,25 @@ async function readAllJsonl() {
|
|
|
283
496
|
}
|
|
284
497
|
}
|
|
285
498
|
/** Upgrade legacy persisted plans without changing task ids or states. */
|
|
499
|
+
/**
|
|
500
|
+
* Fill in dependencies for legacy rows that never stored the field. The previous
|
|
501
|
+
* foreground task is the default; a responder child is display-linked to its
|
|
502
|
+
* parent and must never become a blocker.
|
|
503
|
+
*/
|
|
504
|
+
function normalizeLegacyDependencies(tasks) {
|
|
505
|
+
return tasks.map((task, index) => {
|
|
506
|
+
const previousForeground = tasks
|
|
507
|
+
.slice(0, index)
|
|
508
|
+
.reverse()
|
|
509
|
+
.find((candidate) => !candidate.responderOwned);
|
|
510
|
+
const legacyDefault = task.responderOwned || !previousForeground ? [] : [previousForeground.id];
|
|
511
|
+
return {
|
|
512
|
+
...task,
|
|
513
|
+
dependencies: [...(task.dependencies ?? legacyDefault)],
|
|
514
|
+
resourceLocks: [...(task.resourceLocks ?? [])],
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
}
|
|
286
518
|
function normalizePersistedPlan(plan) {
|
|
287
519
|
return {
|
|
288
520
|
...plan,
|
|
@@ -290,13 +522,7 @@ function normalizePersistedPlan(plan) {
|
|
|
290
522
|
version: typeof plan.version === "number" && plan.version >= 1
|
|
291
523
|
? plan.version
|
|
292
524
|
: 1,
|
|
293
|
-
tasks: plan.tasks
|
|
294
|
-
...task,
|
|
295
|
-
dependencies: [
|
|
296
|
-
...(task.dependencies ?? (index > 0 ? [plan.tasks[index - 1].id] : [])),
|
|
297
|
-
],
|
|
298
|
-
resourceLocks: [...(task.resourceLocks ?? [])],
|
|
299
|
-
})),
|
|
525
|
+
tasks: normalizeLegacyDependencies(plan.tasks),
|
|
300
526
|
};
|
|
301
527
|
}
|
|
302
528
|
/** Heal plans that stored bare `t1`/`t2` strings as task titles. */
|
|
@@ -310,14 +536,18 @@ export async function loadPlan(sessionId) {
|
|
|
310
536
|
const db = await loadDatabase();
|
|
311
537
|
if (db) {
|
|
312
538
|
const row = db
|
|
313
|
-
.prepare("SELECT session_id, goal, detail, tasks_json, status, kind, created_at, updated_at FROM plans WHERE session_id = ?")
|
|
539
|
+
.prepare("SELECT session_id, goal, detail, tasks_json, status, kind, created_at, updated_at, version FROM plans WHERE session_id = ?")
|
|
314
540
|
.get(sessionId);
|
|
315
541
|
if (!row)
|
|
316
542
|
return undefined;
|
|
317
543
|
const { tasks, meta, version } = deserializeTasksPayload(row.tasks_json);
|
|
544
|
+
// The column is authoritative once present (CAS writes maintain it).
|
|
545
|
+
const rowVersion = typeof row.version === "number" && row.version >= 1
|
|
546
|
+
? Math.max(row.version, version)
|
|
547
|
+
: version;
|
|
318
548
|
const plan = {
|
|
319
549
|
schemaVersion: 2,
|
|
320
|
-
version,
|
|
550
|
+
version: rowVersion,
|
|
321
551
|
sessionId: row.session_id,
|
|
322
552
|
goal: row.goal,
|
|
323
553
|
detail: row.detail,
|
|
@@ -332,7 +562,8 @@ export async function loadPlan(sessionId) {
|
|
|
332
562
|
const needsNormalization = plan.tasks.some((task) => task.dependencies === undefined || task.resourceLocks === undefined);
|
|
333
563
|
const normalized = normalizePersistedPlan(plan);
|
|
334
564
|
const { plan: healed, healed: dirty } = healBareIdTasks(normalized);
|
|
335
|
-
|
|
565
|
+
const repairs = enforcePlanInvariants(healed);
|
|
566
|
+
if (dirty || needsNormalization || repairs.length > 0) {
|
|
336
567
|
// Persist upgrades so resumed SQLite and JSONL plans share dependency semantics.
|
|
337
568
|
await savePlan(healed);
|
|
338
569
|
}
|
|
@@ -344,7 +575,8 @@ export async function loadPlan(sessionId) {
|
|
|
344
575
|
const needsNormalization = found.tasks.some((task) => task.dependencies === undefined || task.resourceLocks === undefined);
|
|
345
576
|
const normalized = normalizePersistedPlan(found);
|
|
346
577
|
const { plan: healed, healed: dirty } = healBareIdTasks(normalized);
|
|
347
|
-
|
|
578
|
+
const repairs = enforcePlanInvariants(healed);
|
|
579
|
+
if (dirty || needsNormalization || repairs.length > 0)
|
|
348
580
|
await savePlan(healed);
|
|
349
581
|
return healed;
|
|
350
582
|
}
|
|
@@ -500,10 +732,123 @@ export function readyPlanTasks(plan) {
|
|
|
500
732
|
(task.dependencies ?? []).every((dependency) => done.has(dependency)) &&
|
|
501
733
|
!(task.resourceLocks ?? []).some((resource) => held.has(resource)));
|
|
502
734
|
}
|
|
735
|
+
/** Foreground (non-responder) tasks that are currently `in_progress`. */
|
|
736
|
+
export function activeForegroundTasks(plan) {
|
|
737
|
+
return plan.tasks.filter((task) => !task.responderOwned && task.state === "in_progress");
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* `count(foreground tasks in_progress) <= 1` is a domain invariant,
|
|
741
|
+
* not a prompt convention. Applied on every {@link mutatePlan} commit and on
|
|
742
|
+
* load. The earliest dependency-valid active task is kept; later ones are
|
|
743
|
+
* demoted to `pending` with a repair note (evidence is preserved).
|
|
744
|
+
*
|
|
745
|
+
* A parent/child display relationship never implies a dependency, so responder
|
|
746
|
+
* children may run concurrently and are ignored here.
|
|
747
|
+
*/
|
|
748
|
+
export function enforcePlanInvariants(plan) {
|
|
749
|
+
const repairs = [...repairOrphanParents(plan)];
|
|
750
|
+
const active = activeForegroundTasks(plan);
|
|
751
|
+
if (active.length <= 1)
|
|
752
|
+
return repairs;
|
|
753
|
+
const settled = new Set(plan.tasks
|
|
754
|
+
.filter((task) => task.state === "done" || task.state === "skipped")
|
|
755
|
+
.map((task) => task.id));
|
|
756
|
+
const dependencyValid = (task) => (task.dependencies ?? []).every((dependency) => settled.has(dependency));
|
|
757
|
+
const keep = active.find(dependencyValid) ?? active[0];
|
|
758
|
+
for (const task of active) {
|
|
759
|
+
if (task === keep)
|
|
760
|
+
continue;
|
|
761
|
+
task.state = "pending";
|
|
762
|
+
task.note = task.note
|
|
763
|
+
? `${task.note} (reopened later: only one foreground task may be active)`
|
|
764
|
+
: "Demoted to pending: only one foreground task may be active at a time.";
|
|
765
|
+
repairs.push(`demoted ${task.id} to pending (single-active invariant)`);
|
|
766
|
+
}
|
|
767
|
+
return repairs;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Never leave a child pointing at a task that no longer exists. A revision that
|
|
771
|
+
* removes a parent with live children keeps the ownership audit in the note and
|
|
772
|
+
* detaches the display link instead of silently orphaning the row.
|
|
773
|
+
*/
|
|
774
|
+
function repairOrphanParents(plan) {
|
|
775
|
+
const repairs = [];
|
|
776
|
+
const known = new Set(plan.tasks.map((task) => task.id));
|
|
777
|
+
for (const task of plan.tasks) {
|
|
778
|
+
if (!task.parentTaskId || known.has(task.parentTaskId))
|
|
779
|
+
continue;
|
|
780
|
+
const detached = task.parentTaskId;
|
|
781
|
+
delete task.parentTaskId;
|
|
782
|
+
task.note = task.note
|
|
783
|
+
? `${task.note} (detached: parent ${detached} was removed)`
|
|
784
|
+
: `Detached responder work: parent ${detached} was removed by a revision.`;
|
|
785
|
+
repairs.push(`detached ${task.id} from removed parent ${detached}`);
|
|
786
|
+
}
|
|
787
|
+
return repairs;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Apply a foreground-authored plan snapshot onto fresh state.
|
|
791
|
+
*
|
|
792
|
+
* Whole-plan writes (plan.create, revisions, task.add reordering) are authored
|
|
793
|
+
* against a loaded copy. Replacing the stored plan with that copy dropped
|
|
794
|
+
* anything an asynchronous writer changed in the meantime. This applies the
|
|
795
|
+
* snapshot's foreground intent while treating responder children as owned by
|
|
796
|
+
* process settlement:
|
|
797
|
+
*
|
|
798
|
+
* - responder-owned rows keep their stored state/note/job linkage;
|
|
799
|
+
* - responder children created concurrently are retained;
|
|
800
|
+
* - stored evidence is kept when the snapshot has none for that task.
|
|
801
|
+
*/
|
|
802
|
+
export function applyForegroundSnapshot(draft, snapshot) {
|
|
803
|
+
const stored = new Map(draft.tasks.map((task) => [task.id, task]));
|
|
804
|
+
const next = [];
|
|
805
|
+
for (const task of snapshot.tasks) {
|
|
806
|
+
const existing = stored.get(task.id);
|
|
807
|
+
if (existing?.responderOwned) {
|
|
808
|
+
next.push(existing);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
next.push({
|
|
812
|
+
...task,
|
|
813
|
+
...(task.evidence === undefined && existing?.evidence
|
|
814
|
+
? { evidence: existing.evidence }
|
|
815
|
+
: {}),
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
const kept = new Set(next.map((task) => task.id));
|
|
819
|
+
for (const task of draft.tasks) {
|
|
820
|
+
if (kept.has(task.id))
|
|
821
|
+
continue;
|
|
822
|
+
// Only responder children may appear from a concurrent writer; a foreground
|
|
823
|
+
// task missing from the snapshot was intentionally removed by the author.
|
|
824
|
+
if (!task.responderOwned)
|
|
825
|
+
continue;
|
|
826
|
+
const parentIndex = task.parentTaskId
|
|
827
|
+
? next.findIndex((candidate) => candidate.id === task.parentTaskId)
|
|
828
|
+
: -1;
|
|
829
|
+
if (parentIndex >= 0)
|
|
830
|
+
next.splice(parentIndex + 1, 0, task);
|
|
831
|
+
else
|
|
832
|
+
next.push(task);
|
|
833
|
+
}
|
|
834
|
+
draft.tasks = next;
|
|
835
|
+
draft.goal = snapshot.goal;
|
|
836
|
+
draft.detail = snapshot.detail;
|
|
837
|
+
draft.status = snapshot.status;
|
|
838
|
+
draft.kind = snapshot.kind;
|
|
839
|
+
if (snapshot.meta)
|
|
840
|
+
draft.meta = { ...(draft.meta ?? {}), ...snapshot.meta };
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Apply a task transition. Rejects transitions the table forbids so no
|
|
844
|
+
* caller can rewind terminal work; use a plan revision to supersede a task.
|
|
845
|
+
*/
|
|
503
846
|
export function markTask(plan, taskId, state, note) {
|
|
504
847
|
const task = plan.tasks.find((t) => t.id === taskId);
|
|
505
848
|
if (!task)
|
|
506
849
|
return false;
|
|
850
|
+
if (!evaluateTaskTransition(task.state, state).allowed)
|
|
851
|
+
return false;
|
|
507
852
|
task.state = state;
|
|
508
853
|
if (note !== undefined)
|
|
509
854
|
task.note = note;
|
|
@@ -522,9 +867,24 @@ export function markNextTask(plan, state) {
|
|
|
522
867
|
plan.updatedAt = new Date().toISOString();
|
|
523
868
|
return task;
|
|
524
869
|
}
|
|
525
|
-
|
|
870
|
+
/** Tasks the model owns. Responder children advance from process lifecycle. */
|
|
871
|
+
export function foregroundTasks(plan) {
|
|
526
872
|
return plan.tasks.filter((task) => !task.responderOwned);
|
|
527
873
|
}
|
|
874
|
+
/** Foreground work that is neither settled nor skipped, in plan order. */
|
|
875
|
+
export function foregroundRemaining(plan) {
|
|
876
|
+
return foregroundTasks(plan).filter((task) => task.state === "pending" || task.state === "in_progress");
|
|
877
|
+
}
|
|
878
|
+
/** The single active foreground task, or the next one to resume. */
|
|
879
|
+
export function foregroundActiveTask(plan) {
|
|
880
|
+
const remaining = foregroundRemaining(plan);
|
|
881
|
+
return (remaining.find((task) => task.state === "in_progress") ?? remaining[0]);
|
|
882
|
+
}
|
|
883
|
+
/** Responder children that are still running or awaiting analysis. */
|
|
884
|
+
export function responderOpenTasks(plan) {
|
|
885
|
+
return plan.tasks.filter((task) => task.responderOwned &&
|
|
886
|
+
(task.state === "pending" || task.state === "in_progress"));
|
|
887
|
+
}
|
|
528
888
|
export function planProgress(plan) {
|
|
529
889
|
const foreground = foregroundTasks(plan);
|
|
530
890
|
const done = foreground.filter((task) => task.state === "done").length;
|