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,1184 @@
1
+ // === bootstrap (first-experience value report) ==============================
2
+ //
3
+ // `bootstrap` is the first-experience entry point. The problem it solves: a new
4
+ // user who just ran `init` sees a 200-file framework and does NOT know what it is
5
+ // for — the value is never proven on their OWN work. bootstrap reads what the
6
+ // user actually has locally (their repo, their git activity, their .aict ledger,
7
+ // their AI instruction files) and turns it into ONE plain "AI collaboration
8
+ // baseline report": five cards — PROFILE CLUES (setup signals to confirm),
9
+ // VERIFY (which "done"s cannot be trusted yet), RESUME (where you are / what is
10
+ // missing), ROLES (high-risk keywords mapped to helper roles), HARVEST (what
11
+ // you can carry forward).
12
+ //
13
+ // HONESTY IS THE WHOLE POINT (four red lines, enforced structurally here):
14
+ // 1. Deterministic only. This module reads files + runs read-only git; it calls
15
+ // NO external model, makes NO guess, and sends NOTHING anywhere.
16
+ // 2. A completion claim is NEVER shown as verified/done unless the ledger's OWN
17
+ // honest functions say it is. Every guard level / family marker is RE-COMPUTED
18
+ // by the shared ledger.js functions (computeReceiptGuardLevel /
19
+ // buildHandoffModel / summarizeTasks) — this file rewrites NONE of that logic,
20
+ // so bootstrap can never look cleaner than `status` / `check` / `handoff`.
21
+ // 3. HARVEST candidates are PROPOSED. bootstrap (report-only) writes NOTHING to
22
+ // a profile or any long-term state; it only lists structural facts.
23
+ // 4. The shipped synthetic seed is never counted as the user's own work
24
+ // (isSeedRow excludes it), so an empty workspace honestly says "no data yet"
25
+ // instead of borrowing the example's numbers.
26
+ //
27
+ // This is the report-only version: scan + five cards + a consent preview + init
28
+ // tie-in. It now ALSO does the LOCAL HALF of semantic scanning (dialogue.js): when —
29
+ // and ONLY when — the user EXPLICITLY hands over a local chat/log export
30
+ // (`--dialogue` / `--logs`), bootstrap reads it and extracts DETERMINISTIC signals
31
+ // (a word-table completion claim cross-referenced against the ledger; a repeated
32
+ // correction) to enrich the five cards. That local half stays inside red line #1:
33
+ // no model call, no guess, no network — a "done" found in a chat becomes a VERIFY
34
+ // CANDIDATE labelled "claimed in dialogue · not verified", never a "done". The
35
+ // EXTERNAL-model half (`--send-to-model`), a save/write-back flow, and a GUI are
36
+ // still deliberately out of scope (a later sub-batch). See the TODO markers below.
37
+
38
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
39
+ import path from "node:path";
40
+ import {
41
+ summarizeTasks,
42
+ buildHandoffModel,
43
+ isSeedRow,
44
+ familyHonestyMarker
45
+ } from "./ledger.js";
46
+ import { detectTools } from "./adapters.js";
47
+ import { t } from "./i18n.js";
48
+
49
+ // --- A. Local structure scan (read-only, zero network) ----------------------
50
+
51
+ // Read a JSON file, returning null on any problem (missing / unparseable). Never
52
+ // throws — a scan must degrade gracefully, not abort the whole report.
53
+ function readJsonSafe(file) {
54
+ try {
55
+ return JSON.parse(readFileSync(file, "utf8"));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ // True when a path exists and is a directory (used to confirm a .git or .aict).
62
+ function isDir(p) {
63
+ try {
64
+ return statSync(p).isDirectory();
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ // Find the nearest ancestor directory (including `start`) that contains a `.git`,
71
+ // i.e. the git working-tree root. Returns null when none is found (not a repo).
72
+ // Pure filesystem walk — no git invocation, so it works even where git is absent.
73
+ function findGitRoot(start) {
74
+ let dir = path.resolve(start);
75
+ for (;;) {
76
+ if (existsSync(path.join(dir, ".git"))) return dir;
77
+ const parent = path.dirname(dir);
78
+ if (parent === dir) return null; // reached filesystem root
79
+ dir = parent;
80
+ }
81
+ }
82
+
83
+ // The recognized AI instruction-file markers, surfaced in the scan so the report
84
+ // can say "your repo already talks to <tools>". REUSES the adapters detect logic
85
+ // (detectTools) so bootstrap and `adapters install` agree on what counts as an AI
86
+ // instruction file — single source, no second list to drift.
87
+ function scanAiInstructionFiles(root) {
88
+ const detected = detectTools(root); // e.g. ["claude", "codex"]
89
+ // A few well-known top-level instruction files, reported individually so the
90
+ // user sees the concrete filename. detectTools already drives the load-bearing
91
+ // "which tools" answer; this is only for a friendly per-file presence list.
92
+ const KNOWN_FILES = ["CLAUDE.md", "AGENTS.md", "README.md", ".cursorrules", ".clinerules"];
93
+ const files = KNOWN_FILES.filter((name) => existsSync(path.join(root, name)));
94
+ return { detectedTools: detected, instructionFiles: files };
95
+ }
96
+
97
+ // Read package.json scripts + a best-effort "test entry" (the npm `test` script,
98
+ // when present). Returns { present, scripts: string[], testScript: string|null }.
99
+ function scanPackageJson(root) {
100
+ const pkgPath = path.join(root, "package.json");
101
+ if (!existsSync(pkgPath)) return { present: false, scripts: [], testScript: null };
102
+ const pkg = readJsonSafe(pkgPath);
103
+ if (!pkg || typeof pkg !== "object") return { present: false, scripts: [], testScript: null };
104
+ const scriptsObj = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
105
+ const scripts = Object.keys(scriptsObj);
106
+ const testScript = typeof scriptsObj.test === "string" ? scriptsObj.test : null;
107
+ return { present: true, scripts, testScript };
108
+ }
109
+
110
+ // List up to `limit` of the most-recently-modified files in the repo root (one
111
+ // level only — a shallow, fast, deterministic-ish signal of "what you touched
112
+ // last"), skipping noise dirs. mtime is a heuristic, surfaced as such. Read-only.
113
+ function scanRecentlyModified(root, limit = 8) {
114
+ const SKIP = new Set([".git", "node_modules", ".aict"]);
115
+ let entries;
116
+ try {
117
+ entries = readdirSync(root, { withFileTypes: true });
118
+ } catch {
119
+ return [];
120
+ }
121
+ const rows = [];
122
+ for (const entry of entries) {
123
+ if (entry.name.startsWith(".") && entry.name !== ".cursorrules" && entry.name !== ".clinerules") {
124
+ // skip dotfiles/dirs by default (they are rarely the "what I am working on"
125
+ // signal), except the two instruction dotfiles we already care about.
126
+ if (SKIP.has(entry.name)) continue;
127
+ }
128
+ if (SKIP.has(entry.name)) continue;
129
+ if (!entry.isFile()) continue;
130
+ const full = path.join(root, entry.name);
131
+ try {
132
+ const st = statSync(full);
133
+ rows.push({ name: entry.name, mtimeMs: st.mtimeMs });
134
+ } catch {
135
+ /* unreadable: skip */
136
+ }
137
+ }
138
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
139
+ return rows.slice(0, limit).map((r) => r.name);
140
+ }
141
+
142
+ // Parse the porcelain-ish output of `git log --name-only -n N` (passed in by the
143
+ // CLI, which owns the spawn) into a count of how many of the last N commits each
144
+ // file appeared in. A file touched in MANY recent commits is a candidate "kept
145
+ // re-fixing the same thing" signal — a possible "said done but still patching it"
146
+ // tell, which is exactly the kind of un-trustworthy completion bootstrap exists to
147
+ // surface. Pure string parsing here; the git call is the CLI's job (so this stays
148
+ // testable without a real repo).
149
+ //
150
+ // `logText` is the raw stdout. Lines that look like a path (no leading "commit ",
151
+ // not blank, not an author/date header) are counted. We deliberately count
152
+ // DISTINCT commits per file, not raw line repeats.
153
+ export function parseRepeatedlyTouchedFiles(logText, { minCommits = 3, limit = 5 } = {}) {
154
+ if (typeof logText !== "string" || logText.trim().length === 0) return [];
155
+ // Split into per-commit blocks on the "commit <sha>" boundary that
156
+ // `git log --name-only` prints. The first block may be empty.
157
+ const blocks = logText.split(/^commit [0-9a-f]+/m);
158
+ const counts = new Map();
159
+ for (const block of blocks) {
160
+ const filesInCommit = new Set();
161
+ for (const rawLine of block.split("\n")) {
162
+ const line = rawLine.trim();
163
+ if (line.length === 0) continue;
164
+ // Skip the standard headers git emits inside a commit block.
165
+ if (/^Author:/.test(line) || /^Date:/.test(line) || /^Merge:/.test(line)) continue;
166
+ // A commit message body is indented by 4 spaces in `git log`; a changed path
167
+ // is NOT indented (name-only prints bare paths). Use the RAW line's leading
168
+ // whitespace to tell them apart, and require a path-ish shape.
169
+ if (/^\s/.test(rawLine)) continue; // indented => message text, not a path
170
+ if (!/[\w./-]/.test(line)) continue;
171
+ if (line.includes(" ") && !line.includes("/") && !line.includes(".")) continue;
172
+ filesInCommit.add(line);
173
+ }
174
+ for (const file of filesInCommit) {
175
+ counts.set(file, (counts.get(file) ?? 0) + 1);
176
+ }
177
+ }
178
+ return [...counts.entries()]
179
+ .filter(([, n]) => n >= minCommits)
180
+ .sort((a, b) => b[1] - a[1])
181
+ .slice(0, limit)
182
+ .map(([file, commits]) => ({ file, commits }));
183
+ }
184
+
185
+ // Assemble the full structural scan from the pieces the CLI gathers. `git` carries
186
+ // the read-only git output the CLI already captured (log/diff text + whether git
187
+ // ran), so this module never spawns a process itself.
188
+ //
189
+ // workspaceRoot: the .aict workspace dir (…/.aict)
190
+ // repoRoot: the project root the user is in (holds package.json, the repo)
191
+ // git: { available, logText, diffStatText } captured by the CLI
192
+ //
193
+ // Returns a plain, serializable scan object (no I/O beyond reads done here).
194
+ export function scanLocalStructure({ workspaceRoot, repoRoot, git = {} }) {
195
+ const root = path.resolve(repoRoot);
196
+ const gitRoot = findGitRoot(root);
197
+ const pkg = scanPackageJson(root);
198
+ const ai = scanAiInstructionFiles(root);
199
+ const recentlyModified = scanRecentlyModified(root);
200
+ const repeatedlyTouched = parseRepeatedlyTouchedFiles(typeof git.logText === "string" ? git.logText : "");
201
+ const diffStat = typeof git.diffStatText === "string" ? git.diffStatText.trim() : "";
202
+
203
+ return {
204
+ repoRoot: root,
205
+ workspaceRoot: workspaceRoot ? path.resolve(workspaceRoot) : null,
206
+ git: {
207
+ available: git.available === true,
208
+ isRepo: gitRoot !== null,
209
+ root: gitRoot,
210
+ // The presence of an uncommitted diff is a "work in flight" signal for RESUME.
211
+ hasUncommittedChanges: diffStat.length > 0,
212
+ diffStat,
213
+ repeatedlyTouched
214
+ },
215
+ packageJson: pkg,
216
+ ai,
217
+ recentlyModified
218
+ };
219
+ }
220
+
221
+ // --- B. The trust-bearing cards (the core value), built from the HONEST ledger functions ---
222
+ //
223
+ // Everything trust-bearing below is DERIVED by the shared ledger.js functions, not
224
+ // recomputed here: buildHandoffModel does the done/unverified bucketing with the
225
+ // recomputed guard level + familyUnverified marker; summarizeTasks gives the
226
+ // per-task "author-marked, unverified" flag. The actual per-receipt level math
227
+ // (computeReceiptGuardLevel) runs INSIDE those ledger functions — bootstrap calls
228
+ // them and reads their output; it never calls the level computer itself. bootstrap
229
+ // only SELECTS and LABELS — it owns no level math.
230
+
231
+ // A short, stable reason code + plain sentence for each kind of un-trustworthy
232
+ // completion claim VERIFY surfaces. The phrasing avoids jargon on the first read.
233
+ const VERIFY_REASON = {
234
+ pending_receipt: "a review exists but is still pending — not accepted",
235
+ pass_with_risk_unaccepted: "passed only with a noted risk that no one has signed off on yet",
236
+ self_declared_cross_family: "self-declared cross-family review — the tool cannot verify the other model actually checked it",
237
+ author_marked_done: "marked done by the author with no accepted review behind it"
238
+ };
239
+
240
+ // Build the VERIFY card: the completion claims that CANNOT be trusted as done yet.
241
+ // Sources (all recomputed honestly by buildHandoffModel / summarizeTasks):
242
+ // - every task in the handoff model's `unverified` bucket (pending pass, a
243
+ // pass_with_risk still pending, OR an accepted-but-self-declared-cross-family
244
+ // receipt — buildHandoffModel routes all of these here using the RE-COMPUTED
245
+ // level, never a stored flag);
246
+ // - any task flagged authorMarkedDoneUnverified (status done, no done-eligible
247
+ // accepted receipt — the note-only "done");
248
+ // - a self-declared cross-family marker is called out explicitly even when the
249
+ // receipt was locally accepted, so it is shown "unverified", never "done".
250
+ // Each item carries the task id/title, the offending receipt (with its RE-COMPUTED
251
+ // level + verdict + status), a reason code, and an explicit `displayedAsDone:false`
252
+ // so a consumer can assert bootstrap never renders these as completed.
253
+ function buildVerifyCard(handoff, perTask, dialogue = null) {
254
+ const items = [];
255
+ const seen = new Set(); // de-dupe (taskId, receiptId, reason)
256
+
257
+ const pushItem = (task, receiptView, reasonCode) => {
258
+ const key = `${task.id}|${receiptView ? receiptView.id : "-"}|${reasonCode}`;
259
+ if (seen.has(key)) return;
260
+ seen.add(key);
261
+ items.push({
262
+ // `source: "ledger"` marks the trust-bearing items that come from the user's
263
+ // OWN recorded work (the receipts/runs the tool itself wrote). Dialogue-sourced
264
+ // items below carry `source: "dialogue"` so a renderer/consumer can keep the two
265
+ // visually + structurally SEPARATE (red line: a chat claim is never mixed into
266
+ // the ledger facts as if it were verified).
267
+ source: "ledger",
268
+ taskId: task.id,
269
+ title: task.title,
270
+ taskStatus: task.taskStatus ?? task.status,
271
+ // The receipt is described by its RE-COMPUTED level (buildHandoffModel /
272
+ // summarizeTasks already recompute it); we copy those fields, never a stored
273
+ // guardLevel. receiptView may be null for a note-only done with no receipt.
274
+ receipt: receiptView
275
+ ? {
276
+ id: receiptView.id,
277
+ verdict: receiptView.verdict,
278
+ guardLevel: receiptView.guardLevel, // RE-COMPUTED upstream
279
+ status: receiptView.status,
280
+ familyUnverified: receiptView.familyUnverified === true
281
+ }
282
+ : null,
283
+ reason: reasonCode,
284
+ reasonText: VERIFY_REASON[reasonCode] ?? reasonCode,
285
+ // The load-bearing honesty assertion: bootstrap NEVER displays a VERIFY item
286
+ // as done/verified. Carried explicitly so a test (and a renderer) can rely on it.
287
+ displayedAsDone: false,
288
+ // The self-declared-cross-family marker text, when applicable, surfaced
289
+ // verbatim from the shared helper so the wording matches the rest of the tool.
290
+ familyMarker: receiptView && receiptView.familyUnverified === true
291
+ ? familyHonestyMarker(true)
292
+ : null
293
+ });
294
+ };
295
+
296
+ // 1) Everything the handoff model already bucketed as Unverified. Each such task
297
+ // has >=1 receipt that is NOT done-eligible. We classify the strongest reason
298
+ // per receipt from the RE-COMPUTED view buildHandoffModel handed us.
299
+ for (const entry of handoff.unverified) {
300
+ const receipts = entry.receipts ?? [];
301
+ if (receipts.length === 0) {
302
+ // Reviewed-but-not-accepted with no receipt view should not happen (the
303
+ // bucket implies a receipt), but guard anyway: mark the task itself.
304
+ pushItem(entry, null, "author_marked_done");
305
+ continue;
306
+ }
307
+ for (const r of receipts) {
308
+ if (r.familyUnverified === true) {
309
+ pushItem(entry, r, "self_declared_cross_family");
310
+ } else if (r.verdict === "pass_with_risk" && r.status !== "accepted") {
311
+ pushItem(entry, r, "pass_with_risk_unaccepted");
312
+ } else if (r.status === "pending") {
313
+ pushItem(entry, r, "pending_receipt");
314
+ } else if (r.status === "rejected") {
315
+ // a rejected receipt is also "not done"; surface it as pending-style review
316
+ pushItem(entry, r, "pending_receipt");
317
+ }
318
+ }
319
+ }
320
+
321
+ // 2) A task in ANY bucket whose only/loudest problem is an accepted-but-self-
322
+ // declared-cross-family receipt: buildHandoffModel routes a task whose ONLY
323
+ // acceptances are familyUnverified into `unverified` (handled above), but a
324
+ // task that is Done on a clean receipt AND also carries a separate self-declared
325
+ // cross-family receipt keeps a riskNote — surface that receipt too, so a
326
+ // cross-family claim is never silently trusted. We scan done/blocked entries.
327
+ for (const entry of [...handoff.done, ...handoff.blocked, ...handoff.pending]) {
328
+ for (const r of entry.receipts ?? []) {
329
+ if (r.familyUnverified === true) {
330
+ pushItem(entry, r, "self_declared_cross_family");
331
+ }
332
+ }
333
+ }
334
+
335
+ // 3) Note-only / author-marked done with no done-eligible accepted receipt — the
336
+ // thin "done". summarizeTasks recomputed authorMarkedDoneUnverified for us.
337
+ for (const t of perTask) {
338
+ if (t.isSeed) continue; // never the shipped example
339
+ if (t.authorMarkedDoneUnverified) {
340
+ // Use the strongest receipt view if there is one; else flag the bare task.
341
+ const rv = t.receipt
342
+ ? {
343
+ id: t.receipt.id,
344
+ verdict: t.receipt.verdict,
345
+ guardLevel: t.receipt.guardLevel, // RE-COMPUTED in summarizeTasks
346
+ status: t.receipt.status,
347
+ familyUnverified: t.receipt.familyUnverified === true
348
+ }
349
+ : null;
350
+ pushItem(
351
+ { id: t.id, title: t.title, taskStatus: t.statusDisplay ?? t.status },
352
+ rv,
353
+ "author_marked_done"
354
+ );
355
+ }
356
+ }
357
+
358
+ // 4) DIALOGUE-SOURCED completion claims (red line #2 + #4-visual-separation). Each
359
+ // is a deterministic word-table match from a LOCAL export the user handed over,
360
+ // cross-referenced against the ledger and found UNBACKED (no accepted clean
361
+ // receipt, no executed run). It is surfaced as a VERIFY FINDING with the exact
362
+ // wording "claimed in dialogue · not verified" and displayedAsDone:false — it is
363
+ // NEVER a task status, NEVER rendered as done, and is tagged source:"dialogue" so
364
+ // it is shown SEPARATE from the ledger facts above. Snippets are pre-redacted by
365
+ // dialogue.js (redactSnippet) before they ever reach here.
366
+ if (dialogue && Array.isArray(dialogue.suspectedFalseCompletions)) {
367
+ for (const claim of dialogue.suspectedFalseCompletions) {
368
+ items.push({
369
+ source: "dialogue",
370
+ // The stable, non-softenable label for a chat-sourced, unbacked completion
371
+ // claim. Carried verbatim so a test/renderer can assert it is never "done".
372
+ label: "claimed in dialogue · not verified",
373
+ sourcePath: claim.source,
374
+ line: claim.line,
375
+ subject: claim.subject,
376
+ snippet: claim.snippet, // already redacted + clamped by dialogue.js
377
+ confidence: claim.confidence ?? "low",
378
+ // The load-bearing honesty assertions, identical to the ledger items'.
379
+ displayedAsDone: false,
380
+ backed: false
381
+ });
382
+ }
383
+ }
384
+
385
+ return items;
386
+ }
387
+
388
+ // Build the RESUME card: where the user is and what is missing to pick back up.
389
+ // - activeTasks: open/partial tasks (NOT seed) — work in progress.
390
+ // - missingHandoff: true when there is in-progress/unverified work but NO handoff
391
+ // draft has been generated (the next session would start from zero).
392
+ // - gitDrift: the repeatedly-touched files (a "still churning the same files"
393
+ // signal) + whether there are uncommitted changes.
394
+ function buildResumeCard(handoff, perTask, scan, handoffDraftCount) {
395
+ const activeTasks = perTask
396
+ .filter((t) => !t.isSeed && (t.status === "open" || t.status === "partial"))
397
+ .map((t) => ({ id: t.id, title: t.title, status: t.status }));
398
+
399
+ const hasInFlightWork =
400
+ activeTasks.length > 0 ||
401
+ handoff.unverified.length > 0 ||
402
+ handoff.pending.length > 0 ||
403
+ scan.git.hasUncommittedChanges;
404
+
405
+ return {
406
+ activeTasks,
407
+ inProgressCount: handoff.counts.pending + handoff.counts.unverified,
408
+ missingHandoff: hasInFlightWork && handoffDraftCount === 0,
409
+ handoffDraftCount,
410
+ gitDrift: {
411
+ repeatedlyTouched: scan.git.repeatedlyTouched,
412
+ hasUncommittedChanges: scan.git.hasUncommittedChanges
413
+ }
414
+ };
415
+ }
416
+
417
+ // Build the HARVEST card: what is safe to carry forward — DETERMINISTIC facts only.
418
+ // - confirmedLearnings: the confirmed/edited learning rows buildHandoffModel
419
+ // already collected (proposed/dropped excluded). These are facts the user
420
+ // themselves kept, so they are reported as-is.
421
+ // - candidates: STRUCTURAL, proposed-only observations (v1 lists NO LLM guess) —
422
+ // e.g. "N confirmed learnings ready to carry forward". Each is marked
423
+ // proposed:true; bootstrap (report-only) writes none of them anywhere.
424
+ function buildHarvestCard(handoff, learning = [], dialogue = null) {
425
+ const confirmedLearnings = (handoff.learnings ?? []).map((l) => ({
426
+ id: l.id,
427
+ type: l.type,
428
+ content: l.content,
429
+ status: l.status
430
+ }));
431
+
432
+ // Proposed (not-yet-kept) lessons of the user's OWN — the rows a "learning confirm
433
+ // <id>" next step can actually act on. The shipped example seed (l0) is excluded so
434
+ // the suggestion never pushes the user to confirm the demo lesson. Carried on the
435
+ // card model (not the rendered body) so the Next step can name a real id.
436
+ const proposedLearnings = (Array.isArray(learning) ? learning : [])
437
+ .filter((l) => l && l.status === "proposed" && !isSeedRow(l, "learning"))
438
+ .map((l) => ({ id: l.id, type: l.type, content: l.content, status: l.status }));
439
+
440
+ const candidates = [];
441
+ if (confirmedLearnings.length > 0) {
442
+ candidates.push({
443
+ kind: "confirmed_learnings_ready",
444
+ proposed: true,
445
+ // `count` is carried so the renderer can build the localized detail; `detail`
446
+ // stays canonical English in the model (a stable --json data contract).
447
+ count: confirmedLearnings.length,
448
+ detail: `${confirmedLearnings.length} confirmed learning${confirmedLearnings.length === 1 ? "" : "s"} you can carry into your next task`
449
+ });
450
+ }
451
+ const doneCount = handoff.counts.done;
452
+ if (doneCount > 0) {
453
+ candidates.push({
454
+ kind: "verified_done_tasks",
455
+ proposed: true,
456
+ count: doneCount,
457
+ detail: `${doneCount} task${doneCount === 1 ? "" : "s"} reached a verified (accepted) result — a pattern worth reusing`
458
+ });
459
+ }
460
+
461
+ // DIALOGUE-SOURCED profile candidates (red line #3 + #4-visual-separation). A
462
+ // correction the user repeated >= 2 times in their LOCAL export is a deterministic
463
+ // signal of a standing preference the AI keeps missing — a HARVEST *profile*
464
+ // candidate. It is PROPOSED only (proposed:true): this module writes NOTHING to a
465
+ // profile or ledger; confirming it is the user's explicit `learning confirm` step
466
+ // later. Tagged source:"dialogue" + type:"profile" so it is shown SEPARATE from the
467
+ // user's own confirmed learnings, and labelled as coming from the chat, unverified.
468
+ const dialogueCandidates = [];
469
+ if (dialogue && Array.isArray(dialogue.repeatedCorrections)) {
470
+ for (const corr of dialogue.repeatedCorrections) {
471
+ dialogueCandidates.push({
472
+ kind: "repeated_correction_profile",
473
+ source: "dialogue",
474
+ type: "profile",
475
+ proposed: true,
476
+ count: corr.count,
477
+ normalized: corr.normalized,
478
+ snippet: corr.snippet, // already redacted + clamped by dialogue.js
479
+ confidence: corr.confidence ?? "low",
480
+ detail: `a correction you repeated ${corr.count}× in your chat — a standing preference worth recording`
481
+ });
482
+ }
483
+ }
484
+
485
+ return { confirmedLearnings, proposedLearnings, candidates, dialogueCandidates };
486
+ }
487
+
488
+ // --- B2. Profile-clues card (DETERMINISTIC signals only, NO semantic verdict) --
489
+ //
490
+ // The fourth card. Its ONLY job is to list CONCRETE, machine-observable facts the
491
+ // user can recognise — never a personality read. bootstrap is a report-only engine
492
+ // with no model and no right to GUESS a work style: it may say "you have Claude +
493
+ // Cursor configured here" (a fact the user can confirm by looking) but NOT "you
494
+ // prefer X" (a semantic judgment). The footer makes the contract explicit: these
495
+ // are CLUES the user's OWN ai will turn into a profile in first-run, not a
496
+ // conclusion bootstrap reached. Pure read over the scan; writes nothing, calls
497
+ // nothing.
498
+ //
499
+ // Signals (all from the existing scan, all deterministic):
500
+ // - detectedTools: the AI tools whose instruction files are present (reused from
501
+ // scanAiInstructionFiles -> detectTools). >= 2 is flagged as a cross-tool
502
+ // signal — a FACT (two tools are configured), not a claim about how they work.
503
+ // - fileTypes: the extension distribution of the recently-modified files (a count
504
+ // per extension). "you touched .ts and .sql files lately" is observable; what
505
+ // it MEANS is for the user's ai, not bootstrap.
506
+ // - hasTestScript: whether package.json has a `test` script. We label it the
507
+ // plain fact "has a test script" — a signal the user can verify, never
508
+ // "you value testing" stated as settled truth.
509
+ // Each datum carries the raw fact so a --json consumer (and a test) can assert the
510
+ // card contains evidence, not interpretation.
511
+ export function buildProfileCard(scan) {
512
+ const ai = scan && scan.ai ? scan.ai : { detectedTools: [], instructionFiles: [] };
513
+ const detectedTools = Array.isArray(ai.detectedTools) ? ai.detectedTools : [];
514
+
515
+ // Extension distribution of the recently-modified files. A file with no extension
516
+ // (e.g. "Makefile") is bucketed under "(no ext)" so the count stays honest. The
517
+ // list is sorted by count desc, then name, for a stable display + --json contract.
518
+ const recent = Array.isArray(scan && scan.recentlyModified) ? scan.recentlyModified : [];
519
+ const extCounts = new Map();
520
+ for (const name of recent) {
521
+ if (typeof name !== "string" || name.length === 0) continue;
522
+ const ext = path.extname(name); // ".ts", "", ".sql", …
523
+ const key = ext.length > 0 ? ext : "(no ext)";
524
+ extCounts.set(key, (extCounts.get(key) ?? 0) + 1);
525
+ }
526
+ const fileTypes = [...extCounts.entries()]
527
+ .map(([ext, count]) => ({ ext, count }))
528
+ .sort((a, b) => b.count - a.count || a.ext.localeCompare(b.ext));
529
+
530
+ const hasTestScript =
531
+ Boolean(scan && scan.packageJson && scan.packageJson.present) &&
532
+ typeof scan.packageJson.testScript === "string" &&
533
+ scan.packageJson.testScript.length > 0;
534
+
535
+ return {
536
+ // The detected tools, and whether there is more than one (a cross-tool FACT).
537
+ detectedTools,
538
+ multiTool: detectedTools.length >= 2,
539
+ fileTypes,
540
+ hasTestScript,
541
+ // The load-bearing honesty assertion, mirrored on every card item a consumer
542
+ // might mistake for a verdict: this card draws NO semantic conclusion. Carried
543
+ // so a test can assert bootstrap never crosses the report-only line here.
544
+ semanticConclusion: false
545
+ };
546
+ }
547
+
548
+ // --- B3. Roles-suggestion card (DETERMINISTIC keyword match -> existing roles) --
549
+ //
550
+ // The fifth card. It scans the user's task titles + recently-touched file PATHS for
551
+ // a fixed list of high-risk keywords and, when one matches, suggests an EXISTING
552
+ // open-source role package to bring in. The honesty contract is the same as the
553
+ // profile card: it states a FACT ("the title 'payment callback' contains the word
554
+ // 'payment'") and a deterministic mapping to a role — it does NOT decide the work
555
+ // IS risky, only that a known high-risk WORD appears. The user's ai makes the call;
556
+ // the card's wording says these are clues to confirm. Pure read; no write, no model.
557
+ //
558
+ // The keyword table is grouped by RISK THEME; each theme maps to one or more role
559
+ // suggestions. The roles named here are all packages that already ship in the
560
+ // open-source workspace (.aict/skills/ + .aict/mechanisms/): red-team, dual-guard,
561
+ // scout-review-controller — verified present, never invented.
562
+ const ROLE_RISK_GROUPS = [
563
+ {
564
+ theme: "auth", // authentication / credentials
565
+ keywords: ["auth", "login", "password", "credential", "token", "secret"],
566
+ roles: ["red-team", "dual-guard"]
567
+ },
568
+ {
569
+ theme: "money", // payments / billing
570
+ keywords: ["payment", "pay", "billing", "invoice"],
571
+ roles: ["red-team", "dual-guard"]
572
+ },
573
+ {
574
+ theme: "security", // explicit security / crypto
575
+ keywords: ["security", "crypto"],
576
+ roles: ["scout-review-controller", "red-team"]
577
+ },
578
+ {
579
+ theme: "deploy", // deployment / data layer
580
+ keywords: ["deploy", "release", "migration", "database", "schema"],
581
+ roles: ["scout-review-controller", "red-team"]
582
+ }
583
+ ];
584
+
585
+ // Find the FIRST keyword (lowercase substring) from `keywords` that appears in
586
+ // `haystack` (already lowercased). Returns the matched keyword or null. Substring
587
+ // match is deliberate so "authentication"/"reauth" trip "auth"; it is a CLUE the
588
+ // user's ai confirms, so a slightly broad match is acceptable (and honest about
589
+ // being a word-match, never a verdict).
590
+ function firstKeywordHit(haystack, keywords) {
591
+ for (const kw of keywords) {
592
+ if (haystack.includes(kw)) return kw;
593
+ }
594
+ return null;
595
+ }
596
+
597
+ // Build the roles card. `tasks` and `scan` come from the same inputs the other
598
+ // cards use. Seed tasks are excluded (isSeedRow) so the shipped example never
599
+ // manufactures a role suggestion. Returns:
600
+ // - hits: up to TOP 3 high-risk items, each { subject, keyword, theme, roles,
601
+ // source } — `subject` is the task title or file path that matched, so the card
602
+ // can show the exact evidence ("task 'X' matched keyword 'Y' -> suggest Z").
603
+ // - roles: the de-duplicated union of suggested role ids across the hits (for the
604
+ // Next-step + a --json consumer), each tagged with the plain-language simile key.
605
+ // - hasHighRisk: whether anything matched at all (drives the honest no-match line).
606
+ // The card NEVER says the work is risky; it says a high-risk WORD appears and maps
607
+ // it to a role to consider — a deterministic fact + a fixed mapping, no judgment.
608
+ export function buildRolesCard(tasks, scan) {
609
+ const taskList = Array.isArray(tasks) ? tasks : [];
610
+ const recent = Array.isArray(scan && scan.recentlyModified) ? scan.recentlyModified : [];
611
+
612
+ // The candidate surfaces to scan: every NON-seed task title (source "task") and
613
+ // every recently-modified file path (source "file"). Each carries the raw subject
614
+ // so the matched evidence is shown verbatim, never paraphrased into a conclusion.
615
+ const subjects = [];
616
+ for (const task of taskList) {
617
+ if (!task || typeof task !== "object") continue;
618
+ if (isSeedRow(task, "tasks")) continue; // never the shipped example
619
+ const title = typeof task.title === "string" ? task.title : "";
620
+ if (title.length === 0) continue;
621
+ subjects.push({ source: "task", subject: title });
622
+ }
623
+ for (const name of recent) {
624
+ if (typeof name === "string" && name.length > 0) {
625
+ subjects.push({ source: "file", subject: name });
626
+ }
627
+ }
628
+
629
+ // Match each subject against every risk group; collect the hits. We keep the FIRST
630
+ // group that matches a given subject (a subject rarely belongs to two themes, and
631
+ // showing one clear reason per subject is less noisy than every partial match).
632
+ const hits = [];
633
+ for (const { subject, source } of subjects) {
634
+ const haystack = subject.toLowerCase();
635
+ for (const group of ROLE_RISK_GROUPS) {
636
+ const keyword = firstKeywordHit(haystack, group.keywords);
637
+ if (keyword) {
638
+ hits.push({ subject, source, keyword, theme: group.theme, roles: group.roles });
639
+ break; // one theme per subject
640
+ }
641
+ }
642
+ }
643
+
644
+ // Cap at TOP 3 high-risk items so the card does not carpet-bomb the user (the brief:
645
+ // "no repeated bombardment; at most top 3"). Order is scan order (tasks first, then
646
+ // files), which is stable and deterministic.
647
+ const topHits = hits.slice(0, 3);
648
+
649
+ // The de-duplicated union of suggested roles across the shown hits, each with the
650
+ // plain-language simile key the renderer/Next-step uses. Stable order = first seen.
651
+ const roleOrder = [];
652
+ for (const hit of topHits) {
653
+ for (const role of hit.roles) {
654
+ if (!roleOrder.includes(role)) roleOrder.push(role);
655
+ }
656
+ }
657
+ const roles = roleOrder.map((id) => ({ id }));
658
+
659
+ return {
660
+ hasHighRisk: topHits.length > 0,
661
+ hits: topHits,
662
+ roles,
663
+ // The same report-only honesty flag the profile card carries: a keyword match is
664
+ // a fact + a fixed mapping, NOT a decision that the work is risky.
665
+ semanticConclusion: false
666
+ };
667
+ }
668
+
669
+ // Top-level: turn the scan + the raw ledgers into the full bootstrap model. The
670
+ // ledgers are passed in already-parsed (the CLI reads them via readLedger). We run
671
+ // buildHandoffModel + summarizeTasks ONCE and hand their honest output to the card
672
+ // builders. `hasOwnData` is the seed-honesty gate: TRUE only when there is at least
673
+ // one NON-seed row across the trust-bearing ledgers, so an empty/seed-only
674
+ // workspace is reported as "no data yet" instead of borrowing the example.
675
+ export function buildBootstrapModel({ ledgers, scan, handoffDraftCount = 0, dialogue = null }) {
676
+ const tasks = Array.isArray(ledgers.tasks) ? ledgers.tasks : [];
677
+ const evidence = Array.isArray(ledgers.evidence) ? ledgers.evidence : [];
678
+ const runs = Array.isArray(ledgers.runs) ? ledgers.runs : [];
679
+ const receipts = Array.isArray(ledgers.receipts) ? ledgers.receipts : [];
680
+ const learning = Array.isArray(ledgers.learning) ? ledgers.learning : [];
681
+
682
+ // The HONEST core: bucketing with re-computed levels (buildHandoffModel skips the
683
+ // seed task by default) + per-task achievement (summarizeTasks flags seeds).
684
+ const handoff = buildHandoffModel({ tasks, evidence, runs, receipts, learning });
685
+ const perTask = summarizeTasks(tasks, receipts, evidence, runs);
686
+
687
+ // Seed-honesty: count NON-seed rows. A brand-new workspace ships exactly one seed
688
+ // set (t0/e0/e1/r0/c0/l0); if every row is a seed, the user has no data of their
689
+ // own. We check each ledger with the shared isSeedRow so "what is a seed" stays
690
+ // defined in one place.
691
+ const nonSeed = (rows, key) => rows.filter((row) => !isSeedRow(row, key));
692
+ const ownTasks = nonSeed(tasks, "tasks");
693
+ const ownEvidence = nonSeed(evidence, "evidence");
694
+ const ownRuns = nonSeed(runs, "runs");
695
+ const ownReceipts = nonSeed(receipts, "receipts");
696
+ const ownLearning = nonSeed(learning, "learning");
697
+ const hasOwnData =
698
+ ownTasks.length > 0 ||
699
+ ownEvidence.length > 0 ||
700
+ ownRuns.length > 0 ||
701
+ ownReceipts.length > 0 ||
702
+ ownLearning.length > 0;
703
+
704
+ const verify = buildVerifyCard(handoff, perTask, dialogue);
705
+ const resume = buildResumeCard(handoff, perTask, scan, handoffDraftCount);
706
+ const harvest = buildHarvestCard(handoff, learning, dialogue);
707
+ // The two new DETERMINISTIC cards (red line: facts + recognisable evidence, never
708
+ // a semantic verdict). profile reads the scan only; roles reads the user's task
709
+ // titles (seeds excluded) + recently-touched file paths against a fixed high-risk
710
+ // keyword table. Neither writes or calls a model — pure reads, like the cards above.
711
+ const profile = buildProfileCard(scan);
712
+ const roles = buildRolesCard(tasks, scan);
713
+
714
+ // The dialogue scan is the user's OWN data (a chat THEY exported), so even a
715
+ // seed-only ledger gains a real baseline once a dialogue file is read — `dialogueUsed`
716
+ // lets the renderer show the cards (not the "no data yet" short-circuit) when there
717
+ // are dialogue findings, while the seed-honesty `hasOwnData` (ledger-only) is
718
+ // unchanged. Carried on the model so a --json consumer can branch on it too.
719
+ const dialogueUsed = dialogue && dialogue.used === true;
720
+
721
+ return {
722
+ reportOnly: true, // report-only; this model never drives a write.
723
+ hasOwnData,
724
+ seedOnly: !hasOwnData,
725
+ dialogueUsed: dialogueUsed === true,
726
+ // The transparency record (red line #5): which local files were read, how many
727
+ // flagged snippets, and the explicit "all local, nothing sent" promise. null when
728
+ // no dialogue/log file was provided (so the default report is byte-identical).
729
+ dialogue: dialogue
730
+ ? {
731
+ used: dialogue.used === true,
732
+ sources: dialogue.sources ?? [],
733
+ skipped: dialogue.skipped ?? [],
734
+ snippetCount: dialogue.snippetCount ?? 0
735
+ }
736
+ : null,
737
+ counts: {
738
+ ownTasks: ownTasks.length,
739
+ ownEvidence: ownEvidence.length,
740
+ ownRuns: ownRuns.length,
741
+ ownReceipts: ownReceipts.length,
742
+ ownLearning: ownLearning.length,
743
+ handoff: handoff.counts
744
+ },
745
+ scan,
746
+ // Card order in the model mirrors the render order: profile (a warm, factual
747
+ // opener) -> verify -> resume -> roles -> harvest. A --json consumer reads them
748
+ // by name, so the object-key order is for readability only.
749
+ cards: { profile, verify, resume, roles, harvest }
750
+ };
751
+ }
752
+
753
+ // --- D. Render (plain language first; terms go in details) ------------------
754
+ //
755
+ // The first screen avoids jargon (no L0-L4 / "receipt" / "harvest" theory up top).
756
+ // Each card leads with a plain sentence; the term (guard level, etc.) rides along
757
+ // only as a parenthetical detail. All numbers come from the real scan/model — there
758
+ // is NO hard-coded demo copy.
759
+
760
+ function renderVerifyCard(verify, locale = "en") {
761
+ const lines = [];
762
+ lines.push(t("bootstrap.verify.title", {}, locale));
763
+ // Split the ledger-sourced (trust-bearing) items from the dialogue-sourced
764
+ // candidates so the two are NEVER mixed: ledger facts first, then a clearly
765
+ // separated "from your chat" block (red line #4 — visual separation).
766
+ const ledgerItems = verify.filter((v) => v.source !== "dialogue");
767
+ const dialogueItems = verify.filter((v) => v.source === "dialogue");
768
+
769
+ if (ledgerItems.length === 0 && dialogueItems.length === 0) {
770
+ lines.push(t("bootstrap.verify.allClear1", {}, locale));
771
+ lines.push(t("bootstrap.verify.allClear2", {}, locale));
772
+ return lines.join("\n");
773
+ }
774
+ if (ledgerItems.length > 0) {
775
+ lines.push(t("bootstrap.verify.count", { count: ledgerItems.length, plural: ledgerItems.length === 1 ? "" : "s" }, locale));
776
+ for (const item of ledgerItems) {
777
+ const title = item.title && item.title.length > 0 ? item.title : t("common.untitled", {}, locale);
778
+ // Reason is rendered from the STABLE reason CODE (item.reason), localized here;
779
+ // the model's reasonText stays canonical English (a data contract), the display
780
+ // is translated — including the honesty wording, faithfully, never softened.
781
+ const reasonText = t(`bootstrap.verify.reason.${item.reason}`, {}, locale);
782
+ lines.push(t("bootstrap.verify.item", { taskId: item.taskId, title, reason: reasonText }, locale));
783
+ if (item.receipt) {
784
+ // Term detail (kept on its own indented line): the receipt + its RE-COMPUTED level.
785
+ // The self-declared-cross-family marker is rendered in the active locale (the
786
+ // honesty caveat must read in the user's language, faithfully translated).
787
+ const marker = item.familyMarker
788
+ ? ` [${t("marker.selfDeclaredCrossFamily", {}, locale)}]`
789
+ : "";
790
+ lines.push(
791
+ t("bootstrap.verify.detail", {
792
+ id: item.receipt.id,
793
+ verdict: item.receipt.verdict,
794
+ level: item.receipt.guardLevel,
795
+ status: item.receipt.status,
796
+ marker
797
+ }, locale)
798
+ );
799
+ }
800
+ }
801
+ }
802
+ // The dialogue-sourced block: a separate header that names where it came from and
803
+ // that it is unverified, then each claim with the "claimed in dialogue · not
804
+ // verified" wording (localized but never softened) + its redacted snippet.
805
+ if (dialogueItems.length > 0) {
806
+ lines.push(t("bootstrap.verify.dialogueHead", { count: dialogueItems.length, plural: dialogueItems.length === 1 ? "" : "s" }, locale));
807
+ for (const item of dialogueItems) {
808
+ lines.push(t("bootstrap.verify.dialogueItem", {
809
+ path: item.sourcePath,
810
+ line: item.line,
811
+ snippet: item.snippet
812
+ }, locale));
813
+ }
814
+ }
815
+ return lines.join("\n");
816
+ }
817
+
818
+ function renderResumeCard(resume, locale = "en") {
819
+ const lines = [];
820
+ lines.push(t("bootstrap.resume.title", {}, locale));
821
+ if (resume.activeTasks.length === 0) {
822
+ lines.push(t("bootstrap.resume.noActive", {}, locale));
823
+ } else {
824
+ lines.push(t("bootstrap.resume.count", { count: resume.activeTasks.length, plural: resume.activeTasks.length === 1 ? "" : "s" }, locale));
825
+ for (const task of resume.activeTasks) {
826
+ const title = task.title && task.title.length > 0 ? task.title : t("common.untitled", {}, locale);
827
+ lines.push(t("bootstrap.resume.item", { id: task.id, title, status: task.status }, locale));
828
+ }
829
+ }
830
+ if (resume.missingHandoff) {
831
+ lines.push(t("bootstrap.resume.missingHandoff", {}, locale));
832
+ }
833
+ if (resume.gitDrift.repeatedlyTouched.length > 0) {
834
+ const names = resume.gitDrift.repeatedlyTouched.map((r) => `${r.file} (×${r.commits})`).join(", ");
835
+ lines.push(t("bootstrap.resume.reTouch", { names }, locale));
836
+ lines.push(t("bootstrap.resume.reTouchNote", {}, locale));
837
+ }
838
+ if (resume.gitDrift.hasUncommittedChanges) {
839
+ lines.push(t("bootstrap.resume.uncommitted", {}, locale));
840
+ }
841
+ return lines.join("\n");
842
+ }
843
+
844
+ function renderHarvestCard(harvest, locale = "en") {
845
+ const lines = [];
846
+ lines.push(t("bootstrap.harvest.title", {}, locale));
847
+ const dialogueCandidates = harvest.dialogueCandidates ?? [];
848
+ if (
849
+ harvest.confirmedLearnings.length === 0 &&
850
+ harvest.candidates.length === 0 &&
851
+ dialogueCandidates.length === 0
852
+ ) {
853
+ lines.push(t("bootstrap.harvest.none1", {}, locale));
854
+ lines.push(t("bootstrap.harvest.none2", {}, locale));
855
+ return lines.join("\n");
856
+ }
857
+ for (const c of harvest.candidates) {
858
+ // Render the localized detail from the stable `kind` + `count`; fall back to the
859
+ // model's English `detail` if a kind has no message key (never an empty line).
860
+ const key = `bootstrap.harvest.detail.${c.kind}`;
861
+ const localizedDetail = t(key, { count: c.count, plural: c.count === 1 ? "" : "s" }, locale);
862
+ const detail = localizedDetail === key ? c.detail : localizedDetail;
863
+ lines.push(t("bootstrap.harvest.candidate", { detail }, locale));
864
+ }
865
+ if (harvest.confirmedLearnings.length > 0) {
866
+ lines.push(t("bootstrap.harvest.confirmedHead", {}, locale));
867
+ for (const l of harvest.confirmedLearnings) {
868
+ lines.push(t("bootstrap.harvest.confirmedItem", { type: l.type, content: l.content }, locale));
869
+ }
870
+ }
871
+ // The dialogue-sourced profile candidates: a SEPARATE block, labelled as coming
872
+ // from the chat and PROPOSED (nothing saved). Kept apart from the user's own
873
+ // confirmed learnings above so a chat-derived guess is never shown as a kept fact.
874
+ if (dialogueCandidates.length > 0) {
875
+ lines.push(t("bootstrap.harvest.dialogueHead", {}, locale));
876
+ for (const c of dialogueCandidates) {
877
+ lines.push(t("bootstrap.harvest.dialogueItem", {
878
+ count: c.count,
879
+ snippet: c.snippet
880
+ }, locale));
881
+ }
882
+ }
883
+ return lines.join("\n");
884
+ }
885
+
886
+ // Render the PROFILE-CLUES card. Leads the report with recognisable facts (a warm,
887
+ // factual opener), then a FIXED footer that states the honesty contract out loud:
888
+ // these are clues, not a conclusion, and the user's own ai will confirm the real
889
+ // work-style profile. Every line is a fact from the scan; none is a judgment.
890
+ function renderProfileCard(profile, locale = "en") {
891
+ const lines = [];
892
+ lines.push(t("bootstrap.profile.title", {}, locale));
893
+
894
+ const tools = Array.isArray(profile.detectedTools) ? profile.detectedTools : [];
895
+ if (tools.length > 0) {
896
+ lines.push(t("bootstrap.profile.tools", { tools: tools.join(", ") }, locale));
897
+ // More than one tool is a cross-tool FACT (two tools configured), surfaced as a
898
+ // collaboration SIGNAL — never "you like working across tools" as a verdict.
899
+ if (profile.multiTool) {
900
+ lines.push(t("bootstrap.profile.multiTool", {}, locale));
901
+ }
902
+ } else {
903
+ lines.push(t("bootstrap.profile.noTools", {}, locale));
904
+ }
905
+
906
+ const fileTypes = Array.isArray(profile.fileTypes) ? profile.fileTypes : [];
907
+ if (fileTypes.length > 0) {
908
+ const names = fileTypes.map((f) => `${f.ext} (×${f.count})`).join(", ");
909
+ lines.push(t("bootstrap.profile.fileTypes", { names }, locale));
910
+ }
911
+
912
+ if (profile.hasTestScript) {
913
+ lines.push(t("bootstrap.profile.testScript", {}, locale));
914
+ }
915
+
916
+ // The fixed footer — the load-bearing honesty line. Always present, never softened:
917
+ // "these are clues, not a conclusion; your ai will confirm the full profile."
918
+ lines.push(t("bootstrap.profile.footer", {}, locale));
919
+ return lines.join("\n");
920
+ }
921
+
922
+ // Render the ROLES-SUGGESTION card. Each hit shows the exact matched evidence (the
923
+ // task title or file path + the keyword) and the suggested role(s) with a plain-
924
+ // language simile, so the user sees WHY a role is suggested — a fact + a fixed
925
+ // mapping, never "this work is dangerous" as a verdict. No hit -> an honest "no
926
+ // high-risk keywords matched — a keyword scan only, not a low-risk verdict" line.
927
+ function renderRolesCard(roles, locale = "en") {
928
+ const lines = [];
929
+ lines.push(t("bootstrap.roles.title", {}, locale));
930
+
931
+ if (!roles.hasHighRisk) {
932
+ // Honest no-match: do not manufacture a suggestion. Say plainly that nothing
933
+ // high-risk was scanned and a single tool is enough until the user needs more.
934
+ lines.push(t("bootstrap.roles.none", {}, locale));
935
+ return lines.join("\n");
936
+ }
937
+
938
+ lines.push(t("bootstrap.roles.intro", { count: roles.hits.length, plural: roles.hits.length === 1 ? "" : "s" }, locale));
939
+ for (const hit of roles.hits) {
940
+ // The role names + their plain-language similes, joined. The simile lives in
941
+ // i18n (e.g. "red-team = someone whose whole job is to poke holes"), so the
942
+ // role label is never a bare term wall on the user's first read.
943
+ const roleText = hit.roles
944
+ .map((id) => t(`bootstrap.roles.role.${id}`, {}, locale))
945
+ .join(t("bootstrap.roles.roleJoin", {}, locale));
946
+ // The matched SUBJECT is shown verbatim (the task title or file path), then the
947
+ // keyword, then the suggestion — the full evidence chain, localized but faithful.
948
+ lines.push(t("bootstrap.roles.item", {
949
+ subject: hit.subject,
950
+ keyword: hit.keyword,
951
+ roles: roleText
952
+ }, locale));
953
+ }
954
+ return lines.join("\n");
955
+ }
956
+
957
+ // The whole report as plain text. Leads with the honest framing, then the three
958
+ // Build the transparency block (red line #5) for a report that read a local dialogue
959
+ // or log export: which files were read (path + line count), how many snippets were
960
+ // flagged, the explicit "all local, nothing sent" promise, and a note for any file
961
+ // that was skipped (missing / unreadable / unsupported). Returns [] when no dialogue
962
+ // file was provided, so the default report stays byte-identical. Every snippet shown
963
+ // downstream is already redacted; this header only names files + counts.
964
+ function dialogueTransparencyLines(model, locale = "en") {
965
+ const d = model.dialogue;
966
+ if (!d || d.used !== true) {
967
+ // Even with no readable source, if the user NAMED files that were all skipped, be
968
+ // transparent about that (so a typo'd path is visible, not silently ignored).
969
+ if (d && Array.isArray(d.skipped) && d.skipped.length > 0) {
970
+ const out = [t("bootstrap.dialogue.skippedHead", {}, locale)];
971
+ for (const s of d.skipped) {
972
+ out.push(t(`bootstrap.dialogue.skipped.${s.reason}`, { path: s.path }, locale));
973
+ }
974
+ return out;
975
+ }
976
+ return [];
977
+ }
978
+ const out = [];
979
+ const fileList = d.sources.map((s) => `${s.path} (${s.lines})`).join(", ");
980
+ out.push(t("bootstrap.dialogue.head", {
981
+ count: d.sources.length,
982
+ plural: d.sources.length === 1 ? "" : "s",
983
+ files: fileList,
984
+ snippets: d.snippetCount
985
+ }, locale));
986
+ out.push(t("bootstrap.dialogue.localPromise", {}, locale));
987
+ if (Array.isArray(d.skipped) && d.skipped.length > 0) {
988
+ for (const s of d.skipped) {
989
+ out.push(t(`bootstrap.dialogue.skipped.${s.reason}`, { path: s.path }, locale));
990
+ }
991
+ }
992
+ return out;
993
+ }
994
+
995
+ // cards, then a single concrete next step. Used for the non-JSON output.
996
+ export function renderBootstrapReport(model, locale = "en") {
997
+ const lines = [];
998
+ lines.push(t("bootstrap.report.title", {}, locale));
999
+ lines.push(t("bootstrap.report.scanned", { repoRoot: model.scan.repoRoot }, locale));
1000
+ lines.push(t("bootstrap.report.readonly", {}, locale));
1001
+ // Transparency (red line #5): when a local dialogue/log export was read, state at
1002
+ // the TOP exactly which files, how many flagged snippets, and that it stayed local.
1003
+ for (const line of dialogueTransparencyLines(model, locale)) lines.push(line);
1004
+ lines.push("");
1005
+
1006
+ if (!model.hasOwnData && !model.dialogueUsed) {
1007
+ // Seed-honesty: no data of the user's own AND no dialogue handed over. Do NOT
1008
+ // dress up the shipped example. (If a dialogue WAS read, it is the user's own data,
1009
+ // so we fall through and render the cards with the dialogue findings.)
1010
+ lines.push(t("bootstrap.empty.line1", {}, locale));
1011
+ lines.push(t("bootstrap.empty.line2", {}, locale));
1012
+ lines.push(t("bootstrap.empty.step1", {}, locale));
1013
+ lines.push(t("bootstrap.empty.step2", {}, locale));
1014
+ lines.push("");
1015
+ lines.push(t("bootstrap.empty.note", {}, locale));
1016
+ return lines.join("\n");
1017
+ }
1018
+
1019
+ // Card order (per the product brief): profile leads with recognisable facts so the
1020
+ // first screen earns goodwill, then VERIFY (the most urgent honesty signal), RESUME,
1021
+ // the roles suggestion (high-risk -> bring help), and HARVEST. The two new cards are
1022
+ // DETERMINISTIC fact lists; VERIFY + roles remain the load-bearing content.
1023
+ lines.push(renderProfileCard(model.cards.profile, locale));
1024
+ lines.push("");
1025
+ lines.push(renderVerifyCard(model.cards.verify, locale));
1026
+ lines.push("");
1027
+ lines.push(renderResumeCard(model.cards.resume, locale));
1028
+ lines.push("");
1029
+ lines.push(renderRolesCard(model.cards.roles, locale));
1030
+ lines.push("");
1031
+ lines.push(renderHarvestCard(model.cards.harvest, locale));
1032
+ lines.push("");
1033
+ // One concrete next step, chosen from the real model — and it points at an
1034
+ // EXISTING audited command with the real id filled in, so the user can act
1035
+ // without translating the advice into a command (and without inventing a flag).
1036
+ for (const line of bootstrapNextStepLines(model, locale)) lines.push(line);
1037
+ return lines.join("\n");
1038
+ }
1039
+
1040
+ // Build the "Next:" block for the bootstrap report. Returns 1-2 lines: a plain
1041
+ // sentence plus, when applicable, a real copy-pasteable command with the actual id
1042
+ // filled in. Ordered VERIFY (most urgent) -> RESUME -> HARVEST -> all-clear. Every
1043
+ // command is one the CLI already ships and audits (receipt accept / receipt create /
1044
+ // run exec / handoff create / learning confirm) — bootstrap stays report-only and
1045
+ // never invents a write path (no --save-safe; the only sanctioned write is the
1046
+ // existing learning add/confirm + receipt flow the user runs themselves).
1047
+ export function bootstrapNextStepLines(model, locale = "en") {
1048
+ // The PRIMARY next step keeps its original priority ladder VERIFY -> RESUME ->
1049
+ // HARVEST -> all-clear (VERIFY is the most urgent honesty signal, so it stays the
1050
+ // headline action). The roles HINT is APPENDED after it (not in place of it) when
1051
+ // high-risk roles were suggested, so the user is told to bring help without burying
1052
+ // the verify action. A roles hint is advisory — it points at an EXISTING role
1053
+ // package to read, never a write path.
1054
+ const primary = bootstrapPrimaryNextStep(model, locale);
1055
+
1056
+ // Append the high-risk roles hint, if any. The roles card already de-duped + capped
1057
+ // the suggestions; we surface their plain-language names so the user knows who to
1058
+ // bring — the spec's "if high-risk roles were suggested, fold them into Next".
1059
+ const roles = model.cards.roles;
1060
+ if (roles && roles.hasHighRisk && Array.isArray(roles.roles) && roles.roles.length > 0) {
1061
+ const roleText = roles.roles
1062
+ .map((r) => t(`bootstrap.roles.role.${r.id}`, {}, locale))
1063
+ .join(t("bootstrap.roles.roleJoin", {}, locale));
1064
+ return [...primary, t("bootstrap.next.roles.text", { roles: roleText }, locale)];
1065
+ }
1066
+ return primary;
1067
+ }
1068
+
1069
+ // The primary next-step lines (the original VERIFY -> RESUME -> HARVEST -> all-clear
1070
+ // ladder). Split out so bootstrapNextStepLines can append the roles hint after it
1071
+ // without duplicating the ladder. Returns 1-2 lines: a plain sentence + an optional
1072
+ // real copy-pasteable command. Every command is one the CLI already ships and audits.
1073
+ function bootstrapPrimaryNextStep(model, locale = "en") {
1074
+ // (1) VERIFY (ledger): something is claimed done but cannot be trusted. Point at the
1075
+ // real command that closes the specific gap, with the offending id filled in. We
1076
+ // deliberately pick the first LEDGER item (not a dialogue one) for the receipt-
1077
+ // based commands, since only a ledger item carries a taskId/receipt to act on.
1078
+ const verifyItem = model.cards.verify.find((v) => v.source !== "dialogue");
1079
+ if (verifyItem) {
1080
+ // A receipt that is merely PENDING (a pass_with_risk awaiting sign-off) closes
1081
+ // with an owner acceptance — the lightest real action, so suggest it directly.
1082
+ if (verifyItem.receipt && verifyItem.receipt.status === "pending") {
1083
+ return [
1084
+ t("bootstrap.next.pending.text", { receiptId: verifyItem.receipt.id, taskId: verifyItem.taskId }, locale),
1085
+ t("bootstrap.next.pending.cmd", { receiptId: verifyItem.receipt.id }, locale)
1086
+ ];
1087
+ }
1088
+ // A self-declared cross-family pass is accepted but UNVERIFIED: the honest way to
1089
+ // strengthen it is a reviewer rerun reconciled to a recorded run exec (the L4 path).
1090
+ if (verifyItem.reason === "self_declared_cross_family") {
1091
+ return [
1092
+ t("bootstrap.next.selfCross.text", { receiptId: verifyItem.receipt ? verifyItem.receipt.id : "(none)", taskId: verifyItem.taskId }, locale),
1093
+ t("bootstrap.next.selfCross.cmd", { taskId: verifyItem.taskId }, locale)
1094
+ ];
1095
+ }
1096
+ // Author-marked done with no accepted review behind it: file a real receipt
1097
+ // (after the evidence exists) so the "done" is backed by the guard.
1098
+ return [
1099
+ t("bootstrap.next.authorDone.text", { taskId: verifyItem.taskId }, locale),
1100
+ t("bootstrap.next.authorDone.cmd", { taskId: verifyItem.taskId }, locale)
1101
+ ];
1102
+ }
1103
+
1104
+ // (1b) VERIFY (dialogue): no ledger item, but the chat export claims a "done" the
1105
+ // ledger does not back. The honest next step is to turn that claim into a real,
1106
+ // tracked task + recorded run (so the "done" stops being just words). Points at
1107
+ // `task create`, an existing audited command — never invents a write path.
1108
+ const dialogueVerify = model.cards.verify.find((v) => v.source === "dialogue");
1109
+ if (dialogueVerify) {
1110
+ return [
1111
+ t("bootstrap.next.dialogueClaim.text", {}, locale),
1112
+ t("bootstrap.next.dialogueClaim.cmd", {}, locale)
1113
+ ];
1114
+ }
1115
+
1116
+ // (2) RESUME: work is in flight but there is no handoff draft to resume from.
1117
+ if (model.cards.resume.missingHandoff) {
1118
+ return [
1119
+ t("bootstrap.next.missingHandoff.text", {}, locale),
1120
+ t("bootstrap.next.missingHandoff.cmd", {}, locale)
1121
+ ];
1122
+ }
1123
+
1124
+ // (3) HARVEST: a proposed lesson of your own is waiting to be kept — confirming it
1125
+ // graduates it into your profile (the real, audited write path).
1126
+ const proposed = model.cards.harvest.proposedLearnings && model.cards.harvest.proposedLearnings[0];
1127
+ if (proposed) {
1128
+ return [
1129
+ t("bootstrap.next.keepLesson.text", { id: proposed.id }, locale),
1130
+ t("bootstrap.next.keepLesson.cmd", { id: proposed.id }, locale)
1131
+ ];
1132
+ }
1133
+
1134
+ // (4) Nothing outstanding.
1135
+ return [
1136
+ t("bootstrap.next.allClear.text", {}, locale)
1137
+ ];
1138
+ }
1139
+
1140
+ // --- D. Consent preview (printed before the scan unless --yes) --------------
1141
+ //
1142
+ // bootstrap reads local files + runs read-only git. Before doing so it prints
1143
+ // EXACTLY what it will read and confirms it stays local, then (without --yes) stops
1144
+ // and asks the user to re-run with --yes. The CLI is non-interactive, so "consent"
1145
+ // is an explicit re-run, not a y/n prompt — but the scope is shown first either way.
1146
+ export function renderConsentPreview(repoRoot, locale = "en", dialogueSources = []) {
1147
+ const lines = [
1148
+ t("bootstrap.consent.head", {}, locale),
1149
+ t("bootstrap.consent.repo", { repoRoot }, locale),
1150
+ t("bootstrap.consent.git", {}, locale),
1151
+ t("bootstrap.consent.ledger", {}, locale),
1152
+ t("bootstrap.consent.ai", {}, locale)
1153
+ ];
1154
+ // The dialogue/log connectors are a HIGH-PRIVACY source that is OFF unless the user
1155
+ // EXPLICITLY named a file. Only when they did do we list those files in the consent
1156
+ // scope (so the preview names exactly what extra will be read); otherwise the scope
1157
+ // is identical to before — the connector defaults to "not read".
1158
+ if (Array.isArray(dialogueSources) && dialogueSources.length > 0) {
1159
+ lines.push(t("bootstrap.consent.dialogue", { files: dialogueSources.join(", ") }, locale));
1160
+ }
1161
+ lines.push("");
1162
+ lines.push(t("bootstrap.consent.promise", {}, locale));
1163
+ lines.push(t("bootstrap.consent.rerun", {}, locale));
1164
+ lines.push(t("bootstrap.consent.cmd", {}, locale));
1165
+ return lines.join("\n");
1166
+ }
1167
+
1168
+ // TODO (next sub-batch / Owner's call — deliberately NOT here):
1169
+ // - the EXTERNAL-model half of the semantic scan (`--send-to-model`): send the
1170
+ // redacted snippets to a model for a richer read. Kept out HERE: this batch is the
1171
+ // LOCAL half only — red line #1 is deterministic + no-network, so the model pass is
1172
+ // a separate, explicitly-consented step. redactSnippet (dialogue.js) is already
1173
+ // shared so that path reuses the exact same redaction before anything is sent.
1174
+ // - a save / write-back flow (--save-safe) that promotes a HARVEST candidate into
1175
+ // the profile. This batch is report-only and writes NOTHING; the proposed/confirmed
1176
+ // buffer (learning add/confirm) is the only sanctioned write path today.
1177
+ // - a GUI / richer rendering.
1178
+ //
1179
+ // DONE in this batch (the local half of semantic scanning, see dialogue.js):
1180
+ // - opt-in local connectors `--dialogue` / `--logs` (read only files the user names).
1181
+ // - deterministic completion-claim extraction cross-referenced against the ledger,
1182
+ // surfaced as VERIFY findings "claimed in dialogue · not verified" (never done).
1183
+ // - repeated-correction -> HARVEST *profile* candidate (proposed; nothing written).
1184
+ // - per-snippet redaction (redactSnippet) before anything is shown or recorded.