@junghanacs/entwurf 0.13.1 → 0.14.1

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 (158) hide show
  1. package/AGENTS.md +59 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +74 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +37 -28
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +155 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +300 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +141 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  38. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  39. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  40. package/mcp/entwurf-bridge/src/index.ts +173 -28
  41. package/mcp/entwurf-bridge/start.sh +2 -2
  42. package/mcp/entwurf-bridge/test.sh +23 -9
  43. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  44. package/package.json +22 -11
  45. package/pi-extensions/entwurf-control.ts +227 -40
  46. package/pi-extensions/lib/acp/backend.ts +71 -12
  47. package/pi-extensions/lib/acp/overlay.ts +8 -6
  48. package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  50. package/pi-extensions/lib/entwurf-core.ts +15 -15
  51. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  52. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  53. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  54. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  55. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  56. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  57. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  58. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  59. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  60. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  61. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  62. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  63. package/pi-extensions/lib/meta-session.ts +93 -5
  64. package/pi-extensions/lib/mux-fresh-call.ts +381 -0
  65. package/pi-extensions/lib/mux-launch.ts +267 -0
  66. package/pi-extensions/lib/mux-placement.ts +387 -0
  67. package/pi-extensions/lib/mux-resume-call.ts +189 -0
  68. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  69. package/pi-extensions/lib/session-id.js +8 -5
  70. package/pi-extensions/lib/socket-discovery.ts +3 -3
  71. package/run.sh +471 -201
  72. package/scripts/agy-bridge-config.py +5 -1
  73. package/scripts/check-acp-backend-preflight.ts +1 -1
  74. package/scripts/check-acp-overlay.ts +13 -3
  75. package/scripts/check-acp-stream-hooks.ts +504 -0
  76. package/scripts/check-elapsed.sh +25 -0
  77. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  78. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  79. package/scripts/check-entwurf-control-rpc.ts +4 -3
  80. package/scripts/check-entwurf-resume-args.ts +72 -70
  81. package/scripts/check-entwurf-session-identity.ts +14 -10
  82. package/scripts/check-entwurf-v2-contract.ts +34 -59
  83. package/scripts/check-entwurf-v2-decider.ts +17 -177
  84. package/scripts/check-entwurf-v2-lock.ts +5 -2
  85. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  86. package/scripts/check-entwurf-v2-production.ts +2 -91
  87. package/scripts/check-entwurf-v2-release.ts +10 -105
  88. package/scripts/check-entwurf-v2-runner.ts +4 -85
  89. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  90. package/scripts/check-entwurf-v2-send.ts +0 -28
  91. package/scripts/check-entwurf-v2-surface.ts +157 -128
  92. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  93. package/scripts/check-fresh-cut-gate.sh +1 -1
  94. package/scripts/check-gate-qualification.ts +99 -7
  95. package/scripts/check-install-container.sh +10 -2
  96. package/scripts/check-install-surface.ts +1 -1
  97. package/scripts/check-keyset-overlap.py +1 -1
  98. package/scripts/check-meta-facts.ts +249 -0
  99. package/scripts/check-meta-identity-consumers.ts +1 -1
  100. package/scripts/check-meta-session.ts +169 -0
  101. package/scripts/check-mux-launch-tmux.ts +316 -0
  102. package/scripts/check-mux-launch.ts +288 -0
  103. package/scripts/check-mux-launcher-fence.ts +264 -0
  104. package/scripts/check-mux-parent-artifact.ts +195 -0
  105. package/scripts/check-mux-placement-tmux.ts +322 -0
  106. package/scripts/check-mux-placement.ts +323 -0
  107. package/scripts/check-mux-resume-call.ts +283 -0
  108. package/scripts/check-probe-cli-shim.ts +25 -22
  109. package/scripts/check-probe-ordering.ts +84 -76
  110. package/scripts/check-release-gate-outcomes.ts +127 -7
  111. package/scripts/check-resume-launch-identity.ts +244 -0
  112. package/scripts/check-socket-discovery.ts +1 -1
  113. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  114. package/scripts/inventory-verification-surface.ts +349 -0
  115. package/scripts/lib/claude-launcher-fence.ts +322 -0
  116. package/scripts/lib/mutation-qualify.ts +109 -3
  117. package/scripts/meta-bridge-doctor.sh +6 -8
  118. package/scripts/meta-bridge-state.py +75 -1
  119. package/scripts/meta-facts.ts +60 -0
  120. package/scripts/mutants/acp-overlay.json +17 -0
  121. package/scripts/mutants/acp-stream-hooks.json +158 -0
  122. package/scripts/mutants/bridge-boot-resume.json +45 -0
  123. package/scripts/mutants/meta-facts.json +50 -0
  124. package/scripts/mutants/meta-identity.json +36 -0
  125. package/scripts/mutants/meta-retire.json +47 -0
  126. package/scripts/mutants/mux-boundary.json +196 -0
  127. package/scripts/mutants/mux-fresh-call.json +229 -0
  128. package/scripts/mutants/mux-launcher-fence.json +123 -0
  129. package/scripts/mutants/mux-parent-artifact.json +39 -0
  130. package/scripts/mutants/mux-resume-call.json +148 -0
  131. package/scripts/mutants/probe-ordering.json +0 -1037
  132. package/scripts/mutants/release-gate.json +35 -0
  133. package/scripts/mutants/resume-args.json +76 -0
  134. package/scripts/mutants/resume-launch-identity.json +96 -0
  135. package/scripts/mutants/v2-surface.json +58 -18
  136. package/scripts/mutants/v2-visible-resume.json +215 -0
  137. package/scripts/new-session-id.ts +9 -4
  138. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  139. package/scripts/smoke-agy-native-push-live.ts +6 -17
  140. package/scripts/smoke-entwurf-chain-live.ts +11 -3
  141. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  142. package/scripts/smoke-meta-honesty.sh +1 -1
  143. package/scripts/smoke-meta-install-state.sh +169 -3
  144. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  145. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  146. package/scripts/smoke-pi-attach.ts +1 -1
  147. package/scripts/smoke-user-scope-citizen.sh +1 -1
  148. package/scripts/tsconfig.json +1 -0
  149. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  150. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  151. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  152. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  153. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  154. package/scripts/check-acp-sdk-surface.ts +0 -275
  155. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  156. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  157. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  158. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -78,6 +78,70 @@ export function signatureOnFailureLine(output: string, token: string): boolean {
78
78
  return output.split("\n").some((line) => line.includes(token) && !/^\s*ok\b/.test(line));
79
79
  }
80
80
 
81
+ /** run_vitest prints this line when the runner asked it for a structured report.
82
+ * Its PRESENCE — not the report's readability — is what declares the lane structured. */
83
+ export const VITEST_STRUCTURED_MARKER = "__ENTWURF_VITEST_JSON__";
84
+
85
+ /**
86
+ * What a gate run said about WHICH tests failed.
87
+ *
88
+ * - `"legacy"`: no structured marker — a hand-built node:assert/shell gate whose
89
+ * `ok`-line oracle is unchanged.
90
+ * - `"unreadable"`: the marker was printed but the report is missing or malformed.
91
+ * This NEVER falls back to token scanning: a structured lane that lost its report
92
+ * has no attribution at all, so the mutant is WRONG-REASON, not KILLED.
93
+ * - a title array: the exact `fullName` of every FAILED test.
94
+ */
95
+ export type FailedTestTitles = "legacy" | "unreadable" | string[];
96
+
97
+ /** Read the failed-test titles a vitest gate wrote, if it declared itself structured. */
98
+ export function readVitestFailedTitles(output: string, reportPath: string | null): FailedTestTitles {
99
+ if (!output.split("\n").some((line) => line.trim() === VITEST_STRUCTURED_MARKER)) return "legacy";
100
+ if (!reportPath) return "unreadable";
101
+ let raw: string;
102
+ try {
103
+ raw = fs.readFileSync(reportPath, "utf8");
104
+ } catch {
105
+ return "unreadable";
106
+ }
107
+ let report: {
108
+ testResults?: Array<{ assertionResults?: Array<{ status?: string; fullName?: string; title?: string }> }>;
109
+ };
110
+ try {
111
+ report = JSON.parse(raw);
112
+ } catch {
113
+ return "unreadable";
114
+ }
115
+ if (!Array.isArray(report.testResults)) return "unreadable";
116
+ const titles: string[] = [];
117
+ for (const suite of report.testResults) {
118
+ for (const assertion of suite.assertionResults ?? []) {
119
+ if (assertion.status !== "failed") continue;
120
+ titles.push(assertion.fullName ?? assertion.title ?? "");
121
+ }
122
+ }
123
+ return titles;
124
+ }
125
+
126
+ /**
127
+ * Attribute a kill to its claim. A Vitest failure's CODE FRAME quotes the source lines
128
+ * around the assertion — including an adjacent PASSING test's `it("[QK:…]" …)` title —
129
+ * so scanning output lines would certify a claim whose test never failed (measured:
130
+ * issue #62 review). Structured lanes therefore read only the failed-test title set.
131
+ */
132
+ export function signatureAttributedToFailure(output: string, token: string, failed: FailedTestTitles): boolean {
133
+ if (failed === "legacy") return signatureOnFailureLine(output, token);
134
+ if (failed === "unreadable") return false;
135
+ return failed.some((title) => title.includes(token));
136
+ }
137
+
138
+ /** One legible word for the report line, so a WRONG-REASON says WHY it could not attribute. */
139
+ export function describeAttribution(failed: FailedTestTitles): string {
140
+ if (failed === "legacy") return "failure-line";
141
+ if (failed === "unreadable") return "vitest-structured-but-unreadable";
142
+ return `vitest-failed-titles(${failed.length})`;
143
+ }
144
+
81
145
  // ── manifest schema (fail-loud, exact keys) ─────────────────────────────────
82
146
 
83
147
  export interface MutantSpec {
@@ -430,6 +494,8 @@ export interface GateRunResult {
430
494
  timedOut: boolean;
431
495
  output: string;
432
496
  seconds: number;
497
+ /** Structured failed-test attribution; `"legacy"` for every non-vitest gate. */
498
+ failedTitles: FailedTestTitles;
433
499
  }
434
500
 
435
501
  const OUTPUT_CAP = 4 * 1024 * 1024;
@@ -437,12 +503,23 @@ const OUTPUT_CAP = 4 * 1024 * 1024;
437
503
  /** Env prefixes the outer fence strips so a gate child starts from a neutral host. */
438
504
  const STRIP_ENV_PREFIXES = ["ENTWURF_", "AGY_", "PI_SESSION_ID", "PI_AGENT_ID"];
439
505
 
506
+ /** Where a vitest-backed gate writes its machine report for this one invocation. */
507
+ function vitestReportPath(invocationDir: string): string {
508
+ return path.join(invocationDir, "vitest-report.json");
509
+ }
510
+
440
511
  function fencedEnv(invocationDir: string): NodeJS.ProcessEnv {
441
512
  const env: NodeJS.ProcessEnv = {};
442
513
  for (const [k, v] of Object.entries(process.env)) {
443
514
  if (STRIP_ENV_PREFIXES.some((p) => k === p || k.startsWith(p))) continue;
444
515
  env[k] = v;
445
516
  }
517
+ // A vitest-backed gate writes its machine report HERE, inside the per-invocation dir
518
+ // (outside the snapshot repo, so the work-surface purity hash never sees it). The
519
+ // report is a FILE and not stdout on purpose: `output` merges stdout+stderr, so one
520
+ // vite warning ahead of the JSON would break the parse and silently downgrade a real
521
+ // kill to WRONG-REASON. Legacy gates ignore the variable.
522
+ env.ENTWURF_MUTATION_VITEST_REPORT = vitestReportPath(invocationDir);
446
523
  for (const d of ["home", "xdg-data", "xdg-config", "xdg-cache", "xdg-state"]) {
447
524
  fs.mkdirSync(path.join(invocationDir, d), { recursive: true });
448
525
  }
@@ -499,7 +576,9 @@ export function runGateBounded(opts: {
499
576
  });
500
577
  child.on("close", (code) => {
501
578
  clearTimeout(timer);
502
- resolve({ exitCode: code, timedOut, output, seconds: (Date.now() - started) / 1000 });
579
+ // Read the report BEFORE the caller removes the invocation dir.
580
+ const failedTitles = readVitestFailedTitles(output, vitestReportPath(opts.invocationDir));
581
+ resolve({ exitCode: code, timedOut, output, failedTitles, seconds: (Date.now() - started) / 1000 });
503
582
  });
504
583
  });
505
584
  }
@@ -548,6 +627,29 @@ function countOccurrences(haystack: string, needle: string): number {
548
627
  return count;
549
628
  }
550
629
 
630
+ /** Tail of a red control run's captured output that gets echoed into the log. */
631
+ const CONTROL_TAIL_LINES = 40;
632
+
633
+ /**
634
+ * Echo a RED control run's captured output. Without this the runner reports the
635
+ * verdict (`RED (exit=1 …)`) and DROPS the reason it already holds, which makes a
636
+ * CONTROL-RED that only reproduces on one host — a CI runner, say — undiagnosable
637
+ * from its log: the same single line no matter how often it is re-run. A bounded
638
+ * tail is enough, because a gate names its failure on the way out. Silence is a
639
+ * finding too, so an empty capture is reported as empty rather than skipped.
640
+ */
641
+ function logControlOutput(output: string, log: (line: string) => void): void {
642
+ const body = output.replace(/\s+$/, "");
643
+ if (body === "") {
644
+ log(" │ (the control run produced no output — the failure is upstream of the gate's own reporting)");
645
+ return;
646
+ }
647
+ const lines = body.split("\n");
648
+ const dropped = Math.max(0, lines.length - CONTROL_TAIL_LINES);
649
+ if (dropped > 0) log(` │ … ${dropped} earlier line(s) omitted`);
650
+ for (const line of lines.slice(-CONTROL_TAIL_LINES)) log(` │ ${line}`);
651
+ }
652
+
551
653
  /**
552
654
  * Run every mutant grouped by its gate command, with the control-mutant-restore-
553
655
  * control state machine. A red CONTROL-PRE aborts the whole group (every mutant
@@ -593,6 +695,7 @@ export async function qualifyMutants(
593
695
  ` control-pre ${gate.join(" ")}: ${controlPreOk ? "green" : `RED (exit=${pre.exitCode} timedOut=${pre.timedOut})`} in ${pre.seconds.toFixed(1)}s`,
594
696
  );
595
697
  if (!controlPreOk) {
698
+ logControlOutput(pre.output, log);
596
699
  group.control = "pre-red";
597
700
  for (const m of groupMutants) {
598
701
  group.mutants.push({
@@ -678,7 +781,7 @@ export async function qualifyMutants(
678
781
  matchCount: 1,
679
782
  timedOut: run.timedOut,
680
783
  exitCode: run.exitCode,
681
- signatureOnFailureLine: signatureOnFailureLine(run.output, m.signature),
784
+ signatureOnFailureLine: signatureAttributedToFailure(run.output, m.signature, run.failedTitles),
682
785
  restoredOk,
683
786
  });
684
787
  if (!restoredOk) groupImpure = true;
@@ -687,7 +790,9 @@ export async function qualifyMutants(
687
790
  verdict,
688
791
  seconds: run.seconds,
689
792
  subjectSha256: originalSha,
690
- detail: `exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}`,
793
+ detail:
794
+ `exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}` +
795
+ ` attribution=${describeAttribution(run.failedTitles)}`,
691
796
  });
692
797
  log(
693
798
  ` claim ${m.claim}: ${verdict} in ${run.seconds.toFixed(1)}s (subject ${m.subject} sha256=${originalSha.slice(0, 12)}…)`,
@@ -705,6 +810,7 @@ export async function qualifyMutants(
705
810
  log(
706
811
  ` control-post ${gate.join(" ")}: ${controlPostOk ? "green" : `RED (exit=${post.exitCode} timedOut=${post.timedOut}) — restore contamination or gate state leak`} in ${post.seconds.toFixed(1)}s`,
707
812
  );
813
+ if (!controlPostOk) logControlOutput(post.output, log);
708
814
  }
709
815
 
710
816
  const postTree = computeTreeManifest(snapshot.repoDir);
@@ -1207,14 +1207,12 @@ case "$REPO" in
1207
1207
  fi
1208
1208
  ;;
1209
1209
  esac
1210
- # prefixRoots operator policy (5d-4b): the shared env SSOT both v2 surfaces read. Display
1211
- # ONLY the parser SSOT lives in entwurf-v2-surface.ts (proven by check-entwurf-v2-surface);
1212
- # the doctor does NOT re-implement parsing. Unset no prefix promotion (the safe default).
1213
- if [ -z "${ENTWURF_PREFIX_ROOTS:-}" ]; then
1214
- ok "ENTWURF_PREFIX_ROOTS unset no prefix auto-approve (preflight default trust)"
1215
- else
1216
- ok "ENTWURF_PREFIX_ROOTS set → operator prefix-approve roots: $ENTWURF_PREFIX_ROOTS"
1217
- fi
1210
+ # NOT REPORTED: ENTWURF_PREFIX_ROOTS. It was the operator-policy SSOT that both v2 surfaces
1211
+ # read to feed the dispatch-path trust preflight, and that preflight guarded the resume verdict
1212
+ # so it left with `owned-outcome` in the visible-first cut. Nothing on the dispatch path reads
1213
+ # the variable now, and a doctor line saying "set → operator prefix-approve roots" would report a
1214
+ # knob that does nothing. Reporting an inert setting as live policy is the failure this doctor
1215
+ # exists to prevent, so the row is gone rather than softened.
1218
1216
 
1219
1217
  echo
1220
1218
  if [ "$fail" -eq 0 ]; then echo "meta-bridge doctor: PASS"; else echo "meta-bridge doctor: FAIL (see above)"; exit 1; fi
@@ -74,7 +74,6 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
74
74
  ("promptSuggestionEnabled", ["promptSuggestionEnabled"], False),
75
75
  ("awaySummaryEnabled", ["awaySummaryEnabled"], False),
76
76
  ("autoMemoryEnabled", ["autoMemoryEnabled"], False),
77
- ("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
78
77
  ("verbose", ["verbose"], False),
79
78
  ("autoCompactEnabled", ["autoCompactEnabled"], False),
80
79
  ("showTurnDuration", ["showTurnDuration"], False),
@@ -84,6 +83,19 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
84
83
  ("workflowKeywordTriggerEnabled", ["workflowKeywordTriggerEnabled"], False),
85
84
  ]
86
85
 
86
+ # Keys entwurf used to own but now returns to the operator. An old install-state
87
+ # entry is the only authority to undo our prior write; a bare matching value is
88
+ # not provenance and is never changed. apply() consumes each proven old entry
89
+ # exactly once, then uninstall can no longer restore over the operator's choice.
90
+ RETIRED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
91
+ ("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
92
+ ]
93
+
94
+ _managed_scalar_names = {name for name, _path, _desired in MANAGED_SETTINGS_SCALARS}
95
+ _retired_scalar_names = {name for name, _path, _last in RETIRED_SETTINGS_SCALARS}
96
+ if overlap := _managed_scalar_names & _retired_scalar_names:
97
+ raise RuntimeError(f"settings scalar cannot be both managed and retired: {sorted(overlap)}")
98
+
87
99
 
88
100
  class StateError(RuntimeError):
89
101
  pass
@@ -391,6 +403,9 @@ def apply(repo: Path, asm: Path) -> None:
391
403
  if not isinstance(root, dict):
392
404
  die(f"{claude_root_config_path()} root must be a JSON object")
393
405
 
406
+ for name, path_, last_managed_value in RETIRED_SETTINGS_SCALARS:
407
+ relinquish_retired_scalar(state, settings, name, path_, last_managed_value)
408
+
394
409
  set_nested(settings, ["enabledPlugins", PLUGIN_REF], True)
395
410
  set_nested(settings, ["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm))
396
411
  for path_, desired in [(["permissions", "allow"], PERMISSION_ALLOW), (["permissions", "deny"], PERMISSION_DENY)]:
@@ -446,6 +461,53 @@ def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
446
461
  die(f"unknown state entry kind: {kind}")
447
462
 
448
463
 
464
+ def settings_state_keys(state: dict[str, Any]) -> dict[str, Any]:
465
+ """The settings key ledger, or a loud failure.
466
+
467
+ `load_state` validates only the envelope (schemaVersion/owner), so a consumer
468
+ that indexes straight into files.settings.keys turns a corrupt state file into
469
+ a bare KeyError traceback instead of an operator diagnostic. Ownership
470
+ decisions read this ledger, so an unreadable one must stop before any write.
471
+ """
472
+ files = state.get("files")
473
+ entry = files.get("settings") if isinstance(files, dict) else None
474
+ keys = entry.get("keys") if isinstance(entry, dict) else None
475
+ if not isinstance(keys, dict):
476
+ die(f"install state {state_path()} has no files.settings.keys ledger; re-run install-meta-bridge")
477
+ return keys
478
+
479
+
480
+ def relinquish_retired_scalar(
481
+ state: dict[str, Any], settings: dict[str, Any], name: str, path: list[str], last_managed_value: Any
482
+ ) -> None:
483
+ """Return one formerly managed scalar to operator ownership.
484
+
485
+ Only a preserved install-state entry proves entwurf wrote this path. If the
486
+ current value still has the exact JSON scalar type+value we last managed,
487
+ restore the snapshot. A changed or absent current value is already the
488
+ operator's and stays untouched. Malformed provenance fails before any file
489
+ write; silently discarding it would make a still-dangerous value look clean.
490
+ """
491
+ keys = settings_state_keys(state)
492
+ entry = keys.get(name)
493
+ if entry is None:
494
+ return
495
+ if not isinstance(entry, dict) or entry.get("kind") != "scalar" or entry.get("path") != path:
496
+ die(f"retired scalar state entry {name} is malformed; refusing to discard ownership evidence")
497
+ original = entry.get("original")
498
+ if (
499
+ not isinstance(original, dict)
500
+ or set(original) != {"existed", "value"}
501
+ or type(original.get("existed")) is not bool
502
+ ):
503
+ die(f"retired scalar state entry {name} has malformed original; refusing to guess")
504
+
505
+ existed, value = get_nested(settings, path)
506
+ if existed and type(value) is type(last_managed_value) and value == last_managed_value:
507
+ restore_entry(settings, entry)
508
+ keys.pop(name)
509
+
510
+
449
511
  def preflight_uninstall() -> None:
450
512
  load_state(required=True)
451
513
  print(f"[meta-bridge-state] uninstall preflight ok ({state_path()})")
@@ -536,6 +598,18 @@ def check(repo: Path, asm: Path) -> None:
536
598
  if recorded_asm
537
599
  else desired_marketplace(asm)
538
600
  )
601
+ state_settings_keys = settings_state_keys(state)
602
+ for name, path_, last_managed_value in RETIRED_SETTINGS_SCALARS:
603
+ if name in state_settings_keys:
604
+ failures.append(f"install-state still owns retired scalar {name}; re-run install-meta-bridge to relinquish it")
605
+ continue
606
+ existed, value = get_nested(settings, path_)
607
+ if existed and type(value) is type(last_managed_value) and value == last_managed_value:
608
+ print(
609
+ f"NOTE: settings {name}={json.dumps(last_managed_value)} is operator-owned; "
610
+ f"entwurf no longer suppresses or restores this warning choice"
611
+ )
612
+
539
613
  checks = [
540
614
  (["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
541
615
  (["extraKnownMarketplaces", MARKETPLACE], marketplace_expected, "known marketplace"),
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * meta-facts — owner-normalized, read-only projection of the meta-record store (#65).
4
+ *
5
+ * The one join a consumer cannot make anywhere else — `(gardenId, nativeSessionId,
6
+ * transcriptPath)` — lives only in the meta-record store, and reading that store
7
+ * correctly means carrying the whole certification contract (strict v3 keyset,
8
+ * parse-before-uniqueness, no-winner duplicates, O_NOFOLLOW regular-file reads).
9
+ * External consumers that copied it decayed the day the contract moved. This surface
10
+ * makes THE owner emit the join: it is `listAllMetaIdentities` over the real store —
11
+ * the same listing `entwurf_peers` trusts — serialized verbatim. No parallel parser,
12
+ * no liveness, no socket paths, no transcript contents, no state, no watching.
13
+ *
14
+ * stdout (deterministic, 2-space indent, trailing newline):
15
+ * {
16
+ * "schemaVersion": 1, // of THIS projection, not of the records
17
+ * "storeDir": "/abs/path",
18
+ * "citizens": [ <full verbatim strict 9-field v3 records, sorted by gardenId> ],
19
+ * "defects": [ { "filename", "message" }, … sorted by filename ]
20
+ * }
21
+ *
22
+ * Defects ride IN-BAND: a readable store with uncertifiable entries is a fact to
23
+ * report, not a failure of this command — the healthy citizens are still true, and
24
+ * the defect list is exactly what `listAllMetaIdentities` refused and why (a rival
25
+ * pair is refused on BOTH sides; a schema-invalid record can never quarantine a
26
+ * healthy neighbour, because parse runs before uniqueness).
27
+ *
28
+ * EXIT CONTRACT — the verdict is the exit code (store-doctor precedent):
29
+ * 0 — store readable; the JSON above is on stdout (defects, if any, in-band).
30
+ * A store that does not exist is a readable EMPTY store (ENOENT only).
31
+ * 2 — usage error (bad argv).
32
+ * 3 — the store could not be READ (EACCES/ENOTDIR/…). No JSON is emitted:
33
+ * an unreadable host must never look like an empty one.
34
+ */
35
+
36
+ import { defaultMetaSessionsDir, listAllMetaIdentitiesDir } from "../pi-extensions/lib/meta-session.ts";
37
+
38
+ const arg = process.argv[2];
39
+ // A dash argv is a flag this command does not have, not a store directory — treating
40
+ // it as a path would answer `--help` with "empty store, exit 0", a silent wrong fact.
41
+ if (process.argv.length > 3 || (arg !== undefined && arg.startsWith("-"))) {
42
+ console.error("usage: entwurf meta-facts [meta-sessions-dir]");
43
+ process.exit(2);
44
+ }
45
+
46
+ let facts: ReturnType<typeof listAllMetaIdentitiesDir>;
47
+ try {
48
+ facts = listAllMetaIdentitiesDir(arg ?? defaultMetaSessionsDir());
49
+ } catch (err) {
50
+ console.error(`FAIL: ${err instanceof Error ? err.message : String(err)}`);
51
+ process.exit(3); // see the EXIT CONTRACT above: 3 = unreadable store, never an empty one
52
+ }
53
+
54
+ process.stdout.write(
55
+ `${JSON.stringify(
56
+ { schemaVersion: 1, storeDir: facts.dir, citizens: facts.identities, defects: facts.errors },
57
+ null,
58
+ 2,
59
+ )}\n`,
60
+ );
@@ -0,0 +1,17 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "acp-overlay",
4
+ "mutants": [
5
+ {
6
+ "claim": "ACP-OVERLAY-UNATTENDED-BYPASS",
7
+ "title": "the isolated Claude overlay goes back to authoring permissions.defaultMode \"default\" — the overlay is the only settings authority the ACP child reads, so an unattended entwurf turn suspends on an interactive permission prompt nobody is there to answer, and the session stalls rather than fails",
8
+ "subject": "pi-extensions/lib/acp/overlay.ts",
9
+ "find": ["\t\t\tpermissions: { defaultMode: \"bypassPermissions\" },"],
10
+ "replace": ["\t\t\tpermissions: { defaultMode: \"default\" },"],
11
+ "gate": ["bash", "run.sh", "check-acp-overlay"],
12
+ "timeoutSeconds": 180,
13
+ "signature": "[QK:ACP-OVERLAY-UNATTENDED-BYPASS]",
14
+ "signatureSource": "scripts/check-acp-overlay.ts"
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,158 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "acp-stream-hooks",
4
+ "mutants": [
5
+ {
6
+ "claim": "ACPHOOK-PAYLOAD-EXACT-WIRE",
7
+ "title": "the new-turn send site bypasses the payload hook entirely — the pi-vertex defect: extensions hooking before_provider_request silently observe nothing",
8
+ "subject": "pi-extensions/lib/acp/backend.ts",
9
+ "find": [
10
+ "\t\t\tconst wireParams = await applyProviderPayloadHook(options, { sessionId: acpSessionId, prompt }, model);"
11
+ ],
12
+ "replace": ["\t\t\tconst wireParams = { sessionId: acpSessionId, prompt };"],
13
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
14
+ "timeoutSeconds": 300,
15
+ "signature": "[QK:ACPHOOK-PAYLOAD-EXACT-WIRE]",
16
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
17
+ },
18
+ {
19
+ "claim": "ACPHOOK-REPLACEMENT-ON-WIRE",
20
+ "title": "the helper validates the replacement and then discards it — the original params always cross the wire, so a payload-rewriting extension silently does nothing",
21
+ "subject": "pi-extensions/lib/acp/backend.ts",
22
+ "find": ["\treturn replacement as T;"],
23
+ "replace": ["\treturn params;"],
24
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
25
+ "timeoutSeconds": 300,
26
+ "signature": "[QK:ACPHOOK-REPLACEMENT-ON-WIRE]",
27
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
28
+ },
29
+ {
30
+ "claim": "ACPHOOK-REUSE-PARITY",
31
+ "title": "the reuse send site bypasses the hook, so resident-session requests become invisible to extensions while cold turns stay covered",
32
+ "subject": "pi-extensions/lib/acp/backend.ts",
33
+ "find": [
34
+ "\t\t\tconst wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);"
35
+ ],
36
+ "replace": ["\t\t\tconst wireParams = { sessionId: session.acpSessionId, prompt };"],
37
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
38
+ "timeoutSeconds": 300,
39
+ "signature": "[QK:ACPHOOK-REUSE-PARITY]",
40
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
41
+ },
42
+ {
43
+ "claim": "ACPHOOK-REJECTION-FAILS-LOUD",
44
+ "title": "the new-turn call site silently catches a hook rejection and falls back to the original params — a broken extension keeps 'working'",
45
+ "subject": "pi-extensions/lib/acp/backend.ts",
46
+ "find": [
47
+ "\t\t\tconst wireParams = await applyProviderPayloadHook(options, { sessionId: acpSessionId, prompt }, model);"
48
+ ],
49
+ "replace": [
50
+ "\t\t\tconst wireParams = await applyProviderPayloadHook(options, { sessionId: acpSessionId, prompt }, model).catch(",
51
+ "\t\t\t\t() => ({ sessionId: acpSessionId, prompt }),",
52
+ "\t\t\t);"
53
+ ],
54
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
55
+ "timeoutSeconds": 300,
56
+ "signature": "[QK:ACPHOOK-REJECTION-FAILS-LOUD]",
57
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
58
+ },
59
+ {
60
+ "claim": "ACPHOOK-INVALID-REPLACEMENT-REFUSED",
61
+ "title": "the non-null/non-array object shape check is dropped, so a scalar replacement is refused for the wrong reason (or not at all) instead of by name",
62
+ "subject": "pi-extensions/lib/acp/backend.ts",
63
+ "find": [
64
+ "\tif (typeof replacement !== \"object\" || replacement === null || Array.isArray(replacement)) {",
65
+ "\t\tthrow new Error(",
66
+ "\t\t\t\"entwurf: before_provider_request returned a non-object replacement — the ACP prompt payload must stay a non-null, non-array object\",",
67
+ "\t\t);",
68
+ "\t}"
69
+ ],
70
+ "replace": ["\t// a JS extension can return anything; the sessionId check below is the real guard"],
71
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
72
+ "timeoutSeconds": 300,
73
+ "signature": "[QK:ACPHOOK-INVALID-REPLACEMENT-REFUSED]",
74
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
75
+ },
76
+ {
77
+ "claim": "ACPHOOK-EMPTY-PROMPT-REFUSED",
78
+ "title": "the non-empty prompt invariant is dropped from replacement validation, so an extension can rewrite the turn into an empty ACP send",
79
+ "subject": "pi-extensions/lib/acp/backend.ts",
80
+ "find": [
81
+ "\tconst replacementPrompt = (replacement as { prompt?: unknown }).prompt;",
82
+ "\tif (!Array.isArray(replacementPrompt) || replacementPrompt.length === 0) {",
83
+ "\t\tthrow new Error(",
84
+ "\t\t\t\"entwurf: before_provider_request returned a replacement without a non-empty prompt array — an empty ACP prompt cannot be sent\",",
85
+ "\t\t);",
86
+ "\t}"
87
+ ],
88
+ "replace": ["\t// the send sites already refuse an empty BUILT prompt; trust the replacement"],
89
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
90
+ "timeoutSeconds": 300,
91
+ "signature": "[QK:ACPHOOK-EMPTY-PROMPT-REFUSED]",
92
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
93
+ },
94
+ {
95
+ "claim": "ACPHOOK-SESSIONID-TAMPER-REFUSED",
96
+ "title": "the sessionId invariant is dropped under a literal reading of 'use any returned replacement payload' — the prompt is delivered to a session entwurf never bootstrapped",
97
+ "subject": "pi-extensions/lib/acp/backend.ts",
98
+ "find": [
99
+ "\tif ((replacement as { sessionId?: unknown }).sessionId !== params.sessionId) {",
100
+ "\t\tthrow new Error(",
101
+ "\t\t\t\"entwurf: before_provider_request changed the ACP sessionId — entwurf cannot truthfully deliver to a session it did not bootstrap; prompt rewriting is allowed, session identity is not\",",
102
+ "\t\t);",
103
+ "\t}"
104
+ ],
105
+ "replace": ["\t// upstream says use any returned replacement payload verbatim"],
106
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
107
+ "timeoutSeconds": 300,
108
+ "signature": "[QK:ACPHOOK-SESSIONID-TAMPER-REFUSED]",
109
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
110
+ },
111
+ {
112
+ "claim": "ACPHOOK-ABORT-DURING-HOOK-WINS",
113
+ "title": "the post-hook abort recheck is dropped, so an abort raised during a slow handler still sends the prompt to the wire",
114
+ "subject": "pi-extensions/lib/acp/backend.ts",
115
+ "find": [
116
+ "\t\t\tconst wireParams = await applyProviderPayloadHook(options, { sessionId: acpSessionId, prompt }, model);",
117
+ "\t\t\tif (signal?.aborted) throw new Error(\"aborted during payload hook\");"
118
+ ],
119
+ "replace": [
120
+ "\t\t\tconst wireParams = await applyProviderPayloadHook(options, { sessionId: acpSessionId, prompt }, model);"
121
+ ],
122
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
123
+ "timeoutSeconds": 300,
124
+ "signature": "[QK:ACPHOOK-ABORT-DURING-HOOK-WINS]",
125
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
126
+ },
127
+ {
128
+ "claim": "ACPHOOK-REUSE-ABORT-WINS",
129
+ "title": "the REUSE call site's post-hook abort recheck is dropped, so an abort raised during a slow handler still sends a second prompt to the resident session",
130
+ "subject": "pi-extensions/lib/acp/backend.ts",
131
+ "find": [
132
+ "\t\t\tconst wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);",
133
+ "\t\t\tif (signal?.aborted) throw new Error(\"aborted during payload hook\");"
134
+ ],
135
+ "replace": [
136
+ "\t\t\tconst wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);"
137
+ ],
138
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
139
+ "timeoutSeconds": 300,
140
+ "signature": "[QK:ACPHOOK-REUSE-ABORT-WINS]",
141
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
142
+ },
143
+ {
144
+ "claim": "ACPHOOK-ONRESPONSE-NEVER-CALLED",
145
+ "title": "someone 'completes' the hook contract by fabricating an HTTP 200 response on turn success — exactly the false evidence the exemption exists to forbid",
146
+ "subject": "pi-extensions/lib/acp/backend.ts",
147
+ "find": ["\tfunction finishSuccess(promptResult: { stopReason?: string }): void {"],
148
+ "replace": [
149
+ "\tfunction finishSuccess(promptResult: { stopReason?: string }): void {",
150
+ "\t\tvoid options?.onResponse?.({ status: 200, headers: {} }, model);"
151
+ ],
152
+ "gate": ["bash", "run.sh", "check-acp-stream-hooks"],
153
+ "timeoutSeconds": 300,
154
+ "signature": "[QK:ACPHOOK-ONRESPONSE-NEVER-CALLED]",
155
+ "signatureSource": "scripts/check-acp-stream-hooks.ts"
156
+ }
157
+ ]
158
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "bridge-boot-resume",
4
+ "mutants": [
5
+ {
6
+ "claim": "BRIDGEBOOT-RESUME-CALL-REGISTERED",
7
+ "title": "the resume verb is imported but never registered — invisible to every host, while every import-level assertion stays green",
8
+ "subject": "mcp/entwurf-bridge/src/index.ts",
9
+ "find": ["server.tool(\n\t\"entwurf_resume_call\","],
10
+ "replace": [
11
+ "const _unregisteredResumeTool = (...a: unknown[]) => void a;\n_unregisteredResumeTool(\n\t\"entwurf_resume_call\","
12
+ ],
13
+ "gate": ["bash", "run.sh", "check-entwurf-bridge-boot"],
14
+ "timeoutSeconds": 120,
15
+ "signature": "[QK:BRIDGEBOOT-RESUME-CALL-REGISTERED]",
16
+ "signatureSource": "scripts/check-entwurf-bridge-boot.ts"
17
+ },
18
+ {
19
+ "claim": "BRIDGEBOOT-RESUME-CALL-TARGET-ONLY",
20
+ "title": "a model knob is added to the resume schema, giving the caller a second way to decide what a resumed citizen is after the record already decided",
21
+ "subject": "mcp/entwurf-bridge/src/index.ts",
22
+ "find": ["\t\t\t.describe(\"Garden id of the DORMANT pi citizen to reopen (discover with entwurf_peers).\"),"],
23
+ "replace": [
24
+ "\t\t\t.describe(\"Garden id of the DORMANT pi citizen to reopen (discover with entwurf_peers).\"),\n\t\tmodel: z.string().optional().describe(\"Override the recorded model.\"),"
25
+ ],
26
+ "gate": ["bash", "run.sh", "check-entwurf-bridge-boot"],
27
+ "timeoutSeconds": 120,
28
+ "signature": "[QK:BRIDGEBOOT-RESUME-CALL-TARGET-ONLY]",
29
+ "signatureSource": "scripts/check-entwurf-bridge-boot.ts"
30
+ },
31
+ {
32
+ "claim": "BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET",
33
+ "title": "a verb no assertion happens to name (entwurf_fresh_call) is imported but never registered — the public surface silently shrinks while every named check stays green",
34
+ "subject": "mcp/entwurf-bridge/src/index.ts",
35
+ "find": ["server.tool(\n\t\"entwurf_fresh_call\","],
36
+ "replace": [
37
+ "const _unregisteredFreshCallTool = (...a: unknown[]) => void a;\n_unregisteredFreshCallTool(\n\t\"entwurf_fresh_call\","
38
+ ],
39
+ "gate": ["bash", "run.sh", "check-entwurf-bridge-boot"],
40
+ "timeoutSeconds": 120,
41
+ "signature": "[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET]",
42
+ "signatureSource": "scripts/check-entwurf-bridge-boot.ts"
43
+ }
44
+ ]
45
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "meta-facts",
4
+ "mutants": [
5
+ {
6
+ "claim": "MF-EXIT-UNREADABLE",
7
+ "title": "answering an unreadable store with exit 0 makes an unreadable host look like an empty one — the false-success class the exit contract exists to refuse (#65)",
8
+ "subject": "scripts/meta-facts.ts",
9
+ "find": ["\tprocess.exit(3); // see the EXIT CONTRACT above: 3 = unreadable store, never an empty one"],
10
+ "replace": ["\tprocess.exit(0); // see the EXIT CONTRACT above: 3 = unreadable store, never an empty one"],
11
+ "gate": ["bash", "run.sh", "check-meta-facts"],
12
+ "timeoutSeconds": 120,
13
+ "signature": "[QK:MF-EXIT-UNREADABLE]",
14
+ "signatureSource": "scripts/check-meta-facts.ts"
15
+ },
16
+ {
17
+ "claim": "MF-FULL-V3-JOIN",
18
+ "title": "dropping the kept identities from the projection empties the join while still reporting a certified-looking store (#65 wrong fact)",
19
+ "subject": "scripts/meta-facts.ts",
20
+ "find": ["\t\t{ schemaVersion: 1, storeDir: facts.dir, citizens: facts.identities, defects: facts.errors },"],
21
+ "replace": ["\t\t{ schemaVersion: 1, storeDir: facts.dir, citizens: [], defects: facts.errors },"],
22
+ "gate": ["bash", "run.sh", "check-meta-facts"],
23
+ "timeoutSeconds": 120,
24
+ "signature": "[QK:MF-FULL-V3-JOIN]",
25
+ "signatureSource": "scripts/check-meta-facts.ts"
26
+ },
27
+ {
28
+ "claim": "MF-DEFECTS-IN-BAND",
29
+ "title": "dropping the defect list shows a store the certification refuses as clean — the decayed-copy failure mode this surface exists to end (#65 wrong fact)",
30
+ "subject": "scripts/meta-facts.ts",
31
+ "find": ["\t\t{ schemaVersion: 1, storeDir: facts.dir, citizens: facts.identities, defects: facts.errors },"],
32
+ "replace": ["\t\t{ schemaVersion: 1, storeDir: facts.dir, citizens: facts.identities, defects: [] },"],
33
+ "gate": ["bash", "run.sh", "check-meta-facts"],
34
+ "timeoutSeconds": 120,
35
+ "signature": "[QK:MF-DEFECTS-IN-BAND]",
36
+ "signatureSource": "scripts/check-meta-facts.ts"
37
+ },
38
+ {
39
+ "claim": "MF-USAGE",
40
+ "title": "disabling the argv guard makes extra positional args silently read the first as a store dir instead of refusing usage (#65)",
41
+ "subject": "scripts/meta-facts.ts",
42
+ "find": ["if (process.argv.length > 3 || (arg !== undefined && arg.startsWith(\"-\"))) {"],
43
+ "replace": ["if (false) {"],
44
+ "gate": ["bash", "run.sh", "check-meta-facts"],
45
+ "timeoutSeconds": 120,
46
+ "signature": "[QK:MF-USAGE]",
47
+ "signatureSource": "scripts/check-meta-facts.ts"
48
+ }
49
+ ]
50
+ }