ai-collab-open-system 0.1.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 (259) hide show
  1. package/.aict/START_HERE.md +127 -0
  2. package/.aict/WORKSPACE_MANIFEST.json +91 -0
  3. package/.aict/acceptance/EXAMPLE.synthetic.md +49 -0
  4. package/.aict/acceptance/FAILURE_MODES.md +40 -0
  5. package/.aict/acceptance/PROMPT.md +47 -0
  6. package/.aict/acceptance/README.md +44 -0
  7. package/.aict/acceptance/TEMPLATE.md +57 -0
  8. package/.aict/adapters/SHARED_CORE_CONTRACT.md +106 -0
  9. package/.aict/adapters/claude-code/ADAPTER.md +28 -0
  10. package/.aict/adapters/cline/ADAPTER.md +28 -0
  11. package/.aict/adapters/codex/ADAPTER.md +28 -0
  12. package/.aict/adapters/copilot/ADAPTER.md +28 -0
  13. package/.aict/adapters/cursor/ADAPTER.md +28 -0
  14. package/.aict/adapters/windsurf/ADAPTER.md +28 -0
  15. package/.aict/context/EXAMPLE.synthetic.md +53 -0
  16. package/.aict/context/FAILURE_MODES.md +40 -0
  17. package/.aict/context/PROMPT.md +47 -0
  18. package/.aict/context/README.md +44 -0
  19. package/.aict/context/TEMPLATE.md +63 -0
  20. package/.aict/cookbook/README.md +8 -0
  21. package/.aict/cookbook/bridge-to-a-second-family.md +103 -0
  22. package/.aict/cookbook/connect-a-tool.md +67 -0
  23. package/.aict/cookbook/review-a-half-product.md +79 -0
  24. package/.aict/cookbook/run-a-first-loop.md +81 -0
  25. package/.aict/examples/README.md +21 -0
  26. package/.aict/examples/ai-coding-long-task/CASE.md +161 -0
  27. package/.aict/examples/ai-coding-long-task/artifacts/acceptance-card.md +36 -0
  28. package/.aict/examples/ai-coding-long-task/artifacts/context-package.md +30 -0
  29. package/.aict/examples/ai-coding-long-task/artifacts/execution-prompt.md +30 -0
  30. package/.aict/examples/ai-coding-long-task/artifacts/first-ai-output.md +109 -0
  31. package/.aict/examples/ai-coding-long-task/artifacts/guard-review.md +40 -0
  32. package/.aict/examples/ai-coding-long-task/artifacts/handoff-note.md +28 -0
  33. package/.aict/examples/ai-coding-long-task/artifacts/harvest-seed.md +28 -0
  34. package/.aict/examples/ai-coding-long-task/artifacts/revised-output.md +62 -0
  35. package/.aict/examples/content-production-harvest/CASE.md +87 -0
  36. package/.aict/examples/content-production-harvest/artifacts/acceptance-card.md +28 -0
  37. package/.aict/examples/content-production-harvest/artifacts/context-package.md +28 -0
  38. package/.aict/examples/content-production-harvest/artifacts/execution-prompt.md +30 -0
  39. package/.aict/examples/content-production-harvest/artifacts/guard-review.md +28 -0
  40. package/.aict/examples/content-production-harvest/artifacts/handoff-note.md +28 -0
  41. package/.aict/examples/content-production-harvest/artifacts/harvest-seed.md +28 -0
  42. package/.aict/examples/multi-tool-collaboration/CASE.md +87 -0
  43. package/.aict/examples/multi-tool-collaboration/artifacts/acceptance-card.md +28 -0
  44. package/.aict/examples/multi-tool-collaboration/artifacts/context-package.md +28 -0
  45. package/.aict/examples/multi-tool-collaboration/artifacts/execution-prompt.md +30 -0
  46. package/.aict/examples/multi-tool-collaboration/artifacts/guard-review.md +28 -0
  47. package/.aict/examples/multi-tool-collaboration/artifacts/handoff-note.md +28 -0
  48. package/.aict/examples/multi-tool-collaboration/artifacts/harvest-seed.md +28 -0
  49. package/.aict/examples/personal-judgment-growth-assistant/CASE.md +87 -0
  50. package/.aict/examples/personal-judgment-growth-assistant/artifacts/acceptance-card.md +28 -0
  51. package/.aict/examples/personal-judgment-growth-assistant/artifacts/context-package.md +28 -0
  52. package/.aict/examples/personal-judgment-growth-assistant/artifacts/execution-prompt.md +30 -0
  53. package/.aict/examples/personal-judgment-growth-assistant/artifacts/guard-review.md +28 -0
  54. package/.aict/examples/personal-judgment-growth-assistant/artifacts/handoff-note.md +28 -0
  55. package/.aict/examples/personal-judgment-growth-assistant/artifacts/harvest-seed.md +28 -0
  56. package/.aict/examples/research-knowledge-synthesis/CASE.md +87 -0
  57. package/.aict/examples/research-knowledge-synthesis/artifacts/acceptance-card.md +28 -0
  58. package/.aict/examples/research-knowledge-synthesis/artifacts/context-package.md +28 -0
  59. package/.aict/examples/research-knowledge-synthesis/artifacts/execution-prompt.md +30 -0
  60. package/.aict/examples/research-knowledge-synthesis/artifacts/guard-review.md +28 -0
  61. package/.aict/examples/research-knowledge-synthesis/artifacts/handoff-note.md +28 -0
  62. package/.aict/examples/research-knowledge-synthesis/artifacts/harvest-seed.md +28 -0
  63. package/.aict/guard/EXAMPLE.synthetic.md +51 -0
  64. package/.aict/guard/FAILURE_MODES.md +40 -0
  65. package/.aict/guard/PROMPT.md +47 -0
  66. package/.aict/guard/README.md +44 -0
  67. package/.aict/guard/TEMPLATE.md +60 -0
  68. package/.aict/handoff/EXAMPLE.synthetic.md +51 -0
  69. package/.aict/handoff/FAILURE_MODES.md +40 -0
  70. package/.aict/handoff/PROMPT.md +47 -0
  71. package/.aict/handoff/README.md +44 -0
  72. package/.aict/handoff/TEMPLATE.md +60 -0
  73. package/.aict/harvest/EXAMPLE.synthetic.md +51 -0
  74. package/.aict/harvest/FAILURE_MODES.md +40 -0
  75. package/.aict/harvest/PROMPT.md +47 -0
  76. package/.aict/harvest/README.md +44 -0
  77. package/.aict/harvest/TEMPLATE.md +60 -0
  78. package/.aict/mechanisms/README.md +34 -0
  79. package/.aict/mechanisms/anti-drift-partner/EXAMPLE.synthetic.md +46 -0
  80. package/.aict/mechanisms/anti-drift-partner/FAILURE_MODES.md +25 -0
  81. package/.aict/mechanisms/anti-drift-partner/PROMPT.md +75 -0
  82. package/.aict/mechanisms/anti-drift-partner/README.md +82 -0
  83. package/.aict/mechanisms/anti-drift-partner/TEMPLATE.md +74 -0
  84. package/.aict/mechanisms/blind-spot-scan/EXAMPLE.synthetic.md +39 -0
  85. package/.aict/mechanisms/blind-spot-scan/FAILURE_MODES.md +25 -0
  86. package/.aict/mechanisms/blind-spot-scan/PROMPT.md +72 -0
  87. package/.aict/mechanisms/blind-spot-scan/README.md +79 -0
  88. package/.aict/mechanisms/blind-spot-scan/TEMPLATE.md +70 -0
  89. package/.aict/mechanisms/collaboration-coach/EXAMPLE.synthetic.md +40 -0
  90. package/.aict/mechanisms/collaboration-coach/FAILURE_MODES.md +25 -0
  91. package/.aict/mechanisms/collaboration-coach/PROMPT.md +72 -0
  92. package/.aict/mechanisms/collaboration-coach/README.md +79 -0
  93. package/.aict/mechanisms/collaboration-coach/TEMPLATE.md +61 -0
  94. package/.aict/mechanisms/do-not-handle-yet/EXAMPLE.synthetic.md +15 -0
  95. package/.aict/mechanisms/do-not-handle-yet/FAILURE_MODES.md +16 -0
  96. package/.aict/mechanisms/do-not-handle-yet/PROMPT.md +41 -0
  97. package/.aict/mechanisms/do-not-handle-yet/README.md +30 -0
  98. package/.aict/mechanisms/do-not-handle-yet/TEMPLATE.md +38 -0
  99. package/.aict/mechanisms/dual-guard/EXAMPLE.synthetic.md +54 -0
  100. package/.aict/mechanisms/dual-guard/FAILURE_MODES.md +25 -0
  101. package/.aict/mechanisms/dual-guard/PROMPT.md +76 -0
  102. package/.aict/mechanisms/dual-guard/README.md +81 -0
  103. package/.aict/mechanisms/dual-guard/TEMPLATE.md +73 -0
  104. package/.aict/mechanisms/feedback-absorption-ledger/EXAMPLE.synthetic.md +49 -0
  105. package/.aict/mechanisms/feedback-absorption-ledger/FAILURE_MODES.md +25 -0
  106. package/.aict/mechanisms/feedback-absorption-ledger/PROMPT.md +74 -0
  107. package/.aict/mechanisms/feedback-absorption-ledger/README.md +81 -0
  108. package/.aict/mechanisms/feedback-absorption-ledger/TEMPLATE.md +69 -0
  109. package/.aict/mechanisms/half-product-review/EXAMPLE.synthetic.md +15 -0
  110. package/.aict/mechanisms/half-product-review/FAILURE_MODES.md +16 -0
  111. package/.aict/mechanisms/half-product-review/PROMPT.md +41 -0
  112. package/.aict/mechanisms/half-product-review/README.md +30 -0
  113. package/.aict/mechanisms/half-product-review/TEMPLATE.md +38 -0
  114. package/.aict/mechanisms/handoff-abc/EXAMPLE.synthetic.md +47 -0
  115. package/.aict/mechanisms/handoff-abc/FAILURE_MODES.md +25 -0
  116. package/.aict/mechanisms/handoff-abc/PROMPT.md +75 -0
  117. package/.aict/mechanisms/handoff-abc/README.md +82 -0
  118. package/.aict/mechanisms/handoff-abc/TEMPLATE.md +60 -0
  119. package/.aict/mechanisms/harvest-and-erc/EXAMPLE.synthetic.md +43 -0
  120. package/.aict/mechanisms/harvest-and-erc/FAILURE_MODES.md +25 -0
  121. package/.aict/mechanisms/harvest-and-erc/PROMPT.md +74 -0
  122. package/.aict/mechanisms/harvest-and-erc/README.md +81 -0
  123. package/.aict/mechanisms/harvest-and-erc/TEMPLATE.md +60 -0
  124. package/.aict/mechanisms/honest-calibration/EXAMPLE.synthetic.md +43 -0
  125. package/.aict/mechanisms/honest-calibration/FAILURE_MODES.md +25 -0
  126. package/.aict/mechanisms/honest-calibration/PROMPT.md +74 -0
  127. package/.aict/mechanisms/honest-calibration/README.md +81 -0
  128. package/.aict/mechanisms/honest-calibration/TEMPLATE.md +66 -0
  129. package/.aict/mechanisms/one-click-dispatch/EXAMPLE.synthetic.md +15 -0
  130. package/.aict/mechanisms/one-click-dispatch/FAILURE_MODES.md +16 -0
  131. package/.aict/mechanisms/one-click-dispatch/PROMPT.md +41 -0
  132. package/.aict/mechanisms/one-click-dispatch/README.md +30 -0
  133. package/.aict/mechanisms/one-click-dispatch/TEMPLATE.md +38 -0
  134. package/.aict/mechanisms/plain-language-first-screen/EXAMPLE.synthetic.md +15 -0
  135. package/.aict/mechanisms/plain-language-first-screen/FAILURE_MODES.md +16 -0
  136. package/.aict/mechanisms/plain-language-first-screen/PROMPT.md +41 -0
  137. package/.aict/mechanisms/plain-language-first-screen/README.md +30 -0
  138. package/.aict/mechanisms/plain-language-first-screen/TEMPLATE.md +38 -0
  139. package/.aict/mechanisms/root-cause-brake/EXAMPLE.synthetic.md +55 -0
  140. package/.aict/mechanisms/root-cause-brake/FAILURE_MODES.md +25 -0
  141. package/.aict/mechanisms/root-cause-brake/PROMPT.md +73 -0
  142. package/.aict/mechanisms/root-cause-brake/README.md +79 -0
  143. package/.aict/mechanisms/root-cause-brake/TEMPLATE.md +74 -0
  144. package/.aict/mechanisms/scout-review-controller/EXAMPLE.synthetic.md +15 -0
  145. package/.aict/mechanisms/scout-review-controller/FAILURE_MODES.md +16 -0
  146. package/.aict/mechanisms/scout-review-controller/PROMPT.md +41 -0
  147. package/.aict/mechanisms/scout-review-controller/README.md +30 -0
  148. package/.aict/mechanisms/scout-review-controller/TEMPLATE.md +38 -0
  149. package/.aict/mechanisms/single-tool-guard/EXAMPLE.synthetic.md +54 -0
  150. package/.aict/mechanisms/single-tool-guard/FAILURE_MODES.md +25 -0
  151. package/.aict/mechanisms/single-tool-guard/PROMPT.md +76 -0
  152. package/.aict/mechanisms/single-tool-guard/README.md +83 -0
  153. package/.aict/mechanisms/single-tool-guard/TEMPLATE.md +75 -0
  154. package/.aict/mechanisms/task-splitting/EXAMPLE.synthetic.md +53 -0
  155. package/.aict/mechanisms/task-splitting/FAILURE_MODES.md +25 -0
  156. package/.aict/mechanisms/task-splitting/PROMPT.md +72 -0
  157. package/.aict/mechanisms/task-splitting/README.md +79 -0
  158. package/.aict/mechanisms/task-splitting/TEMPLATE.md +76 -0
  159. package/.aict/modes/README.md +11 -0
  160. package/.aict/modes/execute.md +31 -0
  161. package/.aict/modes/handoff.md +29 -0
  162. package/.aict/modes/harvest.md +30 -0
  163. package/.aict/modes/review.md +28 -0
  164. package/.aict/modes/shape.md +34 -0
  165. package/.aict/privacy/COMMERCIAL_BOUNDARY.md +34 -0
  166. package/.aict/privacy/PRIVACY.md +36 -0
  167. package/.aict/privacy/REDACTION_CHECKLIST.md +12 -0
  168. package/.aict/profile/CANDIDATES.md +44 -0
  169. package/.aict/profile/EXAMPLE.synthetic.md +49 -0
  170. package/.aict/profile/FAILURE_MODES.md +40 -0
  171. package/.aict/profile/PROMPT.md +47 -0
  172. package/.aict/profile/README.md +44 -0
  173. package/.aict/profile/TEMPLATE.md +57 -0
  174. package/.aict/prompts/acceptance-definition.md +109 -0
  175. package/.aict/prompts/guard-review.md +116 -0
  176. package/.aict/prompts/handoff-generation.md +110 -0
  177. package/.aict/prompts/harvest-extraction.md +110 -0
  178. package/.aict/prompts/mode-switching.md +66 -0
  179. package/.aict/prompts/profile-creation.md +66 -0
  180. package/.aict/prompts/profile-refinement.md +66 -0
  181. package/.aict/prompts/project-context-packaging.md +113 -0
  182. package/.aict/prompts/red-team-challenge.md +106 -0
  183. package/.aict/prompts/rule-update-proposal.md +114 -0
  184. package/.aict/prompts/workflow-reset.md +109 -0
  185. package/.aict/roles/README.md +18 -0
  186. package/.aict/roles/executor.md +34 -0
  187. package/.aict/roles/harvester.md +33 -0
  188. package/.aict/roles/owner-controller.md +38 -0
  189. package/.aict/roles/scout.md +33 -0
  190. package/.aict/roles/supervisor.md +34 -0
  191. package/.aict/roles/system-guardian.md +34 -0
  192. package/.aict/skills/acceptance/SKILL.md +43 -0
  193. package/.aict/skills/context/SKILL.md +44 -0
  194. package/.aict/skills/evidence-pack/SKILL.md +42 -0
  195. package/.aict/skills/guard/SKILL.md +46 -0
  196. package/.aict/skills/handoff/SKILL.md +44 -0
  197. package/.aict/skills/harvest/SKILL.md +44 -0
  198. package/.aict/skills/mode-switch/SKILL.md +42 -0
  199. package/.aict/skills/profile/SKILL.md +42 -0
  200. package/.aict/skills/red-team/SKILL.md +42 -0
  201. package/.aict/skills/single-tool-guard/SKILL.md +42 -0
  202. package/.aict/state/CURRENT_STATE.md +13 -0
  203. package/.aict/state/DECISIONS.md +7 -0
  204. package/.aict/state/TASK_LOG.md +7 -0
  205. package/.aict/state/evidence.jsonl +2 -0
  206. package/.aict/state/learning-ledger.jsonl +1 -0
  207. package/.aict/state/receipts.jsonl +1 -0
  208. package/.aict/state/runs.jsonl +1 -0
  209. package/.aict/state/tasks.jsonl +1 -0
  210. package/.aict/walkthroughs/10-minute-your-task.md +107 -0
  211. package/.aict/walkthroughs/10-minute.md +43 -0
  212. package/.aict/walkthroughs/30-minute.md +22 -0
  213. package/.aict/walkthroughs/60-minute.md +27 -0
  214. package/.aict/walkthroughs/synthetic-loop-transcript.md +43 -0
  215. package/CHANGELOG.md +23 -0
  216. package/CODE_OF_CONDUCT.md +20 -0
  217. package/CONTRIBUTING.md +30 -0
  218. package/KNOWN_LIMITATIONS.md +54 -0
  219. package/LICENSE +199 -0
  220. package/PRODUCT_CONTRACT.md +446 -0
  221. package/README.md +245 -0
  222. package/RELEASE_CHECKLIST.md +78 -0
  223. package/SECURITY.md +56 -0
  224. package/START_HERE.md +89 -0
  225. package/bin/ai-collab.js +2 -0
  226. package/docs/DOGFOOD.md +85 -0
  227. package/docs/FEEDBACK.md +61 -0
  228. package/docs/FIRST_EXPERIENCE_SPEC.md +32 -0
  229. package/docs/FREE_VS_PAID.md +53 -0
  230. package/docs/PUBLIC_BOUNDARY.md +36 -0
  231. package/docs/PUBLIC_MAPPING.md +178 -0
  232. package/docs/RELEASE_PRIORITY.md +23 -0
  233. package/docs/WHY_THIS_EXISTS.md +36 -0
  234. package/docs/open-system/00-start-here.md +60 -0
  235. package/docs/open-system/01-ai-collaboration-os.md +33 -0
  236. package/docs/open-system/02-six-layer-architecture.md +45 -0
  237. package/docs/open-system/03-role-system.md +33 -0
  238. package/docs/open-system/04-core-mechanisms.md +34 -0
  239. package/docs/open-system/05-failure-patterns.md +31 -0
  240. package/docs/open-system/06-how-to-adapt-to-your-workflow.md +31 -0
  241. package/package.json +69 -0
  242. package/privacy-manifest.json +78 -0
  243. package/privacy-scan.local.json.example +18 -0
  244. package/scripts/lib/forbidden-in-pack.js +55 -0
  245. package/scripts/pack-check.js +154 -0
  246. package/scripts/privacy-scan.js +487 -0
  247. package/scripts/validate-contract.js +160 -0
  248. package/src/adapters.js +590 -0
  249. package/src/bootstrap.js +1184 -0
  250. package/src/catalog.js +2723 -0
  251. package/src/cli.js +2899 -0
  252. package/src/dialogue.js +470 -0
  253. package/src/i18n.js +1034 -0
  254. package/src/ledger.js +2011 -0
  255. package/src/render.js +1381 -0
  256. package/src/sendmodel.js +452 -0
  257. package/src/validate.js +1307 -0
  258. package/src/workspace.js +1679 -0
  259. package/tests/contract.test.js +8514 -0
@@ -0,0 +1,452 @@
1
+ // === semantic scan v1, EXTERNAL-MODEL HALF (opt-in, consent-gated, redacted) ==
2
+ //
3
+ // This module is the "external-model half" of semantic scanning — the ONLY feature
4
+ // in the whole system that can send a user's data to a model OUTSIDE this machine.
5
+ // Everything else (bootstrap, the dialogue LOCAL half) is deterministic + zero-network.
6
+ // Because this is the single privacy boundary, HONESTY IS HELD BY DESIGN here, not by
7
+ // good intentions: the default is "never send", and the path that DOES send is forced
8
+ // through consent + redaction + a low-confidence/proposed/never-written presentation.
9
+ //
10
+ // HONESTY / PRIVACY RED LINES (each one is enforced by code in this file + its tests):
11
+ // 1. DEFAULT NEVER SENDS. This module does nothing unless the CLI was given an
12
+ // EXPLICIT `--send-to-model`. With no flag, bootstrap's behavior is byte-identical
13
+ // to the pure-local report; none of this code runs. (Enforced in cli.js: the send
14
+ // path is entered only on args.sendToModel === true.)
15
+ // 2. CONSENT + PREVIEW BEFORE SEND. The user is shown EXACTLY what will be sent —
16
+ // which sources, how many snippets, that every snippet is redacted, which model —
17
+ // and must confirm (interactive y/N, or `--yes` when non-interactive). Without
18
+ // confirmation NOTHING is sent. (buildSendPreview + the cli.js gate.)
19
+ // 3. REDACTED PAYLOAD, ASSERTED (for the shapes we recognize). The prompt actually sent
20
+ // is built ONLY from snippets that already passed dialogue.js's redactSnippet, and
21
+ // then the WHOLE prompt is re-scanned and ASSERTED to carry no RECOGNIZED-SHAPE
22
+ // secret: no home-dir or /var //etc //opt //srv //usr or Windows-drive absolute path,
23
+ // and no recognized-shape secret (private keys incl. the multi-line body, provider
24
+ // api keys/tokens, the api-key/token/secret/password assignment forms, emails). This
25
+ // is a SHAPE allowlist, not a guarantee of total cleanliness — a secret with NO
26
+ // recognizable shape (e.g. a bare opaque value with no key= prefix) is not detectable
27
+ // and is NOT claimed clean. See redactSnippet / buildLeakDetectors for the exact
28
+ // covered shapes; the two are kept in sync. assertPayloadRedacted THROWS if a
29
+ // recognized shape slips through, so such a leak aborts the send instead of shipping.
30
+ // `--dry-run-send` prints this exact payload, unsent.
31
+ // 4. OPTIONAL MODEL CALL, GRACEFUL DEGRADE. The call is shelled out to the user's
32
+ // local `claude` (the prompt goes over STDIN, never argv — no injection / length
33
+ // blow-up), overridable via `--model <cmd>`. The invocation is an INJECTABLE
34
+ // function (runModel(payload, { invoke })) so tests stub it and NEVER spawn a real
35
+ // model. If `claude` is missing / errors / times out, we report a clear error and
36
+ // DEGRADE to the local result — never crash, never fabricate.
37
+ // 5. LLM OUTPUT IS THE LEAST-TRUSTED INPUT. The model must return JSON only. We parse
38
+ // it; a parse failure / unexpected shape is DISCARDED with a warning and we degrade
39
+ // to local — an unparsed blob is NEVER shown as a result. A parsed candidate is
40
+ // stamped confidence:"low" (LLM is the lowest tier), proposed:true, source:"model",
41
+ // and bootstrap (report-only) writes it NOWHERE (no profile, no ledger). It is
42
+ // NEVER rendered as done/verified.
43
+ //
44
+ // Pure + serializable + injectable: every export takes its inputs as arguments and the
45
+ // one side-effecting boundary (the model spawn) is a default `invoke` that callers (and
46
+ // tests) replace. So the whole contract is testable without ever calling an external model.
47
+
48
+ import { spawnSync } from "node:child_process";
49
+ import { redactSnippet } from "./dialogue.js";
50
+
51
+ // The recognized LLM candidate kinds. The model is asked to classify each finding into
52
+ // exactly one of these — the SAME four shapes the local half already produces, so an
53
+ // LLM finding can never introduce a new, unaudited card type. Anything outside this set
54
+ // is dropped on parse (red line #5).
55
+ export const MODEL_CANDIDATE_KINDS = Object.freeze([
56
+ "false_completion", // a "done" the chat asserts that nothing backs (-> VERIFY)
57
+ "profile_candidate", // a standing preference / correction (-> HARVEST profile)
58
+ "context_gap", // something the AI keeps missing for lack of context (-> RESUME-ish)
59
+ "harvest_candidate" // a reusable lesson worth carrying forward (-> HARVEST)
60
+ ]);
61
+
62
+ // The default external model command. The prompt is delivered on STDIN to `claude -p`
63
+ // (headless / "print" mode): keeping the payload OFF argv avoids both shell-injection
64
+ // surface and OS arg-length limits on a large redacted transcript. `--model <cmd>`
65
+ // overrides this whole token list. Kept as an array (argv form) so the default never
66
+ // goes through a shell.
67
+ export const DEFAULT_MODEL_CMD = "claude -p";
68
+
69
+ // --- A. Payload assembly (redacted snippets only) ---------------------------
70
+
71
+ // Collect the already-redacted snippets the local dialogue scan surfaced, tagged with
72
+ // where each came from, so the model gets the SAME masked text the user already saw —
73
+ // never raw export lines. `dialogue` is the scanDialogueAndLogs() result (or null). We
74
+ // pull from BOTH signal lists (suspected completions + repeated corrections); each
75
+ // snippet was redacted by dialogue.js (redactSnippet) at extraction time. We DO NOT go
76
+ // back to the raw files here — the redacted snippet is the only thing that travels.
77
+ export function collectRedactedSnippets(dialogue) {
78
+ const out = [];
79
+ if (!dialogue || typeof dialogue !== "object") return out;
80
+ if (Array.isArray(dialogue.suspectedFalseCompletions)) {
81
+ for (const c of dialogue.suspectedFalseCompletions) {
82
+ if (!c || typeof c.snippet !== "string" || c.snippet.length === 0) continue;
83
+ out.push({
84
+ kind: "completion_claim",
85
+ source: c.source ?? null,
86
+ line: c.line ?? null,
87
+ // Defense in depth: redact AGAIN. The snippet is already redacted by
88
+ // dialogue.js, but re-running redactSnippet is idempotent and guarantees that
89
+ // even a future change upstream cannot let a raw fragment through here.
90
+ text: redactSnippet(c.snippet)
91
+ });
92
+ }
93
+ }
94
+ if (Array.isArray(dialogue.repeatedCorrections)) {
95
+ for (const corr of dialogue.repeatedCorrections) {
96
+ if (!corr || typeof corr.snippet !== "string" || corr.snippet.length === 0) continue;
97
+ out.push({
98
+ kind: "repeated_correction",
99
+ count: corr.count ?? null,
100
+ text: redactSnippet(corr.snippet)
101
+ });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ // The HONEST instruction block handed to the model. It does two jobs: (1) constrain the
108
+ // model to OUTPUT JSON ONLY in the candidate schema, and (2) bind it to the four red
109
+ // lines so the model cannot "help" by inventing a verified completion. The four lines
110
+ // are stated as hard rules the model must obey, mirroring the system's own honesty
111
+ // contract. Kept canonical English (this text goes to the model, not the user) — the
112
+ // user-facing strings are localized separately.
113
+ const HONESTY_DIRECTIVE = [
114
+ "You are reviewing REDACTED fragments from a developer's AI-collaboration chat/logs.",
115
+ "Your job: surface CANDIDATE signals only. You must obey these rules without exception:",
116
+ "1. NEVER claim anything is completed or verified. A chat saying \"done\" is NOT done; treat every completion mention as an unverified CLAIM, never a fact.",
117
+ "2. EVERYTHING you output is a PROPOSED candidate for a human to review — not a conclusion, not an action taken.",
118
+ "3. \"Said done in chat\" does not equal real completion; only a recorded, independently re-checked result would, and you cannot see or assert that.",
119
+ "4. You are a single model; your view is NOT an independent cross-family verification. Do not imply your read confirms anything.",
120
+ "",
121
+ "Output STRICT JSON ONLY (no prose, no markdown fences) of the form:",
122
+ "{ \"candidates\": [ { \"kind\": <one of: false_completion | profile_candidate | context_gap | harvest_candidate>,",
123
+ " \"summary\": <short string>, \"confidence\": <\"low\"|\"medium\"|\"high\">, \"basis\": <short string: what in the fragments suggests this>,",
124
+ " \"sourceRefs\": [<short strings referencing the fragment(s)>] } ] }",
125
+ "If you find nothing, output { \"candidates\": [] }. Do not output anything except this JSON object."
126
+ ].join("\n");
127
+
128
+ // Build the EXACT prompt string that will be sent to the model (or printed by
129
+ // --dry-run-send). It is the honesty directive + the redacted snippets, serialized
130
+ // deterministically. The snippets are embedded as a JSON array of {kind,text,…} so the
131
+ // model sees structure but only ever the REDACTED text. The returned string is what
132
+ // assertPayloadRedacted re-checks and what travels on stdin — there is no second, raw
133
+ // version anywhere. `meta` (sources/snippetCount) is carried for the preview only and is
134
+ // NOT part of the prompt payload.
135
+ //
136
+ // CRITICAL: the `source` field is the on-disk path of the export the snippet came from
137
+ // (an absolute path under the user's home directory in the common case). That path is
138
+ // itself a sensitive ABSOLUTE LOCAL PATH — the SAME class redactSnippet masks and
139
+ // assertPayloadRedacted refuses to send. So we run the source path through redactSnippet
140
+ // too before embedding it. Without this, any user whose export lives under their home dir
141
+ // (the common case) would have EVERY real send aborted by assertPayloadRedacted (the raw
142
+ // home path trips the path detector), and the bare path would also be exfiltrated in the
143
+ // payload. Redacting it keeps the leak gate from firing on a legitimate send AND stops the
144
+ // path itself from travelling. (No literal home-dir prefix is written here; the release
145
+ // privacy scan flags real-looking local paths even inside comments.)
146
+ export function buildModelPrompt(snippets) {
147
+ const safe = Array.isArray(snippets) ? snippets : [];
148
+ const body = {
149
+ instructions: HONESTY_DIRECTIVE,
150
+ fragments: safe.map((s) => ({
151
+ kind: s.kind,
152
+ // Redact the source path (absolute local path = sensitive); fall back to nothing.
153
+ ...(s.source ? { source: redactSnippet(String(s.source)) } : {}),
154
+ ...(typeof s.line === "number" ? { line: s.line } : {}),
155
+ ...(typeof s.count === "number" ? { count: s.count } : {}),
156
+ text: s.text
157
+ }))
158
+ };
159
+ // A human-readable directive header followed by the machine block keeps the model on
160
+ // task while staying a single, fully-redacted string.
161
+ return `${HONESTY_DIRECTIVE}\n\n--- REDACTED FRAGMENTS (JSON) ---\n${JSON.stringify(body.fragments, null, 2)}\n--- END FRAGMENTS ---\n`;
162
+ }
163
+
164
+ // --- B. Redaction assertion (the hard privacy gate) -------------------------
165
+ //
166
+ // The last line of defense before ANYTHING leaves the machine. Even though every
167
+ // snippet is already redacted, we re-scan the WHOLE assembled prompt for the sensitive
168
+ // SHAPES (the same classes dialogue.js masks: private keys, provider tokens, secret
169
+ // assignments, emails, absolute local paths) and THROW if any survive. This converts a
170
+ // hypothetical leak from "silently sent" into "send aborted, loudly". Used both before a
171
+ // real send AND inside --dry-run-send (so the audited payload is proven clean too).
172
+
173
+ // The detection shapes. These intentionally MIRROR dialogue.js's REDACTION_RULES /
174
+ // scripts/privacy-scan's alwaysForbidden so "what we mask" and "what we refuse to send"
175
+ // are the same idea — every shape dialogue.js's redactSnippet masks is also one this gate
176
+ // refuses to send (private keys incl. the multi-line body, provider tokens, the
177
+ // api-key/token/secret/password quoted assignment, password=/passwd=/pwd= unquoted values,
178
+ // emails, home-dir absolute paths, the /var //etc //opt //srv //usr system paths, and
179
+ // Windows drive paths). We detect the PLACEHOLDER-SAFE way: a redacted prompt contains
180
+ // "[redacted:*]" tokens (those are fine); we look for UNMASKED sensitive shapes. The
181
+ // home + system path segment names + the PEM marker are assembled from fragments (not
182
+ // literals) so this source file itself stays clean under the release privacy scan.
183
+ function buildLeakDetectors() {
184
+ const HOME_SEGMENTS = ["Users", "home"];
185
+ // System roots that also make an absolute local path (kept IN SYNC with dialogue.js's
186
+ // buildLocalPathRules SYSTEM_ROOTS — what we mask there, we must refuse to send here).
187
+ const SYSTEM_ROOTS = ["var", "etc", "opt", "srv", "usr"];
188
+ const tail = "(?:/[^\\s`'\")]+)*";
189
+ const winTail = "(?:\\\\[^\\s`'\")]+)*";
190
+ const KEY = "PRIVATE KEY";
191
+ const detectors = [
192
+ // PEM private key: FULL block (header + base64 body) AND a lone header — mirrors the
193
+ // dialogue.js full-block + header-fallback rules. A surviving key body must abort too.
194
+ [new RegExp(`-----BEGIN (?:RSA |OPENSSH |EC |DSA )?${KEY}-----[\\s\\S]*?-----END (?:RSA |OPENSSH |EC |DSA )?${KEY}-----`, "g"), "private-key"],
195
+ [new RegExp(`-----BEGIN (?:RSA |OPENSSH |EC |DSA )?${KEY}-----`, "g"), "private-key"],
196
+ [/\bgithub_pat_[A-Za-z0-9_]{30,}/g, "token"],
197
+ [/\bgh[pousr]_[A-Za-z0-9_]{20,}/g, "token"],
198
+ [/\bxox[baprs]-[A-Za-z0-9-]{20,}/g, "token"],
199
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/gi, "token"],
200
+ [/sk-[A-Za-z0-9_-]{20,}/g, "secret-key"],
201
+ [/AKIA[0-9A-Z]{16}/g, "aws-key"],
202
+ [/AIza[0-9A-Za-z_-]{20,}/g, "api-key"],
203
+ [/\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*["'][^"']{8,}["']/gi, "secret"],
204
+ // password=/passwd=/pwd= with an unquoted value (mirrors the dialogue.js rule of the
205
+ // same shape) — a clear-shape secret the quoted-only rule above misses.
206
+ [/\b(?:password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, "secret"],
207
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "email"]
208
+ ];
209
+ for (const seg of HOME_SEGMENTS) {
210
+ detectors.push([new RegExp(`/${seg}/[^/\\s]+${tail}`, "g"), "path"]);
211
+ }
212
+ for (const root of SYSTEM_ROOTS) {
213
+ detectors.push([new RegExp(`/${root}/[^/\\s]+${tail}`, "g"), "path"]);
214
+ }
215
+ // Windows: ANY drive-letter path with at least one child segment (mirrors dialogue.js).
216
+ detectors.push([new RegExp(`[A-Za-z]:\\\\[^\\\\\\s]+${winTail}`, "g"), "path"]);
217
+ return detectors;
218
+ }
219
+ const LEAK_DETECTORS = buildLeakDetectors();
220
+
221
+ // Scan `payload` for any unmasked sensitive class; return the list of class names found
222
+ // (empty = clean). The "[redacted:*]" placeholders themselves never match these shapes,
223
+ // so an already-redacted snippet reads clean. Pure.
224
+ export function findSensitiveLeaks(payload) {
225
+ if (typeof payload !== "string" || payload.length === 0) return [];
226
+ const found = new Set();
227
+ for (const [regex, label] of LEAK_DETECTORS) {
228
+ regex.lastIndex = 0;
229
+ if (regex.test(payload)) found.add(label);
230
+ }
231
+ return [...found];
232
+ }
233
+
234
+ // THROW if the payload still contains any sensitive class — the hard gate that makes
235
+ // "redacted before send" an invariant, not a hope. Callers run this immediately before
236
+ // handing the payload to the model (and --dry-run-send runs it before printing), so an
237
+ // un-redacted payload aborts the send with a clear, do-not-ship message.
238
+ export function assertPayloadRedacted(payload) {
239
+ const leaks = findSensitiveLeaks(payload);
240
+ if (leaks.length > 0) {
241
+ throw new Error(
242
+ `Refusing to send: the payload still contains unredacted sensitive data (${leaks.join(", ")}). Nothing was sent.`
243
+ );
244
+ }
245
+ return true;
246
+ }
247
+
248
+ // --- C. The model invocation (injectable; default = local claude over stdin) -
249
+
250
+ // The DEFAULT invoker: spawn the model command, hand the prompt on STDIN (not argv), and
251
+ // return { ok, stdout, stderr, code, error }. NEVER throws — any spawn failure (binary
252
+ // missing / non-zero exit / timeout) is reported as ok:false so the caller degrades to
253
+ // local. The command is parsed into argv and run WITHOUT a shell (shell:false), so even a
254
+ // user-supplied `--model` cannot inject shell metacharacters, and the redacted prompt on
255
+ // stdin is never interpolated into a command line. `cmd` is the resolved command (default
256
+ // DEFAULT_MODEL_CMD or the user's --model value).
257
+ function defaultInvoke(cmd, payload, { timeoutMs = 60000 } = {}) {
258
+ const tokens = String(cmd).trim().split(/\s+/).filter(Boolean);
259
+ if (tokens.length === 0) {
260
+ return { ok: false, error: "empty model command", stdout: "", stderr: "", code: null };
261
+ }
262
+ const [bin, ...rest] = tokens;
263
+ let res;
264
+ try {
265
+ res = spawnSync(bin, rest, {
266
+ input: payload, // <-- the prompt travels on STDIN, never on argv
267
+ encoding: "utf8",
268
+ timeout: timeoutMs,
269
+ shell: false, // no shell: the user's --model tokens cannot inject shell syntax
270
+ stdio: ["pipe", "pipe", "pipe"]
271
+ });
272
+ } catch (error) {
273
+ return { ok: false, error: error && error.message ? error.message : String(error), stdout: "", stderr: "", code: null };
274
+ }
275
+ // spawnSync reports a missing binary / timeout via res.error (e.g. ENOENT / ETIMEDOUT).
276
+ if (res.error) {
277
+ const code = res.error.code || (res.signal ? `signal:${res.signal}` : "spawn-error");
278
+ return { ok: false, error: `${code}: ${res.error.message || "model invocation failed"}`, stdout: res.stdout || "", stderr: res.stderr || "", code: res.status ?? null };
279
+ }
280
+ if (res.status !== 0) {
281
+ return { ok: false, error: `model exited with code ${res.status}`, stdout: res.stdout || "", stderr: res.stderr || "", code: res.status };
282
+ }
283
+ return { ok: true, stdout: res.stdout || "", stderr: res.stderr || "", code: 0, error: null };
284
+ }
285
+
286
+ // Run the model with the given (already-assembled, already-asserted) payload. `invoke`
287
+ // is the injection point: tests pass a stub that returns a fixed { ok, stdout } and the
288
+ // real model is NEVER spawned. Production passes nothing, so defaultInvoke shells out to
289
+ // the resolved command. This function itself does not assert redaction (the caller does,
290
+ // right before calling) — its sole job is "call the invoker, normalize the result".
291
+ export function runModel(payload, { invoke = defaultInvoke, cmd = DEFAULT_MODEL_CMD, timeoutMs = 60000 } = {}) {
292
+ if (typeof payload !== "string" || payload.length === 0) {
293
+ return { ok: false, error: "empty payload", stdout: "", stderr: "", code: null };
294
+ }
295
+ try {
296
+ const result = invoke(cmd, payload, { timeoutMs });
297
+ // Normalize: an invoke that forgets a field still yields a well-formed result.
298
+ return {
299
+ ok: result && result.ok === true,
300
+ stdout: result && typeof result.stdout === "string" ? result.stdout : "",
301
+ stderr: result && typeof result.stderr === "string" ? result.stderr : "",
302
+ code: result ? result.code ?? null : null,
303
+ error: result && result.ok === true ? null : (result && result.error) || "model invocation failed"
304
+ };
305
+ } catch (error) {
306
+ // An injected invoke that THROWS must still degrade gracefully (never crash bootstrap).
307
+ return { ok: false, error: error && error.message ? error.message : String(error), stdout: "", stderr: "", code: null };
308
+ }
309
+ }
310
+
311
+ // --- D. Response parsing (robust; unparsed => discarded) --------------------
312
+
313
+ // Extract the first balanced top-level JSON object from a model's stdout. Models often
314
+ // wrap JSON in prose or ```json fences despite instructions; we tolerate that by finding
315
+ // the first '{' and scanning to its matching '}' (string-aware, so a brace inside a
316
+ // string literal does not fool it). Returns the substring or null. Pure.
317
+ function extractJsonObject(text) {
318
+ if (typeof text !== "string") return null;
319
+ const start = text.indexOf("{");
320
+ if (start === -1) return null;
321
+ let depth = 0;
322
+ let inString = false;
323
+ let escaped = false;
324
+ for (let i = start; i < text.length; i += 1) {
325
+ const ch = text[i];
326
+ if (inString) {
327
+ if (escaped) escaped = false;
328
+ else if (ch === "\\") escaped = true;
329
+ else if (ch === '"') inString = false;
330
+ continue;
331
+ }
332
+ if (ch === '"') inString = true;
333
+ else if (ch === "{") depth += 1;
334
+ else if (ch === "}") {
335
+ depth -= 1;
336
+ if (depth === 0) return text.slice(start, i + 1);
337
+ }
338
+ }
339
+ return null; // unbalanced => treat as unparseable
340
+ }
341
+
342
+ // Parse the model stdout into a list of NORMALIZED, LOW-TRUST candidates. The contract
343
+ // (red line #5): a parse failure OR an unexpected shape returns { ok:false, candidates:[],
344
+ // reason } — the caller then warns + degrades to local; an unparsed blob is NEVER turned
345
+ // into a candidate. On success, EVERY candidate is forcibly stamped:
346
+ // confidence: "low" (LLM is the least-trusted source — we OVERRIDE whatever the model
347
+ // said its confidence was; it does not get to self-promote)
348
+ // proposed: true (nothing is a conclusion)
349
+ // source: "model" (so a renderer/test can keep it visually + structurally apart)
350
+ // displayedAsDone: false (the load-bearing honesty assertion, carried explicitly)
351
+ // A candidate whose `kind` is not in MODEL_CANDIDATE_KINDS is DROPPED (an unaudited card
352
+ // type never enters). The result also reports how many raw candidates were dropped.
353
+ export function parseModelResponse(stdout) {
354
+ const fail = (reason) => ({ ok: false, candidates: [], dropped: 0, reason });
355
+ if (typeof stdout !== "string" || stdout.trim().length === 0) {
356
+ return fail("empty model output");
357
+ }
358
+ const jsonText = extractJsonObject(stdout);
359
+ if (!jsonText) return fail("no JSON object found in model output");
360
+ let parsed;
361
+ try {
362
+ parsed = JSON.parse(jsonText);
363
+ } catch {
364
+ return fail("model output was not valid JSON");
365
+ }
366
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.candidates)) {
367
+ return fail("model JSON did not contain a candidates array");
368
+ }
369
+ const candidates = [];
370
+ let dropped = 0;
371
+ for (const raw of parsed.candidates) {
372
+ if (!raw || typeof raw !== "object") { dropped += 1; continue; }
373
+ if (!MODEL_CANDIDATE_KINDS.includes(raw.kind)) { dropped += 1; continue; }
374
+ const summary = typeof raw.summary === "string" ? raw.summary.trim() : "";
375
+ if (summary.length === 0) { dropped += 1; continue; }
376
+ candidates.push({
377
+ kind: raw.kind,
378
+ summary,
379
+ basis: typeof raw.basis === "string" ? raw.basis.trim() : "",
380
+ sourceRefs: Array.isArray(raw.sourceRefs)
381
+ ? raw.sourceRefs.filter((r) => typeof r === "string").slice(0, 10)
382
+ : [],
383
+ // FORCED honesty stamps — the model does not get to set these.
384
+ confidence: "low", // LLM source is the lowest tier, ALWAYS — never the model's claim
385
+ proposed: true, // never a conclusion
386
+ source: "model", // kept apart from ledger + local-dialogue findings
387
+ displayedAsDone: false // never rendered as done/verified
388
+ });
389
+ }
390
+ return { ok: true, candidates, dropped, reason: null };
391
+ }
392
+
393
+ // --- E. The orchestrated send (consent already given upstream) --------------
394
+
395
+ // Build the preview the consent gate shows: WHAT will be sent, before sending. It names
396
+ // the sources, the snippet count, the explicit "every snippet is redacted" promise, and
397
+ // the model command. The CLI prints this (localized) and then asks to confirm. Returns a
398
+ // plain object so the CLI can render text OR --json. No side effects.
399
+ export function buildSendPreview({ dialogue, snippets, modelCmd }) {
400
+ const sources = dialogue && Array.isArray(dialogue.sources) ? dialogue.sources.map((s) => s.path) : [];
401
+ return {
402
+ sources,
403
+ snippetCount: Array.isArray(snippets) ? snippets.length : 0,
404
+ allRedacted: true, // every snippet passed redactSnippet (asserted again before send)
405
+ model: modelCmd || DEFAULT_MODEL_CMD
406
+ };
407
+ }
408
+
409
+ // The full external pass, AFTER consent has been confirmed by the CLI. Steps, in order,
410
+ // each upholding a red line:
411
+ // 1. Assemble the prompt from REDACTED snippets only (collectRedactedSnippets feeds it).
412
+ // 2. assertPayloadRedacted(payload) — THROW (caught here -> degrade) if anything leaks.
413
+ // 3. runModel(payload, { invoke, cmd }) — the injectable call; tests stub `invoke`.
414
+ // 4. On call failure -> { ok:false, degraded:true, reason } (caller shows error + local).
415
+ // 5. parseModelResponse(stdout) — unparsed/odd shape -> degrade; never a junk candidate.
416
+ // 6. Return the LOW-confidence / proposed / source:"model" candidates (or degrade).
417
+ // This function NEVER writes anything and NEVER throws to the caller (a thrown redaction
418
+ // assertion is caught and turned into a graceful degrade with the reason surfaced).
419
+ export function runExternalModelPass({
420
+ snippets,
421
+ modelCmd = DEFAULT_MODEL_CMD,
422
+ invoke = defaultInvoke,
423
+ timeoutMs = 60000
424
+ } = {}) {
425
+ const payload = buildModelPrompt(snippets);
426
+ // Hard privacy gate. A leak aborts the SEND (we never reach runModel) and degrades.
427
+ try {
428
+ assertPayloadRedacted(payload);
429
+ } catch (error) {
430
+ return { ok: false, degraded: true, sent: false, reason: error.message, candidates: [], payload };
431
+ }
432
+ const call = runModel(payload, { invoke, cmd: modelCmd, timeoutMs });
433
+ if (!call.ok) {
434
+ // Graceful degrade: the model was unreachable / errored / timed out. The caller
435
+ // keeps the local result and shows this reason; nothing is fabricated.
436
+ return { ok: false, degraded: true, sent: true, reason: call.error || "model call failed", candidates: [], payload };
437
+ }
438
+ const parsed = parseModelResponse(call.stdout);
439
+ if (!parsed.ok) {
440
+ // The model answered but not in the contract shape -> discard, degrade, warn.
441
+ return { ok: false, degraded: true, sent: true, reason: parsed.reason || "unparseable model output", candidates: [], payload };
442
+ }
443
+ return {
444
+ ok: true,
445
+ degraded: false,
446
+ sent: true,
447
+ reason: null,
448
+ candidates: parsed.candidates, // each: low / proposed / source:"model" / displayedAsDone:false
449
+ dropped: parsed.dropped,
450
+ payload
451
+ };
452
+ }