@ngockhoale/ukit 3.4.29 → 3.4.30
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 +16 -0
- package/manifests/documentation.yaml +88 -0
- package/package.json +1 -1
- package/src/core/permissionPolicy.js +1 -1
- package/src/index/buildIndex.js +244 -4
- package/src/index/impactContext.js +91 -11
- package/src/index/languageTools.js +126 -0
- package/src/index/paths.js +5 -1
- package/src/index/playbookRegistry.js +108 -9
- package/src/index/queryIndex.js +415 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/taskRouting.js +25 -3
- package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
- package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
- package/template_project/.claude/hooks/record-execution.mjs +23 -0
- package/template_project/.claude/hooks/skill-router.sh +105 -7
- package/template_project/.claude/settings.json +21 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
- package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
- package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
- package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
- package/template_project/.claude/ukit/index/query-index.mjs +100 -7
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-task.mjs +208 -31
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +453 -24
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
- package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
- package/template_user/playbooks/feature-implementation.md +9 -0
- package/template_user/playbooks/migration.md +4 -0
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
|
|
75
75
|
import fs from 'node:fs/promises';
|
|
76
76
|
import fsSync from 'node:fs';
|
|
77
|
+
import crypto from 'node:crypto';
|
|
77
78
|
import path from 'node:path';
|
|
78
79
|
import { spawn, spawnSync } from 'node:child_process';
|
|
79
80
|
|
|
@@ -207,6 +208,10 @@ export const HANDOFF_CURSOR_DEFAULTS = Object.freeze({
|
|
|
207
208
|
// RUN.md — e.g. one opened just to plan something else — is never hijacked.
|
|
208
209
|
// 'project': legacy behaviour, any session in the repo is bounced.
|
|
209
210
|
scope: 'session',
|
|
211
|
+
// Same failing task across consecutive stops → release after this many
|
|
212
|
+
// completion-gate failures (overridable via handoff.fullstack.maxAttemptsPerFailure
|
|
213
|
+
// or orchestration.maxAttemptsPerFailure).
|
|
214
|
+
maxAttemptsPerFailure: 2,
|
|
210
215
|
});
|
|
211
216
|
|
|
212
217
|
// ─── Redaction ────────────────────────────────────────────────────────────
|
|
@@ -698,6 +703,11 @@ async function loadHandoffGateConfig(projectRoot) {
|
|
|
698
703
|
await fs.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'),
|
|
699
704
|
);
|
|
700
705
|
const gate = parsed?.handoff?.fullstack;
|
|
706
|
+
const configuredAttempts = Number.isFinite(gate?.maxAttemptsPerFailure)
|
|
707
|
+
? Math.max(1, gate.maxAttemptsPerFailure)
|
|
708
|
+
: Number.isFinite(parsed?.orchestration?.maxAttemptsPerFailure)
|
|
709
|
+
? Math.max(1, parsed.orchestration.maxAttemptsPerFailure)
|
|
710
|
+
: HANDOFF_CURSOR_DEFAULTS.maxAttemptsPerFailure;
|
|
701
711
|
return {
|
|
702
712
|
enabled: typeof gate?.stopGateEnabled === 'boolean' ? gate.stopGateEnabled : HANDOFF_CURSOR_DEFAULTS.enabled,
|
|
703
713
|
maxStalledBlocks: Number.isFinite(gate?.stopGateMaxStalledBlocks)
|
|
@@ -706,6 +716,10 @@ async function loadHandoffGateConfig(projectRoot) {
|
|
|
706
716
|
scope: gate?.stopGateScope === 'project' || gate?.stopGateScope === 'session'
|
|
707
717
|
? gate.stopGateScope
|
|
708
718
|
: HANDOFF_CURSOR_DEFAULTS.scope,
|
|
719
|
+
// Same failing task across consecutive stops → release after this many
|
|
720
|
+
// completion-gate failures (orchestration.maxAttemptsPerFailure when set,
|
|
721
|
+
// else the documented default of 2).
|
|
722
|
+
maxAttemptsPerFailure: configuredAttempts,
|
|
709
723
|
};
|
|
710
724
|
} catch {
|
|
711
725
|
return { ...HANDOFF_CURSOR_DEFAULTS };
|
|
@@ -721,9 +735,38 @@ function parseRunCursor(text) {
|
|
|
721
735
|
cursor: field('Cursor'),
|
|
722
736
|
next: field('Next'),
|
|
723
737
|
exitPredicate: field('ExitPredicate'),
|
|
738
|
+
goalScope: field('GoalScope').toLowerCase(),
|
|
724
739
|
};
|
|
725
740
|
}
|
|
726
741
|
|
|
742
|
+
// ─── Goal backlog beyond the active cycle ──────────────────────────────────
|
|
743
|
+
// A finalized cycle resets INDEX.md to a header-only table, so "empty INDEX" proves nothing
|
|
744
|
+
// about the GOAL. For a run-all goal (`GoalScope: run-all` in RUN.md) the authoritative
|
|
745
|
+
// remaining backlog is the set of queued blueprints that a cycle can still consume:
|
|
746
|
+
// docs/AI_HANDOFF/queued/<slug>/ — excluding `QueueIntent: deliberate-future` ones (separate
|
|
747
|
+
// backlog by contract) and blueprints already moved to archive/. Unreadable → null (unknown is
|
|
748
|
+
// never "nothing left").
|
|
749
|
+
export async function pendingQueuedBlueprints(runRoot) {
|
|
750
|
+
const dir = path.join(runRoot, 'docs', 'AI_HANDOFF', 'queued');
|
|
751
|
+
let entries;
|
|
752
|
+
try {
|
|
753
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
754
|
+
} catch (error) {
|
|
755
|
+
return error?.code === 'ENOENT' ? [] : null;
|
|
756
|
+
}
|
|
757
|
+
const pending = [];
|
|
758
|
+
for (const entry of entries) {
|
|
759
|
+
if (!entry.isDirectory()) continue;
|
|
760
|
+
let header = '';
|
|
761
|
+
try {
|
|
762
|
+
header = await fs.readFile(path.join(dir, entry.name, 'HANDOFF.md'), 'utf8');
|
|
763
|
+
} catch { /* a blueprint without HANDOFF.md uses the legacy fold-in behaviour */ }
|
|
764
|
+
if (/^QueueIntent:\s*deliberate-future\b/im.test(header)) continue;
|
|
765
|
+
pending.push(entry.name);
|
|
766
|
+
}
|
|
767
|
+
return pending.sort();
|
|
768
|
+
}
|
|
769
|
+
|
|
727
770
|
// ─── Exit predicate (mechanic #7 — exit-predicate-first loop) ─────────────
|
|
728
771
|
//
|
|
729
772
|
// The planner writes one `ExitPredicate:` line into RUN.md at P2 and the gate
|
|
@@ -747,7 +790,7 @@ export function parseExitPredicate(expr) {
|
|
|
747
790
|
.map((t) => t.trim())
|
|
748
791
|
.filter(Boolean);
|
|
749
792
|
if (terms.length === 0) return null;
|
|
750
|
-
const TERM_RE = /^(index:all-done|git:clean|file-exists:[^\s,]
|
|
793
|
+
const TERM_RE = /^(index:all-done|git:clean|file-exists:[^\s,]+|agents:none-running|verify:pass)$/;
|
|
751
794
|
return terms.every((t) => TERM_RE.test(t)) ? terms : null;
|
|
752
795
|
}
|
|
753
796
|
|
|
@@ -756,7 +799,7 @@ export function parseExitPredicate(expr) {
|
|
|
756
799
|
* terminal-success status (`done` or `cancelled_superseded`). An unreadable or
|
|
757
800
|
* taskless INDEX cannot prove all-done, so the term fails.
|
|
758
801
|
*/
|
|
759
|
-
async function evalIndexAllDone(projectRoot) {
|
|
802
|
+
async function evalIndexAllDone(projectRoot, { goalScope = '' } = {}) {
|
|
760
803
|
let text;
|
|
761
804
|
try {
|
|
762
805
|
text = await fs.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'INDEX.md'), 'utf8');
|
|
@@ -767,7 +810,7 @@ async function evalIndexAllDone(projectRoot) {
|
|
|
767
810
|
.split('\n')
|
|
768
811
|
.map((line) => line.trim())
|
|
769
812
|
.filter((line) => /^\|\s*TASK-/i.test(line));
|
|
770
|
-
if (rows.length === 0) return
|
|
813
|
+
if (rows.length === 0) return evalFinalizedCycles(projectRoot, goalScope);
|
|
771
814
|
const failing = [];
|
|
772
815
|
for (const row of rows) {
|
|
773
816
|
const cells = row.split('|').map((c) => c.trim()).filter(Boolean);
|
|
@@ -781,6 +824,72 @@ async function evalIndexAllDone(projectRoot) {
|
|
|
781
824
|
: { pass: false, detail: `not done: ${failing.join(', ')}` };
|
|
782
825
|
}
|
|
783
826
|
|
|
827
|
+
/** Task id → lower-cased status from the rows of an INDEX.md (or archive INDEX snapshot). */
|
|
828
|
+
function parseIndexStatuses(text) {
|
|
829
|
+
const map = new Map();
|
|
830
|
+
for (const line of String(text).split('\n')) {
|
|
831
|
+
const row = line.trim();
|
|
832
|
+
if (!/^\|\s*TASK-/i.test(row)) continue;
|
|
833
|
+
const cells = row.split('|').slice(1, -1).map((c) => c.trim()); // keep empty cells: columns must not shift
|
|
834
|
+
map.set(String(cells[0]).toUpperCase(), String(cells[4] || '').toLowerCase());
|
|
835
|
+
}
|
|
836
|
+
return map;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* A finalized cycle (F3) resets INDEX.md to a header-only table and moves its task files to
|
|
841
|
+
* archive/cycle-NN/. Zero index rows therefore proves nothing by itself, but it is not "no work
|
|
842
|
+
* happened" either: the archived task files ARE the auditable record. All-done holds when at
|
|
843
|
+
* least one archived task exists, every archived task is terminal-success, and — for a run-all
|
|
844
|
+
* goal — no consumable queued blueprint remains. No archive evidence → still unprovable (fail).
|
|
845
|
+
*/
|
|
846
|
+
async function evalFinalizedCycles(projectRoot, goalScope) {
|
|
847
|
+
const base = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'archive');
|
|
848
|
+
let cycles;
|
|
849
|
+
try {
|
|
850
|
+
cycles = (await fs.readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && /^cycle-\d+$/.test(e.name));
|
|
851
|
+
} catch {
|
|
852
|
+
return { pass: false, detail: 'INDEX.md has no task rows' };
|
|
853
|
+
}
|
|
854
|
+
let total = 0;
|
|
855
|
+
const failing = [];
|
|
856
|
+
for (const cycle of cycles) {
|
|
857
|
+
let files;
|
|
858
|
+
try { files = await fs.readdir(path.join(base, cycle.name)); } catch { continue; }
|
|
859
|
+
// Authoritative status = the INDEX snapshot taken at archive time (resumable-run.mjs
|
|
860
|
+
// archive-cycle). A task file's own `- Status:` header is executor-written and goes stale
|
|
861
|
+
// (`ready`) once INDEX.md owns the lifecycle; it is only the fallback for legacy archives
|
|
862
|
+
// that carry no snapshot. A snapshot row that is NOT terminal always wins (fail closed).
|
|
863
|
+
let snapshot = null;
|
|
864
|
+
if (files.includes('INDEX.md')) {
|
|
865
|
+
try { snapshot = parseIndexStatuses(await fs.readFile(path.join(base, cycle.name, 'INDEX.md'), 'utf8')); } catch { /* unreadable → header fallback */ }
|
|
866
|
+
}
|
|
867
|
+
for (const file of files.filter((f) => /^TASK-[\w.-]+\.md$/i.test(f))) {
|
|
868
|
+
total += 1;
|
|
869
|
+
const id = file.replace(/\.md$/i, '').toUpperCase();
|
|
870
|
+
// A snapshot that exists but has no row for this task is unproven → fail closed; only an
|
|
871
|
+
// archive with NO snapshot (legacy) falls back to the task header.
|
|
872
|
+
let status = snapshot ? (snapshot.get(id) || '') : '';
|
|
873
|
+
if (!snapshot) {
|
|
874
|
+
let body = '';
|
|
875
|
+
try { body = await fs.readFile(path.join(base, cycle.name, file), 'utf8'); } catch { /* unreadable → not proven */ }
|
|
876
|
+
status = (body.match(/^-\s*Status:\s*`?([\w-]+)`?/im)?.[1] || '').toLowerCase();
|
|
877
|
+
}
|
|
878
|
+
if (status !== 'done' && status !== 'cancelled_superseded') {
|
|
879
|
+
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>`)'}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (total === 0) return { pass: false, detail: 'INDEX.md has no task rows' };
|
|
884
|
+
if (failing.length > 0) return { pass: false, detail: `archived task not done: ${failing.join(', ')}` };
|
|
885
|
+
if (String(goalScope).toLowerCase() === 'run-all') {
|
|
886
|
+
const remaining = await pendingQueuedBlueprints(projectRoot);
|
|
887
|
+
if (!Array.isArray(remaining)) return { pass: false, detail: 'queued backlog unreadable' };
|
|
888
|
+
if (remaining.length > 0) return { pass: false, detail: `queued blueprint(s) remain: ${remaining.join(', ')}` };
|
|
889
|
+
}
|
|
890
|
+
return { pass: true, detail: `${total} archived task(s) done, index finalized` };
|
|
891
|
+
}
|
|
892
|
+
|
|
784
893
|
/**
|
|
785
894
|
* `git:clean` — `git status --porcelain` is empty. A non-git directory (or a
|
|
786
895
|
* missing git binary) cannot prove clean, so the term fails without throwing.
|
|
@@ -824,13 +933,92 @@ async function evalFileExists(projectRoot, rel) {
|
|
|
824
933
|
}
|
|
825
934
|
}
|
|
826
935
|
|
|
936
|
+
// ─── Goal-level predicate terms (agents:none-running, verify:pass) ─────────
|
|
937
|
+
// Both read EXISTING state only — the task-claim book (resumable-run.mjs) and
|
|
938
|
+
// the exec-ledger receipts — and both treat unknown/unreadable state as UNMET
|
|
939
|
+
// with a clear detail, never as a crash and never as a pass.
|
|
940
|
+
|
|
941
|
+
let resumableRunModulePromise = null;
|
|
942
|
+
function loadResumableRunModule() {
|
|
943
|
+
if (!resumableRunModulePromise) {
|
|
944
|
+
resumableRunModulePromise = import(
|
|
945
|
+
new URL('./resumable-run.mjs', import.meta.url).href
|
|
946
|
+
).catch(() => null);
|
|
947
|
+
}
|
|
948
|
+
return resumableRunModulePromise;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* `agents:none-running` — the per-task claim book holds no ACTIVE claim
|
|
953
|
+
* (owner + heartbeat within its TTL). No registry at all proves none running;
|
|
954
|
+
* an unreadable registry proves nothing → unmet.
|
|
955
|
+
*/
|
|
956
|
+
async function evalAgentsNoneRunning(projectRoot) {
|
|
957
|
+
const mod = await loadResumableRunModule();
|
|
958
|
+
if (!mod || typeof mod.countActiveTaskClaims !== 'function') {
|
|
959
|
+
return { pass: false, detail: 'claims registry unavailable (runtime module missing)' };
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
const count = await mod.countActiveTaskClaims(projectRoot);
|
|
963
|
+
if (count === null) return { pass: false, detail: 'claims registry unreadable' };
|
|
964
|
+
return count === 0
|
|
965
|
+
? { pass: true, detail: 'no active claims' }
|
|
966
|
+
: { pass: false, detail: `${count} active task claim(s)` };
|
|
967
|
+
} catch {
|
|
968
|
+
return { pass: false, detail: 'claims registry unreadable' };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* `verify:pass` — the NEWEST verification receipt in the newest exec-ledger
|
|
974
|
+
* file passed. No ledger, no verification receipt, or a failed one → unmet.
|
|
975
|
+
* Details stay fixed strings (no paths, no ledger content) so the predicate
|
|
976
|
+
* state is safe to echo into block reasons.
|
|
977
|
+
*/
|
|
978
|
+
async function evalVerifyPass(projectRoot) {
|
|
979
|
+
const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'exec-ledger');
|
|
980
|
+
let entries;
|
|
981
|
+
try {
|
|
982
|
+
entries = (await fs.readdir(dir))
|
|
983
|
+
.filter((f) => f.endsWith('.json') && f !== 'gate-crash-counter.json');
|
|
984
|
+
} catch {
|
|
985
|
+
return { pass: false, detail: 'no exec ledger recorded' };
|
|
986
|
+
}
|
|
987
|
+
if (entries.length === 0) return { pass: false, detail: 'no exec ledger recorded' };
|
|
988
|
+
let newest = null;
|
|
989
|
+
let newestMs = -1;
|
|
990
|
+
for (const name of entries.slice(0, 64)) {
|
|
991
|
+
try {
|
|
992
|
+
const st = await fs.stat(path.join(dir, name));
|
|
993
|
+
if (st.mtimeMs > newestMs) { newestMs = st.mtimeMs; newest = name; }
|
|
994
|
+
} catch { /* vanished mid-scan — skip */ }
|
|
995
|
+
}
|
|
996
|
+
if (!newest) return { pass: false, detail: 'exec ledger unreadable' };
|
|
997
|
+
let ledger;
|
|
998
|
+
try {
|
|
999
|
+
ledger = JSON.parse(await fs.readFile(path.join(dir, newest), 'utf8'));
|
|
1000
|
+
} catch {
|
|
1001
|
+
return { pass: false, detail: 'exec ledger unreadable' };
|
|
1002
|
+
}
|
|
1003
|
+
const receipts = Array.isArray(ledger?.receipts) ? ledger.receipts : [];
|
|
1004
|
+
for (let i = receipts.length - 1; i >= 0; i -= 1) {
|
|
1005
|
+
const r = receipts[i];
|
|
1006
|
+
if (!r || typeof r !== 'object' || r.kind !== 'verification') continue;
|
|
1007
|
+
if (r.success === true && r.verdict?.kind !== 'failed') {
|
|
1008
|
+
return { pass: true, detail: 'last verification passed' };
|
|
1009
|
+
}
|
|
1010
|
+
return { pass: false, detail: 'last verification failed' };
|
|
1011
|
+
}
|
|
1012
|
+
return { pass: false, detail: 'no verification receipt recorded' };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
827
1015
|
/**
|
|
828
1016
|
* Evaluate the RUN.md `ExitPredicate:` expression. Returns
|
|
829
1017
|
* `{ ok, terms: [{term, pass, detail}] }`; `ok` is false when the expression is
|
|
830
1018
|
* malformed (unsatisfiable). Advisory like the rest of the lane — a thrown
|
|
831
1019
|
* error becomes a failed term, never an exception.
|
|
832
1020
|
*/
|
|
833
|
-
async function evaluateExitPredicate(projectRoot, expr) {
|
|
1021
|
+
async function evaluateExitPredicate(projectRoot, expr, { goalScope = '' } = {}) {
|
|
834
1022
|
const terms = parseExitPredicate(expr);
|
|
835
1023
|
if (!terms) {
|
|
836
1024
|
return { ok: false, terms: [{ term: String(expr || '').trim() || '(empty)', pass: false, detail: 'malformed predicate' }] };
|
|
@@ -839,8 +1027,10 @@ async function evaluateExitPredicate(projectRoot, expr) {
|
|
|
839
1027
|
for (const term of terms) {
|
|
840
1028
|
let r;
|
|
841
1029
|
try {
|
|
842
|
-
if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot);
|
|
1030
|
+
if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot, { goalScope });
|
|
843
1031
|
else if (term === 'git:clean') r = evalGitClean(projectRoot);
|
|
1032
|
+
else if (term === 'agents:none-running') r = await evalAgentsNoneRunning(projectRoot);
|
|
1033
|
+
else if (term === 'verify:pass') r = await evalVerifyPass(projectRoot);
|
|
844
1034
|
else r = await evalFileExists(projectRoot, term.slice('file-exists:'.length));
|
|
845
1035
|
} catch (err) {
|
|
846
1036
|
r = { pass: false, detail: `evaluator error: ${err?.message || err}` };
|
|
@@ -855,6 +1045,172 @@ function formatPredicateState(result) {
|
|
|
855
1045
|
return result.terms.map((t) => `${t.term}=${t.pass ? 'pass' : `FAIL (${t.detail})`}`).join(', ');
|
|
856
1046
|
}
|
|
857
1047
|
|
|
1048
|
+
// ─── Goal status derivation (pure, deterministic) ──────────────────────────
|
|
1049
|
+
// The GOAL is the whole run-all objective; a CYCLE is one pass of the pipeline
|
|
1050
|
+
// (cursor P0..F). Four mechanical states, derived — never inferred from model
|
|
1051
|
+
// text:
|
|
1052
|
+
// RUNNING — work remains (or the run is mid-cycle / evidence unproven)
|
|
1053
|
+
// PAUSED — the user or a quota pause held the run (never force-continued)
|
|
1054
|
+
// BLOCKED — remaining work exists but none of it is executable
|
|
1055
|
+
// COMPLETED — cursor done AND every in-scope task terminal AND no active
|
|
1056
|
+
// agents AND dependency-consistent AND verification evidence
|
|
1057
|
+
// passes AND the final snapshot is consistent
|
|
1058
|
+
// COMPLETED is only ever PROVEN from the caller-supplied inputs; absent
|
|
1059
|
+
// evidence is unmet, so a bare `Phase: done` can never derive COMPLETED.
|
|
1060
|
+
|
|
1061
|
+
export const GOAL_STATUSES = Object.freeze(['RUNNING', 'PAUSED', 'BLOCKED', 'COMPLETED']);
|
|
1062
|
+
|
|
1063
|
+
// Terminal-SUCCESS task statuses (INDEX `Status:` vocabulary). `blocked` is
|
|
1064
|
+
// pending-but-not-executable; everything else executable is pending work.
|
|
1065
|
+
const GOAL_DONE_TASK_STATUSES = new Set(['done', 'cancelled', 'cancelled_superseded']);
|
|
1066
|
+
|
|
1067
|
+
function goalTaskId(id) {
|
|
1068
|
+
return String(id ?? '').trim().toLowerCase();
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function goalVerificationVerdict(ledger) {
|
|
1072
|
+
// Evidence travels in the caller's own verdict/ledger fields (the completion
|
|
1073
|
+
// gate's ledger or a receipt verdict) — this function never re-derives it.
|
|
1074
|
+
if (!ledger || typeof ledger !== 'object') return { pass: false, detail: 'verification evidence absent' };
|
|
1075
|
+
const verdict = typeof ledger.verdict === 'string' ? ledger.verdict.trim().toLowerCase() : null;
|
|
1076
|
+
if (verdict === 'pass' || verdict === 'passed' || verdict === 'verified') return { pass: true, detail: 'verification verdict: pass' };
|
|
1077
|
+
if (verdict === 'fail' || verdict === 'failed') return { pass: false, detail: 'verification verdict: fail' };
|
|
1078
|
+
if (ledger.verified === true) return { pass: true, detail: 'verification: verified' };
|
|
1079
|
+
if (ledger.verified === false) return { pass: false, detail: 'verification: not verified' };
|
|
1080
|
+
if (typeof ledger.verify === 'string' && ledger.verify.trim().toLowerCase() === 'pass') return { pass: true, detail: 'verify: pass' };
|
|
1081
|
+
return { pass: false, detail: 'verification evidence unmet' };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Derive the goal status from structured inputs. Pure: no fs, no clock, no
|
|
1086
|
+
* transcript — callers hand in the cursor fields, the CURRENT task snapshot
|
|
1087
|
+
* (INDEX rows incl. review-created repair tasks and newly added tasks), the
|
|
1088
|
+
* ledger verdict fields, and the agent/cycle state.
|
|
1089
|
+
*
|
|
1090
|
+
* @param {object} [input]
|
|
1091
|
+
* @param {{ phase?: string, paused?: boolean } | null} [input.runCursor]
|
|
1092
|
+
* @param {Array<{id: string, status: string, dependsOn?: string[]}> | null} [input.tasks]
|
|
1093
|
+
* @param {{ verdict?: string, verified?: boolean, verify?: string } | null} [input.ledger]
|
|
1094
|
+
* @param {boolean|number} [input.agentsRunning]
|
|
1095
|
+
* @param {boolean} [input.snapshotConsistent]
|
|
1096
|
+
* @returns {{ status: 'RUNNING'|'PAUSED'|'BLOCKED'|'COMPLETED', nextCycle: boolean, reasons: string[] }}
|
|
1097
|
+
*/
|
|
1098
|
+
export function deriveGoalStatus({ runCursor = null, tasks = null, ledger = null, agentsRunning = false, snapshotConsistent = true } = {}) {
|
|
1099
|
+
const reasons = [];
|
|
1100
|
+
const phase = String(runCursor?.phase ?? '').trim();
|
|
1101
|
+
if (runCursor?.paused === true || /^paused\b/i.test(phase)) {
|
|
1102
|
+
return { status: 'PAUSED', nextCycle: false, reasons: ['run cursor is paused'] };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const list = Array.isArray(tasks)
|
|
1106
|
+
? tasks
|
|
1107
|
+
.filter((t) => t && typeof t === 'object')
|
|
1108
|
+
.map((t) => ({
|
|
1109
|
+
id: String(t.id ?? '').trim(),
|
|
1110
|
+
key: goalTaskId(t.id),
|
|
1111
|
+
status: String(t.status ?? '').trim().toLowerCase(),
|
|
1112
|
+
dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.map(goalTaskId) : [],
|
|
1113
|
+
}))
|
|
1114
|
+
: [];
|
|
1115
|
+
const doneIds = new Set(list.filter((t) => GOAL_DONE_TASK_STATUSES.has(t.status)).map((t) => t.key));
|
|
1116
|
+
const pending = list.filter((t) => !GOAL_DONE_TASK_STATUSES.has(t.status));
|
|
1117
|
+
// Dependency consistency: no task may be `done` while a dependency is not.
|
|
1118
|
+
const depInconsistent = list.filter((t) => t.status === 'done' && t.dependsOn.some((d) => !doneIds.has(d)));
|
|
1119
|
+
if (depInconsistent.length > 0) {
|
|
1120
|
+
reasons.push(`dependency-inconsistent done task(s): ${depInconsistent.map((t) => t.id || '?').join(', ')}`);
|
|
1121
|
+
}
|
|
1122
|
+
const executable = pending.filter((t) => t.status !== 'blocked' && t.dependsOn.every((d) => doneIds.has(d)));
|
|
1123
|
+
const blockedTasks = pending.filter((t) => t.status === 'blocked');
|
|
1124
|
+
const agentsActive = agentsRunning === true || (Number.isFinite(agentsRunning) && agentsRunning > 0);
|
|
1125
|
+
|
|
1126
|
+
// Blocked work with nothing executable anywhere is BLOCKED — whatever the
|
|
1127
|
+
// cursor says; the goal cannot advance.
|
|
1128
|
+
if (pending.length > 0 && executable.length === 0 && blockedTasks.length > 0) {
|
|
1129
|
+
return {
|
|
1130
|
+
status: 'BLOCKED',
|
|
1131
|
+
nextCycle: false,
|
|
1132
|
+
reasons: [...reasons, `no executable work; blocked task(s): ${blockedTasks.slice(0, 5).map((t) => t.id || '?').join(', ')}`],
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (/^done\b/i.test(phase)) {
|
|
1137
|
+
// Cycle end: executable backlog → the goal simply continues in a new cycle.
|
|
1138
|
+
if (executable.length > 0) {
|
|
1139
|
+
return {
|
|
1140
|
+
status: 'RUNNING',
|
|
1141
|
+
nextCycle: true,
|
|
1142
|
+
reasons: [...reasons, `${executable.length} executable task(s) remain (e.g. ${executable.slice(0, 3).map((t) => t.id || '?').join(', ')})`],
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
if (pending.length > 0) {
|
|
1146
|
+
// Tasks remain but none is executable and none is blocked (waiting on a
|
|
1147
|
+
// dependency that is itself still pending, or a dep cycle) — not BLOCKED
|
|
1148
|
+
// (no dead end), not COMPLETED (work remains).
|
|
1149
|
+
return { status: 'RUNNING', nextCycle: false, reasons: [...reasons, 'tasks remain but none is currently executable'] };
|
|
1150
|
+
}
|
|
1151
|
+
// Everything terminal: completion must be PROVEN, each gate in turn.
|
|
1152
|
+
if (depInconsistent.length > 0) return { status: 'RUNNING', nextCycle: false, reasons };
|
|
1153
|
+
const verdict = goalVerificationVerdict(ledger);
|
|
1154
|
+
if (!verdict.pass) return { status: 'RUNNING', nextCycle: false, reasons: [...reasons, verdict.detail] };
|
|
1155
|
+
if (agentsActive) return { status: 'RUNNING', nextCycle: false, reasons: [...reasons, 'agents/cycle still running'] };
|
|
1156
|
+
if (snapshotConsistent === false) return { status: 'RUNNING', nextCycle: false, reasons: [...reasons, 'final snapshot inconsistent'] };
|
|
1157
|
+
return { status: 'COMPLETED', nextCycle: false, reasons: ['all in-scope tasks terminal, verification passed, no agents running, snapshot consistent'] };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Nonterminal cycle: the goal tracks the live run.
|
|
1161
|
+
reasons.push(`run phase "${phase || '(none)'}" is not terminal`);
|
|
1162
|
+
return { status: 'RUNNING', nextCycle: false, reasons };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ─── INDEX snapshot (task ids + statuses) ──────────────────────────────────
|
|
1166
|
+
// The CURRENT INDEX rows feed both the goal derivation and the no-progress
|
|
1167
|
+
// breaker: the ids+statuses hash changes whenever a task moves or a new one is
|
|
1168
|
+
// reconciled in, so the stall signature tracks goal progress, not just cursor
|
|
1169
|
+
// wording.
|
|
1170
|
+
|
|
1171
|
+
function indexTaskRows(text) {
|
|
1172
|
+
return String(text)
|
|
1173
|
+
.split('\n')
|
|
1174
|
+
.map((line) => line.trim())
|
|
1175
|
+
.filter((line) => /^\|\s*TASK-/i.test(line));
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function indexTaskCells(row) {
|
|
1179
|
+
return row.split('|').map((c) => c.trim()).filter(Boolean);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function indexTasks(indexText) {
|
|
1183
|
+
return indexTaskRows(indexText).map((row) => {
|
|
1184
|
+
const cells = indexTaskCells(row);
|
|
1185
|
+
return { id: cells[0] || '', status: (cells[4] || '').toLowerCase() };
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function indexTaskSnapshotHash(indexText) {
|
|
1190
|
+
const rows = indexTaskRows(indexText).map((row) => {
|
|
1191
|
+
const cells = indexTaskCells(row);
|
|
1192
|
+
return `${(cells[0] || '').toLowerCase()}=${(cells[4] || '').toLowerCase()}`;
|
|
1193
|
+
});
|
|
1194
|
+
return crypto.createHash('sha256').update(rows.join('|')).digest('hex').slice(0, 16);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function firstOpenIndexTaskId(indexText) {
|
|
1198
|
+
for (const row of indexTaskRows(indexText)) {
|
|
1199
|
+
const cells = indexTaskCells(row);
|
|
1200
|
+
const status = (cells[4] || '').toLowerCase();
|
|
1201
|
+
if (status !== 'done' && status !== 'cancelled_superseded') return cells[0] || null;
|
|
1202
|
+
}
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function readIndexSnapshot(runRoot) {
|
|
1207
|
+
try {
|
|
1208
|
+
return await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'INDEX.md'), 'utf8');
|
|
1209
|
+
} catch {
|
|
1210
|
+
return '';
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
858
1214
|
// ─── Session intent (does THIS session CURRENTLY drive the run?) ──────────
|
|
859
1215
|
// Structured-evidence rules live in the shared handoff-intent module so the
|
|
860
1216
|
// SessionStart resume hook and this Stop lane classify a transcript identically.
|
|
@@ -894,7 +1250,13 @@ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
|
|
|
894
1250
|
/**
|
|
895
1251
|
* Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
|
|
896
1252
|
* { signature, count }. Same signature as last time → count+1; a moved cursor
|
|
897
|
-
* resets the streak
|
|
1253
|
+
* or task snapshot resets the streak (the signature carries the INDEX
|
|
1254
|
+
* ids+statuses hash, so reconciling a new task or moving a task status counts
|
|
1255
|
+
* as progress). Also maintains the sibling `handoffTask` slot:
|
|
1256
|
+
* { snapshotHash, firstOpenTaskId, failingCount } — `failingCount` accrues only
|
|
1257
|
+
* while the completion gate keeps blocking on the SAME first-open task and
|
|
1258
|
+
* snapshot, and resets to 0 on any progress or a non-failing stop. Returns
|
|
1259
|
+
* `{ count, failingCount }` (both null only when the state file is unwritable).
|
|
898
1260
|
*
|
|
899
1261
|
* RC-4: a lock-busy acquisition used to return null, and the release branch
|
|
900
1262
|
* required `streak !== null` — so a leaked state lock froze the
|
|
@@ -904,7 +1266,7 @@ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
|
|
|
904
1266
|
* under a racing writer only delays the cap by one stop; a frozen breaker loops
|
|
905
1267
|
* forever. Only a genuinely unwritable state file still returns null.
|
|
906
1268
|
*/
|
|
907
|
-
async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
|
|
1269
|
+
async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs, snapshotHash = '', firstOpenTaskId = null, failing = false }) {
|
|
908
1270
|
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
|
|
909
1271
|
const bump = async () => {
|
|
910
1272
|
let current = {};
|
|
@@ -913,13 +1275,19 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
|
|
|
913
1275
|
} catch {}
|
|
914
1276
|
const prev = current.handoff || {};
|
|
915
1277
|
const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
|
|
1278
|
+
const prevTask = current.handoffTask || {};
|
|
1279
|
+
const sameFailingTask = prevTask.snapshotHash === snapshotHash
|
|
1280
|
+
&& (prevTask.firstOpenTaskId || null) === (firstOpenTaskId || null);
|
|
1281
|
+
const failingCount = failing
|
|
1282
|
+
? (sameFailingTask ? (Number(prevTask.failingCount) || 0) + 1 : 1)
|
|
1283
|
+
: 0;
|
|
916
1284
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
917
1285
|
await fs.writeFile(
|
|
918
1286
|
statePath,
|
|
919
|
-
`${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
|
|
1287
|
+
`${JSON.stringify({ ...current, handoff: { signature, count }, handoffTask: { snapshotHash, firstOpenTaskId, failingCount } }, null, 1)}\n`,
|
|
920
1288
|
'utf8',
|
|
921
1289
|
);
|
|
922
|
-
return count;
|
|
1290
|
+
return { count, failingCount };
|
|
923
1291
|
};
|
|
924
1292
|
try {
|
|
925
1293
|
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, bump);
|
|
@@ -932,7 +1300,7 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
|
|
|
932
1300
|
// keeps advancing behind a wedged or leaked lock.
|
|
933
1301
|
return await bump();
|
|
934
1302
|
} catch {
|
|
935
|
-
return null;
|
|
1303
|
+
return { count: null, failingCount: null };
|
|
936
1304
|
}
|
|
937
1305
|
}
|
|
938
1306
|
|
|
@@ -944,19 +1312,28 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
|
|
|
944
1312
|
* premature "done" reply gets bounced straight back into the pipeline. Releases
|
|
945
1313
|
* when the cursor says `done` (or `blocked` — a genuinely stuck run is a
|
|
946
1314
|
* legitimate final report), when RUN.md is absent/unreadable, or when the same
|
|
947
|
-
* un-advancing cursor
|
|
948
|
-
* (liveness breaker — the gate must never be an unstoppable
|
|
1315
|
+
* un-advancing cursor AND task snapshot have already bounced `maxStalledBlocks`
|
|
1316
|
+
* stops in a row (liveness breaker — the gate must never be an unstoppable
|
|
1317
|
+
* loop).
|
|
949
1318
|
*
|
|
950
1319
|
* Exit predicate (mechanic #7): when RUN.md carries an `ExitPredicate:` line,
|
|
951
1320
|
* `Phase: done` is only accepted if the predicate evaluates true — a `done`
|
|
952
|
-
* claim with failing terms is bounced back with the per-term state. A
|
|
953
|
-
* predicate
|
|
1321
|
+
* claim with failing terms is bounced back with the per-term state. A `done`
|
|
1322
|
+
* claim WITHOUT a predicate evaluates the default `all(index:all-done)` (the
|
|
1323
|
+
* goal-completion floor: a run-all goal cannot claim done over an unfinished
|
|
1324
|
+
* backlog); a project with NO INDEX.md at all keeps the legacy phase-only
|
|
1325
|
+
* release because there is nothing in scope to prove. `Phase: blocked` stays
|
|
954
1326
|
* terminal either way. The predicate is never relaxed: the stalled-cursor
|
|
955
1327
|
* advisory reports its state so a plateau is visible, not silent.
|
|
956
1328
|
*
|
|
1329
|
+
* Same failing task (task #5): when the completion gate keeps blocking on the
|
|
1330
|
+
* same first-open task across consecutive stops, the lane releases after
|
|
1331
|
+
* `maxAttemptsPerFailure` failures — retrying the same edit is a loop, not
|
|
1332
|
+
* progress.
|
|
1333
|
+
*
|
|
957
1334
|
* Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
|
|
958
1335
|
*/
|
|
959
|
-
export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false } = {}) {
|
|
1336
|
+
export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false, completionKind = null } = {}) {
|
|
960
1337
|
// `runRoot` is the checkout whose RUN.md cursor this session may be held to: the
|
|
961
1338
|
// git-verified linked worktree when the session runs in one, else `projectRoot`.
|
|
962
1339
|
// Policy (gate config) stays with `projectRoot`; the cursor, its exit predicate
|
|
@@ -970,14 +1347,38 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
970
1347
|
} catch {
|
|
971
1348
|
return { kind: 'none' };
|
|
972
1349
|
}
|
|
973
|
-
const { command, goal, phase, cursor, next, exitPredicate } = parseRunCursor(text);
|
|
1350
|
+
const { command, goal, phase, cursor, next, exitPredicate, goalScope } = parseRunCursor(text);
|
|
974
1351
|
// Shared terminal predicate — tests/handoff/cycle21 greps this exact shape across
|
|
975
1352
|
// all four RUN.md consumers; keep `^(done|blocked)` as one expression.
|
|
976
1353
|
const terminal = String(phase).match(/^(done|blocked)\b/i)?.[1]?.toLowerCase() || '';
|
|
977
1354
|
if (!phase || terminal === 'blocked') {
|
|
978
1355
|
return { kind: 'none' };
|
|
979
1356
|
}
|
|
980
|
-
|
|
1357
|
+
// Default exit predicate (goal completion): a `done` cursor with no explicit
|
|
1358
|
+
// ExitPredicate is no longer released on phase alone when the project HAS a
|
|
1359
|
+
// task index — the default `all(index:all-done)` applies so a run-all goal
|
|
1360
|
+
// cannot claim done over an unfinished backlog. No INDEX.md at all → the
|
|
1361
|
+
// legacy phase-only release (nothing in scope to prove).
|
|
1362
|
+
let effectivePredicate = exitPredicate;
|
|
1363
|
+
let predicateIsDefault = false;
|
|
1364
|
+
let openNextCycle = null;
|
|
1365
|
+
if (terminal === 'done' && !effectivePredicate) {
|
|
1366
|
+
const doneIndex = await readIndexSnapshot(runRoot);
|
|
1367
|
+
if (!doneIndex.trim()) return { kind: 'none' }; // legacy release: no index, nothing in scope
|
|
1368
|
+
// A finalized cycle resets INDEX.md to a header-only table before `Phase: done`:
|
|
1369
|
+
// zero task rows = nothing in scope, so release rather than fail all-done.
|
|
1370
|
+
if (!/^\s*\|\s*TASK-/im.test(doneIndex)) {
|
|
1371
|
+
// Header-only INDEX = this CYCLE is finalized, not necessarily the GOAL: a run-all goal
|
|
1372
|
+
// continues while consumable queued blueprints remain (or the backlog is unreadable).
|
|
1373
|
+
if (goalScope !== 'run-all') return { kind: 'none' };
|
|
1374
|
+
const remaining = await pendingQueuedBlueprints(runRoot);
|
|
1375
|
+
if (Array.isArray(remaining) && remaining.length === 0) return { kind: 'none' };
|
|
1376
|
+
openNextCycle = { remaining };
|
|
1377
|
+
} else {
|
|
1378
|
+
effectivePredicate = 'all(index:all-done)';
|
|
1379
|
+
predicateIsDefault = true;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
981
1382
|
|
|
982
1383
|
// Session scope: a run owned by another session (or abandoned) must not
|
|
983
1384
|
// hijack an unrelated session, and a session whose CURRENT intent is planning
|
|
@@ -989,22 +1390,56 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
989
1390
|
return { kind: 'none' };
|
|
990
1391
|
}
|
|
991
1392
|
|
|
1393
|
+
if (terminal === 'done' && openNextCycle) {
|
|
1394
|
+
const names = openNextCycle.remaining;
|
|
1395
|
+
return {
|
|
1396
|
+
kind: 'block',
|
|
1397
|
+
reason: [
|
|
1398
|
+
'UKit run-all goal: this cycle is finalized but the GOAL is not complete — ',
|
|
1399
|
+
names === null
|
|
1400
|
+
? 'the queued backlog (docs/AI_HANDOFF/queued/) could not be read, so remaining work is unknown, not zero.'
|
|
1401
|
+
: `${names.length} queued blueprint(s) remain: ${names.slice(0, 5).join(', ')}.`,
|
|
1402
|
+
'',
|
|
1403
|
+
'CONTINUE IMMEDIATELY: open the next cycle — fold the next queued blueprint into',
|
|
1404
|
+
'INDEX.md / tasks/ (P2), set `Phase:` to a live phase, and execute it. Archive each',
|
|
1405
|
+
'consumed blueprint to archive/blueprints/. Set `Phase: blocked` only for a real external blocker.',
|
|
1406
|
+
].join('\n').replace('— \n', '— '),
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
|
|
992
1410
|
if (terminal === 'done') {
|
|
993
1411
|
let predicate;
|
|
994
1412
|
try {
|
|
995
|
-
predicate = await evaluateExitPredicate(runRoot,
|
|
1413
|
+
predicate = await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope });
|
|
996
1414
|
} catch {
|
|
997
1415
|
return { kind: 'none' }; // advisory lane: evaluator failure never wedges a stop
|
|
998
1416
|
}
|
|
999
1417
|
if (predicate.ok) return { kind: 'none' };
|
|
1418
|
+
// Goal derivation over the CURRENT index snapshot — informs the bounce with
|
|
1419
|
+
// the mechanical goal state (release stays predicate-owned: verification
|
|
1420
|
+
// evidence belongs to the completion-gate lane, not the cursor lane).
|
|
1421
|
+
let goalLine = '';
|
|
1422
|
+
try {
|
|
1423
|
+
const doneIndex = await readIndexSnapshot(runRoot);
|
|
1424
|
+
const claimState = await evalAgentsNoneRunning(runRoot);
|
|
1425
|
+
const goal = deriveGoalStatus({
|
|
1426
|
+
runCursor: { phase },
|
|
1427
|
+
tasks: indexTasks(doneIndex),
|
|
1428
|
+
ledger: null,
|
|
1429
|
+
// Unreadable registry proves nothing → treated as running (fail closed).
|
|
1430
|
+
agentsRunning: !claimState.pass,
|
|
1431
|
+
});
|
|
1432
|
+
goalLine = ` Goal status: ${goal.status}${goal.nextCycle ? ' (next cycle — executable work remains)' : ''}`;
|
|
1433
|
+
} catch { /* enrichment only — never changes the decision */ }
|
|
1000
1434
|
const lines = [
|
|
1001
1435
|
'UKit handoff run claims `Phase: done` but its ExitPredicate is not satisfied — this',
|
|
1002
1436
|
'stop is refused. "Done" means the predicate the planner wrote at P2 evaluates true;',
|
|
1003
1437
|
'a plateau is not a stop and the predicate is never relaxed.',
|
|
1004
1438
|
'',
|
|
1005
1439
|
` Goal: ${goal || '(not recorded)'}`,
|
|
1006
|
-
` ExitPredicate: ${
|
|
1440
|
+
` ExitPredicate: ${effectivePredicate}${predicateIsDefault ? ' (default — no ExitPredicate line recorded)' : ''}`,
|
|
1007
1441
|
` Predicate state: ${formatPredicateState(predicate)}`,
|
|
1442
|
+
...(goalLine ? [goalLine] : []),
|
|
1008
1443
|
'',
|
|
1009
1444
|
'CONTINUE IMMEDIATELY: read docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md, finish',
|
|
1010
1445
|
'the work the failing terms name (or record the real external blocker and set',
|
|
@@ -1037,14 +1472,28 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1037
1472
|
};
|
|
1038
1473
|
}
|
|
1039
1474
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1475
|
+
// No-progress breaker inputs: the stall signature now carries the INDEX
|
|
1476
|
+
// ids+statuses hash, so an unchanged task snapshot accrues toward the cap even
|
|
1477
|
+
// when the cursor wording keeps moving — and reconciling a new task or moving
|
|
1478
|
+
// a task status resets it (real goal progress).
|
|
1479
|
+
const indexText = await readIndexSnapshot(runRoot);
|
|
1480
|
+
const snapshotHash = indexTaskSnapshotHash(indexText);
|
|
1481
|
+
const firstOpenTask = firstOpenIndexTaskId(indexText);
|
|
1482
|
+
const signature = `${phase}|${cursor}|${next}|${snapshotHash}`;
|
|
1483
|
+
const streak = await bumpHandoffStallCount({
|
|
1484
|
+
projectRoot: runRoot,
|
|
1485
|
+
signature,
|
|
1486
|
+
lockBudgetMs,
|
|
1487
|
+
snapshotHash,
|
|
1488
|
+
firstOpenTaskId: firstOpenTask,
|
|
1489
|
+
failing: completionKind === 'block',
|
|
1490
|
+
});
|
|
1042
1491
|
// Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
|
|
1043
1492
|
// inside the continuation turn WE forced. The same cursor signature seen again
|
|
1044
1493
|
// (streak > 1) means that continuation made no cursor progress — blocking again
|
|
1045
1494
|
// is a no-progress loop, so release once instead of waiting for the stalled cap.
|
|
1046
1495
|
// A moved cursor resets the streak to 1 and keeps blocking (real progress).
|
|
1047
|
-
if (stopHookActive === true && streak !== null && streak > 1) {
|
|
1496
|
+
if (stopHookActive === true && streak !== null && streak.count > 1) {
|
|
1048
1497
|
return {
|
|
1049
1498
|
kind: 'advisory',
|
|
1050
1499
|
systemMessage:
|
|
@@ -1053,11 +1502,11 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1053
1502
|
`RUN.md is untouched; resume with /ukit:handoff-fullstack or clear with /ukit:handoff-clear.`,
|
|
1054
1503
|
};
|
|
1055
1504
|
}
|
|
1056
|
-
if (streak !== null && streak > gate.maxStalledBlocks) {
|
|
1505
|
+
if (streak !== null && streak.count > gate.maxStalledBlocks) {
|
|
1057
1506
|
let predicateState = 'no ExitPredicate recorded';
|
|
1058
|
-
if (
|
|
1507
|
+
if (effectivePredicate) {
|
|
1059
1508
|
try {
|
|
1060
|
-
predicateState = formatPredicateState(await evaluateExitPredicate(runRoot,
|
|
1509
|
+
predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope }));
|
|
1061
1510
|
} catch {
|
|
1062
1511
|
predicateState = 'predicate evaluation failed';
|
|
1063
1512
|
}
|
|
@@ -1066,13 +1515,29 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1066
1515
|
kind: 'advisory',
|
|
1067
1516
|
systemMessage:
|
|
1068
1517
|
`[ukit-stop-coordinator] handoff-cursor released this stop: the run cursor ` +
|
|
1069
|
-
`("${signature}") has not advanced across ${streak - 1} consecutive blocked stops ` +
|
|
1518
|
+
`("${signature}") has not advanced across ${streak.count - 1} consecutive blocked stops ` +
|
|
1070
1519
|
`(cap ${gate.maxStalledBlocks}), so the gate treats the run as stalled rather than ` +
|
|
1071
1520
|
`looping forever. ExitPredicate state: ${predicateState}. ` +
|
|
1072
1521
|
`Inspect docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md; ` +
|
|
1073
1522
|
`the run can be resumed with /ukit:handoff-fullstack or cleared with /ukit:handoff-clear.`,
|
|
1074
1523
|
};
|
|
1075
1524
|
}
|
|
1525
|
+
// Same failing task (maxAttempts): a completion gate that keeps failing on the
|
|
1526
|
+
// same first-open task and snapshot is a retry loop, not progress — release
|
|
1527
|
+
// after `maxAttemptsPerFailure` consecutive failures so the session reports
|
|
1528
|
+
// honestly instead of being bounced into the same wall.
|
|
1529
|
+
if (streak !== null && streak.failingCount >= gate.maxAttemptsPerFailure && firstOpenTask) {
|
|
1530
|
+
return {
|
|
1531
|
+
kind: 'advisory',
|
|
1532
|
+
systemMessage:
|
|
1533
|
+
`[ukit-stop-coordinator] handoff-cursor released this stop: task ${firstOpenTask} has now ` +
|
|
1534
|
+
`failed the completion gate ${streak.failingCount} consecutive times ` +
|
|
1535
|
+
`(maxAttemptsPerFailure ${gate.maxAttemptsPerFailure}). The task snapshot ` +
|
|
1536
|
+
`("${snapshotHash}") has not changed — record the concrete blocker on the task ` +
|
|
1537
|
+
`(or fix the failure differently) and continue; the counter resets on any ` +
|
|
1538
|
+
`snapshot or first-open-task change.`,
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1076
1541
|
|
|
1077
1542
|
const lines = [
|
|
1078
1543
|
'UKit handoff run in progress — this stop is refused. A handoff-fullstack cycle is still',
|
|
@@ -1188,6 +1653,111 @@ export async function detectUserInterruption(transcriptPath, {
|
|
|
1188
1653
|
}
|
|
1189
1654
|
}
|
|
1190
1655
|
|
|
1656
|
+
// ─── Provider quota / rate-limit pause (goal-loop liveness) ────────────────
|
|
1657
|
+
// A provider quota exhaustion is a genuine external pause: forcing continuation
|
|
1658
|
+
// would bounce the model against a wall, and reporting completion would be a
|
|
1659
|
+
// lie. The gate RELEASES such a stop with an advisory and marks the run cursor
|
|
1660
|
+
// PAUSED (the `Paused: yes` line — the exact marker runCursorPaused reads —
|
|
1661
|
+
// with `Phase:` untouched), so a later resume is possible. Never COMPLETED.
|
|
1662
|
+
//
|
|
1663
|
+
// 'stream_disconnected' is deliberately NOT a quota signature: a dropped stream
|
|
1664
|
+
// is a retry, not a pause.
|
|
1665
|
+
export const QUOTA_SCAN_MAX_BYTES = 64 * 1024;
|
|
1666
|
+
export const QUOTA_SCAN_BUDGET_MS = 500;
|
|
1667
|
+
export const QUOTA_LIMIT_RE = /\busage limit\b|\brate limit\b|\bquota\b|\b429\b|\bcredit balance\b/i;
|
|
1668
|
+
|
|
1669
|
+
/** First quota/rate-limit signature in the text (lowercased), or null. */
|
|
1670
|
+
export function detectQuotaSignal(text) {
|
|
1671
|
+
const m = QUOTA_LIMIT_RE.exec(String(text || ''));
|
|
1672
|
+
return m ? m[0].toLowerCase() : null;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Quota signature in STRUCTURED provider-error records only. Ordinary
|
|
1677
|
+
* assistant/user/tool text that merely mentions "rate limit" or "429" (code,
|
|
1678
|
+
* discussion) must never pause a run, so each JSONL line is parsed and only
|
|
1679
|
+
* error records (`isApiErrorMessage`, error/api_error type or subtype, or an
|
|
1680
|
+
* `error` field) are scanned. A partial first line of the tail fails to parse
|
|
1681
|
+
* and is skipped.
|
|
1682
|
+
*/
|
|
1683
|
+
export function detectQuotaSignalInTranscript(text) {
|
|
1684
|
+
for (const line of String(text || '').split('\n')) {
|
|
1685
|
+
const t = line.trim();
|
|
1686
|
+
if (!t.startsWith('{')) continue;
|
|
1687
|
+
let rec;
|
|
1688
|
+
try { rec = JSON.parse(t); } catch { continue; }
|
|
1689
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
1690
|
+
const kind = `${rec.type || ''} ${rec.subtype || ''}`;
|
|
1691
|
+
const structured = rec.isApiErrorMessage === true || /\berror\b|api_error/i.test(kind) || rec.error != null;
|
|
1692
|
+
if (!structured) continue;
|
|
1693
|
+
const hit = detectQuotaSignal(JSON.stringify(rec.error ?? rec.message ?? rec.content ?? ''));
|
|
1694
|
+
if (hit) return hit;
|
|
1695
|
+
}
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* Bounded tail scan of the host transcript (plus any caller-supplied text) for
|
|
1701
|
+
* a quota/rate-limit signature. Unknown/unreadable/over-budget → null, so a
|
|
1702
|
+
* lost read never pauses a healthy run.
|
|
1703
|
+
*/
|
|
1704
|
+
export async function detectQuotaPauseSignal({ transcriptPath = null, extraText = '' } = {}) {
|
|
1705
|
+
if (typeof extraText === 'string' && extraText) {
|
|
1706
|
+
const direct = detectQuotaSignal(extraText);
|
|
1707
|
+
if (direct) return direct;
|
|
1708
|
+
}
|
|
1709
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return null;
|
|
1710
|
+
let handle;
|
|
1711
|
+
try {
|
|
1712
|
+
handle = await fs.open(transcriptPath, 'r');
|
|
1713
|
+
const size = Number((await handle.stat()).size) || 0;
|
|
1714
|
+
const start = Math.max(0, size - QUOTA_SCAN_MAX_BYTES);
|
|
1715
|
+
const deadline = Date.now() + QUOTA_SCAN_BUDGET_MS;
|
|
1716
|
+
const buf = Buffer.allocUnsafe(size - start);
|
|
1717
|
+
let got = 0;
|
|
1718
|
+
while (got < buf.length) {
|
|
1719
|
+
if (Date.now() > deadline) return null;
|
|
1720
|
+
const { bytesRead } = await handle.read(buf, got, buf.length - got, start + got);
|
|
1721
|
+
if (!bytesRead) break;
|
|
1722
|
+
got += bytesRead;
|
|
1723
|
+
}
|
|
1724
|
+
return detectQuotaSignalInTranscript(buf.toString('utf8', 0, got));
|
|
1725
|
+
} catch {
|
|
1726
|
+
return null;
|
|
1727
|
+
} finally {
|
|
1728
|
+
try { await handle?.close(); } catch {}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Mark RUN.md paused WITHOUT touching its `Phase:` line: write/replace a
|
|
1734
|
+
* literal `Paused: yes` line — the exact marker the shared `runCursorPaused`
|
|
1735
|
+
* contract reads, so every consumer (resume hook, Stop gate, omp bridge) sees
|
|
1736
|
+
* the same pause. Idempotent; atomic tmp+rename; missing RUN.md → no-op.
|
|
1737
|
+
*/
|
|
1738
|
+
export async function markRunCursorPaused(runRoot) {
|
|
1739
|
+
const runMdPath = path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
|
|
1740
|
+
let text;
|
|
1741
|
+
try {
|
|
1742
|
+
text = await fs.readFile(runMdPath, 'utf8');
|
|
1743
|
+
} catch {
|
|
1744
|
+
return { ok: false, reason: 'no-run-md' };
|
|
1745
|
+
}
|
|
1746
|
+
if (/^Paused:\s*yes\b/im.test(text)) return { ok: true, already: true };
|
|
1747
|
+
const next = /^Paused:/m.test(text)
|
|
1748
|
+
? text.replace(/^Paused:.*$/m, 'Paused: yes')
|
|
1749
|
+
: `${text.replace(/\s*$/, '\n')}Paused: yes\n`;
|
|
1750
|
+
const tmp = `${runMdPath}.tmp-pause-${process.pid}-${Date.now()}`;
|
|
1751
|
+
try {
|
|
1752
|
+
await fs.writeFile(tmp, next, 'utf8');
|
|
1753
|
+
await fs.rename(tmp, runMdPath);
|
|
1754
|
+
return { ok: true, already: false };
|
|
1755
|
+
} catch {
|
|
1756
|
+
try { await fs.rm(tmp, { force: true }); } catch {}
|
|
1757
|
+
return { ok: false, reason: 'write-failed' };
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1191
1761
|
// ─── Once-per-stop guard ──────────────────────────────────────────────────
|
|
1192
1762
|
/**
|
|
1193
1763
|
* Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
|
|
@@ -1297,6 +1867,41 @@ export async function runStopCoordinator({
|
|
|
1297
1867
|
failures.watchdog = error?.message || String(error);
|
|
1298
1868
|
process.stderr.write(`[ukit-stop-coordinator] task-watchdog evaluator failed: ${failures.watchdog}\n`);
|
|
1299
1869
|
}
|
|
1870
|
+
|
|
1871
|
+
// Provider quota / rate limit: a genuine external pause. Release the stop with
|
|
1872
|
+
// an advisory, mark the cursor PAUSED via the shared `Paused: yes` contract
|
|
1873
|
+
// (`Phase:` untouched), and never report completion for this stop.
|
|
1874
|
+
try {
|
|
1875
|
+
const quotaSignal = await detectQuotaPauseSignal({
|
|
1876
|
+
transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
|
|
1877
|
+
// Only a structured hook-payload error counts; raw input text is free-form.
|
|
1878
|
+
extraText: payload?.error != null ? JSON.stringify(payload.error) : '',
|
|
1879
|
+
});
|
|
1880
|
+
if (quotaSignal) {
|
|
1881
|
+
const pause = await markRunCursorPaused(await resolveRunRoot(projectRoot, payload?.cwd));
|
|
1882
|
+
return {
|
|
1883
|
+
skip: null,
|
|
1884
|
+
merged: {
|
|
1885
|
+
decision: null,
|
|
1886
|
+
reason: undefined,
|
|
1887
|
+
quotaPause: true,
|
|
1888
|
+
// This early return bypasses the shared terminalOutcome assignment
|
|
1889
|
+
// below, so the paused outcome is stamped here: a quota pause waits,
|
|
1890
|
+
// it is never COMPLETE.
|
|
1891
|
+
terminalOutcome: 'WAIT_FOR_APPROVAL',
|
|
1892
|
+
systemMessage:
|
|
1893
|
+
`[ukit-stop-coordinator] stop released: a provider quota/rate-limit signal ("${quotaSignal}") ` +
|
|
1894
|
+
`was detected. ` + (pause.ok
|
|
1895
|
+
? 'The run cursor is marked PAUSED; nothing was resumed or completed. '
|
|
1896
|
+
: 'No RUN.md cursor found to pause; nothing was resumed or completed. ')
|
|
1897
|
+
+ 'Continue in a NEW message (or /ukit:handoff-fullstack) once the quota resets.',
|
|
1898
|
+
policyOwner: 'quota-pause',
|
|
1899
|
+
policies: ['quota-pause'],
|
|
1900
|
+
},
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
} catch { /* advisory lane: detection failure never changes the stop */ }
|
|
1904
|
+
|
|
1300
1905
|
let handoff = null;
|
|
1301
1906
|
try {
|
|
1302
1907
|
handoff = await evaluateHandoffCursor({
|
|
@@ -1306,6 +1911,7 @@ export async function runStopCoordinator({
|
|
|
1306
1911
|
lockBudgetMs,
|
|
1307
1912
|
transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
|
|
1308
1913
|
stopHookActive: payload?.stop_hook_active === true,
|
|
1914
|
+
completionKind: completion?.kind ?? null,
|
|
1309
1915
|
});
|
|
1310
1916
|
} catch (error) {
|
|
1311
1917
|
failures.handoff = error?.message || String(error);
|
|
@@ -1321,11 +1927,12 @@ export async function runStopCoordinator({
|
|
|
1321
1927
|
} catch { /* detector failure keeps the original decision */ }
|
|
1322
1928
|
|
|
1323
1929
|
// Termination contract (label only; no new hook, no LLM): every coordinated stop ends in one
|
|
1324
|
-
// outcome. A loop-breaker release is BLOCKED_AND_STOP, never approval; a PAUSED run
|
|
1930
|
+
// outcome. A loop-breaker release is BLOCKED_AND_STOP, never approval; a PAUSED run
|
|
1931
|
+
// (user interruption or quota pause) waits — it is never COMPLETE.
|
|
1325
1932
|
// Advisory label only (nothing consumes it); a gate block approves nothing.
|
|
1326
1933
|
merged.terminalOutcome = merged.decision === 'block' ? 'CONTINUE_REQUIRED'
|
|
1327
1934
|
: merged.loopBreak === true ? 'BLOCKED_AND_STOP'
|
|
1328
|
-
: handoff?.waitForApproval === true ? 'WAIT_FOR_APPROVAL' : 'COMPLETE_AND_STOP';
|
|
1935
|
+
: merged.quotaPause === true || handoff?.waitForApproval === true ? 'WAIT_FOR_APPROVAL' : 'COMPLETE_AND_STOP';
|
|
1329
1936
|
|
|
1330
1937
|
// C85 TASK-015 (BL-015): advisory review-policy verdict. The evaluator runs
|
|
1331
1938
|
// only when a route record exists; its action + namedCheck are recorded on
|