@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.
- package/dist/build-info.json +3 -3
- package/dist/cli/hook-ingress.js +5 -10
- package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
- package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
- package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
- package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +32 -3
- package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
- package/dist/harness-runtime/lib/process-json.mjs +3 -2
- package/dist/services/code-checks.js +53 -16
- package/dist/services/controller-fanout.js +37 -6
- package/dist/services/eval-snapshots.js +148 -38
- package/dist/services/external-work-launch.js +60 -5
- package/dist/services/prompts.js +2 -0
- package/dist/services/review-copy.js +136 -0
- package/dist/services/run-control-worker.js +16 -11
- package/dist/services/run-controller-adapter.js +9 -0
- package/dist/services/runs.js +3 -0
- package/dist/services/runtime-scope-stop.js +66 -6
- package/dist/services/vnext-code-review.js +6 -2
- package/dist/services/vnext-code.js +1 -0
- package/dist/services/vnext-fanout.js +9 -1
- package/dist/services/vnext-merge.js +8 -4
- package/dist/services/vnext-plan-review.js +4 -15
- package/dist/services/vnext-plan.js +4 -0
- package/dist/services/vnext-protocolize.js +4 -2
- package/dist/services/vnext-specify.js +7 -0
- package/dist/services/work-registry.js +208 -22
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +3 -1
- 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,
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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 =
|
|
994
|
-
? [
|
|
995
|
-
:
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
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);
|