@deksden-com/dd-flow-cli 0.5.0 → 0.6.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/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +4 -3
- package/dist/cli/run-cli.js +13 -4
- package/dist/domain/flow-contract.js +4 -4
- package/dist/domain/session-coverage.js +88 -0
- package/dist/schemas/flow-contract.schema.json +15 -13
- package/dist/schemas/stage-prompt.schema.json +9 -5
- package/dist/schemas/stage-start-response.schema.json +6 -5
- package/dist/services/cleanup.js +77 -0
- package/dist/services/hooks.js +27 -10
- package/dist/services/migrations.js +1 -1
- package/dist/services/run-projection.js +18 -4
- package/dist/services/runs.js +33 -0
- package/dist/services/sessions.js +33 -73
- package/dist/services/stage-lifecycle.js +206 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 47cd270: Add SPC-006 bootstrap stage start/context packets and sealed stage finish, while repairing Memory Bank upgrade stage routing, Codex hook event identity, PATH-stable per-home hook installation, canonical Codex session registration, session coverage projections, terminal orchestrator closure, and safe reconciliation of runs whose legacy authority and workspace are gone.
|
|
8
|
+
|
|
3
9
|
## 0.5.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -75,10 +75,10 @@ dd-flow protocol register HANDSHAKE-001 --project-root "$PWD" --json
|
|
|
75
75
|
dd-flow protocol status PRT-HANDSHAKE-001 --project-root "$PWD" --json
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Inspect the canonical plan and update its SQLite progress projection:
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
dd-flow plan
|
|
81
|
+
dd-flow plan status PRT-HANDSHAKE-001 --project-root "$PWD" --json
|
|
82
82
|
dd-flow plan item start PRT-HANDSHAKE-001 P1 --project-root "$PWD" --json
|
|
83
83
|
dd-flow plan item done PRT-HANDSHAKE-001 P1 --project-root "$PWD" --summary "Implemented" --evidence "pnpm test" --json
|
|
84
84
|
```
|
package/dist/build-info.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-08-
|
|
3
|
+
"cli_version": "0.6.0",
|
|
4
|
+
"cli_commit": "22cf9c8f5293168dae76212c9a4b74b90a1efeab",
|
|
5
|
+
"built_at": "2026-08-12T05:09:56.163Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
|
-
"version": "3.
|
|
8
|
-
"commit": "
|
|
7
|
+
"version": "3.1.0",
|
|
8
|
+
"commit": "c201167b896a039ff4881d6d816b9735fba2f799",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
10
|
"repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
|
|
11
11
|
"memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
|
package/dist/cli/help.js
CHANGED
|
@@ -143,7 +143,7 @@ Usage:
|
|
|
143
143
|
dd-flow id next --type protocol|run --project-root <root> --slug <slug> --json
|
|
144
144
|
dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
145
145
|
dd-flow run list --project-root <root> --json
|
|
146
|
-
dd-flow run timeline <RUN-ID|RUN-short-id> --project-root <root>
|
|
146
|
+
dd-flow run timeline <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
147
147
|
dd-flow run usage <RUN-ID|RUN-short-id> --project-root <root> [--group-by session|role|stage|aspect|plan-item] --json
|
|
148
148
|
dd-flow run flags status <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
149
149
|
dd-flow run flags revise <RUN-ID|RUN-short-id> --project-root <root> --expected-revision <n> --idempotency-key <key> [--preset <name>] [--flag key=value] [--flag key=value] [--allow-downgrade --reason <text>] --json
|
|
@@ -165,9 +165,10 @@ Examples:
|
|
|
165
165
|
|
|
166
166
|
Usage:
|
|
167
167
|
dd-flow stage start <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--dir <NN-stage-slug>] --json
|
|
168
|
-
dd-flow stage
|
|
168
|
+
dd-flow stage start --bootstrap --project-root <root> --stage specify --subject <safe-subject> [--intake-file <path>] --json
|
|
169
|
+
dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--outcome done|blocked|failed] [--semantic-file <RUN-local-json>] --json
|
|
169
170
|
|
|
170
|
-
stage start
|
|
171
|
+
stage start is the worker's first practical flow command. It performs deterministic setup and returns a ready-to-use, stage-specific work packet with trusted runtime facts, compatibility and permission preflight, aliases, write boundary and exact finish command. The same packet is written to RUN-local stage-prompt.md and stage-prompt.json. --bootstrap creates the initial protocol/RUN envelope and copies the supplied intake into the RUN home before starting specify. stage finish defaults to @stage/stage-input.json, accepts semantic --outcome and always generates validated JSON, Markdown, template-backed HTML and a durable protocol-summary projection. Raw intake and telemetry remain RUN-local.`
|
|
171
172
|
],
|
|
172
173
|
[
|
|
173
174
|
"id",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -541,23 +541,28 @@ async function dispatch(args, context, io, scopeProjectRoot = null) {
|
|
|
541
541
|
}
|
|
542
542
|
if (family === "stage" && command === "start") {
|
|
543
543
|
const dir = optionalOption(parsed, "dir");
|
|
544
|
+
const bootstrap = hasOption(parsed, "bootstrap");
|
|
545
|
+
const subject = optionalOption(parsed, "subject");
|
|
546
|
+
const intakeFile = optionalOption(parsed, "intake-file");
|
|
547
|
+
const sessionId = optionalOption(parsed, "session-id");
|
|
544
548
|
return startStage(context, {
|
|
545
549
|
projectRoot: requiredOption(parsed, "project-root"),
|
|
546
|
-
runId: requiredPosition(parsed, 0, "run-id"),
|
|
550
|
+
...(bootstrap ? { bootstrap: { subject: subject ?? requiredOption(parsed, "subject"), ...(intakeFile ? { intakeFile } : {}) } } : { runId: requiredPosition(parsed, 0, "run-id") }),
|
|
547
551
|
stage: requiredOption(parsed, "stage"),
|
|
552
|
+
...(sessionId ? { sessionId } : {}),
|
|
548
553
|
...(dir ? { dir } : {})
|
|
549
554
|
});
|
|
550
555
|
}
|
|
551
556
|
if (family === "stage" && command === "finish") {
|
|
552
557
|
const dir = optionalOption(parsed, "dir");
|
|
553
|
-
const
|
|
558
|
+
const outcome = optionalOption(parsed, "outcome") ?? optionalOption(parsed, "status");
|
|
554
559
|
const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
|
|
555
560
|
return finishStage(context, {
|
|
556
561
|
projectRoot: requiredOption(parsed, "project-root"),
|
|
557
562
|
runId: requiredPosition(parsed, 0, "run-id"),
|
|
558
563
|
stage: requiredOption(parsed, "stage"),
|
|
559
564
|
...(dir ? { dir } : {}),
|
|
560
|
-
...(
|
|
565
|
+
...(outcome ? { outcome } : {}),
|
|
561
566
|
...(semanticFile ? { semanticFile } : {})
|
|
562
567
|
});
|
|
563
568
|
}
|
|
@@ -1327,11 +1332,15 @@ function dispatchCodexHome(context, parsed) {
|
|
|
1327
1332
|
function dispatchCodexHook(context, parsed, stdin) {
|
|
1328
1333
|
const action = requiredPosition(parsed, 0, "codex hook action");
|
|
1329
1334
|
if (action === "handle") {
|
|
1330
|
-
|
|
1335
|
+
const result = handleCodexHook(context, {
|
|
1331
1336
|
event: requiredOption(parsed, "event"),
|
|
1332
1337
|
projectRoot: optionalOption(parsed, "project-root"),
|
|
1333
1338
|
stdin
|
|
1334
1339
|
});
|
|
1340
|
+
if (result && typeof result === "object" && !Array.isArray(result) && "hookSpecificOutput" in result) {
|
|
1341
|
+
return { hookSpecificOutput: result.hookSpecificOutput };
|
|
1342
|
+
}
|
|
1343
|
+
return {};
|
|
1335
1344
|
}
|
|
1336
1345
|
throw new AppError("usage", `Unknown codex hook action: ${action}`, 2);
|
|
1337
1346
|
}
|
|
@@ -332,7 +332,6 @@ function flagStrength(key, value, definition) {
|
|
|
332
332
|
"subagents.route": ["self_check", "grouped_subagent", "focused_subagent", "mixed"],
|
|
333
333
|
"subagents.grouping": ["off", "allowlisted"],
|
|
334
334
|
"subagents.pool_fallback": ["one", "runtime"],
|
|
335
|
-
"observability.detail": ["compact", "normal", "full"],
|
|
336
335
|
"knowledge.extract": ["skip", "conditional", "required"],
|
|
337
336
|
"knowledge.promote": ["skip", "conditional", "required"],
|
|
338
337
|
"workspace.bootstrap.mode": ["not_required", "revalidate", "required"],
|
|
@@ -362,10 +361,10 @@ function numberValue(value) {
|
|
|
362
361
|
export function normalizeFlowContract(value) {
|
|
363
362
|
const version = requireNumber(value.version, "/version");
|
|
364
363
|
const id = requireString(value.id, "/id");
|
|
365
|
-
if (version === 4 || version === 5) {
|
|
364
|
+
if (version === 4 || version === 5 || version === 6) {
|
|
366
365
|
return normalizeCanonicalRuntimeContract(value, id, version);
|
|
367
366
|
}
|
|
368
|
-
if (version >
|
|
367
|
+
if (version > 6)
|
|
369
368
|
fail("/version", `unsupported flow-contract version: ${version}`);
|
|
370
369
|
if (version === 3 && !/^dd-flow-canonical-[0-9]{4}-[0-9]{2}$/.test(id)) {
|
|
371
370
|
fail("/id", "must identify a canonical version 3 flow contract");
|
|
@@ -462,7 +461,8 @@ function normalizeCanonicalRuntimeContract(value, id, version) {
|
|
|
462
461
|
"flow_run_snapshot.v1",
|
|
463
462
|
"session_coverage_units.v1",
|
|
464
463
|
"privacy_safe_observability.v1",
|
|
465
|
-
...(version
|
|
464
|
+
...(version >= 5 ? ["canonical_protocol_plan.v1"] : []),
|
|
465
|
+
...(version >= 6 ? ["stage_bootstrap_context_packet.v1"] : [])
|
|
466
466
|
],
|
|
467
467
|
stages,
|
|
468
468
|
transitions,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export function reconcileSessionCoverageRows(sessions) {
|
|
2
|
+
const sessionCounts = countValues(sessions.map((session) => session.session_id));
|
|
3
|
+
const duplicateSessionIds = duplicates(sessionCounts);
|
|
4
|
+
const invalidSessionIds = [];
|
|
5
|
+
const parsed = sessions.map((session) => {
|
|
6
|
+
try {
|
|
7
|
+
return { session, units: normalizeCoverageUnits(JSON.parse(session.coverage_units_json || "[]")) };
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
invalidSessionIds.push(session.session_id);
|
|
11
|
+
return { session, units: [] };
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
const orchestrators = parsed.filter(({ session }) => session.session_kind === "orchestrator");
|
|
15
|
+
const workers = parsed.filter(({ session }) => session.session_kind !== "orchestrator");
|
|
16
|
+
const expectedCounts = countValues(orchestrators.flatMap(({ units }) => units.map((unit) => unit.unit_id)));
|
|
17
|
+
const expectedUnitIds = [...expectedCounts.keys()].sort();
|
|
18
|
+
const duplicateExpectedUnitIds = duplicates(expectedCounts);
|
|
19
|
+
const observedOwners = new Map();
|
|
20
|
+
for (const { session, units } of workers) {
|
|
21
|
+
for (const unit of units) {
|
|
22
|
+
const owners = observedOwners.get(unit.unit_id) ?? [];
|
|
23
|
+
owners.push(session.session_id);
|
|
24
|
+
observedOwners.set(unit.unit_id, owners);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const observedUnitIds = [...observedOwners.keys()].sort();
|
|
28
|
+
const missingUnitIds = expectedUnitIds.filter((unitId) => !observedOwners.has(unitId));
|
|
29
|
+
const duplicateUnitIds = [...observedOwners.entries()]
|
|
30
|
+
.filter(([, owners]) => new Set(owners).size > 1)
|
|
31
|
+
.map(([id]) => id)
|
|
32
|
+
.sort();
|
|
33
|
+
const diagnostics = [];
|
|
34
|
+
if (orchestrators.length === 0)
|
|
35
|
+
diagnostics.push("orchestrator_session_missing");
|
|
36
|
+
if (orchestrators.length > 1)
|
|
37
|
+
diagnostics.push("multiple_orchestrator_sessions");
|
|
38
|
+
if (workers.length > 0 && expectedUnitIds.length === 0)
|
|
39
|
+
diagnostics.push("expected_worker_units_missing");
|
|
40
|
+
if (missingUnitIds.length > 0)
|
|
41
|
+
diagnostics.push("expected_worker_units_unobserved");
|
|
42
|
+
if (duplicateUnitIds.length > 0 || duplicateExpectedUnitIds.length > 0)
|
|
43
|
+
diagnostics.push("duplicate_coverage_binding");
|
|
44
|
+
if (duplicateSessionIds.length > 0)
|
|
45
|
+
diagnostics.push("duplicate_session_binding");
|
|
46
|
+
if (invalidSessionIds.length > 0)
|
|
47
|
+
diagnostics.push("invalid_coverage_units");
|
|
48
|
+
const unavailable = orchestrators.length === 0 || invalidSessionIds.length > 0;
|
|
49
|
+
const partial = orchestrators.length !== 1
|
|
50
|
+
|| (workers.length > 0 && expectedUnitIds.length === 0)
|
|
51
|
+
|| missingUnitIds.length > 0
|
|
52
|
+
|| duplicateUnitIds.length > 0
|
|
53
|
+
|| duplicateExpectedUnitIds.length > 0
|
|
54
|
+
|| duplicateSessionIds.length > 0;
|
|
55
|
+
return {
|
|
56
|
+
status: unavailable ? "unavailable" : partial ? "partial" : "complete",
|
|
57
|
+
expected_unit_ids: expectedUnitIds,
|
|
58
|
+
observed_unit_ids: observedUnitIds,
|
|
59
|
+
missing_unit_ids: missingUnitIds,
|
|
60
|
+
duplicate_unit_ids: duplicateUnitIds,
|
|
61
|
+
duplicate_expected_unit_ids: duplicateExpectedUnitIds,
|
|
62
|
+
duplicate_session_ids: duplicateSessionIds,
|
|
63
|
+
orchestrator_session_ids: orchestrators.map(({ session }) => session.session_id).sort(),
|
|
64
|
+
invalid_session_ids: [...new Set(invalidSessionIds)].sort(),
|
|
65
|
+
diagnostics
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function normalizeCoverageUnits(value) {
|
|
69
|
+
if (!Array.isArray(value))
|
|
70
|
+
throw new Error("coverage_units must be an array");
|
|
71
|
+
return value.map((item) => {
|
|
72
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
73
|
+
throw new Error("coverage unit must be an object");
|
|
74
|
+
const unitId = item.unit_id;
|
|
75
|
+
if (typeof unitId !== "string" || unitId.length === 0)
|
|
76
|
+
throw new Error("coverage unit requires unit_id");
|
|
77
|
+
return { unit_id: unitId };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function countValues(values) {
|
|
81
|
+
const counts = new Map();
|
|
82
|
+
for (const value of values)
|
|
83
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
84
|
+
return counts;
|
|
85
|
+
}
|
|
86
|
+
function duplicates(counts) {
|
|
87
|
+
return [...counts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
|
|
88
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "dd-flow/flow-contract@
|
|
3
|
+
"$id": "dd-flow/flow-contract@6",
|
|
4
4
|
"title": "dd-flow canonical runtime contract",
|
|
5
|
-
"description": "Structural contract for the SPC-004
|
|
5
|
+
"description": "Structural contract for the SPC-004/005/006 runtime, PLAN and deterministic stage-context cutover. Dynamic command and cross-repository checks belong to companion projects.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"required": [
|
|
@@ -25,21 +25,21 @@
|
|
|
25
25
|
"policy"
|
|
26
26
|
],
|
|
27
27
|
"properties": {
|
|
28
|
-
"schema_id": {"const": "dd-flow/flow-contract@
|
|
28
|
+
"schema_id": {"const": "dd-flow/flow-contract@6"},
|
|
29
29
|
"id": {"const": "dd-flow-canonical-2026-08"},
|
|
30
30
|
"contract_id": {"type": "string", "pattern": "^dd-flow-canonical-[0-9]{4}-[0-9]{2}$"},
|
|
31
|
-
"version": {"const":
|
|
31
|
+
"version": {"const": 6},
|
|
32
32
|
"status": {"const": "active"},
|
|
33
33
|
"source": {"const": ".memory-bank/dd-flow/common/runtime-contract.md"},
|
|
34
34
|
"specifications": {
|
|
35
35
|
"type": "array",
|
|
36
|
-
"minItems":
|
|
36
|
+
"minItems": 3,
|
|
37
37
|
"items": {
|
|
38
38
|
"type": "object",
|
|
39
39
|
"additionalProperties": false,
|
|
40
40
|
"required": ["id", "version", "sha256"],
|
|
41
41
|
"properties": {
|
|
42
|
-
"id": {"enum": ["SPC-004", "SPC-005"]},
|
|
42
|
+
"id": {"enum": ["SPC-004", "SPC-005", "SPC-006"]},
|
|
43
43
|
"version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"},
|
|
44
44
|
"sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
|
45
45
|
}
|
|
@@ -73,21 +73,23 @@
|
|
|
73
73
|
"stageLifecycle": {
|
|
74
74
|
"type": "object",
|
|
75
75
|
"additionalProperties": false,
|
|
76
|
-
"required": ["actions", "start_command", "finish_command", "prompt_sections", "current_write_alias", "archive_pattern"],
|
|
76
|
+
"required": ["actions", "start_command", "bootstrap_command", "finish_command", "prompt_sections", "current_write_alias", "archive_pattern"],
|
|
77
77
|
"properties": {
|
|
78
78
|
"actions": {
|
|
79
79
|
"const": ["stage_start", "stage_finish"]
|
|
80
80
|
},
|
|
81
81
|
"start_command": {"const": "dd-flow stage start <RUN> --stage <stage> --json"},
|
|
82
|
-
"
|
|
82
|
+
"bootstrap_command": {"const": "dd-flow stage start --bootstrap --stage specify --project-root <root> --subject <label> --intake-file <path> --json"},
|
|
83
|
+
"finish_command": {"const": "dd-flow stage finish <RUN> --stage <stage> --outcome <outcome> --json"},
|
|
83
84
|
"prompt_sections": {
|
|
84
85
|
"const": [
|
|
85
86
|
"stage_identity",
|
|
86
|
-
"
|
|
87
|
-
"
|
|
87
|
+
"authoritative_runtime_facts",
|
|
88
|
+
"preflight",
|
|
89
|
+
"task_intake",
|
|
88
90
|
"applicable_instructions",
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
+
"required_context",
|
|
92
|
+
"work_contract",
|
|
91
93
|
"completion_contract"
|
|
92
94
|
]
|
|
93
95
|
},
|
|
@@ -112,7 +114,7 @@
|
|
|
112
114
|
"required": ["finish_outputs", "model_input", "mechanical_facts"],
|
|
113
115
|
"properties": {
|
|
114
116
|
"finish_outputs": {"const": ["stage-report.json", "stage-report.md", "stage-report.html", "summary.md"]},
|
|
115
|
-
"model_input": {"const": "semantic-only"},
|
|
117
|
+
"model_input": {"const": "semantic-only; outcome flag"},
|
|
116
118
|
"mechanical_facts": {"const": "cli-owned"}
|
|
117
119
|
}
|
|
118
120
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "dd-flow/stage-prompt@
|
|
3
|
+
"$id": "dd-flow/stage-prompt@2",
|
|
4
4
|
"title": "Generated stage prompt",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"additionalProperties": false,
|
|
@@ -16,16 +16,20 @@
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
],
|
|
19
|
-
"required": ["schema_id", "run_id", "stage", "generated_at", "prompt_path", "sections", "aliases", "write_boundary", "source_fragments"],
|
|
19
|
+
"required": ["schema_id", "run_id", "stage", "generated_at", "prompt_path", "sections", "aliases", "write_boundary", "source_fragments", "authoritative_facts", "preflight", "required_context", "worker_prompt_markdown"],
|
|
20
20
|
"properties": {
|
|
21
|
-
"schema_id": {"const": "dd-flow/stage-prompt@
|
|
21
|
+
"schema_id": {"const": "dd-flow/stage-prompt@2"},
|
|
22
22
|
"run_id": {"type": "string", "minLength": 1},
|
|
23
23
|
"stage": {"type": "string", "minLength": 1},
|
|
24
24
|
"generated_at": {"type": "string", "format": "date-time"},
|
|
25
25
|
"prompt_path": {"type": "string", "minLength": 1},
|
|
26
|
-
"sections": {"type": "array", "minItems":
|
|
26
|
+
"sections": {"type": "array", "minItems": 8, "maxItems": 8, "items": {"enum": ["stage_identity", "authoritative_runtime_facts", "preflight", "task_intake", "applicable_instructions", "required_context", "work_contract", "completion_contract"]}},
|
|
27
27
|
"aliases": {"type": "object", "required": ["project", "workspace", "run", "stage", "protocol", "intake"], "additionalProperties": {"type": "string"}},
|
|
28
28
|
"write_boundary": {"type": "object", "required": ["current", "archive", "archive_writable"], "properties": {"current": {"const": "@stage"}, "archive": {"pattern": "try-[0-9]{3}"}, "archive_writable": {"const": false}}, "additionalProperties": false},
|
|
29
|
-
"source_fragments": {"type": "array", "items": {"type": "string", "minLength": 1}}
|
|
29
|
+
"source_fragments": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
|
30
|
+
"authoritative_facts": {"type": "object", "additionalProperties": true},
|
|
31
|
+
"preflight": {"type": "object", "additionalProperties": true},
|
|
32
|
+
"required_context": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
|
33
|
+
"worker_prompt_markdown": {"type": "string", "minLength": 1}
|
|
30
34
|
}
|
|
31
35
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "dd-flow/stage-start-response@
|
|
3
|
+
"$id": "dd-flow/stage-start-response@2",
|
|
4
4
|
"title": "Stage start response",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"additionalProperties": false,
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
"then": {"required": ["plan_ref", "aspect_map_ref"]}
|
|
11
11
|
}
|
|
12
12
|
],
|
|
13
|
-
"required": ["schema_id", "run_id", "stage", "attempt_number", "stage_root", "prompt_path", "aliases", "next_command"],
|
|
13
|
+
"required": ["schema_id", "run_id", "stage", "attempt_number", "stage_root", "prompt_path", "aliases", "next_command", "worker_prompt_markdown"],
|
|
14
14
|
"properties": {
|
|
15
|
-
"schema_id": {"const": "dd-flow/stage-start-response@
|
|
15
|
+
"schema_id": {"const": "dd-flow/stage-start-response@2"},
|
|
16
16
|
"run_id": {"type": "string", "minLength": 1},
|
|
17
17
|
"stage": {"type": "string", "minLength": 1},
|
|
18
18
|
"attempt_number": {"type": "integer", "minimum": 1},
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"resolved_context": {"type": "object", "additionalProperties": true},
|
|
24
24
|
"plan_ref": {"type": "string", "pattern": "^\\.memory-bank/protocol/[^/]+/plan\\.json$"},
|
|
25
25
|
"aspect_map_ref": {"type": "string", "minLength": 1},
|
|
26
|
-
"next_command": {"
|
|
27
|
-
"permission_probe": {"type": "object", "additionalProperties": true}
|
|
26
|
+
"next_command": {"type": "string", "pattern": "^dd-flow stage finish "},
|
|
27
|
+
"permission_probe": {"type": "object", "additionalProperties": true},
|
|
28
|
+
"worker_prompt_markdown": {"type": "string", "minLength": 1}
|
|
28
29
|
}
|
|
29
30
|
}
|
package/dist/services/cleanup.js
CHANGED
|
@@ -12,6 +12,7 @@ const actionKinds = new Set([
|
|
|
12
12
|
"repair_protocol_state",
|
|
13
13
|
"cancel_queue_job",
|
|
14
14
|
"close_flow_session",
|
|
15
|
+
"discard_stale_run",
|
|
15
16
|
"expire_lane_lock",
|
|
16
17
|
"expire_lane_waiter",
|
|
17
18
|
"close_worktree_record"
|
|
@@ -86,6 +87,36 @@ export function cleanupScan(context, input) {
|
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
}
|
|
90
|
+
const staleRuns = context.db.all(`SELECT id, runtime_path, run_dir, workspace_root FROM flow_runs
|
|
91
|
+
WHERE project_id = ? AND status = 'running'
|
|
92
|
+
ORDER BY updated_at ASC, id ASC`, [project.id]);
|
|
93
|
+
for (const run of staleRuns) {
|
|
94
|
+
const missingAuthority = !fs.existsSync(run.runtime_path);
|
|
95
|
+
const missingRunHome = !fs.existsSync(run.run_dir);
|
|
96
|
+
const missingWorkspace = !fs.existsSync(run.workspace_root);
|
|
97
|
+
if (!missingAuthority)
|
|
98
|
+
continue;
|
|
99
|
+
const details = {
|
|
100
|
+
code: "stale_run_missing_authority",
|
|
101
|
+
severity: "warning",
|
|
102
|
+
run_id: run.id,
|
|
103
|
+
runtime_path: run.runtime_path,
|
|
104
|
+
missing_run_home: missingRunHome,
|
|
105
|
+
missing_workspace: missingWorkspace
|
|
106
|
+
};
|
|
107
|
+
if (missingRunHome && missingWorkspace) {
|
|
108
|
+
pushFinding(findings, actions, details.code, details.severity, {
|
|
109
|
+
run_id: run.id,
|
|
110
|
+
runtime_path: run.runtime_path,
|
|
111
|
+
missing_run_home: missingRunHome,
|
|
112
|
+
missing_workspace: missingWorkspace,
|
|
113
|
+
action: { kind: "discard_stale_run", project_id: project.id, run_id: run.id }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
findings.push({ ...details, action: null, reason: "manual_reconciliation_required" });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
89
120
|
const now = context.now();
|
|
90
121
|
const expiredLocks = context.db.all(`SELECT id, lane, worker_id, expires_at FROM lane_locks
|
|
91
122
|
WHERE project_id = ? AND status = 'active' AND expires_at <= ?
|
|
@@ -289,6 +320,41 @@ function applyAction(context, action, reason, force, now) {
|
|
|
289
320
|
});
|
|
290
321
|
return { kind: action.kind, changed: true, session_id: action.session_id };
|
|
291
322
|
}
|
|
323
|
+
if (action.kind === "discard_stale_run" && action.run_id) {
|
|
324
|
+
const run = context.db.get("SELECT status, runtime_path, run_dir, workspace_root, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
|
|
325
|
+
if (!run) {
|
|
326
|
+
return { kind: action.kind, changed: false, skipped: true, reason: "run_not_found", run_id: action.run_id };
|
|
327
|
+
}
|
|
328
|
+
if (run.status === "discarded") {
|
|
329
|
+
const index = discardedRunIndex(run.index_json, reason, now);
|
|
330
|
+
context.db.run("UPDATE flow_runs SET verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?) WHERE project_id = ? AND id = ?", [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
|
|
331
|
+
if (fs.existsSync(run.runtime_path))
|
|
332
|
+
fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
|
|
333
|
+
return { kind: action.kind, changed: true, run_id: action.run_id };
|
|
334
|
+
}
|
|
335
|
+
if (run.status !== "running") {
|
|
336
|
+
return { kind: action.kind, changed: false, skipped: true, reason: `already_${run.status}`, run_id: action.run_id };
|
|
337
|
+
}
|
|
338
|
+
if (fs.existsSync(run.runtime_path) || fs.existsSync(run.run_dir) || fs.existsSync(run.workspace_root)) {
|
|
339
|
+
return { kind: action.kind, changed: false, skipped: true, reason: "runtime_or_workspace_reappeared", run_id: action.run_id };
|
|
340
|
+
}
|
|
341
|
+
const index = discardedRunIndex(run.index_json, reason, now);
|
|
342
|
+
context.db.run(`UPDATE flow_runs SET status = 'discarded', verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = ?
|
|
343
|
+
WHERE project_id = ? AND id = ? AND status = 'running'`, [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
|
|
344
|
+
fs.mkdirSync(run.run_dir, { recursive: true });
|
|
345
|
+
fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
|
|
346
|
+
context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
347
|
+
WHERE project_id = ? AND run_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.run_id]);
|
|
348
|
+
context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE project_id = ? AND run_id = ? AND ended_at IS NULL", [now, action.project_id, action.run_id]);
|
|
349
|
+
appendAudit(context, {
|
|
350
|
+
projectId: action.project_id,
|
|
351
|
+
eventType: "flow_run.discarded",
|
|
352
|
+
reason,
|
|
353
|
+
forced: force,
|
|
354
|
+
payload: { run_id: action.run_id, source: "cleanup.apply", authority_missing: true }
|
|
355
|
+
});
|
|
356
|
+
return { kind: action.kind, changed: true, run_id: action.run_id };
|
|
357
|
+
}
|
|
292
358
|
if (action.kind === "expire_lane_lock" && typeof action.lock_id === "number") {
|
|
293
359
|
const result = context.db.run("UPDATE lane_locks SET status = 'expired', reason = ?, updated_at = ? WHERE project_id = ? AND id = ? AND status = 'active'", [reason, now, action.project_id, action.lock_id]);
|
|
294
360
|
return {
|
|
@@ -330,6 +396,17 @@ function applyAction(context, action, reason, force, now) {
|
|
|
330
396
|
}
|
|
331
397
|
return { kind: action.kind, changed: false, skipped: true, reason: "invalid_action" };
|
|
332
398
|
}
|
|
399
|
+
function discardedRunIndex(indexJson, reason, now) {
|
|
400
|
+
const index = JSON.parse(indexJson);
|
|
401
|
+
index.schema_id = "dd-flow/flow-run@2";
|
|
402
|
+
index.status = "discarded";
|
|
403
|
+
index.verdict = "discarded";
|
|
404
|
+
index.next_action = reason;
|
|
405
|
+
index.updated_at = now;
|
|
406
|
+
index.completed_at ??= now;
|
|
407
|
+
index.finished_at ??= now;
|
|
408
|
+
return index;
|
|
409
|
+
}
|
|
333
410
|
function dirtyGit(worktreePath) {
|
|
334
411
|
if (!fs.existsSync(worktreePath)) {
|
|
335
412
|
return false;
|
package/dist/services/hooks.js
CHANGED
|
@@ -196,7 +196,7 @@ export function getCodexHooksStatus(context, input) {
|
|
|
196
196
|
};
|
|
197
197
|
}
|
|
198
198
|
const status = hookFileStatus(location.hooksPath, project.root);
|
|
199
|
-
const recorded = hookInstallation(context, project.id, installationScope(target, location.profile));
|
|
199
|
+
const recorded = hookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath));
|
|
200
200
|
const driftStatus = status.installed ? "none" : recorded?.installed === 1 ? "drifted" : status.drift_status;
|
|
201
201
|
return {
|
|
202
202
|
ok: true,
|
|
@@ -224,7 +224,7 @@ export function installCodexHooks(context, input) {
|
|
|
224
224
|
const nextConfig = mergeHooksConfig(existing, project.root);
|
|
225
225
|
ensureDir(path.dirname(location.hooksPath));
|
|
226
226
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
227
|
-
recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, true, "none");
|
|
227
|
+
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
|
|
228
228
|
appendAudit(context, {
|
|
229
229
|
projectId: project.id,
|
|
230
230
|
eventType: "codex_hooks.installed",
|
|
@@ -256,7 +256,7 @@ export function removeCodexHooks(context, input) {
|
|
|
256
256
|
const nextConfig = removeManagedHooks(existing, project.root);
|
|
257
257
|
ensureDir(path.dirname(location.hooksPath));
|
|
258
258
|
writeJsonFile(location.hooksPath, nextConfig);
|
|
259
|
-
recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, false, "not_installed");
|
|
259
|
+
recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
|
|
260
260
|
appendAudit(context, {
|
|
261
261
|
projectId: project.id,
|
|
262
262
|
eventType: "codex_hooks.removed",
|
|
@@ -277,13 +277,16 @@ export function handleCodexHook(context, input) {
|
|
|
277
277
|
if (!command)
|
|
278
278
|
return { ok: true, observed: false, reason: "non_bash_tool" };
|
|
279
279
|
const flowPayload = flowSessionPayloadFromRegisterCommand(command);
|
|
280
|
-
|
|
280
|
+
const bootstrapStageStart = /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
|
|
281
|
+
if (!flowPayload && !bootstrapStageStart)
|
|
281
282
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
282
283
|
const project = projectForHook(context, input.projectRoot, stringValue(payload.cwd));
|
|
283
284
|
if (!project)
|
|
284
285
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
285
286
|
const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
|
|
286
|
-
const observedSession =
|
|
287
|
+
const observedSession = flowPayload
|
|
288
|
+
? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined)
|
|
289
|
+
: undefined;
|
|
287
290
|
const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
|
|
288
291
|
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
289
292
|
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
@@ -315,9 +318,23 @@ export function handleCodexHook(context, input) {
|
|
|
315
318
|
duplicate: !inserted,
|
|
316
319
|
event_key: eventKey,
|
|
317
320
|
session_id: effectiveSessionId,
|
|
318
|
-
protocol_id: protocolId
|
|
321
|
+
protocol_id: protocolId,
|
|
322
|
+
...(sessionId
|
|
323
|
+
? {
|
|
324
|
+
hookSpecificOutput: {
|
|
325
|
+
hookEventName: "PreToolUse",
|
|
326
|
+
permissionDecision: "allow",
|
|
327
|
+
updatedInput: { command: commandWithSessionId(command, sessionId) }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
: {})
|
|
319
331
|
};
|
|
320
332
|
}
|
|
333
|
+
function commandWithSessionId(command, sessionId) {
|
|
334
|
+
if (/(?:^|\s)--session-id(?:=|\s)/.test(command))
|
|
335
|
+
return command;
|
|
336
|
+
return `${command} --session-id ${JSON.stringify(sessionId)}`;
|
|
337
|
+
}
|
|
321
338
|
export function hookStatusForProject(context, projectId) {
|
|
322
339
|
return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
|
|
323
340
|
FROM hook_installations WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
|
|
@@ -460,7 +477,7 @@ function commandHook(command, statusMessage) {
|
|
|
460
477
|
return { type: "command", command, timeout: 5, statusMessage };
|
|
461
478
|
}
|
|
462
479
|
function hookCommand(event) {
|
|
463
|
-
return `dd-flow codex hook handle --event ${event} --json`;
|
|
480
|
+
return `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
|
|
464
481
|
}
|
|
465
482
|
function resolveHookTarget(target) {
|
|
466
483
|
if (!target || target === "isolated") {
|
|
@@ -632,8 +649,8 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
|
|
|
632
649
|
drift_status = excluded.drift_status,
|
|
633
650
|
updated_at = excluded.updated_at`, [projectId, scope, configPath, hashObject(readJsonIfExists(configPath) ?? {}), installed ? 1 : 0, driftStatus, now, now]);
|
|
634
651
|
}
|
|
635
|
-
function installationScope(target, profile) {
|
|
636
|
-
return target === "isolated" ? `isolated:${profile}` :
|
|
652
|
+
function installationScope(target, profile, hooksPath) {
|
|
653
|
+
return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
|
|
637
654
|
}
|
|
638
655
|
function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
|
|
639
656
|
const existing = sessionBinding(context, project.id, sessionId);
|
|
@@ -688,7 +705,7 @@ function projectForHook(context, explicitRoot, cwd) {
|
|
|
688
705
|
.sort((left, right) => right.root.length - left.root.length)[0];
|
|
689
706
|
}
|
|
690
707
|
function hookEventKey(payload, eventName, toolName, command) {
|
|
691
|
-
const explicit = stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
708
|
+
const explicit = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
|
|
692
709
|
if (explicit)
|
|
693
710
|
return explicit;
|
|
694
711
|
return crypto.createHash("sha256").update(JSON.stringify({
|
|
@@ -232,7 +232,7 @@ function adjacentMigrationChain(source, target) {
|
|
|
232
232
|
function activeStateSummary(context, projectId) {
|
|
233
233
|
return {
|
|
234
234
|
protocols: context.db.all("SELECT id, status, stage FROM protocols WHERE project_id = ? AND status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC", [projectId]),
|
|
235
|
-
runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE project_id = ? AND status
|
|
235
|
+
runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE project_id = ? AND status = 'running' ORDER BY updated_at DESC", [projectId]),
|
|
236
236
|
merge_queue: context.db.all("SELECT protocol_id, status FROM merge_queue WHERE project_id = ? AND status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC", [projectId]),
|
|
237
237
|
lane_locks: context.db.all("SELECT lane, worker_id, status FROM lane_locks WHERE project_id = ? AND status = 'active' ORDER BY updated_at DESC", [projectId])
|
|
238
238
|
};
|
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
|
|
3
4
|
export function refreshRunSessionProjection(context, projectId, runId) {
|
|
4
|
-
const run = context.db.get("SELECT id, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
5
|
+
const run = context.db.get("SELECT id, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
5
6
|
if (!run)
|
|
6
7
|
return;
|
|
7
8
|
const index = JSON.parse(run.index_json);
|
|
8
|
-
const
|
|
9
|
+
const sessionRows = context.db.all(`SELECT session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
|
|
9
10
|
created_at, updated_at, stopped_at, coverage_units_json
|
|
10
|
-
FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId])
|
|
11
|
+
FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]);
|
|
12
|
+
const sessions = sessionRows.map(sessionProjection);
|
|
13
|
+
const coverage = reconcileSessionCoverageRows(sessionRows);
|
|
14
|
+
const sessionCoverage = {
|
|
15
|
+
status: coverage.status,
|
|
16
|
+
expected: coverage.expected_unit_ids,
|
|
17
|
+
observed: coverage.observed_unit_ids,
|
|
18
|
+
missing: coverage.missing_unit_ids,
|
|
19
|
+
diagnostics: coverage.diagnostics
|
|
20
|
+
};
|
|
11
21
|
const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root) : {};
|
|
12
22
|
const workers = workerProjection(context, projectId, runId);
|
|
13
23
|
if (JSON.stringify(index.sessions ?? []) === JSON.stringify(sessions)
|
|
24
|
+
&& JSON.stringify(index.session_coverage) === JSON.stringify(sessionCoverage)
|
|
14
25
|
&& JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref)
|
|
15
26
|
&& JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress)
|
|
16
27
|
&& JSON.stringify(index.workers ?? {}) === JSON.stringify(workers))
|
|
17
28
|
return;
|
|
18
29
|
index.sessions = sessions;
|
|
30
|
+
index.session_coverage = sessionCoverage;
|
|
19
31
|
if (plan.plan_ref)
|
|
20
32
|
index.plan_ref = plan.plan_ref;
|
|
21
33
|
if (plan.plan_progress)
|
|
@@ -31,6 +43,7 @@ export function refreshRunSessionProjection(context, projectId, runId) {
|
|
|
31
43
|
index.runtime_revision = runtimeRevision;
|
|
32
44
|
const authoritative = runtime ?? { ...index, schema_id: "dd-flow/flow-run@2" };
|
|
33
45
|
authoritative.sessions = sessions;
|
|
46
|
+
authoritative.session_coverage = sessionCoverage;
|
|
34
47
|
if (plan.plan_ref)
|
|
35
48
|
authoritative.plan_ref = plan.plan_ref;
|
|
36
49
|
if (plan.plan_progress)
|
|
@@ -38,7 +51,8 @@ export function refreshRunSessionProjection(context, projectId, runId) {
|
|
|
38
51
|
authoritative.workers = workers;
|
|
39
52
|
authoritative.updated_at = index.updated_at;
|
|
40
53
|
authoritative.runtime_revision = runtimeRevision;
|
|
41
|
-
|
|
54
|
+
if (run.status !== "discarded" || fs.existsSync(run.runtime_path))
|
|
55
|
+
writeJson(run.runtime_path, authoritative);
|
|
42
56
|
context.db.run("UPDATE flow_runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), index.updated_at, projectId, runId]);
|
|
43
57
|
}
|
|
44
58
|
function planProjection(context, projectId, protocolId, projectRoot) {
|
package/dist/services/runs.js
CHANGED
|
@@ -428,6 +428,7 @@ export function completeFlowRun(context, input) {
|
|
|
428
428
|
};
|
|
429
429
|
}
|
|
430
430
|
persistRunState(context, project, run, index);
|
|
431
|
+
closeRunOrchestrators(context, project.id, run, status, now);
|
|
431
432
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
432
433
|
appendAudit(context, {
|
|
433
434
|
projectId: project.id,
|
|
@@ -439,6 +440,30 @@ export function completeFlowRun(context, input) {
|
|
|
439
440
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
440
441
|
return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
441
442
|
}
|
|
443
|
+
function closeRunOrchestrators(context, projectId, run, status, now) {
|
|
444
|
+
if (!["done", "blocked", "cancelled", "failed"].includes(status))
|
|
445
|
+
return;
|
|
446
|
+
const active = context.db.all(`SELECT session_id FROM flow_sessions
|
|
447
|
+
WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
|
|
448
|
+
AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [projectId, run.id]);
|
|
449
|
+
if (active.length === 0)
|
|
450
|
+
return;
|
|
451
|
+
const reason = `run_${status}`;
|
|
452
|
+
context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
453
|
+
WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
|
|
454
|
+
AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, projectId, run.id]);
|
|
455
|
+
context.db.run(`UPDATE flow_session_segments SET ended_at = ?
|
|
456
|
+
WHERE project_id = ? AND run_id = ? AND session_id IN (${active.map(() => "?").join(", ")}) AND ended_at IS NULL`, [now, projectId, run.id, ...active.map((session) => session.session_id)]);
|
|
457
|
+
for (const session of active) {
|
|
458
|
+
appendRunTimeline(runArtifactRoot(run), {
|
|
459
|
+
at: now,
|
|
460
|
+
type: "session_stopped",
|
|
461
|
+
run_id: run.id,
|
|
462
|
+
session_id: session.session_id,
|
|
463
|
+
reason
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
442
467
|
const timelineSections = ["stages", "events", "sessions", "usage", "artifacts"];
|
|
443
468
|
export function getFlowRunTimeline(context, input) {
|
|
444
469
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -511,6 +536,14 @@ export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
|
|
|
511
536
|
appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
|
|
512
537
|
}
|
|
513
538
|
function guidanceForRun(context, run, index) {
|
|
539
|
+
if (run.status === "discarded") {
|
|
540
|
+
return buildRunFlowGuidance({
|
|
541
|
+
stageRuns: index.stage_runs,
|
|
542
|
+
protocolStage: "cancelled",
|
|
543
|
+
runId: run.id,
|
|
544
|
+
runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
|
|
545
|
+
});
|
|
546
|
+
}
|
|
514
547
|
if (run.subject_type === "protocol") {
|
|
515
548
|
try {
|
|
516
549
|
const protocol = requireProtocol(context, run.subject_id, run.project_id);
|
|
@@ -8,6 +8,7 @@ import { appendAudit } from "./audit.js";
|
|
|
8
8
|
import { cancelLaneWaitersForWorker } from "./lanes.js";
|
|
9
9
|
import { checkpointSessionUsage } from "./usage.js";
|
|
10
10
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
11
|
+
import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
|
|
11
12
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
12
13
|
export function recordFlowSessionObservation(context, input) {
|
|
13
14
|
const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
@@ -99,68 +100,7 @@ export function getFlowSessionStatus(context, input) {
|
|
|
99
100
|
};
|
|
100
101
|
}
|
|
101
102
|
export function reconcileSessionCoverage(sessions) {
|
|
102
|
-
|
|
103
|
-
const duplicateSessionIds = [...sessionCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
|
|
104
|
-
const invalidSessionIds = [];
|
|
105
|
-
const parsed = sessions.map((session) => {
|
|
106
|
-
try {
|
|
107
|
-
return { session, units: normalizeCoverageUnits(JSON.parse(session.coverage_units_json || "[]")) };
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
invalidSessionIds.push(session.session_id);
|
|
111
|
-
return { session, units: [] };
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
const orchestrators = parsed.filter(({ session }) => session.session_kind === "orchestrator");
|
|
115
|
-
const expectedCounts = countValues(orchestrators.flatMap(({ units }) => units.map((unit) => unit.unit_id)));
|
|
116
|
-
const expectedUnitIds = [...expectedCounts.keys()].sort();
|
|
117
|
-
const duplicateExpectedUnitIds = [...expectedCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
|
|
118
|
-
const observedOwners = new Map();
|
|
119
|
-
for (const { session, units } of parsed) {
|
|
120
|
-
if (session.session_kind === "orchestrator")
|
|
121
|
-
continue;
|
|
122
|
-
for (const unit of units) {
|
|
123
|
-
const owners = observedOwners.get(unit.unit_id) ?? [];
|
|
124
|
-
owners.push(session.session_id);
|
|
125
|
-
observedOwners.set(unit.unit_id, owners);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
const observedUnitIds = [...observedOwners.keys()].sort();
|
|
129
|
-
const missingUnitIds = expectedUnitIds.filter((unitId) => !observedOwners.has(unitId));
|
|
130
|
-
const duplicateUnitIds = [...observedOwners.entries()].filter(([, owners]) => new Set(owners).size > 1).map(([id]) => id).sort();
|
|
131
|
-
const diagnostics = [];
|
|
132
|
-
if (orchestrators.length === 0)
|
|
133
|
-
diagnostics.push("orchestrator_session_missing");
|
|
134
|
-
if (orchestrators.length > 1)
|
|
135
|
-
diagnostics.push("multiple_orchestrator_sessions");
|
|
136
|
-
if (expectedUnitIds.length === 0)
|
|
137
|
-
diagnostics.push("expected_worker_units_missing");
|
|
138
|
-
if (missingUnitIds.length > 0)
|
|
139
|
-
diagnostics.push("expected_worker_units_unobserved");
|
|
140
|
-
if (duplicateUnitIds.length > 0 || duplicateExpectedUnitIds.length > 0)
|
|
141
|
-
diagnostics.push("duplicate_coverage_binding");
|
|
142
|
-
if (duplicateSessionIds.length > 0)
|
|
143
|
-
diagnostics.push("duplicate_session_binding");
|
|
144
|
-
if (invalidSessionIds.length > 0)
|
|
145
|
-
diagnostics.push("invalid_coverage_units");
|
|
146
|
-
const unavailable = orchestrators.length === 0 || expectedUnitIds.length === 0 || invalidSessionIds.length > 0;
|
|
147
|
-
const partial = orchestrators.length !== 1
|
|
148
|
-
|| missingUnitIds.length > 0
|
|
149
|
-
|| duplicateUnitIds.length > 0
|
|
150
|
-
|| duplicateExpectedUnitIds.length > 0
|
|
151
|
-
|| duplicateSessionIds.length > 0;
|
|
152
|
-
return {
|
|
153
|
-
status: unavailable ? "unavailable" : partial ? "partial" : "complete",
|
|
154
|
-
expected_unit_ids: expectedUnitIds,
|
|
155
|
-
observed_unit_ids: observedUnitIds,
|
|
156
|
-
missing_unit_ids: missingUnitIds,
|
|
157
|
-
duplicate_unit_ids: duplicateUnitIds,
|
|
158
|
-
duplicate_expected_unit_ids: duplicateExpectedUnitIds,
|
|
159
|
-
duplicate_session_ids: duplicateSessionIds,
|
|
160
|
-
orchestrator_session_ids: orchestrators.map(({ session }) => session.session_id).sort(),
|
|
161
|
-
invalid_session_ids: [...new Set(invalidSessionIds)].sort(),
|
|
162
|
-
diagnostics
|
|
163
|
-
};
|
|
103
|
+
return reconcileSessionCoverageRows(sessions);
|
|
164
104
|
}
|
|
165
105
|
export function stopFlowSession(context, input) {
|
|
166
106
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -245,9 +185,10 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
|
|
|
245
185
|
return nextCount;
|
|
246
186
|
}
|
|
247
187
|
export function flowSessionPayloadFromRegisterCommand(command) {
|
|
248
|
-
if (
|
|
188
|
+
if (/\bdd-flow\s+stage\s+start\b/.test(command))
|
|
189
|
+
return flowSessionPayloadFromStageStartCommand(command);
|
|
190
|
+
if (!/\bdd-flow\s+session\s+register\b/.test(command))
|
|
249
191
|
return undefined;
|
|
250
|
-
}
|
|
251
192
|
const payloadBase64 = optionFromCommand(command, "payload-base64");
|
|
252
193
|
const payloadJson = optionFromCommand(command, "payload-json");
|
|
253
194
|
const payloadFile = optionFromCommand(command, "payload-file");
|
|
@@ -256,6 +197,25 @@ export function flowSessionPayloadFromRegisterCommand(command) {
|
|
|
256
197
|
}
|
|
257
198
|
return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
|
|
258
199
|
}
|
|
200
|
+
function flowSessionPayloadFromStageStartCommand(command) {
|
|
201
|
+
const projectRoot = optionFromCommand(command, "project-root");
|
|
202
|
+
const stage = optionFromCommand(command, "stage");
|
|
203
|
+
const run = command.match(/\bdd-flow\s+stage\s+start\s+([^\s]+)/)?.[1];
|
|
204
|
+
if (!projectRoot || !stage || !run || run.startsWith("--"))
|
|
205
|
+
return undefined;
|
|
206
|
+
return {
|
|
207
|
+
project_root: projectRoot,
|
|
208
|
+
flow_kind: ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning",
|
|
209
|
+
run_id: run,
|
|
210
|
+
protocol_id: null,
|
|
211
|
+
worker_id: null,
|
|
212
|
+
workspace_path: null,
|
|
213
|
+
continuation_policy: "go_router",
|
|
214
|
+
session_kind: "orchestrator",
|
|
215
|
+
current_stage: stage,
|
|
216
|
+
coverage_units: []
|
|
217
|
+
};
|
|
218
|
+
}
|
|
259
219
|
function decodeFlowSessionPayload(input) {
|
|
260
220
|
if (input.payloadFile) {
|
|
261
221
|
return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
|
|
@@ -485,9 +445,8 @@ function sessionKind(payload) {
|
|
|
485
445
|
function normalizeCoverageUnits(value) {
|
|
486
446
|
if (value === undefined || value === null)
|
|
487
447
|
return [];
|
|
488
|
-
if (!Array.isArray(value))
|
|
448
|
+
if (!Array.isArray(value))
|
|
489
449
|
throw new AppError("validation", "coverage_units must be an array", 2);
|
|
490
|
-
}
|
|
491
450
|
return value.map((item, index) => {
|
|
492
451
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
493
452
|
throw new AppError("validation", `coverage_units[${index}] must be an object`, 2);
|
|
@@ -496,16 +455,17 @@ function normalizeCoverageUnits(value) {
|
|
|
496
455
|
if (typeof object.unit_id !== "string" || object.unit_id.length === 0) {
|
|
497
456
|
throw new AppError("validation", `coverage_units[${index}].unit_id is required`, 2);
|
|
498
457
|
}
|
|
499
|
-
const optional = (key) =>
|
|
458
|
+
const optional = (key) => {
|
|
459
|
+
const field = object[key];
|
|
460
|
+
if (field === undefined || field === null)
|
|
461
|
+
return null;
|
|
462
|
+
if (typeof field === "string")
|
|
463
|
+
return field;
|
|
464
|
+
throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2);
|
|
465
|
+
};
|
|
500
466
|
return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
|
|
501
467
|
});
|
|
502
468
|
}
|
|
503
|
-
function countValues(values) {
|
|
504
|
-
const counts = new Map();
|
|
505
|
-
for (const value of values)
|
|
506
|
-
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
507
|
-
return counts;
|
|
508
|
-
}
|
|
509
469
|
function requiredStringField(payload, key) {
|
|
510
470
|
const value = stringField(payload, key, true);
|
|
511
471
|
if (!value) {
|
|
@@ -5,20 +5,106 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { requireProjectByRoot } from "./projects.js";
|
|
6
6
|
import { appendAudit } from "./audit.js";
|
|
7
7
|
import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from "./protocols.js";
|
|
8
|
-
import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus } from "./runs.js";
|
|
8
|
+
import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
9
9
|
import { validateSchema } from "./schema-validation.js";
|
|
10
|
-
import { reconcileSessionCoverage } from "./sessions.js";
|
|
10
|
+
import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
|
|
11
11
|
import { usageForRun } from "./usage.js";
|
|
12
12
|
import { boundCanonicalPlan } from "./plan-runtime.js";
|
|
13
13
|
import { AppError } from "../shared/errors.js";
|
|
14
14
|
import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
15
|
+
import { resolveCanonRoot } from "./canon.js";
|
|
16
|
+
import { preflightMemoryPermissions } from "./memory-permissions.js";
|
|
17
|
+
import { registerProject } from "./projects.js";
|
|
18
|
+
import { registerProtocol } from "./protocols.js";
|
|
19
|
+
import { previewNextEntityId } from "./ids.js";
|
|
20
|
+
const stagePromptSections = [
|
|
21
|
+
"stage_identity",
|
|
22
|
+
"authoritative_runtime_facts",
|
|
23
|
+
"preflight",
|
|
24
|
+
"task_intake",
|
|
25
|
+
"applicable_instructions",
|
|
26
|
+
"required_context",
|
|
27
|
+
"work_contract",
|
|
28
|
+
"completion_contract"
|
|
29
|
+
];
|
|
30
|
+
function bootstrapStageRun(context, projectRoot, input) {
|
|
31
|
+
if (input.stage !== "specify") {
|
|
32
|
+
throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2, { stage: input.stage });
|
|
33
|
+
}
|
|
34
|
+
const subject = input.bootstrap?.subject.trim();
|
|
35
|
+
if (!subject)
|
|
36
|
+
throw new AppError("usage", "--bootstrap requires --subject", 2);
|
|
37
|
+
registerProject(context, { root: projectRoot });
|
|
38
|
+
const preview = previewNextEntityId(context, { projectRoot, type: "protocol", slug: subject });
|
|
39
|
+
const protocolId = preview.entity.id;
|
|
40
|
+
registerProtocol(context, { handshakeId: protocolId, projectRoot });
|
|
41
|
+
const started = startFlowRun(context, {
|
|
42
|
+
projectRoot,
|
|
43
|
+
flowKind: "mb_sdlc",
|
|
44
|
+
subjectType: "protocol",
|
|
45
|
+
subjectId: protocolId,
|
|
46
|
+
slug: subject,
|
|
47
|
+
nextAction: "run_specify"
|
|
48
|
+
});
|
|
49
|
+
const intakeFile = input.bootstrap?.intakeFile;
|
|
50
|
+
if (intakeFile) {
|
|
51
|
+
const source = path.resolve(intakeFile);
|
|
52
|
+
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
|
|
53
|
+
throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
|
|
54
|
+
}
|
|
55
|
+
const intakeDir = path.join(started.run.run_home_path ?? path.dirname(started.run.id), "intake");
|
|
56
|
+
fs.mkdirSync(intakeDir, { recursive: true });
|
|
57
|
+
fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
|
|
58
|
+
}
|
|
59
|
+
return started.run.id;
|
|
60
|
+
}
|
|
61
|
+
function stagePreflight(projectRoot, stageRoot) {
|
|
62
|
+
const memoryBank = preflightMemoryPermissions({
|
|
63
|
+
root: projectRoot,
|
|
64
|
+
memoryBank: ".memory-bank",
|
|
65
|
+
flow: "custom",
|
|
66
|
+
mode: "write",
|
|
67
|
+
targets: [{ path: ".memory-bank", mode: "read" }]
|
|
68
|
+
});
|
|
69
|
+
const probe = path.join(stageRoot, `.dd-flow-permission-${crypto.randomUUID()}`);
|
|
70
|
+
try {
|
|
71
|
+
fs.writeFileSync(probe, "");
|
|
72
|
+
fs.renameSync(probe, `${probe}.renamed`);
|
|
73
|
+
fs.rmSync(`${probe}.renamed`);
|
|
74
|
+
return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
for (const candidate of [probe, `${probe}.renamed`]) {
|
|
78
|
+
try {
|
|
79
|
+
fs.rmSync(candidate, { force: true });
|
|
80
|
+
}
|
|
81
|
+
catch { /* best effort */ }
|
|
82
|
+
}
|
|
83
|
+
return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function flowKindForStage(stage) {
|
|
87
|
+
return ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning";
|
|
88
|
+
}
|
|
89
|
+
function runtimeFacts(view, projectRoot) {
|
|
90
|
+
return {
|
|
91
|
+
project_root: projectRoot,
|
|
92
|
+
workspace_root: view.run.workspace_root,
|
|
93
|
+
run_id: view.run.id,
|
|
94
|
+
protocol_id: view.run.subject.id,
|
|
95
|
+
git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
15
98
|
export function startStage(context, input) {
|
|
16
99
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
17
|
-
const
|
|
18
|
-
|
|
100
|
+
const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
|
|
101
|
+
if (!runId)
|
|
102
|
+
throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
|
|
103
|
+
const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
104
|
+
const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
|
|
19
105
|
const attached = runView(attachFlowRunStage(context, {
|
|
20
106
|
projectRoot,
|
|
21
|
-
runId
|
|
107
|
+
runId,
|
|
22
108
|
stage: input.stage,
|
|
23
109
|
dir,
|
|
24
110
|
status: "running",
|
|
@@ -28,9 +114,29 @@ export function startStage(context, input) {
|
|
|
28
114
|
const stageRoot = path.join(runHome, dir);
|
|
29
115
|
ensureWithin(runHome, stageRoot, "stage root");
|
|
30
116
|
fs.mkdirSync(stageRoot, { recursive: true });
|
|
117
|
+
const preflight = stagePreflight(projectRoot, stageRoot);
|
|
118
|
+
if (preflight.ok !== true)
|
|
119
|
+
throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
|
|
31
120
|
syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
|
|
121
|
+
if (input.sessionId) {
|
|
122
|
+
registerFlowSession(context, {
|
|
123
|
+
sessionId: input.sessionId,
|
|
124
|
+
payloadJson: JSON.stringify({
|
|
125
|
+
project_root: projectRoot,
|
|
126
|
+
flow_kind: flowKindForStage(input.stage),
|
|
127
|
+
run_id: attached.run.id,
|
|
128
|
+
protocol_id: attached.run.subject.id,
|
|
129
|
+
worker_id: null,
|
|
130
|
+
workspace_path: attached.run.workspace_root,
|
|
131
|
+
continuation_policy: "go_router",
|
|
132
|
+
session_kind: "orchestrator",
|
|
133
|
+
current_stage: input.stage,
|
|
134
|
+
cwd: attached.run.workspace_root
|
|
135
|
+
})
|
|
136
|
+
});
|
|
137
|
+
}
|
|
32
138
|
const promptPath = path.join(stageRoot, "stage-prompt.md");
|
|
33
|
-
const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir);
|
|
139
|
+
const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
|
|
34
140
|
atomicWrite(promptPath, prompt);
|
|
35
141
|
const promptDataPath = path.join(stageRoot, "stage-prompt.json");
|
|
36
142
|
const planPath = planJsonPath(projectRoot, attached.run.subject.id);
|
|
@@ -38,12 +144,12 @@ export function startStage(context, input) {
|
|
|
38
144
|
const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
|
|
39
145
|
const attemptNumber = Number(attempt.replace("try-", "")) || 1;
|
|
40
146
|
atomicWrite(promptDataPath, {
|
|
41
|
-
schema_id: "dd-flow/stage-prompt@
|
|
147
|
+
schema_id: "dd-flow/stage-prompt@2",
|
|
42
148
|
run_id: attached.run.id,
|
|
43
149
|
stage: input.stage,
|
|
44
150
|
generated_at: context.now(),
|
|
45
151
|
prompt_path: promptPath,
|
|
46
|
-
sections:
|
|
152
|
+
sections: stagePromptSections,
|
|
47
153
|
aliases: {
|
|
48
154
|
project: projectRoot,
|
|
49
155
|
workspace: attached.run.workspace_root,
|
|
@@ -54,11 +160,15 @@ export function startStage(context, input) {
|
|
|
54
160
|
...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
|
|
55
161
|
},
|
|
56
162
|
write_boundary: { current: "@stage", archive: attempt, archive_writable: false },
|
|
57
|
-
source_fragments:
|
|
163
|
+
source_fragments: stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage).map((source) => source.label),
|
|
164
|
+
authoritative_facts: runtimeFacts(attached, projectRoot),
|
|
165
|
+
preflight,
|
|
166
|
+
required_context: stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage).map((source) => source.label),
|
|
167
|
+
worker_prompt_markdown: prompt
|
|
58
168
|
});
|
|
59
169
|
validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot });
|
|
60
170
|
atomicWrite(path.join(stageRoot, "stage-start.json"), {
|
|
61
|
-
schema_id: "dd-flow/stage-start@
|
|
171
|
+
schema_id: "dd-flow/stage-start@2",
|
|
62
172
|
run_id: attached.run.id,
|
|
63
173
|
stage: input.stage,
|
|
64
174
|
dir,
|
|
@@ -68,7 +178,7 @@ export function startStage(context, input) {
|
|
|
68
178
|
});
|
|
69
179
|
return {
|
|
70
180
|
ok: true,
|
|
71
|
-
schema_id: "dd-flow/stage-start-response@
|
|
181
|
+
schema_id: "dd-flow/stage-start-response@2",
|
|
72
182
|
run_id: attached.run.id,
|
|
73
183
|
stage: input.stage,
|
|
74
184
|
attempt_number: attemptNumber,
|
|
@@ -86,32 +196,33 @@ export function startStage(context, input) {
|
|
|
86
196
|
},
|
|
87
197
|
...(input.stage === "plan" ? { plan_ref: planPath, aspect_map_ref: aspectMapPath } : {}),
|
|
88
198
|
resolved_context: { run: attached.run, stage: { name: input.stage, dir, status: "running" } },
|
|
89
|
-
next_command:
|
|
90
|
-
permission_probe:
|
|
199
|
+
next_command: `dd-flow stage finish ${attached.run.id} --stage ${input.stage} --outcome done --project-root ${JSON.stringify(projectRoot)} --json`,
|
|
200
|
+
permission_probe: preflight,
|
|
91
201
|
run: attached.run,
|
|
92
202
|
prompt: { path: promptPath, data_path: promptDataPath },
|
|
203
|
+
worker_prompt_markdown: prompt,
|
|
93
204
|
lifecycle: "stage start -> semantic work -> stage finish"
|
|
94
205
|
};
|
|
95
206
|
}
|
|
96
207
|
export function finishStage(context, input) {
|
|
97
208
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
98
|
-
|
|
209
|
+
if (!input.runId)
|
|
210
|
+
throw new AppError("usage", "stage finish requires a RUN id", 2);
|
|
211
|
+
const runId = input.runId;
|
|
212
|
+
const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
|
|
99
213
|
const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
|
|
100
214
|
if (!existing) {
|
|
101
215
|
throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
|
|
102
216
|
}
|
|
103
|
-
const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage);
|
|
217
|
+
const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage, view.run.flow_kind);
|
|
104
218
|
const runHome = runHomePath(view.run);
|
|
105
219
|
const stageRoot = path.join(runHome, dir);
|
|
106
220
|
ensureWithin(runHome, stageRoot, "stage root");
|
|
107
221
|
fs.mkdirSync(stageRoot, { recursive: true });
|
|
108
|
-
|
|
109
|
-
throw new AppError("stage_finish_input_required", "Stage finish requires --semantic-file with dd-flow/stage-finish-input@1", 2);
|
|
110
|
-
}
|
|
111
|
-
const semanticFile = resolveStageFile(input.semanticFile, stageRoot, runHome);
|
|
222
|
+
const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
|
|
112
223
|
validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot });
|
|
113
224
|
const semantic = readSemanticFile(semanticFile, runHome);
|
|
114
|
-
const status = input.
|
|
225
|
+
const status = input.outcome ?? "done";
|
|
115
226
|
if (!["done", "blocked", "failed"].includes(status)) {
|
|
116
227
|
throw new AppError("validation", "Stage finish status must be done, blocked, or failed", 2, { status });
|
|
117
228
|
}
|
|
@@ -135,7 +246,7 @@ export function finishStage(context, input) {
|
|
|
135
246
|
const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
|
|
136
247
|
const completed = completeFlowRunStage(context, {
|
|
137
248
|
projectRoot,
|
|
138
|
-
runId
|
|
249
|
+
runId,
|
|
139
250
|
stage: input.stage,
|
|
140
251
|
status,
|
|
141
252
|
stageReport: htmlPath,
|
|
@@ -223,8 +334,20 @@ function nextActionForProtocolStage(stage) {
|
|
|
223
334
|
function runHomePath(run) {
|
|
224
335
|
return run.run_home_path ?? path.dirname(run.run_index_path);
|
|
225
336
|
}
|
|
226
|
-
function defaultStageDir(stage) {
|
|
337
|
+
export function defaultStageDir(stage, flowKind) {
|
|
227
338
|
const normalized = stage === "implementation" ? "code" : stage;
|
|
339
|
+
if (flowKind === "mb-upgrade") {
|
|
340
|
+
const upgrade = {
|
|
341
|
+
preflight: "01-preflight",
|
|
342
|
+
"diff-analysis": "02-diff-analysis",
|
|
343
|
+
upgrade: "03-upgrade",
|
|
344
|
+
lint: "04-lint",
|
|
345
|
+
review: "05-review",
|
|
346
|
+
merge: "06-merge"
|
|
347
|
+
};
|
|
348
|
+
if (upgrade[normalized])
|
|
349
|
+
return upgrade[normalized];
|
|
350
|
+
}
|
|
228
351
|
const known = { specify: "01-specify", plan: "02-plan", code: "03-code", readiness: "03-code", merge: "04-merge" };
|
|
229
352
|
return known[normalized] ?? `03-${normalized.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
|
|
230
353
|
}
|
|
@@ -237,54 +360,96 @@ function dataSchemaForStage(stage) {
|
|
|
237
360
|
return "dd-flow/merge-stage-report@2";
|
|
238
361
|
return "dd-flow/code-stage-report@2";
|
|
239
362
|
}
|
|
240
|
-
function composeStagePrompt(context, projectRoot, before, current, stage, dir) {
|
|
363
|
+
function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
|
|
241
364
|
const project = requireProjectByRoot(context, projectRoot);
|
|
242
365
|
const runHome = runHomePath(current.run);
|
|
243
|
-
const
|
|
244
|
-
?
|
|
245
|
-
: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"];
|
|
246
|
-
const instructions = flowFiles.map((file) => {
|
|
247
|
-
const absolute = path.join(projectRoot, file);
|
|
248
|
-
return fs.existsSync(absolute) ? `### ${file}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${file}\n\n(unavailable)`;
|
|
366
|
+
const instructions = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage).map(({ label, absolute }) => {
|
|
367
|
+
return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
|
|
249
368
|
}).join("\n\n");
|
|
250
369
|
const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
|
|
370
|
+
const requiredContext = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage)
|
|
371
|
+
.map(({ label }) => `- Read: \`${label}\``).join("\n");
|
|
251
372
|
return [
|
|
252
|
-
"
|
|
253
|
-
"",
|
|
254
|
-
"## Identity",
|
|
373
|
+
"<stage_identity>",
|
|
374
|
+
"# Stage work packet",
|
|
255
375
|
`- Project: ${project.id}`,
|
|
256
376
|
`- Run: ${current.run.id}`,
|
|
257
377
|
`- Protocol: ${current.run.subject.id}`,
|
|
258
378
|
`- Stage: ${stage}`,
|
|
259
379
|
`- Attempt: ${current.index.stage_runs?.find((item) => item.stage === stage)?.attempt ?? "try-001"}`,
|
|
380
|
+
"</stage_identity>",
|
|
260
381
|
"",
|
|
261
|
-
"
|
|
382
|
+
"<authoritative_runtime_facts>",
|
|
383
|
+
"These facts were collected by dd-flow. Trust them; do not re-run CLI, Git, compatibility, or permission discovery unless the semantic work changes the relevant state.",
|
|
262
384
|
`- Project root: ${projectRoot}`,
|
|
263
385
|
`- Workspace root: ${current.run.workspace_root}`,
|
|
264
386
|
`- Run home: ${runHome}`,
|
|
265
387
|
`- Stage root: ${path.join(runHome, dir)}`,
|
|
266
388
|
`- Git branch: ${current.index.execution?.git?.branch ?? "unknown"}`,
|
|
267
389
|
`- Git head: ${current.index.execution?.git?.head ?? "unknown"}`,
|
|
390
|
+
"</authoritative_runtime_facts>",
|
|
391
|
+
"",
|
|
392
|
+
"<preflight>",
|
|
393
|
+
`- Permission probe: ${String(preflight.ok === true ? "passed" : "failed")}.`,
|
|
394
|
+
"- CLI compatibility was checked before this state-changing command.",
|
|
395
|
+
"</preflight>",
|
|
268
396
|
"",
|
|
269
|
-
"
|
|
397
|
+
"<task_intake>",
|
|
270
398
|
"Raw intake and provider telemetry stay under the RUN home. Promote only durable decisions to the protocol summary.",
|
|
271
399
|
`- Protocol next action: ${protocol?.next_action ?? "not recorded"}`,
|
|
272
400
|
`- Previous stage: ${before.index.stage_runs?.map((item) => `${item.stage}:${item.status}`).join(", ") || "none"}`,
|
|
401
|
+
"</task_intake>",
|
|
273
402
|
"",
|
|
274
|
-
"
|
|
403
|
+
"<applicable_instructions>",
|
|
275
404
|
instructions,
|
|
405
|
+
"</applicable_instructions>",
|
|
276
406
|
"",
|
|
277
|
-
"
|
|
278
|
-
|
|
407
|
+
"<required_context>",
|
|
408
|
+
requiredContext || "- No additional stage-specific source is required.",
|
|
409
|
+
"</required_context>",
|
|
279
410
|
"",
|
|
280
|
-
"
|
|
281
|
-
"
|
|
411
|
+
"<work_contract>",
|
|
412
|
+
"Perform only the semantic work for this stage. Write current-stage artifacts to `@stage`; `@stage/try-NNN` is read-only archive history.",
|
|
413
|
+
"Do not repeat deterministic preflight, global CLI help, runtime/Git inspection, session registration, report rendering, summary generation, or state transition work.",
|
|
414
|
+
"</work_contract>",
|
|
282
415
|
"",
|
|
283
|
-
"
|
|
284
|
-
`
|
|
416
|
+
"<completion_contract>",
|
|
417
|
+
`Write semantic output to \`@stage/stage-input.json\`, then run: \`dd-flow stage finish ${current.run.id} --stage ${stage} --outcome done --project-root ${JSON.stringify(projectRoot)} --json\`.`,
|
|
285
418
|
"The CLI must generate validated JSON, Markdown, HTML and protocol-summary evidence.",
|
|
419
|
+
"</completion_contract>",
|
|
286
420
|
].join("\n");
|
|
287
421
|
}
|
|
422
|
+
function stageInstructionSources(context, projectRoot, flowKind, stage) {
|
|
423
|
+
if (flowKind === "mb-upgrade") {
|
|
424
|
+
const canon = resolveCanonRoot(context);
|
|
425
|
+
if (!canon.ok || !canon.canon) {
|
|
426
|
+
throw new AppError("canon_unavailable", "mb-upgrade stage prompt requires the canonical Memory Bank", 1, {
|
|
427
|
+
blockers: canon.blockers,
|
|
428
|
+
bootstrap: canon.bootstrap
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const flowRoot = canon.canon.flow_root;
|
|
432
|
+
const stageSupport = {
|
|
433
|
+
"diff-analysis": "mb-upgrade/diff-analysis.md",
|
|
434
|
+
lint: "mb-upgrade/lint-verification.md",
|
|
435
|
+
review: "mb-upgrade/review/index.md",
|
|
436
|
+
merge: "mb-upgrade/merge.md"
|
|
437
|
+
};
|
|
438
|
+
return ["mb-upgrade.md", "mb-upgrade/index.md", stageSupport[stage]]
|
|
439
|
+
.filter((file) => Boolean(file))
|
|
440
|
+
.map((file) => ({ label: `canonical:${file}`, absolute: path.join(flowRoot, file) }));
|
|
441
|
+
}
|
|
442
|
+
const filesByStage = {
|
|
443
|
+
specify: [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"],
|
|
444
|
+
plan: [".memory-bank/dd-flow/plan.md"],
|
|
445
|
+
code: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
|
|
446
|
+
implementation: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
|
|
447
|
+
readiness: [".memory-bank/dd-flow/mb-sdlc/code/readiness.md"],
|
|
448
|
+
merge: [".memory-bank/dd-flow/merge.md"]
|
|
449
|
+
};
|
|
450
|
+
const files = filesByStage[stage] ?? [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"];
|
|
451
|
+
return files.map((file) => ({ label: file, absolute: path.join(projectRoot, file) }));
|
|
452
|
+
}
|
|
288
453
|
function safeProtocolState(context, projectId, protocolId) {
|
|
289
454
|
try {
|
|
290
455
|
const protocol = requireProtocol(context, protocolId, projectId);
|