@deksden-com/dd-flow-cli 0.9.0-beta.100 → 0.9.0-beta.103

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 (46) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/hook-ingress.js +5 -10
  3. package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
  4. package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
  5. package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
  6. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  7. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  8. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  9. package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
  10. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  11. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
  12. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
  13. package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
  14. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  15. package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
  16. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
  17. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
  19. package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
  20. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
  21. package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
  22. package/dist/harness-runtime/lib/managed-daemon.mjs +32 -3
  23. package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
  24. package/dist/harness-runtime/lib/process-json.mjs +3 -2
  25. package/dist/services/code-checks.js +53 -16
  26. package/dist/services/controller-fanout.js +37 -6
  27. package/dist/services/eval-snapshots.js +148 -38
  28. package/dist/services/external-work-launch.js +60 -5
  29. package/dist/services/prompts.js +2 -0
  30. package/dist/services/review-copy.js +136 -0
  31. package/dist/services/run-control-worker.js +16 -11
  32. package/dist/services/run-controller-adapter.js +9 -0
  33. package/dist/services/runs.js +3 -0
  34. package/dist/services/runtime-scope-stop.js +66 -6
  35. package/dist/services/vnext-code-review.js +6 -2
  36. package/dist/services/vnext-code.js +1 -0
  37. package/dist/services/vnext-fanout.js +9 -1
  38. package/dist/services/vnext-merge.js +8 -4
  39. package/dist/services/vnext-plan-review.js +4 -15
  40. package/dist/services/vnext-plan.js +4 -0
  41. package/dist/services/vnext-protocolize.js +4 -2
  42. package/dist/services/vnext-specify.js +7 -0
  43. package/dist/services/work-registry.js +208 -22
  44. package/dist/services/workspace-files.js +83 -0
  45. package/dist/services/worktrees.js +3 -1
  46. package/package.json +1 -1
@@ -15,6 +15,7 @@ import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recover
15
15
  import { resourceHome } from "./managed-processes.js";
16
16
  import { codexHomeSharedEntries } from "./hooks.js";
17
17
  import { prepareOutputFile } from "../cli/input-preparation.js";
18
+ import { localEnvironmentAllowed, projectServicePathFilter } from "./workspace-files.js";
18
19
  /** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
19
20
  export function prepareEvalBootstrapSnapshot(context, input) {
20
21
  const projectRoot = resolveProjectRoot(input.projectRoot);
@@ -38,17 +39,42 @@ export function prepareEvalBootstrapSnapshot(context, input) {
38
39
  }
39
40
  export function createEvalBootstrapSnapshot(context, input) {
40
41
  const { projectRoot, output } = prepareEvalBootstrapSnapshot(context, input);
41
- fs.mkdirSync(output, { recursive: true });
42
- fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
43
- const manifest = {
44
- schema_id: "dd-eval/bootstrap-snapshot@1",
45
- stage: "specify",
46
- project_root: projectRoot,
47
- project_sha256: treeHash(path.join(output, "project")),
48
- project_git: snapshotProjectGit(projectRoot, output)
49
- };
50
- writeJson(path.join(output, "bootstrap.json"), manifest);
51
- return { ok: true, snapshot: output, ...manifest };
42
+ const temporary = `${output}.partial-${crypto.randomUUID()}`;
43
+ const before = bootstrapSourceVersion(projectRoot);
44
+ try {
45
+ fs.mkdirSync(temporary, { recursive: true });
46
+ fs.cpSync(projectRoot, path.join(temporary, "project"), { recursive: true, verbatimSymlinks: true, filter: projectPayloadFilter(projectRoot) });
47
+ const after = bootstrapSourceVersion(projectRoot);
48
+ if (before !== after)
49
+ throw new AppError("snapshot_source_changed", "Bootstrap source changed during snapshot capture", 1, { phase: "bootstrap", recoverable: true });
50
+ const manifest = {
51
+ schema_id: "dd-eval/bootstrap-snapshot@1",
52
+ stage: "specify",
53
+ project_root: projectRoot,
54
+ project_sha256: treeHash(path.join(temporary, "project")),
55
+ project_git: snapshotProjectGit(projectRoot, temporary)
56
+ };
57
+ writeJson(path.join(temporary, "bootstrap.json"), manifest);
58
+ if (after !== bootstrapSourceVersion(projectRoot))
59
+ throw new AppError("snapshot_source_changed", "Bootstrap source changed while preserving Git evidence", 1, { phase: "bootstrap", recoverable: true });
60
+ syncSnapshotTree(temporary);
61
+ fs.renameSync(temporary, output);
62
+ syncPath(path.dirname(output));
63
+ return { ok: true, snapshot: output, ...manifest };
64
+ }
65
+ catch (error) {
66
+ fs.rmSync(temporary, { recursive: true, force: true });
67
+ throw error;
68
+ }
69
+ }
70
+ function bootstrapSourceVersion(projectRoot) {
71
+ return JSON.stringify([
72
+ treeHash(projectRoot, true),
73
+ treeModesHash(projectRoot, true),
74
+ git(projectRoot, ["symbolic-ref", "--quiet", "HEAD"]),
75
+ gitBytes(projectRoot, ["show-ref", "--head"]).toString("base64"),
76
+ indexEntries(projectRoot).toString("base64")
77
+ ]);
52
78
  }
53
79
  /** Restore a pre-RUN project state; `stage start --bootstrap` creates runtime state afterwards. */
54
80
  export function prepareEvalBootstrapRestore(input) {
@@ -185,7 +211,7 @@ function captureEvalRunSnapshot(context, input) {
185
211
  }
186
212
  }
187
213
  else
188
- copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions]);
214
+ copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions], grokProviderHomes(context.ddFlowHome));
189
215
  // VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
190
216
  // pages. Copying the live database and its WAL separately is not atomic.
191
217
  const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
@@ -217,7 +243,7 @@ function captureEvalRunSnapshot(context, input) {
217
243
  fs.cpSync(projectRoot, path.join(output, "project"), {
218
244
  recursive: true,
219
245
  verbatimSymlinks: true,
220
- filter: snapshotPayloadAllowed
246
+ filter: projectPayloadFilter(projectRoot)
221
247
  });
222
248
  const projectGit = snapshotProjectGit(projectRoot, output);
223
249
  const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
@@ -244,6 +270,8 @@ function captureEvalRunSnapshot(context, input) {
244
270
  created_at: context.now(),
245
271
  runtime_sha256: treeHash(path.join(output, "runtime")),
246
272
  project_sha256: treeHash(path.join(output, "project")),
273
+ project_payload_policy: "service-files@1",
274
+ ...(!recovery ? { runtime_payload_policy: "flow-evidence@1" } : {}),
247
275
  project_git: projectGit,
248
276
  workspace,
249
277
  payload_modes: { project: treeModesHash(path.join(output, "project")), runtime: treeModesHash(path.join(output, "runtime")), ...(workspace.kind === "git_worktree" ? { workspace: treeModesHash(path.join(output, "workspace")) } : {}) },
@@ -608,7 +636,7 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
608
636
  const { branch, baseRef } = prepared;
609
637
  const workspace = path.join(output, "workspace");
610
638
  const bundle = path.join(output, "workspace.bundle");
611
- fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
639
+ fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: projectPayloadFilter(workspaceRoot) });
612
640
  runGit(projectRoot, ["bundle", "create", bundle, branch]);
613
641
  return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle), ...snapshotIndex(workspaceRoot, output, "workspace") };
614
642
  }
@@ -675,7 +703,7 @@ function restoreProjectGit(snapshot, source, projectRoot) {
675
703
  const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
676
704
  function indexEntries(root) {
677
705
  const entries = Buffer.from(gitBytes(root, ["ls-files", "--stage", "-z"])
678
- .toString("utf8").split("\0").filter(entry => entry && snapshotEnvironmentAllowed(entry.slice(entry.indexOf("\t") + 1)))
706
+ .toString("utf8").split("\0").filter(entry => entry && localEnvironmentAllowed(entry.slice(entry.indexOf("\t") + 1)))
679
707
  .map(entry => `${entry}\0`).join(""));
680
708
  const merge = {};
681
709
  for (const name of mergeStateFiles) {
@@ -764,10 +792,11 @@ export function verifyRecoverySnapshot(snapshot, input, checkWorkingState = true
764
792
  ];
765
793
  verifySnapshotPayload(snapshot, manifest);
766
794
  for (const entry of entries) {
795
+ const allowed = manifest.project_payload_policy === "service-files@1" ? projectPayloadFilter(entry.root) : snapshotPayloadAllowed;
767
796
  const expectedModes = manifest.payload_modes?.[entry.name];
768
- if (checkWorkingState && expectedModes && treeModesHash(entry.root, true) !== expectedModes)
797
+ if (checkWorkingState && expectedModes && treeModesHash(entry.root, allowed) !== expectedModes)
769
798
  throw new AppError("recovery_workspace_drift", "Executable file modes changed after recovery capture", 1, { payload: entry.name });
770
- if (checkWorkingState && (treeHash(entry.root, true) !== entry.hash || git(entry.root, ["rev-parse", "HEAD"]) !== entry.head || git(entry.root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== entry.git.branch || (entry.git.index_sha256 && crypto.createHash("sha256").update(entry.git.index_format === "entries@1" ? indexEntries(entry.root) : indexPatch(entry.root)).digest("hex") !== entry.git.index_sha256)))
799
+ if (checkWorkingState && (treeHash(entry.root, allowed) !== entry.hash || git(entry.root, ["rev-parse", "HEAD"]) !== entry.head || git(entry.root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== entry.git.branch || (entry.git.index_sha256 && crypto.createHash("sha256").update(entry.git.index_format === "entries@1" ? indexEntries(entry.root) : indexPatch(entry.root)).digest("hex") !== entry.git.index_sha256)))
771
800
  throw new AppError("recovery_workspace_drift", "Working files or Git state changed after recovery capture", 1, { payload: entry.name });
772
801
  }
773
802
  }
@@ -805,18 +834,48 @@ function runGit(root, args) {
805
834
  throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
806
835
  }
807
836
  function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
808
- function snapshotEnvironmentAllowed(source) {
809
- const name = path.basename(source);
810
- // Public configuration template, not a local environment/credential file.
811
- return name === ".env.example" || (name !== ".env" && !name.startsWith(".env."));
812
- }
813
837
  function snapshotPayloadAllowed(source) {
814
838
  const name = path.basename(source);
815
839
  return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
816
- && snapshotEnvironmentAllowed(source);
840
+ && localEnvironmentAllowed(source);
841
+ }
842
+ function projectPayloadFilter(root) {
843
+ const serviceAllowed = projectServicePathFilter(root);
844
+ return file => snapshotPayloadAllowed(file) && serviceAllowed(file);
817
845
  }
818
846
  function runtimePayloadAllowed(source) {
819
- return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
847
+ return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "auth.encrypted", "auth.v2.key", "auth.v2.file", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
848
+ }
849
+ /** Only a Grok daemon's verified provider root is excluded from flow evidence.
850
+ * An unrelated directory named grok-home remains ordinary project data. */
851
+ function grokProviderHomes(runtimeRoot) {
852
+ const homes = [];
853
+ const visit = (directory) => {
854
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
855
+ const file = path.join(directory, entry.name);
856
+ if (entry.isDirectory()) {
857
+ if (![".git", "node_modules", "grok-home"].includes(entry.name))
858
+ visit(file);
859
+ }
860
+ else if (entry.isFile() && entry.name === "daemon.json") {
861
+ let state;
862
+ try {
863
+ state = JSON.parse(fs.readFileSync(file, "utf8"));
864
+ }
865
+ catch {
866
+ continue;
867
+ }
868
+ if (state.schema_id !== "dd-grok/daemon-state@1")
869
+ continue;
870
+ const expected = path.join(directory, "grok-home");
871
+ if (state.config?.grokHome !== expected || state.config?.ddFlowHome !== runtimeRoot)
872
+ throw new AppError("snapshot_native_scope_invalid", "Grok provider home differs from its retained daemon binding", 1, { state_dir: directory });
873
+ homes.push(expected);
874
+ }
875
+ }
876
+ };
877
+ visit(runtimeRoot);
878
+ return homes;
820
879
  }
821
880
  function sqliteFile(file) {
822
881
  if (!fs.existsSync(file) || !fs.statSync(file).isFile())
@@ -830,9 +889,55 @@ function sqliteFile(file) {
830
889
  fs.closeSync(descriptor);
831
890
  }
832
891
  }
892
+ function sqliteIdentifier(value) { return `"${value.replaceAll('"', '""')}"`; }
893
+ function sqliteValue(value) {
894
+ if (value === null || typeof value === "string" || typeof value === "boolean")
895
+ return value;
896
+ if (typeof value === "number")
897
+ return Number.isFinite(value) ? value : String(value);
898
+ if (typeof value === "bigint")
899
+ return `${value}n`;
900
+ if (Buffer.isBuffer(value))
901
+ return `blob:${value.toString("base64")}`;
902
+ if (value instanceof Uint8Array)
903
+ return `blob:${Buffer.from(value).toString("base64")}`;
904
+ return String(value);
905
+ }
906
+ /**
907
+ * SQLite page layout is not semantic identity: VACUUM, checkpoint and a
908
+ * delete/insert rewrite can change serialized bytes without changing the
909
+ * logical database. Snapshot only the schema and ordered logical row values.
910
+ * This intentionally applies to the small set of managed SQLite files, not
911
+ * to arbitrary provider databases.
912
+ */
913
+ function sqliteSemanticFingerprint(db, resourceRegistry = false) {
914
+ db.exec("PRAGMA busy_timeout = 4000");
915
+ db.exec("BEGIN");
916
+ try {
917
+ const schema = db.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name, tbl_name, sql").all();
918
+ const tables = schema.filter(row => row.type === "table").map(row => {
919
+ const columns = db.prepare(`PRAGMA table_info(${sqliteIdentifier(row.name)})`).all()
920
+ .sort((left, right) => Number(left.cid ?? 0) - Number(right.cid ?? 0))
921
+ .map(column => ({ name: String(column.name ?? ""), type: String(column.type ?? ""), notnull: Number(column.notnull ?? 0), default: sqliteValue(column.dflt_value ?? null), pk: Number(column.pk ?? 0) }));
922
+ const names = columns.map(column => column.name);
923
+ const stableNames = resourceRegistry && row.name === "managed_processes" ? names.filter(name => name !== "lease_expires_at" && name !== "updated_at") : names;
924
+ const rows = db.prepare(`SELECT * FROM ${sqliteIdentifier(row.name)}`).all()
925
+ .map(record => stableNames.map(name => [name, sqliteValue(record[name])]))
926
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
927
+ return { name: row.name, columns, rows };
928
+ });
929
+ return crypto.createHash("sha256").update(JSON.stringify({ schema, tables })).digest("hex");
930
+ }
931
+ finally {
932
+ try {
933
+ db.exec("ROLLBACK");
934
+ }
935
+ catch { /* The read transaction may already have closed on provider error. */ }
936
+ }
937
+ }
833
938
  /** A WAL is copied through its SQLite read snapshot, not as raw bytes. */
834
- function runtimeSnapshotKind(file, excluded) {
835
- if (!runtimePayloadAllowed(file) || excluded.includes(file))
939
+ function runtimeSnapshotKind(file, excluded, providerHomes = []) {
940
+ if (!runtimePayloadAllowed(file) || providerHomes.some(home => file === home || isInside(home, file)) || excluded.includes(file))
836
941
  return "excluded";
837
942
  if (fs.lstatSync(file).isFile()) {
838
943
  if (/-(?:wal|shm|journal)$/.test(file) && sqliteFile(file.replace(/-(?:wal|shm|journal)$/, "")))
@@ -854,10 +959,11 @@ function snapshotSourceVersion(context, input, retainedDatabases) {
854
959
  throw new AppError("not_found", "Snapshot RUN is not registered", 1);
855
960
  const entries = new Map();
856
961
  const excluded = codexHomeSnapshotExclusions(context, project.id, input.runId);
962
+ const providerHomes = input.recoveryId === undefined ? grokProviderHomes(context.ddFlowHome) : [];
857
963
  const visit = (directory) => {
858
964
  for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
859
965
  const file = path.join(directory, entry.name);
860
- const kind = runtimeSnapshotKind(file, excluded);
966
+ const kind = runtimeSnapshotKind(file, excluded, providerHomes);
861
967
  if (kind === "excluded" || kind === "sidecar")
862
968
  continue;
863
969
  const key = `runtime/${path.relative(context.ddFlowHome, file)}`;
@@ -869,18 +975,18 @@ function snapshotSourceVersion(context, input, retainedDatabases) {
869
975
  else if (stat.isSymbolicLink())
870
976
  entries.set(key, `link:${fs.readlinkSync(file)}`);
871
977
  else if (kind === "sqlite") {
872
- // Resource heartbeats are copied consistently but are not productive
873
- // RUN writers; owner identity is fenced by boundary.verify().
874
- if (file === path.join(context.ddFlowHome, "runtime.sqlite")) {
875
- entries.set(key, `resource-sqlite:${stat.dev}:${stat.ino}:${stat.mode & 0o111}`);
876
- continue;
877
- }
878
978
  let db = databases.get(file);
879
979
  if (!db) {
880
980
  db = new DatabaseSync(file, { readOnly: true });
881
981
  databases.set(file, db);
882
982
  }
883
- entries.set(key, `sqlite:${crypto.createHash("sha256").update(db.serialize()).digest("hex")}:${stat.dev}:${stat.ino}:${stat.mode & 0o111}`);
983
+ // Resource heartbeats are copied consistently but are not productive
984
+ // RUN writers; owner identity is fenced by boundary.verify().
985
+ if (file === path.join(resourceHome(context), "runtime.sqlite")) {
986
+ entries.set(key, `resource-sqlite:${sqliteSemanticFingerprint(db, true)}:${stat.mode & 0o111}`);
987
+ continue;
988
+ }
989
+ entries.set(key, `sqlite:${sqliteSemanticFingerprint(db)}:${stat.mode & 0o111}`);
884
990
  }
885
991
  else if (stat.isFile())
886
992
  entries.set(key, `file:${crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex")}:${stat.mode & 0o111}`);
@@ -944,7 +1050,7 @@ function codexHomeSnapshotExclusions(context, projectId, runId) {
944
1050
  return excluded;
945
1051
  }
946
1052
  /** Copy a dedicated home or scoped recovery tree, preserving SQLite WAL commits. */
947
- function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
1053
+ function copyRuntimeTree(source, destination, scoped = true, excluded = [], providerHomes = []) {
948
1054
  if (fs.lstatSync(source).isSymbolicLink())
949
1055
  throw new AppError("snapshot_runtime_symlink_outside_scope", "Recovery artifact roots must not be symbolic links", 1, { path: source });
950
1056
  const root = physicalPath(source);
@@ -955,7 +1061,7 @@ function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
955
1061
  fs.cpSync(source, destination, {
956
1062
  recursive: true, verbatimSymlinks: true,
957
1063
  filter: file => {
958
- const kind = runtimeSnapshotKind(file, excluded);
1064
+ const kind = runtimeSnapshotKind(file, excluded, providerHomes);
959
1065
  if (kind === "excluded" || kind === "sidecar")
960
1066
  return false;
961
1067
  const stat = fs.lstatSync(file);
@@ -1057,11 +1163,12 @@ function captureRecoveryAuxiliary(context, captured, input) {
1057
1163
  }
1058
1164
  function treeHash(root, filtered = false) {
1059
1165
  const hash = crypto.createHash("sha256");
1166
+ const allowed = typeof filtered === "function" ? filtered : filtered ? projectPayloadFilter(root) : () => true;
1060
1167
  const visit = (dir, relative) => {
1061
1168
  for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
1062
1169
  const file = path.join(dir, entry.name);
1063
1170
  const rel = path.join(relative, entry.name);
1064
- if (typeof filtered === "function" ? !filtered(file) : filtered && !snapshotPayloadAllowed(file))
1171
+ if (!allowed(file))
1065
1172
  continue;
1066
1173
  if (entry.isDirectory())
1067
1174
  visit(file, rel);
@@ -1080,10 +1187,11 @@ function treeHash(root, filtered = false) {
1080
1187
  }
1081
1188
  function treeModesHash(root, filtered = false) {
1082
1189
  const entries = [];
1190
+ const allowed = typeof filtered === "function" ? filtered : filtered ? projectPayloadFilter(root) : () => true;
1083
1191
  const visit = (directory) => {
1084
1192
  for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
1085
1193
  const file = path.join(directory, entry.name);
1086
- if (typeof filtered === "function" ? !filtered(file) : filtered && !snapshotPayloadAllowed(file))
1194
+ if (!allowed(file))
1087
1195
  continue;
1088
1196
  if (entry.isDirectory())
1089
1197
  visit(file);
@@ -1131,6 +1239,8 @@ function readManifest(snapshot) {
1131
1239
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
1132
1240
  if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete", "recovery"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
1133
1241
  throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
1242
+ if (value.runtime_payload_policy !== undefined && (value.runtime_payload_policy !== "flow-evidence@1" || value.purpose === "recovery"))
1243
+ throw new AppError("snapshot_invalid", "Snapshot runtime payload policy is invalid for its purpose", 1, { snapshot });
1134
1244
  if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
1135
1245
  throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
1136
1246
  if (value.workspace.kind === "git_worktree" && (!value.workspace.branch || !value.workspace.base_ref || !value.workspace.bundle_sha256))
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { ensureWorkLaunchStorage } from "../storage/database.js";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  import { runtimeBudgetForRun } from "./runtime-budget.js";
@@ -8,9 +9,12 @@ import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } fro
8
9
  import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
9
10
  import { resolveExecutionPolicy, runtimeHarness, validateExecutionOverride } from "./execution-policy.js";
10
11
  import { assertRunMutationAllowed } from "./run-recovery.js";
11
- import { assertWorkLaunchReady, workStartCommand } from "./work-registry.js";
12
+ import { assertWorkLaunchReady, reviewSourceInputs, workStartCommand } from "./work-registry.js";
12
13
  import { initCodexHome } from "./hooks.js";
13
14
  import { managedInvocationContext } from "./lifecycle-invocations.js";
15
+ import { reviewExecutionCopyHash, reviewSourceFilter, reviewSourceHash, reviewSourcePaths } from "./review-copy.js";
16
+ import { workspaceGitFiles } from "./workspace-files.js";
17
+ import { qualifyAgyHooks } from "../harness-runtime/lib/dd-agy-daemon.mjs";
14
18
  /**
15
19
  * Creates an external provider root for one already-created Work. The launch
16
20
  * is not a Work claim: only the worker's trusted `work start` hook binds it.
@@ -43,13 +47,34 @@ export async function launchExternalWork(context, input) {
43
47
  const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy, profiles: snapshot.execution_profile.agent_profiles, ...(stage ? { stage } : {}), ...(override ? { work: override } : {}) });
44
48
  if (resolved.delegation.mode !== "external")
45
49
  throw new AppError("external_launch_not_selected", "This Work's frozen policy selects native delegation", 2, { work_id: work.work_id });
50
+ const agyWorker = runtimeHarness(resolved.worker) === "antigravity-cli";
51
+ if (agyWorker)
52
+ await qualifyAgyHooks(run.workspace_root);
46
53
  const operationId = `OPR-${crypto.randomUUID()}`;
47
54
  const root = path.join(run.run_root, "external-launches", operationId);
48
55
  const journal = path.join(root, "adapter.events.jsonl");
49
56
  fs.mkdirSync(root, { recursive: true });
57
+ const isolatedWorkspace = isReadOnlyWork(work.payload_json) ? path.join(root, "read-only-workspace") : null;
58
+ const reviewInputs = isolatedWorkspace ? reviewSourceInputs(context, work.work_id) : [];
59
+ const reviewBase = snapshot.workspace?.base && snapshot.workspace.base !== "unknown" ? snapshot.workspace.base : "HEAD";
60
+ let reviewCopy = null;
61
+ if (isolatedWorkspace) {
62
+ const allowed = reviewSourceFilter(run.workspace_root, reviewInputs, reviewBase);
63
+ const before = reviewSourceHash(run.workspace_root, reviewInputs, reviewBase);
64
+ fs.cpSync(run.workspace_root, isolatedWorkspace, { recursive: true, verbatimSymlinks: true, filter: allowed });
65
+ if (agyWorker)
66
+ await qualifyAgyHooks(isolatedWorkspace);
67
+ const after = reviewSourceHash(run.workspace_root, reviewInputs, reviewBase), copy = reviewExecutionCopyHash(isolatedWorkspace, run.workspace_root, false, reviewInputs, reviewBase);
68
+ if (before !== after || after !== copy)
69
+ throw new AppError("read_only_input_changed", "Read-only execution copy differs from its authoritative source", 2, { work_id: work.work_id, before, after, copy });
70
+ writeReviewGitEvidence(run.workspace_root, isolatedWorkspace, snapshot.workspace?.base, reviewInputs);
71
+ if (reviewSourceHash(run.workspace_root, reviewInputs, reviewBase) !== after)
72
+ throw new AppError("read_only_input_changed", "Authoritative review workspace changed while collecting Git evidence", 2, { work_id: work.work_id });
73
+ reviewCopy = { root: path.relative(run.run_root, isolatedWorkspace).split(path.sep).join("/"), sha256: reviewExecutionCopyHash(isolatedWorkspace, run.workspace_root, true, reviewInputs, reviewBase), source_sha256: after };
74
+ }
50
75
  const now = context.now();
51
76
  const flowExecutable = prepareHarnessFlowExecutable(context, root);
52
- const env = { ...context.env, DD_FLOW_BIN: flowExecutable, DD_FLOW_INVOCATION_SCOPE: undefined, DD_FLOW_CURRENT_INVOCATION: undefined };
77
+ const env = { ...context.env, DD_FLOW_BIN: flowExecutable, DD_FLOW_INVOCATION_SCOPE: undefined, DD_FLOW_CURRENT_INVOCATION: undefined, ...(isolatedWorkspace ? { DD_FLOW_READ_ONLY_WORKSPACE: isolatedWorkspace } : {}) };
53
78
  // An external Codex root is a fresh thread, so it does not inherit the
54
79
  // coordinator's managed PreToolUse hook. Its first `work start` must emit
55
80
  // that hook event to bind the Work to the physical session.
@@ -61,8 +86,8 @@ export async function launchExternalWork(context, input) {
61
86
  let runtimeContext = { ...context, env };
62
87
  const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${work.run_id}:work:${work.work_id}`, role: "worker", project_id: work.project_id, run_id: work.run_id, work_id: work.work_id, operation_id: operationId, state_dir: root, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, work.project_id, work.run_id) };
63
88
  const executable = resolveHarnessCommand(context.ddFlowHome, runtimeHarness(resolved.worker), "adapter");
64
- context.db.run("INSERT INTO work_launches (operation_id, work_id, profile_json, profile_hash, status, state_dir, journal_path, provider_session_id, adapter_receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'launching', ?, ?, NULL, NULL, ?, ?)", [operationId, work.work_id, JSON.stringify({ ...resolved, runtime_adapter: { executable, owner: runtimeOwner } }), resolved.profile_hash, root, journal, now, now]);
65
- const common = ["--dd-flow-bin", flowExecutable, "--state-dir", root, "--journal", journal, "--cwd", run.workspace_root, "--provider", resolved.worker.provider, "--model", resolved.worker.model, "--reasoning", resolved.worker.reasoning, "--mode", resolved.worker.mode, "--permission", resolved.worker.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
89
+ context.db.run("INSERT INTO work_launches (operation_id, work_id, profile_json, profile_hash, status, state_dir, journal_path, provider_session_id, adapter_receipt_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'launching', ?, ?, NULL, NULL, ?, ?)", [operationId, work.work_id, JSON.stringify({ ...resolved, runtime_adapter: { executable, owner: runtimeOwner }, ...(reviewCopy ? { review_copy: reviewCopy } : {}) }), resolved.profile_hash, root, journal, now, now]);
90
+ const common = ["--dd-flow-bin", flowExecutable, "--state-dir", root, "--journal", journal, "--cwd", isolatedWorkspace ?? run.workspace_root, "--provider", resolved.worker.provider, "--model", resolved.worker.model, "--reasoning", resolved.worker.reasoning, "--mode", resolved.worker.mode, "--permission", resolved.worker.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
66
91
  const evidence = (value) => fs.appendFileSync(journal, `${JSON.stringify({ type: "adapter_call", at: context.now(), ...value })}\n`);
67
92
  try {
68
93
  const call = (args, timeoutMs) => runHarnessAdapter({ executable, args, env: runtimeContext.env, operationId: `${operationId}:${args[0]}.${args[1]}`, runtimeOwner, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
@@ -78,8 +103,10 @@ export async function launchExternalWork(context, input) {
78
103
  projectRoot: run.project_root, runId: work.run_id, generation });
79
104
  assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
80
105
  assertWorkLaunchReady(context, work.work_id);
106
+ if (reviewCopy && reviewSourceHash(run.workspace_root, reviewInputs, reviewBase) !== reviewCopy.source_sha256)
107
+ throw new AppError("read_only_input_changed", "Authoritative review workspace changed after the execution copy was made", 2, { work_id: work.work_id });
81
108
  const promptFile = path.join(root, "launch.md");
82
- fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.\n`);
109
+ fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.${isolatedWorkspace ? `\n\nThe review copy has no .git. Read the captured status/diff or gitless note in ${path.join(isolatedWorkspace, ".dd-flow", "review-evidence")}; do not run Git inside the copy.\n` : "\n"}`);
83
110
  const prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60_000);
84
111
  assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
85
112
  context.db.run("UPDATE work_launches SET status = 'start_requested', provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ create: created, prompt: prompted }), context.now(), operationId]);
@@ -90,6 +117,24 @@ export async function launchExternalWork(context, input) {
90
117
  throw error;
91
118
  }
92
119
  }
120
+ export function writeReviewGitEvidence(source, copy, base, explicitInputs = []) {
121
+ const directory = path.join(copy, ".dd-flow", "review-evidence");
122
+ fs.mkdirSync(directory, { recursive: true });
123
+ if (workspaceGitFiles(source, ["--cached"]) === null) {
124
+ fs.writeFileSync(path.join(directory, "gitless.txt"), "Source workspace is not a Git repository. Review the copied files and accepted RUN evidence.\n");
125
+ return;
126
+ }
127
+ if (!base || base === "unknown")
128
+ throw new AppError("review_git_evidence_missing", "Git review source has no frozen base commit", 1, { workspace_root: source });
129
+ const paths = (reviewSourcePaths(source, explicitInputs, base) ?? []).map(file => `:(literal)${file}`);
130
+ for (const [name, args] of [["status.txt", ["status", "--short", "--untracked-files=all"]], ["diff.patch", ["diff", "--no-ext-diff", "--no-renames", "--binary", base]]]) {
131
+ const result = paths.length ? spawnSync("git", ["-C", source, ...args, "--", ...paths], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }) : { status: 0, stdout: "", stderr: "", error: undefined };
132
+ if (result.status !== 0 || result.error)
133
+ throw new AppError("review_git_evidence_failed", "Could not collect read-only Git evidence for reviewer", 1, { workspace_root: source, command: name, cause: result.error?.message ?? result.stderr });
134
+ fs.writeFileSync(path.join(directory, name), result.stdout);
135
+ }
136
+ fs.writeFileSync(path.join(directory, "base.txt"), `${base}\n`);
137
+ }
93
138
  function workOverride(payload) {
94
139
  if (!payload)
95
140
  return undefined;
@@ -108,3 +153,13 @@ function workOverride(payload) {
108
153
  validateExecutionOverride(execution);
109
154
  return execution;
110
155
  }
156
+ function isReadOnlyWork(payload) {
157
+ if (!payload)
158
+ return false;
159
+ try {
160
+ return JSON.parse(payload).read_only === true;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
@@ -300,6 +300,8 @@ function renderPrompt(input) {
300
300
  `- Acceptance contribution: ${item.semantic_spine?.acceptance_contribution ?? "not_applicable"}`,
301
301
  "",
302
302
  "## Hard Write Boundary",
303
+ "Before editing, change into the project workspace shown below (cd -- WORKSPACE) or use its absolute path when the tool cannot persist cwd.",
304
+ "Write only the project workspace and the named report directory; never write another RUN/native home or Git/worktree control data.",
303
305
  `- All project writes must stay under: \`${input.workspaceRoot}\``,
304
306
  "- Do not write outside this workspace, to another RUN, or into Git/worktree control data.",
305
307
  "- Accepted requirements and non-goals remain binding; planned write areas do not.",
@@ -0,0 +1,136 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { ignoredWorkspacePaths } from "./code-checks.js";
7
+ import { localEnvironmentAllowed, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
8
+ export function reviewEnvironmentAllowed(file) {
9
+ return localEnvironmentAllowed(file);
10
+ }
11
+ export function reviewSourcePaths(root, explicitInputs = [], base = "HEAD") {
12
+ const inventory = workspaceGitFiles(root, ["--cached", "--others", "--exclude-standard"]);
13
+ if (inventory === null)
14
+ return null;
15
+ const serviceAllowed = projectServicePathFilter(root, explicitInputs);
16
+ const hasBase = base !== "HEAD" || spawnSync("git", ["-C", root, "rev-parse", "--verify", "HEAD"], { encoding: "utf8" }).status === 0;
17
+ const changes = hasBase ? spawnSync("git", ["-C", root, "diff", "--name-status", "-z", "--find-renames", base, "--"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }) : null;
18
+ if (changes && (changes.status !== 0 || changes.error))
19
+ throw new AppError("review_git_evidence_failed", "Could not classify renamed review paths", 1, { workspace_root: root, cause: changes.error?.message ?? changes.stderr });
20
+ const fields = changes?.stdout.split("\0").filter(Boolean) ?? [], tainted = new Set();
21
+ for (let index = 0; index < fields.length;) {
22
+ const status = fields[index++];
23
+ if (/^[RC]/.test(status)) {
24
+ const before = fields[index++], after = fields[index++];
25
+ if (!reviewEnvironmentAllowed(before) || !reviewEnvironmentAllowed(after)) {
26
+ tainted.add(before);
27
+ tainted.add(after);
28
+ }
29
+ }
30
+ else
31
+ index += 1;
32
+ }
33
+ return [...new Set([...inventory, ...explicitInputs])]
34
+ .filter(relative => reviewEnvironmentAllowed(relative) && !tainted.has(relative) && serviceAllowed(path.join(root, relative)))
35
+ .sort();
36
+ }
37
+ export function reviewSourceFilter(root, explicitInputs = [], base = "HEAD") {
38
+ const selected = reviewSourcePaths(root, explicitInputs, base);
39
+ const serviceAllowed = projectServicePathFilter(root, explicitInputs);
40
+ const retained = new Set(selected?.map(relative => path.resolve(root, relative)) ?? []);
41
+ const branches = new Set();
42
+ for (const file of retained)
43
+ for (let directory = path.dirname(file); directory !== path.dirname(directory); directory = path.dirname(directory))
44
+ branches.add(directory);
45
+ return file => {
46
+ const parts = path.relative(root, file).split(path.sep);
47
+ if (parts.some(part => [".git", "node_modules", ".dd-flow"].includes(part)) || !reviewEnvironmentAllowed(file))
48
+ return false;
49
+ return selected === null ? serviceAllowed(file) : retained.has(path.resolve(file)) || branches.has(path.resolve(file));
50
+ };
51
+ }
52
+ export function reviewSourceHash(root, explicitInputs = [], base = "HEAD") {
53
+ const allowed = reviewSourceFilter(root, explicitInputs, base), ownedHook = ownedWorkspaceHookPath(root);
54
+ return reviewCopyHash(root, file => path.relative(root, file) !== ownedHook && allowed(file));
55
+ }
56
+ /** The copied project has no Git index. Apply the source's exact service-file
57
+ * policy to it, while retaining only our frozen Git evidence under .dd-flow. */
58
+ export function reviewExecutionCopyHash(copyRoot, sourceRoot, includeEvidence = true, explicitInputs = [], base = "HEAD") {
59
+ if (ownedWorkspaceHookPath(sourceRoot) && !ownedWorkspaceHookPath(copyRoot))
60
+ throw new AppError("read_only_input_changed", "Qualified review hook differs between execution copy and source", 2, { copy_root: copyRoot });
61
+ const sourceAllowed = reviewSourceFilter(sourceRoot, explicitInputs, base);
62
+ const serviceAllowed = projectServicePathFilter(sourceRoot, explicitInputs);
63
+ const ownedHook = ownedWorkspaceHookPath(sourceRoot);
64
+ const verifySelection = (directory) => {
65
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
66
+ const file = path.join(directory, entry.name), relative = path.relative(copyRoot, file);
67
+ const parts = relative.split(path.sep);
68
+ if (parts[0] === ".dd-flow" || parts.some(part => [".git", "node_modules"].includes(part)))
69
+ continue;
70
+ if (!reviewEnvironmentAllowed(file))
71
+ throw new AppError("read_only_input_changed", "Read-only execution copy contains a local environment file", 2, { path: relative });
72
+ if (!serviceAllowed(path.join(sourceRoot, relative)))
73
+ continue;
74
+ if (!sourceAllowed(path.join(sourceRoot, relative)) && ignoredWorkspacePaths(sourceRoot, [relative]).includes(relative.split(path.sep).join("/")))
75
+ continue;
76
+ if (!sourceAllowed(path.join(sourceRoot, relative)))
77
+ throw new AppError("read_only_input_changed", "Read-only execution copy contains an unselected product input", 2, { path: relative });
78
+ if (entry.isDirectory())
79
+ verifySelection(file);
80
+ }
81
+ };
82
+ verifySelection(copyRoot);
83
+ return reviewCopyHash(copyRoot, file => {
84
+ const relative = path.relative(copyRoot, file);
85
+ const parts = relative.split(path.sep);
86
+ if (parts[0] === ".dd-flow")
87
+ return includeEvidence && (parts.length === 1 || parts[1] === "review-evidence");
88
+ return relative !== ownedHook && sourceAllowed(path.join(sourceRoot, relative));
89
+ });
90
+ }
91
+ /** Exact bytes visible in a read-only execution copy, including symlink targets. */
92
+ export function reviewCopyHash(root, allowed = () => true) {
93
+ const hash = crypto.createHash("sha256");
94
+ let canonicalRoot;
95
+ try {
96
+ canonicalRoot = fs.realpathSync(root);
97
+ }
98
+ catch {
99
+ throw new AppError("read_only_input_changed", "Read-only execution copy is missing", 2, { root });
100
+ }
101
+ const visit = (directory) => {
102
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
103
+ const file = path.join(directory, entry.name);
104
+ if (!allowed(file))
105
+ continue;
106
+ const relative = path.relative(root, file).split(path.sep).join("/");
107
+ const stat = fs.lstatSync(file);
108
+ const record = (kind, value = "") => hash.update(`${JSON.stringify([relative, kind, stat.mode & 0o111, value])}\n`);
109
+ if (entry.isDirectory()) {
110
+ record("directory");
111
+ visit(file);
112
+ continue;
113
+ }
114
+ if (entry.isSymbolicLink()) {
115
+ const link = fs.readlinkSync(file);
116
+ let target;
117
+ try {
118
+ target = fs.realpathSync(file);
119
+ }
120
+ catch {
121
+ throw new AppError("read_only_workspace_escape", "Read-only copy contains an unresolved symlink", 1, { path: relative });
122
+ }
123
+ const inside = path.relative(canonicalRoot, target);
124
+ if (path.isAbsolute(link) || inside === ".." || inside.startsWith(`..${path.sep}`))
125
+ throw new AppError("read_only_workspace_escape", "Read-only copy contains a symlink outside its workspace", 1, { path: relative });
126
+ record("symlink", link);
127
+ }
128
+ else if (entry.isFile())
129
+ record("file", crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
130
+ else
131
+ throw new AppError("read_only_workspace_unsupported", "Read-only copy contains an unsupported file type", 1, { path: relative });
132
+ }
133
+ };
134
+ visit(root);
135
+ return hash.digest("hex");
136
+ }