@deksden-com/dd-flow-cli 0.7.0 → 0.8.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 +666 -0
- package/README.md +7 -2
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +88 -10
- package/dist/cli/run-cli.js +523 -28
- package/dist/domain/stage-catalog.js +22 -0
- package/dist/domain/validation.js +1 -1
- package/dist/schemas/code-review-decision.schema.json +26 -0
- package/dist/schemas/code-review-result.schema.json +14 -0
- package/dist/schemas/code-verification.schema.json +14 -0
- package/dist/schemas/code-work-batch.schema.json +24 -0
- package/dist/schemas/code-work-result.schema.json +16 -0
- package/dist/schemas/compatibility.schema.json +32 -0
- package/dist/schemas/flow-contract.schema.json +9 -5
- package/dist/schemas/flow-run.schema.json +16 -123
- package/dist/schemas/plan-aspect-map.schema.json +22 -0
- package/dist/schemas/plan-review-decision.schema.json +14 -0
- package/dist/schemas/plan-review-result.schema.json +42 -0
- package/dist/schemas/protocol-plan.schema.json +15 -182
- package/dist/schemas/stage-finish-input.schema.json +16 -2
- package/dist/schemas/stage-report.schema.json +8 -7
- package/dist/schemas/stage-start-response.schema.json +4 -2
- package/dist/schemas/status-report.schema.json +76 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
- package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
- package/dist/schemas/vnext-specify.schema.json +45 -0
- package/dist/services/branch-context.js +1 -1
- package/dist/services/cleanup.js +8 -8
- package/dist/services/cli-operation-classifier.js +10 -2
- package/dist/services/code-checks.js +244 -0
- package/dist/services/config.js +7 -1
- package/dist/services/dashboard.js +12 -12
- package/dist/services/engines.js +1 -1
- package/dist/services/eval-snapshots.js +404 -0
- package/dist/services/hooks.js +774 -18
- package/dist/services/ids.js +16 -6
- package/dist/services/lanes.js +1 -1
- package/dist/services/merge-queue.js +5 -5
- package/dist/services/merge-worker.js +2 -2
- package/dist/services/migrations.js +2 -2
- package/dist/services/plan-runtime.js +1 -1
- package/dist/services/projects.js +4 -4
- package/dist/services/prompts.js +17 -11
- package/dist/services/protocols.js +8 -8
- package/dist/services/run-projection.js +49 -13
- package/dist/services/runs.js +504 -51
- package/dist/services/schema-validation.js +21 -3
- package/dist/services/sessions.js +51 -12
- package/dist/services/stage-blocker.js +57 -0
- package/dist/services/stage-context.js +90 -0
- package/dist/services/stage-lifecycle.js +198 -75
- package/dist/services/stage-pause.js +175 -0
- package/dist/services/stage-report-renderer.js +65 -0
- package/dist/services/usage.js +526 -18
- package/dist/services/vnext-code-review.js +305 -0
- package/dist/services/vnext-code.js +686 -0
- package/dist/services/vnext-contracts.js +1 -0
- package/dist/services/vnext-execution-profile.js +27 -0
- package/dist/services/vnext-fanout.js +79 -0
- package/dist/services/vnext-plan-review.js +499 -0
- package/dist/services/vnext-plan.js +552 -0
- package/dist/services/vnext-protocolize.js +542 -0
- package/dist/services/vnext-specify.js +595 -0
- package/dist/services/vnext-workspace-policy.js +87 -0
- package/dist/services/work-registry.js +522 -0
- package/dist/services/worktrees.js +58 -37
- package/dist/storage/database.js +263 -34
- package/dist/storage/paths.js +47 -1
- package/package.json +12 -12
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Ajv } from "ajv/dist/ajv.js";
|
|
6
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
6
7
|
import { normalizeFlowContract } from "../domain/flow-contract.js";
|
|
7
8
|
import { AppError } from "../shared/errors.js";
|
|
8
9
|
import { findRunHome, readRunEngineBinding } from "./run-engine-bindings.js";
|
|
@@ -96,8 +97,24 @@ export function validateSchema(options) {
|
|
|
96
97
|
const data = readJson(filePath, "input file");
|
|
97
98
|
const schemaResolution = resolveSchema(options);
|
|
98
99
|
const schema = readJson(schemaResolution.path, "schema");
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
// Ajv's default export only carries the draft-07 meta-schema. New stage
|
|
101
|
+
// contracts may deliberately use draft 2020-12, so choose the validator
|
|
102
|
+
// from the declared schema rather than treating that declaration as data.
|
|
103
|
+
const schemaVersion = asRecord(schema) ? objectValue(asRecord(schema), "$schema") : undefined;
|
|
104
|
+
const ajv = schemaVersion === "https://json-schema.org/draft/2020-12/schema"
|
|
105
|
+
? new Ajv2020({ allErrors: true, strict: false, validateFormats: false })
|
|
106
|
+
: new Ajv({ allErrors: true, strict: false, validateFormats: false });
|
|
107
|
+
let validate;
|
|
108
|
+
try {
|
|
109
|
+
validate = ajv.compile(schema);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw new AppError("schema_registry_invalid", "Schema cannot be compiled by the selected JSON Schema validator", 1, {
|
|
113
|
+
schema: schemaResolution,
|
|
114
|
+
declared_draft: schemaVersion ?? null,
|
|
115
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
116
|
+
});
|
|
117
|
+
}
|
|
101
118
|
const valid = validate(data);
|
|
102
119
|
const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
|
|
103
120
|
const semanticErrors = valid ? validateSemanticSchema(options.schemaName, data) : [];
|
|
@@ -169,7 +186,8 @@ function resolveRunBoundSchema(options) {
|
|
|
169
186
|
const data = readJson(path.resolve(options.file), "input file");
|
|
170
187
|
const root = asRecord(data);
|
|
171
188
|
const run = root ? asRecord(objectValue(root, "run")) : undefined;
|
|
172
|
-
const
|
|
189
|
+
const inferredRunId = run ? objectValue(run, "run_id") : objectValue(root ?? {}, "run_id");
|
|
190
|
+
const runId = options.runId ?? inferredRunId;
|
|
173
191
|
if (typeof runId !== "string")
|
|
174
192
|
return null;
|
|
175
193
|
const projectRoot = path.resolve(options.projectRoot);
|
|
@@ -141,7 +141,7 @@ export function stopMergeWorker(context, input) {
|
|
|
141
141
|
return { ok: true, stopped_sessions: sessions.length, lock_release, waiter_cancellation };
|
|
142
142
|
}
|
|
143
143
|
export function stoppedMergeWorkerState(context, projectId, workerId) {
|
|
144
|
-
const latest = context.db.get(`SELECT status, stop_reason FROM
|
|
144
|
+
const latest = context.db.get(`SELECT status, stop_reason FROM sessions
|
|
145
145
|
WHERE project_id = ? AND worker_id = ? AND flow_kind = 'merge_worker'
|
|
146
146
|
ORDER BY updated_at DESC, rowid DESC LIMIT 1`, [projectId, workerId]);
|
|
147
147
|
return {
|
|
@@ -151,21 +151,21 @@ export function stoppedMergeWorkerState(context, projectId, workerId) {
|
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
export function activeFlowSessionsForProject(context, projectId) {
|
|
154
|
-
return context.db.all(`SELECT * FROM
|
|
154
|
+
return context.db.all(`SELECT * FROM sessions
|
|
155
155
|
WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
|
|
156
156
|
ORDER BY updated_at DESC`, [projectId]);
|
|
157
157
|
}
|
|
158
158
|
export function flowSessionsForProject(context, projectId, filter = {}) {
|
|
159
159
|
if (filter.sessionId) {
|
|
160
|
-
return context.db.all("SELECT * FROM
|
|
160
|
+
return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND session_id = ? ORDER BY updated_at DESC", [projectId, filter.sessionId]);
|
|
161
161
|
}
|
|
162
162
|
if (filter.workerId) {
|
|
163
|
-
return context.db.all("SELECT * FROM
|
|
163
|
+
return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND worker_id = ? ORDER BY updated_at DESC", [projectId, filter.workerId]);
|
|
164
164
|
}
|
|
165
|
-
return context.db.all("SELECT * FROM
|
|
165
|
+
return context.db.all("SELECT * FROM sessions WHERE project_id = ? ORDER BY updated_at DESC", [projectId]);
|
|
166
166
|
}
|
|
167
167
|
export function flowSessionById(context, projectId, sessionId) {
|
|
168
|
-
return context.db.get("SELECT * FROM
|
|
168
|
+
return context.db.get("SELECT * FROM sessions WHERE project_id = ? AND session_id = ?", [
|
|
169
169
|
projectId,
|
|
170
170
|
sessionId
|
|
171
171
|
]);
|
|
@@ -177,7 +177,7 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
|
|
|
177
177
|
}
|
|
178
178
|
const actionHash = hashString(actionKey);
|
|
179
179
|
const nextCount = session.last_action_hash === actionHash ? session.continuation_count + 1 : 1;
|
|
180
|
-
context.db.run(`UPDATE
|
|
180
|
+
context.db.run(`UPDATE sessions
|
|
181
181
|
SET continuation_count = ?, last_action_hash = ?, updated_at = ?
|
|
182
182
|
WHERE project_id = ? AND session_id = ?`, [nextCount, actionHash, context.now(), projectId, sessionId]);
|
|
183
183
|
if (session.run_id)
|
|
@@ -200,7 +200,8 @@ export function flowSessionPayloadFromRegisterCommand(command) {
|
|
|
200
200
|
function flowSessionPayloadFromStageStartCommand(command) {
|
|
201
201
|
const projectRoot = optionFromCommand(command, "project-root");
|
|
202
202
|
const stage = optionFromCommand(command, "stage");
|
|
203
|
-
const
|
|
203
|
+
const rawRun = command.match(/\bdd-flow\s+stage\s+start\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s]+)/)?.[1];
|
|
204
|
+
const run = rawRun ? shellArgument(rawRun) : undefined;
|
|
204
205
|
if (!projectRoot || !stage || !run || run.startsWith("--"))
|
|
205
206
|
return undefined;
|
|
206
207
|
return {
|
|
@@ -248,6 +249,14 @@ function normalizeFlowSessionPayload(payload) {
|
|
|
248
249
|
const sessionId = stringField(payload, "session_id", false);
|
|
249
250
|
return {
|
|
250
251
|
...(sessionId ? { session_id: sessionId } : {}),
|
|
252
|
+
harness: stringField(payload, "harness", false) ?? "codex-desktop",
|
|
253
|
+
provider_session_id: stringField(payload, "provider_session_id", false) ?? null,
|
|
254
|
+
agent_id: stringField(payload, "agent_id", false) ?? null,
|
|
255
|
+
provider: stringField(payload, "provider", false) ?? null,
|
|
256
|
+
model: stringField(payload, "model", false) ?? null,
|
|
257
|
+
reasoning: stringField(payload, "reasoning", false) ?? null,
|
|
258
|
+
mode: stringField(payload, "mode", false) ?? null,
|
|
259
|
+
agent_type: stringField(payload, "agent_type", false) ?? null,
|
|
251
260
|
project_root: projectRoot,
|
|
252
261
|
flow_kind: flowKind,
|
|
253
262
|
run_id: stringField(payload, "run_id", false) ?? null,
|
|
@@ -272,17 +281,28 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
|
272
281
|
const now = context.now();
|
|
273
282
|
const sessionId = forcedSessionId ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
|
|
274
283
|
const workspacePath = payload.workspace_path ?? payload.cwd ?? project.root;
|
|
275
|
-
context.db.run(`INSERT INTO
|
|
276
|
-
(session_id, project_id, project_root, flow_kind, status, run_id, parent_session_id, role, aspect_id, plan_item_id, session_kind, protocol_id, worker_id, workspace_path,
|
|
284
|
+
context.db.run(`INSERT INTO sessions
|
|
285
|
+
(session_id, project_id, harness, provider_session_id, agent_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, parent_session_id, role, aspect_id, plan_item_id, session_kind, protocol_id, worker_id, workspace_path,
|
|
277
286
|
continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason,
|
|
278
287
|
transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at)
|
|
279
288
|
VALUES (
|
|
280
|
-
?, ?, ?, ?, 'active',
|
|
289
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active',
|
|
281
290
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, NULL,
|
|
282
291
|
?, ?, ?, ?, ?, ?, NULL
|
|
283
292
|
)
|
|
284
293
|
ON CONFLICT(session_id, project_id) DO UPDATE SET
|
|
285
294
|
project_root = excluded.project_root,
|
|
295
|
+
harness = CASE
|
|
296
|
+
WHEN sessions.harness <> 'codex-desktop' AND excluded.harness = 'codex-desktop' THEN sessions.harness
|
|
297
|
+
ELSE excluded.harness
|
|
298
|
+
END,
|
|
299
|
+
provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id),
|
|
300
|
+
agent_id = COALESCE(excluded.agent_id, agent_id),
|
|
301
|
+
provider = COALESCE(excluded.provider, provider),
|
|
302
|
+
model = COALESCE(excluded.model, model),
|
|
303
|
+
reasoning = COALESCE(excluded.reasoning, reasoning),
|
|
304
|
+
mode = COALESCE(excluded.mode, mode),
|
|
305
|
+
agent_type = COALESCE(excluded.agent_type, agent_type),
|
|
286
306
|
flow_kind = excluded.flow_kind,
|
|
287
307
|
status = 'active',
|
|
288
308
|
run_id = excluded.run_id,
|
|
@@ -306,6 +326,14 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
|
306
326
|
stopped_at = NULL`, [
|
|
307
327
|
sessionId,
|
|
308
328
|
project.id,
|
|
329
|
+
payload.harness ?? "codex-desktop",
|
|
330
|
+
payload.provider_session_id ?? null,
|
|
331
|
+
payload.agent_id ?? null,
|
|
332
|
+
payload.provider ?? null,
|
|
333
|
+
payload.model ?? null,
|
|
334
|
+
payload.reasoning ?? null,
|
|
335
|
+
payload.mode ?? null,
|
|
336
|
+
payload.agent_type ?? null,
|
|
309
337
|
project.root,
|
|
310
338
|
payload.flow_kind,
|
|
311
339
|
payload.run_id ?? null,
|
|
@@ -338,7 +366,7 @@ function requireFlowSession(context, projectId, sessionId) {
|
|
|
338
366
|
}
|
|
339
367
|
function markSessionStopped(context, project, session, reason) {
|
|
340
368
|
const now = context.now();
|
|
341
|
-
context.db.run(`UPDATE
|
|
369
|
+
context.db.run(`UPDATE sessions
|
|
342
370
|
SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
343
371
|
WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
|
|
344
372
|
if (session.run_id && session.session_kind !== "orchestrator") {
|
|
@@ -399,6 +427,17 @@ function optionFromCommand(command, key) {
|
|
|
399
427
|
const match = command.match(pattern);
|
|
400
428
|
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
401
429
|
}
|
|
430
|
+
function shellArgument(raw) {
|
|
431
|
+
if (raw.startsWith('"')) {
|
|
432
|
+
try {
|
|
433
|
+
return JSON.parse(raw);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return raw.startsWith("'") ? raw.slice(1, -1) : raw;
|
|
440
|
+
}
|
|
402
441
|
function sanitizeValue(value) {
|
|
403
442
|
if (Array.isArray(value)) {
|
|
404
443
|
return value.map(sanitizeValue);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AppError } from "../shared/errors.js";
|
|
2
|
+
import { resolveProjectRoot } from "../storage/paths.js";
|
|
3
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
4
|
+
import { blockFlowRunStage, unblockFlowRunStage } from "./runs.js";
|
|
5
|
+
import { flowCommand } from "./stage-pause.js";
|
|
6
|
+
import { refreshRunWorkProjection } from "./work-registry.js";
|
|
7
|
+
export function blockStageForRuntime(context, input) {
|
|
8
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
9
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
10
|
+
const run = requireRun(context, project.id, input.runId);
|
|
11
|
+
const work = requireWork(context, project.id, run.id, input.workId);
|
|
12
|
+
if (work.status !== "running")
|
|
13
|
+
throw new AppError("invalid_work_state", "Only a running Work can be blocked", 1, { work_id: work.work_id, status: work.status });
|
|
14
|
+
if (!input.code.trim() || !input.summary.trim())
|
|
15
|
+
throw new AppError("validation", "stage block requires a code and summary", 2);
|
|
16
|
+
const state = blockFlowRunStage(context, { ...input, projectRoot });
|
|
17
|
+
const now = context.now();
|
|
18
|
+
context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
|
|
19
|
+
refreshRunWorkProjection(context, project.id, work.run_id);
|
|
20
|
+
const resumeCommand = `${flowCommand(context)} stage unblock ${work.run_id} --stage ${input.stage} --work ${work.work_id} --project-root ${JSON.stringify(projectRoot)} --json`;
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
outcome: "blocked",
|
|
24
|
+
run_id: work.run_id,
|
|
25
|
+
stage: input.stage,
|
|
26
|
+
work_id: work.work_id,
|
|
27
|
+
blocker: { kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable },
|
|
28
|
+
next_action: "repair_external_blocker_then_unblock_same_stage",
|
|
29
|
+
unblock_command: resumeCommand,
|
|
30
|
+
state
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function unblockStageAfterRuntimeRepair(context, input) {
|
|
34
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
35
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
36
|
+
const run = requireRun(context, project.id, input.runId);
|
|
37
|
+
const work = requireWork(context, project.id, run.id, input.workId);
|
|
38
|
+
if (work.status !== "paused")
|
|
39
|
+
throw new AppError("invalid_work_state", "stage unblock requires its paused Work", 1, { work_id: work.work_id, status: work.status });
|
|
40
|
+
const state = unblockFlowRunStage(context, { projectRoot, runId: work.run_id, stage: input.stage, workId: work.work_id });
|
|
41
|
+
const now = context.now();
|
|
42
|
+
context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
|
|
43
|
+
refreshRunWorkProjection(context, project.id, work.run_id);
|
|
44
|
+
return { ok: true, outcome: "resumed", run_id: work.run_id, stage: input.stage, work_id: work.work_id, next_action: `continue_${input.stage}`, state };
|
|
45
|
+
}
|
|
46
|
+
function requireWork(context, projectId, runId, workId) {
|
|
47
|
+
const work = context.db.get("SELECT work_id, project_id, run_id, status FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [projectId, runId, workId]);
|
|
48
|
+
if (!work)
|
|
49
|
+
throw new AppError("not_found", "Work does not belong to this RUN", 1, { run_id: runId, work_id: workId });
|
|
50
|
+
return work;
|
|
51
|
+
}
|
|
52
|
+
function requireRun(context, projectId, runId) {
|
|
53
|
+
const rows = context.db.all("SELECT id, short_id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
|
|
54
|
+
if (rows.length !== 1)
|
|
55
|
+
throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: runId });
|
|
56
|
+
return rows[0];
|
|
57
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { parseJsonObject } from "../shared/json.js";
|
|
6
|
+
export function loadExternalStageContext(input) {
|
|
7
|
+
const sourcePath = path.resolve(input.file);
|
|
8
|
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile())
|
|
9
|
+
throw new AppError("not_found", "Stage context file is missing", 2, { context_file: sourcePath });
|
|
10
|
+
const bytes = fs.readFileSync(sourcePath);
|
|
11
|
+
const materializedSha256 = sha256(bytes);
|
|
12
|
+
if (materializedSha256 !== input.expectedSha256)
|
|
13
|
+
throw new AppError("context_checksum_mismatch", "Stage context checksum does not match --context-sha256", 2, { context_file: sourcePath, expected_sha256: input.expectedSha256, actual_sha256: materializedSha256 });
|
|
14
|
+
const value = parseJsonObject(bytes.toString("utf8"), "stage context");
|
|
15
|
+
validateContext(value, input.stage);
|
|
16
|
+
return { value, sourcePath, materializedSha256 };
|
|
17
|
+
}
|
|
18
|
+
/** Installs exactly one immutable external context for a stage attempt. */
|
|
19
|
+
export function installExternalStageContext(input) {
|
|
20
|
+
fs.mkdirSync(input.stageRoot, { recursive: true });
|
|
21
|
+
const destination = path.join(input.stageRoot, "stage-context.json");
|
|
22
|
+
if (fs.existsSync(destination)) {
|
|
23
|
+
const existing = sha256(fs.readFileSync(destination));
|
|
24
|
+
if (existing !== input.loaded.materializedSha256)
|
|
25
|
+
throw new AppError("stage_context_replacement", "A different stage context is already installed for this Stage", 2, { stage_context: destination, expected_sha256: input.loaded.materializedSha256, actual_sha256: existing });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// The receipt is the hash of the runner-materialized bytes, not of a
|
|
29
|
+
// convenient reserialization. Preserve those exact bytes as evidence.
|
|
30
|
+
const temporary = `${destination}.${process.pid}.tmp`;
|
|
31
|
+
fs.copyFileSync(input.loaded.sourcePath, temporary);
|
|
32
|
+
fs.renameSync(temporary, destination);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
path: destination,
|
|
36
|
+
materialized_sha256: input.loaded.materializedSha256,
|
|
37
|
+
semantic_package_sha256: input.loaded.value.semantic_package_sha256 ?? null,
|
|
38
|
+
context_slice_sha256: input.loaded.value.context_slice_sha256 ?? null
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Install the runner slice and make it visible in the agent packet before a
|
|
42
|
+
* caller performs its lifecycle transition. */
|
|
43
|
+
export function applyExternalStageContext(input) {
|
|
44
|
+
if (!input.loaded)
|
|
45
|
+
return undefined;
|
|
46
|
+
const installed = installExternalStageContext({ stageRoot: input.stageRoot, loaded: input.loaded });
|
|
47
|
+
const marker = "<external_stage_context>";
|
|
48
|
+
const current = fs.existsSync(input.promptPath) ? fs.readFileSync(input.promptPath, "utf8") : "";
|
|
49
|
+
if (!current.includes(marker)) {
|
|
50
|
+
fs.writeFileSync(input.promptPath, `${renderExternalStageContext({ loaded: input.loaded, installed })}\n\n${current}`);
|
|
51
|
+
}
|
|
52
|
+
return installed;
|
|
53
|
+
}
|
|
54
|
+
export function renderExternalStageContext(input) {
|
|
55
|
+
const value = input.loaded.value;
|
|
56
|
+
const sources = value.sources ?? [];
|
|
57
|
+
const taskInput = value.task_input ?? [];
|
|
58
|
+
const decisions = value.accepted_decisions ?? [];
|
|
59
|
+
return [
|
|
60
|
+
"<external_stage_context>",
|
|
61
|
+
`- materialized context: ${String(input.installed.path)}`,
|
|
62
|
+
`- materialized SHA-256: ${input.loaded.materializedSha256}`,
|
|
63
|
+
`- semantic package SHA-256: ${value.semantic_package_sha256 ?? "not supplied"}`,
|
|
64
|
+
`- context slice SHA-256: ${value.context_slice_sha256 ?? "not supplied"}`,
|
|
65
|
+
"</external_stage_context>", "",
|
|
66
|
+
"<objective>", value.objective, "</objective>", "",
|
|
67
|
+
"<required_inputs>",
|
|
68
|
+
...(taskInput.length ? taskInput.map((item) => `- ${item.role}: ${item.path}`) : ["- No separate task-input file is declared."]),
|
|
69
|
+
"</required_inputs>", "",
|
|
70
|
+
"<project_context>",
|
|
71
|
+
...(sources.length ? sources.map((item) => `- ${item.role}: ${item.path}${item.required === false ? " (optional)" : ""} — ${item.reason}`) : ["- No extra project sources are declared."]),
|
|
72
|
+
"</project_context>", "",
|
|
73
|
+
"<accepted_decisions>",
|
|
74
|
+
...(decisions.length ? decisions.map((item) => `- ${typeof item === "string" ? item : `${item.id ? `${item.id}: ` : ""}${item.statement ?? ""}`}`) : ["- No additional accepted decisions."]),
|
|
75
|
+
"</accepted_decisions>"
|
|
76
|
+
].join("\n");
|
|
77
|
+
}
|
|
78
|
+
function validateContext(value, expectedStage) {
|
|
79
|
+
if (value.schema_id !== "dd-eval/stage-context@1")
|
|
80
|
+
throw new AppError("validation", "Stage context must use dd-eval/stage-context@1", 2);
|
|
81
|
+
if (value.stage !== expectedStage)
|
|
82
|
+
throw new AppError("validation", "Stage context does not match --stage", 2, { expected_stage: expectedStage, context_stage: value.stage });
|
|
83
|
+
if (!value.objective?.trim())
|
|
84
|
+
throw new AppError("validation", "Stage context objective is required", 2);
|
|
85
|
+
for (const source of value.sources ?? []) {
|
|
86
|
+
if (!source.role?.trim() || !source.path?.trim() || !source.reason?.trim())
|
|
87
|
+
throw new AppError("validation", "Every stage context source needs role, path and reason", 2);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function sha256(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
|