cclaw-cli 6.7.0 → 6.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifact-linter/design.js +1 -1
- package/dist/artifact-linter/shared.js +2 -1
- package/dist/artifact-linter/tdd.d.ts +11 -0
- package/dist/artifact-linter/tdd.js +174 -7
- package/dist/content/harness-doc.js +1 -1
- package/dist/content/hooks.js +209 -6
- package/dist/content/iron-laws.js +6 -2
- package/dist/content/node-hooks.js +15 -1308
- package/dist/content/skills-elicitation.js +2 -2
- package/dist/content/skills.js +2 -0
- package/dist/content/stages/brainstorm.js +2 -2
- package/dist/content/stages/design.js +2 -2
- package/dist/content/stages/scope.js +2 -2
- package/dist/content/stages/tdd.js +1 -0
- package/dist/content/subagents.js +11 -1
- package/dist/delegation.d.ts +105 -0
- package/dist/delegation.js +229 -6
- package/dist/early-loop.js +15 -1
- package/dist/gate-evidence.js +15 -23
- package/dist/harness-adapters.js +4 -2
- package/dist/install.js +37 -221
- package/dist/internal/advance-stage.js +9 -0
- package/dist/internal/detect-supply-chain-changes.d.ts +6 -0
- package/dist/internal/detect-supply-chain-changes.js +138 -0
- package/dist/internal/flow-state-repair.d.ts +7 -0
- package/dist/internal/flow-state-repair.js +57 -18
- package/dist/run-persistence.d.ts +2 -0
- package/dist/run-persistence.js +62 -3
- package/dist/runtime/run-hook.mjs +44 -8729
- package/package.json +1 -1
|
@@ -424,7 +424,7 @@ export async function lintDesignStage(ctx) {
|
|
|
424
424
|
if (layeredDocumentReview !== null) {
|
|
425
425
|
findings.push({
|
|
426
426
|
section: "Document Reviewer Structured Findings",
|
|
427
|
-
required:
|
|
427
|
+
required: true,
|
|
428
428
|
rule: "When Layered review references coherence-reviewer/scope-guardian-reviewer/feasibility-reviewer, include explicit reviewer status plus calibrated finding lines.",
|
|
429
429
|
found: layeredDocumentReview.missingStructured.length === 0,
|
|
430
430
|
details: layeredDocumentReview.missingStructured.length === 0
|
|
@@ -1881,7 +1881,8 @@ export function checkInvestigationTrace(sectionBody) {
|
|
|
1881
1881
|
*/
|
|
1882
1882
|
export function evaluateInvestigationTrace(ctx, sectionName) {
|
|
1883
1883
|
const body = sectionBodyByName(ctx.sections, sectionName);
|
|
1884
|
-
const
|
|
1884
|
+
const authoredBody = body === null ? null : extractAuthoredBody(body);
|
|
1885
|
+
const result = checkInvestigationTrace(authoredBody);
|
|
1885
1886
|
if (result === null)
|
|
1886
1887
|
return;
|
|
1887
1888
|
ctx.findings.push({
|
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
import { type StageLintContext } from "./shared.js";
|
|
2
2
|
export declare function lintTddStage(ctx: StageLintContext): Promise<void>;
|
|
3
|
+
interface ParsedSliceCycleResult {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
details: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function parseVerticalSliceCycle(body: string): ParsedSliceCycleResult;
|
|
8
|
+
interface VerificationLadderResult {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
details: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function evaluateVerificationLadder(body: string | null): VerificationLadderResult;
|
|
13
|
+
export {};
|
|
@@ -74,16 +74,13 @@ export async function lintTddStage(ctx) {
|
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
else {
|
|
77
|
-
const
|
|
78
|
-
const missing = required.filter((token) => !new RegExp(token, "u").test(sliceCycleBody));
|
|
77
|
+
const cycleResult = parseVerticalSliceCycle(sliceCycleBody);
|
|
79
78
|
findings.push({
|
|
80
79
|
section: "Vertical Slice Cycle Coverage",
|
|
81
80
|
required: true,
|
|
82
|
-
rule: "Vertical Slice Cycle must
|
|
83
|
-
found:
|
|
84
|
-
details:
|
|
85
|
-
? "Vertical Slice Cycle references RED/GREEN/REFACTOR."
|
|
86
|
-
: `Vertical Slice Cycle is missing phase token(s): ${missing.join(", ")}.`
|
|
81
|
+
rule: "Vertical Slice Cycle must show RED -> GREEN -> REFACTOR monotonic progression per slice (refactor may be deferred with one-line rationale, e.g. `deferred because <reason>`).",
|
|
82
|
+
found: cycleResult.ok,
|
|
83
|
+
details: cycleResult.details
|
|
87
84
|
});
|
|
88
85
|
}
|
|
89
86
|
const assertionBody = sectionBodyByName(sections, "Assertion Correctness Notes");
|
|
@@ -196,4 +193,174 @@ export async function lintTddStage(ctx) {
|
|
|
196
193
|
: "integration-overseer completion exists, but PASS/PASS_WITH_GAPS evidence is missing in delegation evidenceRefs and artifact text."
|
|
197
194
|
});
|
|
198
195
|
}
|
|
196
|
+
{
|
|
197
|
+
const verificationBody = sectionBodyByName(sections, "Verification Ladder") ??
|
|
198
|
+
sectionBodyByName(sections, "Verification Status") ??
|
|
199
|
+
sectionBodyByName(sections, "Verification");
|
|
200
|
+
const ladderResult = evaluateVerificationLadder(verificationBody);
|
|
201
|
+
findings.push({
|
|
202
|
+
section: "tdd_verification_pending",
|
|
203
|
+
required: true,
|
|
204
|
+
rule: "Verification Ladder rows must not remain `pending`; promote each row to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete.",
|
|
205
|
+
found: ladderResult.ok,
|
|
206
|
+
details: ladderResult.details
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function parseVerticalSliceCycle(body) {
|
|
211
|
+
const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
|
|
212
|
+
if (tableLines.length < 3) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
details: "Vertical Slice Cycle table must have a header, separator, and at least one slice row."
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const headerCells = splitMarkdownRow(tableLines[0]).map((cell) => cell.toLowerCase());
|
|
219
|
+
const findIdx = (token) => headerCells.findIndex((cell) => cell.includes(token));
|
|
220
|
+
const sliceIdx = findIdx("slice");
|
|
221
|
+
const redIdx = findIdx("red");
|
|
222
|
+
const greenIdx = findIdx("green");
|
|
223
|
+
const refactorIdx = findIdx("refactor");
|
|
224
|
+
if (sliceIdx < 0 || redIdx < 0 || greenIdx < 0 || refactorIdx < 0) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
details: "Vertical Slice Cycle header must include Slice, RED, GREEN, and REFACTOR columns."
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const dataRows = tableLines.slice(2);
|
|
231
|
+
const populated = dataRows.filter((row) => splitMarkdownRow(row).some((cell) => cell.length > 0));
|
|
232
|
+
if (populated.length === 0) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
details: "Vertical Slice Cycle has no populated slice rows."
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const errors = [];
|
|
239
|
+
for (const row of populated) {
|
|
240
|
+
const cells = splitMarkdownRow(row);
|
|
241
|
+
const slice = cells[sliceIdx] ?? "";
|
|
242
|
+
const red = cells[redIdx] ?? "";
|
|
243
|
+
const green = cells[greenIdx] ?? "";
|
|
244
|
+
const refactor = cells[refactorIdx] ?? "";
|
|
245
|
+
const label = slice.length > 0 ? slice : `row ${populated.indexOf(row) + 1}`;
|
|
246
|
+
const redTs = parseTimestampCell(red);
|
|
247
|
+
const greenTs = parseTimestampCell(green);
|
|
248
|
+
if (red.length === 0) {
|
|
249
|
+
errors.push(`${label}: RED ts is empty.`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (green.length === 0) {
|
|
253
|
+
errors.push(`${label}: GREEN ts is empty.`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (redTs === null) {
|
|
257
|
+
errors.push(`${label}: RED ts \`${red}\` is not an ISO timestamp.`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (greenTs === null) {
|
|
261
|
+
errors.push(`${label}: GREEN ts \`${green}\` is not an ISO timestamp.`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (greenTs < redTs) {
|
|
265
|
+
errors.push(`${label}: GREEN (${green}) precedes RED (${red}) — order must be monotonic.`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (refactor.length === 0) {
|
|
269
|
+
errors.push(`${label}: REFACTOR cell is empty; provide a timestamp or \`deferred because <reason>\`.`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (isDeferredOrNotNeeded(refactor)) {
|
|
273
|
+
const rationale = extractDeferRationale(refactor);
|
|
274
|
+
if (rationale.length === 0) {
|
|
275
|
+
errors.push(`${label}: REFACTOR marked deferred/not-needed but rationale is missing — use \`deferred because <reason>\` or \`not needed because <reason>\`.`);
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const refactorTs = parseTimestampCell(refactor);
|
|
280
|
+
if (refactorTs === null) {
|
|
281
|
+
errors.push(`${label}: REFACTOR cell \`${refactor}\` is not an ISO timestamp and not marked deferred/not-needed with rationale.`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (refactorTs < greenTs) {
|
|
285
|
+
errors.push(`${label}: REFACTOR (${refactor}) precedes GREEN (${green}) — order must be monotonic.`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (errors.length > 0) {
|
|
290
|
+
return { ok: false, details: errors.join(" ") };
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
ok: true,
|
|
294
|
+
details: `${populated.length} slice row(s) show monotonic RED -> GREEN -> REFACTOR (deferred-with-rationale accepted).`
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function splitMarkdownRow(line) {
|
|
298
|
+
const trimmed = line.trim();
|
|
299
|
+
if (!trimmed.startsWith("|"))
|
|
300
|
+
return [];
|
|
301
|
+
const inner = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
|
|
302
|
+
return inner.split("|").map((cell) => cell.trim());
|
|
303
|
+
}
|
|
304
|
+
function parseTimestampCell(cell) {
|
|
305
|
+
const trimmed = cell.replace(/^[`*_\s]+|[`*_\s]+$/gu, "");
|
|
306
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u.test(trimmed))
|
|
307
|
+
return null;
|
|
308
|
+
const t = Date.parse(trimmed);
|
|
309
|
+
return Number.isFinite(t) ? t : null;
|
|
310
|
+
}
|
|
311
|
+
function isDeferredOrNotNeeded(cell) {
|
|
312
|
+
return /\b(deferred|not[\s-]?needed|n\/?a|skipped)\b/iu.test(cell);
|
|
313
|
+
}
|
|
314
|
+
function extractDeferRationale(cell) {
|
|
315
|
+
const cleaned = cell.replace(/`/gu, "").trim();
|
|
316
|
+
const match = /(?:deferred|not[\s-]?needed|skipped)\s+(?:because|since|due to|—|-)\s*(.+)/iu.exec(cleaned);
|
|
317
|
+
if (match !== null && match[1] !== undefined && match[1].trim().length > 0) {
|
|
318
|
+
return match[1].trim();
|
|
319
|
+
}
|
|
320
|
+
// Accept any free-form rationale text following the deferral marker.
|
|
321
|
+
const fallback = cleaned.replace(/^\s*(deferred|not[\s-]?needed|skipped|n\/?a)\b[:\s-]*/iu, "").trim();
|
|
322
|
+
return fallback;
|
|
323
|
+
}
|
|
324
|
+
export function evaluateVerificationLadder(body) {
|
|
325
|
+
if (body === null) {
|
|
326
|
+
return {
|
|
327
|
+
ok: true,
|
|
328
|
+
details: "No Verification Ladder section present; rule advisory."
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
|
|
332
|
+
if (tableLines.length < 3) {
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
details: "Verification Ladder section has no table rows; rule advisory."
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const dataRows = tableLines.slice(2);
|
|
339
|
+
const pendingRows = [];
|
|
340
|
+
for (const row of dataRows) {
|
|
341
|
+
const cells = splitMarkdownRow(row);
|
|
342
|
+
if (cells.length === 0)
|
|
343
|
+
continue;
|
|
344
|
+
if (cells.every((cell) => cell.length === 0))
|
|
345
|
+
continue;
|
|
346
|
+
const cellsLower = cells.map((cell) => cell.toLowerCase().replace(/`/gu, "").trim());
|
|
347
|
+
const hasPending = cellsLower.some((cell) => /\bpending\b/u.test(cell));
|
|
348
|
+
if (hasPending) {
|
|
349
|
+
const label = cells[0] !== undefined && cells[0].length > 0
|
|
350
|
+
? cells[0]
|
|
351
|
+
: `row ${dataRows.indexOf(row) + 1}`;
|
|
352
|
+
pendingRows.push(label);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (pendingRows.length === 0) {
|
|
356
|
+
return {
|
|
357
|
+
ok: true,
|
|
358
|
+
details: "Verification Ladder has no rows still marked `pending`."
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
details: `Verification Ladder has ${pendingRows.length} row(s) still marked \`pending\`: ${pendingRows.join(", ")}. ` +
|
|
364
|
+
"Promote each to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete."
|
|
365
|
+
};
|
|
199
366
|
}
|
|
@@ -60,6 +60,6 @@ export function harnessIntegrationDocMarkdown() {
|
|
|
60
60
|
head + " For the end-to-end subagent dispatch model, proof sequence, controller/worker responsibilities, and future roadmap, see [`docs/subagent-flow.md`](./subagent-flow.md).\n\n## Capability tiers\n\n| Harness | ID | Tier | declaredSupport | runtimeLaunch | Fallback | proofRequired | proofSource | Hook surface | Structured ask |\n|---|---|---|---|---|---|---|---|---|---|\n| Claude Code | `claude` | `tier1` (full native automation) | full | native Task launch | native | spanId+dispatchId or workerRunId+ACK | `.cclaw/state/delegation-events.jsonl` + ledger | full | AskUserQuestion |\n| Cursor | `cursor` | `tier2` (supported with fallback paths) | generic | generic Task/Subagent role prompt | generic-dispatch | spanId+dispatchId/evidenceRefs | events + artifact evidenceRefs | full | AskQuestion |\n| OpenCode | `opencode` | `tier2` hooks, native dispatch declared | full | prompt-level launch via Task / `@agent` against `.opencode/agents` | native | spanId+dispatchId+ackTs+completedTs | `.opencode/agents/<agent>.md` + events | plugin | question |\n| OpenAI Codex | `codex` | `tier2` hooks, native dispatch declared | full | prompt-level request to spawn `.codex/agents` custom agents | native | spanId+dispatchId+ackTs+completedTs | `.codex/agents/<agent>.toml` + events | limited | request_user_input |\n",
|
|
61
61
|
perHarnessRecipeMarkdown(),
|
|
62
62
|
hookLayeringSectionMarkdown(),
|
|
63
|
-
"\nFallback legend:\n\n- `native` \u2014 first-class named subagent dispatch (Claude).\n- `generic-dispatch` \u2014 generic Task dispatcher mapped to cclaw roles (Cursor).\n- `role-switch` \u2014 degraded fallback for a runtime where declared native/generic dispatch is unavailable; explicit role headers, artifact outputs, and non-empty delegation-log evidenceRefs are required.\n- `waiver` \u2014 no parity path; reserved for harnesses that cannot role-switch (none shipped).\n\n## Stage-Aware Native Dispatch Workflow\n\nOpenCode and Codex receive generated native isolated subagents. Use them before considering role-switch fallback:\n\n1. Use the active stage skill's generated dispatch table as the source of truth.\n2. OpenCode: invoke `.opencode/agents/<agent>.md` via Task or `@<agent>`; Codex: ask Codex to spawn `.codex/agents/<agent>.toml` by name, in parallel when lanes are independent.\n3. Load `.cclaw/agents/<agent>.md`, execute only that role's stage task, and write outputs into the active stage artifact.\n4. Append `.cclaw/state/delegation-events.jsonl` for scheduled/launched/acknowledged/completed/failed/waived/stale, then mirror current state in `.cclaw/state/delegation-log.json`. The ledger is current state; the event log is proof/audit.\n5. Treat completed role-switch rows without `evidenceRefs` as unresolved; treat native isolated completion without matching `spanId` + `dispatchId`/`workerRunId` + `ackTs` + `completedTs` as fake isolated completion. Native isolated rows are not a role-switch substitute and should reflect a real dispatched worker.\n\nThis is staged agent work backed by the harness-native subagent surfaces. Role-switch remains only a degraded fallback when that surface is unavailable in the active runtime.\n\n## Parallel research dispatch semantics\n\nDesign-stage research fleet uses the same parity model:\n\n- **Claude / Cursor**: dispatch all four research lenses in one turn\n (stack, features, architecture, pitfalls) and synthesize into\n `.cclaw/artifacts/02a-research.md`.\n- **OpenCode / Codex**: dispatch generated native subagents for the same\n four lenses and run independent lanes in parallel where the active runtime\n permits. Use role-switch with evidence only as a degraded fallback.\n\n## Semantic hook event coverage\n\n| Event | Claude | Cursor | OpenCode | Codex |\n|---|---|---|---|---|\n| `session_rehydrate` | SessionStart matcher startup|resume|clear|compact | sessionStart/sessionResume/sessionClear/sessionCompact | plugin event handlers + transform rehydration | SessionStart matcher startup|resume |\n| `pre_tool_prompt_guard` | PreToolUse -> prompt-guard | preToolUse -> prompt-guard | plugin tool.execute.before -> prompt-guard | PreToolUse matcher Bash -> prompt-guard (plus UserPromptSubmit for non-Bash prompts) |\n| `pre_tool_workflow_guard` | PreToolUse -> workflow-guard | preToolUse -> workflow-guard | plugin tool.execute.before -> workflow-guard | PreToolUse matcher Bash -> workflow-guard (Bash-only) |\n| `post_tool_context_monitor` | PostToolUse -> context-monitor | postToolUse -> context-monitor | plugin tool.execute.after -> context-monitor | PostToolUse matcher Bash -> context-monitor (Bash-only) |\n| `stop_handoff` | Stop -> stop-handoff | stop -> stop-handoff | plugin session.idle -> stop-handoff | Stop -> stop-handoff |\n| `precompact_compat` | PreCompact -> pre-compact | sessionCompact -> pre-compact | plugin session.compacted -> pre-compact | missing |\n| `strict_state_verify` | missing | missing | missing | UserPromptSubmit -> verify-current-state (blocks only in strict mode) |\n\n## Hook lifecycle aliases\n\nThe generated Node dispatcher accepts a small compatibility alias set for lifecycle names: `stop` and `stop-checkpoint` route to `stop-handoff`, `precompact` routes to `pre-compact`, and `session-rehydrate` routes to `session-start`. The `pre-compact` handler is intentionally a no-op compatibility marker; rehydration remains the `session-start` responsibility after compact events. Harness JSON should still emit the canonical handler names from `src/content/hook-manifest.ts`.\n\n## Hook event casing\n\nHook keys are intentionally harness-native and must not be normalized:\n\n| Harness | ID | Event key casing |\n|---|---|---|\n| Claude Code | `claude` | PascalCase (`SessionStart`, `PreToolUse`) |\n| Cursor | `cursor` | camelCase (`sessionStart`, `preToolUse`) |\n| OpenCode | `opencode` | camelCase (`sessionStart`, `preToolUse`) |\n| OpenAI Codex | `codex` | PascalCase (`SessionStart`, `PreToolUse`) |\n\nUse the exact event names from each harness schema. Treating all hooks as one\nshared casing silently breaks generated wiring.\n\n## Interpretation\n\n- `tier1`: full native delegation + structured asks + full hook surface.\n- `tier2`: usable flow with capability gaps; mandatory delegation can require waivers.\n- Codex-specific ceiling: `PreToolUse` can only intercept `Bash`. Direct\n `Write`/`Edit` to `.cclaw/state/flow-state.json` cannot be hard-blocked\n at hook level, so the canonical path is\n `node .cclaw/hooks/stage-complete.mjs <stage>` plus the non-blocking\n `UserPromptSubmit` state nudge.\n- In `strict` mode, Codex additionally runs the generated Node/runtime `verify-current-state` path on `UserPromptSubmit` as a fail-closed check. Advisory mode remains non-blocking, including when the generated local Node entrypoint is missing; sync reports that install drift separately. This strict-only coverage is represented explicitly by the `strict_state_verify` semantic row above.\n\n## Shared command contract\n\nAll harnesses receive the same utility commands:\n\n- `/cc` - flow entry and resume\n- `/cc` - stage progression and post-ship closeout\n- `/cc-idea` - idea mode for ranked repo-improvement backlog\n- `/cc-view` - read-only router for status/tree/diff\n\nRead-only subcommands:\n- `/cc-view status` - visual flow snapshot\n- `/cc-view tree` - deep flow tree (stages, artifacts, stale markers)\n- `/cc-view diff` - before/after flow-state diff map\n\nOperational work is handled by `/cc`, `/cc-idea`, `/cc-view`, and `node .cclaw/hooks/stage-complete.mjs <stage>` inside the installed harness runtime. `npx cclaw-cli` is the installer/support surface for init, sync, upgrade, and explicit/manual archive; the normal stage flow must not depend on a runtime `cclaw` binary in PATH.\n\nCritical-path stage order remains canonical:\n`brainstorm -> scope -> design -> spec -> plan -> tdd -> review -> ship`\n\nEvery track then closes out through:\n`retro -> compound -> archive`\n\n## Stage -> skill folder mapping\n\n| Stage | Skill folder |\n|---|---|\n| `brainstorm` | `brainstorm` |\n| `scope` | `scope` |\n| `design` | `design` |\n| `spec` | `spec` |\n| `plan` | `plan` |\n| `tdd` | `tdd` |\n| `review` | `review` |\n| `ship` | `ship` |\n\nThis map is generated from `src/constants.ts::STAGE_TO_SKILL_FOLDER` so\nskill-path naming stays explicit and stable even when stage ids differ from\nfolder names.\n\n## Install surfaces\n\nAlways generated:\n\n- `.cclaw/commands/*.md`\n- `.cclaw/skills/*/SKILL.md`\n- `.cclaw/state/*.json|*.jsonl`\n- `AGENTS.md` managed block\n\nHarness-specific additions:\n\n- `claude`: `.claude/commands/cc*.md`, `.claude/hooks/hooks.json`\n- `cursor`: `.cursor/commands/cc*.md`, `.cursor/hooks.json`, `.cursor/rules/cclaw-workflow.mdc`\n- `opencode`: `.opencode/commands/cc*.md`, `.opencode/plugins/cclaw-plugin.mjs`, opencode plugin registration with `permission.question: \"allow\"`; set `OPENCODE_ENABLE_QUESTION_TOOL=1` for ACP clients so structured asks can route through question tooling. Sync/runtime checks validate the config permission and warn when the environment hint is absent.\n- `codex`: `.agents/skills/cc/SKILL.md`, `.agents/skills/cc-idea/SKILL.md`, `.agents/skills/cc-view/SKILL.md`, `.codex/hooks.json` (Codex CLI reads `.agents/skills/` for custom skills and consumes `.codex/hooks.json` on v0.114+ when `[features] codex_hooks = true` is set in `~/.codex/config.toml`. `.codex/commands/` and the legacy `.agents/skills/cclaw-cc*/` layout from v0.39.x are auto-cleaned on sync.)\n\n## Runtime observability\n\n- `npx cclaw-cli sync` validates shim, hook, and lifecycle surfaces against this capability model.\n- `/cc-view status` and `/cc-view tree` surface the same harness tier/fallback facts from the generated runtime metadata.\n\n## Delegation Proof Model\n\nRuntime state is split deliberately:\n\n- `.cclaw/state/delegation-log.json` is the compact current ledger used by stage gates and `/cc-view` summaries.\n- `.cclaw/state/delegation-events.jsonl` is append-only audit proof for `scheduled`, `launched`, `acknowledged`, `completed`, `failed`, `waived`, and `stale` lifecycle transitions.\n- `.cclaw/state/subagents.json` is a lightweight active-worker tracker for status/tree/sync reports.\n- `.cclaw/hooks/delegation-record.mjs` is the generated helper for lifecycle rows/events. It validates required fields and emits JSON diagnostics with `--json`.\n\nIsolated completion requires `spanId`, `dispatchId` or `workerRunId`, `dispatchSurface`, `agentDefinitionPath`, `ackTs`, `launchedTs`, and `completedTs`. Cursor/generic dispatch and role-switch also require evidence refs when artifact evidence is the proof source. Legacy inferred completions remain readable, but sync reports them as warnings because they predate event-log proof.\n\n## Reference Audit Appendix\n\nStatus meanings: `deep` = read for transferable implementation contract; `targeted` = inspected the relevant files only; `skimmed` = searched/read enough to classify; `not relevant` = intentionally excluded from implementation influence.\n\n| Reference path under `<repo-relative references dir>` | Status | Findings preserved |\n|---|---|---|\n| `evanklem-evanflow/skills/evanflow-coder-overseer/SKILL.md` | deep | Contract-first coder/overseer loop, reviewer reads code rather than worker narrative, and integration overseer pattern map cleanly onto cclaw subagent guidance. |\n| `evanklem-evanflow/agents/evanflow-coder.md` | targeted | Worker role is narrow: implement the pasted contract, avoid broad orchestration, and return evidence for overseer verification. |\n| `evanklem-evanflow/agents/evanflow-overseer.md` | targeted | Overseer validates actual code and acceptance evidence before controller marks work complete. |\n| `oh-my-codex/src/agents/native-config.ts` | deep | Native agent config shape supports explicit metadata/model/tool posture; cclaw should validate generated `.codex/agents/*.toml` shape instead of trusting file presence. |\n| `oh-my-codex/src/team/state/events.ts` and `src/team/state/workers.ts` | targeted | Append-only events plus worker state are useful as separate audit/current-state layers; cclaw mirrors that with `delegation-events.jsonl` and `subagents.json`. |\n| `oh-my-openagent/src/tools/delegate-task/tools.ts` | deep | Delegation should have an explicit dispatch surface and mode instead of relying on a prose claim that an agent was launched. |\n| `oh-my-openagent/src/tools/delegate-task/subagent-resolver.ts` | targeted | Agent discovery should be checked by sync so missing/corrupt generated agent definitions are visible before dispatch. |\n| `oh-my-openagent/src/tools/delegate-task/prompt-builder.ts` | targeted | Prompt builders should include exact invocation/return contracts; cclaw generated worker prompts now carry ACK/result schemas. |\n| `giancarloerra-socraticode/**` | skimmed | Useful for workflow/e2e and graph-oriented contract testing, but not a subagent dispatch implementation reference; no runtime pattern imported. |\n| unrelated large reference trees not named above | not relevant | Searched/skipped because they did not contain flow/subagent/harness dispatch patterns relevant to this plan. |\n"
|
|
63
|
+
"\nFallback legend:\n\n- `native` \u2014 first-class named subagent dispatch (Claude).\n- `generic-dispatch` \u2014 generic Task dispatcher mapped to cclaw roles (Cursor).\n- `role-switch` \u2014 degraded fallback for a runtime where declared native/generic dispatch is unavailable; explicit role headers, artifact outputs, and non-empty delegation-log evidenceRefs are required.\n- `waiver` \u2014 no parity path; reserved for harnesses that cannot role-switch (none shipped).\n\n## Stage-Aware Native Dispatch Workflow\n\nOpenCode and Codex receive generated native isolated subagents. Use them before considering role-switch fallback:\n\n1. Use the active stage skill's generated dispatch table as the source of truth.\n2. OpenCode: invoke `.opencode/agents/<agent>.md` via Task or `@<agent>`; Codex: ask Codex to spawn `.codex/agents/<agent>.toml` by name, in parallel when lanes are independent.\n3. Load `.cclaw/agents/<agent>.md`, execute only that role's stage task, and write outputs into the active stage artifact.\n4. Append `.cclaw/state/delegation-events.jsonl` for scheduled/launched/acknowledged/completed/failed/waived/stale, then mirror current state in `.cclaw/state/delegation-log.json`. The ledger is current state; the event log is proof/audit.\n5. Treat completed role-switch rows without `evidenceRefs` as unresolved; treat native isolated completion without matching `spanId` + `dispatchId`/`workerRunId` + `ackTs` + `completedTs` as fake isolated completion. Native isolated rows are not a role-switch substitute and should reflect a real dispatched worker.\n\nThis is staged agent work backed by the harness-native subagent surfaces. Role-switch remains only a degraded fallback when that surface is unavailable in the active runtime.\n\n## Parallel research dispatch semantics\n\nDesign-stage research fleet uses the same parity model:\n\n- **Claude / Cursor**: dispatch all four research lenses in one turn\n (stack, features, architecture, pitfalls) and synthesize into\n `.cclaw/artifacts/02a-research.md`.\n- **OpenCode / Codex**: dispatch generated native subagents for the same\n four lenses and run independent lanes in parallel where the active runtime\n permits. Use role-switch with evidence only as a degraded fallback.\n\n## Semantic hook event coverage\n\nRuntime Honesty 6.9.0 reduced the runtime to two dispatched handlers:\n`session-start` (rehydrate) and `stop-handoff` (clean handoff). Earlier\nreleases also generated `prompt-guard`, `workflow-guard`,\n`context-monitor`, `pre-compact`, and `verify-current-state` handlers, but\nthose entry points were unreachable via the dispatch table they shipped\nwith and have been removed.\n\n| Event | Claude | Cursor | OpenCode | Codex |\n|---|---|---|---|---|\n| `session_rehydrate` | SessionStart matcher startup|resume|clear|compact | sessionStart/sessionResume/sessionClear/sessionCompact | plugin event handlers + transform rehydration | SessionStart matcher startup|resume |\n| `stop_handoff` | Stop -> stop-handoff | stop -> stop-handoff | plugin session.idle -> stop-handoff | Stop -> stop-handoff |\n\n## Hook lifecycle aliases\n\nThe generated Node dispatcher accepts a small compatibility alias set for lifecycle names: `stop` and `stop-checkpoint` route to `stop-handoff`, and `session-rehydrate` routes to `session-start`. Rehydration remains the `session-start` responsibility after compact events. Harness JSON should still emit the canonical handler names from `src/content/hook-manifest.ts`.\n\n## Hook event casing\n\nHook keys are intentionally harness-native and must not be normalized:\n\n| Harness | ID | Event key casing |\n|---|---|---|\n| Claude Code | `claude` | PascalCase (`SessionStart`, `Stop`) |\n| Cursor | `cursor` | camelCase (`sessionStart`, `stop`) |\n| OpenCode | `opencode` | camelCase (`sessionStart`, `stop`) |\n| OpenAI Codex | `codex` | PascalCase (`SessionStart`, `Stop`) |\n\nUse the exact event names from each harness schema. Treating all hooks as one\nshared casing silently breaks generated wiring.\n\n## Interpretation\n\n- `tier1`: full native delegation + structured asks + full hook surface.\n- `tier2`: usable flow with capability gaps; mandatory delegation can require waivers.\n- Hook-level pre-tool blocking (prompt-guard / workflow-guard) was removed\n in 6.9.0. Stage transitions are owned by the canonical CLI path\n `node .cclaw/hooks/stage-complete.mjs <stage>`; harness sessions enforce\n workflow discipline via the iron-laws block surfaced at session-start\n rather than via per-tool hook interception.\n\n## Shared command contract\n\nAll harnesses receive the same utility commands:\n\n- `/cc` - flow entry and resume\n- `/cc` - stage progression and post-ship closeout\n- `/cc-idea` - idea mode for ranked repo-improvement backlog\n- `/cc-view` - read-only router for status/tree/diff\n\nRead-only subcommands:\n- `/cc-view status` - visual flow snapshot\n- `/cc-view tree` - deep flow tree (stages, artifacts, stale markers)\n- `/cc-view diff` - before/after flow-state diff map\n\nOperational work is handled by `/cc`, `/cc-idea`, `/cc-view`, and `node .cclaw/hooks/stage-complete.mjs <stage>` inside the installed harness runtime. `npx cclaw-cli` is the installer/support surface for init, sync, upgrade, and explicit/manual archive; the normal stage flow must not depend on a runtime `cclaw` binary in PATH.\n\nCritical-path stage order remains canonical:\n`brainstorm -> scope -> design -> spec -> plan -> tdd -> review -> ship`\n\nEvery track then closes out through:\n`post_ship_review -> archive`\n\n## Stage -> skill folder mapping\n\n| Stage | Skill folder |\n|---|---|\n| `brainstorm` | `brainstorm` |\n| `scope` | `scope` |\n| `design` | `design` |\n| `spec` | `spec` |\n| `plan` | `plan` |\n| `tdd` | `tdd` |\n| `review` | `review` |\n| `ship` | `ship` |\n\nThis map is generated from `src/constants.ts::STAGE_TO_SKILL_FOLDER` so\nskill-path naming stays explicit and stable even when stage ids differ from\nfolder names.\n\n## Install surfaces\n\nAlways generated:\n\n- `.cclaw/commands/*.md`\n- `.cclaw/skills/*/SKILL.md`\n- `.cclaw/state/*.json|*.jsonl`\n- `AGENTS.md` managed block\n\nHarness-specific additions:\n\n- `claude`: `.claude/commands/cc*.md`, `.claude/hooks/hooks.json`\n- `cursor`: `.cursor/commands/cc*.md`, `.cursor/hooks.json`, `.cursor/rules/cclaw-workflow.mdc`\n- `opencode`: `.opencode/commands/cc*.md`, `.opencode/plugins/cclaw-plugin.mjs`, opencode plugin registration with `permission.question: \"allow\"`; set `OPENCODE_ENABLE_QUESTION_TOOL=1` for ACP clients so structured asks can route through question tooling. Sync/runtime checks validate the config permission and warn when the environment hint is absent.\n- `codex`: `.agents/skills/cc/SKILL.md`, `.agents/skills/cc-idea/SKILL.md`, `.agents/skills/cc-view/SKILL.md`, `.codex/hooks.json` (Codex CLI reads `.agents/skills/` for custom skills and consumes `.codex/hooks.json` on v0.114+ when `[features] codex_hooks = true` is set in `~/.codex/config.toml`. `.codex/commands/` and the legacy `.agents/skills/cclaw-cc*/` layout from v0.39.x are auto-cleaned on sync.)\n\n## Runtime observability\n\n- `npx cclaw-cli sync` validates shim, hook, and lifecycle surfaces against this capability model.\n- `/cc-view status` and `/cc-view tree` surface the same harness tier/fallback facts from the generated runtime metadata.\n\n## Delegation Proof Model\n\nRuntime state is split deliberately:\n\n- `.cclaw/state/delegation-log.json` is the compact current ledger used by stage gates and `/cc-view` summaries.\n- `.cclaw/state/delegation-events.jsonl` is append-only audit proof for `scheduled`, `launched`, `acknowledged`, `completed`, `failed`, `waived`, and `stale` lifecycle transitions.\n- `.cclaw/state/subagents.json` is a lightweight active-worker tracker for status/tree/sync reports.\n- `.cclaw/hooks/delegation-record.mjs` is the generated helper for lifecycle rows/events. It validates required fields and emits JSON diagnostics with `--json`.\n\nIsolated completion requires `spanId`, `dispatchId` or `workerRunId`, `dispatchSurface`, `agentDefinitionPath`, `ackTs`, `launchedTs`, and `completedTs`. Cursor/generic dispatch and role-switch also require evidence refs when artifact evidence is the proof source. Legacy inferred completions remain readable, but sync reports them as warnings because they predate event-log proof.\n\n## Reference Audit Appendix\n\nStatus meanings: `deep` = read for transferable implementation contract; `targeted` = inspected the relevant files only; `skimmed` = searched/read enough to classify; `not relevant` = intentionally excluded from implementation influence.\n\n| Reference path under `<repo-relative references dir>` | Status | Findings preserved |\n|---|---|---|\n| `evanklem-evanflow/skills/evanflow-coder-overseer/SKILL.md` | deep | Contract-first coder/overseer loop, reviewer reads code rather than worker narrative, and integration overseer pattern map cleanly onto cclaw subagent guidance. |\n| `evanklem-evanflow/agents/evanflow-coder.md` | targeted | Worker role is narrow: implement the pasted contract, avoid broad orchestration, and return evidence for overseer verification. |\n| `evanklem-evanflow/agents/evanflow-overseer.md` | targeted | Overseer validates actual code and acceptance evidence before controller marks work complete. |\n| `oh-my-codex/src/agents/native-config.ts` | deep | Native agent config shape supports explicit metadata/model/tool posture; cclaw should validate generated `.codex/agents/*.toml` shape instead of trusting file presence. |\n| `oh-my-codex/src/team/state/events.ts` and `src/team/state/workers.ts` | targeted | Append-only events plus worker state are useful as separate audit/current-state layers; cclaw mirrors that with `delegation-events.jsonl` and `subagents.json`. |\n| `oh-my-openagent/src/tools/delegate-task/tools.ts` | deep | Delegation should have an explicit dispatch surface and mode instead of relying on a prose claim that an agent was launched. |\n| `oh-my-openagent/src/tools/delegate-task/subagent-resolver.ts` | targeted | Agent discovery should be checked by sync so missing/corrupt generated agent definitions are visible before dispatch. |\n| `oh-my-openagent/src/tools/delegate-task/prompt-builder.ts` | targeted | Prompt builders should include exact invocation/return contracts; cclaw generated worker prompts now carry ACK/result schemas. |\n| `giancarloerra-socraticode/**` | skimmed | Useful for workflow/e2e and graph-oriented contract testing, but not a subagent dispatch implementation reference; no runtime pattern imported. |\n| unrelated large reference trees not named above | not relevant | Searched/skipped because they did not contain flow/subagent/harness dispatch patterns relevant to this plan. |\n"
|
|
64
64
|
].join("");
|
|
65
65
|
}
|
package/dist/content/hooks.js
CHANGED
|
@@ -326,7 +326,7 @@ function hasPriorAck(events, args, runId) {
|
|
|
326
326
|
function usage() {
|
|
327
327
|
process.stderr.write([
|
|
328
328
|
"Usage:",
|
|
329
|
-
" node .cclaw/hooks/delegation-record.mjs --stage=<stage> --agent=<agent> --mode=<mandatory|proactive> --status=<scheduled|launched|acknowledged|completed|failed|waived|stale> --span-id=<id> [--dispatch-id=<id>] [--worker-run-id=<id>] [--dispatch-surface=<surface>] [--agent-definition-path=<path>] [--ack-ts=<iso>] [--launched-ts=<iso>] [--completed-ts=<iso>] [--evidence-ref=<ref>] [--waiver-reason=<text>] [--json]",
|
|
329
|
+
" node .cclaw/hooks/delegation-record.mjs --stage=<stage> --agent=<agent> --mode=<mandatory|proactive> --status=<scheduled|launched|acknowledged|completed|failed|waived|stale> --span-id=<id> [--dispatch-id=<id>] [--worker-run-id=<id>] [--dispatch-surface=<surface>] [--agent-definition-path=<path>] [--ack-ts=<iso>] [--launched-ts=<iso>] [--completed-ts=<iso>] [--evidence-ref=<ref>] [--waiver-reason=<text>] [--supersede=<prevSpanId>] [--allow-parallel] [--json]",
|
|
330
330
|
" node .cclaw/hooks/delegation-record.mjs --rerecord --span-id=<id> --dispatch-id=<id> --dispatch-surface=<surface> --agent-definition-path=<path> [--ack-ts=<iso>] [--completed-ts=<iso>] [--evidence-ref=<ref>] [--json]",
|
|
331
331
|
" node .cclaw/hooks/delegation-record.mjs --repair --span-id=<id> --repair-reason=\"<why>\" [--json]",
|
|
332
332
|
"",
|
|
@@ -335,6 +335,10 @@ function usage() {
|
|
|
335
335
|
"",
|
|
336
336
|
"Per-surface allowed --agent-definition-path prefixes:",
|
|
337
337
|
...VALID_DISPATCH_SURFACES.map((surface) => " " + surface + ": " + (SURFACE_PATH_PREFIXES[surface].length === 0 ? "(any)" : SURFACE_PATH_PREFIXES[surface].join(", "))),
|
|
338
|
+
"",
|
|
339
|
+
"Dispatch dedup (v6.8.0):",
|
|
340
|
+
" --supersede=<prevSpanId> close the previous active span on this (stage, agent) as 'stale' before recording the new scheduled row",
|
|
341
|
+
" --allow-parallel record both spans as concurrent; new row is tagged allowParallel: true",
|
|
338
342
|
""
|
|
339
343
|
].join("\\n") + "\\n");
|
|
340
344
|
}
|
|
@@ -350,6 +354,51 @@ function emitProblems(problems, json, code) {
|
|
|
350
354
|
process.exitCode = exitCode;
|
|
351
355
|
}
|
|
352
356
|
|
|
357
|
+
function emitErrorJson(error, details, json) {
|
|
358
|
+
if (json) {
|
|
359
|
+
process.stdout.write(JSON.stringify({ ok: false, error, details }, null, 2) + "\\n");
|
|
360
|
+
} else {
|
|
361
|
+
process.stderr.write("[cclaw] delegation-record: error: " + error + " — " + JSON.stringify(details) + "\\n");
|
|
362
|
+
}
|
|
363
|
+
process.exit(2);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// keep in sync with validateMonotonicTimestamps in src/delegation.ts
|
|
367
|
+
function validateMonotonicTimestampsInline(stamped, prior) {
|
|
368
|
+
const startTs = stamped.startTs;
|
|
369
|
+
if (stamped.launchedTs && startTs && stamped.launchedTs < startTs) {
|
|
370
|
+
return { field: "launchedTs", actual: stamped.launchedTs, bound: startTs };
|
|
371
|
+
}
|
|
372
|
+
if (stamped.ackTs) {
|
|
373
|
+
const ackBound = stamped.launchedTs || startTs;
|
|
374
|
+
if (ackBound && stamped.ackTs < ackBound) {
|
|
375
|
+
return { field: "ackTs", actual: stamped.ackTs, bound: ackBound };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (stamped.completedTs) {
|
|
379
|
+
const completedBound = stamped.ackTs || stamped.launchedTs || startTs;
|
|
380
|
+
if (completedBound && stamped.completedTs < completedBound) {
|
|
381
|
+
return { field: "completedTs", actual: stamped.completedTs, bound: completedBound };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!stamped.spanId) return null;
|
|
385
|
+
const priorForSpan = (prior || []).filter((entry) => entry && entry.spanId === stamped.spanId);
|
|
386
|
+
if (priorForSpan.length === 0) return null;
|
|
387
|
+
const tsValues = priorForSpan
|
|
388
|
+
.map((entry) => entry.ts || entry.startTs || "")
|
|
389
|
+
.filter((ts) => ts.length > 0);
|
|
390
|
+
if (tsValues.length === 0) return null;
|
|
391
|
+
let latest = tsValues[0];
|
|
392
|
+
for (let i = 1; i < tsValues.length; i += 1) {
|
|
393
|
+
if (tsValues[i] > latest) latest = tsValues[i];
|
|
394
|
+
}
|
|
395
|
+
const stampedTs = stamped.ts || stamped.startTs || "";
|
|
396
|
+
if (stampedTs && stampedTs < latest) {
|
|
397
|
+
return { field: "ts", actual: stampedTs, bound: latest };
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
353
402
|
function normalizeRelPath(value) {
|
|
354
403
|
return String(value || "").replace(/\\\\/gu, "/").replace(/^\\.\\//u, "");
|
|
355
404
|
}
|
|
@@ -382,12 +431,15 @@ function normalizeEvidenceRefs(args) {
|
|
|
382
431
|
return [];
|
|
383
432
|
}
|
|
384
433
|
|
|
385
|
-
function buildRow(args, status, runId, now) {
|
|
434
|
+
function buildRow(args, status, runId, now, options) {
|
|
386
435
|
const fulfillmentMode = args["dispatch-surface"] === "role-switch"
|
|
387
436
|
? "role-switch"
|
|
388
437
|
: args["dispatch-surface"] === "cursor-task" || args["dispatch-surface"] === "generic-task"
|
|
389
438
|
? "generic-dispatch"
|
|
390
439
|
: "isolated";
|
|
440
|
+
// Inherit the span's startTs from prior rows so monotonic validation
|
|
441
|
+
// can compare against the original schedule, not the row write time.
|
|
442
|
+
const startTs = (options && options.spanStartTs) || now;
|
|
391
443
|
return {
|
|
392
444
|
stage: args.stage,
|
|
393
445
|
agent: args.agent,
|
|
@@ -402,13 +454,87 @@ function buildRow(args, status, runId, now) {
|
|
|
402
454
|
waiverReason: args["waiver-reason"],
|
|
403
455
|
evidenceRefs: normalizeEvidenceRefs(args),
|
|
404
456
|
runId,
|
|
405
|
-
startTs
|
|
457
|
+
startTs,
|
|
406
458
|
ts: now,
|
|
407
459
|
launchedTs: args["launched-ts"] || (status === "launched" ? now : undefined),
|
|
408
460
|
ackTs: args["ack-ts"] || (status === "acknowledged" ? now : undefined),
|
|
409
461
|
completedTs: args["completed-ts"] || (status === "completed" ? now : undefined),
|
|
410
462
|
endTs: TERMINAL.has(status) ? now : undefined,
|
|
411
|
-
schemaVersion: LEDGER_SCHEMA_VERSION
|
|
463
|
+
schemaVersion: LEDGER_SCHEMA_VERSION,
|
|
464
|
+
allowParallel: args["allow-parallel"] === true ? true : undefined
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function readDelegationLedgerEntries(root) {
|
|
469
|
+
try {
|
|
470
|
+
const raw = await fs.readFile(path.join(root, RUNTIME_ROOT, "state", "delegation-log.json"), "utf8");
|
|
471
|
+
const parsed = JSON.parse(raw);
|
|
472
|
+
if (parsed && Array.isArray(parsed.entries)) return parsed.entries;
|
|
473
|
+
} catch {
|
|
474
|
+
// empty / missing ledger is fine for dedup + monotonicity checks
|
|
475
|
+
}
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// keep in sync with findActiveSpanForPair / DispatchDuplicateError in src/delegation.ts
|
|
480
|
+
function findActiveSpanForPairInline(stage, agent, runId, entries) {
|
|
481
|
+
const ACTIVE_STATUSES = new Set(["scheduled", "launched", "acknowledged"]);
|
|
482
|
+
const effectiveTs = (entry) =>
|
|
483
|
+
entry.completedTs || entry.ackTs || entry.launchedTs || entry.endTs || entry.startTs || entry.ts || "";
|
|
484
|
+
const latestBySpan = new Map();
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
if (!entry || typeof entry !== "object") continue;
|
|
487
|
+
if (typeof entry.spanId !== "string" || entry.spanId.length === 0) continue;
|
|
488
|
+
// Strict run-scope (v6.9.0 R7 fix): legacy entries without a runId
|
|
489
|
+
// are treated as foreign so they cannot keep an old span "active"
|
|
490
|
+
// across runs and trip dispatch_duplicate on a fresh dispatch.
|
|
491
|
+
if (typeof entry.runId !== "string" || entry.runId.length === 0) continue;
|
|
492
|
+
if (entry.runId !== runId) continue;
|
|
493
|
+
if (entry.stage !== stage || entry.agent !== agent) continue;
|
|
494
|
+
const existing = latestBySpan.get(entry.spanId);
|
|
495
|
+
if (!existing || effectiveTs(entry) >= effectiveTs(existing)) {
|
|
496
|
+
latestBySpan.set(entry.spanId, entry);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (const entry of latestBySpan.values()) {
|
|
500
|
+
if (ACTIVE_STATUSES.has(entry.status)) return entry;
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function enforceDispatchDedupInline(stamped, priorEntries, args) {
|
|
506
|
+
if (stamped.status !== "scheduled") return null;
|
|
507
|
+
if (args["allow-parallel"] === true) return null;
|
|
508
|
+
const existing = findActiveSpanForPairInline(
|
|
509
|
+
stamped.stage,
|
|
510
|
+
stamped.agent,
|
|
511
|
+
stamped.runId,
|
|
512
|
+
priorEntries
|
|
513
|
+
);
|
|
514
|
+
if (!existing || existing.spanId === stamped.spanId) return null;
|
|
515
|
+
if (typeof args.supersede === "string" && args.supersede.length > 0) {
|
|
516
|
+
if (args.supersede !== existing.spanId) {
|
|
517
|
+
return {
|
|
518
|
+
kind: "supersede-mismatch",
|
|
519
|
+
details: {
|
|
520
|
+
requested: args.supersede,
|
|
521
|
+
actualActiveSpanId: existing.spanId,
|
|
522
|
+
stage: stamped.stage,
|
|
523
|
+
agent: stamped.agent
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
return { kind: "supersede", existing };
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
kind: "error",
|
|
531
|
+
details: {
|
|
532
|
+
existingSpanId: existing.spanId,
|
|
533
|
+
existingStatus: existing.status,
|
|
534
|
+
newSpanId: stamped.spanId,
|
|
535
|
+
pair: { stage: stamped.stage, agent: stamped.agent },
|
|
536
|
+
hint: "pass --supersede=" + existing.spanId + " to close the previous span as stale, or --allow-parallel to record both as concurrent"
|
|
537
|
+
}
|
|
412
538
|
};
|
|
413
539
|
}
|
|
414
540
|
|
|
@@ -490,7 +616,32 @@ async function persistEntry(root, runId, clean, event, options = {}) {
|
|
|
490
616
|
await releaseDelegationLogLock(lockDir);
|
|
491
617
|
}
|
|
492
618
|
|
|
493
|
-
|
|
619
|
+
// keep in sync with computeActiveSubagents in src/delegation.ts
|
|
620
|
+
const ACTIVE_STATUSES = new Set(["scheduled", "launched", "acknowledged"]);
|
|
621
|
+
const effectiveTs = (entry) =>
|
|
622
|
+
entry.completedTs || entry.ackTs || entry.launchedTs || entry.endTs || entry.startTs || entry.ts || "";
|
|
623
|
+
const latestBySpan = new Map();
|
|
624
|
+
for (const entry of ledger.entries) {
|
|
625
|
+
if (!entry || typeof entry !== "object" || typeof entry.spanId !== "string" || entry.spanId.length === 0) continue;
|
|
626
|
+
const existing = latestBySpan.get(entry.spanId);
|
|
627
|
+
if (!existing) {
|
|
628
|
+
latestBySpan.set(entry.spanId, entry);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (effectiveTs(entry) >= effectiveTs(existing)) {
|
|
632
|
+
latestBySpan.set(entry.spanId, entry);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const active = [];
|
|
636
|
+
for (const entry of latestBySpan.values()) {
|
|
637
|
+
if (ACTIVE_STATUSES.has(entry.status)) active.push(entry);
|
|
638
|
+
}
|
|
639
|
+
active.sort((a, b) => {
|
|
640
|
+
const aKey = a.startTs || a.ts || "";
|
|
641
|
+
const bKey = b.startTs || b.ts || "";
|
|
642
|
+
if (aKey === bKey) return 0;
|
|
643
|
+
return aKey < bKey ? -1 : 1;
|
|
644
|
+
});
|
|
494
645
|
await fs.writeFile(path.join(stateDir, "subagents.json"), JSON.stringify({ active, updatedAt: event.eventTs }, null, 2) + "\\n", { encoding: "utf8", mode: 0o600 });
|
|
495
646
|
}
|
|
496
647
|
|
|
@@ -814,9 +965,61 @@ async function main() {
|
|
|
814
965
|
}
|
|
815
966
|
|
|
816
967
|
const status = args.status;
|
|
817
|
-
const
|
|
968
|
+
const priorLedger = await readDelegationLedgerEntries(root);
|
|
969
|
+
const priorForSpan = priorLedger.filter((e) => e && e.spanId === args["span-id"]);
|
|
970
|
+
const inheritedStartTs = priorForSpan
|
|
971
|
+
.map((e) => e.startTs)
|
|
972
|
+
.filter((ts) => typeof ts === "string" && ts.length > 0)
|
|
973
|
+
.sort()[0];
|
|
974
|
+
// When no prior row exists, fall back to the earliest user-supplied
|
|
975
|
+
// event timestamp so the monotonic validator never sees the row write
|
|
976
|
+
// time overshoot the real event timestamps.
|
|
977
|
+
const lifecycleCandidates = [
|
|
978
|
+
inheritedStartTs,
|
|
979
|
+
args["launched-ts"],
|
|
980
|
+
args["ack-ts"],
|
|
981
|
+
args["completed-ts"],
|
|
982
|
+
now
|
|
983
|
+
].filter((value) => typeof value === "string" && value.length > 0);
|
|
984
|
+
const spanStartTs = inheritedStartTs ||
|
|
985
|
+
lifecycleCandidates.reduce((min, candidate) => (candidate < min ? candidate : min), now);
|
|
986
|
+
const row = buildRow(args, status, runId, now, { spanStartTs });
|
|
818
987
|
const clean = Object.fromEntries(Object.entries(row).filter(([, value]) => value !== undefined));
|
|
819
988
|
const event = { ...clean, event: status, eventTs: now };
|
|
989
|
+
|
|
990
|
+
const violation = validateMonotonicTimestampsInline(clean, priorLedger);
|
|
991
|
+
if (violation) {
|
|
992
|
+
emitErrorJson("delegation_timestamp_non_monotonic", violation, json);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const dedupViolation = enforceDispatchDedupInline(clean, priorLedger, args);
|
|
996
|
+
if (dedupViolation) {
|
|
997
|
+
if (dedupViolation.kind === "supersede") {
|
|
998
|
+
const stalenessTs = new Date(new Date(now).getTime() - 1).toISOString();
|
|
999
|
+
const staleRow = {
|
|
1000
|
+
stage: dedupViolation.existing.stage,
|
|
1001
|
+
agent: dedupViolation.existing.agent,
|
|
1002
|
+
mode: dedupViolation.existing.mode,
|
|
1003
|
+
status: "stale",
|
|
1004
|
+
spanId: dedupViolation.existing.spanId,
|
|
1005
|
+
runId,
|
|
1006
|
+
startTs: dedupViolation.existing.startTs || stalenessTs,
|
|
1007
|
+
ts: stalenessTs,
|
|
1008
|
+
endTs: stalenessTs,
|
|
1009
|
+
supersededBy: clean.spanId,
|
|
1010
|
+
schemaVersion: LEDGER_SCHEMA_VERSION
|
|
1011
|
+
};
|
|
1012
|
+
const staleEvent = { ...staleRow, event: "stale", eventTs: stalenessTs };
|
|
1013
|
+
await persistEntry(root, runId, staleRow, staleEvent);
|
|
1014
|
+
} else if (dedupViolation.kind === "error") {
|
|
1015
|
+
emitErrorJson("dispatch_duplicate", dedupViolation.details, json);
|
|
1016
|
+
return;
|
|
1017
|
+
} else if (dedupViolation.kind === "supersede-mismatch") {
|
|
1018
|
+
emitErrorJson("dispatch_supersede_mismatch", dedupViolation.details, json);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
820
1023
|
await persistEntry(root, runId, clean, event);
|
|
821
1024
|
process.stdout.write(JSON.stringify({ ok: true, event }, null, 2) + "\\n");
|
|
822
1025
|
}
|
|
@@ -158,9 +158,13 @@ function hardGateReference(law) {
|
|
|
158
158
|
.join(", ");
|
|
159
159
|
}
|
|
160
160
|
export function ironLawsSkillMarkdown() {
|
|
161
|
+
// v6.9.0: Phase A purged the `PreToolUse` / `PostToolUse` / pre-tool
|
|
162
|
+
// pipeline handlers, so `review-coverage-complete-before-ship` is no
|
|
163
|
+
// longer hook-enforced — it now lives in the ship stage HARD-GATE.
|
|
164
|
+
// Only `stop-clean-or-handoff` (Stop hook) is still hook-enforced;
|
|
165
|
+
// everything else is stage-owned advisory.
|
|
161
166
|
const enforcedLawIds = new Set([
|
|
162
|
-
"stop-clean-or-handoff"
|
|
163
|
-
"review-coverage-complete-before-ship"
|
|
167
|
+
"stop-clean-or-handoff"
|
|
164
168
|
]);
|
|
165
169
|
const enforced = IRON_LAWS.filter((law) => enforcedLawIds.has(law.id));
|
|
166
170
|
const advisory = IRON_LAWS.filter((law) => !enforcedLawIds.has(law.id));
|