@deksden-com/dd-flow-cli 0.8.0-beta.135 → 0.9.0-beta.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +31 -4
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +15 -63
  5. package/dist/cli/run-cli.js +55 -26
  6. package/dist/domain/stage-catalog.js +2 -2
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/agent-profile.schema.json +17 -0
  9. package/dist/schemas/code-review-decision.schema.json +5 -3
  10. package/dist/schemas/code-work-batch.schema.json +3 -3
  11. package/dist/schemas/compatibility.schema.json +32 -0
  12. package/dist/schemas/flow-contract.schema.json +3 -2
  13. package/dist/schemas/merge-result.schema.json +15 -0
  14. package/dist/schemas/protocol-plan.schema.json +1 -1
  15. package/dist/schemas/stage-start-response.schema.json +4 -2
  16. package/dist/schemas/status-report.schema.json +76 -0
  17. package/dist/schemas/vnext-protocol-plan.schema.json +4 -4
  18. package/dist/services/cli-operation-classifier.js +1 -1
  19. package/dist/services/code-checks.js +125 -208
  20. package/dist/services/eval-snapshots.js +10 -1
  21. package/dist/services/harness-adapter.js +59 -0
  22. package/dist/services/hooks.js +87 -237
  23. package/dist/services/ids.js +18 -1
  24. package/dist/services/lifecycle-command.js +288 -0
  25. package/dist/services/merge-server.js +124 -0
  26. package/dist/services/prompts.js +16 -10
  27. package/dist/services/runs.js +7 -2
  28. package/dist/services/sessions.js +18 -27
  29. package/dist/services/stage-pause.js +13 -0
  30. package/dist/services/vnext-code-review.js +71 -20
  31. package/dist/services/vnext-code.js +131 -34
  32. package/dist/services/vnext-execution-profile.js +6 -3
  33. package/dist/services/vnext-merge.js +330 -0
  34. package/dist/services/vnext-plan-review.js +2 -2
  35. package/dist/services/vnext-plan.js +22 -38
  36. package/dist/services/vnext-specify.js +5 -2
  37. package/dist/services/vnext-workspace-policy.js +8 -2
  38. package/dist/services/work-registry.js +85 -34
  39. package/dist/storage/database.js +66 -0
  40. package/package.json +2 -1
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "definitions": {
23
23
  "semanticSpine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "non_goals": {"type": "array", "items": {"type": "string", "minLength": 1}}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
24
- "executionContext": {"type": "object", "additionalProperties": false, "required": ["prompt_profile", "required_read", "discovery_boundary", "write_scope", "checks"], "properties": {"prompt_profile": {"enum": ["documentation_contract", "code_implementation", "verification"]}, "required_read": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "discovery_boundary": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "write_scope": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "checks": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}}},
24
+ "executionContext": {"type": "object", "additionalProperties": false, "required": ["prompt_profile", "required_read", "discovery_boundary", "planned_write_areas", "checks"], "properties": {"prompt_profile": {"enum": ["documentation_contract", "code_implementation", "verification"]}, "required_read": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "discovery_boundary": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Soft concurrent-work coordination hints, never a write allowlist."}, "checks": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}}},
25
25
  "verificationContract": {"type": "object", "additionalProperties": false, "required": ["checks", "evidence"], "properties": {"checks": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, "evidence": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}}},
26
26
  "planItem": {"type": "object", "additionalProperties": false, "required": ["id", "kind", "title", "summary", "depends_on", "owner", "target_stage", "required", "requirements", "semantic_spine", "execution_context", "verification_contract"], "properties": {"id": {"type": "string", "pattern": "^P[0-9]+$"}, "kind": {"type": "string", "minLength": 1}, "title": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "details": {"type": "string", "minLength": 1}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "owner": {"type": "string", "minLength": 1}, "target_stage": {"enum": ["specify", "plan", "code", "readiness", "merge", "release"]}, "required": {"type": "boolean"}, "requirements": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^SPC-[0-9]{3}@[0-9]+\\.[0-9]+\\.[0-9]+/R-[0-9]+$"}}, "semantic_spine": {"$ref": "#/definitions/semanticSpine"}, "execution_context": {"$ref": "#/definitions/executionContext"}, "verification_contract": {"$ref": "#/definitions/verificationContract"}}},
27
27
  "taskAssessment": {"type": "object", "additionalProperties": false, "required": ["scope_breadth", "solution_novelty", "solution_uncertainty", "failure_impact", "plan_floor"], "properties": {"scope_breadth": {"$ref": "#/definitions/assessmentAxis"}, "solution_novelty": {"$ref": "#/definitions/assessmentAxis"}, "solution_uncertainty": {"$ref": "#/definitions/assessmentAxis"}, "failure_impact": {"$ref": "#/definitions/assessmentAxis"}, "plan_floor": {"$ref": "#/definitions/assessmentAxis"}}},
@@ -10,7 +10,7 @@
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", "worker_prompt_markdown"],
13
+ "required": ["schema_id", "run_id", "stage", "attempt_number", "stage_root", "prompt_path", "aliases", "resolved_context", "preflight", "worker_prompt_markdown", "next_command"],
14
14
  "properties": {
15
15
  "schema_id": {"const": "dd-flow/stage-start-response@2"},
16
16
  "run_id": {"type": "string", "minLength": 1},
@@ -24,7 +24,9 @@
24
24
  "plan_ref": {"type": "string", "pattern": "^\\.memory-bank/protocol/[^/]+/plan\\.json$"},
25
25
  "aspect_map_ref": {"type": "string", "minLength": 1},
26
26
  "next_command": {"type": "string", "pattern": "^dd-flow stage finish "},
27
+ "preflight": {"type": "object", "required": ["git", "compatibility", "permissions", "session_binding"], "additionalProperties": true},
28
+ "worker_prompt_markdown": {"type": "string", "minLength": 1},
27
29
  "permission_probe": {"type": "object", "additionalProperties": true},
28
- "worker_prompt_markdown": {"type": "string", "minLength": 1}
30
+ "bootstrap": {"type": "boolean"}
29
31
  }
30
32
  }
@@ -84,9 +84,85 @@
84
84
  }
85
85
  }
86
86
  }
87
+ },
88
+ "engine": {
89
+ "type": ["object", "null"],
90
+ "additionalProperties": true,
91
+ "properties": {
92
+ "contexts": { "$ref": "#/definitions/compatibilityContexts" }
93
+ }
87
94
  }
88
95
  },
89
96
  "definitions": {
97
+ "compatibilityContexts": {
98
+ "type": "object",
99
+ "additionalProperties": false,
100
+ "required": ["project_current", "upgrade_target", "effective_execution"],
101
+ "properties": {
102
+ "project_current": { "$ref": "#/definitions/projectCurrentContext" },
103
+ "upgrade_target": {
104
+ "anyOf": [
105
+ { "type": "null" },
106
+ { "$ref": "#/definitions/upgradeTargetContext" }
107
+ ]
108
+ },
109
+ "effective_execution": { "$ref": "#/definitions/effectiveExecutionContext" }
110
+ }
111
+ },
112
+ "projectCurrentContext": {
113
+ "type": "object",
114
+ "additionalProperties": true,
115
+ "required": ["name", "canon", "memory_bank_version", "package_name", "required_engine_range", "resolution", "engine_version", "diagnostics"],
116
+ "properties": {
117
+ "name": { "const": "project_current" },
118
+ "canon": { "type": "object", "additionalProperties": true },
119
+ "memory_bank_version": { "type": ["string", "null"] },
120
+ "package_name": { "type": "string", "minLength": 1 },
121
+ "required_engine_range": { "type": ["string", "null"] },
122
+ "recommended_engine_version": { "type": ["string", "null"] },
123
+ "resolution": { "type": "string", "minLength": 1 },
124
+ "engine_version": { "type": ["string", "null"] },
125
+ "diagnostics": { "type": "array", "items": { "type": "string" } }
126
+ }
127
+ },
128
+ "upgradeTargetContext": {
129
+ "type": "object",
130
+ "additionalProperties": true,
131
+ "required": ["name", "canon", "memory_bank_version", "package_name", "required_engine_range", "resolution", "engine_version", "diagnostics"],
132
+ "properties": {
133
+ "name": { "const": "upgrade_target" },
134
+ "canon": {
135
+ "type": "object",
136
+ "additionalProperties": true,
137
+ "required": ["root", "version", "commit", "compatibility_sha256"],
138
+ "properties": {
139
+ "root": { "type": "string", "minLength": 1 },
140
+ "version": { "type": "string", "minLength": 1 },
141
+ "commit": { "type": "string", "minLength": 1 },
142
+ "compatibility_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
143
+ }
144
+ },
145
+ "memory_bank_version": { "type": ["string", "null"] },
146
+ "package_name": { "type": "string", "minLength": 1 },
147
+ "required_engine_range": { "type": ["string", "null"] },
148
+ "recommended_engine_version": { "type": ["string", "null"] },
149
+ "resolution": { "type": "string", "minLength": 1 },
150
+ "engine_version": { "type": ["string", "null"] },
151
+ "diagnostics": { "type": "array", "items": { "type": "string" } }
152
+ }
153
+ },
154
+ "effectiveExecutionContext": {
155
+ "type": "object",
156
+ "additionalProperties": false,
157
+ "required": ["operation", "mode", "package_name", "engine_version", "reason"],
158
+ "properties": {
159
+ "operation": { "type": "string", "minLength": 1 },
160
+ "mode": { "type": "string", "minLength": 1 },
161
+ "package_name": { "type": "string", "minLength": 1 },
162
+ "engine_version": { "type": ["string", "null"] },
163
+ "reason": { "type": "string", "minLength": 1 }
164
+ }
165
+ },
90
166
  "cliCompatibility": {
91
167
  "type": "object",
92
168
  "additionalProperties": true,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "dd-flow/protocol-plan@5",
3
+ "$id": "dd-flow/protocol-plan@6",
4
4
  "title": "Semantic protocol implementation plan",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
7
7
  "required": ["schema_id", "plan_id", "protocol_id", "revision", "title", "summary", "source_refs", "goal", "assessment", "decisions", "document_updates", "checks", "items", "acceptance"],
8
8
  "properties": {
9
- "schema_id": {"const": "dd-flow/protocol-plan@5"},
9
+ "schema_id": {"const": "dd-flow/protocol-plan@6"},
10
10
  "plan_id": {"type": "string", "pattern": "^PLAN-[A-Za-z0-9][A-Za-z0-9._-]*$"},
11
11
  "protocol_id": {"type": "string", "pattern": "^PRT-[A-Za-z0-9][A-Za-z0-9._-]*$"},
12
12
  "revision": {"type": "integer", "minimum": 1},
@@ -28,8 +28,8 @@
28
28
  "decision": {"type": "object", "additionalProperties": false, "required": ["id", "decision", "rationale", "affected_surfaces"], "properties": {"id": {"type": "string", "minLength": 1}, "decision": {"type": "string", "minLength": 1}, "rationale": {"type": "string", "minLength": 1}, "affected_surfaces": {"$ref": "#/$defs/strings"}, "durable_ref": {"type": "string", "minLength": 1}}},
29
29
  "documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}}},
30
30
  "spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"type": "array", "items": {"type": "string", "minLength": 1}}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
31
- "execution": {"type": "object", "additionalProperties": false, "required": ["required_read", "discovery_boundary", "write_scope", "stop_conditions"], "properties": {"required_read": {"$ref": "#/$defs/strings"}, "discovery_boundary": {"$ref": "#/$defs/strings"}, "write_scope": {"$ref": "#/$defs/strings"}, "stop_conditions": {"$ref": "#/$defs/strings"}}},
32
- "check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}]},
31
+ "execution": {"type": "object", "additionalProperties": false, "required": ["required_read", "discovery_boundary", "planned_write_areas", "stop_conditions"], "properties": {"required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources. This is not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas. A worker may inspect other project-local sources when necessary."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Optional file or directory hints used only to coordinate concurrent Works. They never grant or deny write permission."}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Semantic contradictions or external blockers that require the worker to stop."}}},
32
+ "check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}, {"if": {"properties": {"command": {"pattern": "^@check/"}}}, "then": {"required": ["definition"]}}]},
33
33
  "verification": {"type": "object", "additionalProperties": false, "required": ["check_refs"], "properties": {"check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}}},
34
34
  "item": {"type": "object", "additionalProperties": false, "required": ["id", "title", "summary", "details", "depends_on", "requirement_refs", "semantic_spine", "execution_context", "verification"], "properties": {"id": {"type": "string", "pattern": "^P[0-9]+$"}, "title": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "details": {"type": "string", "minLength": 1}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "requirement_refs": {"$ref": "#/$defs/strings"}, "semantic_spine": {"$ref": "#/$defs/spine"}, "execution_context": {"$ref": "#/$defs/execution"}, "verification": {"$ref": "#/$defs/verification"}}},
35
35
  "acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "plan_item_ids", "changed_surfaces", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "plan_item_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "changed_surfaces": {"$ref": "#/$defs/strings"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"type": "array", "items": {"type": "string", "minLength": 1}}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"enum": ["work", "code", "readiness", "merge", "release", "external"]}}}
@@ -133,7 +133,7 @@ function isReadOnlyDiagnostic(family, command, action) {
133
133
  (command === "workspace" && action === "check"));
134
134
  }
135
135
  if (family === "merge")
136
- return command === "status";
136
+ return command === "status" || (command === "request" && action === "status") || (command === "server" && action === "status");
137
137
  if (family === "merge-worker")
138
138
  return command === "status";
139
139
  if (family === "merge-queue")
@@ -3,242 +3,159 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
- /**
7
- * A local CODE gate must not share a mutable database with another checkout.
8
- * The project command consumes this opaque suffix only for its local/test
9
- * database target; preview and production-like targets remain unchanged.
10
- */
11
- export function codeExecutionEnvironment(workspaceRoot) {
12
- const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
13
- return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix };
6
+ const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
7
+ export function codeExecutionEnvironment(workspaceRoot) { const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12); return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix }; }
8
+ export function readCodeCheckProfile(workspaceRoot) {
9
+ const file = path.join(workspaceRoot, profileRelativePath);
10
+ if (!fs.existsSync(file))
11
+ return { profile: null, hash: null, file };
12
+ const bytes = fs.readFileSync(file);
13
+ const value = JSON.parse(bytes.toString("utf8"));
14
+ if (value.schema_id !== "dd-flow/code-check-profile@5")
15
+ throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@5" });
16
+ if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate))
17
+ throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for and mandatory_by_gate are invalid", 2, { file });
18
+ for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
19
+ for (const alias of aliases ?? [])
20
+ if (!value.aliases[alias])
21
+ throw new AppError("invalid_code_check_profile", "A mandatory gate references an unknown alias", 2, { file, gate, alias });
22
+ return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
14
23
  }
15
24
  export function validateCodeCheckCommands(workspaceRoot, commands) {
16
- const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
17
- // Keep one resolved command for every declared check. Two declarations may
18
- // deliberately share a command while carrying different acceptance roles.
19
- if (!fs.existsSync(file))
25
+ const { profile, file } = readCodeCheckProfile(workspaceRoot);
26
+ if (!profile)
20
27
  return [...commands];
21
- const profile = JSON.parse(fs.readFileSync(file, "utf8"));
22
- if (profile.schema_id !== "dd-flow/code-check-profile@4")
23
- throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: profile.schema_id ?? null });
24
- const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases)
25
- ? profile.aliases
26
- : {};
27
- const required = profile.require_alias_for && typeof profile.require_alias_for === "object" && !Array.isArray(profile.require_alias_for)
28
- ? profile.require_alias_for
29
- : {};
30
- return commands.map((command) => {
31
- if (command.startsWith("@check/")) {
32
- const template = aliases[command];
33
- if (typeof template !== "string" || !template.trim())
34
- throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
35
- return command;
36
- }
37
- for (const [prefix, alias] of Object.entries(required)) {
38
- if (command === prefix || command.startsWith(`${prefix} `)) {
39
- throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: String(alias), file });
40
- }
41
- }
28
+ return commands.map((command) => { if (command.startsWith("@check/")) {
29
+ if (!profile.aliases[command]?.trim())
30
+ throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
42
31
  return command;
43
- });
44
- }
45
- export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
46
- const validated = validateCodeCheckCommands(workspaceRoot, commands);
47
- const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
48
- if (!fs.existsSync(file))
49
- return validated;
50
- const profile = JSON.parse(fs.readFileSync(file, "utf8"));
51
- const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases)
52
- ? profile.aliases
53
- : {};
54
- return validated.map((command) => command.startsWith("@check/")
55
- ? String(aliases[command]).replaceAll("{run_id}", runId)
56
- : command);
32
+ } for (const [prefix, alias] of Object.entries(profile.require_alias_for))
33
+ if (command === prefix || command.startsWith(`${prefix} `))
34
+ throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file }); return command; });
35
+ }
36
+ export function resolveCodeCheckCommands(workspaceRoot, runId, commands) { const validated = validateCodeCheckCommands(workspaceRoot, commands); const { profile } = readCodeCheckProfile(workspaceRoot); return validated.map((command) => command.startsWith("@check/") ? profile.aliases[command].replaceAll("{run_id}", runId) : command); }
37
+ export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
38
+ const { profile } = readCodeCheckProfile(workspaceRoot);
39
+ return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available" }; });
40
+ }
41
+ export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
42
+ export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
43
+ export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["code", "readiness"]); }
44
+ export function checksForRunAt(checks, runAt) { return checks.filter((check) => check.run_at === runAt); }
45
+ export function validateCheckPlacement(workspaceRoot, checks) { const { profile } = readCodeCheckProfile(workspaceRoot); const aggregateCommands = new Set(projectPolicyCheckDeclarations(workspaceRoot, "code").flatMap((check) => [check.command, profile?.aliases[check.command] ?? check.command])); for (const check of checks)
46
+ if (check.run_at === "work" && aggregateCommands.has(check.command))
47
+ throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, { check_id: check.id, command: check.command, run_at: check.run_at }); }
48
+ export function validateCheckDeclaration(workspaceRoot, check) {
49
+ if (check.run_at === "external")
50
+ return;
51
+ const { profile } = readCodeCheckProfile(workspaceRoot);
52
+ if (check.availability === "planned" && check.command.startsWith("@check/") && !profile?.aliases[check.command])
53
+ throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
54
+ validateCodeCheckCommands(workspaceRoot, [check.command]);
55
+ if (check.command.startsWith("@check/") && check.definition && profile?.aliases[check.command] !== check.definition)
56
+ throw new AppError("check_definition_drift", "The accepted check alias definition differs from the current project profile", 2, { check_id: check.id, alias: check.command, expected: check.definition, actual: profile?.aliases[check.command] ?? null });
57
+ if (check.availability !== "planned")
58
+ return;
59
+ if (!check.command.startsWith("@check/"))
60
+ throw new AppError("planned_check_requires_alias", "A planned CODE check must declare a new @check/... alias", 2, { check_id: check.id, command: check.command });
61
+ if (!check.definition?.trim())
62
+ throw new AppError("planned_check_definition_missing", "A planned CODE check must declare its exact alias definition", 2, { check_id: check.id, command: check.command });
63
+ if (!profile?.aliases[check.command])
64
+ throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
57
65
  }
58
66
  export async function runCodeChecks(context, input) {
59
- const receipts = [];
60
67
  for (const check of input.checks)
61
68
  validateCheckDeclaration(input.workspaceRoot, check);
62
- const commands = resolveCodeCheckCommands(input.workspaceRoot, input.runId, input.checks.map((check) => check.command));
63
- for (let index = 0; index < commands.length; index += 1) {
64
- const declaration = input.checks[index];
65
- const command = commands[index];
69
+ const { hash: profileHash } = readCodeCheckProfile(input.workspaceRoot);
70
+ const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, input.checks.map((check) => check.command));
71
+ const groups = deduplicate(input.checks, resolved);
72
+ const receipts = [];
73
+ for (let index = 0; index < groups.length; index += 1) {
74
+ const group = groups[index];
66
75
  const ordinal = (context.db.get("SELECT COUNT(*) AS count FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId])?.count ?? 0) + 1;
67
76
  const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
68
77
  const id = `${input.workId ?? input.runId}/${localId}`;
69
78
  const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
70
- fs.mkdirSync(directory, { recursive: true });
79
+ const evidenceDir = path.join(directory, "artifacts");
80
+ fs.mkdirSync(evidenceDir, { recursive: true });
71
81
  const stdoutPath = path.join(directory, "stdout.log");
72
82
  const stderrPath = path.join(directory, "stderr.log");
73
83
  const receiptPath = path.join(directory, "receipt.json");
74
- const evidenceDir = path.join(directory, "artifacts");
75
- fs.mkdirSync(evidenceDir, { recursive: true });
84
+ const before = workspaceState(input.workspaceRoot);
85
+ const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts })).digest("hex");
86
+ const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
76
87
  const startedAt = context.now();
77
- input.progress?.(`check ${index + 1}/${commands.length} started: ${command}`);
88
+ input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
78
89
  const stdout = fs.createWriteStream(stdoutPath);
79
90
  const stderr = fs.createWriteStream(stderrPath);
80
- const result = await runCheck(command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir }, stdout, stderr, (elapsed) => {
81
- input.progress?.(`check ${index + 1}/${commands.length} still running (${elapsed}s): ${command}`);
82
- });
91
+ const result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`));
83
92
  await Promise.all([closeStream(stdout), closeStream(stderr)]);
84
- const finishedAt = context.now();
85
- const exitCode = result.exitCode;
86
- const artifacts = collectRequiredArtifacts(evidenceDir, declaration.required_artifacts ?? []);
87
- const status = exitCode === 0 && !result.error && artifacts.complete ? "passed" : "failed";
93
+ const after = workspaceState(input.workspaceRoot);
94
+ const mutationPaths = changedPaths(before.files, after.files);
95
+ const artifacts = collectRequiredArtifacts(evidenceDir, group.requiredArtifacts);
88
96
  if (!artifacts.complete)
89
97
  fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
90
98
  if (result.error)
91
99
  fs.appendFileSync(stderrPath, `\n${result.error}\n`);
92
- const receipt = {
93
- id,
94
- local_id: localId,
95
- declaration_id: declaration.id,
96
- scope: input.scope,
97
- work_id: input.workId ?? null,
98
- command,
99
- status,
100
- exit_code: exitCode,
101
- stdout_path: stdoutPath,
102
- stderr_path: stderrPath,
103
- receipt_path: receiptPath,
104
- workspace_fingerprint: workspaceFingerprint(input.workspaceRoot),
105
- artifacts: artifacts.items,
106
- started_at: startedAt,
107
- finished_at: finishedAt
108
- };
100
+ if (mutationPaths.length)
101
+ fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
102
+ const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 ? "passed" : "failed";
103
+ const receipt = { id, local_id: localId, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), gate: group.gate, scope: input.scope, work_id: input.workId ?? null, command: group.command, input_hash: inputHash, verification_epoch: epoch, status, exit_code: result.exitCode, stdout_path: stdoutPath, stderr_path: stderrPath, receipt_path: receiptPath, workspace_fingerprint: before.fingerprint, before_fingerprint: before.fingerprint, after_fingerprint: after.fingerprint, profile_hash: profileHash, mutation_paths: mutationPaths, artifacts: artifacts.items, started_at: startedAt, finished_at: context.now() };
109
104
  fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
110
- context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, command, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, declaration.id, command, status, exitCode, stdoutPath, stderrPath, receiptPath, receipt.workspace_fingerprint, JSON.stringify(receipt.artifacts), startedAt, finishedAt]);
105
+ context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, receipt.declaration_id, JSON.stringify(receipt.check_refs), receipt.gate, receipt.command, receipt.input_hash, receipt.verification_epoch, receipt.status, receipt.exit_code, stdoutPath, stderrPath, receiptPath, receipt.workspace_fingerprint, receipt.before_fingerprint, receipt.after_fingerprint, receipt.profile_hash, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.started_at, receipt.finished_at]);
111
106
  receipts.push(receipt);
112
- input.progress?.(`check ${index + 1}/${commands.length} ${status}: ${command}`);
107
+ input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
113
108
  }
114
109
  return receipts;
115
110
  }
116
- export function aggregateCheckDeclarations(workspaceRoot) {
117
- const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
118
- if (!fs.existsSync(file))
119
- return [];
120
- const profile = JSON.parse(fs.readFileSync(file, "utf8"));
121
- if (profile.schema_id !== "dd-flow/code-check-profile@4")
122
- throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: profile.schema_id ?? null });
123
- if (!Array.isArray(profile.aggregate_commands) || !profile.aggregate_commands.every((command) => typeof command === "string" && command.trim()))
124
- throw new AppError("invalid_code_check_profile", "aggregate_commands must be an array of commands", 2, { file });
125
- return profile.aggregate_commands.map((command, index) => ({ id: `CHK-POLICY-CODE-${String(index + 1).padStart(3, "0")}`, command, purpose: "Mandatory project-wide policy gate.", run_at: "code", availability: "available" }));
126
- }
127
- /**
128
- * A project-wide gate can observe files owned by more than one CODE Work.
129
- * Keep it at the CODE/readiness fan-in, where the engine can create a repair
130
- * Work with the failed receipt and the right write scope.
131
- */
132
- export function validateCheckPlacement(workspaceRoot, checks) {
133
- const aggregate = new Set(aggregateCheckDeclarations(workspaceRoot).map((check) => check.command));
134
- for (const check of checks) {
135
- if (check.run_at === "work" && check.availability === "available" && aggregate.has(check.command)) {
136
- throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, {
137
- check_id: check.id,
138
- command: check.command,
139
- run_at: check.run_at
140
- });
141
- }
142
- }
143
- }
144
- export function checksForRunAt(checks, runAt) {
145
- return checks.filter((check) => check.run_at === runAt);
146
- }
147
- /** One SSOT for the final CODE gate, reused after CODE-REVIEW repairs. */
148
- export function finalCodeCheckDeclarations(workspaceRoot, declared) {
149
- const selected = [...aggregateCheckDeclarations(workspaceRoot), ...declared.filter((check) => check.run_at === "code" || check.run_at === "readiness")];
150
- const unique = new Map();
151
- for (const check of selected)
152
- if (!unique.has(check.id))
153
- unique.set(check.id, check);
154
- return [...unique.values()];
155
- }
156
- /** Prevent an unchanged failed stage gate from rerunning expensive commands. */
157
- export function unchangedFinalGateFailures(context, input) {
158
- const wanted = new Set(input.declarations.map((check) => check.id));
159
- const current = workspaceFingerprint(input.workspaceRoot);
160
- const latest = new Map();
161
- for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && wanted.has(item.declaration_id)))
162
- latest.set(receipt.declaration_id, receipt);
163
- return [...latest.values()].filter((receipt) => receipt.status === "failed" && receipt.workspace_fingerprint === current);
164
- }
165
- export function validateCheckDeclaration(workspaceRoot, check) {
166
- if (check.availability !== "planned") {
167
- validateCodeCheckCommands(workspaceRoot, [check.command]);
168
- return;
169
- }
170
- if (!check.command.startsWith("@check/")) {
171
- throw new AppError("planned_check_requires_alias", "A planned CODE check must declare a new @check/... alias", 2, { check_id: check.id, command: check.command });
172
- }
173
- if (!check.definition?.trim()) {
174
- throw new AppError("planned_check_definition_missing", "A planned CODE check must declare its exact alias definition", 2, { check_id: check.id, command: check.command });
175
- }
176
- const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
177
- const profile = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
178
- const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases) ? profile.aliases : {};
179
- const actual = aliases[check.command];
180
- if (typeof actual !== "string")
181
- throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
182
- if (check.definition && actual !== check.definition)
183
- throw new AppError("planned_check_definition_mismatch", "A planned check alias does not match its declared definition", 2, { check_id: check.id, command: check.command, expected: check.definition, actual });
184
- }
185
- function collectRequiredArtifacts(root, required) {
186
- const missing = [];
187
- const items = required.map((relative) => {
188
- if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
189
- throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
190
- const file = path.join(root, relative);
191
- if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
192
- missing.push(relative);
193
- return null;
194
- }
195
- return { path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") };
196
- }).filter((item) => item !== null);
197
- return { complete: missing.length === 0, missing, items };
198
- }
199
- export function workspaceFingerprint(root) {
200
- const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
201
- const listing = git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root);
202
- const hash = crypto.createHash("sha256");
203
- for (const relative of listing) {
204
- const file = path.join(root, relative);
205
- hash.update(relative).update("\0");
206
- hash.update(fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : "<missing>");
207
- hash.update("\0");
208
- }
209
- return hash.digest("hex");
210
- }
211
- function listFiles(root, current = root) {
212
- return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => {
213
- if ([".git", "node_modules"].includes(entry.name))
214
- return [];
215
- const absolute = path.join(current, entry.name);
216
- return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)];
217
- }).sort();
218
- }
219
- function runCheck(command, cwd, environment, stdout, stderr, heartbeat) {
220
- return new Promise((resolve) => {
221
- const started = Date.now();
222
- const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
223
- child.stdout.pipe(stdout, { end: false });
224
- child.stderr.pipe(stderr, { end: false });
225
- const progress = setInterval(() => heartbeat(Math.floor((Date.now() - started) / 1000)), 15_000);
226
- let timedOut = false;
227
- const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, 15 * 60 * 1000);
228
- let error = null;
229
- child.once("error", (value) => { error = value.message; });
230
- child.once("close", (exitCode, signal) => {
231
- clearInterval(progress);
232
- clearTimeout(timeout);
233
- resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds" : signal ? `terminated by ${signal}` : null) });
234
- });
235
- });
236
- }
237
- function closeStream(stream) {
238
- return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve()));
239
- }
111
+ export function unchangedFinalGateFailures(context, input) { const wanted = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)])); const current = workspaceFingerprint(input.workspaceRoot); const latest = new Map(); for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && item.check_refs.some((ref) => wanted.has(ref))))
112
+ for (const ref of receipt.check_refs)
113
+ latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
240
114
  export function checkReceipts(context, input) {
241
115
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
242
116
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
243
- return context.db.all(`SELECT id, declaration_id, scope, work_id, command, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((receipt) => ({ ...receipt, local_id: receipt.id.slice(receipt.id.lastIndexOf("/") + 1), artifacts: JSON.parse(receipt.artifacts_json) }));
244
- }
117
+ return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), artifacts: JSON.parse(row.artifacts_json) }));
118
+ }
119
+ export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
120
+ function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts]); const current = groups.get(key); if (current)
121
+ current.declarations.push(check);
122
+ else
123
+ groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts }); }); return [...groups.values()]; }
124
+ function workspaceState(root) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const listing = git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
125
+ const file = path.join(root, relative);
126
+ const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
127
+ const digest = crypto.createHash("sha256").update(content).digest("hex");
128
+ files.set(relative, digest);
129
+ hash.update(relative).update("\0").update(digest).update("\0");
130
+ } return { fingerprint: hash.digest("hex"), files }; }
131
+ function changedPaths(before, after) { return [...new Set([...before.keys(), ...after.keys()])].filter((key) => before.get(key) !== after.get(key)).sort(); }
132
+ function checkRef(check) { return check.canonical_ref ?? check.id; }
133
+ function parseArray(value, fallback) { try {
134
+ const parsed = JSON.parse(value);
135
+ return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback;
136
+ }
137
+ catch {
138
+ return fallback;
139
+ } }
140
+ function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
141
+ function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
142
+ function collectRequiredArtifacts(root, required) { const missing = []; const items = []; for (const relative of required) {
143
+ if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes(".."))
144
+ throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
145
+ const file = path.join(root, relative);
146
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
147
+ missing.push(relative);
148
+ continue;
149
+ }
150
+ items.push({ path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") });
151
+ } return { complete: missing.length === 0, missing, items }; }
152
+ function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
153
+ return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
154
+ function runCheck(command, cwd, environment, stdout, stderr, heartbeat) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" }); child.stdout.pipe(stdout, { end: false }); child.stderr.pipe(stderr, { end: false }); const progress = setInterval(() => heartbeat(Math.floor((Date.now() - started) / 1000)), 15_000); let timedOut = false; let error = null; let escalation; const timeout = setTimeout(() => { timedOut = true; terminateProcessGroup(child.pid, "SIGTERM"); escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000); }, 15 * 60 * 1000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); clearTimeout(timeout); if (escalation)
155
+ clearTimeout(escalation); resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds" : signal ? `terminated by ${signal}` : null), aborted: timedOut || Boolean(signal) || Boolean(error) }); }); }); }
156
+ function terminateProcessGroup(pid, signal) { if (!pid)
157
+ return; try {
158
+ process.kill(process.platform === "win32" ? pid : -pid, signal);
159
+ }
160
+ catch { /* already exited */ } }
161
+ function closeStream(stream) { return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve())); }
@@ -61,9 +61,10 @@ export function createEvalRunSnapshot(context, input) {
61
61
  assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
62
62
  else
63
63
  throw new AppError("usage", "Snapshot requires exactly one of stageEntry or candidate", 2);
64
+ const allowedCreatedResultSchema = input.stageEntry ? createdWorkSchemaForStage(input.stageEntry) : null;
64
65
  const activeChildren = input.candidate
65
66
  ? context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND status IN ('created', 'running', 'paused')", [project.id, input.runId])?.count ?? 0
66
- : context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? <> 'code' OR COALESCE(result_schema, '') NOT LIKE 'dd-flow/code-work-result@%')))", [project.id, input.runId, input.stageEntry])?.count ?? 0;
67
+ : context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? IS NULL OR COALESCE(result_schema, '') NOT LIKE ?)))", [project.id, input.runId, allowedCreatedResultSchema, allowedCreatedResultSchema ? `${allowedCreatedResultSchema}@%` : ""])?.count ?? 0;
67
68
  if (activeChildren > 0)
68
69
  throw new AppError("snapshot_not_quiescent", `${input.candidate ? "Candidate" : "Stage-entry"} snapshot requires no active child Work`, 1, { run_id: input.runId, active_children: activeChildren });
69
70
  const output = path.resolve(input.output);
@@ -106,6 +107,14 @@ export function createEvalRunSnapshot(context, input) {
106
107
  writeJson(path.join(output, "snapshot.json"), manifest);
107
108
  return { ok: true, snapshot: output, ...manifest };
108
109
  }
110
+ /** A not-yet-started Work packet may be part of the target Stage's entry state. */
111
+ function createdWorkSchemaForStage(stage) {
112
+ if (stage === "code")
113
+ return "dd-flow/code-work-result";
114
+ if (stage === "merge")
115
+ return "dd-flow/merge-result";
116
+ return null;
117
+ }
109
118
  export function restoreEvalRunSnapshot(context, input) {
110
119
  const snapshot = path.resolve(input.snapshot);
111
120
  const manifest = readManifest(snapshot);