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

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 (47) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/hook-ingress.js +5 -10
  3. package/dist/cli/run-cli.js +9 -3
  4. package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
  5. package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
  6. package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
  7. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  8. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  9. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  10. package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
  11. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  12. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
  13. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
  14. package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
  15. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  16. package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
  17. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
  18. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
  19. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
  20. package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
  21. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
  22. package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
  23. package/dist/harness-runtime/lib/managed-daemon.mjs +38 -4
  24. package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
  25. package/dist/harness-runtime/lib/process-json.mjs +3 -2
  26. package/dist/services/code-checks.js +53 -16
  27. package/dist/services/controller-fanout.js +37 -6
  28. package/dist/services/eval-snapshots.js +148 -38
  29. package/dist/services/external-work-launch.js +60 -5
  30. package/dist/services/prompts.js +2 -0
  31. package/dist/services/review-copy.js +136 -0
  32. package/dist/services/run-control-worker.js +16 -11
  33. package/dist/services/run-controller-adapter.js +9 -0
  34. package/dist/services/runs.js +3 -0
  35. package/dist/services/runtime-scope-stop.js +66 -6
  36. package/dist/services/vnext-code-review.js +6 -2
  37. package/dist/services/vnext-code.js +1 -0
  38. package/dist/services/vnext-fanout.js +9 -1
  39. package/dist/services/vnext-merge.js +8 -4
  40. package/dist/services/vnext-plan-review.js +4 -15
  41. package/dist/services/vnext-plan.js +4 -0
  42. package/dist/services/vnext-protocolize.js +4 -2
  43. package/dist/services/vnext-specify.js +7 -0
  44. package/dist/services/work-registry.js +208 -22
  45. package/dist/services/workspace-files.js +83 -0
  46. package/dist/services/worktrees.js +3 -1
  47. package/package.json +1 -1
@@ -22,6 +22,7 @@ import { publicSessionIdentity } from "./session-identity.js";
22
22
  import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
23
23
  import { runControlDirective } from "./run-control.js";
24
24
  import { prepareError, prepareJsonFile } from "../cli/input-preparation.js";
25
+ import { reviewEnvironmentAllowed, reviewExecutionCopyHash, reviewSourceHash, reviewSourcePaths } from "./review-copy.js";
25
26
  const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
26
27
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
27
28
  function ownsPhysicalWorkSession(context, projectId, owner, identity) {
@@ -403,6 +404,8 @@ export function assertWorkLaunchReady(context, id) {
403
404
  const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => currentStageWorkIds(context, candidate.project_id, candidate.run_id)?.has(candidate.work_id) !== false && isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
404
405
  throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping planned coordination areas are free", 2, { work_id: id, blockers, ready });
405
406
  }
407
+ if (parsePayload(work)?.read_only === true)
408
+ assertSharedReviewInput(context, work, requireRun(context, work.project_id, work.run_id));
406
409
  return work;
407
410
  }
408
411
  export function startWork(context, id, input) {
@@ -531,6 +534,9 @@ export function startBoundWork(context, work, run, claimIdentity, hookEventId, s
531
534
  const promptPath = path.join(packetDirectory, "prompt.md"), resultPath = path.join(directory, "result.json");
532
535
  // The first reviewer freezes shared inputs in the same transaction as its claim.
533
536
  const readOnly = parsePayload(work)?.read_only === true;
537
+ const workerRun = readOnly && context.env.DD_FLOW_READ_ONLY_WORKSPACE
538
+ ? { ...run, workspace_root: path.resolve(context.env.DD_FLOW_READ_ONLY_WORKSPACE) }
539
+ : run;
534
540
  let fingerprint;
535
541
  let binding;
536
542
  try {
@@ -547,14 +553,21 @@ export function startBoundWork(context, work, run, claimIdentity, hookEventId, s
547
553
  throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
548
554
  const identity = claimIdentity();
549
555
  assertRecoveryHookCurrent(context, current.project_id, current.run_id, identity);
556
+ if (readOnly && !context.env.DD_FLOW_READ_ONLY_WORKSPACE
557
+ && context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'work_launches'")
558
+ && context.db.get("SELECT 1 FROM work_launches WHERE work_id = ? AND status IN ('launching','start_requested')", [current.work_id]))
559
+ throw new AppError("read_only_workspace_unbound", "External reviewer did not inherit its qualified execution workspace", 1, { work_id: current.work_id });
560
+ const reviewInput = readOnly ? assertSharedReviewInput(context, current, run, generation) : null;
561
+ const reviewPaths = reviewInput?.run_inputs.filter(item => item.root === "workspace").map(item => item.path) ?? [];
562
+ const reviewCopy = readOnly && context.env.DD_FLOW_READ_ONLY_WORKSPACE ? boundReviewCopy(context, current, run, identity, context.env.DD_FLOW_READ_ONLY_WORKSPACE, reviewPaths) : null;
550
563
  const dependencyResults = parseDependencies(current).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
551
564
  const recovery = recoveryPromptForWork(context, current.work_id);
552
- let prompt = renderWorkerPrompt(context, current, run, dependencyResults);
565
+ let prompt = renderWorkerPrompt(context, current, workerRun, dependencyResults);
553
566
  if (recovery)
554
567
  prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
555
568
  if (readOnly)
556
- fingerprint = sharedReviewInput(work, run, true) ?? workspaceFingerprint(run.workspace_root);
557
- const workContext = { schema_id: "dd-flow/work-context@1", run_id: current.run_id, work_id: current.work_id, parent_work_id: current.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(current), launch_policy: current.launch_policy, result_schema: current.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: fingerprint } : {}) };
569
+ fingerprint = reviewInput?.source_sha256 ?? reviewInput?.workspace_fingerprint ?? legacyReviewFingerprint(current, run) ?? workspaceFingerprint(run.workspace_root);
570
+ const workContext = { schema_id: "dd-flow/work-context@1", run_id: current.run_id, work_id: current.work_id, parent_work_id: current.parent_work_id, project_root: run.project_root, workspace_root: workerRun.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(current), launch_policy: current.launch_policy, result_schema: current.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: fingerprint, ...(reviewInput ? { review_input: reviewInput } : {}), ...(reviewCopy ? { review_copy: reviewCopy } : {}) } : {}) };
558
571
  const contextBytes = `${JSON.stringify(workContext, null, 2)}\n`;
559
572
  const response = { ok: true, work_id: current.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
560
573
  const receipt = JSON.stringify({ version: 1, prompt, context: contextBytes, prompt_sha256: sha256(prompt), context_sha256: sha256(contextBytes), response, response_sha256: sha256(JSON.stringify(response)), generation, invocation_id: context.env.DD_FLOW_CURRENT_INVOCATION ?? null });
@@ -756,7 +769,82 @@ export function retryWork(context, id, reason) {
756
769
  refreshRunWorkProjection(context, work.project_id, work.run_id);
757
770
  return { ok: true, work_id: work.work_id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason };
758
771
  }
759
- function sharedReviewInput(work, run, initialize = false) {
772
+ export function reviewSourceInputs(context, workId) {
773
+ const work = requireWork(context, workId);
774
+ const input = assertSharedReviewInput(context, work, requireRun(context, work.project_id, work.run_id));
775
+ return input?.run_inputs.filter(item => item.root === "workspace").map(item => item.path) ?? [];
776
+ }
777
+ function reviewInputPath(root, relative) {
778
+ const resolved = path.resolve(root, relative);
779
+ const inside = path.relative(root, resolved);
780
+ if (!relative || path.isAbsolute(relative) || inside === ".." || inside.startsWith(`..${path.sep}`))
781
+ throw new AppError("review_input_invalid", "Review input path escapes its declared root", 1, { path: relative });
782
+ if (fs.existsSync(resolved)) {
783
+ const canonicalInside = path.relative(fs.realpathSync(root), fs.realpathSync(resolved));
784
+ if (canonicalInside === ".." || canonicalInside.startsWith(`..${path.sep}`))
785
+ throw new AppError("review_input_invalid", "Review input symlink escapes its declared root", 1, { path: relative });
786
+ }
787
+ return resolved;
788
+ }
789
+ function boundReviewCopy(context, work, run, identity, requested, reviewPaths) {
790
+ const launch = context.db.get("SELECT provider_session_id, profile_json FROM work_launches WHERE work_id = ? AND status IN ('launching','start_requested')", [work.work_id]);
791
+ const metadata = launch ? JSON.parse(launch.profile_json) : null;
792
+ const copy = metadata?.review_copy;
793
+ if (!copy?.root || !copy.sha256 || !copy.source_sha256 || launch?.provider_session_id !== identity.providerSessionId)
794
+ throw new AppError("read_only_workspace_unbound", "Read-only execution copy has no matching trusted launch", 1, { work_id: work.work_id });
795
+ const expected = reviewInputPath(requireRunHome(run), copy.root);
796
+ if (path.resolve(requested) !== expected)
797
+ throw new AppError("read_only_workspace_unbound", "Read-only execution path differs from the trusted launch", 1, { work_id: work.work_id });
798
+ const base = reviewWorkspaceBase(context, work);
799
+ if (reviewSourceHash(run.workspace_root, reviewPaths, base) !== copy.source_sha256)
800
+ throw new AppError("read_only_input_changed", "Authoritative review workspace changed after the execution copy was made", 2, { work_id: work.work_id });
801
+ const actual = reviewExecutionCopyHash(expected, run.workspace_root, true, reviewPaths, base);
802
+ if (actual !== copy.sha256)
803
+ throw new AppError("read_only_input_changed", "Read-only execution copy changed before Work start", 2, { work_id: work.work_id, expected: copy.sha256, current: actual });
804
+ return { root: copy.root, sha256: copy.sha256, source_sha256: copy.source_sha256 };
805
+ }
806
+ function assertReviewCopyUnchanged(context, work, run, link) {
807
+ if (!link)
808
+ return;
809
+ const row = context.db.get("SELECT start_receipt_json FROM work_sessions WHERE id = ?", [link.id]);
810
+ const external = context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'work_launches'")
811
+ ? context.db.get("SELECT 1 FROM work_launches WHERE work_id = ? LIMIT 1", [work.work_id]) : null;
812
+ if (!row?.start_receipt_json) {
813
+ if (external)
814
+ throw new AppError("read_only_workspace_unbound", "External review has no committed start receipt", 1, { work_id: work.work_id });
815
+ return;
816
+ }
817
+ let copy;
818
+ let reviewPaths = [];
819
+ try {
820
+ const receipt = JSON.parse(row.start_receipt_json);
821
+ const retained = receipt.context ? JSON.parse(receipt.context) : null;
822
+ copy = retained?.review_copy;
823
+ reviewPaths = retained?.review_input?.run_inputs.filter(item => item.root === "workspace").map(item => item.path) ?? [];
824
+ }
825
+ catch {
826
+ throw new AppError("read_only_workspace_unbound", "Review start receipt is malformed", 1, { work_id: work.work_id });
827
+ }
828
+ if (!copy) {
829
+ if (external)
830
+ throw new AppError("read_only_workspace_unbound", "External review start receipt has no copy binding", 1, { work_id: work.work_id });
831
+ return;
832
+ }
833
+ if (!copy.root || !copy.sha256)
834
+ throw new AppError("read_only_workspace_unbound", "Review copy receipt is incomplete", 1, { work_id: work.work_id });
835
+ const base = reviewWorkspaceBase(context, work);
836
+ const actual = reviewExecutionCopyHash(reviewInputPath(requireRunHome(run), copy.root), run.workspace_root, true, reviewPaths, base);
837
+ if (actual !== copy.sha256)
838
+ throw new AppError("read_only_input_changed", "Read-only execution copy changed during review", 2, { work_id: work.work_id, expected: copy.sha256, current: actual });
839
+ if (copy.source_sha256 && reviewSourceHash(run.workspace_root, reviewPaths, base) !== copy.source_sha256)
840
+ throw new AppError("read_only_input_changed", "Authoritative review workspace changed during review", 2, { work_id: work.work_id });
841
+ }
842
+ function reviewWorkspaceBase(context, work) {
843
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id]);
844
+ const base = row && JSON.parse(row.index_json).workspace?.base;
845
+ return base && base !== "unknown" ? base : "HEAD";
846
+ }
847
+ function legacyReviewFingerprint(work, run) {
760
848
  const kind = parsePayload(work)?.kind;
761
849
  const relative = kind === "code-review" ? "06-code-review/review-context.json" : kind === "plan-review" ? "04-plan-review/work-context.json" : null;
762
850
  if (!relative || !work.parent_work_id)
@@ -765,17 +853,106 @@ function sharedReviewInput(work, run, initialize = false) {
765
853
  if (!fs.existsSync(file))
766
854
  return null;
767
855
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
768
- if (value.read_only_input?.parent_work_id !== work.parent_work_id)
856
+ return value.read_only_input?.version !== 2 && value.read_only_input?.parent_work_id === work.parent_work_id && typeof value.read_only_input.fingerprint === "string" ? value.read_only_input.fingerprint : null;
857
+ }
858
+ function assertSharedReviewInput(context, work, run, generation = recoveryGuard(context, work.project_id, work.run_id)?.generation ?? 0) {
859
+ const payload = parsePayload(work), kind = payload?.kind;
860
+ const relative = kind === "code-review" ? "06-code-review/review-context.json" : kind === "plan-review" ? "04-plan-review/work-context.json" : null;
861
+ if (!relative || !work.parent_work_id)
769
862
  return null;
770
- if (!value.read_only_input.fingerprint && initialize) {
771
- // Called under the Work-start writer reservation, after stage setup has
772
- // finished. The first reviewer accepts the input shared by this group.
773
- value.read_only_input.fingerprint = workspaceFingerprint(run.workspace_root);
774
- const temporary = `${file}.tmp-${crypto.randomUUID()}`;
775
- fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
776
- fs.renameSync(temporary, file);
863
+ const home = requireRunHome(run), file = path.join(home, relative);
864
+ if (!fs.existsSync(file))
865
+ throw new AppError("review_input_missing", "The review stage context is missing", 1, { work_id: work.work_id, file: relative });
866
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
867
+ if (value.read_only_input?.parent_work_id !== work.parent_work_id)
868
+ throw new AppError("review_input_invalid", "Reviewer belongs to a different review group", 1, { work_id: work.work_id, parent_work_id: work.parent_work_id });
869
+ const index = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id]).index_json);
870
+ const stage = (name) => index.stage_runs?.find(item => item.stage === name);
871
+ if (value.read_only_input.version !== 2 && value.read_only_input.version !== 3) {
872
+ if (stage(kind === "plan-review" ? "plan" : "code")?.data_sha256)
873
+ throw new AppError("review_input_contract_missing", "A new review run cannot downgrade its input contract", 1, { work_id: work.work_id });
874
+ return null; // Historical receipts retain their original fingerprint contract.
777
875
  }
778
- return typeof value.read_only_input.fingerprint === "string" ? value.read_only_input.fingerprint : null;
876
+ const expected = new Map();
877
+ const add = (root, relativePath, sha) => {
878
+ if (!/^[a-f0-9]{64}$/.test(sha))
879
+ throw new AppError("review_input_invalid", "Review input lacks an accepted checksum", 1, { path: relativePath });
880
+ reviewInputPath(root === "run" ? home : run.workspace_root, relativePath);
881
+ const key = `${root}:${relativePath}`;
882
+ if (expected.has(key) && expected.get(key) !== sha)
883
+ throw new AppError("review_input_invalid", "Review input has conflicting accepted checksums", 1, { path: relativePath });
884
+ expected.set(key, sha);
885
+ };
886
+ const stageReport = (name, relativePath) => {
887
+ const sha = stage(name)?.data_sha256;
888
+ if (!sha)
889
+ throw new AppError("review_input_contract_missing", "Accepted stage report has no committed checksum", 1, { stage: name });
890
+ add("run", relativePath, sha);
891
+ const reportFile = reviewInputPath(home, relativePath);
892
+ if (!fs.existsSync(reportFile) || sha256(fs.readFileSync(reportFile, "utf8")) !== sha)
893
+ throw new AppError("read_only_input_changed", "Accepted stage report changed before or during review", 2, { path: relativePath, expected: sha });
894
+ return JSON.parse(fs.readFileSync(reportFile, "utf8"));
895
+ };
896
+ if (kind === "plan-review") {
897
+ const accepted = stageReport("plan", "03-plan/stage-report.json").semantic;
898
+ if (!accepted?.plan_checksums?.length || !accepted.aspect_map_checksums?.length || !accepted.batch_checksum)
899
+ throw new AppError("review_input_contract_missing", "Accepted PLAN lacks exact review input checksums", 1);
900
+ for (const item of accepted.plan_checksums)
901
+ add("workspace", item.path, item.sha256);
902
+ for (const item of accepted.aspect_map_checksums)
903
+ add("run", item.path, item.sha256);
904
+ add("run", "03-plan/code-work-batch.json", accepted.batch_checksum);
905
+ if (value.system?.batch_checksum !== accepted.batch_checksum)
906
+ throw new AppError("read_only_input_changed", "PLAN-REVIEW batch differs from accepted PLAN", 2);
907
+ const planSet = accepted.plan_checksums.map(item => ({ protocol_id: path.basename(path.dirname(item.path)), path: item.path, sha256: item.sha256 })).sort((a, b) => a.protocol_id.localeCompare(b.protocol_id));
908
+ if (value.system?.plan_checksum !== sha256(JSON.stringify(planSet)))
909
+ throw new AppError("read_only_input_changed", "PLAN-REVIEW plan revision differs from accepted PLAN", 2);
910
+ }
911
+ else {
912
+ stageReport("code", "05-code/stage-report.json");
913
+ if (value.code_report_sha256 !== stage("code")?.data_sha256 || !stage("code-review")?.input_sha256 || !value.review_batch_sha256 || !Array.isArray(value.aspect_map_checksums) || !value.aspect_map_checksums.length
914
+ || sha256(JSON.stringify({ batch: value.review_batch_sha256, aspect_maps: value.aspect_map_checksums })) !== stage("code-review")?.input_sha256)
915
+ throw new AppError("review_input_contract_missing", "CODE-REVIEW inputs are not bound to their accepted stage", 1);
916
+ add("run", "06-code-review/review-work-batch.json", value.review_batch_sha256);
917
+ for (const item of value.aspect_map_checksums)
918
+ add("run", item.path, item.sha256);
919
+ if (value.cycle !== payload?.review_cycle)
920
+ throw new AppError("review_input_invalid", "CODE-REVIEW cycle differs from its Work", 1);
921
+ }
922
+ const runInputs = [...expected].map(([key, sha]) => { const [root, ...parts] = key.split(":"); return { root: root, path: parts.join(":"), sha256: sha }; }).sort((a, b) => `${a.root}:${a.path}`.localeCompare(`${b.root}:${b.path}`));
923
+ for (const item of runInputs) {
924
+ const target = reviewInputPath(item.root === "run" ? home : run.workspace_root, item.path);
925
+ const actual = fs.existsSync(target) ? sha256(fs.readFileSync(target, "utf8")) : null;
926
+ if (actual !== item.sha256)
927
+ throw new AppError("read_only_input_changed", "Accepted review input changed", 2, { work_id: work.work_id, root: item.root, path: item.path, expected: item.sha256, current: actual });
928
+ }
929
+ const reviewPaths = runInputs.filter(item => item.root === "workspace").map(item => item.path);
930
+ if (value.read_only_input.version === 3)
931
+ for (const relative of reviewPaths) {
932
+ if (!reviewEnvironmentAllowed(relative))
933
+ throw new AppError("review_input_invalid", "Required review input is excluded from external review", 1, { path: relative });
934
+ }
935
+ const base = index.workspace?.base && index.workspace.base !== "unknown" ? index.workspace.base : "HEAD";
936
+ const selectedPaths = value.read_only_input.version === 3 ? reviewSourcePaths(run.workspace_root, reviewPaths, base) : null;
937
+ if (selectedPaths && reviewPaths.some(relative => !selectedPaths.includes(relative)))
938
+ throw new AppError("review_input_invalid", "Required review input is excluded from external review", 1, { work_id: work.work_id });
939
+ const selectedSource = value.read_only_input.version === 3 ? reviewSourceHash(run.workspace_root, reviewPaths, base) : null;
940
+ const current = { schema_id: value.read_only_input.version === 3 ? "dd-flow/review-input@3" : "dd-flow/review-input@2", parent_work_id: work.parent_work_id, kind: String(kind), cycle: kind === "code-review" ? String(payload?.review_cycle) : "initial", generation, workspace_fingerprint: selectedSource ?? workspaceFingerprint(run.workspace_root), ...(selectedSource ? { source_policy: "review-inputs@1", source_sha256: selectedSource } : {}), run_inputs: runInputs };
941
+ const committed = context.db.all("SELECT ws.start_receipt_json FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id = ? AND json_extract(w.payload_json, '$.kind') = ? AND ws.start_receipt_json IS NOT NULL ORDER BY ws.created_at, ws.id", [work.project_id, work.run_id, work.parent_work_id, kind]);
942
+ for (const row of committed) {
943
+ const receipt = JSON.parse(row.start_receipt_json);
944
+ if (!receipt.context)
945
+ continue;
946
+ const prior = JSON.parse(receipt.context).review_input;
947
+ if (!prior || prior.cycle !== current.cycle)
948
+ continue;
949
+ // Recovery changes the execution owner, not the review revision. A
950
+ // completed sibling from an older generation still pins this review cycle.
951
+ if (prior.schema_id !== current.schema_id || JSON.stringify({ ...prior, generation }) !== JSON.stringify(current))
952
+ throw new AppError("read_only_input_changed", "Reviewer inputs differ from the first committed review start", 2, { work_id: work.work_id, baseline: prior, current });
953
+ break;
954
+ }
955
+ return current;
779
956
  }
780
957
  async function settle(context, id, status, result, progress, retry) {
781
958
  const { work, run, link } = prepareWorkSettlement(context, id, status, result);
@@ -784,13 +961,17 @@ async function settle(context, id, status, result, progress, retry) {
784
961
  let coordinationDrift = [];
785
962
  const assertReadOnlyInput = () => {
786
963
  if (status === "completed" && parsePayload(work)?.read_only === true) {
787
- const shared = sharedReviewInput(work, run);
964
+ assertReviewCopyUnchanged(context, work, run, link);
965
+ const shared = assertSharedReviewInput(context, work, run);
966
+ if (shared)
967
+ return;
968
+ const legacy = legacyReviewFingerprint(work, run);
788
969
  // The final reviewer commits the shared input boundary under the writer
789
970
  // reservation. Concurrent finishes cannot both skip this verification.
790
- if (shared && context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND work_id <> ? AND status IN ('created','running','paused') AND json_extract(payload_json, '$.read_only') = 1 AND json_extract(payload_json, '$.kind') = ? LIMIT 1", [work.project_id, work.run_id, work.parent_work_id, work.work_id, parsePayload(work)?.kind]))
971
+ if (legacy && context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND work_id <> ? AND status IN ('created','running','paused') AND json_extract(payload_json, '$.read_only') = 1 AND json_extract(payload_json, '$.kind') = ? LIMIT 1", [work.project_id, work.run_id, work.parent_work_id, work.work_id, parsePayload(work)?.kind]))
791
972
  return;
792
973
  const contextFile = link ? path.join(path.dirname(link.prompt_path), "context.json") : "";
793
- const baseline = shared ?? (contextFile && fs.existsSync(contextFile) ? JSON.parse(fs.readFileSync(contextFile, "utf8")).workspace_fingerprint : null);
974
+ const baseline = legacy ?? (contextFile && fs.existsSync(contextFile) ? JSON.parse(fs.readFileSync(contextFile, "utf8")).workspace_fingerprint : null);
794
975
  const current = workspaceFingerprint(run.workspace_root);
795
976
  if (typeof baseline !== "string" || baseline !== current)
796
977
  throw new AppError("read_only_input_changed", "The workspace changed during read-only Work; its review input is stale (the writer is unknown)", 2, { work_id: work.work_id, baseline, current });
@@ -986,16 +1167,21 @@ export function validateReviewResolutions(workId, assigned, resolutions) {
986
1167
  function renderWorkerPrompt(context, work, run, dependencies) {
987
1168
  const command = flowCommand(context);
988
1169
  const packet = codePacket(work);
1170
+ const readOnly = parsePayload(work)?.read_only === true;
989
1171
  const resultInputPath = path.join(requireRunHome(run), "works", work.work_id, "result-input.json");
990
1172
  const finishWorkCommand = managedLifecycleCommand(context, runtimeCommand(context, ["work", "finish", work.work_id, "--result-file", resultInputPath, "--project-root", run.project_root, "--json", "--progress-jsonl"]));
991
1173
  const failWorkCommand = managedLifecycleCommand(context, runtimeCommand(context, ["work", "fail", shortWorkId(work.work_id), "--reason", "receipt path + exact external or semantic blocker", "--project-root", run.project_root, "--json"]));
992
1174
  const mergeWork = parsePayload(work)?.kind === "merge";
993
- const writeBoundary = mergeWork
994
- ? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
995
- : [`HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not."];
996
- const completionRepair = mergeWork
997
- ? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
998
- : ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then use the returned retry_command with your result. Internal invocation IDs are runtime-owned; do not copy or invent them. Do not invent a cause that does not appear in the retained output."];
1175
+ const writeBoundary = readOnly
1176
+ ? [`Review inputs under workspace_root (${run.workspace_root}) and run_home are read-only. After Work start, work from workspace_root and do not edit project source, PLAN, aspect maps, CODE evidence, or another Work's files.`, "Do not write inside workspace_root, including an isolated review copy. Write only this Work's declared result-input.json and explicitly assigned Work-owned evidence paths under run_home. Report changed or inaccessible review inputs as a blocker; do not repair them."]
1177
+ : mergeWork
1178
+ ? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
1179
+ : [`HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not."];
1180
+ const completionRepair = readOnly
1181
+ ? ["If Finish reports input drift or an infrastructure failure, report the exact receipt and stop. Do not change the reviewed files to make the check pass."]
1182
+ : mergeWork
1183
+ ? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
1184
+ : ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then use the returned retry_command with your result. Internal invocation IDs are runtime-owned; do not copy or invent them. Do not invent a cause that does not appear in the retained output."];
999
1185
  const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies materialization at Work finish. Execution occurs at its declared run_at gate, not necessarily in this Work.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
1000
1186
  if (packet)
1001
1187
  codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { agyWorkspaceHooks } from "../harness-runtime/lib/agy-hooks.mjs";
7
+ /** Local environment/credential files are never an exported reviewer or EVAL input. */
8
+ export function localEnvironmentAllowed(source) {
9
+ const name = path.basename(source);
10
+ return name === ".env.example" || (name !== ".env" && !name.startsWith(".env."));
11
+ }
12
+ /** A damaged/inaccessible repository is not an intentional gitless workspace. */
13
+ export function isGitlessWorkspace(root, result) {
14
+ if (result.error || result.status !== 128 || !/not a git repository|outside a repository/i.test(result.stderr ?? ""))
15
+ return false;
16
+ for (let directory = path.resolve(root);; directory = path.dirname(directory)) {
17
+ try {
18
+ fs.lstatSync(path.join(directory, ".git"));
19
+ return false;
20
+ }
21
+ catch (error) {
22
+ if (error.code !== "ENOENT")
23
+ return false;
24
+ }
25
+ if (path.dirname(directory) === directory)
26
+ return true;
27
+ }
28
+ }
29
+ export function workspaceGitFiles(root, args) {
30
+ const result = spawnSync("git", ["ls-files", "-z", ...args], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
31
+ if (isGitlessWorkspace(root, result))
32
+ return null;
33
+ if (result.status !== 0 || result.error)
34
+ throw new AppError("workspace_status_failed", "Could not determine workspace file inventory", 1, { workspace_root: root, stderr: result.stderr || null, cause: result.error?.message ?? null });
35
+ return [...new Set(result.stdout.split("\0").filter(Boolean))];
36
+ }
37
+ /** The exact qualified AGY file is infrastructure only while untracked.
38
+ * Never write a shared worktree exclude: that would hide a sibling's mixed
39
+ * user hooks. Snapshots still retain this qualification input. */
40
+ export function ownedWorkspaceHookPath(root, trackedFiles) {
41
+ const relative = ".agents/hooks.json", file = path.join(root, relative);
42
+ try {
43
+ if (!fs.lstatSync(path.dirname(file)).isDirectory() || !fs.lstatSync(file).isFile() || !isDeepStrictEqual(JSON.parse(fs.readFileSync(file, "utf8")), agyWorkspaceHooks()))
44
+ return null;
45
+ }
46
+ catch (error) {
47
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
48
+ return null;
49
+ throw error;
50
+ }
51
+ const tracked = trackedFiles ?? workspaceGitFiles(root, ["--cached", "--", relative]) ?? [];
52
+ return tracked.includes(relative) ? null : relative;
53
+ }
54
+ /** New feature worktrees do not inherit untracked qualified hooks via Git. */
55
+ export function propagateOwnedWorkspaceHooks(sourceRoot, targetRoot) {
56
+ const relative = ownedWorkspaceHookPath(sourceRoot);
57
+ if (!relative || path.resolve(sourceRoot) === path.resolve(targetRoot))
58
+ return null;
59
+ const target = path.join(targetRoot, relative), directory = path.dirname(target);
60
+ if (fs.lstatSync(target, { throwIfNoEntry: false }))
61
+ return null; // User/tracked target wins; daemon checks qualification.
62
+ if (workspaceGitFiles(targetRoot, ["--cached", "--", relative])?.includes(relative))
63
+ return null;
64
+ if (fs.lstatSync(directory, { throwIfNoEntry: false }) && !fs.lstatSync(directory).isDirectory())
65
+ throw new AppError("workspace_hooks_unqualified", "AGY hook directory is not an owned directory", 1, { path: directory });
66
+ fs.mkdirSync(directory, { recursive: true });
67
+ fs.copyFileSync(path.join(sourceRoot, relative), target, fs.constants.COPYFILE_EXCL);
68
+ fs.chmodSync(target, 0o600);
69
+ return target;
70
+ }
71
+ /** Ignore only provider-owned service files. Tracked/explicit inputs win;
72
+ * other ignored and untracked product/task files remain snapshot payload. */
73
+ export function projectServicePathFilter(root, explicitInputs = [], trackedFiles) {
74
+ const retained = new Set([...(trackedFiles ?? workspaceGitFiles(root, ["--cached"]) ?? []), ...explicitInputs].map(file => path.resolve(root, file)));
75
+ const branches = new Set();
76
+ for (const file of retained)
77
+ for (let directory = path.dirname(file); directory !== path.dirname(directory); directory = path.dirname(directory))
78
+ branches.add(directory);
79
+ return file => {
80
+ const absolute = path.resolve(file), relative = path.relative(root, absolute);
81
+ return !relative.split(path.sep).includes(".zcode") || retained.has(absolute) || branches.has(absolute);
82
+ };
83
+ }
@@ -9,6 +9,7 @@ import { appendAudit } from "./audit.js";
9
9
  import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
10
10
  import { requireProjectByRoot } from "./projects.js";
11
11
  import { ensureManagedWorktrunk, inspectManagedWorktrunk } from "./tooling.js";
12
+ import { propagateOwnedWorkspaceHooks } from "./workspace-files.js";
12
13
  export function planWorktree(context, input) {
13
14
  const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
14
15
  const state = readProtocolRuntimeState(context, protocol).state;
@@ -151,7 +152,8 @@ export function bootstrapManagedWorktree(context, input, prepared = prepareManag
151
152
  throw new AppError("bootstrap_failed", "Project-owned Worktrunk bootstrap failed", 1, { command: sanitizeCommand(command), result, results });
152
153
  }
153
154
  }
154
- return { status: "succeeded", commands: results };
155
+ const qualifiedHook = propagateOwnedWorkspaceHooks(prepared.projectRoot, worktreePath);
156
+ return { status: "succeeded", commands: results, ...(qualifiedHook ? { qualified_hook: qualifiedHook } : {}) };
155
157
  }
156
158
  export function getWorktreeStatus(context, input) {
157
159
  const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.100",
3
+ "version": "0.9.0-beta.104",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {