@junghanacs/entwurf 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * check-pi-preflight — deterministic gate for 0.11 Stage 0 (2): the controlled-
3
+ * launch trust decision. Pure decision + a real temp agentDir; no backend, no
4
+ * network, no hook. Safe in the `pnpm check` static floor.
5
+ *
6
+ * Synthetic fixture = pi's OWN `ProjectTrustStore` pointed at a temp agentDir:
7
+ * we seed trust with `store.set(cwd, …)` and `preflight()` reads it back through
8
+ * the same store, so the saved-trust path is checked against pi's real function
9
+ * (same process — trivial, frozen ledger). `PI_CODING_AGENT_DIR` / the injected
10
+ * `agentDir` keep this off the operator's real `~/.pi/agent/trust.json`.
11
+ *
12
+ * Proves frozen decision 8 precedence end to end:
13
+ * saved false > saved true > prefix match > no-trust-inputs > fail-fast
14
+ * plus decision 7's separator boundary (`/repos` ≠ `/repos-sibling`), `~`
15
+ * expansion of operator roots, and the rich evidence the fact surface consumes.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { ProjectTrustStore } from "@earendil-works/pi-coding-agent";
23
+ import { formatPreflightDenial, preflight } from "../pi-extensions/lib/entwurf-preflight.ts";
24
+
25
+ let passed = 0;
26
+ function ok(label: string, cond: boolean): void {
27
+ assert.ok(cond, label);
28
+ console.log(` ok ${label}`);
29
+ passed++;
30
+ }
31
+
32
+ const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "psa-preflight-")));
33
+ const agentDir = path.join(tmpRoot, "agent");
34
+ const prefixRoot = path.join(tmpRoot, "repos");
35
+ fs.mkdirSync(agentDir, { recursive: true });
36
+ fs.mkdirSync(prefixRoot, { recursive: true });
37
+
38
+ // Seed a trust-gated project input. We use a `.pi/settings.json` config file,
39
+ // NOT an AGENTS.md: pi 0.79.x dropped AGENTS.md/CLAUDE.md from
40
+ // hasTrustRequiringProjectResources (they are now always-loaded context files,
41
+ // not trust-gated inputs). `.pi` is cwd-scoped, so each fixture is an
42
+ // independent trust input with no walk-to-root bleed; current pi requires one of
43
+ // the trust-requiring entries under `.pi`, not a bare directory.
44
+ function seedTrustInput(dir: string): void {
45
+ const piDir = path.join(dir, ".pi");
46
+ fs.mkdirSync(piDir, { recursive: true });
47
+ fs.writeFileSync(path.join(piDir, "settings.json"), "{}\n");
48
+ }
49
+
50
+ // A real cwd must exist for ProjectTrustStore's realpathSync canonicalization
51
+ // and for hasTrustRequiringProjectResources' filesystem walk.
52
+ function mkCwd(name: string, withTrustInput = false): string {
53
+ const dir = path.join(prefixRoot, name);
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ if (withTrustInput) seedTrustInput(dir);
56
+ return dir;
57
+ }
58
+
59
+ const store = new ProjectTrustStore(agentDir);
60
+
61
+ try {
62
+ // 1. saved false beats a prefix match (explicit distrust wins — the load-
63
+ // bearing half of decision 8). Evidence carries the raw store value.
64
+ const cwd1 = mkCwd("saved-false", true);
65
+ store.set(cwd1, false);
66
+ const d1 = preflight({ cwd: cwd1, agentDir, prefixRoots: [prefixRoot] });
67
+ ok(
68
+ "saved false → deny/saved-false (even under a prefix root)",
69
+ d1.kind === "deny" && d1.reason === "saved-false" && d1.trustStoreDecision === false && d1.launchArgs.length === 0,
70
+ );
71
+ // F5a evidence: a DIRECT decision on the cwd carries entryPath === the cwd
72
+ // (pi-canonical === our normalizePath here, since tmpRoot is a realpath) and
73
+ // is NOT inherited. This is also the entryPath axis assertion — if pi's
74
+ // canonicalizePath diverged from our normalizePath on this cwd, entryPath
75
+ // would not equal cwd1 and this fails loud (NEXT: symlink edge caught here).
76
+ ok(
77
+ "direct saved-false: entryPath === cwd, not inherited",
78
+ d1.trustStoreEntryPath === cwd1 && d1.trustStoreInherited === false,
79
+ );
80
+
81
+ // 2. saved true → approve, launchArgs carries the internal --approve.
82
+ const cwd2 = mkCwd("saved-true", true);
83
+ store.set(cwd2, true);
84
+ const d2 = preflight({ cwd: cwd2, agentDir });
85
+ ok(
86
+ "saved true → approve/saved-true + launchArgs=['--approve']",
87
+ d2.kind === "approve" &&
88
+ d2.reason === "saved-true" &&
89
+ d2.trustStoreDecision === true &&
90
+ d2.launchArgs.length === 1 &&
91
+ d2.launchArgs[0] === "--approve",
92
+ );
93
+
94
+ // 3. undecided + prefix match → approve; evidence names the matched root AND
95
+ // still reports hasTrustInputs (the fact surface explains what it may load).
96
+ const cwd3 = mkCwd("prefix-hit", true); // has a trust input, yet prefix wins over fail-fast
97
+ const d3 = preflight({ cwd: cwd3, agentDir, prefixRoots: [prefixRoot] });
98
+ ok(
99
+ "null + prefix match → approve/prefix-match + matchedPrefixRoot + hasTrustInputs",
100
+ d3.kind === "approve" &&
101
+ d3.reason === "prefix-match" &&
102
+ d3.matchedPrefixRoot === prefixRoot &&
103
+ d3.hasTrustInputs === true &&
104
+ d3.launchArgs[0] === "--approve",
105
+ );
106
+
107
+ // 4. undecided + no trust inputs + no prefix → trusted-no-arg, no launch arg.
108
+ const cwd4 = path.join(tmpRoot, "no-inputs"); // OUTSIDE prefixRoot, no trust input
109
+ fs.mkdirSync(cwd4, { recursive: true });
110
+ const d4 = preflight({ cwd: cwd4, agentDir, prefixRoots: [prefixRoot] });
111
+ ok(
112
+ "null + no trust inputs → trusted-no-arg + launchArgs=[] + hasTrustInputs=false",
113
+ d4.kind === "trusted-no-arg" &&
114
+ d4.reason === "no-trust-inputs" &&
115
+ d4.hasTrustInputs === false &&
116
+ d4.launchArgs.length === 0,
117
+ );
118
+ // F5a evidence: no store decision (neither cwd nor any ancestor) ⇒ no entry
119
+ // path and not inherited. The deny/handler layers must see "nothing decided".
120
+ ok(
121
+ "no decision → entryPath undefined + not inherited",
122
+ d4.trustStoreEntryPath === undefined && d4.trustStoreInherited === false,
123
+ );
124
+
125
+ // 5. undecided + trust inputs + no prefix → fail-fast.
126
+ const cwd5 = path.join(tmpRoot, "lonely-inputs"); // OUTSIDE prefixRoot
127
+ fs.mkdirSync(cwd5, { recursive: true });
128
+ seedTrustInput(cwd5);
129
+ const d5 = preflight({ cwd: cwd5, agentDir, prefixRoots: [prefixRoot] });
130
+ ok(
131
+ "null + trust inputs + no prefix → deny/fail-fast",
132
+ d5.kind === "deny" && d5.reason === "fail-fast" && d5.hasTrustInputs === true && d5.launchArgs.length === 0,
133
+ );
134
+
135
+ // 6. empty roots ⇒ no prefix promotion (frozen decision 7: no package default).
136
+ const cwd6 = mkCwd("would-match-but-no-roots", true);
137
+ const d6 = preflight({ cwd: cwd6, agentDir, prefixRoots: [] });
138
+ ok(
139
+ "undecided + inputs + EMPTY roots → fail-fast (no package-default prefix)",
140
+ d6.kind === "deny" && d6.reason === "fail-fast" && d6.matchedPrefixRoot === undefined,
141
+ );
142
+
143
+ // 7. separator boundary: a sibling sharing a string prefix must NOT match.
144
+ // root=<tmp>/repos, cwd=<tmp>/repos-sibling — bare startsWith would wrongly
145
+ // match; canonical + sep boundary must not.
146
+ const sibling = path.join(tmpRoot, "repos-sibling");
147
+ fs.mkdirSync(sibling, { recursive: true });
148
+ seedTrustInput(sibling);
149
+ const d7 = preflight({ cwd: sibling, agentDir, prefixRoots: [prefixRoot] });
150
+ ok(
151
+ "prefix `/repos` does NOT match sibling `/repos-sibling` (sep boundary)",
152
+ d7.kind === "deny" && d7.reason === "fail-fast" && d7.matchedPrefixRoot === undefined,
153
+ );
154
+
155
+ // 8. the root itself (cwd === root) matches, and canonicalCwd is the realpath.
156
+ const dRoot = preflight({ cwd: prefixRoot, agentDir, prefixRoots: [prefixRoot] });
157
+ ok(
158
+ "cwd === prefix root matches (boundary inclusive) + canonicalCwd is realpath",
159
+ dRoot.kind === "approve" && dRoot.canonicalCwd === prefixRoot,
160
+ );
161
+
162
+ // 9. `~`-relative operator root expands and matches (frozen decision 7 GLG
163
+ // default is `~/repos/gh` shaped). HOME → temp so we never touch the real
164
+ // home; os.homedir() reads $HOME on Linux.
165
+ const origHome = process.env.HOME;
166
+ process.env.HOME = tmpRoot;
167
+ try {
168
+ const tildeProj = path.join(tmpRoot, "repos", "gh", "proj");
169
+ fs.mkdirSync(tildeProj, { recursive: true });
170
+ seedTrustInput(tildeProj);
171
+ const dTilde = preflight({ cwd: tildeProj, agentDir, prefixRoots: ["~/repos"] });
172
+ ok(
173
+ "`~/repos` operator root expands and matches cwd under it",
174
+ dTilde.kind === "approve" && dTilde.reason === "prefix-match" && dTilde.matchedPrefixRoot === prefixRoot,
175
+ );
176
+ } finally {
177
+ if (origHome === undefined) delete process.env.HOME;
178
+ else process.env.HOME = origHome;
179
+ }
180
+
181
+ // 10-12. nearest-ancestor trust inheritance (pi 0.79.x). 0.79.0's store.get
182
+ // matched the cwd EXACTLY; 0.79.x getEntry/findNearestTrustEntry walks
183
+ // up to the closest ancestor carrying an explicit decision. preflight reads
184
+ // pi's store directly (frozen decision 9), so this propagation flows
185
+ // through verbatim — a saved decision on a PARENT now decides a child that
186
+ // has no decision of its own. This is the production half of frozen
187
+ // decision 8: an operator distrust on `~/repos/gh` reaches every repo under
188
+ // it. These assertions FAIL on 0.79.0 (parent set, child get → null) and
189
+ // are intentionally gated behind the 0.79.3 floor (committed with the bump).
190
+
191
+ // 10. inherited distrust beats a prefix match: parent=false, child under both
192
+ // the parent AND a prefix root → the inherited saved-false still denies
193
+ // (saved distrust is the strongest rung; a prefix only promotes null).
194
+ const inheritParent = mkCwd("inherit-deny", true);
195
+ const inheritChild = path.join(inheritParent, "nested", "child");
196
+ fs.mkdirSync(inheritChild, { recursive: true });
197
+ store.set(inheritParent, false);
198
+ const dInheritDeny = preflight({ cwd: inheritChild, agentDir, prefixRoots: [prefixRoot] });
199
+ ok(
200
+ "child inherits parent's saved-false (nearest ancestor) → deny, even under a prefix root",
201
+ dInheritDeny.kind === "deny" && dInheritDeny.reason === "saved-false" && dInheritDeny.trustStoreDecision === false,
202
+ );
203
+ // F5a evidence: an INHERITED decision carries entryPath === the ANCESTOR that
204
+ // holds it (not the cwd) and is flagged inherited. This is the raw material
205
+ // N3b's deny message turns into "inheritedFrom <parent> — open an interactive
206
+ // pi at <cwd> to override".
207
+ ok(
208
+ "inherited saved-false: entryPath === parent (ancestor), inherited === true",
209
+ dInheritDeny.trustStoreEntryPath === inheritParent && dInheritDeny.trustStoreInherited === true,
210
+ );
211
+
212
+ // 11. inherited trust: parent=true, child with no own decision → approve via
213
+ // saved-true (reason is saved-true, NOT prefix-match — the store wins first).
214
+ const trustParent = mkCwd("inherit-approve", true);
215
+ const trustChild = path.join(trustParent, "nested", "child");
216
+ fs.mkdirSync(trustChild, { recursive: true });
217
+ store.set(trustParent, true);
218
+ const dInheritApprove = preflight({ cwd: trustChild, agentDir });
219
+ ok(
220
+ "child inherits parent's saved-true (nearest ancestor) → approve/saved-true + --approve",
221
+ dInheritApprove.kind === "approve" &&
222
+ dInheritApprove.reason === "saved-true" &&
223
+ dInheritApprove.trustStoreDecision === true &&
224
+ dInheritApprove.launchArgs[0] === "--approve",
225
+ );
226
+ ok(
227
+ "inherited saved-true: entryPath === parent (ancestor), inherited === true",
228
+ dInheritApprove.trustStoreEntryPath === trustParent && dInheritApprove.trustStoreInherited === true,
229
+ );
230
+
231
+ // 12. nearest wins: the child's OWN false overrides an ancestor true (the walk
232
+ // stops at the closest decision, it does not keep climbing past it).
233
+ const nearestChild = path.join(trustParent, "distrusted-leaf");
234
+ fs.mkdirSync(nearestChild, { recursive: true });
235
+ store.set(nearestChild, false);
236
+ const dNearest = preflight({ cwd: nearestChild, agentDir });
237
+ ok(
238
+ "child's own saved-false overrides an ancestor saved-true (nearest entry wins)",
239
+ dNearest.kind === "deny" && dNearest.reason === "saved-false" && dNearest.trustStoreDecision === false,
240
+ );
241
+ ok(
242
+ "nearest direct false (over ancestor true): entryPath === cwd, not inherited",
243
+ dNearest.trustStoreEntryPath === nearestChild && dNearest.trustStoreInherited === false,
244
+ );
245
+
246
+ // 13b. ESCAPE DIRECTION (F5a / Trust 2층 active-prompt). The mirror of #10:
247
+ // parent=false, but the child carries its OWN saved-true. nearest-entry
248
+ // wins, so the child approves DESPITE the inherited distrust. This is the
249
+ // exact post-state of the only escape from an inherited false — a human
250
+ // opens an interactive pi at the child cwd, the handler prompts, and yes →
251
+ // `{trusted:"yes", remember:true}` writes a direct child true. Once that
252
+ // entry exists, preflight must approve the child (entryPath === child, not
253
+ // inherited), proving the escape actually releases the launch.
254
+ const escapeParent = mkCwd("escape-parent", true);
255
+ const escapeChild = path.join(escapeParent, "trusted-leaf");
256
+ fs.mkdirSync(escapeChild, { recursive: true });
257
+ store.set(escapeParent, false);
258
+ store.set(escapeChild, true);
259
+ const dEscape = preflight({ cwd: escapeChild, agentDir });
260
+ ok(
261
+ "child's own saved-true beats an ancestor saved-false (escape direction) → approve",
262
+ dEscape.kind === "approve" &&
263
+ dEscape.reason === "saved-true" &&
264
+ dEscape.trustStoreEntryPath === escapeChild &&
265
+ dEscape.trustStoreInherited === false &&
266
+ dEscape.launchArgs[0] === "--approve",
267
+ );
268
+
269
+ // N3b. deny-message formatter — a PURE formatter over deny evidence (no
270
+ // launcher wiring; that is bucket B). The inherited-false message MUST
271
+ // name the inheritedFrom source AND the active-prompt remedy; the other
272
+ // two deny shapes must not leak an ancestor they don't have.
273
+ if (dInheritDeny.kind === "deny") {
274
+ const msg = formatPreflightDenial(dInheritDeny);
275
+ ok(
276
+ "N3b: inherited-false deny names inheritedFrom (ancestor) + interactive-pi remedy + cwd",
277
+ msg.includes(inheritParent) && msg.includes("interactive pi") && msg.includes(inheritChild),
278
+ );
279
+ }
280
+ if (d1.kind === "deny") {
281
+ const msg = formatPreflightDenial(d1);
282
+ // Direct distrust: explains "explicitly distrusted" + remedy, and does NOT
283
+ // invent an ancestor (cwd1 has no inherited source).
284
+ ok(
285
+ "N3b: direct-false deny says explicitly distrusted + remedy, no phantom ancestor",
286
+ msg.includes("explicitly distrusted") && msg.includes("interactive pi") && msg.includes(cwd1),
287
+ );
288
+ }
289
+ if (d5.kind === "deny") {
290
+ const msg = formatPreflightDenial(d5);
291
+ ok(
292
+ "N3b: fail-fast deny says untrusted + prefix-root-or-interactive remedy",
293
+ msg.includes("untrusted") && msg.includes("prefix") && msg.includes("interactive pi"),
294
+ );
295
+ }
296
+
297
+ // 13. isolation: every set above landed in the temp store; the real operator
298
+ // trust.json was never opened.
299
+ ok("temp agentDir holds the trust file (real ~/.pi untouched)", fs.existsSync(path.join(agentDir, "trust.json")));
300
+ } finally {
301
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
302
+ }
303
+
304
+ console.log(`[check-pi-preflight] ${passed} assertions ok`);
@@ -0,0 +1,265 @@
1
+ /**
2
+ * check-project-trust-handler — deterministic gate for 0.11 Stage 0 (Trust 2층
3
+ * active-prompt escape). Drives the pure `decideProjectTrust` matrix with a fake
4
+ * prompt and the `createProjectTrustHandler` adapter with a fake `ctx.ui.select`,
5
+ * so NO real pi UI opens. Real outcomes come from `preflight` over a temp
6
+ * agentDir (same isolation as check-pi-preflight). No backend, no network.
7
+ *
8
+ * Proves the GLG 6 review points:
9
+ * ① single writer — the handler returns `{yes, remember:true}` but NEVER calls
10
+ * store.set; the end-to-end case asserts trust.json gains no child key.
11
+ * ② no undefined — every branch returns a concrete `{trusted, …}`.
12
+ * ③ non-interactive (print / rpc) never prompts → undecided.
13
+ * ④ ctx.ui injection — fake prompt drives the matrix; the escape prompt title
14
+ * reuses F5a evidence (names the inherited source).
15
+ * ⑤ inherited distrust escape: interactive + "trust-here" → `{yes, remember:true}`.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { ProjectTrustStore } from "@earendil-works/pi-coding-agent";
23
+ import { preflight } from "../pi-extensions/lib/entwurf-preflight.ts";
24
+ import {
25
+ type ActivePrompt,
26
+ type ActivePromptChoice,
27
+ createProjectTrustHandler,
28
+ decideProjectTrust,
29
+ KEEP_DISTRUSTED_LABEL,
30
+ TRUST_HERE_LABEL,
31
+ } from "../pi-extensions/lib/project-trust-handler.ts";
32
+
33
+ let passed = 0;
34
+ function ok(label: string, cond: boolean): void {
35
+ assert.ok(cond, label);
36
+ console.log(` ok ${label}`);
37
+ passed++;
38
+ }
39
+
40
+ function fakePrompt(answer: ActivePromptChoice): { prompt: ActivePrompt; calls: () => number } {
41
+ let calls = 0;
42
+ const prompt: ActivePrompt = async () => {
43
+ calls += 1;
44
+ return answer;
45
+ };
46
+ return { prompt, calls: () => calls };
47
+ }
48
+
49
+ const TUI = { hasUI: true, mode: "tui" as const };
50
+ const PRINT = { hasUI: false, mode: "print" as const };
51
+ const RPC = { hasUI: true, mode: "rpc" as const }; // hasUI but not a human at a TUI
52
+
53
+ const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "psa-pth-")));
54
+ const agentDir = path.join(tmpRoot, "agent");
55
+ const reposRoot = path.join(tmpRoot, "repos");
56
+ fs.mkdirSync(agentDir, { recursive: true });
57
+ fs.mkdirSync(reposRoot, { recursive: true });
58
+ const store = new ProjectTrustStore(agentDir);
59
+
60
+ function mkdir(p: string): string {
61
+ fs.mkdirSync(p, { recursive: true });
62
+ return p;
63
+ }
64
+ function seedTrustInput(dir: string): string {
65
+ const piDir = path.join(dir, ".pi");
66
+ fs.mkdirSync(piDir, { recursive: true });
67
+ fs.writeFileSync(path.join(piDir, "settings.json"), "{}\n");
68
+ return dir;
69
+ }
70
+
71
+ try {
72
+ // Build REAL outcomes via preflight (exact evidence shape), then decide.
73
+ // approve (saved-true)
74
+ const cwdApprove = seedTrustInput(mkdir(path.join(reposRoot, "approve")));
75
+ store.set(cwdApprove, true);
76
+ const oApprove = preflight({ cwd: cwdApprove, agentDir });
77
+ // trusted-no-arg (no trust input)
78
+ const cwdNoArg = mkdir(path.join(reposRoot, "no-inputs"));
79
+ const oNoArg = preflight({ cwd: cwdNoArg, agentDir });
80
+ // direct distrust
81
+ const cwdDirect = seedTrustInput(mkdir(path.join(reposRoot, "direct-false")));
82
+ store.set(cwdDirect, false);
83
+ const oDirect = preflight({ cwd: cwdDirect, agentDir });
84
+ // inherited distrust (parent false, child has its OWN trust input but no direct
85
+ // decision). The child trust input is load-bearing for THIS gate: pi's
86
+ // resolveProjectTrusted returns true early when !hasTrustRequiringProjectResources(cwd), so
87
+ // WITHOUT a child trust input the project_trust event never fires and the escape
88
+ // prompt is unreachable in the real pi path. With it, the event fires and the
89
+ // inherited parent false is exactly what the escape overrides (GPT review).
90
+ const inheritParent = seedTrustInput(mkdir(path.join(reposRoot, "inherit")));
91
+ store.set(inheritParent, false);
92
+ const inheritChild = seedTrustInput(mkdir(path.join(inheritParent, "nested", "child")));
93
+ const oInherit = preflight({ cwd: inheritChild, agentDir });
94
+ // fail-fast (trust input, no prefix root)
95
+ const cwdFailFast = seedTrustInput(mkdir(path.join(reposRoot, "fail-fast")));
96
+ const oFailFast = preflight({ cwd: cwdFailFast, agentDir });
97
+
98
+ // Sanity: outcomes are the shapes we expect to decide over.
99
+ ok(
100
+ "setup: outcome kinds as expected",
101
+ oApprove.kind === "approve" &&
102
+ oNoArg.kind === "trusted-no-arg" &&
103
+ oDirect.kind === "deny" &&
104
+ oInherit.kind === "deny" &&
105
+ oFailFast.kind === "deny",
106
+ );
107
+ ok(
108
+ "setup: inherited fixture is event-reachable (parent false + child trust input)",
109
+ oInherit.kind === "deny" &&
110
+ oInherit.reason === "saved-false" &&
111
+ oInherit.trustStoreInherited === true &&
112
+ oInherit.hasTrustInputs === true && // pi fires project_trust only when this is true
113
+ oDirect.kind === "deny" &&
114
+ oDirect.trustStoreInherited === false,
115
+ );
116
+
117
+ // ── decideProjectTrust matrix ──────────────────────────────────────────
118
+ {
119
+ const fp = fakePrompt("trust-here");
120
+ const r = await decideProjectTrust(oApprove, TUI, fp.prompt);
121
+ ok("approve → {yes, remember:false}, no prompt", r.trusted === "yes" && r.remember === false && fp.calls() === 0);
122
+ }
123
+ {
124
+ const fp = fakePrompt("trust-here");
125
+ const r = await decideProjectTrust(oNoArg, TUI, fp.prompt);
126
+ ok(
127
+ "trusted-no-arg → {yes, remember:false}, no prompt",
128
+ r.trusted === "yes" && r.remember === false && fp.calls() === 0,
129
+ );
130
+ }
131
+ {
132
+ const fp = fakePrompt("trust-here");
133
+ const r = await decideProjectTrust(oDirect, TUI, fp.prompt);
134
+ ok(
135
+ "direct distrust → {no, remember:false}, no prompt (already stored)",
136
+ r.trusted === "no" && r.remember === false && fp.calls() === 0,
137
+ );
138
+ }
139
+ {
140
+ // ⑤ THE ESCAPE: inherited false + interactive + trust-here → {yes, remember:true}
141
+ const fp = fakePrompt("trust-here");
142
+ const r = await decideProjectTrust(oInherit, TUI, fp.prompt);
143
+ ok(
144
+ "inherited false + interactive + trust-here → {yes, remember:true} (escape)",
145
+ r.trusted === "yes" && r.remember === true && fp.calls() === 1,
146
+ );
147
+ }
148
+ {
149
+ const fp = fakePrompt("no");
150
+ const r = await decideProjectTrust(oInherit, TUI, fp.prompt);
151
+ ok(
152
+ "inherited false + interactive + no → {no, remember:false} (R3a: no child false)",
153
+ r.trusted === "no" && r.remember === false && fp.calls() === 1,
154
+ );
155
+ }
156
+ {
157
+ const fp = fakePrompt("cancel");
158
+ const r = await decideProjectTrust(oInherit, TUI, fp.prompt);
159
+ ok(
160
+ "inherited false + interactive + cancel/ESC → {undecided} (defer = safe deny)",
161
+ r.trusted === "undecided" && fp.calls() === 1,
162
+ );
163
+ }
164
+ {
165
+ // ③ non-interactive: pi -p (print) never prompts
166
+ const fp = fakePrompt("trust-here");
167
+ const r = await decideProjectTrust(oInherit, PRINT, fp.prompt);
168
+ ok("inherited false + print (pi -p) → {undecided}, NO prompt", r.trusted === "undecided" && fp.calls() === 0);
169
+ }
170
+ {
171
+ // ③ hasUI but not a TUI (rpc) — still no active prompt
172
+ const fp = fakePrompt("trust-here");
173
+ const r = await decideProjectTrust(oInherit, RPC, fp.prompt);
174
+ ok(
175
+ "inherited false + rpc (hasUI, not tui) → {undecided}, NO prompt",
176
+ r.trusted === "undecided" && fp.calls() === 0,
177
+ );
178
+ }
179
+ {
180
+ const fp = fakePrompt("trust-here");
181
+ const r = await decideProjectTrust(oFailFast, TUI, fp.prompt);
182
+ ok("fail-fast → {undecided}, no active prompt (defer to pi's own)", r.trusted === "undecided" && fp.calls() === 0);
183
+ }
184
+
185
+ // ② no undefined: every outcome×ctx returns a concrete trusted field.
186
+ {
187
+ const all = [oApprove, oNoArg, oDirect, oInherit, oFailFast];
188
+ const ctxs = [TUI, PRINT, RPC];
189
+ let everReturned = 0;
190
+ let everUndefined = 0;
191
+ for (const o of all) {
192
+ for (const c of ctxs) {
193
+ const r = await decideProjectTrust(o, c, fakePrompt("cancel").prompt);
194
+ everReturned += 1;
195
+ if (r.trusted === undefined) everUndefined += 1;
196
+ }
197
+ }
198
+ ok("② every branch returns a concrete result (never undefined)", everReturned === 15 && everUndefined === 0);
199
+ }
200
+
201
+ // ── adapter end-to-end (① single writer + ④ ctx.ui injection) ───────────
202
+ {
203
+ let selectTitle = "";
204
+ const handler = createProjectTrustHandler({ prefixRoots: [], agentDir });
205
+ const fakeCtx = {
206
+ cwd: inheritChild,
207
+ mode: "tui" as const,
208
+ hasUI: true,
209
+ ui: {
210
+ select: async (title: string, _options: string[]) => {
211
+ selectTitle = title;
212
+ return TRUST_HERE_LABEL;
213
+ },
214
+ confirm: async () => false,
215
+ input: async () => undefined,
216
+ notify: () => {},
217
+ },
218
+ };
219
+ const r = await handler({ type: "project_trust", cwd: inheritChild }, fakeCtx);
220
+ ok(
221
+ "adapter: inherited child + select(trust-here) → {yes, remember:true}",
222
+ r.trusted === "yes" && r.remember === true,
223
+ );
224
+ ok(
225
+ "④ adapter prompt title reuses F5a evidence (names inherited source)",
226
+ selectTitle.includes(inheritParent) && selectTitle.includes("interactive pi"),
227
+ );
228
+ // ① single writer: the handler did NOT persist. Only pi's resolveProjectTrusted
229
+ // would, on remember:true. So trust.json still has parent=false and NO child key.
230
+ const fresh = new ProjectTrustStore(agentDir);
231
+ ok(
232
+ "① handler never wrote the child key (pi is the single writer)",
233
+ fresh.getEntry(inheritChild)?.path === inheritParent,
234
+ );
235
+ }
236
+
237
+ // label sanity: the no-label maps to "no", an unknown label maps to cancel.
238
+ {
239
+ let selected = "";
240
+ const handler = createProjectTrustHandler({ prefixRoots: [], agentDir });
241
+ const ctx = {
242
+ cwd: inheritChild,
243
+ mode: "tui" as const,
244
+ hasUI: true,
245
+ ui: {
246
+ select: async () => {
247
+ selected = KEEP_DISTRUSTED_LABEL;
248
+ return KEEP_DISTRUSTED_LABEL;
249
+ },
250
+ confirm: async () => false,
251
+ input: async () => undefined,
252
+ notify: () => {},
253
+ },
254
+ };
255
+ const r = await handler({ type: "project_trust", cwd: inheritChild }, ctx);
256
+ ok(
257
+ "adapter: select(keep-distrusted) → {no, remember:false}",
258
+ r.trusted === "no" && r.remember === false && selected === KEEP_DISTRUSTED_LABEL,
259
+ );
260
+ }
261
+ } finally {
262
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
263
+ }
264
+
265
+ console.log(`[check-project-trust-handler] ${passed} assertions ok`);