@ngockhoale/ukit 3.4.67 → 3.4.68
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/CHANGELOG.md +12 -0
- package/manifests/documentation.yaml +33 -0
- package/manifests/instructionRules.yaml +10 -0
- package/manifests/platform.full.yaml +67 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +111 -2
- package/src/core/buildPlan.js +3 -1
- package/src/core/diffPlan.js +49 -1
- package/src/core/executionContracts.js +49 -8
- package/src/core/metadata.js +45 -0
- package/src/core/runInstallPipeline.js +30 -2
- package/src/core/uninstall.js +106 -2
- package/src/manifest/validateManifest.js +25 -1
- package/template_project/.claude/agents/code-reviewer.md +22 -9
- package/template_project/.claude/agents/handoff-planner.md +97 -8
- package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
- package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
- package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
- package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
- package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
- package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
- package/template_project/.claude/hooks/handoff-resume.sh +141 -1
- package/template_project/.claude/hooks/record-execution.mjs +140 -9
- package/template_project/.claude/hooks/skill-router.sh +173 -8
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
- package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
- package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
- package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
- package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
- package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
- package/template_project/.claude/ukit/index/route-task.mjs +180 -11
- package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
- package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
- package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
- package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
- package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
- package/template_project/.codex/README.md +25 -0
- package/template_project/.omp/RULES.md +22 -22
- package/template_project/.omp/agents/code-reviewer.md +22 -9
- package/template_project/.omp/agents/handoff-planner.md +102 -12
- package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
- package/template_project/AGENTS.md +5 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
- package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
- package/template_project/instructions/core.md +5 -1
- package/template_project/instructions/layout.yaml +2 -0
- package/template_project/instructions/overlays/omp-rules.md +22 -22
- package/template_project/ukit/storage/config.json +1 -1
|
@@ -1,75 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `docs/AI_HANDOFF/RUN.md` carries `Phase:` other than `done`/`blocked`, a
|
|
17
|
-
* handoff-fullstack run is still in flight and the stop is bounced back with
|
|
18
|
-
* the cursor's `Next:` step — this is what keeps an overnight run moving
|
|
19
|
-
* after a recap instead of stalling idle. When RUN.md carries an
|
|
20
|
-
* `ExitPredicate:` line (mechanic #7), `Phase: done` is accepted only if the
|
|
21
|
-
* predicate evaluates true — `all(index:all-done, git:clean,
|
|
22
|
-
* file-exists:<relpath>)` terms checked mechanically, no eval, no shell-out.
|
|
23
|
-
* A stalled-cursor breaker releases the stop if the cursor has not advanced
|
|
24
|
-
* across `stopGateMaxStalledBlocks` consecutive blocked stops (same liveness
|
|
25
|
-
* shape as the ledger's noProgressCount breaker), and its advisory reports
|
|
26
|
-
* the predicate state so a plateau is visible;
|
|
27
|
-
* - it merges the three evaluator results with a deterministic owner order —
|
|
28
|
-
* fail-closed completion failure > completion block > handoff-cursor block >
|
|
29
|
-
* watchdog block > merged advisory > silent release — and emits AT MOST ONE
|
|
30
|
-
* Stop JSON line;
|
|
31
|
-
* - a lock-scoped once-per-stop guard makes a legacy double-registered Stop
|
|
32
|
-
* (old settings.json still listing both hooks) inert: the second coordinator
|
|
33
|
-
* invocation for the same session inside the dedupe window emits nothing and
|
|
34
|
-
* mutates nothing, so counters still move at most once.
|
|
35
|
-
*
|
|
36
|
-
* Failure posture (security test row):
|
|
37
|
-
* - The completion gate is the fail-closed blocker. If it fails to produce a
|
|
38
|
-
* verdict (missing script, spawn error, non-zero exit, unparseable output,
|
|
39
|
-
* budget overrun) the coordinator BLOCKS fail-closed with a fixed, redacted
|
|
40
|
-
* infrastructure reason — raw failure detail goes to stderr only, never into
|
|
41
|
-
* the parsed decision (mirrors execution-ledger's own crash counter posture).
|
|
42
|
-
* - The watchdog is an advisory lane. Its failure never decides anything; the
|
|
43
|
-
* other evaluator's decision stands and the failure is reported verbatim in
|
|
44
|
-
* the emitted `systemMessage` (stderr carries it too).
|
|
45
|
-
*
|
|
46
|
-
* Exports (independently testable — tests import the module directly):
|
|
47
|
-
* DEDUPE_WINDOW_MS — same-session Stop invocations closer than this are
|
|
48
|
-
* treated as one event (a recovery turn takes >>2s).
|
|
49
|
-
* redactInfrastructureReason — the fixed redacted block reason for a failed
|
|
50
|
-
* fail-closed blocker; never repeats its input.
|
|
51
|
-
* redactDeadlineReason — the fixed redacted block reason for a deadline
|
|
52
|
-
* overrun (budget, not crash — same redaction).
|
|
53
|
-
* emitDeadlineBlock — the deadline path's single fail-closed emission.
|
|
54
|
-
* normalizeEvaluatorStdout — ledger stdout → { kind, reason?, systemMessage? }.
|
|
55
|
-
* coordinateStopDecisions — the pure ordered merge; owns ordering + provenance
|
|
56
|
-
* only (evaluators stay independently testable).
|
|
57
|
-
* evaluateHandoffCursor — in-process RUN.md lane; advisory on any failure.
|
|
58
|
-
* evaluateCompletionPolicy — spawns the ledger once, returns the normalized
|
|
59
|
-
* completion evaluator result (throws on failure).
|
|
60
|
-
* runStopCoordinator — event guard + dedupe + both evaluators + merge.
|
|
61
|
-
*
|
|
62
|
-
* Like every UKit hook runtime this fails loud and stays inside the hook deadline:
|
|
63
|
-
* a self-kill timer honours UKIT_HOOK_DEADLINE_MS (default 3000ms), the ledger child
|
|
64
|
-
* gets a bounded budget, and every path exits 0.
|
|
65
|
-
*
|
|
66
|
-
* Deadline posture (fix round 1 — reviewer critical): the deadline is the LAST line
|
|
67
|
-
* of defence for the fail-closed blocker, so it must not be a silent exit. The
|
|
68
|
-
* wrapper (`completion-gate.sh`) reads "exit 0 with empty stdout" as a clean release,
|
|
69
|
-
* which meant a coordinator that hit its own deadline while the completion evaluator
|
|
70
|
-
* hung RELEASED Stop instead of blocking it. The deadline now emits the same fixed
|
|
71
|
-
* redacted fail-closed block (synchronously, so process.exit cannot drop a buffered
|
|
72
|
-
* pipe write) and reaps any in-flight evaluator child before exiting.
|
|
3
|
+
* One Stop coordinator (TASK-025, PLAN §2 H17): evaluate the ledger child,
|
|
4
|
+
* watchdog and RUN cursor once; emit at most one decision. A lock-scoped dedupe
|
|
5
|
+
* window prevents legacy double hook registration from double-counting.
|
|
6
|
+
* Priority: fail-closed completion failure > completion block > cursor block >
|
|
7
|
+
* watchdog block > merged advisory > silent release.
|
|
8
|
+
* RUN Next carries continuation; done requires its mechanical ExitPredicate.
|
|
9
|
+
* Stalled-cursor releases are advisory, never completion evidence.
|
|
10
|
+
* Completion infrastructure failures block with redacted reasons (details only
|
|
11
|
+
* on stderr); watchdog failures are advisory and reported verbatim.
|
|
12
|
+
* UKIT_HOOK_DEADLINE_MS bounds execution (default 3000ms), including the ledger
|
|
13
|
+
* child. Deadline expiry synchronously emits a fail-closed block and reaps the
|
|
14
|
+
* child before exit0: empty stdout would incorrectly release the wrapper.
|
|
15
|
+
* Exported evaluators, merge, normalization and redaction seams are tested directly.
|
|
73
16
|
*/
|
|
74
17
|
|
|
75
18
|
import fs from 'node:fs/promises';
|
|
@@ -79,6 +22,10 @@ import path from 'node:path';
|
|
|
79
22
|
import { spawn, spawnSync } from 'node:child_process';
|
|
80
23
|
|
|
81
24
|
import { withAsyncLock } from './async-lock.mjs';
|
|
25
|
+
// Frozen RUN scope membership (TaskScope / ScopeQueue / ScopePlans, cycle-qualified):
|
|
26
|
+
// the SAME selector filters the completion and blocked predicates below. Sandbox
|
|
27
|
+
// runtimes that copy this file by an explicit sibling list must copy this module too.
|
|
28
|
+
import { selectTaskScope } from './handoff-task-scope.mjs';
|
|
82
29
|
// TASK-005 (FR-005): the outcome seam loads LAZILY — a partial install missing
|
|
83
30
|
// observability-emit.mjs must degrade to "no outcome record" (telemetry is
|
|
84
31
|
// advisory), never crash the coordinator at module load.
|
|
@@ -884,8 +831,15 @@ export function parseExitPredicate(expr) {
|
|
|
884
831
|
* `index:all-done` — every TASK-xxx row in docs/AI_HANDOFF/INDEX.md is in a
|
|
885
832
|
* terminal-success status (`done` or `cancelled_superseded`). An unreadable or
|
|
886
833
|
* taskless INDEX cannot prove all-done, so the term fails.
|
|
834
|
+
*
|
|
835
|
+
* Frozen RUN scope: rows are filtered through the SAME TaskScope selector the
|
|
836
|
+
* blocked predicate uses (runText supplies the mappings; the live INDEX resolves
|
|
837
|
+
* against the run's current `Cycle:`). A row explicitly mapped outside the frozen
|
|
838
|
+
* ScopeQueue/ScopePlans is excluded; unknown/ambiguous mappings are retained AND
|
|
839
|
+
* their reconciliation diagnostics fail the term — completion is never declared
|
|
840
|
+
* over an unresolved mapping.
|
|
887
841
|
*/
|
|
888
|
-
async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '', cycleField = '' } = {}) {
|
|
842
|
+
async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '', cycleField = '', runText = '' } = {}) {
|
|
889
843
|
let text;
|
|
890
844
|
try {
|
|
891
845
|
text = await fs.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'INDEX.md'), 'utf8');
|
|
@@ -893,9 +847,20 @@ async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '',
|
|
|
893
847
|
return { pass: false, detail: 'INDEX.md unreadable' };
|
|
894
848
|
}
|
|
895
849
|
const rows = indexTaskRows(text);
|
|
896
|
-
if (rows.length === 0) return evalFinalizedCycles(projectRoot, goalScope, scopeQueue, cycleField);
|
|
850
|
+
if (rows.length === 0) return evalFinalizedCycles(projectRoot, goalScope, scopeQueue, cycleField, runText);
|
|
851
|
+
const selectable = rows.map((raw) => ({ id: (indexTaskCells(raw)[0] || ''), raw }));
|
|
852
|
+
const { rows: scoped, reconciliation } = selectTaskScope(runText, selectable, { cycle: parseRunCycle(cycleField) });
|
|
853
|
+
if (reconciliation.length > 0) {
|
|
854
|
+
return { pass: false, detail: `frozen TaskScope reconciliation unresolved: ${reconciliation.slice(0, 3).join(' | ')}` };
|
|
855
|
+
}
|
|
897
856
|
const failing = [];
|
|
898
|
-
|
|
857
|
+
const liveIds = new Set(scoped.map((r) => goalTaskId(r.id)));
|
|
858
|
+
const liveCls = new Map(scoped.filter((r) => indexTaskRowIsWellFormed(r.raw)).map((r) => {
|
|
859
|
+
const c = indexTaskCells(r.raw);
|
|
860
|
+
return [goalTaskId(c[0]), classifyTaskStatus((c[4] || '').toLowerCase(), c[0], liveIds)];
|
|
861
|
+
}));
|
|
862
|
+
for (const scopedRow of scoped) {
|
|
863
|
+
const row = scopedRow.raw;
|
|
899
864
|
if (!indexTaskRowIsWellFormed(row)) {
|
|
900
865
|
const shape = row.startsWith('|') ? 'malformed task row' : 'unsupported task row (missing outer leading pipe)';
|
|
901
866
|
failing.push(`${shape} (expected ${INDEX_TASK_COLUMNS} columns): ${row.slice(0, 60)}`);
|
|
@@ -903,12 +868,14 @@ async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '',
|
|
|
903
868
|
}
|
|
904
869
|
const cells = indexTaskCells(row);
|
|
905
870
|
const status = (cells[4] || '').toLowerCase();
|
|
906
|
-
|
|
907
|
-
|
|
871
|
+
const cls = classifyTaskStatus(status, cells[0], liveIds);
|
|
872
|
+
const terminal = cls === 'delivered' || (cls === 'cancelled-grounded' && groundedCancellationResolved(cells[0], liveCls));
|
|
873
|
+
if (!terminal) {
|
|
874
|
+
failing.push(`${cells[0]}=${cells[4] || '?'}${cls === 'review-obligation' ? ' (review/integration owed; not delivered)' : cls === 'unresolved' ? ' (bare cancelled does not prove the requirement was met)' : ''}`);
|
|
908
875
|
}
|
|
909
876
|
}
|
|
910
877
|
return failing.length === 0
|
|
911
|
-
? { pass: true, detail: `${
|
|
878
|
+
? { pass: true, detail: `${scoped.length} in-scope task row(s) done` }
|
|
912
879
|
: { pass: false, detail: `not done: ${failing.join(', ')}` };
|
|
913
880
|
}
|
|
914
881
|
|
|
@@ -934,8 +901,13 @@ function parseIndexStatuses(text) {
|
|
|
934
901
|
* happened" either: the archived task files ARE the auditable record. All-done holds when at
|
|
935
902
|
* least one archived task exists, every archived task is terminal-success, and — for a run-all
|
|
936
903
|
* goal — no consumable queued blueprint remains. No archive evidence → still unprovable (fail).
|
|
904
|
+
*
|
|
905
|
+
* Frozen RUN scope: each archive cycle's rows resolve through the SAME TaskScope selector
|
|
906
|
+
* against that ARCHIVE cycle's own keys (`runText` supplies the preserved mappings), so a
|
|
907
|
+
* snapshot row mapped outside the frozen scope is excluded and an unknown/ambiguous mapping
|
|
908
|
+
* fails closed — an unresolved mapping can never prove a finalized cycle.
|
|
937
909
|
*/
|
|
938
|
-
async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycleField = '') {
|
|
910
|
+
async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycleField = '', runText = '') {
|
|
939
911
|
const cycles = [];
|
|
940
912
|
const seen = new Set();
|
|
941
913
|
const assignedCycle = parseRunCycle(cycleField);
|
|
@@ -992,7 +964,20 @@ async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycl
|
|
|
992
964
|
snapshot = parseIndexStatuses(raw);
|
|
993
965
|
if (snapshot === null) return { pass: false, detail: `archive INDEX snapshot malformed: ${cycle.name}` };
|
|
994
966
|
}
|
|
995
|
-
|
|
967
|
+
// Frozen-scope selection over THIS archive cycle: rows resolve against the archive
|
|
968
|
+
// cycle's own keys, so preserved per-cycle mappings decide and a stale unqualified
|
|
969
|
+
// line that another cycle qualified is ambiguous (included + reconciliation, fail).
|
|
970
|
+
const taskFiles = files.filter((f) => /^TASK-[\w.-]+\.md$/i.test(f));
|
|
971
|
+
const { rows: scopedFiles, reconciliation: scopeDiags } = selectTaskScope(
|
|
972
|
+
runText,
|
|
973
|
+
taskFiles.map((f) => ({ id: f.replace(/\.md$/i, ''), file: f })),
|
|
974
|
+
{ cycle: cycle.name },
|
|
975
|
+
);
|
|
976
|
+
if (scopeDiags.length > 0) {
|
|
977
|
+
return { pass: false, detail: `archive ${cycle.name}: frozen TaskScope reconciliation unresolved: ${scopeDiags.slice(0, 3).join(' | ')}` };
|
|
978
|
+
}
|
|
979
|
+
for (const scopedFile of scopedFiles) {
|
|
980
|
+
const file = scopedFile.file;
|
|
996
981
|
total += 1;
|
|
997
982
|
const id = file.replace(/\.md$/i, '').toUpperCase();
|
|
998
983
|
// A snapshot that exists but has no row for this task is unproven → fail closed; only an
|
|
@@ -1003,7 +988,11 @@ async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycl
|
|
|
1003
988
|
try { body = await fs.readFile(path.join(cycle.dir, file), 'utf8'); } catch { /* unreadable → not proven */ }
|
|
1004
989
|
status = (body.match(/^-\s*Status:\s*`?([\w-]+)`?/im)?.[1] || '').toLowerCase();
|
|
1005
990
|
}
|
|
1006
|
-
|
|
991
|
+
const archIds = new Set(snapshot ? [...snapshot.keys()].map(goalTaskId) : []);
|
|
992
|
+
const archCls = classifyTaskStatus(status, id, archIds);
|
|
993
|
+
const archClsByKey = new Map([...(snapshot || new Map()).entries()].map(([k, st]) => [goalTaskId(k), classifyTaskStatus(st, k, archIds)]));
|
|
994
|
+
const archTerminal = archCls === 'delivered' || (archCls === 'cancelled-grounded' && groundedCancellationResolved(id, archClsByKey));
|
|
995
|
+
if (!archTerminal) {
|
|
1007
996
|
failing.push(`${file.replace(/\.md$/i, '')}=${status || '?'}${snapshot ? ' (not in the archive INDEX snapshot)' : ' (no INDEX snapshot — run `node .claude/ukit/runtime/resumable-run.mjs archive-cycle <root> <NN>`)'}`);
|
|
1008
997
|
}
|
|
1009
998
|
}
|
|
@@ -1151,7 +1140,7 @@ async function evalVerifyPass(projectRoot) {
|
|
|
1151
1140
|
* malformed (unsatisfiable). Advisory like the rest of the lane — a thrown
|
|
1152
1141
|
* error becomes a failed term, never an exception.
|
|
1153
1142
|
*/
|
|
1154
|
-
async function evaluateExitPredicate(projectRoot, expr, { goalScope = '', scopeQueue = '', cycleField = '' } = {}) {
|
|
1143
|
+
async function evaluateExitPredicate(projectRoot, expr, { goalScope = '', scopeQueue = '', cycleField = '', runText = '' } = {}) {
|
|
1155
1144
|
const terms = parseExitPredicate(expr);
|
|
1156
1145
|
if (!terms) {
|
|
1157
1146
|
return { ok: false, terms: [{ term: String(expr || '').trim() || '(empty)', pass: false, detail: 'malformed predicate' }] };
|
|
@@ -1160,7 +1149,7 @@ async function evaluateExitPredicate(projectRoot, expr, { goalScope = '', scopeQ
|
|
|
1160
1149
|
for (const term of terms) {
|
|
1161
1150
|
let r;
|
|
1162
1151
|
try {
|
|
1163
|
-
if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot, { goalScope, scopeQueue, cycleField });
|
|
1152
|
+
if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot, { goalScope, scopeQueue, cycleField, runText });
|
|
1164
1153
|
else if (term === 'git:clean') r = evalGitClean(projectRoot);
|
|
1165
1154
|
else if (term === 'agents:none-running') r = await evalAgentsNoneRunning(projectRoot);
|
|
1166
1155
|
else if (term === 'verify:pass') r = await evalVerifyPass(projectRoot);
|
|
@@ -1195,7 +1184,70 @@ export const GOAL_STATUSES = Object.freeze(['RUNNING', 'PAUSED', 'BLOCKED', 'COM
|
|
|
1195
1184
|
|
|
1196
1185
|
// Terminal-SUCCESS task statuses (INDEX `Status:` vocabulary). `blocked` is
|
|
1197
1186
|
// pending-but-not-executable; everything else executable is pending work.
|
|
1198
|
-
|
|
1187
|
+
//
|
|
1188
|
+
// The same status vocabulary must NOT answer every question. Four distinct facts:
|
|
1189
|
+
// delivered `done` only — proves delivery.
|
|
1190
|
+
// cancelled-grounded `cancelled_superseded` = the RULES recovery state (record could not resume;
|
|
1191
|
+
// a `<ID>-R<n>` successor row carries the obligation and is judged on its own).
|
|
1192
|
+
// Terminal, but never proof of delivery. A bare `cancelled` carries no such
|
|
1193
|
+
// grounds and does not prove the requirement was met: it stays an unresolved
|
|
1194
|
+
// obligation (reconcile, never silently dropped).
|
|
1195
|
+
// review-obligation `pending_review`/`approved`/`approved_minor`: nothing left to IMPLEMENT, but
|
|
1196
|
+
// review/integration/status-flip/release is still owed — never "delivered".
|
|
1197
|
+
// history `archived`: moved out of the live cycle; no obligation, no delivery claim.
|
|
1198
|
+
// needs-planning `needs_breakdown`: not yet validator+review-approved, so its actor is the
|
|
1199
|
+
// PLANNER/REVIEW gate (/ukit:handoff-create resume-gates), never the executor
|
|
1200
|
+
// (handoff-implement only picks `ready`). It keeps the run open and is never
|
|
1201
|
+
// delivered/done — but it is NOT executor-executable, and ordering the executor
|
|
1202
|
+
// to run it deadlocks the stop until a liveness cap falsely releases the run.
|
|
1203
|
+
// Everything else (ready, in_progress, changes_requested, critical_block, needs_executor_report,
|
|
1204
|
+
// merge_conflict, queued_blueprint, unknown) is dispatchable work. `blocked` is pending but not
|
|
1205
|
+
// dispatchable.
|
|
1206
|
+
const REVIEW_OBLIGATION_STATUSES = new Set(['pending_review', 'approved', 'approved_minor', 'approved_with_minor']);
|
|
1207
|
+
|
|
1208
|
+
// A grounded cancellation (`cancelled_superseded`) is resolved ONLY by a STRICTLY LATER
|
|
1209
|
+
// `<ID>-R<n>` successor — a repair number strictly greater than its own (the root accepts
|
|
1210
|
+
// any R>=1) — that is itself delivered, or that is grounded-cancelled and resolved in turn
|
|
1211
|
+
// (chained recovery). A same-or-lower-numbered sibling delivers an EARLIER obligation, not
|
|
1212
|
+
// this one: `root cancelled_superseded → R1 done → R2 cancelled_superseded` leaves R2 open
|
|
1213
|
+
// even though R1 is done. A missing successor, a pending/review-owed successor (ready,
|
|
1214
|
+
// pending_review, approved*) and a bare `cancelled` successor all leave the obligation open.
|
|
1215
|
+
// `clsByKey`: Map<lower-cased id, classification> over the SAME task set. Each hop appends
|
|
1216
|
+
// `-R<n>`, so chains are finite; `seen` guards any loop. Recovery numbering is per root
|
|
1217
|
+
// (`TASK-xxx-R1`, `-R2`, …), so a successor of any `-R<n>` row is a sibling `<root>-R<m>`
|
|
1218
|
+
// row with m > n; suffixes compare numerically (BigInt, like the claim runtime), so R2 does
|
|
1219
|
+
// not resolve R10. The root is the id with its trailing `-R<n>` stripped.
|
|
1220
|
+
function groundedCancellationResolved(id, clsByKey, seen = new Set()) {
|
|
1221
|
+
const key = goalTaskId(id);
|
|
1222
|
+
if (seen.has(key)) return false;
|
|
1223
|
+
seen.add(key);
|
|
1224
|
+
const root = key.replace(/-r\d+$/, '');
|
|
1225
|
+
const ownSuffix = key.match(/-r(\d+)$/);
|
|
1226
|
+
const ownNum = ownSuffix ? BigInt(ownSuffix[1]) : 0n;
|
|
1227
|
+
const prefix = `${root}-r`;
|
|
1228
|
+
for (const [other, cls] of clsByKey) {
|
|
1229
|
+
if (!other.startsWith(prefix) || !/^\d+$/.test(other.slice(prefix.length))) continue;
|
|
1230
|
+
const num = BigInt(other.slice(prefix.length));
|
|
1231
|
+
if (num <= ownNum) continue;
|
|
1232
|
+
if (cls === 'delivered') return true;
|
|
1233
|
+
if (cls === 'cancelled-grounded' && groundedCancellationResolved(other, clsByKey, seen)) return true;
|
|
1234
|
+
}
|
|
1235
|
+
return false;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Classify one task. `allIds` = Set of lower-cased ids in the same task set (for successor grounding). */
|
|
1239
|
+
export function classifyTaskStatus(status, id, allIds = new Set()) {
|
|
1240
|
+
const st = String(status ?? '').trim().toLowerCase();
|
|
1241
|
+
if (st === 'done') return 'delivered';
|
|
1242
|
+
if (st === 'cancelled_superseded') return 'cancelled-grounded';
|
|
1243
|
+
if (st === 'cancelled') return 'unresolved';
|
|
1244
|
+
if (st === 'archived') return 'history';
|
|
1245
|
+
if (st === 'blocked') return 'blocked';
|
|
1246
|
+
if (st === 'needs_breakdown') return 'needs-planning';
|
|
1247
|
+
if (st === 'malformed') return 'malformed';
|
|
1248
|
+
if (REVIEW_OBLIGATION_STATUSES.has(st)) return 'review-obligation';
|
|
1249
|
+
return 'dispatchable';
|
|
1250
|
+
}
|
|
1199
1251
|
|
|
1200
1252
|
function goalTaskId(id) {
|
|
1201
1253
|
return String(id ?? '').trim().toLowerCase();
|
|
@@ -1246,24 +1298,45 @@ export function deriveGoalStatus({ runCursor = null, tasks = null, ledger = null
|
|
|
1246
1298
|
dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.map(goalTaskId) : [],
|
|
1247
1299
|
}))
|
|
1248
1300
|
: [];
|
|
1249
|
-
const
|
|
1250
|
-
const
|
|
1301
|
+
const allKeys = new Set(list.map((t) => t.key));
|
|
1302
|
+
for (const t of list) t.cls = classifyTaskStatus(t.status, t.id, allKeys);
|
|
1303
|
+
// A dependency is satisfied by delivery, history, or a grounded cancellation whose successor
|
|
1304
|
+
// was delivered; approved/cancelled-without-grounds never satisfy it.
|
|
1305
|
+
const deliveredKeys = new Set(list.filter((t) => t.cls === 'delivered').map((t) => t.key));
|
|
1306
|
+
const doneIds = new Set(list.filter((t) => t.cls === 'delivered' || t.cls === 'history').map((t) => t.key));
|
|
1307
|
+
const clsByKey = new Map(list.map((t) => [t.key, t.cls]));
|
|
1308
|
+
for (const t of list) {
|
|
1309
|
+
if (t.cls === 'cancelled-grounded' && groundedCancellationResolved(t.key, clsByKey)) doneIds.add(t.key);
|
|
1310
|
+
}
|
|
1311
|
+
// Only a RESOLVED grounded cancellation leaves pending; an orphan one is an open obligation.
|
|
1312
|
+
const pending = list.filter((t) => !doneIds.has(t.key));
|
|
1251
1313
|
// Dependency consistency: no task may be `done` while a dependency is not.
|
|
1252
|
-
const depInconsistent = list.filter((t) => t.
|
|
1314
|
+
const depInconsistent = list.filter((t) => t.cls === 'delivered' && t.dependsOn.some((d) => !doneIds.has(d)));
|
|
1253
1315
|
if (depInconsistent.length > 0) {
|
|
1254
1316
|
reasons.push(`dependency-inconsistent done task(s): ${depInconsistent.map((t) => t.id || '?').join(', ')}`);
|
|
1255
1317
|
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1318
|
+
// Executor-executable: dispatchable/review-obligation/malformed work whose deps are done.
|
|
1319
|
+
// needs-planning rows (needs_breakdown) are NOT executor-executable — their actor is the
|
|
1320
|
+
// planner/review gate — and `blocked` rows are pending but not dispatchable.
|
|
1321
|
+
const executable = pending.filter((t) => t.cls !== 'blocked' && t.cls !== 'needs-planning' && t.dependsOn.every((d) => doneIds.has(d)));
|
|
1322
|
+
const blockedTasks = pending.filter((t) => t.cls === 'blocked');
|
|
1323
|
+
const planningTasks = pending.filter((t) => t.cls === 'needs-planning');
|
|
1258
1324
|
const agentsActive = agentsRunning === true || (Number.isFinite(agentsRunning) && agentsRunning > 0);
|
|
1259
1325
|
|
|
1260
|
-
// Blocked work with nothing executable anywhere is
|
|
1261
|
-
// cursor says; the goal cannot advance.
|
|
1262
|
-
if (pending.length > 0 && executable.length === 0 && blockedTasks.length > 0) {
|
|
1326
|
+
// Blocked or planning-gated work with nothing executor-executable anywhere is
|
|
1327
|
+
// BLOCKED — whatever the cursor says; the goal cannot advance.
|
|
1328
|
+
if (pending.length > 0 && executable.length === 0 && (blockedTasks.length > 0 || planningTasks.length > 0)) {
|
|
1329
|
+
const deadEnd = [...reasons];
|
|
1330
|
+
if (blockedTasks.length > 0) {
|
|
1331
|
+
deadEnd.push(`no executable work; blocked task(s): ${blockedTasks.slice(0, 5).map((t) => t.id || '?').join(', ')}`);
|
|
1332
|
+
}
|
|
1333
|
+
if (planningTasks.length > 0) {
|
|
1334
|
+
deadEnd.push(`needs-planning task(s) await the planner/review gate, not the executor (run /ukit:handoff-create resume-gates (validator + plan review)): ${planningTasks.slice(0, 5).map((t) => t.id || '?').join(', ')}`);
|
|
1335
|
+
}
|
|
1263
1336
|
return {
|
|
1264
1337
|
status: 'BLOCKED',
|
|
1265
1338
|
nextCycle: false,
|
|
1266
|
-
reasons:
|
|
1339
|
+
reasons: deadEnd,
|
|
1267
1340
|
};
|
|
1268
1341
|
}
|
|
1269
1342
|
|
|
@@ -1328,10 +1401,32 @@ function indexTasks(indexText) {
|
|
|
1328
1401
|
return indexTaskRows(indexText).map((row) => {
|
|
1329
1402
|
if (!indexTaskRowIsWellFormed(row)) return { id: '', status: 'malformed' };
|
|
1330
1403
|
const cells = indexTaskCells(row);
|
|
1331
|
-
|
|
1404
|
+
const deps = (cells[3] || '').split(',').map((d) => d.trim()).filter((d) => d && d.toLowerCase() !== 'none');
|
|
1405
|
+
return { id: cells[0] || '', status: (cells[4] || '').toLowerCase(), deps };
|
|
1332
1406
|
});
|
|
1333
1407
|
}
|
|
1334
1408
|
|
|
1409
|
+
// Task-file dependency authority for the blocked-run predicate. Returns normalized TASK ids,
|
|
1410
|
+
// [] for `(none)`, or null when the file/section cannot be read (caller fails closed).
|
|
1411
|
+
async function readTaskFileDependencies(runRoot, taskId) {
|
|
1412
|
+
const id = String(taskId ?? '').trim();
|
|
1413
|
+
if (!/^TASK-[A-Za-z0-9._-]+$/.test(id)) return null;
|
|
1414
|
+
try {
|
|
1415
|
+
const text = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'tasks', `${id}.md`), 'utf8');
|
|
1416
|
+
const m = text.match(/^##\s+Dependencies\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/m);
|
|
1417
|
+
if (!m) return null;
|
|
1418
|
+
// Only list items / bare id lines declare dependencies; prose ("Unlike TASK-001 ...") does not.
|
|
1419
|
+
const body = m[1]
|
|
1420
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
1421
|
+
.split(/\r?\n/)
|
|
1422
|
+
.filter((l) => /^\s*(?:[-*+]|\d+[.)])\s/.test(l) || /^\s*TASK-[\w.,\s-]*$/i.test(l))
|
|
1423
|
+
.join('\n');
|
|
1424
|
+
return [...new Set((body.match(/TASK-[A-Za-z0-9._-]*[A-Za-z0-9]/g) || []).map(goalTaskId))];
|
|
1425
|
+
} catch {
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1335
1430
|
function indexTaskSnapshotHash(indexText) {
|
|
1336
1431
|
const rows = indexTaskRows(indexText).map((row) => {
|
|
1337
1432
|
const cells = indexTaskCells(row);
|
|
@@ -1341,10 +1436,17 @@ function indexTaskSnapshotHash(indexText) {
|
|
|
1341
1436
|
}
|
|
1342
1437
|
|
|
1343
1438
|
function firstOpenIndexTaskId(indexText) {
|
|
1439
|
+
const allIds = new Set(indexTaskRows(indexText).map((r) => goalTaskId(indexTaskCells(r)[0])));
|
|
1440
|
+
const clsByKey = new Map(indexTaskRows(indexText).filter((r) => indexTaskRowIsWellFormed(r)).map((r) => {
|
|
1441
|
+
const c = indexTaskCells(r);
|
|
1442
|
+
return [goalTaskId(c[0]), classifyTaskStatus((c[4] || '').toLowerCase(), c[0], allIds)];
|
|
1443
|
+
}));
|
|
1344
1444
|
for (const row of indexTaskRows(indexText)) {
|
|
1345
1445
|
const cells = indexTaskCells(row);
|
|
1346
1446
|
const status = (cells[4] || '').toLowerCase();
|
|
1347
|
-
|
|
1447
|
+
const cls = classifyTaskStatus(status, cells[0], allIds);
|
|
1448
|
+
const terminal = cls === 'delivered' || (cls === 'cancelled-grounded' && groundedCancellationResolved(cells[0], clsByKey));
|
|
1449
|
+
if (!terminal) return cells[0] || null;
|
|
1348
1450
|
}
|
|
1349
1451
|
return null;
|
|
1350
1452
|
}
|
|
@@ -1412,7 +1514,7 @@ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
|
|
|
1412
1514
|
* under a racing writer only delays the cap by one stop; a frozen breaker loops
|
|
1413
1515
|
* forever. Only a genuinely unwritable state file still returns null.
|
|
1414
1516
|
*/
|
|
1415
|
-
async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs, snapshotHash = '', firstOpenTaskId = null, failing = false }) {
|
|
1517
|
+
async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs, snapshotHash = '', firstOpenTaskId = null, failing = false, cycle = null }) {
|
|
1416
1518
|
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
|
|
1417
1519
|
const bump = async () => {
|
|
1418
1520
|
let current = {};
|
|
@@ -1422,15 +1524,17 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs, sna
|
|
|
1422
1524
|
const prev = current.handoff || {};
|
|
1423
1525
|
const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
|
|
1424
1526
|
const prevTask = current.handoffTask || {};
|
|
1527
|
+
// A reused task id under a NEW cycle is a different task: the failing streak restarts.
|
|
1425
1528
|
const sameFailingTask = prevTask.snapshotHash === snapshotHash
|
|
1426
|
-
&& (prevTask.firstOpenTaskId || null) === (firstOpenTaskId || null)
|
|
1529
|
+
&& (prevTask.firstOpenTaskId || null) === (firstOpenTaskId || null)
|
|
1530
|
+
&& (prevTask.cycle || null) === (cycle || null);
|
|
1427
1531
|
const failingCount = failing
|
|
1428
1532
|
? (sameFailingTask ? (Number(prevTask.failingCount) || 0) + 1 : 1)
|
|
1429
1533
|
: 0;
|
|
1430
1534
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
1431
1535
|
await fs.writeFile(
|
|
1432
1536
|
statePath,
|
|
1433
|
-
`${JSON.stringify({ ...current, handoff: { signature, count }, handoffTask: { snapshotHash, firstOpenTaskId, failingCount } }, null, 1)}\n`,
|
|
1537
|
+
`${JSON.stringify({ ...current, handoff: { signature, count }, handoffTask: { snapshotHash, firstOpenTaskId, cycle: cycle || null, failingCount } }, null, 1)}\n`,
|
|
1434
1538
|
'utf8',
|
|
1435
1539
|
);
|
|
1436
1540
|
return { count, failingCount };
|
|
@@ -1497,7 +1601,7 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1497
1601
|
// Shared terminal predicate — tests/handoff/cycle21 greps this exact shape across
|
|
1498
1602
|
// all four RUN.md consumers; keep `^(done|blocked)` as one expression.
|
|
1499
1603
|
const terminal = String(phase).match(/^(done|blocked)\b/i)?.[1]?.toLowerCase() || '';
|
|
1500
|
-
if (!phase
|
|
1604
|
+
if (!phase) {
|
|
1501
1605
|
return { kind: 'none' };
|
|
1502
1606
|
}
|
|
1503
1607
|
// Default exit predicate (goal completion): a `done` cursor with no explicit
|
|
@@ -1526,6 +1630,108 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1526
1630
|
};
|
|
1527
1631
|
}
|
|
1528
1632
|
|
|
1633
|
+
// `Phase: blocked` is a true external-blocker release only when NO pending row is executable:
|
|
1634
|
+
// every pending row is blocked or waits on a dependency that is not done. An independent READY
|
|
1635
|
+
// row (deps all done) keeps the run open. Malformed rows fail closed (treated as executable).
|
|
1636
|
+
// Runs AFTER the pause check, so a paused run is never bounced here. Empty INDEX → legacy release.
|
|
1637
|
+
if (terminal === 'blocked') {
|
|
1638
|
+
const blockedIndex = await readIndexSnapshot(runRoot);
|
|
1639
|
+
const allRows = indexTasks(blockedIndex);
|
|
1640
|
+
// Same frozen-scope selector as the completion predicate: a row explicitly mapped outside
|
|
1641
|
+
// the frozen ScopeQueue/ScopePlans does not keep a blocked run open, and rows whose mapping
|
|
1642
|
+
// is unknown/ambiguous are RETAINED (reconcile, never guess). The live INDEX resolves
|
|
1643
|
+
// against the run's current `Cycle:`; repair/replan tasks inherit their original scope.
|
|
1644
|
+
const { rows, reconciliation } = selectTaskScope(text, allRows, { cycle: parseRunCycle(cycleField) });
|
|
1645
|
+
// Blocked gate asks "is any pending row still EXECUTOR-workable?" — NOT "is it delivered?".
|
|
1646
|
+
// Dispatchable, review-obligation (pending_review/approved*: review/flip/integration still
|
|
1647
|
+
// owed), unresolved (bare cancelled / superseded without successor) and malformed rows all
|
|
1648
|
+
// keep the run open when their dependencies are satisfied. needs-planning rows
|
|
1649
|
+
// (needs_breakdown) do NOT: their actor is the planner/review gate, and ordering the executor
|
|
1650
|
+
// to run them deadlocks the stop (FS-04).
|
|
1651
|
+
const allKeys = new Set(rows.map((t) => goalTaskId(t.id)));
|
|
1652
|
+
for (const t of rows) t.cls = t.status === 'malformed' ? 'malformed' : classifyTaskStatus(t.status, t.id, allKeys);
|
|
1653
|
+
const clsOf = new Map(rows.map((t) => [goalTaskId(t.id), t.cls]));
|
|
1654
|
+
const depSatisfied = (d) => {
|
|
1655
|
+
const c = clsOf.get(d);
|
|
1656
|
+
if (c === 'delivered' || c === 'history') return true;
|
|
1657
|
+
if (c === 'cancelled-grounded') {
|
|
1658
|
+
// Satisfied once the recovery successor (if any) is delivered; with no successor row the
|
|
1659
|
+
// grounded cancellation itself is terminal.
|
|
1660
|
+
return groundedCancellationResolved(d, clsOf);
|
|
1661
|
+
}
|
|
1662
|
+
return false;
|
|
1663
|
+
};
|
|
1664
|
+
const pending = rows.filter((t) => t.cls !== 'delivered' && t.cls !== 'history'
|
|
1665
|
+
&& !(t.cls === 'cancelled-grounded' && groundedCancellationResolved(t.id, clsOf)));
|
|
1666
|
+
const planning = pending.filter((t) => t.cls === 'needs-planning');
|
|
1667
|
+
// INDEX has no dependency column: the authority is the task file's `## Dependencies`
|
|
1668
|
+
// section. Unreadable file, unknown dependency id => fail closed (row counts as executable).
|
|
1669
|
+
const executable = [];
|
|
1670
|
+
for (const t of pending) {
|
|
1671
|
+
if (t.cls === 'malformed') { executable.push(t); continue; }
|
|
1672
|
+
if (t.cls === 'blocked' || t.cls === 'needs-planning') continue;
|
|
1673
|
+
const deps = await readTaskFileDependencies(runRoot, t.id);
|
|
1674
|
+
if (deps === null) { executable.push(t); continue; }
|
|
1675
|
+
if (deps.every(depSatisfied)) executable.push(t);
|
|
1676
|
+
else if (deps.some((d) => !allKeys.has(d))) executable.push(t);
|
|
1677
|
+
else if (deps.some((d) => clsOf.get(d) === 'unresolved')) executable.push(t);
|
|
1678
|
+
}
|
|
1679
|
+
if (rows.length === 0) return { kind: 'none' };
|
|
1680
|
+
if (executable.length === 0) {
|
|
1681
|
+
if (planning.length === 0) return { kind: 'none' }; // legacy terminal: genuinely stuck / externally blocked
|
|
1682
|
+
// FS-04(d)/FS-05: the ONLY remaining rows are planner-gated. Defined terminal
|
|
1683
|
+
// outcome — release WITHOUT a completion claim and persist the durable
|
|
1684
|
+
// unfinished marker through the existing `Paused: yes` writer (Phase untouched,
|
|
1685
|
+
// never `done`), so a later resume sees the obligation instead of silently
|
|
1686
|
+
// inheriting a "completed" session.
|
|
1687
|
+
const pause = await markRunCursorPaused(runRoot);
|
|
1688
|
+
const namedPlanning = planning.slice(0, 5).map((t) => (t.id ? `${t.id}=${t.status}` : '(malformed INDEX row)')).join(', ');
|
|
1689
|
+
return {
|
|
1690
|
+
kind: 'advisory',
|
|
1691
|
+
waitForApproval: true,
|
|
1692
|
+
systemMessage: [
|
|
1693
|
+
`[ukit-stop-coordinator] handoff run released: Phase: blocked and every remaining in-scope task is needs_breakdown (${namedPlanning}).`,
|
|
1694
|
+
'These rows are planner/review-gated, not executor-executable — the run ends this stop UNFINISHED, never as completed.',
|
|
1695
|
+
pause?.ok
|
|
1696
|
+
? 'RUN.md is marked `Paused: yes` (Phase untouched): the open obligation is persisted and no resume is auto-injected.'
|
|
1697
|
+
: 'WARNING: RUN.md could not be marked `Paused: yes` (write failed) — the unfinished obligation is NOT durably marked.',
|
|
1698
|
+
'CONTINUE LATER: run /ukit:handoff-create resume-gates (validator + plan review) for these tasks, then resume explicitly with /ukit:handoff-fullstack (or clear with /ukit:handoff-clear).',
|
|
1699
|
+
].join('\n'),
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
const named = executable.slice(0, 5).map((t) => (t.id ? `${t.id}=${t.status}` : '(malformed INDEX row)')).join(', ');
|
|
1703
|
+
// Unresolved scope reconciliation is surfaced bounded — it must be resolved against
|
|
1704
|
+
// RUN.md before terminal phases, never guessed away.
|
|
1705
|
+
const reconcileNote = reconciliation.length > 0
|
|
1706
|
+
? [
|
|
1707
|
+
'',
|
|
1708
|
+
`Frozen TaskScope needs reconciliation (${reconciliation.slice(0, 3).join(' | ')}).`,
|
|
1709
|
+
'Resolve the mapping lines in docs/AI_HANDOFF/RUN.md before terminal phases.',
|
|
1710
|
+
]
|
|
1711
|
+
: [];
|
|
1712
|
+
// FS-04: planning-gated rows ride along so the executor is never ordered to run
|
|
1713
|
+
// work only the planner gate can act on.
|
|
1714
|
+
const planningNote = planning.length > 0
|
|
1715
|
+
? [
|
|
1716
|
+
'',
|
|
1717
|
+
`needs_breakdown row(s) (${planning.slice(0, 5).map((t) => (t.id ? `${t.id}=${t.status}` : '(malformed INDEX row)')).join(', ')}) are NOT executor-executable.`,
|
|
1718
|
+
'Run /ukit:handoff-create resume-gates (validator + plan review) for these tasks.',
|
|
1719
|
+
]
|
|
1720
|
+
: [];
|
|
1721
|
+
return {
|
|
1722
|
+
kind: 'block',
|
|
1723
|
+
reason: [
|
|
1724
|
+
`UKit handoff run: Phase: blocked, but independent in-scope work is still executable (${named}).`,
|
|
1725
|
+
'A blocker on one task does not end the run.',
|
|
1726
|
+
...planningNote,
|
|
1727
|
+
...reconcileNote,
|
|
1728
|
+
'',
|
|
1729
|
+
'CONTINUE IMMEDIATELY: execute the executable row(s) above, then rewrite the run cursor.',
|
|
1730
|
+
'Set `Phase: blocked` only when no executable row remains.',
|
|
1731
|
+
].join('\n'),
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1529
1735
|
let effectivePredicate = exitPredicate;
|
|
1530
1736
|
let predicateIsDefault = false;
|
|
1531
1737
|
let openNextCycle = null;
|
|
@@ -1572,7 +1778,7 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1572
1778
|
if (terminal === 'done') {
|
|
1573
1779
|
let predicate;
|
|
1574
1780
|
try {
|
|
1575
|
-
predicate = await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField });
|
|
1781
|
+
predicate = await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField, runText: text });
|
|
1576
1782
|
} catch {
|
|
1577
1783
|
return { kind: 'none' }; // advisory lane: evaluator failure never wedges a stop
|
|
1578
1784
|
}
|
|
@@ -1630,13 +1836,16 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1630
1836
|
const indexText = await readIndexSnapshot(runRoot);
|
|
1631
1837
|
const snapshotHash = indexTaskSnapshotHash(indexText);
|
|
1632
1838
|
const firstOpenTask = firstOpenIndexTaskId(indexText);
|
|
1633
|
-
|
|
1839
|
+
// The run's cycle is part of the identity: a streak accrued under cycle-01 must not carry into
|
|
1840
|
+
// cycle-02 when task ids restart (same TASK-001, same cursor wording would otherwise match).
|
|
1841
|
+
const signature = `${cycleField || ''}|${phase}|${cursor}|${next}|${snapshotHash}`;
|
|
1634
1842
|
const streak = await bumpHandoffStallCount({
|
|
1635
1843
|
projectRoot: runRoot,
|
|
1636
1844
|
signature,
|
|
1637
1845
|
lockBudgetMs,
|
|
1638
1846
|
snapshotHash,
|
|
1639
1847
|
firstOpenTaskId: firstOpenTask,
|
|
1848
|
+
cycle: cycleField,
|
|
1640
1849
|
failing: completionKind === 'block',
|
|
1641
1850
|
});
|
|
1642
1851
|
// Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
|
|
@@ -1657,39 +1866,76 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1657
1866
|
let predicateState = 'no ExitPredicate recorded';
|
|
1658
1867
|
if (effectivePredicate) {
|
|
1659
1868
|
try {
|
|
1660
|
-
predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField }));
|
|
1869
|
+
predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField, runText: text }));
|
|
1661
1870
|
} catch {
|
|
1662
1871
|
predicateState = 'predicate evaluation failed';
|
|
1663
1872
|
}
|
|
1664
1873
|
}
|
|
1874
|
+
// FS-05: releasing a nonterminal run is a loop-liveness escape, never a
|
|
1875
|
+
// completion — persist the durable unfinished marker before the advisory.
|
|
1876
|
+
const pause = await markRunCursorPaused(runRoot);
|
|
1665
1877
|
return {
|
|
1666
1878
|
kind: 'advisory',
|
|
1879
|
+
waitForApproval: true,
|
|
1667
1880
|
systemMessage:
|
|
1668
1881
|
`[ukit-stop-coordinator] handoff-cursor released this stop: the run cursor ` +
|
|
1669
1882
|
`("${signature}") has not advanced across ${streak.count - 1} consecutive blocked stops ` +
|
|
1670
1883
|
`(cap ${gate.maxStalledBlocks}), so the gate treats the run as stalled rather than ` +
|
|
1671
1884
|
`looping forever. ExitPredicate state: ${predicateState}. ` +
|
|
1885
|
+
`${unfinishedMarkerNote(pause)} ` +
|
|
1672
1886
|
`Inspect docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md; ` +
|
|
1673
|
-
`
|
|
1887
|
+
`To resume, send a fresh explicit /ukit:handoff-fullstack in a new message (only that explicit invoke lifts the pause; ordinary prompts do not); ` +
|
|
1888
|
+
`or clear with /ukit:handoff-clear.`,
|
|
1674
1889
|
};
|
|
1675
1890
|
}
|
|
1676
1891
|
// Same failing task (maxAttempts): a completion gate that keeps failing on the
|
|
1677
1892
|
// same first-open task and snapshot is a retry loop, not progress — release
|
|
1678
1893
|
// after `maxAttemptsPerFailure` consecutive failures so the session reports
|
|
1679
|
-
// honestly instead of being bounced into the same wall.
|
|
1894
|
+
// honestly instead of being bounced into the same wall. FS-05: the release
|
|
1895
|
+
// persists the durable unfinished marker — it is a loop escape, not a done.
|
|
1680
1896
|
if (streak !== null && streak.failingCount >= gate.maxAttemptsPerFailure && firstOpenTask) {
|
|
1897
|
+
const pause = await markRunCursorPaused(runRoot);
|
|
1681
1898
|
return {
|
|
1682
1899
|
kind: 'advisory',
|
|
1900
|
+
waitForApproval: true,
|
|
1683
1901
|
systemMessage:
|
|
1684
1902
|
`[ukit-stop-coordinator] handoff-cursor released this stop: task ${firstOpenTask} has now ` +
|
|
1685
1903
|
`failed the completion gate ${streak.failingCount} consecutive times ` +
|
|
1686
1904
|
`(maxAttemptsPerFailure ${gate.maxAttemptsPerFailure}). The task snapshot ` +
|
|
1687
1905
|
`("${snapshotHash}") has not changed — record the concrete blocker on the task ` +
|
|
1688
1906
|
`(or fix the failure differently) and continue; the counter resets on any ` +
|
|
1689
|
-
`snapshot or first-open-task change
|
|
1907
|
+
`snapshot or first-open-task change. ${unfinishedMarkerNote(pause)} ` +
|
|
1908
|
+
`Resume explicitly with /ukit:handoff-fullstack or clear with /ukit:handoff-clear.`,
|
|
1690
1909
|
};
|
|
1691
1910
|
}
|
|
1692
1911
|
|
|
1912
|
+
// FS-04: a run whose ENTIRE in-scope backlog is needs_breakdown is planner-gated —
|
|
1913
|
+
// handoff-implement only picks `ready`, so the generic "execute the `Next:` step"
|
|
1914
|
+
// order below would loop on work the executor refuses. Route this stop to the
|
|
1915
|
+
// planner/review gate instead. Malformed rows fail closed (treated as executor
|
|
1916
|
+
// work, generic block), and any dispatchable/review/blocked row keeps the generic
|
|
1917
|
+
// continuation. The stall-cap and maxAttempts releases above keep priority: a
|
|
1918
|
+
// wedged planning-only run still trips liveness and releases (FS-05 marker).
|
|
1919
|
+
if (indexText.trim()) {
|
|
1920
|
+
const ntScope = selectTaskScope(text, indexTasks(indexText), { cycle: parseRunCycle(cycleField) });
|
|
1921
|
+
const ntRows = ntScope.rows || [];
|
|
1922
|
+
const isPlanningRow = (t) => t.status !== 'malformed' && classifyTaskStatus(t.status, t.id) === 'needs-planning';
|
|
1923
|
+
const planningRows = ntRows.filter(isPlanningRow);
|
|
1924
|
+
const executorRows = ntRows.filter((t) => !isPlanningRow(t));
|
|
1925
|
+
if (ntRows.length > 0 && executorRows.length === 0 && planningRows.length > 0) {
|
|
1926
|
+
const namedPlanning = planningRows.slice(0, 5).map((t) => (t.id ? `${t.id}=${t.status}` : '(malformed INDEX row)')).join(', ');
|
|
1927
|
+
return {
|
|
1928
|
+
kind: 'block',
|
|
1929
|
+
reason: [
|
|
1930
|
+
`UKit handoff run: every in-scope task is needs_breakdown (${namedPlanning}) — nothing here is executor-executable.`,
|
|
1931
|
+
'handoff-implement only picks `ready` rows: these tasks are awaiting the planner/review gate, so continuing the current `Next:` step cannot start them.',
|
|
1932
|
+
'',
|
|
1933
|
+
'CONTINUE IMMEDIATELY: run /ukit:handoff-create resume-gates (validator + plan review) for these tasks, flip them to `ready`, then rewrite the run cursor and keep working.',
|
|
1934
|
+
].join('\n'),
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1693
1939
|
const lines = [
|
|
1694
1940
|
'UKit handoff run in progress — this stop is refused. A handoff-fullstack cycle is still',
|
|
1695
1941
|
'in flight; a recap, wave summary, or "waiting" reply is NOT completion. The only valid',
|
|
@@ -1909,6 +2155,18 @@ export async function markRunCursorPaused(runRoot) {
|
|
|
1909
2155
|
}
|
|
1910
2156
|
}
|
|
1911
2157
|
|
|
2158
|
+
// FS-05: a liveness release (stalled-cursor cap, maxAttemptsPerFailure, or a
|
|
2159
|
+
// blocked run whose only remaining rows are planner-gated) must be
|
|
2160
|
+
// distinguishable from a completed report. The marker is the run cursor's own
|
|
2161
|
+
// `Paused: yes` line — the exact contract the SessionStart resume hook reads
|
|
2162
|
+
// (never auto-inject a resume; only an explicit instruction lifts it) — written
|
|
2163
|
+
// by the existing markRunCursorPaused writer, which never touches `Phase:`.
|
|
2164
|
+
function unfinishedMarkerNote(pause) {
|
|
2165
|
+
return pause?.ok
|
|
2166
|
+
? 'The run is unfinished, not completed: RUN.md is marked `Paused: yes` (Phase untouched), so no resume is auto-injected and nothing is reported as done.'
|
|
2167
|
+
: 'WARNING: the run is unfinished, not completed, but RUN.md could not be marked `Paused: yes` (write failed) — the open obligation is not durably marked.';
|
|
2168
|
+
}
|
|
2169
|
+
|
|
1912
2170
|
// ─── Once-per-stop guard ──────────────────────────────────────────────────
|
|
1913
2171
|
/**
|
|
1914
2172
|
* Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
|
|
@@ -1952,6 +2210,18 @@ async function resolveRunRoot(projectRoot, cwd) {
|
|
|
1952
2210
|
return mod.resolveHandoffRunRoot(projectRoot, cwd);
|
|
1953
2211
|
}
|
|
1954
2212
|
|
|
2213
|
+
// The run's pinned cycle (RUN.md `Cycle:`, normalized by parseRunCycle) — the
|
|
2214
|
+
// watchdog's cycle-scoping input so per-task anchors from a PREVIOUS cycle are
|
|
2215
|
+
// reset instead of false-tripping a reused task id. Unreadable/blank → null.
|
|
2216
|
+
async function readRunCycle(runRoot) {
|
|
2217
|
+
try {
|
|
2218
|
+
const text = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
|
|
2219
|
+
return parseRunCycle((String(text).match(/^Cycle:\s*(.+)$/m) || [])[1] || '');
|
|
2220
|
+
} catch {
|
|
2221
|
+
return null;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1955
2225
|
// ─── Orchestration ────────────────────────────────────────────────────────
|
|
1956
2226
|
/**
|
|
1957
2227
|
* One coordinator invocation: event guard → once-per-stop dedupe → completion
|
|
@@ -2013,7 +2283,12 @@ export async function runStopCoordinator({
|
|
|
2013
2283
|
let watchdog = null;
|
|
2014
2284
|
try {
|
|
2015
2285
|
const watchdogRuntime = await import(new URL('./task-watchdog.mjs', import.meta.url).href);
|
|
2016
|
-
watchdog = await watchdogRuntime.evaluateStopWatchdog({
|
|
2286
|
+
watchdog = await watchdogRuntime.evaluateStopWatchdog({
|
|
2287
|
+
projectRoot,
|
|
2288
|
+
now,
|
|
2289
|
+
lockBudgetMs,
|
|
2290
|
+
cycle: await readRunCycle(await resolveRunRoot(projectRoot, payload?.cwd)),
|
|
2291
|
+
});
|
|
2017
2292
|
} catch (error) {
|
|
2018
2293
|
failures.watchdog = error?.message || String(error);
|
|
2019
2294
|
process.stderr.write(`[ukit-stop-coordinator] task-watchdog evaluator failed: ${failures.watchdog}\n`);
|
|
@@ -2232,6 +2507,15 @@ async function main() {
|
|
|
2232
2507
|
// Compare real paths (same shape as execution-ledger.mjs): a project under a
|
|
2233
2508
|
// symlinked root makes import.meta.url resolve to the real path while argv[1]
|
|
2234
2509
|
// keeps the symlinked spelling. Importing this module must never run main().
|
|
2510
|
+
// FS-06: a direct run through a RENAMED symlink (`node stop-gate.mjs` where
|
|
2511
|
+
// stop-gate.mjs → stop-coordinator.mjs) used to be rejected by the basename
|
|
2512
|
+
// fast path, so main() never ran and a wrapper spawning the coordinator through
|
|
2513
|
+
// such a link got a silent release instead of the fail-closed block. argv[1] is
|
|
2514
|
+
// realpath'd first: it is the entry node already executed (never a stalled
|
|
2515
|
+
// mount), and when it moved, the decision is made on real paths — an importer's
|
|
2516
|
+
// real path is its own file and can never equal ours, so that comparison is
|
|
2517
|
+
// decisive. Plain importers keep the BUG-C22-01 fast path (basename skip, no
|
|
2518
|
+
// extra fs beyond the one argv realpath).
|
|
2235
2519
|
function isDirectRun() {
|
|
2236
2520
|
const argvPath = process.argv[1];
|
|
2237
2521
|
if (!argvPath) return false;
|
|
@@ -2243,9 +2527,25 @@ function isDirectRun() {
|
|
|
2243
2527
|
selfUrlPath = null;
|
|
2244
2528
|
}
|
|
2245
2529
|
if (selfUrlPath && selfUrlPath === selfPath) return true;
|
|
2530
|
+
if (!selfUrlPath) return false;
|
|
2531
|
+
let realArgPath = null;
|
|
2532
|
+
try {
|
|
2533
|
+
realArgPath = fsSync.realpathSync(selfPath);
|
|
2534
|
+
} catch {
|
|
2535
|
+
realArgPath = null;
|
|
2536
|
+
}
|
|
2537
|
+
if (realArgPath && realArgPath !== selfPath) {
|
|
2538
|
+
let realSelfPath = null;
|
|
2539
|
+
try {
|
|
2540
|
+
realSelfPath = fsSync.realpathSync(selfUrlPath);
|
|
2541
|
+
} catch {
|
|
2542
|
+
realSelfPath = selfUrlPath;
|
|
2543
|
+
}
|
|
2544
|
+
return realArgPath === realSelfPath;
|
|
2545
|
+
}
|
|
2246
2546
|
// BUG-C22-01: skip fs entirely for non-matching importers — a stalled mount
|
|
2247
2547
|
// must not park the event loop during `await import` inside an armed deadline.
|
|
2248
|
-
if (
|
|
2548
|
+
if (path.basename(selfUrlPath) !== path.basename(selfPath)) return false;
|
|
2249
2549
|
try {
|
|
2250
2550
|
return fsSync.realpathSync(selfUrlPath) === fsSync.realpathSync(selfPath);
|
|
2251
2551
|
} catch {
|