cclaw-cli 7.7.1 → 8.1.1
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/README.md +211 -134
- package/dist/artifact-frontmatter.d.ts +51 -0
- package/dist/artifact-frontmatter.js +131 -0
- package/dist/artifact-paths.d.ts +7 -27
- package/dist/artifact-paths.js +20 -249
- package/dist/cancel.d.ts +16 -0
- package/dist/cancel.js +66 -0
- package/dist/cli.d.ts +2 -27
- package/dist/cli.js +107 -511
- package/dist/compound.d.ts +26 -0
- package/dist/compound.js +96 -0
- package/dist/config.d.ts +14 -51
- package/dist/config.js +23 -359
- package/dist/constants.d.ts +11 -18
- package/dist/constants.js +19 -106
- package/dist/content/antipatterns.d.ts +1 -0
- package/dist/content/antipatterns.js +109 -0
- package/dist/content/artifact-templates.d.ts +10 -0
- package/dist/content/artifact-templates.js +550 -0
- package/dist/content/cancel-command.d.ts +2 -2
- package/dist/content/cancel-command.js +25 -17
- package/dist/content/core-agents.d.ts +9 -233
- package/dist/content/core-agents.js +39 -768
- package/dist/content/decision-protocol.d.ts +1 -12
- package/dist/content/decision-protocol.js +27 -20
- package/dist/content/examples.d.ts +8 -42
- package/dist/content/examples.js +293 -425
- package/dist/content/idea-command.d.ts +2 -0
- package/dist/content/idea-command.js +38 -0
- package/dist/content/iron-laws.d.ts +4 -138
- package/dist/content/iron-laws.js +18 -197
- package/dist/content/meta-skill.d.ts +1 -3
- package/dist/content/meta-skill.js +57 -134
- package/dist/content/node-hooks.d.ts +12 -8
- package/dist/content/node-hooks.js +188 -838
- package/dist/content/recovery.d.ts +8 -0
- package/dist/content/recovery.js +179 -0
- package/dist/content/reference-patterns.d.ts +4 -13
- package/dist/content/reference-patterns.js +260 -389
- package/dist/content/research-playbooks.d.ts +8 -8
- package/dist/content/research-playbooks.js +108 -121
- package/dist/content/review-loop.d.ts +6 -192
- package/dist/content/review-loop.js +29 -731
- package/dist/content/skills.d.ts +8 -38
- package/dist/content/skills.js +681 -732
- package/dist/content/specialist-prompts/architect.d.ts +1 -0
- package/dist/content/specialist-prompts/architect.js +225 -0
- package/dist/content/specialist-prompts/brainstormer.d.ts +1 -0
- package/dist/content/specialist-prompts/brainstormer.js +168 -0
- package/dist/content/specialist-prompts/index.d.ts +2 -0
- package/dist/content/specialist-prompts/index.js +14 -0
- package/dist/content/specialist-prompts/planner.d.ts +1 -0
- package/dist/content/specialist-prompts/planner.js +182 -0
- package/dist/content/specialist-prompts/reviewer.d.ts +1 -0
- package/dist/content/specialist-prompts/reviewer.js +193 -0
- package/dist/content/specialist-prompts/security-reviewer.d.ts +1 -0
- package/dist/content/specialist-prompts/security-reviewer.js +133 -0
- package/dist/content/specialist-prompts/slice-builder.d.ts +1 -0
- package/dist/content/specialist-prompts/slice-builder.js +232 -0
- package/dist/content/stage-playbooks.d.ts +8 -0
- package/dist/content/stage-playbooks.js +404 -0
- package/dist/content/start-command.d.ts +2 -12
- package/dist/content/start-command.js +221 -207
- package/dist/flow-state.d.ts +21 -178
- package/dist/flow-state.js +67 -170
- package/dist/fs-utils.d.ts +6 -26
- package/dist/fs-utils.js +29 -162
- package/dist/gitignore.d.ts +2 -1
- package/dist/gitignore.js +51 -34
- package/dist/harness-detect.d.ts +10 -0
- package/dist/harness-detect.js +29 -0
- package/dist/harness-prompt.d.ts +26 -0
- package/dist/harness-prompt.js +142 -0
- package/dist/install.d.ts +35 -15
- package/dist/install.js +238 -1347
- package/dist/knowledge-store.d.ts +19 -163
- package/dist/knowledge-store.js +56 -590
- package/dist/logger.d.ts +8 -3
- package/dist/logger.js +13 -4
- package/dist/orchestrator-routing.d.ts +29 -0
- package/dist/orchestrator-routing.js +156 -0
- package/dist/run-persistence.d.ts +7 -118
- package/dist/run-persistence.js +29 -845
- package/dist/runtime/run-hook.entry.d.ts +1 -3
- package/dist/runtime/run-hook.entry.js +19 -4
- package/dist/runtime/run-hook.mjs +13 -1024
- package/dist/types.d.ts +25 -261
- package/dist/types.js +8 -36
- package/package.json +6 -3
- package/dist/artifact-linter/brainstorm.d.ts +0 -2
- package/dist/artifact-linter/brainstorm.js +0 -353
- package/dist/artifact-linter/design.d.ts +0 -18
- package/dist/artifact-linter/design.js +0 -444
- package/dist/artifact-linter/findings-dedup.d.ts +0 -56
- package/dist/artifact-linter/findings-dedup.js +0 -232
- package/dist/artifact-linter/plan.d.ts +0 -2
- package/dist/artifact-linter/plan.js +0 -826
- package/dist/artifact-linter/review-army.d.ts +0 -49
- package/dist/artifact-linter/review-army.js +0 -520
- package/dist/artifact-linter/review.d.ts +0 -2
- package/dist/artifact-linter/review.js +0 -113
- package/dist/artifact-linter/scope.d.ts +0 -2
- package/dist/artifact-linter/scope.js +0 -158
- package/dist/artifact-linter/shared.d.ts +0 -637
- package/dist/artifact-linter/shared.js +0 -2163
- package/dist/artifact-linter/ship.d.ts +0 -2
- package/dist/artifact-linter/ship.js +0 -250
- package/dist/artifact-linter/spec.d.ts +0 -2
- package/dist/artifact-linter/spec.js +0 -176
- package/dist/artifact-linter/tdd.d.ts +0 -118
- package/dist/artifact-linter/tdd.js +0 -1404
- package/dist/artifact-linter.d.ts +0 -15
- package/dist/artifact-linter.js +0 -517
- package/dist/codex-feature-flag.d.ts +0 -58
- package/dist/codex-feature-flag.js +0 -193
- package/dist/content/closeout-guidance.d.ts +0 -14
- package/dist/content/closeout-guidance.js +0 -44
- package/dist/content/diff-command.d.ts +0 -1
- package/dist/content/diff-command.js +0 -43
- package/dist/content/harness-doc.d.ts +0 -1
- package/dist/content/harness-doc.js +0 -65
- package/dist/content/hook-events.d.ts +0 -9
- package/dist/content/hook-events.js +0 -23
- package/dist/content/hook-manifest.d.ts +0 -81
- package/dist/content/hook-manifest.js +0 -156
- package/dist/content/hooks.d.ts +0 -11
- package/dist/content/hooks.js +0 -1972
- package/dist/content/idea.d.ts +0 -60
- package/dist/content/idea.js +0 -416
- package/dist/content/language-policy.d.ts +0 -2
- package/dist/content/language-policy.js +0 -13
- package/dist/content/learnings.d.ts +0 -6
- package/dist/content/learnings.js +0 -141
- package/dist/content/observe.d.ts +0 -19
- package/dist/content/observe.js +0 -86
- package/dist/content/opencode-plugin.d.ts +0 -1
- package/dist/content/opencode-plugin.js +0 -635
- package/dist/content/review-prompts.d.ts +0 -1
- package/dist/content/review-prompts.js +0 -104
- package/dist/content/runtime-shared-snippets.d.ts +0 -8
- package/dist/content/runtime-shared-snippets.js +0 -80
- package/dist/content/session-hooks.d.ts +0 -7
- package/dist/content/session-hooks.js +0 -107
- package/dist/content/skills-elicitation.d.ts +0 -1
- package/dist/content/skills-elicitation.js +0 -167
- package/dist/content/stage-command.d.ts +0 -2
- package/dist/content/stage-command.js +0 -17
- package/dist/content/stage-schema.d.ts +0 -117
- package/dist/content/stage-schema.js +0 -955
- package/dist/content/stages/_lint-metadata/index.d.ts +0 -2
- package/dist/content/stages/_lint-metadata/index.js +0 -97
- package/dist/content/stages/brainstorm.d.ts +0 -2
- package/dist/content/stages/brainstorm.js +0 -184
- package/dist/content/stages/design.d.ts +0 -2
- package/dist/content/stages/design.js +0 -288
- package/dist/content/stages/index.d.ts +0 -8
- package/dist/content/stages/index.js +0 -11
- package/dist/content/stages/plan.d.ts +0 -2
- package/dist/content/stages/plan.js +0 -191
- package/dist/content/stages/review.d.ts +0 -2
- package/dist/content/stages/review.js +0 -240
- package/dist/content/stages/schema-types.d.ts +0 -203
- package/dist/content/stages/schema-types.js +0 -1
- package/dist/content/stages/scope.d.ts +0 -2
- package/dist/content/stages/scope.js +0 -254
- package/dist/content/stages/ship.d.ts +0 -2
- package/dist/content/stages/ship.js +0 -159
- package/dist/content/stages/spec.d.ts +0 -2
- package/dist/content/stages/spec.js +0 -170
- package/dist/content/stages/tdd.d.ts +0 -4
- package/dist/content/stages/tdd.js +0 -273
- package/dist/content/state-contracts.d.ts +0 -1
- package/dist/content/state-contracts.js +0 -63
- package/dist/content/status-command.d.ts +0 -4
- package/dist/content/status-command.js +0 -109
- package/dist/content/subagent-context-skills.d.ts +0 -4
- package/dist/content/subagent-context-skills.js +0 -279
- package/dist/content/subagents.d.ts +0 -3
- package/dist/content/subagents.js +0 -997
- package/dist/content/templates.d.ts +0 -26
- package/dist/content/templates.js +0 -1692
- package/dist/content/track-render-context.d.ts +0 -18
- package/dist/content/track-render-context.js +0 -53
- package/dist/content/tree-command.d.ts +0 -1
- package/dist/content/tree-command.js +0 -64
- package/dist/content/utility-skills.d.ts +0 -30
- package/dist/content/utility-skills.js +0 -160
- package/dist/content/view-command.d.ts +0 -2
- package/dist/content/view-command.js +0 -92
- package/dist/delegation.d.ts +0 -649
- package/dist/delegation.js +0 -1539
- package/dist/early-loop.d.ts +0 -70
- package/dist/early-loop.js +0 -302
- package/dist/execution-topology.d.ts +0 -44
- package/dist/execution-topology.js +0 -95
- package/dist/gate-evidence.d.ts +0 -85
- package/dist/gate-evidence.js +0 -631
- package/dist/harness-adapters.d.ts +0 -151
- package/dist/harness-adapters.js +0 -756
- package/dist/harness-selection.d.ts +0 -31
- package/dist/harness-selection.js +0 -214
- package/dist/hook-schema.d.ts +0 -6
- package/dist/hook-schema.js +0 -114
- package/dist/hook-schemas/claude-hooks.v1.json +0 -10
- package/dist/hook-schemas/codex-hooks.v1.json +0 -10
- package/dist/hook-schemas/cursor-hooks.v1.json +0 -13
- package/dist/init-detect.d.ts +0 -2
- package/dist/init-detect.js +0 -50
- package/dist/internal/advance-stage/advance.d.ts +0 -89
- package/dist/internal/advance-stage/advance.js +0 -655
- package/dist/internal/advance-stage/cancel-run.d.ts +0 -8
- package/dist/internal/advance-stage/cancel-run.js +0 -19
- package/dist/internal/advance-stage/flow-state-coercion.d.ts +0 -3
- package/dist/internal/advance-stage/flow-state-coercion.js +0 -81
- package/dist/internal/advance-stage/helpers.d.ts +0 -14
- package/dist/internal/advance-stage/helpers.js +0 -145
- package/dist/internal/advance-stage/hook.d.ts +0 -8
- package/dist/internal/advance-stage/hook.js +0 -40
- package/dist/internal/advance-stage/parsers.d.ts +0 -72
- package/dist/internal/advance-stage/parsers.js +0 -357
- package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +0 -24
- package/dist/internal/advance-stage/proactive-delegation-trace.js +0 -56
- package/dist/internal/advance-stage/review-loop.d.ts +0 -16
- package/dist/internal/advance-stage/review-loop.js +0 -199
- package/dist/internal/advance-stage/rewind.d.ts +0 -14
- package/dist/internal/advance-stage/rewind.js +0 -108
- package/dist/internal/advance-stage/start-flow.d.ts +0 -13
- package/dist/internal/advance-stage/start-flow.js +0 -241
- package/dist/internal/advance-stage/verify.d.ts +0 -21
- package/dist/internal/advance-stage/verify.js +0 -185
- package/dist/internal/advance-stage.d.ts +0 -7
- package/dist/internal/advance-stage.js +0 -138
- package/dist/internal/cohesion-contract-stub.d.ts +0 -24
- package/dist/internal/cohesion-contract-stub.js +0 -148
- package/dist/internal/compound-readiness.d.ts +0 -23
- package/dist/internal/compound-readiness.js +0 -102
- package/dist/internal/detect-public-api-changes.d.ts +0 -5
- package/dist/internal/detect-public-api-changes.js +0 -45
- package/dist/internal/detect-supply-chain-changes.d.ts +0 -6
- package/dist/internal/detect-supply-chain-changes.js +0 -138
- package/dist/internal/early-loop-status.d.ts +0 -7
- package/dist/internal/early-loop-status.js +0 -93
- package/dist/internal/envelope-validate.d.ts +0 -7
- package/dist/internal/envelope-validate.js +0 -66
- package/dist/internal/flow-state-repair.d.ts +0 -20
- package/dist/internal/flow-state-repair.js +0 -104
- package/dist/internal/plan-split-waves.d.ts +0 -190
- package/dist/internal/plan-split-waves.js +0 -764
- package/dist/internal/runtime-integrity.d.ts +0 -7
- package/dist/internal/runtime-integrity.js +0 -268
- package/dist/internal/slice-commit.d.ts +0 -7
- package/dist/internal/slice-commit.js +0 -619
- package/dist/internal/tdd-loop-status.d.ts +0 -14
- package/dist/internal/tdd-loop-status.js +0 -68
- package/dist/internal/tdd-red-evidence.d.ts +0 -7
- package/dist/internal/tdd-red-evidence.js +0 -153
- package/dist/internal/waiver-grant.d.ts +0 -62
- package/dist/internal/waiver-grant.js +0 -294
- package/dist/internal/wave-status.d.ts +0 -74
- package/dist/internal/wave-status.js +0 -506
- package/dist/managed-resources.d.ts +0 -53
- package/dist/managed-resources.js +0 -313
- package/dist/policy.d.ts +0 -10
- package/dist/policy.js +0 -167
- package/dist/retro-gate.d.ts +0 -9
- package/dist/retro-gate.js +0 -47
- package/dist/run-archive.d.ts +0 -61
- package/dist/run-archive.js +0 -391
- package/dist/runs.d.ts +0 -2
- package/dist/runs.js +0 -2
- package/dist/stack-detection.d.ts +0 -116
- package/dist/stack-detection.js +0 -489
- package/dist/streaming/event-stream.d.ts +0 -31
- package/dist/streaming/event-stream.js +0 -114
- package/dist/tdd-cycle.d.ts +0 -107
- package/dist/tdd-cycle.js +0 -289
- package/dist/tdd-verification-evidence.d.ts +0 -17
- package/dist/tdd-verification-evidence.js +0 -122
- package/dist/track-heuristics.d.ts +0 -27
- package/dist/track-heuristics.js +0 -154
- package/dist/util/slice-id.d.ts +0 -58
- package/dist/util/slice-id.js +0 -89
- package/dist/worktree-manager.d.ts +0 -20
- package/dist/worktree-manager.js +0 -108
package/dist/delegation.js
DELETED
|
@@ -1,1539 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
|
-
import { RUNTIME_ROOT } from "./constants.js";
|
|
6
|
-
import { readConfig, resolveMaxBuilders } from "./config.js";
|
|
7
|
-
import { exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
|
|
8
|
-
import { HARNESS_ADAPTERS } from "./harness-adapters.js";
|
|
9
|
-
import { readFlowState } from "./runs.js";
|
|
10
|
-
import { mandatoryAgentsFor, stageSchema } from "./content/stage-schema.js";
|
|
11
|
-
import { compareCanonicalUnitIds, mergeParallelWaveDefinitions, parseImplementationUnitParallelFields, parseImplementationUnits, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "./internal/plan-split-waves.js";
|
|
12
|
-
import { compareSliceIds } from "./util/slice-id.js";
|
|
13
|
-
const execFileAsync = promisify(execFile);
|
|
14
|
-
const TERMINAL_DELEGATION_STATUSES = new Set(["completed", "failed", "waived", "stale"]);
|
|
15
|
-
export const DELEGATION_DISPATCH_SURFACES = [
|
|
16
|
-
"claude-task",
|
|
17
|
-
"cursor-task",
|
|
18
|
-
"opencode-agent",
|
|
19
|
-
"codex-agent",
|
|
20
|
-
"generic-task",
|
|
21
|
-
"role-switch",
|
|
22
|
-
"manual"
|
|
23
|
-
];
|
|
24
|
-
/** Agents that declare `claimedPaths` for parallel/disjoint scheduling and fan-out caps. */
|
|
25
|
-
export function isParallelTddSliceWorker(agent) {
|
|
26
|
-
return agent === "slice-builder";
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Per-surface allowed agent-definition path prefixes. Used by the generated
|
|
30
|
-
* `.cclaw/hooks/delegation-record.mjs` helper to reject mismatched
|
|
31
|
-
* `--agent-definition-path` values without inspecting any harness state.
|
|
32
|
-
*
|
|
33
|
-
* The list is intentionally structural: each surface maps to one or more
|
|
34
|
-
* repo-relative path prefixes that must be a parent of the supplied path.
|
|
35
|
-
* `role-switch` and `manual` accept any path because the agent-definition
|
|
36
|
-
* is intentionally not a generated artifact for those surfaces.
|
|
37
|
-
*/
|
|
38
|
-
export const DELEGATION_DISPATCH_SURFACE_PATH_PREFIXES = {
|
|
39
|
-
"claude-task": [".claude/agents/", ".cclaw/agents/"],
|
|
40
|
-
"cursor-task": [".cursor/agents/", ".cclaw/agents/"],
|
|
41
|
-
"opencode-agent": [".opencode/agents/", ".cclaw/agents/"],
|
|
42
|
-
"codex-agent": [".codex/agents/", ".cclaw/agents/"],
|
|
43
|
-
"generic-task": [".cclaw/agents/"],
|
|
44
|
-
"role-switch": [],
|
|
45
|
-
"manual": []
|
|
46
|
-
};
|
|
47
|
-
export const DELEGATION_PHASES = [
|
|
48
|
-
"red",
|
|
49
|
-
"green",
|
|
50
|
-
"refactor",
|
|
51
|
-
"refactor-deferred",
|
|
52
|
-
"doc",
|
|
53
|
-
"resolve-conflict"
|
|
54
|
-
];
|
|
55
|
-
export const DELEGATION_LEDGER_SCHEMA_VERSION = 3;
|
|
56
|
-
function delegationLogPath(projectRoot) {
|
|
57
|
-
return path.join(projectRoot, RUNTIME_ROOT, "state", "delegation-log.json");
|
|
58
|
-
}
|
|
59
|
-
function delegationLockPath(projectRoot) {
|
|
60
|
-
return path.join(projectRoot, RUNTIME_ROOT, "state", ".delegation.lock");
|
|
61
|
-
}
|
|
62
|
-
function delegationEventsPath(projectRoot) {
|
|
63
|
-
return path.join(projectRoot, RUNTIME_ROOT, "state", "delegation-events.jsonl");
|
|
64
|
-
}
|
|
65
|
-
function subagentsStatePath(projectRoot) {
|
|
66
|
-
return path.join(projectRoot, RUNTIME_ROOT, "state", "subagents.json");
|
|
67
|
-
}
|
|
68
|
-
function createSpanId() {
|
|
69
|
-
return `dspan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
70
|
-
}
|
|
71
|
-
function activeHarnessSubagentFallback() {
|
|
72
|
-
const activeHarness = process.env.CCLAW_ACTIVE_HARNESS;
|
|
73
|
-
if (!activeHarness)
|
|
74
|
-
return undefined;
|
|
75
|
-
return HARNESS_ADAPTERS[activeHarness]
|
|
76
|
-
?.capabilities.subagentFallback;
|
|
77
|
-
}
|
|
78
|
-
async function resolveReviewDiffBase(projectRoot) {
|
|
79
|
-
let head = "";
|
|
80
|
-
try {
|
|
81
|
-
head = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: projectRoot })).stdout.trim();
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
const candidates = ["origin/main", "origin/master", "main", "master"];
|
|
87
|
-
for (const candidate of candidates) {
|
|
88
|
-
try {
|
|
89
|
-
await execFileAsync("git", ["rev-parse", "--verify", candidate], { cwd: projectRoot });
|
|
90
|
-
const { stdout } = await execFileAsync("git", ["merge-base", "HEAD", candidate], {
|
|
91
|
-
cwd: projectRoot
|
|
92
|
-
});
|
|
93
|
-
const base = stdout.trim();
|
|
94
|
-
if (base.length > 0 && base !== head) {
|
|
95
|
-
return base;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
try {
|
|
103
|
-
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD~1"], {
|
|
104
|
-
cwd: projectRoot
|
|
105
|
-
});
|
|
106
|
-
const base = stdout.trim();
|
|
107
|
-
return base.length > 0 ? base : null;
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Heuristic: does a changed file path strongly imply a trust-boundary
|
|
115
|
-
* surface? Used by tests and prompt guidance for risk-triggered review.
|
|
116
|
-
*
|
|
117
|
-
* Matches authN/Z, credentials, crypto, policy, or explicit sanitization
|
|
118
|
-
* or injection handling. Intentionally excludes broad terms like `input`
|
|
119
|
-
* and `validation` because they match innocuous paths such as
|
|
120
|
-
* `form-input.ts` or `number-validation.ts` and produce false positives.
|
|
121
|
-
*/
|
|
122
|
-
export function isTrustBoundaryPath(filePath) {
|
|
123
|
-
return /(auth|security|secret|token|credential|permission|acl|policy|oauth|session|encrypt|decrypt|sanitize|untrusted|csrf|xss|injection|taint)/iu.test(filePath);
|
|
124
|
-
}
|
|
125
|
-
async function detectReviewTriggers(projectRoot) {
|
|
126
|
-
const empty = {
|
|
127
|
-
changedFiles: 0,
|
|
128
|
-
changedLines: 0,
|
|
129
|
-
trustBoundaryChanged: false
|
|
130
|
-
};
|
|
131
|
-
const base = await resolveReviewDiffBase(projectRoot);
|
|
132
|
-
if (!base) {
|
|
133
|
-
return empty;
|
|
134
|
-
}
|
|
135
|
-
try {
|
|
136
|
-
const range = `${base}..HEAD`;
|
|
137
|
-
const shortstat = await execFileAsync("git", ["diff", "--shortstat", range], {
|
|
138
|
-
cwd: projectRoot
|
|
139
|
-
});
|
|
140
|
-
const short = shortstat.stdout.trim();
|
|
141
|
-
const changedFiles = Number((/(\d+)\s+files?\s+changed/u.exec(short)?.[1] ?? "0"));
|
|
142
|
-
const insertions = Number((/(\d+)\s+insertions?\(\+\)/u.exec(short)?.[1] ?? "0"));
|
|
143
|
-
const deletions = Number((/(\d+)\s+deletions?\(-\)/u.exec(short)?.[1] ?? "0"));
|
|
144
|
-
const changedLines = insertions + deletions;
|
|
145
|
-
const names = await execFileAsync("git", ["diff", "--name-only", range], {
|
|
146
|
-
cwd: projectRoot
|
|
147
|
-
});
|
|
148
|
-
const changedPaths = names.stdout
|
|
149
|
-
.split(/\r?\n/gu)
|
|
150
|
-
.map((line) => line.trim())
|
|
151
|
-
.filter((line) => line.length > 0);
|
|
152
|
-
const trustBoundaryChanged = changedPaths.some((p) => isTrustBoundaryPath(p));
|
|
153
|
-
return {
|
|
154
|
-
changedFiles,
|
|
155
|
-
changedLines,
|
|
156
|
-
trustBoundaryChanged
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return empty;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
function hasValidWaiverReason(value) {
|
|
164
|
-
return typeof value === "string" && value.trim().length > 0;
|
|
165
|
-
}
|
|
166
|
-
function isDelegationTokenUsage(value) {
|
|
167
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
168
|
-
return false;
|
|
169
|
-
const o = value;
|
|
170
|
-
return (typeof o.input === "number" &&
|
|
171
|
-
Number.isFinite(o.input) &&
|
|
172
|
-
typeof o.output === "number" &&
|
|
173
|
-
Number.isFinite(o.output) &&
|
|
174
|
-
typeof o.model === "string" &&
|
|
175
|
-
o.model.trim().length > 0);
|
|
176
|
-
}
|
|
177
|
-
function isDelegationEntry(value) {
|
|
178
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
179
|
-
return false;
|
|
180
|
-
const o = value;
|
|
181
|
-
const modeOk = o.mode === "mandatory" || o.mode === "proactive";
|
|
182
|
-
const statusOk = o.status === "scheduled" ||
|
|
183
|
-
o.status === "launched" ||
|
|
184
|
-
o.status === "acknowledged" ||
|
|
185
|
-
o.status === "completed" ||
|
|
186
|
-
o.status === "failed" ||
|
|
187
|
-
o.status === "waived" ||
|
|
188
|
-
o.status === "stale";
|
|
189
|
-
const timestampOk = typeof o.ts === "string" ||
|
|
190
|
-
typeof o.startTs === "string";
|
|
191
|
-
const terminalStatus = o.status === "completed" || o.status === "failed" || o.status === "waived" || o.status === "stale";
|
|
192
|
-
const lifecycleOk = (o.status !== "scheduled" && o.status !== "launched" && o.status !== "acknowledged") || o.endTs === undefined;
|
|
193
|
-
const terminalLifecycleOk = !terminalStatus ||
|
|
194
|
-
o.endTs === undefined ||
|
|
195
|
-
typeof o.endTs === "string";
|
|
196
|
-
const retryOk = o.retryCount === undefined ||
|
|
197
|
-
(typeof o.retryCount === "number" &&
|
|
198
|
-
Number.isFinite(o.retryCount) &&
|
|
199
|
-
Number.isInteger(o.retryCount) &&
|
|
200
|
-
o.retryCount >= 0);
|
|
201
|
-
const waiverOk = o.status !== "waived" || hasValidWaiverReason(o.waiverReason);
|
|
202
|
-
return (typeof o.stage === "string" &&
|
|
203
|
-
typeof o.agent === "string" &&
|
|
204
|
-
modeOk &&
|
|
205
|
-
statusOk &&
|
|
206
|
-
timestampOk &&
|
|
207
|
-
lifecycleOk &&
|
|
208
|
-
terminalLifecycleOk &&
|
|
209
|
-
(o.spanId === undefined || typeof o.spanId === "string") &&
|
|
210
|
-
(o.parentSpanId === undefined || typeof o.parentSpanId === "string") &&
|
|
211
|
-
(o.startTs === undefined || typeof o.startTs === "string") &&
|
|
212
|
-
(o.endTs === undefined || typeof o.endTs === "string") &&
|
|
213
|
-
(o.taskId === undefined || typeof o.taskId === "string") &&
|
|
214
|
-
(o.waiverReason === undefined || typeof o.waiverReason === "string") &&
|
|
215
|
-
(o.acceptedBy === undefined || o.acceptedBy === "user-flag") &&
|
|
216
|
-
(o.approvalToken === undefined || typeof o.approvalToken === "string") &&
|
|
217
|
-
(o.approvalReason === undefined || typeof o.approvalReason === "string") &&
|
|
218
|
-
(o.approvalIssuedAt === undefined || typeof o.approvalIssuedAt === "string") &&
|
|
219
|
-
waiverOk &&
|
|
220
|
-
(o.runId === undefined || typeof o.runId === "string") &&
|
|
221
|
-
(o.fulfillmentMode === undefined ||
|
|
222
|
-
o.fulfillmentMode === "isolated" ||
|
|
223
|
-
o.fulfillmentMode === "generic-dispatch" ||
|
|
224
|
-
o.fulfillmentMode === "role-switch" ||
|
|
225
|
-
o.fulfillmentMode === "harness-waiver" ||
|
|
226
|
-
o.fulfillmentMode === "legacy-inferred") &&
|
|
227
|
-
(o.conditionTrigger === undefined || typeof o.conditionTrigger === "string") &&
|
|
228
|
-
(o.dispatchId === undefined || typeof o.dispatchId === "string") &&
|
|
229
|
-
(o.workerRunId === undefined || typeof o.workerRunId === "string") &&
|
|
230
|
-
(o.dispatchSurface === undefined || isDelegationDispatchSurface(o.dispatchSurface)) &&
|
|
231
|
-
(o.agentDefinitionPath === undefined || typeof o.agentDefinitionPath === "string") &&
|
|
232
|
-
(o.ackTs === undefined || typeof o.ackTs === "string") &&
|
|
233
|
-
(o.launchedTs === undefined || typeof o.launchedTs === "string") &&
|
|
234
|
-
(o.completedTs === undefined || typeof o.completedTs === "string") &&
|
|
235
|
-
(o.tokens === undefined || isDelegationTokenUsage(o.tokens)) &&
|
|
236
|
-
retryOk &&
|
|
237
|
-
(o.evidenceRefs === undefined || (Array.isArray(o.evidenceRefs) && o.evidenceRefs.every((item) => typeof item === "string"))) &&
|
|
238
|
-
(o.skill === undefined || typeof o.skill === "string") &&
|
|
239
|
-
(o.schemaVersion === undefined || o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3) &&
|
|
240
|
-
(o.allowParallel === undefined || typeof o.allowParallel === "boolean") &&
|
|
241
|
-
(o.supersededBy === undefined || typeof o.supersededBy === "string") &&
|
|
242
|
-
(o.claimedPaths === undefined ||
|
|
243
|
-
(Array.isArray(o.claimedPaths) && o.claimedPaths.every((item) => typeof item === "string"))) &&
|
|
244
|
-
(o.sliceId === undefined || typeof o.sliceId === "string") &&
|
|
245
|
-
(o.phase === undefined ||
|
|
246
|
-
(typeof o.phase === "string" &&
|
|
247
|
-
DELEGATION_PHASES.includes(o.phase))) &&
|
|
248
|
-
(o.refactorOutcome === undefined || isRefactorOutcomeShape(o.refactorOutcome)) &&
|
|
249
|
-
(o.riskTier === undefined ||
|
|
250
|
-
o.riskTier === "low" ||
|
|
251
|
-
o.riskTier === "medium" ||
|
|
252
|
-
o.riskTier === "high"));
|
|
253
|
-
}
|
|
254
|
-
function isRefactorOutcomeShape(value) {
|
|
255
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
256
|
-
return false;
|
|
257
|
-
const o = value;
|
|
258
|
-
if (o.mode !== "inline" && o.mode !== "deferred")
|
|
259
|
-
return false;
|
|
260
|
-
if (o.rationale !== undefined && typeof o.rationale !== "string")
|
|
261
|
-
return false;
|
|
262
|
-
return true;
|
|
263
|
-
}
|
|
264
|
-
function isDelegationDispatchSurface(value) {
|
|
265
|
-
return typeof value === "string" && DELEGATION_DISPATCH_SURFACES.includes(value);
|
|
266
|
-
}
|
|
267
|
-
function statusTimestampPatch(entry, ts) {
|
|
268
|
-
const patch = { ...entry };
|
|
269
|
-
if (patch.status === "launched")
|
|
270
|
-
patch.launchedTs = patch.launchedTs ?? ts;
|
|
271
|
-
if (patch.status === "acknowledged")
|
|
272
|
-
patch.ackTs = patch.ackTs ?? ts;
|
|
273
|
-
if (patch.status === "completed")
|
|
274
|
-
patch.completedTs = patch.completedTs ?? patch.endTs ?? ts;
|
|
275
|
-
return patch;
|
|
276
|
-
}
|
|
277
|
-
function eventFromEntry(entry) {
|
|
278
|
-
const eventTs = entry.completedTs ?? entry.ackTs ?? entry.launchedTs ?? entry.endTs ?? entry.startTs ?? entry.ts ?? new Date().toISOString();
|
|
279
|
-
return {
|
|
280
|
-
...entry,
|
|
281
|
-
event: entry.status,
|
|
282
|
-
eventTs,
|
|
283
|
-
schemaVersion: DELEGATION_LEDGER_SCHEMA_VERSION
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
function isDelegationEvent(value) {
|
|
287
|
-
if (!isDelegationEntry(value))
|
|
288
|
-
return false;
|
|
289
|
-
const o = value;
|
|
290
|
-
if (o.event !== o.status || typeof o.eventTs !== "string")
|
|
291
|
-
return false;
|
|
292
|
-
return true;
|
|
293
|
-
}
|
|
294
|
-
function parseLedger(raw, runId) {
|
|
295
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
296
|
-
return { runId, entries: [], schemaVersion: DELEGATION_LEDGER_SCHEMA_VERSION };
|
|
297
|
-
}
|
|
298
|
-
const o = raw;
|
|
299
|
-
const ledgerSchemaVersion = (o.schemaVersion === 1 || o.schemaVersion === 2 || o.schemaVersion === 3
|
|
300
|
-
? o.schemaVersion
|
|
301
|
-
: undefined);
|
|
302
|
-
const entriesRaw = o.entries;
|
|
303
|
-
const entries = [];
|
|
304
|
-
if (Array.isArray(entriesRaw)) {
|
|
305
|
-
for (const item of entriesRaw) {
|
|
306
|
-
if (isDelegationEntry(item)) {
|
|
307
|
-
const ts = item.startTs ?? item.ts ?? new Date().toISOString();
|
|
308
|
-
// A row is "pre-v3 legacy" when the file format predates the
|
|
309
|
-
// dispatch-proof contract: schemaVersion is missing on both ledger
|
|
310
|
-
// and entry, the entry has no fulfillmentMode, and there is no
|
|
311
|
-
// dispatch-surface or dispatch-id evidence on the row. We honor
|
|
312
|
-
// that by tagging fulfillmentMode = "legacy-inferred" so callers
|
|
313
|
-
// (stage-complete, sync/runtime checks) can require an explicit `--rerecord`
|
|
314
|
-
// before the row counts as proof-era.
|
|
315
|
-
const ledgerHasNoVersion = ledgerSchemaVersion === undefined || ledgerSchemaVersion === 1;
|
|
316
|
-
const entryHasNoVersion = item.schemaVersion === undefined || item.schemaVersion === 1;
|
|
317
|
-
const looksLegacy = ledgerHasNoVersion &&
|
|
318
|
-
entryHasNoVersion &&
|
|
319
|
-
item.fulfillmentMode === undefined &&
|
|
320
|
-
item.dispatchSurface === undefined &&
|
|
321
|
-
item.dispatchId === undefined &&
|
|
322
|
-
item.workerRunId === undefined &&
|
|
323
|
-
item.agentDefinitionPath === undefined &&
|
|
324
|
-
item.status === "completed";
|
|
325
|
-
const inferredFulfillmentMode = item.fulfillmentMode
|
|
326
|
-
?? (looksLegacy ? "legacy-inferred" : (item.status === "completed" && item.schemaVersion === undefined ? "isolated" : undefined));
|
|
327
|
-
entries.push({
|
|
328
|
-
...item,
|
|
329
|
-
spanId: item.spanId ?? createSpanId(),
|
|
330
|
-
startTs: ts,
|
|
331
|
-
endTs: TERMINAL_DELEGATION_STATUSES.has(item.status) ? (item.endTs ?? ts) : undefined,
|
|
332
|
-
ts,
|
|
333
|
-
launchedTs: item.launchedTs ?? (item.status === "launched" ? ts : undefined),
|
|
334
|
-
ackTs: item.ackTs ?? (item.status === "acknowledged" ? ts : undefined),
|
|
335
|
-
completedTs: item.completedTs ?? (item.status === "completed" ? (item.endTs ?? ts) : undefined),
|
|
336
|
-
retryCount: typeof item.retryCount === "number" && Number.isInteger(item.retryCount) && item.retryCount >= 0
|
|
337
|
-
? item.retryCount
|
|
338
|
-
: 0,
|
|
339
|
-
evidenceRefs: Array.isArray(item.evidenceRefs) ? item.evidenceRefs : [],
|
|
340
|
-
fulfillmentMode: inferredFulfillmentMode,
|
|
341
|
-
schemaVersion: item.schemaVersion ?? DELEGATION_LEDGER_SCHEMA_VERSION
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return { runId, entries, schemaVersion: ledgerSchemaVersion ?? DELEGATION_LEDGER_SCHEMA_VERSION };
|
|
347
|
-
}
|
|
348
|
-
export async function readDelegationLedger(projectRoot) {
|
|
349
|
-
const { activeRunId } = await readFlowState(projectRoot);
|
|
350
|
-
const filePath = delegationLogPath(projectRoot);
|
|
351
|
-
if (!(await exists(filePath))) {
|
|
352
|
-
return { runId: activeRunId, entries: [] };
|
|
353
|
-
}
|
|
354
|
-
try {
|
|
355
|
-
const text = await fs.readFile(filePath, "utf8");
|
|
356
|
-
const parsed = JSON.parse(text);
|
|
357
|
-
return parseLedger(parsed, activeRunId);
|
|
358
|
-
}
|
|
359
|
-
catch {
|
|
360
|
-
return { runId: activeRunId, entries: [] };
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* Audit-only event types that live in
|
|
365
|
-
* `delegation-events.jsonl` but do NOT carry a delegation lifecycle
|
|
366
|
-
* payload (no agent/spanId). The parser must accept them so they
|
|
367
|
-
* don't show up as corrupt lines.
|
|
368
|
-
*/
|
|
369
|
-
const NON_DELEGATION_AUDIT_EVENTS = new Set([
|
|
370
|
-
"mandatory_delegations_skipped_by_track",
|
|
371
|
-
"artifact_validation_demoted_by_track",
|
|
372
|
-
"expansion_strategist_skipped_by_track",
|
|
373
|
-
"cclaw_slice_lease_expired",
|
|
374
|
-
"cclaw_fanin_applied",
|
|
375
|
-
"cclaw_fanin_conflict",
|
|
376
|
-
"cclaw_fanin_resolved",
|
|
377
|
-
"cclaw_fanin_abandoned",
|
|
378
|
-
"cclaw_integration_overseer_skipped",
|
|
379
|
-
"cclaw_allow_parallel_auto_flip",
|
|
380
|
-
"slice-completed"
|
|
381
|
-
]);
|
|
382
|
-
function isAuditEventLine(parsed) {
|
|
383
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
384
|
-
return false;
|
|
385
|
-
const evt = parsed.event;
|
|
386
|
-
return typeof evt === "string" && NON_DELEGATION_AUDIT_EVENTS.has(evt);
|
|
387
|
-
}
|
|
388
|
-
export async function readDelegationEvents(projectRoot) {
|
|
389
|
-
const filePath = delegationEventsPath(projectRoot);
|
|
390
|
-
if (!(await exists(filePath))) {
|
|
391
|
-
return { events: [], corruptLines: [] };
|
|
392
|
-
}
|
|
393
|
-
const events = [];
|
|
394
|
-
const corruptLines = [];
|
|
395
|
-
const text = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
396
|
-
const lines = text.split(/\r?\n/gu);
|
|
397
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
398
|
-
const line = lines[index]?.trim() ?? "";
|
|
399
|
-
if (line.length === 0)
|
|
400
|
-
continue;
|
|
401
|
-
try {
|
|
402
|
-
const parsed = JSON.parse(line);
|
|
403
|
-
if (isDelegationEvent(parsed)) {
|
|
404
|
-
events.push(parsed);
|
|
405
|
-
}
|
|
406
|
-
else if (isAuditEventLine(parsed)) {
|
|
407
|
-
// Audit-only row (e.g. mandatory_delegations_skipped_by_track).
|
|
408
|
-
// Not a delegation lifecycle event but valid audit content.
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
corruptLines.push(index + 1);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
catch {
|
|
416
|
-
corruptLines.push(index + 1);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
return { events, corruptLines };
|
|
420
|
-
}
|
|
421
|
-
async function appendDelegationEvent(projectRoot, event) {
|
|
422
|
-
const filePath = delegationEventsPath(projectRoot);
|
|
423
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
424
|
-
await fs.appendFile(filePath, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* Effective timestamp used to order rows that share a `spanId`. Newest
|
|
428
|
-
* lifecycle column wins. Returns the empty string when nothing is set
|
|
429
|
-
* so the caller still has a stable lexicographic compare key.
|
|
430
|
-
*
|
|
431
|
-
* keep in sync with the inline copy in
|
|
432
|
-
* `src/content/hooks.ts::delegationRecordScript`.
|
|
433
|
-
*/
|
|
434
|
-
function effectiveSpanTs(entry) {
|
|
435
|
-
return entry.completedTs ?? entry.ackTs ?? entry.launchedTs ?? entry.endTs ?? entry.startTs ?? entry.ts ?? "";
|
|
436
|
-
}
|
|
437
|
-
const ACTIVE_DELEGATION_STATUSES = new Set([
|
|
438
|
-
"scheduled",
|
|
439
|
-
"launched",
|
|
440
|
-
"acknowledged"
|
|
441
|
-
]);
|
|
442
|
-
/**
|
|
443
|
-
* Fold ledger entries to the latest row per `spanId` and keep only spans
|
|
444
|
-
* whose latest status is still active (`scheduled | launched |
|
|
445
|
-
* acknowledged`). Used by the `state/subagents.json` writer so the
|
|
446
|
-
* tracker never reports a span that already has a terminal row.
|
|
447
|
-
*
|
|
448
|
-
* Output is ordered by ascending `startTs ?? ts` so existing UI
|
|
449
|
-
* consumers see a stable presentation order.
|
|
450
|
-
*
|
|
451
|
-
* Rows without a `spanId` are skipped — they are not addressable by
|
|
452
|
-
* the tracker contract and would collide on the empty key.
|
|
453
|
-
*
|
|
454
|
-
* Callers are expected to pass entries already filtered to the active
|
|
455
|
-
* `runId`; cross-run rows are therefore not re-filtered here.
|
|
456
|
-
*
|
|
457
|
-
* keep in sync with the inline copy in
|
|
458
|
-
* `src/content/hooks.ts::delegationRecordScript`.
|
|
459
|
-
*/
|
|
460
|
-
export function computeActiveSubagents(entries) {
|
|
461
|
-
const latestBySpan = new Map();
|
|
462
|
-
for (const entry of entries) {
|
|
463
|
-
if (!entry.spanId)
|
|
464
|
-
continue;
|
|
465
|
-
const existing = latestBySpan.get(entry.spanId);
|
|
466
|
-
if (!existing) {
|
|
467
|
-
latestBySpan.set(entry.spanId, entry);
|
|
468
|
-
continue;
|
|
469
|
-
}
|
|
470
|
-
const existingTs = effectiveSpanTs(existing);
|
|
471
|
-
const incomingTs = effectiveSpanTs(entry);
|
|
472
|
-
if (incomingTs >= existingTs) {
|
|
473
|
-
latestBySpan.set(entry.spanId, entry);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
const folded = [];
|
|
477
|
-
for (const entry of latestBySpan.values()) {
|
|
478
|
-
if (ACTIVE_DELEGATION_STATUSES.has(entry.status)) {
|
|
479
|
-
folded.push(entry);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
folded.sort((a, b) => {
|
|
483
|
-
const aKey = a.startTs ?? a.ts ?? "";
|
|
484
|
-
const bKey = b.startTs ?? b.ts ?? "";
|
|
485
|
-
if (aKey === bKey)
|
|
486
|
-
return 0;
|
|
487
|
-
return aKey < bKey ? -1 : 1;
|
|
488
|
-
});
|
|
489
|
-
return folded;
|
|
490
|
-
}
|
|
491
|
-
/**
|
|
492
|
-
* Thrown by `validateMonotonicTimestamps` when an incoming row
|
|
493
|
-
* would push a span's timeline backwards. Carries enough context that
|
|
494
|
-
* the CLI / hook surface can format a `delegation_timestamp_non_monotonic`
|
|
495
|
-
* JSON payload without re-deriving the offending field.
|
|
496
|
-
*
|
|
497
|
-
* keep in sync with the inline copy in
|
|
498
|
-
* `src/content/hooks.ts::delegationRecordScript`.
|
|
499
|
-
*/
|
|
500
|
-
export class DelegationTimestampError extends Error {
|
|
501
|
-
field;
|
|
502
|
-
actual;
|
|
503
|
-
priorBound;
|
|
504
|
-
constructor(field, actual, priorBound) {
|
|
505
|
-
super(`delegation_timestamp_non_monotonic — ${field}: ${actual} < ${priorBound}`);
|
|
506
|
-
this.name = "DelegationTimestampError";
|
|
507
|
-
this.field = field;
|
|
508
|
-
this.actual = actual;
|
|
509
|
-
this.priorBound = priorBound;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
/**
|
|
513
|
-
* Enforce that lifecycle timestamps on a delegation span move
|
|
514
|
-
* forward (or stay equal). Validates both per-row invariants
|
|
515
|
-
* (`startTs ≤ launchedTs ≤ ackTs ≤ completedTs`) and a cross-row
|
|
516
|
-
* invariant: the union of prior rows for this `spanId` plus the
|
|
517
|
-
* incoming row must have non-decreasing `ts`.
|
|
518
|
-
*
|
|
519
|
-
* Equality is allowed because fast-completing dispatches legitimately
|
|
520
|
-
* collapse multiple lifecycle markers onto the same instant.
|
|
521
|
-
*
|
|
522
|
-
* keep in sync with the inline copy in
|
|
523
|
-
* `src/content/hooks.ts::delegationRecordScript`.
|
|
524
|
-
*/
|
|
525
|
-
export function validateMonotonicTimestamps(stamped, prior) {
|
|
526
|
-
const startTs = stamped.startTs;
|
|
527
|
-
if (stamped.launchedTs && startTs && stamped.launchedTs < startTs) {
|
|
528
|
-
throw new DelegationTimestampError("launchedTs", stamped.launchedTs, startTs);
|
|
529
|
-
}
|
|
530
|
-
if (stamped.ackTs) {
|
|
531
|
-
const ackBound = stamped.launchedTs ?? startTs;
|
|
532
|
-
if (ackBound && stamped.ackTs < ackBound) {
|
|
533
|
-
throw new DelegationTimestampError("ackTs", stamped.ackTs, ackBound);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
if (stamped.completedTs) {
|
|
537
|
-
const completedBound = stamped.ackTs ?? stamped.launchedTs ?? startTs;
|
|
538
|
-
if (completedBound && stamped.completedTs < completedBound) {
|
|
539
|
-
throw new DelegationTimestampError("completedTs", stamped.completedTs, completedBound);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
if (!stamped.spanId)
|
|
543
|
-
return;
|
|
544
|
-
const priorForSpan = prior.filter((entry) => entry.spanId === stamped.spanId);
|
|
545
|
-
if (priorForSpan.length === 0)
|
|
546
|
-
return;
|
|
547
|
-
const timeline = [...priorForSpan, stamped]
|
|
548
|
-
.map((entry) => ({ entry, ts: entry.ts ?? entry.startTs ?? "" }))
|
|
549
|
-
.filter((row) => row.ts.length > 0)
|
|
550
|
-
.sort((a, b) => (a.ts === b.ts ? 0 : a.ts < b.ts ? -1 : 1));
|
|
551
|
-
for (let i = 1; i < timeline.length; i += 1) {
|
|
552
|
-
const previous = timeline[i - 1];
|
|
553
|
-
const current = timeline[i];
|
|
554
|
-
if (current.ts < previous.ts) {
|
|
555
|
-
throw new DelegationTimestampError("ts", current.ts, previous.ts);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
// Find the latest existing row by `ts` for the same spanId; if the
|
|
559
|
-
// new row's `ts` is older than that latest, the timeline regressed.
|
|
560
|
-
const latestPrior = priorForSpan
|
|
561
|
-
.map((entry) => entry.ts ?? entry.startTs ?? "")
|
|
562
|
-
.filter((ts) => ts.length > 0)
|
|
563
|
-
.sort()
|
|
564
|
-
.at(-1);
|
|
565
|
-
const stampedTs = stamped.ts ?? stamped.startTs ?? "";
|
|
566
|
-
if (latestPrior && stampedTs && stampedTs < latestPrior) {
|
|
567
|
-
throw new DelegationTimestampError("ts", stampedTs, latestPrior);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
/**
|
|
571
|
-
* Thrown by `appendDelegation` when the operator opens a
|
|
572
|
-
* second `scheduled` span on the same `(stage, agent)` pair while an
|
|
573
|
-
* earlier span on the same pair is still active. Callers can catch and
|
|
574
|
-
* either pass the existing span id via `--supersede=<id>` (which
|
|
575
|
-
* pre-writes a synthetic `stale` row) or `--allow-parallel` to record
|
|
576
|
-
* concurrent spans intentionally.
|
|
577
|
-
*/
|
|
578
|
-
export class DispatchDuplicateError extends Error {
|
|
579
|
-
existingSpanId;
|
|
580
|
-
existingStatus;
|
|
581
|
-
newSpanId;
|
|
582
|
-
pair;
|
|
583
|
-
constructor(params) {
|
|
584
|
-
super(`dispatch_duplicate — already-active spanId=${params.existingSpanId} (status=${params.existingStatus}) on stage=${params.pair.stage}, agent=${params.pair.agent}. ` +
|
|
585
|
-
`pass --supersede=${params.existingSpanId} to close the previous span as stale, or --allow-parallel to record both as concurrent.`);
|
|
586
|
-
this.name = "DispatchDuplicateError";
|
|
587
|
-
this.existingSpanId = params.existingSpanId;
|
|
588
|
-
this.existingStatus = params.existingStatus;
|
|
589
|
-
this.newSpanId = params.newSpanId;
|
|
590
|
-
this.pair = params.pair;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
/**
|
|
594
|
-
* Thrown by `validateFileOverlap` when a new `slice-builder` is scheduled
|
|
595
|
-
* on a TDD stage with at least one `claimedPaths` entry that overlaps an
|
|
596
|
-
* active span. The scheduler auto-allows parallel dispatch when paths are
|
|
597
|
-
* disjoint, so an explicit overlap is treated as a serialization signal:
|
|
598
|
-
* the operator must wait for the existing span to terminate or pass
|
|
599
|
-
* `--allow-parallel` deliberately to acknowledge the conflict.
|
|
600
|
-
*/
|
|
601
|
-
export class DispatchOverlapError extends Error {
|
|
602
|
-
existingSpanId;
|
|
603
|
-
newSpanId;
|
|
604
|
-
pair;
|
|
605
|
-
conflictingPaths;
|
|
606
|
-
constructor(params) {
|
|
607
|
-
super(`dispatch_overlap — slice-builder span ${params.newSpanId} claims path(s) ${params.conflictingPaths.join(", ")} already held by active spanId=${params.existingSpanId} on stage=${params.pair.stage}. ` +
|
|
608
|
-
`Wait for ${params.existingSpanId} to finish, dispatch a non-overlapping slice, or pass --allow-parallel to acknowledge the conflict.`);
|
|
609
|
-
this.name = "DispatchOverlapError";
|
|
610
|
-
this.existingSpanId = params.existingSpanId;
|
|
611
|
-
this.newSpanId = params.newSpanId;
|
|
612
|
-
this.pair = params.pair;
|
|
613
|
-
this.conflictingPaths = params.conflictingPaths;
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
/**
|
|
617
|
-
* Thrown when the count of active `slice-builder` spans reaches
|
|
618
|
-
* `MAX_PARALLEL_SLICE_BUILDERS` and a new scheduled row would push it past
|
|
619
|
-
* the cap. Cap can be configured via `.cclaw/config.yaml::execution.maxBuilders`,
|
|
620
|
-
* overridden once via `--override-cap=N` on the hook flag, or globally via
|
|
621
|
-
* `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<N>` env.
|
|
622
|
-
*/
|
|
623
|
-
export class DispatchCapError extends Error {
|
|
624
|
-
cap;
|
|
625
|
-
active;
|
|
626
|
-
pair;
|
|
627
|
-
constructor(params) {
|
|
628
|
-
super(`dispatch_cap — ${params.active} active ${params.pair.agent}(s) at the cap of ${params.cap}. ` +
|
|
629
|
-
`Complete one before scheduling another, or pass --override-cap=N (or CCLAW_MAX_PARALLEL_SLICE_BUILDERS=N) to lift the cap for this run.`);
|
|
630
|
-
this.name = "DispatchCapError";
|
|
631
|
-
this.cap = params.cap;
|
|
632
|
-
this.active = params.active;
|
|
633
|
-
this.pair = params.pair;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
/**
|
|
637
|
-
* Patterns describing repo-relative paths owned by the cclaw managed
|
|
638
|
-
* runtime under `.cclaw/`. Workers MUST NOT claim these as
|
|
639
|
-
* `claimedPaths` because they are regenerated/rebound by `cclaw-cli sync`
|
|
640
|
-
* (and similar managed flows), and worker writes silently bypass the
|
|
641
|
-
* managed-resources manifest. Note: `.cclaw/artifacts/` is intentionally
|
|
642
|
-
* NOT protected — slice-builders legitimately write slice cards there.
|
|
643
|
-
*
|
|
644
|
-
* Motivated by the hox-session 7.0.5 finding: subagent S-36 hand-edited
|
|
645
|
-
* `.cclaw/hooks/delegation-record.mjs`, which had to be reverted because
|
|
646
|
-
* the next `cclaw-cli sync` would have stomped the change.
|
|
647
|
-
*/
|
|
648
|
-
const MANAGED_RUNTIME_PATH_PATTERNS = [
|
|
649
|
-
/^\.cclaw\/(hooks|agents|skills|commands|templates|seeds|rules|state)\//u,
|
|
650
|
-
/^\.cclaw\/config\.yaml$/u,
|
|
651
|
-
/^\.cclaw\/managed-resources\.json$/u,
|
|
652
|
-
/^\.cclaw\/\.flow-state\.guard\.json$/u
|
|
653
|
-
];
|
|
654
|
-
/**
|
|
655
|
-
* Return `true` when `path` is a repo-relative path owned by the cclaw
|
|
656
|
-
* managed runtime under `.cclaw/`. Used by `validateClaimedPathsNotProtected`
|
|
657
|
-
* during `appendDelegation` to reject `slice-builder` (or any worker)
|
|
658
|
-
* spans that try to claim ownership of cclaw-managed files. Does not
|
|
659
|
-
* normalise the input — callers pass the path exactly as the worker wrote
|
|
660
|
-
* it into `claimedPaths` so the error message points at the real string.
|
|
661
|
-
*/
|
|
662
|
-
export function isManagedRuntimePath(path) {
|
|
663
|
-
if (typeof path !== "string" || path.length === 0)
|
|
664
|
-
return false;
|
|
665
|
-
return MANAGED_RUNTIME_PATH_PATTERNS.some((pattern) => pattern.test(path));
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* Thrown by `appendDelegation` when a scheduled span declares a
|
|
669
|
-
* `claimedPaths` entry that lives under the cclaw managed runtime
|
|
670
|
-
* (see `isManagedRuntimePath`). Workers must never edit those paths
|
|
671
|
-
* directly — they are owned by the managed sync surface. The error
|
|
672
|
-
* lists the offending paths so the operator can drop or rewrite them.
|
|
673
|
-
*/
|
|
674
|
-
export class DispatchClaimedPathProtectedError extends Error {
|
|
675
|
-
protectedPaths;
|
|
676
|
-
spanId;
|
|
677
|
-
constructor(params) {
|
|
678
|
-
super(`dispatch_claimed_path_protected — span ${params.spanId} claims managed-runtime path(s) ${params.protectedPaths.join(", ")}; ` +
|
|
679
|
-
`paths under .cclaw/{hooks,agents,skills,commands,templates,seeds,rules,state}/, .cclaw/config.yaml, .cclaw/managed-resources.json, and .cclaw/.flow-state.guard.json are owned by cclaw-cli sync and must not appear in claimedPaths. ` +
|
|
680
|
-
`Drop them from claimedPaths or, if a managed-runtime change is genuinely required, ship it through a cclaw release rather than a worker span.`);
|
|
681
|
-
this.name = "DispatchClaimedPathProtectedError";
|
|
682
|
-
this.protectedPaths = params.protectedPaths;
|
|
683
|
-
this.spanId = params.spanId;
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
/**
|
|
687
|
-
* Reject any worker span that declares `claimedPaths` entries owned by
|
|
688
|
-
* the cclaw managed runtime. Called from `appendDelegation` for
|
|
689
|
-
* `status === "scheduled"` rows alongside the overlap and fan-out
|
|
690
|
-
* checks. Throws `DispatchClaimedPathProtectedError` listing every
|
|
691
|
-
* offending path so the operator can fix the dispatch in one pass.
|
|
692
|
-
*/
|
|
693
|
-
export function validateClaimedPathsNotProtected(stamped) {
|
|
694
|
-
const claimed = Array.isArray(stamped.claimedPaths) ? stamped.claimedPaths : [];
|
|
695
|
-
if (claimed.length === 0)
|
|
696
|
-
return;
|
|
697
|
-
const offending = claimed.filter((p) => isManagedRuntimePath(p));
|
|
698
|
-
if (offending.length === 0)
|
|
699
|
-
return;
|
|
700
|
-
throw new DispatchClaimedPathProtectedError({
|
|
701
|
-
protectedPaths: offending,
|
|
702
|
-
spanId: stamped.spanId ?? "unknown"
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
/**
|
|
706
|
-
* Thrown by `appendDelegation` (and the inline `delegation-record.mjs`
|
|
707
|
-
* helper) when an event with a non-null `phase` is recorded with
|
|
708
|
-
* `status="acknowledged"`. Phase-level granularity only makes sense on
|
|
709
|
-
* terminal outcomes (`completed` or `failed`); the dispatch-level ACK
|
|
710
|
-
* (no phase) is the controller saying "I see the dispatch surface back".
|
|
711
|
-
*
|
|
712
|
-
* Motivated by hox W-08/S-41: the slice-builder agent recorded all four
|
|
713
|
-
* phase events with `--status=acknowledged`, which the helper silently
|
|
714
|
-
* accepted but `slice-commit.mjs` only fires on `phase=doc status=completed`.
|
|
715
|
-
* `wave-status` then saw the slice as phantom-open even though the
|
|
716
|
-
* worker had finished. Recovery required raw backfill commands.
|
|
717
|
-
*
|
|
718
|
-
* 7.6.0 makes the constraint explicit: pair `--phase=<phase>` with
|
|
719
|
-
* `--status=completed` (or `--status=failed`) and use
|
|
720
|
-
* `--status=acknowledged` only for the dispatch-level ack (no phase).
|
|
721
|
-
*/
|
|
722
|
-
export class PhaseEventRequiresTerminalStatusError extends Error {
|
|
723
|
-
phase;
|
|
724
|
-
status;
|
|
725
|
-
spanId;
|
|
726
|
-
correctedCommandHint;
|
|
727
|
-
constructor(params) {
|
|
728
|
-
super(`phase_event_requires_completed_or_failed_status — span ${params.spanId} recorded --phase=${params.phase} with --status=${params.status}; ` +
|
|
729
|
-
`phase-level events are only valid on terminal outcomes (--status=completed or --status=failed). ` +
|
|
730
|
-
`The dispatch-level ack (no --phase) can still use --status=acknowledged. ` +
|
|
731
|
-
`Corrected command: ${params.correctedCommandHint}`);
|
|
732
|
-
this.name = "PhaseEventRequiresTerminalStatusError";
|
|
733
|
-
this.phase = params.phase;
|
|
734
|
-
this.status = params.status;
|
|
735
|
-
this.spanId = params.spanId;
|
|
736
|
-
this.correctedCommandHint = params.correctedCommandHint;
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
/**
|
|
740
|
-
* Reject delegation rows where `phase` is set but `status` is not
|
|
741
|
-
* `completed` or `failed`. Acknowledged/launched/scheduled/waived/stale
|
|
742
|
-
* rows must NOT carry a phase — the phase-level lifecycle exists only
|
|
743
|
-
* to record terminal outcomes per phase (RED/GREEN/REFACTOR/DOC).
|
|
744
|
-
*
|
|
745
|
-
* Throws `PhaseEventRequiresTerminalStatusError`; the message includes
|
|
746
|
-
* an actionable corrected-command hint that the controller can paste.
|
|
747
|
-
*/
|
|
748
|
-
export function validatePhaseEventStatus(stamped) {
|
|
749
|
-
if (typeof stamped.phase !== "string" || stamped.phase.length === 0)
|
|
750
|
-
return;
|
|
751
|
-
if (stamped.status === "completed" || stamped.status === "failed")
|
|
752
|
-
return;
|
|
753
|
-
const phase = stamped.phase;
|
|
754
|
-
const sliceFlag = typeof stamped.sliceId === "string" && stamped.sliceId.length > 0
|
|
755
|
-
? `--slice=${stamped.sliceId} `
|
|
756
|
-
: "";
|
|
757
|
-
const spanFlag = typeof stamped.spanId === "string" && stamped.spanId.length > 0
|
|
758
|
-
? `--span-id=${stamped.spanId} `
|
|
759
|
-
: "";
|
|
760
|
-
const correctedCommandHint = `node .cclaw/hooks/delegation-record.mjs --stage=${stamped.stage} --agent=${stamped.agent} --mode=${stamped.mode} --status=completed --phase=${phase} ${sliceFlag}${spanFlag}--evidence-ref="<phase outcome>"`;
|
|
761
|
-
throw new PhaseEventRequiresTerminalStatusError({
|
|
762
|
-
phase,
|
|
763
|
-
status: stamped.status,
|
|
764
|
-
spanId: stamped.spanId ?? "unknown",
|
|
765
|
-
correctedCommandHint
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
/**
|
|
769
|
-
* Thrown by `appendDelegation` when a new `scheduled` span would open a
|
|
770
|
-
* second TDD cycle for a slice that already has at least one closed span
|
|
771
|
-
* (a span with completed phase rows for `red`, `green`, at least one of
|
|
772
|
-
* `refactor`/`refactor-deferred`, and `doc`) in the same run. Re-running
|
|
773
|
-
* a slice under a fresh span is almost always controller drift —
|
|
774
|
-
* legitimate replay reuses the original spanId and is absorbed by the
|
|
775
|
-
* existing dedup. Motivated by the hox-session 7.0.5 finding where
|
|
776
|
-
* `S-36` had two scheduled spans (`span-w07-S-36-final` and `span-w07-S-36`)
|
|
777
|
-
* that the linter then misread as out-of-order phases.
|
|
778
|
-
*/
|
|
779
|
-
export class SliceAlreadyClosedError extends Error {
|
|
780
|
-
sliceId;
|
|
781
|
-
runId;
|
|
782
|
-
closedSpanId;
|
|
783
|
-
newSpanId;
|
|
784
|
-
constructor(params) {
|
|
785
|
-
super(`slice ${params.sliceId} already has a closed span (${params.closedSpanId}); refusing to schedule new span ${params.newSpanId} in run ${params.runId}`);
|
|
786
|
-
this.name = "SliceAlreadyClosedError";
|
|
787
|
-
this.sliceId = params.sliceId;
|
|
788
|
-
this.runId = params.runId;
|
|
789
|
-
this.closedSpanId = params.closedSpanId;
|
|
790
|
-
this.newSpanId = params.newSpanId;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
/**
|
|
794
|
-
* Detect closed spans for `(sliceId, runId)`. A span is considered
|
|
795
|
-
* closed when it has completed phase rows for `red`, `green`, REFACTOR
|
|
796
|
-
* coverage (either `phase=refactor`, `phase=refactor-deferred`, or
|
|
797
|
-
* `phase=green` carrying `refactorOutcome`), AND `doc`. Returns the set of
|
|
798
|
-
* closed spanIds; callers use this to reject new scheduled spans on
|
|
799
|
-
* already-closed slices.
|
|
800
|
-
*/
|
|
801
|
-
function closedSliceSpans(prior, sliceId, runId) {
|
|
802
|
-
const closed = new Set();
|
|
803
|
-
if (typeof sliceId !== "string" || sliceId.length === 0)
|
|
804
|
-
return closed;
|
|
805
|
-
const matches = prior.filter((entry) => entry.sliceId === sliceId &&
|
|
806
|
-
entry.runId === runId &&
|
|
807
|
-
typeof entry.spanId === "string" &&
|
|
808
|
-
entry.spanId.length > 0);
|
|
809
|
-
const bySpan = new Map();
|
|
810
|
-
for (const entry of matches) {
|
|
811
|
-
const spanId = entry.spanId;
|
|
812
|
-
const existing = bySpan.get(spanId) ?? [];
|
|
813
|
-
existing.push(entry);
|
|
814
|
-
bySpan.set(spanId, existing);
|
|
815
|
-
}
|
|
816
|
-
for (const [spanId, entries] of bySpan.entries()) {
|
|
817
|
-
const phases = new Set(entries
|
|
818
|
-
.filter((e) => e.status === "completed" && typeof e.phase === "string")
|
|
819
|
-
.map((e) => e.phase));
|
|
820
|
-
const hasRed = phases.has("red");
|
|
821
|
-
const hasGreen = phases.has("green");
|
|
822
|
-
const hasRefactorPhase = phases.has("refactor") || phases.has("refactor-deferred");
|
|
823
|
-
const greens = entries.filter((e) => e.status === "completed" && e.phase === "green");
|
|
824
|
-
const greenWithOutcome = greens.find((e) => e.refactorOutcome &&
|
|
825
|
-
(e.refactorOutcome.mode === "inline" || e.refactorOutcome.mode === "deferred"));
|
|
826
|
-
let hasRefactorFromGreen = false;
|
|
827
|
-
if (greenWithOutcome?.refactorOutcome?.mode === "deferred") {
|
|
828
|
-
hasRefactorFromGreen = !!((greenWithOutcome.refactorOutcome.rationale &&
|
|
829
|
-
greenWithOutcome.refactorOutcome.rationale.trim().length > 0) ||
|
|
830
|
-
(Array.isArray(greenWithOutcome.evidenceRefs) &&
|
|
831
|
-
greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0)));
|
|
832
|
-
}
|
|
833
|
-
else if (greenWithOutcome?.refactorOutcome?.mode === "inline") {
|
|
834
|
-
hasRefactorFromGreen = true;
|
|
835
|
-
}
|
|
836
|
-
const hasRefactor = hasRefactorPhase || hasRefactorFromGreen;
|
|
837
|
-
const hasDoc = phases.has("doc");
|
|
838
|
-
if (hasRed && hasGreen && hasRefactor && hasDoc) {
|
|
839
|
-
closed.add(spanId);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
return closed;
|
|
843
|
-
}
|
|
844
|
-
/**
|
|
845
|
-
* Default cap on active `slice-builder` spans in a single TDD run. Override
|
|
846
|
-
* via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<int>` (validated `>=1`).
|
|
847
|
-
*/
|
|
848
|
-
export const MAX_PARALLEL_SLICE_BUILDERS = 5;
|
|
849
|
-
/**
|
|
850
|
-
* Return up to `cap` slice units whose dependsOn are satisfied, avoiding
|
|
851
|
-
* `claimedPaths` intersections with already-selected units and active holders.
|
|
852
|
-
*/
|
|
853
|
-
export function selectReadySlices(units, opts) {
|
|
854
|
-
const ordered = [...units].sort((a, b) => compareCanonicalUnitIds(a.unitId, b.unitId));
|
|
855
|
-
const selected = [];
|
|
856
|
-
const blockedPaths = new Set();
|
|
857
|
-
for (const holder of opts.activePathHolders) {
|
|
858
|
-
for (const p of holder.paths) {
|
|
859
|
-
blockedPaths.add(p);
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
for (const u of ordered) {
|
|
863
|
-
if (opts.completedUnitIds.has(u.unitId))
|
|
864
|
-
continue;
|
|
865
|
-
if (!u.dependsOn.every((d) => opts.completedUnitIds.has(d)))
|
|
866
|
-
continue;
|
|
867
|
-
let clash = false;
|
|
868
|
-
for (const p of u.claimedPaths) {
|
|
869
|
-
if (blockedPaths.has(p)) {
|
|
870
|
-
clash = true;
|
|
871
|
-
break;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
if (clash)
|
|
875
|
-
continue;
|
|
876
|
-
for (const v of selected) {
|
|
877
|
-
for (const pu of u.claimedPaths) {
|
|
878
|
-
if (v.claimedPaths.includes(pu)) {
|
|
879
|
-
clash = true;
|
|
880
|
-
break;
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
if (clash)
|
|
884
|
-
break;
|
|
885
|
-
}
|
|
886
|
-
if (clash)
|
|
887
|
-
continue;
|
|
888
|
-
selected.push(u);
|
|
889
|
-
for (const p of u.claimedPaths) {
|
|
890
|
-
blockedPaths.add(p);
|
|
891
|
-
}
|
|
892
|
-
if (selected.length >= opts.cap)
|
|
893
|
-
break;
|
|
894
|
-
}
|
|
895
|
-
return selected;
|
|
896
|
-
}
|
|
897
|
-
/**
|
|
898
|
-
* Build scheduler rows from merged parallel wave definitions + plan units.
|
|
899
|
-
*/
|
|
900
|
-
export function readySliceUnitsFromMergedWaves(mergedWaves, planMarkdown, options) {
|
|
901
|
-
const units = parseImplementationUnits(planMarkdown);
|
|
902
|
-
const metaByUnit = new Map(units.map((u) => {
|
|
903
|
-
const m = parseImplementationUnitParallelFields(u, options);
|
|
904
|
-
return [m.unitId, m];
|
|
905
|
-
}));
|
|
906
|
-
const sliceSet = new Set();
|
|
907
|
-
for (const w of mergedWaves) {
|
|
908
|
-
for (const m of w.members) {
|
|
909
|
-
sliceSet.add(m.sliceId);
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
const out = [];
|
|
913
|
-
for (const sliceId of [...sliceSet].sort(compareSliceIds)) {
|
|
914
|
-
const member = mergedWaves.flatMap((w) => w.members).find((x) => x.sliceId === sliceId);
|
|
915
|
-
if (!member)
|
|
916
|
-
continue;
|
|
917
|
-
const meta = metaByUnit.get(member.unitId);
|
|
918
|
-
if (!meta) {
|
|
919
|
-
out.push({
|
|
920
|
-
unitId: member.unitId,
|
|
921
|
-
sliceId,
|
|
922
|
-
dependsOn: [],
|
|
923
|
-
claimedPaths: [],
|
|
924
|
-
parallelizable: true
|
|
925
|
-
});
|
|
926
|
-
continue;
|
|
927
|
-
}
|
|
928
|
-
out.push({
|
|
929
|
-
unitId: meta.unitId,
|
|
930
|
-
sliceId,
|
|
931
|
-
dependsOn: meta.dependsOn,
|
|
932
|
-
claimedPaths: meta.claimedPaths,
|
|
933
|
-
parallelizable: meta.parallelizable
|
|
934
|
-
});
|
|
935
|
-
}
|
|
936
|
-
return out;
|
|
937
|
-
}
|
|
938
|
-
/**
|
|
939
|
-
* Heuristic helper deciding whether a multi-slice wave needs
|
|
940
|
-
* the `integration-overseer` dispatch.
|
|
941
|
-
*
|
|
942
|
-
* Triggers (any one):
|
|
943
|
-
* - **two or more closed slices share import boundaries** (heuristic:
|
|
944
|
-
* two slices declare a `claimedPaths` whose first 2 path segments
|
|
945
|
-
* match — same package/module directory);
|
|
946
|
-
* - any slice has `riskTier === "high"`.
|
|
947
|
-
*
|
|
948
|
-
* When none fire, the verdict is `{ required: false, reasons: ["disjoint-paths"] }`
|
|
949
|
-
* and the caller should record a `cclaw_integration_overseer_skipped`
|
|
950
|
-
* audit before bypassing the dispatch.
|
|
951
|
-
*
|
|
952
|
-
* Note on inputs: this function reads from the supplied delegation
|
|
953
|
-
* events list directly so callers can inject synthetic data in tests.
|
|
954
|
-
* Use `readDelegationEvents(projectRoot)` in production paths.
|
|
955
|
-
*/
|
|
956
|
-
export function integrationCheckRequired(events) {
|
|
957
|
-
const reasons = [];
|
|
958
|
-
// Closed slices = ones whose phase=green or phase=refactor row is
|
|
959
|
-
// completed. We collect each unique sliceId's representative paths
|
|
960
|
-
// and risk tier so the heuristic looks at terminal state only.
|
|
961
|
-
const sliceState = new Map();
|
|
962
|
-
for (const evt of events) {
|
|
963
|
-
if (evt.stage !== "tdd")
|
|
964
|
-
continue;
|
|
965
|
-
if (typeof evt.sliceId !== "string" || evt.sliceId.length === 0)
|
|
966
|
-
continue;
|
|
967
|
-
if (evt.status !== "completed")
|
|
968
|
-
continue;
|
|
969
|
-
if (evt.phase !== "green" && evt.phase !== "refactor" && evt.phase !== "refactor-deferred") {
|
|
970
|
-
continue;
|
|
971
|
-
}
|
|
972
|
-
const existing = sliceState.get(evt.sliceId) ?? { sliceId: evt.sliceId };
|
|
973
|
-
if (Array.isArray(evt.claimedPaths) && evt.claimedPaths.length > 0) {
|
|
974
|
-
const merged = new Set(existing.claimedPaths ?? []);
|
|
975
|
-
for (const p of evt.claimedPaths)
|
|
976
|
-
merged.add(p);
|
|
977
|
-
existing.claimedPaths = [...merged];
|
|
978
|
-
}
|
|
979
|
-
if (evt.riskTier === "low" || evt.riskTier === "medium" || evt.riskTier === "high") {
|
|
980
|
-
// Highest-wins so the verdict is conservative.
|
|
981
|
-
const order = { low: 0, medium: 1, high: 2 };
|
|
982
|
-
const prev = existing.riskTier ?? "low";
|
|
983
|
-
if (order[evt.riskTier] >= order[prev]) {
|
|
984
|
-
existing.riskTier = evt.riskTier;
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
sliceState.set(evt.sliceId, existing);
|
|
988
|
-
}
|
|
989
|
-
const slices = [...sliceState.values()];
|
|
990
|
-
if (slices.some((s) => s.riskTier === "high")) {
|
|
991
|
-
reasons.push("high-risk-slice");
|
|
992
|
-
}
|
|
993
|
-
// Shared-directory heuristic — two distinct slices with overlapping
|
|
994
|
-
// first-2-segment directory prefixes count as shared boundary.
|
|
995
|
-
const sliceDirs = new Map();
|
|
996
|
-
for (const s of slices) {
|
|
997
|
-
const dirs = new Set();
|
|
998
|
-
for (const raw of s.claimedPaths ?? []) {
|
|
999
|
-
const segments = raw.split("/").filter((seg) => seg.length > 0);
|
|
1000
|
-
if (segments.length === 0)
|
|
1001
|
-
continue;
|
|
1002
|
-
// For top-level files like `package.json`, fall back to the
|
|
1003
|
-
// first segment so single-segment paths still count as a shared
|
|
1004
|
-
// directory when two slices both claim the file.
|
|
1005
|
-
const prefix = segments.slice(0, Math.max(1, Math.min(2, segments.length))).join("/");
|
|
1006
|
-
dirs.add(prefix);
|
|
1007
|
-
}
|
|
1008
|
-
if (dirs.size > 0)
|
|
1009
|
-
sliceDirs.set(s.sliceId, dirs);
|
|
1010
|
-
}
|
|
1011
|
-
let sharedFound = false;
|
|
1012
|
-
const ids = [...sliceDirs.keys()];
|
|
1013
|
-
outer: for (let i = 0; i < ids.length; i += 1) {
|
|
1014
|
-
const a = sliceDirs.get(ids[i]);
|
|
1015
|
-
for (let j = i + 1; j < ids.length; j += 1) {
|
|
1016
|
-
const b = sliceDirs.get(ids[j]);
|
|
1017
|
-
for (const dir of a) {
|
|
1018
|
-
if (b.has(dir)) {
|
|
1019
|
-
sharedFound = true;
|
|
1020
|
-
break outer;
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
if (sharedFound)
|
|
1026
|
-
reasons.push("shared-import-boundary");
|
|
1027
|
-
if (reasons.length > 0) {
|
|
1028
|
-
return { required: true, reasons };
|
|
1029
|
-
}
|
|
1030
|
-
return { required: false, reasons: ["disjoint-paths"] };
|
|
1031
|
-
}
|
|
1032
|
-
/**
|
|
1033
|
-
* Append a non-delegation audit event recording that the
|
|
1034
|
-
* integration-overseer dispatch was skipped because
|
|
1035
|
-
* `integrationCheckRequired()` returned `required: false`. Best-effort;
|
|
1036
|
-
* never throws.
|
|
1037
|
-
*/
|
|
1038
|
-
export async function recordIntegrationOverseerSkipped(projectRoot, params) {
|
|
1039
|
-
const eventsPath = delegationEventsPath(projectRoot);
|
|
1040
|
-
const payload = {
|
|
1041
|
-
event: "cclaw_integration_overseer_skipped",
|
|
1042
|
-
runId: params.runId,
|
|
1043
|
-
reasons: params.reasons,
|
|
1044
|
-
sliceIds: params.sliceIds,
|
|
1045
|
-
ts: new Date().toISOString()
|
|
1046
|
-
};
|
|
1047
|
-
try {
|
|
1048
|
-
await fs.mkdir(path.dirname(eventsPath), { recursive: true });
|
|
1049
|
-
await fs.appendFile(eventsPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
1050
|
-
}
|
|
1051
|
-
catch {
|
|
1052
|
-
// best-effort audit; never block stage advance.
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
/**
|
|
1056
|
-
* Load merged wave plan (Parallel Execution Plan block + wave-plans/) and map to `ReadySliceUnit[]`.
|
|
1057
|
-
*/
|
|
1058
|
-
export async function loadTddReadySlicePool(planMarkdown, artifactsDir, options) {
|
|
1059
|
-
const merged = mergeParallelWaveDefinitions(parseParallelExecutionPlanWaves(planMarkdown), await parseWavePlanDirectory(artifactsDir));
|
|
1060
|
-
return readySliceUnitsFromMergedWaves(merged, planMarkdown, options);
|
|
1061
|
-
}
|
|
1062
|
-
function readMaxParallelOverrideFromEnv() {
|
|
1063
|
-
const raw = process.env.CCLAW_MAX_PARALLEL_SLICE_BUILDERS;
|
|
1064
|
-
if (typeof raw !== "string" || raw.trim().length === 0)
|
|
1065
|
-
return null;
|
|
1066
|
-
const parsed = Number(raw);
|
|
1067
|
-
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1)
|
|
1068
|
-
return null;
|
|
1069
|
-
return parsed;
|
|
1070
|
-
}
|
|
1071
|
-
/**
|
|
1072
|
-
* When scheduling a `slice-builder` on a TDD stage, compare `claimedPaths`
|
|
1073
|
-
* against every currently active span on the same `(stage, agent)` pair.
|
|
1074
|
-
* Overlap → throw `DispatchOverlapError`; disjoint paths → return
|
|
1075
|
-
* `{ autoParallel: true }` so the caller can mark the new entry
|
|
1076
|
-
* `allowParallel = true` without explicit operator intent. When the agent
|
|
1077
|
-
* is not a slice-builder or no `claimedPaths` are supplied, the function
|
|
1078
|
-
* returns `{ autoParallel: false }` and the standard dedup path takes over.
|
|
1079
|
-
*/
|
|
1080
|
-
export function validateFileOverlap(stamped, activeEntries) {
|
|
1081
|
-
if (!isParallelTddSliceWorker(stamped.agent) || stamped.stage !== "tdd") {
|
|
1082
|
-
return { autoParallel: false };
|
|
1083
|
-
}
|
|
1084
|
-
const newPaths = Array.isArray(stamped.claimedPaths) ? stamped.claimedPaths : [];
|
|
1085
|
-
if (newPaths.length === 0) {
|
|
1086
|
-
return { autoParallel: false };
|
|
1087
|
-
}
|
|
1088
|
-
const sameLane = activeEntries.filter((entry) => entry.stage === stamped.stage &&
|
|
1089
|
-
entry.agent === stamped.agent &&
|
|
1090
|
-
entry.spanId !== stamped.spanId);
|
|
1091
|
-
if (sameLane.length === 0) {
|
|
1092
|
-
return { autoParallel: true };
|
|
1093
|
-
}
|
|
1094
|
-
for (const existing of sameLane) {
|
|
1095
|
-
const existingPaths = Array.isArray(existing.claimedPaths) ? existing.claimedPaths : [];
|
|
1096
|
-
if (existingPaths.length === 0) {
|
|
1097
|
-
// We can't prove disjoint without the other side declaring paths;
|
|
1098
|
-
// be conservative and let the standard dedup error path fire.
|
|
1099
|
-
return { autoParallel: false };
|
|
1100
|
-
}
|
|
1101
|
-
const overlap = newPaths.filter((p) => existingPaths.includes(p));
|
|
1102
|
-
if (overlap.length > 0) {
|
|
1103
|
-
throw new DispatchOverlapError({
|
|
1104
|
-
existingSpanId: existing.spanId ?? "unknown",
|
|
1105
|
-
newSpanId: stamped.spanId ?? "unknown",
|
|
1106
|
-
pair: { stage: stamped.stage, agent: stamped.agent },
|
|
1107
|
-
conflictingPaths: overlap
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
return { autoParallel: true };
|
|
1112
|
-
}
|
|
1113
|
-
/**
|
|
1114
|
-
* Enforce the slice-builder fan-out cap. The new scheduled row pushes the
|
|
1115
|
-
* active count from N to N+1; if that would exceed the cap (default/config 5,
|
|
1116
|
-
* env-overridable via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS`), throw
|
|
1117
|
-
* `DispatchCapError`.
|
|
1118
|
-
*
|
|
1119
|
-
* Caller passes the already-folded list of active entries (latest row per
|
|
1120
|
-
* spanId, ACTIVE statuses only). The function counts entries that match
|
|
1121
|
-
* the agent on the same `stage`. The new row's own spanId is excluded so
|
|
1122
|
-
* re-recording a `scheduled` doesn't trip the cap on a span that's already
|
|
1123
|
-
* counted.
|
|
1124
|
-
*/
|
|
1125
|
-
export function validateFanOutCap(stamped, activeEntries, override) {
|
|
1126
|
-
if (!isParallelTddSliceWorker(stamped.agent) || stamped.stage !== "tdd")
|
|
1127
|
-
return;
|
|
1128
|
-
if (stamped.status !== "scheduled")
|
|
1129
|
-
return;
|
|
1130
|
-
const cap = readMaxParallelOverrideFromEnv() ??
|
|
1131
|
-
(override !== null &&
|
|
1132
|
-
override !== undefined &&
|
|
1133
|
-
Number.isInteger(override) &&
|
|
1134
|
-
override >= 1
|
|
1135
|
-
? override
|
|
1136
|
-
: MAX_PARALLEL_SLICE_BUILDERS);
|
|
1137
|
-
const sameLaneActive = activeEntries.filter((entry) => entry.stage === stamped.stage &&
|
|
1138
|
-
entry.agent === stamped.agent &&
|
|
1139
|
-
entry.spanId !== stamped.spanId);
|
|
1140
|
-
if (sameLaneActive.length + 1 > cap) {
|
|
1141
|
-
throw new DispatchCapError({
|
|
1142
|
-
cap,
|
|
1143
|
-
active: sameLaneActive.length,
|
|
1144
|
-
pair: { stage: stamped.stage, agent: stamped.agent }
|
|
1145
|
-
});
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* Find the latest active span for a given `(stage, agent)`
|
|
1150
|
-
* pair in the supplied ledger entries. Returns the row whose latest
|
|
1151
|
-
* status (after the latest-by-spanId fold) is still in the active set
|
|
1152
|
-
* (`scheduled | launched | acknowledged`).
|
|
1153
|
-
*
|
|
1154
|
-
* Run-scope is **strict**: only entries whose `runId` matches the
|
|
1155
|
-
* supplied `runId` are folded. Entries with empty/missing `runId`
|
|
1156
|
-
* (older ledgers without explicit run scoping) are treated as NOT belonging
|
|
1157
|
-
* to the current run, so they cannot keep an old span "active" across
|
|
1158
|
-
* a fresh dispatch and trip a spurious `dispatch_duplicate`. This
|
|
1159
|
-
* Ensures a slice-builder that ran in run-1 does not block a
|
|
1160
|
-
* slice-builder scheduled in run-2.
|
|
1161
|
-
*
|
|
1162
|
-
* keep in sync with the inline copy in
|
|
1163
|
-
* `src/content/hooks.ts::delegationRecordScript`.
|
|
1164
|
-
*/
|
|
1165
|
-
export function findActiveSpanForPair(stage, agent, runId, ledger) {
|
|
1166
|
-
const sameRun = ledger.entries.filter((entry) => {
|
|
1167
|
-
if (typeof entry.runId !== "string" || entry.runId.length === 0)
|
|
1168
|
-
return false;
|
|
1169
|
-
if (entry.runId !== runId)
|
|
1170
|
-
return false;
|
|
1171
|
-
return entry.stage === stage && entry.agent === agent;
|
|
1172
|
-
});
|
|
1173
|
-
for (const entry of computeActiveSubagents(sameRun)) {
|
|
1174
|
-
return entry;
|
|
1175
|
-
}
|
|
1176
|
-
return null;
|
|
1177
|
-
}
|
|
1178
|
-
async function writeSubagentTracker(projectRoot, entries) {
|
|
1179
|
-
const active = computeActiveSubagents(entries).map((entry) => ({
|
|
1180
|
-
spanId: entry.spanId,
|
|
1181
|
-
dispatchId: entry.dispatchId,
|
|
1182
|
-
workerRunId: entry.workerRunId,
|
|
1183
|
-
stage: entry.stage,
|
|
1184
|
-
agent: entry.agent,
|
|
1185
|
-
status: entry.status,
|
|
1186
|
-
dispatchSurface: entry.dispatchSurface,
|
|
1187
|
-
agentDefinitionPath: entry.agentDefinitionPath,
|
|
1188
|
-
startedAt: entry.startTs,
|
|
1189
|
-
launchedAt: entry.launchedTs,
|
|
1190
|
-
acknowledgedAt: entry.ackTs,
|
|
1191
|
-
allowParallel: entry.allowParallel
|
|
1192
|
-
}));
|
|
1193
|
-
await writeFileSafe(subagentsStatePath(projectRoot), `${JSON.stringify({ active, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
1194
|
-
}
|
|
1195
|
-
export async function appendDelegation(projectRoot, entry) {
|
|
1196
|
-
const flowState = await readFlowState(projectRoot);
|
|
1197
|
-
const { activeRunId } = flowState;
|
|
1198
|
-
await withDirectoryLock(delegationLockPath(projectRoot), async () => {
|
|
1199
|
-
const filePath = delegationLogPath(projectRoot);
|
|
1200
|
-
const prior = await readDelegationLedger(projectRoot);
|
|
1201
|
-
const lifecycleCandidates = [
|
|
1202
|
-
entry.startTs,
|
|
1203
|
-
entry.launchedTs,
|
|
1204
|
-
entry.ackTs,
|
|
1205
|
-
entry.completedTs,
|
|
1206
|
-
entry.ts
|
|
1207
|
-
].filter((value) => typeof value === "string" && value.length > 0);
|
|
1208
|
-
const earliestLifecycle = lifecycleCandidates.length > 0
|
|
1209
|
-
? lifecycleCandidates.reduce((min, candidate) => (candidate < min ? candidate : min))
|
|
1210
|
-
: undefined;
|
|
1211
|
-
const startTs = entry.startTs ?? earliestLifecycle ?? new Date().toISOString();
|
|
1212
|
-
if (entry.status === "waived" && !hasValidWaiverReason(entry.waiverReason)) {
|
|
1213
|
-
throw new Error("waived delegation entries require a non-empty waiverReason");
|
|
1214
|
-
}
|
|
1215
|
-
const stamped = statusTimestampPatch({ ...entry, runId: entry.runId ?? activeRunId }, startTs);
|
|
1216
|
-
stamped.spanId = entry.spanId ?? createSpanId();
|
|
1217
|
-
stamped.startTs = startTs;
|
|
1218
|
-
stamped.ts = startTs;
|
|
1219
|
-
if (TERMINAL_DELEGATION_STATUSES.has(stamped.status) && !stamped.endTs) {
|
|
1220
|
-
stamped.endTs = new Date().toISOString();
|
|
1221
|
-
}
|
|
1222
|
-
if (stamped.status === "completed") {
|
|
1223
|
-
stamped.completedTs = stamped.completedTs ?? stamped.endTs ?? new Date().toISOString();
|
|
1224
|
-
}
|
|
1225
|
-
if (stamped.status === "scheduled") {
|
|
1226
|
-
delete stamped.endTs;
|
|
1227
|
-
}
|
|
1228
|
-
stamped.schemaVersion = DELEGATION_LEDGER_SCHEMA_VERSION;
|
|
1229
|
-
if (stamped.retryCount === undefined ||
|
|
1230
|
-
!Number.isInteger(stamped.retryCount) ||
|
|
1231
|
-
stamped.retryCount < 0) {
|
|
1232
|
-
stamped.retryCount = 0;
|
|
1233
|
-
}
|
|
1234
|
-
if (!Array.isArray(stamped.evidenceRefs)) {
|
|
1235
|
-
stamped.evidenceRefs = [];
|
|
1236
|
-
}
|
|
1237
|
-
if (stamped.status === "completed" && stamped.fulfillmentMode === undefined) {
|
|
1238
|
-
const activeFallback = activeHarnessSubagentFallback();
|
|
1239
|
-
if (activeFallback) {
|
|
1240
|
-
stamped.fulfillmentMode = expectedFulfillmentMode([activeFallback]);
|
|
1241
|
-
}
|
|
1242
|
-
else {
|
|
1243
|
-
const config = await readConfig(projectRoot).catch(() => null);
|
|
1244
|
-
const harnesses = config?.harnesses ?? [];
|
|
1245
|
-
const fallbacks = harnesses.map((h) => HARNESS_ADAPTERS[h].capabilities.subagentFallback);
|
|
1246
|
-
stamped.fulfillmentMode = expectedFulfillmentMode(fallbacks);
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
if (prior.entries.some((existing) => existing.spanId === stamped.spanId &&
|
|
1250
|
-
existing.status === stamped.status &&
|
|
1251
|
-
(existing.phase ?? null) === (stamped.phase ?? null))) {
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1254
|
-
validateMonotonicTimestamps(stamped, prior.entries);
|
|
1255
|
-
validatePhaseEventStatus(stamped);
|
|
1256
|
-
if (stamped.status === "scheduled" &&
|
|
1257
|
-
typeof stamped.sliceId === "string" &&
|
|
1258
|
-
stamped.sliceId.length > 0 &&
|
|
1259
|
-
stamped.phase === undefined) {
|
|
1260
|
-
const closed = closedSliceSpans(prior.entries, stamped.sliceId, activeRunId);
|
|
1261
|
-
if (closed.size > 0 && !(stamped.spanId && closed.has(stamped.spanId))) {
|
|
1262
|
-
const closedSpanId = closed.values().next().value;
|
|
1263
|
-
throw new SliceAlreadyClosedError({
|
|
1264
|
-
sliceId: stamped.sliceId,
|
|
1265
|
-
runId: activeRunId,
|
|
1266
|
-
closedSpanId,
|
|
1267
|
-
newSpanId: stamped.spanId ?? "unknown"
|
|
1268
|
-
});
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
if (stamped.status === "scheduled") {
|
|
1272
|
-
validateClaimedPathsNotProtected(stamped);
|
|
1273
|
-
const sameRunPrior = prior.entries.filter((entry) => entry.runId === activeRunId);
|
|
1274
|
-
const activeForRun = computeActiveSubagents(sameRunPrior);
|
|
1275
|
-
const overlap = validateFileOverlap(stamped, activeForRun);
|
|
1276
|
-
if (overlap.autoParallel && stamped.allowParallel !== true) {
|
|
1277
|
-
stamped.allowParallel = true;
|
|
1278
|
-
}
|
|
1279
|
-
const config = await readConfig(projectRoot).catch(() => null);
|
|
1280
|
-
validateFanOutCap(stamped, activeForRun, resolveMaxBuilders(config));
|
|
1281
|
-
if (stamped.allowParallel !== true) {
|
|
1282
|
-
const existing = findActiveSpanForPair(stamped.stage, stamped.agent, activeRunId, prior);
|
|
1283
|
-
if (existing && existing.spanId && existing.spanId !== stamped.spanId) {
|
|
1284
|
-
throw new DispatchDuplicateError({
|
|
1285
|
-
existingSpanId: existing.spanId,
|
|
1286
|
-
existingStatus: existing.status,
|
|
1287
|
-
newSpanId: stamped.spanId,
|
|
1288
|
-
pair: { stage: stamped.stage, agent: stamped.agent }
|
|
1289
|
-
});
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
await appendDelegationEvent(projectRoot, eventFromEntry(stamped));
|
|
1294
|
-
const ledger = {
|
|
1295
|
-
runId: activeRunId,
|
|
1296
|
-
entries: [...prior.entries, stamped],
|
|
1297
|
-
schemaVersion: DELEGATION_LEDGER_SCHEMA_VERSION
|
|
1298
|
-
};
|
|
1299
|
-
await writeFileSafe(filePath, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
|
|
1300
|
-
await writeSubagentTracker(projectRoot, ledger.entries);
|
|
1301
|
-
});
|
|
1302
|
-
}
|
|
1303
|
-
/**
|
|
1304
|
-
* Aggregate the fulfillment mode cclaw expects for the active harness set.
|
|
1305
|
-
* Priority native > generic-dispatch > role-switch > waiver — the best
|
|
1306
|
-
* available mode wins so mixed installs (e.g. claude + codex) inherit the
|
|
1307
|
-
* strongest guarantee.
|
|
1308
|
-
*/
|
|
1309
|
-
export function expectedFulfillmentMode(fallbacks) {
|
|
1310
|
-
if (fallbacks.length === 0)
|
|
1311
|
-
return "isolated";
|
|
1312
|
-
if (fallbacks.some((f) => f === "native"))
|
|
1313
|
-
return "isolated";
|
|
1314
|
-
if (fallbacks.some((f) => f === "generic-dispatch"))
|
|
1315
|
-
return "generic-dispatch";
|
|
1316
|
-
if (fallbacks.some((f) => f === "role-switch"))
|
|
1317
|
-
return "role-switch";
|
|
1318
|
-
return "harness-waiver";
|
|
1319
|
-
}
|
|
1320
|
-
export async function checkMandatoryDelegations(projectRoot, stage, options = {}) {
|
|
1321
|
-
const flowState = await readFlowState(projectRoot, {
|
|
1322
|
-
repairFeatureSystem: options.repairFeatureSystem
|
|
1323
|
-
});
|
|
1324
|
-
// Read `flowState.taskClass` as a fallback
|
|
1325
|
-
// when the caller doesn't pass an explicit override. The
|
|
1326
|
-
// `cclaw advance-stage` path (`buildValidationReport` →
|
|
1327
|
-
// `checkMandatoryDelegations`) never forwarded `taskClass`, which left
|
|
1328
|
-
// the `software-bugfix` skip dead for users who classified their run
|
|
1329
|
-
// via `flow-state.json`. Forward-typed `null` callers still suppress
|
|
1330
|
-
// the lookup explicitly; only `undefined` triggers the fallback.
|
|
1331
|
-
const resolvedTaskClass = options.taskClass !== undefined ? options.taskClass : flowState.taskClass ?? null;
|
|
1332
|
-
const mandatory = mandatoryAgentsFor(stage, flowState.track, resolvedTaskClass, "standard", flowState.discoveryMode);
|
|
1333
|
-
const skippedByTrack = mandatory.length === 0 &&
|
|
1334
|
-
stageSchema(stage, flowState.track, flowState.discoveryMode, resolvedTaskClass).mandatoryDelegations.length > 0;
|
|
1335
|
-
if (skippedByTrack) {
|
|
1336
|
-
await recordMandatorySkippedByTrack(projectRoot, {
|
|
1337
|
-
stage,
|
|
1338
|
-
track: flowState.track,
|
|
1339
|
-
taskClass: resolvedTaskClass,
|
|
1340
|
-
runId: flowState.activeRunId
|
|
1341
|
-
});
|
|
1342
|
-
}
|
|
1343
|
-
const { activeRunId } = flowState;
|
|
1344
|
-
const ledger = await readDelegationLedger(projectRoot);
|
|
1345
|
-
const events = await readDelegationEvents(projectRoot);
|
|
1346
|
-
const forStage = ledger.entries.filter((e) => e.stage === stage);
|
|
1347
|
-
const forRun = forStage.filter((e) => e.runId === activeRunId);
|
|
1348
|
-
const staleIgnored = forStage
|
|
1349
|
-
.filter((e) => e.runId !== activeRunId)
|
|
1350
|
-
.map((e) => `${e.agent}(runId=${e.runId ?? "unknown"})`);
|
|
1351
|
-
const missing = [];
|
|
1352
|
-
const waived = [];
|
|
1353
|
-
const missingEvidence = [];
|
|
1354
|
-
const missingDispatchProof = [];
|
|
1355
|
-
const legacyInferredCompletions = [];
|
|
1356
|
-
let legacyRequiresRerecord = false;
|
|
1357
|
-
const terminalSpanIds = new Set(forRun
|
|
1358
|
-
.filter((entry) => TERMINAL_DELEGATION_STATUSES.has(entry.status) && entry.spanId)
|
|
1359
|
-
.map((entry) => entry.spanId));
|
|
1360
|
-
const staleWorkers = forRun
|
|
1361
|
-
.filter((entry) => entry.status === "scheduled" && entry.spanId && !terminalSpanIds.has(entry.spanId))
|
|
1362
|
-
.map((entry) => `${entry.agent}(spanId=${entry.spanId})`);
|
|
1363
|
-
const config = await readConfig(projectRoot).catch(() => null);
|
|
1364
|
-
const harnesses = config?.harnesses ?? [];
|
|
1365
|
-
const configuredFallbacks = harnesses.map((h) => HARNESS_ADAPTERS[h].capabilities.subagentFallback);
|
|
1366
|
-
const activeFallback = activeHarnessSubagentFallback();
|
|
1367
|
-
const expectedMode = expectedFulfillmentMode(activeFallback ? [activeFallback] : configuredFallbacks);
|
|
1368
|
-
for (const agent of mandatory) {
|
|
1369
|
-
const rows = forRun.filter((e) => e.agent === agent);
|
|
1370
|
-
const completedRows = rows.filter((e) => e.status === "completed");
|
|
1371
|
-
const waivedRows = rows.filter((e) => e.status === "waived" && e.mode === "mandatory");
|
|
1372
|
-
const hasCompleted = completedRows.length >= 1;
|
|
1373
|
-
const hasWaived = waivedRows.length > 0;
|
|
1374
|
-
const ok = hasWaived || hasCompleted;
|
|
1375
|
-
if (!ok) {
|
|
1376
|
-
missing.push(agent);
|
|
1377
|
-
continue;
|
|
1378
|
-
}
|
|
1379
|
-
if (hasWaived) {
|
|
1380
|
-
waived.push(agent);
|
|
1381
|
-
}
|
|
1382
|
-
// Evidence is required for non-isolated completions and for explicit
|
|
1383
|
-
// degraded role-switch rows. Native OpenCode/Codex/Claude isolated
|
|
1384
|
-
// dispatch is accepted as true subagent work; role-switch remains a
|
|
1385
|
-
// fallback that must point at artifact evidence.
|
|
1386
|
-
const evidenceRequired = expectedMode !== "isolated" || completedRows.some((e) => (e.fulfillmentMode ?? "isolated") !== "isolated");
|
|
1387
|
-
if (hasCompleted &&
|
|
1388
|
-
evidenceRequired &&
|
|
1389
|
-
!completedRows.some((e) => Array.isArray(e.evidenceRefs) && e.evidenceRefs.length > 0)) {
|
|
1390
|
-
missingEvidence.push(agent);
|
|
1391
|
-
}
|
|
1392
|
-
// legacyInferredCompletions has two sources, split by `legacyTagged`:
|
|
1393
|
-
// - legacyTagged === true : the row was *parsed* as legacy-inferred
|
|
1394
|
-
// from a pre-v3 ledger file. Requires `delegation-record.mjs
|
|
1395
|
-
// --rerecord` and BLOCKS satisfied.
|
|
1396
|
-
// - legacyTagged === false: in-check inference for minimally-spec'd
|
|
1397
|
-
// isolated rows that lack proof-era signals. Advisory only —
|
|
1398
|
-
// preserves backward-compatible behavior for existing API callers.
|
|
1399
|
-
for (const row of completedRows) {
|
|
1400
|
-
const mode = row.fulfillmentMode ?? "isolated";
|
|
1401
|
-
if (mode === "legacy-inferred") {
|
|
1402
|
-
legacyInferredCompletions.push(`${agent}(spanId=${row.spanId ?? "unknown"})`);
|
|
1403
|
-
legacyRequiresRerecord = true;
|
|
1404
|
-
continue;
|
|
1405
|
-
}
|
|
1406
|
-
if (mode === "isolated") {
|
|
1407
|
-
const spanEvents = events.events.filter((event) => event.runId === activeRunId &&
|
|
1408
|
-
event.stage === stage &&
|
|
1409
|
-
event.agent === agent &&
|
|
1410
|
-
event.spanId === row.spanId);
|
|
1411
|
-
const dispatchId = row.dispatchId ?? row.workerRunId ?? spanEvents.find((event) => event.dispatchId || event.workerRunId)?.dispatchId ?? spanEvents.find((event) => event.workerRunId)?.workerRunId;
|
|
1412
|
-
const dispatchSurface = row.dispatchSurface ?? spanEvents.find((event) => event.dispatchSurface)?.dispatchSurface;
|
|
1413
|
-
const agentDefinitionPath = row.agentDefinitionPath ?? spanEvents.find((event) => event.agentDefinitionPath)?.agentDefinitionPath;
|
|
1414
|
-
const hasAck = Boolean(row.ackTs || spanEvents.some((event) => event.event === "acknowledged" && event.ackTs));
|
|
1415
|
-
const hasCompleted = Boolean(row.completedTs || spanEvents.some((event) => event.event === "completed" && event.completedTs));
|
|
1416
|
-
const hasDispatchProof = Boolean(row.spanId && dispatchId && dispatchSurface && agentDefinitionPath && hasAck && hasCompleted);
|
|
1417
|
-
if (!hasDispatchProof) {
|
|
1418
|
-
const proofEraSignal = Boolean(row.dispatchId || row.workerRunId || row.dispatchSurface || row.agentDefinitionPath || spanEvents.some((event) => event.dispatchId || event.workerRunId || event.dispatchSurface || event.agentDefinitionPath || event.event === "acknowledged" || event.event === "launched"));
|
|
1419
|
-
if (proofEraSignal) {
|
|
1420
|
-
missingDispatchProof.push(agent);
|
|
1421
|
-
}
|
|
1422
|
-
else {
|
|
1423
|
-
legacyInferredCompletions.push(`${agent}(spanId=${row.spanId ?? "unknown"})`);
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
return {
|
|
1430
|
-
satisfied: missing.length === 0 &&
|
|
1431
|
-
missingEvidence.length === 0 &&
|
|
1432
|
-
missingDispatchProof.length === 0 &&
|
|
1433
|
-
!legacyRequiresRerecord &&
|
|
1434
|
-
staleWorkers.length === 0 &&
|
|
1435
|
-
events.corruptLines.length === 0,
|
|
1436
|
-
missing,
|
|
1437
|
-
waived,
|
|
1438
|
-
staleIgnored,
|
|
1439
|
-
missingEvidence,
|
|
1440
|
-
missingDispatchProof,
|
|
1441
|
-
legacyInferredCompletions,
|
|
1442
|
-
corruptEventLines: events.corruptLines,
|
|
1443
|
-
staleWorkers,
|
|
1444
|
-
expectedMode,
|
|
1445
|
-
skippedByTrack
|
|
1446
|
-
};
|
|
1447
|
-
}
|
|
1448
|
-
/**
|
|
1449
|
-
* Append a non-delegation audit event to
|
|
1450
|
-
* `delegation-events.jsonl` recording that the mandatory delegation
|
|
1451
|
-
* gate was skipped because of the active track / task class. Plays the
|
|
1452
|
-
* same audit role as a `waived` row but does NOT carry an agent —
|
|
1453
|
-
* downstream tooling treats `event === "mandatory_delegations_skipped_by_track"`
|
|
1454
|
-
* lines as informational.
|
|
1455
|
-
*
|
|
1456
|
-
* Failures are swallowed: the audit log is best-effort. Missing the
|
|
1457
|
-
* event must never block stage advance because the gate skip itself is
|
|
1458
|
-
* authoritative.
|
|
1459
|
-
*/
|
|
1460
|
-
async function recordMandatorySkippedByTrack(projectRoot, params) {
|
|
1461
|
-
const eventsPath = delegationEventsPath(projectRoot);
|
|
1462
|
-
const payload = {
|
|
1463
|
-
event: "mandatory_delegations_skipped_by_track",
|
|
1464
|
-
stage: params.stage,
|
|
1465
|
-
track: params.track,
|
|
1466
|
-
taskClass: params.taskClass,
|
|
1467
|
-
runId: params.runId,
|
|
1468
|
-
ts: new Date().toISOString()
|
|
1469
|
-
};
|
|
1470
|
-
try {
|
|
1471
|
-
await fs.mkdir(path.dirname(eventsPath), { recursive: true });
|
|
1472
|
-
await fs.appendFile(eventsPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
1473
|
-
}
|
|
1474
|
-
catch {
|
|
1475
|
-
// best-effort audit; never block stage advance.
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
/**
|
|
1479
|
-
* Append a non-delegation audit event recording
|
|
1480
|
-
* that one or more required artifact-validation findings were
|
|
1481
|
-
* demoted from blocking to advisory because the active run is on a
|
|
1482
|
-
* small-fix lane (`track === "quick"` or `taskClass === "software-bugfix"`).
|
|
1483
|
-
*
|
|
1484
|
-
* The event mirrors `mandatory_delegations_skipped_by_track`
|
|
1485
|
-
* audit pattern: best-effort write to `delegation-events.jsonl`, no
|
|
1486
|
-
* agent payload, recognized by `readDelegationEvents` so it does not
|
|
1487
|
-
* corrupt downstream parsers. Failures are swallowed.
|
|
1488
|
-
*/
|
|
1489
|
-
export async function recordArtifactValidationDemotedByTrack(projectRoot, params) {
|
|
1490
|
-
if (params.sections.length === 0)
|
|
1491
|
-
return;
|
|
1492
|
-
const eventsPath = delegationEventsPath(projectRoot);
|
|
1493
|
-
const payload = {
|
|
1494
|
-
event: "artifact_validation_demoted_by_track",
|
|
1495
|
-
stage: params.stage,
|
|
1496
|
-
track: params.track,
|
|
1497
|
-
taskClass: params.taskClass,
|
|
1498
|
-
runId: params.runId,
|
|
1499
|
-
sections: params.sections,
|
|
1500
|
-
ts: new Date().toISOString()
|
|
1501
|
-
};
|
|
1502
|
-
try {
|
|
1503
|
-
await fs.mkdir(path.dirname(eventsPath), { recursive: true });
|
|
1504
|
-
await fs.appendFile(eventsPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
1505
|
-
}
|
|
1506
|
-
catch {
|
|
1507
|
-
// best-effort audit; never block stage advance.
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
/**
|
|
1511
|
-
* Append a non-delegation audit event recording
|
|
1512
|
-
* that the scope-stage Expansion Strategist (`product-discovery`)
|
|
1513
|
-
* delegation requirement was skipped because the active run is on a
|
|
1514
|
-
* small-fix lane (`track === "quick"` or `taskClass === "software-bugfix"`).
|
|
1515
|
-
*
|
|
1516
|
-
* Mirrors the `mandatory_delegations_skipped_by_track`
|
|
1517
|
-
* audit pattern: best-effort write to `delegation-events.jsonl`, no
|
|
1518
|
-
* agent payload, recognized by `readDelegationEvents` so it does not
|
|
1519
|
-
* corrupt downstream parsers. Failures are swallowed.
|
|
1520
|
-
*/
|
|
1521
|
-
export async function recordExpansionStrategistSkippedByTrack(projectRoot, params) {
|
|
1522
|
-
const eventsPath = delegationEventsPath(projectRoot);
|
|
1523
|
-
const payload = {
|
|
1524
|
-
event: "expansion_strategist_skipped_by_track",
|
|
1525
|
-
stage: "scope",
|
|
1526
|
-
track: params.track,
|
|
1527
|
-
taskClass: params.taskClass,
|
|
1528
|
-
runId: params.runId,
|
|
1529
|
-
selectedScopeMode: params.selectedScopeMode,
|
|
1530
|
-
ts: new Date().toISOString()
|
|
1531
|
-
};
|
|
1532
|
-
try {
|
|
1533
|
-
await fs.mkdir(path.dirname(eventsPath), { recursive: true });
|
|
1534
|
-
await fs.appendFile(eventsPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
1535
|
-
}
|
|
1536
|
-
catch {
|
|
1537
|
-
// best-effort audit; never block stage advance.
|
|
1538
|
-
}
|
|
1539
|
-
}
|