@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
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
// could not complete (missing/unreadable path, budget overrun) — callers keep
|
|
29
29
|
// their fail-closed posture on `known=false`.
|
|
30
30
|
//
|
|
31
|
-
// Intent values
|
|
31
|
+
// Intent values: 'create' | 'fullstack' | 'review' | 'neutral' (status/clear); every
|
|
32
|
+
// consumer compares by equality with 'create' / 'fullstack', so the added values are
|
|
33
|
+
// non-matching by construction. An exact
|
|
32
34
|
// `handoff-implement` invocation CLAIMS the run (the fullstack command says so
|
|
33
35
|
// itself), so it maps onto the existing 'fullstack' intent instead of adding a
|
|
34
36
|
// new enum every consumer would have to learn (TASK-C97-004).
|
|
@@ -48,7 +50,7 @@ export const HANDOFF_RESUME_BANNER = 'UKIT HANDOFF RESUME — an unfinished hand
|
|
|
48
50
|
|
|
49
51
|
// Sweeper for "which command tag came LAST in this entry": finds every exact
|
|
50
52
|
// command-name tag occurrence with its position so the newest one wins.
|
|
51
|
-
const COMMAND_TAG_SWEEP = /<command-name>\/?(?:ukit:)?handoff-(create|fullstack|implement)(?![\w-])/g;
|
|
53
|
+
const COMMAND_TAG_SWEEP = /<command-name>\/?(?:ukit:)?handoff-(create|fullstack|implement|review|status|clear)(?![\w-])/g;
|
|
52
54
|
|
|
53
55
|
// omp and Codex do NOT wrap a slash command in `<command-name>` tags the way
|
|
54
56
|
// Claude Code's user turn does. Their native evidence is the command *expansion*:
|
|
@@ -58,7 +60,7 @@ const COMMAND_TAG_SWEEP = /<command-name>\/?(?:ukit:)?handoff-(create|fullstack|
|
|
|
58
60
|
// the FIRST line only (not any heading in the body) keeps prose references to the
|
|
59
61
|
// command inside the document from being mistaken for an invocation. The same
|
|
60
62
|
// exact token boundary applies: `-custom` suffixed headings are not commands.
|
|
61
|
-
const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack|handoff-implement)(?![\w-])/;
|
|
63
|
+
const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack|handoff-implement|handoff-review|handoff-status|handoff-clear)(?![\w-])/;
|
|
62
64
|
|
|
63
65
|
// The unique customType the omp bridge stamps on host-injected UKit hook context
|
|
64
66
|
// (`sendContext` → `hookContextMessage`). A resume banner only counts as intent
|
|
@@ -89,6 +91,10 @@ function commandTokenIntent(token) {
|
|
|
89
91
|
const t = String(token).toLowerCase().replace(/^handoff-/, '');
|
|
90
92
|
if (t === 'create') return 'create';
|
|
91
93
|
if (t === 'fullstack' || t === 'implement') return 'fullstack';
|
|
94
|
+
// review is its own phase (scoped fix + re-review); status/clear are neutral. They
|
|
95
|
+
// must SUPERSEDE an older create, otherwise the create-scope guard leaks forward.
|
|
96
|
+
if (t === 'review') return 'review';
|
|
97
|
+
if (t === 'status' || t === 'clear') return 'neutral';
|
|
92
98
|
return null;
|
|
93
99
|
}
|
|
94
100
|
|
|
@@ -182,14 +188,14 @@ function classifyAssistantBlocks(blocks) {
|
|
|
182
188
|
if (!b || typeof b !== 'object') continue;
|
|
183
189
|
if (b.type === 'tool_use' && b.name === 'Skill') {
|
|
184
190
|
const skill = String(b.input?.skill ?? '');
|
|
185
|
-
const m = /^(?:ukit:)?handoff-(create|fullstack|implement)$/.exec(skill);
|
|
191
|
+
const m = /^(?:ukit:)?handoff-(create|fullstack|implement|review|status|clear)$/.exec(skill);
|
|
186
192
|
if (m) latest = commandTokenIntent(m[1]);
|
|
187
193
|
continue;
|
|
188
194
|
}
|
|
189
195
|
if (b.type === 'toolCall') {
|
|
190
196
|
const name = String(b.name ?? '');
|
|
191
197
|
const skill = String(b.arguments?.skill ?? '');
|
|
192
|
-
const m = /^(?:ukit:)?handoff-(create|fullstack|implement)$/i.exec(name) || /^(?:ukit:)?handoff-(create|fullstack|implement)$/.exec(skill);
|
|
198
|
+
const m = /^(?:ukit:)?handoff-(create|fullstack|implement|review|status|clear)$/i.exec(name) || /^(?:ukit:)?handoff-(create|fullstack|implement|review|status|clear)$/.exec(skill);
|
|
193
199
|
if (m) latest = commandTokenIntent(m[1]);
|
|
194
200
|
}
|
|
195
201
|
}
|
|
@@ -307,7 +313,7 @@ export async function transcriptHandoffIntent(transcriptPath, {
|
|
|
307
313
|
// Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
|
|
308
314
|
// `handoff-implement` is included: an implement invocation after a create
|
|
309
315
|
// must be seen as the NEWEST intent, not lost to the older create line.
|
|
310
|
-
if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('handoff-implement') && !line.includes('HANDOFF RESUME')) {
|
|
316
|
+
if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('handoff-implement') && !line.includes('handoff-review') && !line.includes('handoff-status') && !line.includes('handoff-clear') && !line.includes('HANDOFF RESUME')) {
|
|
311
317
|
continue;
|
|
312
318
|
}
|
|
313
319
|
// This is the NEWEST line carrying intent evidence. A host append can be
|
|
@@ -410,3 +416,113 @@ export function runCursorPaused(text) {
|
|
|
410
416
|
if (/^paused\b/i.test(field('Phase'))) return true;
|
|
411
417
|
return /\bPAUSED\b/.test(field('Cursor')) || /\bPAUSED\b/.test(field('Next'));
|
|
412
418
|
}
|
|
419
|
+
|
|
420
|
+
// ─── Run-all intent (goal-level loop phrase) ──────────────────────────────
|
|
421
|
+
// A run-all instruction ("run all remaining tasks", "finish everything",
|
|
422
|
+
// "chạy hết các task còn lại") binds the GOAL, not one cycle: the executor
|
|
423
|
+
// keeps cycling until no in-scope task remains. This is TEXT evidence for the
|
|
424
|
+
// goal loop ONLY — it never feeds classifyHandoffEntry, whose structured-only
|
|
425
|
+
// rule (TASK-007) keeps raw phrases from minting command ownership, so
|
|
426
|
+
// "handoff-create + run-all" stays planning. Every alternative below demands a
|
|
427
|
+
// totality quantifier (all/every/everything/hết/toàn bộ/…) next to the task
|
|
428
|
+
// word, so a single-task prompt ("run task 3", "TASK-003") never matches.
|
|
429
|
+
export const RUN_ALL_RE = new RegExp(
|
|
430
|
+
[
|
|
431
|
+
// EN verb … all/every … tasks ("run all remaining tasks", "execute every open task").
|
|
432
|
+
String.raw`\b(?:run|execute|do|process|implement|finish|complete|handle|work\s+through)\b[^.\n]{0,40}?\b(?:all|every)\b[^.\n]{0,40}?\btasks?\b`,
|
|
433
|
+
// EN bare quantifier over tasks ("all remaining tasks", "every queued task", "all of the tasks").
|
|
434
|
+
String.raw`\b(?:all|every)\s+(?:the\s+)?(?:(?:remaining|pending|open|unfinished|authorized|queued|outstanding|in-scope)\s+)?tasks?\b|\b(?:all|every)\b[^.\n]{0,20}?\bof\s+the\b[^.\n]{0,20}?\btasks?\b`,
|
|
435
|
+
// EN totality idioms ("run all", "finish everything", "complete the whole goal",
|
|
436
|
+
// "continue until no tasks remain", "until all tasks are done").
|
|
437
|
+
String.raw`\brun\s+(?:all|everything)\b(?!\s+(?:of\s+)?(?:the\s+)?(?:(?:unit|e2e|integration|smoke)\s+)?(?:tests?|specs?|checks?|lint|builds?|suites?|migrations?)\b)|\b(?:finish|complete|do)\s+(?:everything|all\s+of\s+it|the\s+(?:whole|entire)\s+(?:goal|backlog|list|plan))\b|\b(?:continue|keep\s+going|work)\s+until\s+(?:all\s+tasks?\s+(?:are\s+)?done|no\s+tasks?\s+(?:remain|are\s+left))\b|\buntil\s+(?:all\s+)?tasks?\s+(?:are\s+)?(?:done|complete[d]?|finished)\b|\bcontinue\s+until\s+nothing\s+(?:is\s+)?left\b`,
|
|
438
|
+
// VN (diacritic + ascii fallbacks): chạy hết / chạy tất cả task / làm hết các task còn lại /
|
|
439
|
+
// hoàn thành toàn bộ / cho đến khi không còn task.
|
|
440
|
+
String.raw`ch(?:ạy|ay)\s+h(?:ế|e)t|ch(?:ạy|ay)\s+t(?:ấ|a)t\s+c(?:ả|a)|l(?:à|a)m\s+h(?:ế|e)t|ho(?:à|a)n\s+th(?:à|a)nh\s+(?:to(?:à|a)n\s+b(?:ộ|o)|h(?:ế|e)t)|t(?:ấ|a)t\s+c(?:ả|a)\s+(?:c(?:á|a)c\s+)?tasks?|to(?:à|a)n\s+b(?:ộ|o)\s+(?:c(?:á|a)c\s+)?tasks?|cho\s+đ(?:ế|e)n\s+khi\s+kh(?:ô|o)ng\s+c(?:ò|o)n\s+tasks?`,
|
|
441
|
+
].join('|'),
|
|
442
|
+
'iu',
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Goal-level loop phrase detector. Pure text matching — deliberately separate
|
|
447
|
+
* from the structured intent classification above.
|
|
448
|
+
* @returns {boolean}
|
|
449
|
+
*/
|
|
450
|
+
export function isRunAllRequest(text) {
|
|
451
|
+
const t = String(text ?? '');
|
|
452
|
+
if (!t.trim()) return false;
|
|
453
|
+
// Read/list requests and plan-only / negated asks never authorize a backlog run.
|
|
454
|
+
if (RUN_ALL_READ_RE.test(t) || RUN_ALL_VETO_RE.test(t)) return false;
|
|
455
|
+
if (SINGLE_TASK_REF_RE.test(t)) return false;
|
|
456
|
+
return RUN_ALL_RE.test(t);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const RUN_ALL_READ_RE = /^\s*(?:please\s+)?(?:show|list|view|display|print|count|xem|liệt\s+kê|hiển\s+thị)\b/iu;
|
|
460
|
+
const RUN_ALL_VETO_RE = new RegExp(
|
|
461
|
+
[
|
|
462
|
+
String.raw`\b(?:do\s+not|don'?t|dont|never|no\s+need\s+to|without|not)\b[^.\n]{0,30}?\b(?:run|execute|do|process|implement|finish|complete|handle)\b`,
|
|
463
|
+
String.raw`\b(?:plan|planning)[\s-]+only\b|\bonly\s+(?:create|write|make|draft)\s+(?:a\s+|the\s+)?plan`,
|
|
464
|
+
String.raw`(?:kh(?:ô|o)ng|đ(?:ừ|u)ng|dung|ch(?:ư|u)a)\s+(?:được\s+|duoc\s+|cần\s+|can\s+)?(?:ch(?:ạ|a)y|l(?:à|a)m|th(?:ự|u)c\s+hi(?:ệ|e)n|implement)`,
|
|
465
|
+
String.raw`ch(?:ỉ|i)\s+(?:t(?:ạ|a)o|vi(?:ế|e)t|l(?:ậ|a)p)\s+(?:plan|k(?:ế|e)\s+ho(?:ạ|a)ch)`,
|
|
466
|
+
].join('|'),
|
|
467
|
+
'iu',
|
|
468
|
+
);
|
|
469
|
+
const SINGLE_TASK_REF_RE = /\b(?:task|việc|viec)[\s_-]*#?\d+\b|\bTASK-\d+\b/i;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Shared phase lookup for BOTH Stop paths (Claude `execution-ledger --evaluate-stop` and the
|
|
473
|
+
* omp bridge's in-process `runSessionStop`). Returns the current intent only when the scan
|
|
474
|
+
* is conclusive and it is one of create/review/neutral — the phases that change what the
|
|
475
|
+
* completion gate may demand. fullstack/implement, unknown and unreadable all return null,
|
|
476
|
+
* which keeps the normal (strict) gate: a lost read never relaxes enforcement.
|
|
477
|
+
*/
|
|
478
|
+
export async function resolveStopPhase(transcriptPath, opts = {}) {
|
|
479
|
+
try {
|
|
480
|
+
const { intent, known } = await transcriptHandoffIntent(transcriptPath, opts);
|
|
481
|
+
if (!known) return null;
|
|
482
|
+
return intent === 'create' || intent === 'review' || intent === 'neutral' ? intent : null;
|
|
483
|
+
} catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Subagent inheritance. A delegated omp session is its own transcript (own session id, no command
|
|
489
|
+
// tag) whose first record is `{type:"session", parentSession:<parent transcript path>}`; the
|
|
490
|
+
// parent's command authority must follow the delegation. Walks at most `maxDepth` parents, each
|
|
491
|
+
// scan bounded by the normal budget; a missing/unreadable link is `known:false` (never relaxed).
|
|
492
|
+
export async function transcriptHandoffIntentInherited(transcriptPath, { maxDepth = 3, ...opts } = {}) {
|
|
493
|
+
let current = transcriptPath;
|
|
494
|
+
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
|
495
|
+
const scan = await transcriptHandoffIntent(current, opts);
|
|
496
|
+
// A parent link that cannot be read is reported (never relaxed): the caller fails closed
|
|
497
|
+
// against the parent's session marker instead of treating the child as unrestricted.
|
|
498
|
+
if (!scan.known) return depth > 0 ? { ...scan, unresolvedParent: current } : scan;
|
|
499
|
+
if (scan.intent) return scan;
|
|
500
|
+
const parent = await readParentSession(current);
|
|
501
|
+
if (!parent) return scan;
|
|
502
|
+
current = parent;
|
|
503
|
+
}
|
|
504
|
+
return { intent: null, known: false, unresolvedParent: current };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function readParentSession(transcriptPath) {
|
|
508
|
+
let handle;
|
|
509
|
+
try {
|
|
510
|
+
handle = await fs.open(transcriptPath, 'r');
|
|
511
|
+
const buf = Buffer.allocUnsafe(8192);
|
|
512
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
513
|
+
for (const line of buf.toString('utf8', 0, bytesRead).split('\n').slice(0, 4)) {
|
|
514
|
+
if (!line.includes('"parentSession"')) continue;
|
|
515
|
+
try {
|
|
516
|
+
const rec = JSON.parse(line);
|
|
517
|
+
if (rec?.type === 'session' && typeof rec.parentSession === 'string' && rec.parentSession.trim()) {
|
|
518
|
+
return rec.parentSession;
|
|
519
|
+
}
|
|
520
|
+
} catch { /* header larger than the probe window: treat as no link */ }
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
} catch {
|
|
524
|
+
return null;
|
|
525
|
+
} finally {
|
|
526
|
+
await handle?.close().catch(() => {});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
@@ -697,6 +697,393 @@ export async function resumableRunSourceFingerprint(projectRoot, { indexGenerate
|
|
|
697
697
|
};
|
|
698
698
|
}
|
|
699
699
|
|
|
700
|
+
// --- per-task execution claims (goal-loop duplicate-execution guard) -----------
|
|
701
|
+
//
|
|
702
|
+
// A goal-level run-all loop resumes by re-reading persisted state; without a
|
|
703
|
+
// claim book, a stale resume (or two parallel resumes after a compact) can
|
|
704
|
+
// execute the same task twice. A claim is `{ owner, heartbeat, ttlMs }` recorded
|
|
705
|
+
// per safeTaskId in `.ukit/storage/runs/claims.json` — a SEPARATE file, so
|
|
706
|
+
// previously written C10 records stay byte-compatible and their validation and
|
|
707
|
+
// journaling semantics are untouched. Heartbeats keep a claim alive; a claim
|
|
708
|
+
// whose heartbeat is older than its TTL may be taken over. Every mutation is
|
|
709
|
+
// lock-scoped through the same withAsyncLock discipline as the record itself
|
|
710
|
+
// and published with the atomic tmp+rename write. A lock-busy acquisition fails
|
|
711
|
+
// CLOSED (no claim = no execution right): two claimers must never both proceed
|
|
712
|
+
// on a contended lock.
|
|
713
|
+
|
|
714
|
+
export const TASK_CLAIMS_SCHEMA_VERSION = 1;
|
|
715
|
+
export const DEFAULT_TASK_CLAIM_TTL_MS = 15 * 60 * 1000;
|
|
716
|
+
const CLAIM_OWNER_MAX = 128;
|
|
717
|
+
|
|
718
|
+
export function taskClaimsPath(projectRoot) {
|
|
719
|
+
return path.join(buildRuntimePaths(projectRoot).storageRoot, 'runs', 'claims.json');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function normalizeClaimOwner(owner) {
|
|
723
|
+
const token = typeof owner === 'string' && owner.trim()
|
|
724
|
+
? owner.trim().slice(0, CLAIM_OWNER_MAX)
|
|
725
|
+
: `pid-${process.pid}-${crypto.randomUUID().slice(0, 8)}`;
|
|
726
|
+
return token;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function claimActive(claim, now) {
|
|
730
|
+
const heartbeat = Number(claim?.heartbeat);
|
|
731
|
+
const ttl = Number(claim?.ttlMs);
|
|
732
|
+
return Number.isFinite(heartbeat) && Number.isFinite(ttl) && ttl >= 0
|
|
733
|
+
? now - heartbeat <= ttl
|
|
734
|
+
: false;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Read the claims map. Missing file → empty map; corrupt file → `ok:false`
|
|
738
|
+
// (callers fail closed on mutations, report honestly on reads).
|
|
739
|
+
async function readClaimsMap(projectRoot) {
|
|
740
|
+
try {
|
|
741
|
+
const parsed = JSON.parse(await fs.readFile(taskClaimsPath(projectRoot), 'utf8'));
|
|
742
|
+
const claims = isPlainObject(parsed?.claims) ? parsed.claims : {};
|
|
743
|
+
return { ok: true, claims };
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (error?.code === 'ENOENT') return { ok: true, claims: {} };
|
|
746
|
+
return { ok: false, reason: 'claims-unreadable' };
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function writeClaimsMap(projectRoot, claims) {
|
|
751
|
+
const target = taskClaimsPath(projectRoot);
|
|
752
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
753
|
+
await writeJsonAtomic(target, { schemaVersion: TASK_CLAIMS_SCHEMA_VERSION, claims });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Claim exclusive execution of `taskId`. Idempotent for the SAME owner (the
|
|
758
|
+
* heartbeat is refreshed); a second owner inside the TTL gets
|
|
759
|
+
* `{ ok: false, reason: 'active-claim' }`; an expired claim can be taken over.
|
|
760
|
+
*
|
|
761
|
+
* @returns {Promise<{ok:true, claim:{owner,heartbeat,ttlMs}, refreshed?:boolean, tookOver?:boolean}
|
|
762
|
+
* | {ok:false, reason:string, claim?:object}>}
|
|
763
|
+
*/
|
|
764
|
+
export async function acquireTaskClaim(projectRoot, taskId, {
|
|
765
|
+
owner = null,
|
|
766
|
+
sessionId = null,
|
|
767
|
+
ttlMs = DEFAULT_TASK_CLAIM_TTL_MS,
|
|
768
|
+
now = Date.now(),
|
|
769
|
+
deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
|
|
770
|
+
} = {}) {
|
|
771
|
+
const key = safeTaskId(taskId);
|
|
772
|
+
const ownerToken = normalizeClaimOwner(owner);
|
|
773
|
+
const ttl = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : DEFAULT_TASK_CLAIM_TTL_MS;
|
|
774
|
+
try {
|
|
775
|
+
const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
|
|
776
|
+
const read = await readClaimsMap(projectRoot);
|
|
777
|
+
if (!read.ok) return { ok: false, reason: read.reason };
|
|
778
|
+
const claims = read.claims;
|
|
779
|
+
const existing = claims[key];
|
|
780
|
+
if (isPlainObject(existing) && claimActive(existing, now)) {
|
|
781
|
+
if (existing.owner === ownerToken) {
|
|
782
|
+
claims[key] = { ...existing, heartbeat: now, ttlMs: existing.ttlMs ?? ttl };
|
|
783
|
+
await writeClaimsMap(projectRoot, claims);
|
|
784
|
+
return { ok: true, claim: { ...claims[key] }, refreshed: true };
|
|
785
|
+
}
|
|
786
|
+
return { ok: false, reason: 'active-claim', claim: { ...existing } };
|
|
787
|
+
}
|
|
788
|
+
// `generation` is the fencing token: every fresh grant or takeover bumps it, so a result
|
|
789
|
+
// committed by an owner whose claim lapsed can be told apart from the current holder's.
|
|
790
|
+
const generation = (Number(existing?.generation) || 0) + 1;
|
|
791
|
+
const claim = {
|
|
792
|
+
owner: ownerToken,
|
|
793
|
+
heartbeat: now,
|
|
794
|
+
ttlMs: ttl,
|
|
795
|
+
generation,
|
|
796
|
+
...(sessionId ? { sessionId: String(sessionId) } : {}),
|
|
797
|
+
};
|
|
798
|
+
claims[key] = claim;
|
|
799
|
+
await writeClaimsMap(projectRoot, claims);
|
|
800
|
+
return {
|
|
801
|
+
ok: true,
|
|
802
|
+
claim: { ...claim },
|
|
803
|
+
...(isPlainObject(existing) ? { tookOver: true } : {}),
|
|
804
|
+
};
|
|
805
|
+
});
|
|
806
|
+
// withAsyncLock wraps the callback result as { ok, value } — unwrap it; a
|
|
807
|
+
// lock failure (ok:false, no value) fails closed below.
|
|
808
|
+
if (outcome?.ok === true && isPlainObject(outcome.value)) return outcome.value;
|
|
809
|
+
return { ok: false, reason: outcome?.value?.reason ?? outcome?.reason ?? 'busy' };
|
|
810
|
+
} catch {
|
|
811
|
+
return { ok: false, reason: 'busy' }; // fail closed: no claim, no execution right
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Refresh the heartbeat of a claim this owner still holds. Never creates one. */
|
|
816
|
+
export async function heartbeatTaskClaim(projectRoot, taskId, {
|
|
817
|
+
owner = null,
|
|
818
|
+
now = Date.now(),
|
|
819
|
+
deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
|
|
820
|
+
} = {}) {
|
|
821
|
+
const key = safeTaskId(taskId);
|
|
822
|
+
const ownerToken = normalizeClaimOwner(owner);
|
|
823
|
+
try {
|
|
824
|
+
const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
|
|
825
|
+
const read = await readClaimsMap(projectRoot);
|
|
826
|
+
if (!read.ok) return { ok: false, reason: read.reason };
|
|
827
|
+
const existing = read.claims[key];
|
|
828
|
+
if (!isPlainObject(existing)) return { ok: false, reason: 'no-claim' };
|
|
829
|
+
if (existing.owner !== ownerToken) return { ok: false, reason: 'owner-mismatch' };
|
|
830
|
+
read.claims[key] = { ...existing, heartbeat: now };
|
|
831
|
+
await writeClaimsMap(projectRoot, read.claims);
|
|
832
|
+
return { ok: true, claim: { ...read.claims[key] } };
|
|
833
|
+
});
|
|
834
|
+
if (outcome?.ok === true && isPlainObject(outcome.value)) return outcome.value;
|
|
835
|
+
return { ok: false, reason: outcome?.value?.reason ?? outcome?.reason ?? 'busy' };
|
|
836
|
+
} catch {
|
|
837
|
+
return { ok: false, reason: 'busy' };
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Release a claim. Only the owner can remove it; absent claims release clean. */
|
|
842
|
+
export async function releaseTaskClaim(projectRoot, taskId, {
|
|
843
|
+
owner = null,
|
|
844
|
+
deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
|
|
845
|
+
} = {}) {
|
|
846
|
+
const key = safeTaskId(taskId);
|
|
847
|
+
const ownerToken = normalizeClaimOwner(owner);
|
|
848
|
+
try {
|
|
849
|
+
const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
|
|
850
|
+
const read = await readClaimsMap(projectRoot);
|
|
851
|
+
if (!read.ok) return { ok: false, reason: read.reason };
|
|
852
|
+
const existing = read.claims[key];
|
|
853
|
+
if (!isPlainObject(existing)) return { ok: true, released: false };
|
|
854
|
+
if (existing.owner !== ownerToken) return { ok: false, reason: 'owner-mismatch' };
|
|
855
|
+
delete read.claims[key];
|
|
856
|
+
await writeClaimsMap(projectRoot, read.claims);
|
|
857
|
+
return { ok: true, released: true };
|
|
858
|
+
});
|
|
859
|
+
if (outcome?.ok === true && isPlainObject(outcome.value)) return outcome.value;
|
|
860
|
+
return { ok: false, reason: outcome?.value?.reason ?? outcome?.reason ?? 'busy' };
|
|
861
|
+
} catch {
|
|
862
|
+
return { ok: false, reason: 'busy' };
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Read one claim: `{ claimed: true, active, claim }` when present, else
|
|
868
|
+
* `{ claimed: false }`. Lock-free read — the atomic publish means a reader sees
|
|
869
|
+
* the old or the new map, never a torn one.
|
|
870
|
+
*/
|
|
871
|
+
export async function readTaskClaim(projectRoot, taskId, { now = Date.now() } = {}) {
|
|
872
|
+
const read = await readClaimsMap(projectRoot);
|
|
873
|
+
if (!read.ok) return { claimed: false, unreadable: true };
|
|
874
|
+
const existing = read.claims[safeTaskId(taskId)];
|
|
875
|
+
if (!isPlainObject(existing)) return { claimed: false };
|
|
876
|
+
return { claimed: true, active: claimActive(existing, now), claim: { ...existing } };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ─── delegation → claim wiring ──────────────────────────────────────────────
|
|
880
|
+
// The PreToolUse delegation guard acquires, the PostToolUse receipt hook releases. Only
|
|
881
|
+
// write-capable executor agents claim (a reviewer/planner touching the same task must not
|
|
882
|
+
// be blocked by — or block — the executor), and only a task id that names ONE task: the
|
|
883
|
+
// FIRST `TASK-…` in the delegation text is the primary task (later ids are dependencies).
|
|
884
|
+
export const CLAIMING_AGENT_TYPES = new Set(['feature-implementer', 'bug-debugger']);
|
|
885
|
+
export const DELEGATED_CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
886
|
+
const DELEGATED_TASK_ID_RE = /\bTASK-(?:C\d+-)?\d+\b/gi;
|
|
887
|
+
// A TASK id directly after an execution verb is the delegation's primary task; ids elsewhere in
|
|
888
|
+
// the text are dependencies or context ("after TASK-001", "see TASK-004").
|
|
889
|
+
const DELEGATED_PRIMARY_RE = /\b(?:execute|implement|run|do|fix|complete|finish|work\s+on|resume|retry|start)\s+(?:the\s+)?(?:task\s+)?(TASK-(?:C\d+-)?\d+)\b/i;
|
|
890
|
+
|
|
891
|
+
function structuredTaskId(source) {
|
|
892
|
+
const meta = isPlainObject(source?.metadata) ? source.metadata : {};
|
|
893
|
+
const raw = source?.task_id ?? source?.taskId ?? meta.task_id ?? meta.taskId;
|
|
894
|
+
return typeof raw === 'string' && /^TASK-(?:C\d+-)?\d+$/i.test(raw.trim()) ? raw.trim().toUpperCase() : null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Resolve the ONE task a delegation executes. Authority order: structured id field > id after an
|
|
899
|
+
* execution verb > the sole distinct id > (several ids, no verb) the first id the caller can
|
|
900
|
+
* validate against the task store. Anything unresolvable claims nothing rather than guessing.
|
|
901
|
+
*/
|
|
902
|
+
function resolveDelegatedTaskId(entry, isKnownTask) {
|
|
903
|
+
if (entry.structured) return entry.structured;
|
|
904
|
+
const verbLed = DELEGATED_PRIMARY_RE.exec(entry.text);
|
|
905
|
+
if (verbLed) return verbLed[1].toUpperCase();
|
|
906
|
+
const ids = [...new Set((entry.text.match(DELEGATED_TASK_ID_RE) || []).map((id) => id.toUpperCase()))];
|
|
907
|
+
if (ids.length === 1) return ids[0];
|
|
908
|
+
if (ids.length > 1 && typeof isKnownTask === 'function') return ids.find((id) => isKnownTask(id)) ?? null;
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** @returns {Array<{taskId:string, agent:string}>} */
|
|
913
|
+
export function delegatedClaimTargets(toolInput, { isKnownTask = null } = {}) {
|
|
914
|
+
const input = isPlainObject(toolInput) ? toolInput : {};
|
|
915
|
+
const entries = Array.isArray(input.tasks) && input.tasks.length
|
|
916
|
+
? input.tasks.map((t) => ({
|
|
917
|
+
agent: t?.agent || t?.subagent_type,
|
|
918
|
+
structured: structuredTaskId(t) || structuredTaskId(input),
|
|
919
|
+
text: [t?.task, t?.name, t?.prompt, t?.description].filter((v) => typeof v === 'string').join('\n'),
|
|
920
|
+
}))
|
|
921
|
+
: [{
|
|
922
|
+
agent: input.subagent_type || input.agent || input.agent_type,
|
|
923
|
+
structured: structuredTaskId(input),
|
|
924
|
+
text: [input.prompt, input.description, input.task].filter((v) => typeof v === 'string').join('\n'),
|
|
925
|
+
}];
|
|
926
|
+
const targets = [];
|
|
927
|
+
const seen = new Set();
|
|
928
|
+
for (const entry of entries) {
|
|
929
|
+
const agent = String(entry.agent ?? '').toLowerCase();
|
|
930
|
+
if (!CLAIMING_AGENT_TYPES.has(agent)) continue;
|
|
931
|
+
const taskId = resolveDelegatedTaskId(entry, isKnownTask);
|
|
932
|
+
if (taskId && !seen.has(taskId)) {
|
|
933
|
+
seen.add(taskId);
|
|
934
|
+
targets.push({ taskId, agent });
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return targets;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Stable owner token for one delegation: the tool-call id, else the session id. */
|
|
941
|
+
export function delegatedClaimOwner(payload) {
|
|
942
|
+
const id = payload?.tool_use_id || payload?.session_id;
|
|
943
|
+
return typeof id === 'string' && id ? `deleg:${id}` : null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Lease renewal: a delegated agent that runs longer than the claim TTL keeps its claim alive
|
|
948
|
+
* through its own session activity. Any tool event of the owning session (the parent's hooks
|
|
949
|
+
* fire for subagent tool calls too) refreshes that session's active claims, so a LIVE task is
|
|
950
|
+
* never handed to a second agent by TTL alone; only a session that went silent for a full TTL
|
|
951
|
+
* loses its claim. Lock-free pre-check keeps the common call (nothing due) free of I/O writes.
|
|
952
|
+
*/
|
|
953
|
+
export async function renewSessionClaims(projectRoot, sessionId, {
|
|
954
|
+
now = Date.now(),
|
|
955
|
+
deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
|
|
956
|
+
} = {}) {
|
|
957
|
+
const sid = typeof sessionId === 'string' ? sessionId : '';
|
|
958
|
+
if (!sid) return { renewed: 0 };
|
|
959
|
+
const due = (claim) => isPlainObject(claim) && claim.sessionId === sid && claimActive(claim, now)
|
|
960
|
+
&& now - Number(claim.heartbeat) > Number(claim.ttlMs) / 10;
|
|
961
|
+
const peek = await readClaimsMap(projectRoot);
|
|
962
|
+
if (!peek.ok || !Object.values(peek.claims).some(due)) return { renewed: 0 };
|
|
963
|
+
try {
|
|
964
|
+
const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
|
|
965
|
+
const read = await readClaimsMap(projectRoot);
|
|
966
|
+
if (!read.ok) return { renewed: 0 };
|
|
967
|
+
let renewed = 0;
|
|
968
|
+
for (const [key, claim] of Object.entries(read.claims)) {
|
|
969
|
+
if (due(claim)) {
|
|
970
|
+
read.claims[key] = { ...claim, heartbeat: now };
|
|
971
|
+
renewed += 1;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (renewed) await writeClaimsMap(projectRoot, read.claims);
|
|
975
|
+
return { renewed };
|
|
976
|
+
});
|
|
977
|
+
return outcome?.ok === true && isPlainObject(outcome.value) ? outcome.value : { renewed: 0 };
|
|
978
|
+
} catch {
|
|
979
|
+
return { renewed: 0 };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Fence check for result commits: true only while `owner` still holds an ACTIVE claim on the
|
|
985
|
+
* task (and, when given, at the same generation). An owner whose lease lapsed and was taken over
|
|
986
|
+
* must not commit — the new holder's generation differs.
|
|
987
|
+
*/
|
|
988
|
+
export async function verifyTaskClaimFence(projectRoot, taskId, { owner, generation = null, now = Date.now() } = {}) {
|
|
989
|
+
const read = await readTaskClaim(projectRoot, taskId, { now });
|
|
990
|
+
if (!read.claimed || !read.active) return false;
|
|
991
|
+
if (read.claim.owner !== owner) return false;
|
|
992
|
+
return generation === null || read.claim.generation === generation;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Count the ACTIVE claims in the registry — the `agents:none-running` exit
|
|
997
|
+
* predicate's input. Returns null only when the registry exists but cannot be
|
|
998
|
+
* read (unknown state must read as unmet, never as "none running").
|
|
999
|
+
*/
|
|
1000
|
+
export async function countActiveTaskClaims(projectRoot, { now = Date.now() } = {}) {
|
|
1001
|
+
const read = await readClaimsMap(projectRoot);
|
|
1002
|
+
if (!read.ok) return null;
|
|
1003
|
+
return Object.values(read.claims).filter((claim) => claimActive(claim, now)).length;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// --- Archive-time status snapshot ---------------------------------------------------
|
|
1007
|
+
// INDEX.md owns the task lifecycle; a task file's own `- Status:` header is executor-written
|
|
1008
|
+
// and goes stale (`ready`) once the task is archived. `archiveCycleSnapshot` persists the
|
|
1009
|
+
// verified terminal INDEX rows beside the archived task files (`archive/cycle-NN/INDEX.md`),
|
|
1010
|
+
// which is what the Stop predicate (stop-coordinator `evalFinalizedCycles`) reads. Idempotent,
|
|
1011
|
+
// crash-safe (run it BEFORE the INDEX reset; a retry after a partial archive just re-derives),
|
|
1012
|
+
// and fail-closed: a non-terminal live row is recorded as-is, never promoted.
|
|
1013
|
+
|
|
1014
|
+
const TERMINAL_TASK_STATUSES = new Set(['done', 'cancelled_superseded']);
|
|
1015
|
+
|
|
1016
|
+
function indexRows(text) {
|
|
1017
|
+
const rows = new Map();
|
|
1018
|
+
for (const line of String(text).split('\n')) {
|
|
1019
|
+
const row = line.trim();
|
|
1020
|
+
if (!/^\|\s*TASK-/i.test(row)) continue;
|
|
1021
|
+
const cells = row.split('|').slice(1, -1).map((c) => c.trim()); // keep empty cells: columns must not shift
|
|
1022
|
+
rows.set(String(cells[0]).toUpperCase(), { cells, status: String(cells[4] || '').toLowerCase() });
|
|
1023
|
+
}
|
|
1024
|
+
return rows;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Snapshot the live INDEX rows of the tasks that belong to `archive/cycle-<NN>/` (task files
|
|
1029
|
+
* already moved there, or still in `tasks/` and about to move) and stamp each archived task
|
|
1030
|
+
* header with its terminal INDEX status. Returns `{ ok, cycle, snapshotted, stamped, unchanged }`.
|
|
1031
|
+
*/
|
|
1032
|
+
export async function archiveCycleSnapshot(projectRoot, cycle) {
|
|
1033
|
+
const nn = String(cycle).padStart(2, '0');
|
|
1034
|
+
if (!/^\d{2,}$/.test(nn)) return { ok: false, reason: 'bad-cycle' };
|
|
1035
|
+
const ai = path.join(projectRoot, 'docs', 'AI_HANDOFF');
|
|
1036
|
+
const dir = path.join(ai, 'archive', `cycle-${nn}`);
|
|
1037
|
+
const snapPath = path.join(dir, 'INDEX.md');
|
|
1038
|
+
let live = new Map();
|
|
1039
|
+
try { live = indexRows(await fs.readFile(path.join(ai, 'INDEX.md'), 'utf8')); } catch { /* header-only / missing → rely on prior snapshot */ }
|
|
1040
|
+
let prior = new Map();
|
|
1041
|
+
try { prior = indexRows(await fs.readFile(snapPath, 'utf8')); } catch { /* first run */ }
|
|
1042
|
+
// Only task files already inside THIS cycle's archive folder are snapshotted (F3 moves them
|
|
1043
|
+
// first). Task ids restart per cycle, so the live `tasks/` and live INDEX rows of a LATER
|
|
1044
|
+
// cycle must never be attributed here: an existing snapshot row is immutable (prior wins);
|
|
1045
|
+
// a live row only fills an id that has no snapshot row yet.
|
|
1046
|
+
const ids = new Set();
|
|
1047
|
+
try {
|
|
1048
|
+
for (const f of await fs.readdir(dir)) {
|
|
1049
|
+
if (/^TASK-[\w.-]+\.md$/i.test(f)) ids.add(f.replace(/\.md$/i, '').toUpperCase());
|
|
1050
|
+
}
|
|
1051
|
+
} catch { /* absent dir */ }
|
|
1052
|
+
if (ids.size === 0) return { ok: false, reason: 'no-task-files' };
|
|
1053
|
+
const merged = new Map();
|
|
1054
|
+
for (const id of [...ids].sort()) {
|
|
1055
|
+
const row = prior.get(id) || live.get(id);
|
|
1056
|
+
if (row) merged.set(id, row);
|
|
1057
|
+
}
|
|
1058
|
+
const header = '# Archived INDEX snapshot (cycle ' + nn + ')\n\n'
|
|
1059
|
+
+ '| ID | Title | Priority | Size | Status | Owner | Reviewer | File |\n'
|
|
1060
|
+
+ '|----|-------|----------|------|--------|-------|----------|------|\n';
|
|
1061
|
+
const body = [...merged.values()].map((r) => `| ${r.cells.join(' | ')} |`).join('\n');
|
|
1062
|
+
const next = `${header}${body}${body ? '\n' : ''}`;
|
|
1063
|
+
let unchanged = true;
|
|
1064
|
+
let current = null;
|
|
1065
|
+
try { current = await fs.readFile(snapPath, 'utf8'); } catch { /* none */ }
|
|
1066
|
+
if (merged.size > 0 && current !== next) {
|
|
1067
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1068
|
+
const tmp = `${snapPath}.${process.pid}.tmp`;
|
|
1069
|
+
await fs.writeFile(tmp, next, 'utf8');
|
|
1070
|
+
await fs.rename(tmp, snapPath);
|
|
1071
|
+
unchanged = false;
|
|
1072
|
+
}
|
|
1073
|
+
let stamped = 0;
|
|
1074
|
+
for (const [id, row] of merged) {
|
|
1075
|
+
if (!TERMINAL_TASK_STATUSES.has(row.status)) continue;
|
|
1076
|
+
const file = path.join(dir, `${id}.md`);
|
|
1077
|
+
let text;
|
|
1078
|
+
try { text = await fs.readFile(file, 'utf8'); } catch { continue; }
|
|
1079
|
+
const m = text.match(/^(-\s*Status:\s*`?)([\w-]+)(`?)/im);
|
|
1080
|
+
if (!m || m[2].toLowerCase() === row.status) continue;
|
|
1081
|
+
await fs.writeFile(file, text.replace(m[0], `${m[1]}${row.status}${m[3]}`), 'utf8');
|
|
1082
|
+
stamped += 1;
|
|
1083
|
+
}
|
|
1084
|
+
return { ok: true, cycle: nn, snapshotted: merged.size, stamped, unchanged: unchanged && stamped === 0 };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
700
1087
|
// --- CLI (diagnostics) ---------------------------------------------------------
|
|
701
1088
|
|
|
702
1089
|
function parseCliFlags(argv) {
|
|
@@ -762,8 +1149,38 @@ async function main() {
|
|
|
762
1149
|
if (!result.ok && !result.journaled) process.exitCode = 3;
|
|
763
1150
|
return;
|
|
764
1151
|
}
|
|
1152
|
+
if (command === 'archive-cycle' && taskId) {
|
|
1153
|
+
const result = await archiveCycleSnapshot(projectRoot, taskId);
|
|
1154
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1155
|
+
if (!result.ok) process.exitCode = 3;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if ((command === 'claim' || command === 'release') && taskId) {
|
|
1159
|
+
// Main-agent participation in the existing claim registry (no second scheduler): the
|
|
1160
|
+
// orchestrator that must run a task itself claims it first; the guard honours `main:<session>`.
|
|
1161
|
+
const owner = typeof flags.owner === 'string' && flags.owner ? flags.owner : null;
|
|
1162
|
+
if (!owner) {
|
|
1163
|
+
// A random owner token would never match the guard's `main:<session>` check and could
|
|
1164
|
+
// not be released — refuse instead of minting an unusable claim.
|
|
1165
|
+
process.stderr.write('claim|release requires --owner <main:session>\n');
|
|
1166
|
+
process.exitCode = 2;
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const result = command === 'claim'
|
|
1170
|
+
? await acquireTaskClaim(projectRoot, taskId.toUpperCase(), {
|
|
1171
|
+
owner,
|
|
1172
|
+
sessionId: owner.includes(':') ? owner.slice(owner.indexOf(':') + 1) : null,
|
|
1173
|
+
ttlMs: DELEGATED_CLAIM_TTL_MS,
|
|
1174
|
+
})
|
|
1175
|
+
: await releaseTaskClaim(projectRoot, taskId.toUpperCase(), { owner });
|
|
1176
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1177
|
+
if (result.ok === false) process.exitCode = 3;
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
765
1180
|
process.stderr.write(
|
|
766
1181
|
'usage: resumable-run.mjs read <projectRoot> <taskId> [--boundary <id>]\n'
|
|
1182
|
+
+ ' resumable-run.mjs archive-cycle <projectRoot> <NN>\n'
|
|
1183
|
+
+ ' resumable-run.mjs claim|release <projectRoot> <taskId> --owner <main:session>\n'
|
|
767
1184
|
+ ' resumable-run.mjs suspend <projectRoot> <taskId> [--boundary <id>] '
|
|
768
1185
|
+ '[--phase <phase>] [--next <action>] [--completed <a,b,...>]\n',
|
|
769
1186
|
);
|