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,487 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { findForbiddenPackFiles } from "./lib/forbidden-in-pack.js";
7
+
8
+ const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
9
+
10
+ function parseArgs(argv) {
11
+ const args = {};
12
+ for (let index = 0; index < argv.length; index += 1) {
13
+ const flag = argv[index];
14
+ if (flag === "--workspace") {
15
+ args.workspace = argv[index + 1];
16
+ index += 1;
17
+ } else if (flag === "--no-extras") {
18
+ // Skip the side-effecting scan surfaces (adapters-install output + npm pack
19
+ // file list). Lets the scanner run as a pure read-only pass over a target
20
+ // directory in a strict sandbox, instead of crashing on EPERM.
21
+ args.noExtras = true;
22
+ } else if (flag === "--strict") {
23
+ args.strict = true;
24
+ }
25
+ }
26
+ return args;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Maintainable denylist / bilingual allowlist (privacy-manifest.json).
31
+ // The scanner reads the manifest so the forbidden paths/terms and the sanctioned
32
+ // bilingual surface can be extended without editing scanner code. If the manifest
33
+ // is missing or malformed we fall back to safe built-in defaults and warn.
34
+ // ---------------------------------------------------------------------------
35
+ const DEFAULT_DENYLIST = {
36
+ // Note: absolute home prefixes like the users/home roots are intentionally NOT
37
+ // string literals here — the alwaysForbidden regexes already hard-block real
38
+ // local paths everywhere, and putting them as plain strings would make this
39
+ // scanner file flag itself. The manifest `paths` array is the extension point.
40
+ //
41
+ // Keep this list GENERIC (anyone-applies). `.claude/hooks` is the standard
42
+ // Claude Code hooks directory that every Claude Code user has, so it is a safe
43
+ // generic marker for "don't paste your own private hook contents". A
44
+ // maintainer's OWN private dir names (a personal governance folder, a private
45
+ // knowledge base) are NOT listed here — that would ship the maintainer's
46
+ // private dir name in the public package. Users add their own via the
47
+ // gitignored privacy-scan.local.json (see privacy-scan.local.json.example).
48
+ paths: [".claude/hooks"],
49
+ terms: [],
50
+ chineseTerms: []
51
+ };
52
+ const DEFAULT_BILINGUAL = {
53
+ sectionMarkers: ["中文"],
54
+ lineAllow: [],
55
+ // Whole-file sanctioned bilingual SOURCES (relative posix paths). A file listed
56
+ // here is the product's intentional bilingual surface (e.g. the i18n message
57
+ // catalog), so the Chinese-leak HEURISTIC (the unmarked-run check) does not flag
58
+ // its localized strings. This is the manifest-driven extension point for "this
59
+ // file is bilingual by design", parallel to how the scanner already treats its own
60
+ // policy files. IMPORTANT: it ONLY relaxes the run heuristic — the hard token/key/
61
+ // local-path/email rules AND the chineseTerms denylist still apply, so a real
62
+ // secret or a denylisted private Chinese term in such a file STILL fails the scan.
63
+ sanctionedFiles: [],
64
+ zhKeyAllowed: true,
65
+ contextWindow: 12,
66
+ minRun: 4
67
+ };
68
+
69
+ // Optional, gitignored local override. A maintainer keeps their OWN private dir
70
+ // names / terms here instead of in the shipped manifest, so the public package
71
+ // never names them. When present, its arrays are appended onto the manifest's.
72
+ function loadLocalOverride() {
73
+ const localPath = path.join(repoRoot, "privacy-scan.local.json");
74
+ if (!existsSync(localPath)) return null;
75
+ try {
76
+ return JSON.parse(readFileSync(localPath, "utf8"));
77
+ } catch (error) {
78
+ console.warn(`privacy-scan: could not parse privacy-scan.local.json (${error.message}); ignoring local overrides.`);
79
+ return null;
80
+ }
81
+ }
82
+
83
+ // Merge denylist arrays from manifest + local override on top of the generic
84
+ // built-in defaults. Arrays are concatenated and de-duplicated so a user's local
85
+ // private markers extend (never replace) the shipped generic ones.
86
+ function mergeDenylist(base, ...overlays) {
87
+ const merged = { paths: [...(base.paths ?? [])], terms: [...(base.terms ?? [])], chineseTerms: [...(base.chineseTerms ?? [])] };
88
+ for (const overlay of overlays) {
89
+ if (!overlay) continue;
90
+ for (const key of ["paths", "terms", "chineseTerms"]) {
91
+ if (Array.isArray(overlay[key])) merged[key].push(...overlay[key]);
92
+ }
93
+ }
94
+ for (const key of ["paths", "terms", "chineseTerms"]) {
95
+ merged[key] = [...new Set(merged[key].filter((entry) => typeof entry === "string" && entry.length > 0))];
96
+ }
97
+ return merged;
98
+ }
99
+
100
+ function loadManifest() {
101
+ const localOverride = loadLocalOverride();
102
+ const manifestPath = path.join(repoRoot, "privacy-manifest.json");
103
+ if (!existsSync(manifestPath)) {
104
+ console.warn("privacy-scan: privacy-manifest.json not found; using built-in denylist defaults.");
105
+ return { denylist: mergeDenylist(DEFAULT_DENYLIST, localOverride), bilingual: DEFAULT_BILINGUAL, publicContacts: [] };
106
+ }
107
+ try {
108
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
109
+ return {
110
+ denylist: mergeDenylist(DEFAULT_DENYLIST, manifest.scanDenylist ?? {}, localOverride),
111
+ bilingual: { ...DEFAULT_BILINGUAL, ...(manifest.bilingual ?? {}) },
112
+ publicContacts: Array.isArray(manifest.publicContacts) ? manifest.publicContacts : []
113
+ };
114
+ } catch (error) {
115
+ console.warn(`privacy-scan: could not parse privacy-manifest.json (${error.message}); using built-in denylist defaults.`);
116
+ return { denylist: mergeDenylist(DEFAULT_DENYLIST, localOverride), bilingual: DEFAULT_BILINGUAL, publicContacts: [] };
117
+ }
118
+ }
119
+
120
+ const { denylist, bilingual, publicContacts } = loadManifest();
121
+ // EXACT-string allowlist for the email rule below. The scanner blocks EVERY email
122
+ // address EXCEPT one that exactly matches an entry here — a narrow, explainable
123
+ // exemption for the project's PUBLISHED public contact only (see manifest
124
+ // publicContacts), NOT a domain or category allowlist: a different address, even at
125
+ // the same domain, still fails. Lower-cased so the comparison is case-insensitive.
126
+ const publicContactEmails = new Set((publicContacts ?? []).map((entry) => String(entry).toLowerCase()));
127
+ // The set of private directory names to also block from the npm tarball: the
128
+ // scanner's path denylist (generic + manifest + local). Generic literals like
129
+ // `.claude/` already live in the shared pack rules; the extra dirs here cover a
130
+ // user's configured private dirs without baking their names into shipped code.
131
+ const configuredPrivateDirs = denylist.paths ?? [];
132
+
133
+ function escapeRegExp(value) {
134
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
135
+ }
136
+
137
+ const sectionMarkerRegexes = (bilingual.sectionMarkers ?? []).map((marker) => new RegExp(escapeRegExp(marker)));
138
+ const lineAllowRegexes = (bilingual.lineAllow ?? []).map((pattern) => new RegExp(pattern));
139
+ // Normalize the whole-file sanctioned bilingual sources to a set of posix-relative
140
+ // paths for an exact match in scanFile.
141
+ const sanctionedBilingualFiles = new Set(
142
+ (bilingual.sanctionedFiles ?? []).map((p) => String(p).split(path.sep).join("/"))
143
+ );
144
+ const cjkRun = new RegExp(`[\\u4e00-\\u9fff]{${Math.max(1, bilingual.minRun ?? 4)},}`);
145
+ const cjkAny = /[一-鿿]/;
146
+
147
+ function walk(root, files = []) {
148
+ if (!existsSync(root)) return files;
149
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
150
+ if ([".git", "node_modules", ".DS_Store"].includes(entry.name)) continue;
151
+ const fullPath = path.join(root, entry.name);
152
+ if (entry.isDirectory()) walk(fullPath, files);
153
+ else files.push(fullPath);
154
+ }
155
+ return files;
156
+ }
157
+
158
+ function isPolicyFile(file, root) {
159
+ const relative = path.relative(root, file).split(path.sep).join("/");
160
+ const name = path.basename(file).toLowerCase();
161
+ if (relative === "scripts/privacy-scan.js") return true;
162
+ // pack-check.js is a release-safety enforcement script: it legitimately
163
+ // enumerates the forbidden-to-ship patterns (.env, governance dirs, key
164
+ // material), so it is treated as a policy file like the scanner itself.
165
+ if (relative === "scripts/pack-check.js") return true;
166
+ // The shared forbidden-in-pack rule module likewise names the generic
167
+ // forbidden patterns (.env, .claude, secrets, key material) as regex literals
168
+ // by design. It does not bake in any maintainer-private dir names.
169
+ if (relative === "scripts/lib/forbidden-in-pack.js") return true;
170
+ if (relative === "privacy-manifest.json") return true;
171
+ if (relative.startsWith(".aict/privacy/")) return true;
172
+ if (relative.startsWith("docs/") && (
173
+ name.includes("privacy") ||
174
+ name.includes("boundary") ||
175
+ name.includes("redaction") ||
176
+ name.includes("mapping") ||
177
+ name.includes("security") ||
178
+ name.includes("manifest")
179
+ )) {
180
+ return true;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ // A Chinese run on a given line is "sanctioned" (part of the intentional
186
+ // bilingual surface) if the line itself is allowlisted, is a localized `zh:`
187
+ // string, or sits inside a bilingual block opened by a section marker within the
188
+ // configured context window. Everything else (stray, unmarked Chinese) is treated
189
+ // as a likely paste of private material and fails the scan.
190
+ function buildSanctionedLineSet(lines) {
191
+ const sanctioned = new Set();
192
+ let blockUntil = -1;
193
+ for (let i = 0; i < lines.length; i += 1) {
194
+ const line = lines[i];
195
+ if (sectionMarkerRegexes.some((re) => re.test(line))) {
196
+ blockUntil = i + (bilingual.contextWindow ?? 12);
197
+ }
198
+ if (i <= blockUntil) sanctioned.add(i);
199
+ if (lineAllowRegexes.some((re) => re.test(line))) sanctioned.add(i);
200
+ if (bilingual.zhKeyAllowed && /(^|[^\w])zh\s*:/.test(line)) sanctioned.add(i);
201
+ }
202
+ return sanctioned;
203
+ }
204
+
205
+ function scanChinese(content, relative, isPolicy, isBilingualFile = false) {
206
+ const errors = [];
207
+ // Policy/manifest/boundary files legitimately enumerate the forbidden Chinese
208
+ // terms and discuss the bilingual surface, so the whole Chinese pass is gated
209
+ // the same way as the English privateBoundaryPatterns. A real leak lands in a
210
+ // non-policy file (a doc, a template, a generated artifact), which is still
211
+ // scanned in full.
212
+ if (isPolicy) return errors;
213
+ // Hard denylist of known private Chinese terms — forbidden anywhere outside
214
+ // policy files, even on an otherwise-sanctioned bilingual line OR a whole-file
215
+ // sanctioned bilingual source. This stays ENFORCED so a private term like
216
+ // "真实客户" can never hide inside the i18n catalog either.
217
+ for (const term of denylist.chineseTerms ?? []) {
218
+ if (content.includes(term)) {
219
+ errors.push(`${relative}: contains denylisted Chinese private term "${term}"`);
220
+ }
221
+ }
222
+
223
+ // A whole-file sanctioned bilingual source (the i18n message catalog) is allowed
224
+ // to be densely Chinese, so the unmarked-run HEURISTIC below would only produce
225
+ // noise. We skip just that heuristic for these files — the denylist above (and the
226
+ // token/key/path/email rules in scanFile) still apply.
227
+ if (isBilingualFile) return errors;
228
+
229
+ const lines = content.split(/\r?\n/);
230
+ const sanctioned = buildSanctionedLineSet(lines);
231
+ for (let i = 0; i < lines.length; i += 1) {
232
+ if (!cjkAny.test(lines[i])) continue;
233
+ if (sanctioned.has(i)) continue;
234
+ if (cjkRun.test(lines[i])) {
235
+ const sample = (lines[i].match(cjkRun) || [""])[0].slice(0, 24);
236
+ errors.push(`${relative}:${i + 1}: contains unsanctioned Chinese text (possible private leak): "${sample}…"`);
237
+ }
238
+ }
239
+ return errors;
240
+ }
241
+
242
+ function scanFile(file, root) {
243
+ const content = readFileSync(file, "utf8");
244
+ const relative = path.relative(root, file);
245
+ const errors = [];
246
+
247
+ const alwaysForbidden = [
248
+ [/\/Users\/[^/\s]+(?:\/[^\s`'")]+)?/g, "local machine path"],
249
+ [/\/home\/[^/\s]+(?:\/[^\s`'")]+)?/g, "local machine path"],
250
+ [/[A-Za-z]:\\Users\\[^\\\s]+(?:\\[^\s`'")]+)?/g, "local machine path"],
251
+ // Home-directory path VARIANTS, parallel to the absolute /Users//home//C:\Users
252
+ // rules above. These are still GENERIC, anyone-applies markers (env-var home
253
+ // expansions and tilde home) — they name no specific private directory, so they
254
+ // do not bake any maintainer dir name into the shipped scanner.
255
+ // POSIX env-var home expansion (dollar-HOME, optionally brace-wrapped).
256
+ // Example forms are not written literally here so the scanner does not flag
257
+ // its own comment; see tests/contract.test.js for the concrete fixtures.
258
+ [/\$\{?HOME\}?\/[^\s`'")]+/g, "local machine path"],
259
+ // Windows env-var home expansion (percent-USERPROFILE-percent backslash ...).
260
+ // The percent signs are written as [%] char-classes so this regex literal does
261
+ // not match itself when the scanner scans its own source (same self-exemption
262
+ // technique as the escaped $HOME rule above); the matched input is identical.
263
+ [/[%]USERPROFILE[%]\\[^\s`'")]+/gi, "local machine path"],
264
+ // Tilde home subtree (tilde-slash named-dir slash more). Deliberately NARROW:
265
+ // it requires a NON-dot first segment plus a deeper level, so it flags a leaked
266
+ // real home path (a personal docs/desktop subtree) but NOT a bare tilde-slash, a
267
+ // single-segment tilde-slash-foo, or a standard dot tool/config dir (the npm log,
268
+ // config, or ssh dotfile dirs). Those dotfile dirs are generic-and-benign (the
269
+ // same stance as the Claude hooks dir); a maintainer's OWN private dir name is
270
+ // matched instead via the manifest/local denylist.paths loop, never hard-coded
271
+ // here. Concrete matchable examples live only in the test fixtures, never in
272
+ // this scanned source.
273
+ [/~\/(?!\.)[^/\s`'")]+\/[^\s`'")]+/g, "local machine path"],
274
+ [/\bgh[pousr]_[A-Za-z0-9_]{20,}/g, "GitHub token"],
275
+ [/\bgithub_pat_[A-Za-z0-9_]{30,}/g, "GitHub token"],
276
+ [/\bxox[baprs]-[A-Za-z0-9-]{20,}/g, "Slack token"],
277
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/gi, "Bearer token"],
278
+ [/\bapiKey\s*[:=]\s*["'][^"']{16,}["']/g, "apiKey"],
279
+ [/\b(?:api[_-]?key|token|secret)\s*[:=]\s*["'][^"']{12,}["']/gi, "API key / token / secret"],
280
+ [/(?<![A-Za-z0-9_])(?:\+?\d{1,3}[\s.-])?(?:\(?\d{3}\)?[\s.-])\d{3}[\s.-]\d{4}(?![A-Za-z0-9_])/g, "phone number"],
281
+ [/(?<![\d.\-])1[3-9]\d{9}(?![\d.\-])/g, "Chinese mobile number"],
282
+ [/sk-[A-Za-z0-9_-]{20,}/g, "OpenAI-style secret key"],
283
+ [/AKIA[0-9A-Z]{16}/g, "AWS access key"],
284
+ [/AIza[0-9A-Za-z_-]{20,}/g, "Google API key"],
285
+ [/-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/g, "private key"],
286
+ [/sessionid\s*[:=]\s*[A-Za-z0-9_.-]{12,}/gi, "session id"],
287
+ [/password\s*[:=]\s*['"][^'"]{6,}['"]/gi, "literal password"],
288
+ [/(?<![A-Za-z0-9])[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?![A-Za-z0-9])/g, "UUID-style session id"]
289
+ ];
290
+
291
+ for (const [regex, label] of alwaysForbidden) {
292
+ if (regex.test(content)) errors.push(`${relative}: contains ${label}`);
293
+ }
294
+
295
+ // Email addresses: any address is a likely private leak EXCEPT one that EXACTLY
296
+ // matches the project's published public contact (manifest publicContacts). A
297
+ // different address — even at the same domain — still fails. This is what keeps a
298
+ // legitimate "contact: <public email>" footer in README/SECURITY/package.json from
299
+ // tripping the gate, without opening a domain or whole-category allowlist. Applied
300
+ // unconditionally (policy files included), exactly like the other always-forbidden
301
+ // patterns, so a non-public email cannot hide in a policy/boundary doc either.
302
+ const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
303
+ const leakedEmails = (content.match(emailRegex) || []).filter((addr) => !publicContactEmails.has(addr.toLowerCase()));
304
+ if (leakedEmails.length > 0) {
305
+ errors.push(`${relative}: contains email address (${leakedEmails[0]})`);
306
+ }
307
+
308
+ const policy = isPolicyFile(file, root);
309
+ if (!policy) {
310
+ // Manifest-driven literal path/term denylist (case-insensitive substring).
311
+ // Policy/boundary docs are allowed to *name* forbidden paths/terms, so this is
312
+ // gated the same way as privateBoundaryPatterns below.
313
+ const lowerContent = content.toLowerCase();
314
+ for (const needle of denylist.paths ?? []) {
315
+ if (lowerContent.includes(needle.toLowerCase())) errors.push(`${relative}: contains denylisted path "${needle}"`);
316
+ }
317
+ for (const needle of denylist.terms ?? []) {
318
+ if (lowerContent.includes(needle.toLowerCase())) errors.push(`${relative}: contains denylisted term "${needle}"`);
319
+ }
320
+
321
+ // Generic, anyone-applies boundary patterns. A maintainer's OWN private dir
322
+ // names (a personal governance folder, a private knowledge base) are NOT
323
+ // hard-coded here — that would ship those names in the public package. They
324
+ // are matched instead via the manifest/local `denylist.paths` loop above, so
325
+ // users configure their own in the gitignored privacy-scan.local.json.
326
+ // `.claude/hooks` stays as a generic marker (the standard Claude Code hooks
327
+ // dir that every Claude Code user has).
328
+ const privateBoundaryPatterns = [
329
+ [/\.claude\/hooks/gi, "Claude hooks dir"],
330
+ [/(^|[^\w.-])\.env(?:$|[^\w.-])/g, "environment file"],
331
+ [/\baccount[_ -]?id\s*[:=]\s*[A-Za-z0-9_.-]{8,}|\bacct_(?:live|prod|private)[A-Za-z0-9_.-]*/gi, "account identifier"],
332
+ [/\breal customer\b|\bcustomer name\b|\bclient name\b/gi, "real customer"],
333
+ [/\breal project\b|\bprivate project\b|\bsecret project\b/gi, "real project"],
334
+ [/\breal conversation\b|\braw private conversation\b|\bprivate chat transcript\b/gi, "real conversation"],
335
+ [/\bprivate hook\b/gi, "private hook"],
336
+ [/\bprivate route\b|\binternal route\b|\bprivate routing rules\b/gi, "private route"],
337
+ [/\b(?:internal\s+)?threshold\s*[:=]\s*[0-9.]+|\bcalibration thresholds?\b/gi, "internal threshold"],
338
+ [/\b(?:internal\s+)?(?:scoring\s+)?weight\s*[:=]\s*[0-9.]+|\bjudgment weights?\b/gi, "internal weight"],
339
+ [/\binternal session(?: id)?\s*[:=]\s*[A-Za-z0-9_.-]{12,}|\bsess_[A-Za-z0-9_.-]{12,}/gi, "internal session id"]
340
+ ];
341
+ for (const [regex, label] of privateBoundaryPatterns) {
342
+ if (regex.test(content)) errors.push(`${relative}: contains ${label}`);
343
+ }
344
+ }
345
+
346
+ const relativePosix = relative.split(path.sep).join("/");
347
+ const isBilingualFile = sanctionedBilingualFiles.has(relativePosix);
348
+ errors.push(...scanChinese(content, relative, policy, isBilingualFile));
349
+
350
+ return errors;
351
+ }
352
+
353
+ // jsonl is included so the P1 run-layer ledgers (state/*.jsonl) are scanned for
354
+ // leaked emails / tokens / local paths just like every other shipped text file.
355
+ // Omitting it would let a real secret pasted into a ledger ship while the scan
356
+ // stays silently green.
357
+ const SCANNED_EXT = /\.(md|mdc|json|jsonl|js|txt|yml|yaml)$/;
358
+
359
+ function isScannable(file) {
360
+ try {
361
+ return statSync(file).isFile() && (SCANNED_EXT.test(file) || path.basename(file) === ".clinerules");
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ function scanDirectory(root, label) {
368
+ const files = walk(root).filter(isScannable);
369
+ const errors = files.flatMap((file) => scanFile(file, root));
370
+ return { label, root, count: files.length, errors };
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Extra (side-effecting) scan surfaces. These run by default to harden release
375
+ // safety, but are skipped with --no-extras / when a target --workspace is given,
376
+ // so the core read-only scan still works in a strict sandbox.
377
+ // ---------------------------------------------------------------------------
378
+ function scanAdaptersInstallOutput() {
379
+ // Actually run `adapters install` into a throwaway dir and scan what src/adapters.js
380
+ // would write into a user's external repo.
381
+ let tmp;
382
+ try {
383
+ tmp = mkdtempSync(path.join(tmpdir(), "aicos-privacy-adapters-"));
384
+ } catch (error) {
385
+ return { label: "adapters-install output", root: "(skipped)", count: 0, errors: [], skipped: `cannot create temp dir (${error.code || error.message}); pass --no-extras in a read-only sandbox` };
386
+ }
387
+ try {
388
+ // --tool all so every adapter entrypoint's rendered content is scanned (the
389
+ // default --tool auto would detect no tool in this empty temp dir and write
390
+ // nothing, leaving this privacy surface empty).
391
+ execFileSync(process.execPath, [path.join(repoRoot, "bin", "ai-collab.js"), "adapters", "install", "--target", tmp, "--force", "--tool", "all"], {
392
+ cwd: repoRoot,
393
+ stdio: ["ignore", "ignore", "pipe"]
394
+ });
395
+ const result = scanDirectory(tmp, "adapters-install output");
396
+ return result;
397
+ } catch (error) {
398
+ return { label: "adapters-install output", root: tmp, count: 0, errors: [`adapters install failed: ${error.message}`] };
399
+ } finally {
400
+ if (tmp) {
401
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
402
+ }
403
+ }
404
+ }
405
+
406
+ function scanPackFileList() {
407
+ // npm pack --dry-run --json lists exactly what would ship. Make sure no private
408
+ // file (e.g. .env, a backup, a private config) sneaks into the tarball. The
409
+ // forbidden-file rules live in scripts/lib/forbidden-in-pack.js so this scanner
410
+ // and scripts/pack-check.js enforce one shared list (no drift).
411
+ try {
412
+ const output = execFileSync("npm", ["pack", "--dry-run", "--json"], {
413
+ cwd: repoRoot,
414
+ encoding: "utf8",
415
+ stdio: ["ignore", "pipe", "pipe"]
416
+ });
417
+ const [pack] = JSON.parse(output);
418
+ const files = (pack.files ?? []).map((file) => file.path);
419
+ // Also block any user-configured private dirs (from manifest/local denylist
420
+ // paths) from the tarball, without naming them in the shared shipped module.
421
+ const errors = findForbiddenPackFiles(files, configuredPrivateDirs).map(
422
+ ({ label, file }) => `npm pack would ship ${label}: ${file}`
423
+ );
424
+ return { label: "npm pack file list", root: "(npm pack --dry-run)", count: files.length, errors };
425
+ } catch (error) {
426
+ return { label: "npm pack file list", root: "(npm pack --dry-run)", count: 0, errors: [], skipped: `npm pack unavailable (${error.code || "error"}); set npm_config_cache to a writable dir or pass --no-extras` };
427
+ }
428
+ }
429
+
430
+ const args = parseArgs(process.argv.slice(2));
431
+
432
+ // --strict means "do not skip any scan surface". --no-extras explicitly skips the
433
+ // side-effecting surfaces (adapters-install output + npm pack file list). Asking
434
+ // for both at once is self-contradictory, so reject it instead of silently letting
435
+ // strict pass while real scan surfaces were dropped. (Previously --strict --no-extras
436
+ // exited 0 because the skipped extras were never recorded in `skipped`.)
437
+ if (args.strict && args.noExtras) {
438
+ console.error(
439
+ "Privacy scan (strict): --strict and --no-extras are contradictory. " +
440
+ "--strict requires every scan surface to run, but --no-extras skips the " +
441
+ "side-effecting surfaces (adapters-install output + npm pack file list). " +
442
+ "Run strict in a writable environment without --no-extras, or drop --strict."
443
+ );
444
+ process.exit(1);
445
+ }
446
+
447
+ const targetWorkspace = args.workspace ? path.resolve(args.workspace) : repoRoot;
448
+ const scanningRepo = !args.workspace;
449
+
450
+ const surfaces = [];
451
+ // Source tree + generated .aict are both under repoRoot, so the single walk of
452
+ // repoRoot covers "source tree (src/ + scripts/)" and "generated .aict". When a
453
+ // --workspace is given we scan exactly that tree.
454
+ surfaces.push(scanDirectory(targetWorkspace, scanningRepo ? "source tree + committed .aict" : "workspace"));
455
+
456
+ // Side-effecting surfaces only when scanning the repo and not asked to skip them.
457
+ const runExtras = scanningRepo && !args.noExtras;
458
+ if (runExtras) {
459
+ surfaces.push(scanAdaptersInstallOutput());
460
+ surfaces.push(scanPackFileList());
461
+ }
462
+
463
+ const allErrors = surfaces.flatMap((surface) => surface.errors);
464
+ const skipped = surfaces.filter((surface) => surface.skipped);
465
+
466
+ if (allErrors.length > 0) {
467
+ console.error(`Privacy scan failed:\n${allErrors.map((error) => `- ${error}`).join("\n")}`);
468
+ if (skipped.length > 0) {
469
+ console.error(`\nSkipped surfaces:\n${skipped.map((s) => `- ${s.label}: ${s.skipped}`).join("\n")}`);
470
+ }
471
+ process.exit(1);
472
+ }
473
+
474
+ if (args.strict && skipped.length > 0) {
475
+ console.error(`Privacy scan (strict): required surfaces were skipped:\n${skipped.map((s) => `- ${s.label}: ${s.skipped}`).join("\n")}`);
476
+ process.exit(1);
477
+ }
478
+
479
+ const summary = surfaces
480
+ .map((surface) => ` - ${surface.label}: ${surface.skipped ? `skipped (${surface.skipped})` : `${surface.count} entr${surface.count === 1 ? "y" : "ies"} clean`}`)
481
+ .join("\n");
482
+
483
+ console.log(`Privacy scan passed.
484
+ Root: ${targetWorkspace}
485
+ Surfaces:
486
+ ${summary}
487
+ `);
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ import { mkdtempSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { createWorkspace } from "../src/workspace.js";
6
+ import { validateWorkspace } from "../src/validate.js";
7
+
8
+ function parseArgs(argv) {
9
+ const args = {};
10
+ for (let index = 0; index < argv.length; index += 1) {
11
+ if (argv[index] === "--workspace") {
12
+ args.workspace = argv[index + 1];
13
+ index += 1;
14
+ }
15
+ }
16
+ return args;
17
+ }
18
+
19
+ function fail(errors) {
20
+ console.error(`Contract check failed:\n${errors.map((error) => `- ${error}`).join("\n")}`);
21
+ process.exit(1);
22
+ }
23
+
24
+ function checkRoot(repoRoot) {
25
+ const errors = [];
26
+ for (const file of ["README.md", "PRODUCT_CONTRACT.md", "package.json", "privacy-manifest.json"]) {
27
+ if (!existsSync(path.join(repoRoot, file))) errors.push(`missing root file ${file}`);
28
+ }
29
+
30
+ for (const file of [
31
+ "00-start-here.md",
32
+ "01-ai-collaboration-os.md",
33
+ "02-six-layer-architecture.md",
34
+ "03-role-system.md",
35
+ "04-core-mechanisms.md",
36
+ "05-failure-patterns.md",
37
+ "06-how-to-adapt-to-your-workflow.md"
38
+ ]) {
39
+ if (!existsSync(path.join(repoRoot, "docs", "open-system", file))) {
40
+ errors.push(`missing open-system doc ${file}`);
41
+ }
42
+ }
43
+ if (!existsSync(path.join(repoRoot, "docs", "PUBLIC_MAPPING.md"))) {
44
+ errors.push("missing docs/PUBLIC_MAPPING.md");
45
+ }
46
+ if (!existsSync(path.join(repoRoot, "docs", "WHY_THIS_EXISTS.md"))) {
47
+ errors.push("missing docs/WHY_THIS_EXISTS.md");
48
+ }
49
+
50
+ if (existsSync(path.join(repoRoot, "README.md"))) {
51
+ const readme = readFileSync(path.join(repoRoot, "README.md"), "utf8");
52
+ const firstScreen = readme.slice(0, 1800);
53
+ if (!/open-source personal AI collaboration workspace/i.test(firstScreen)) {
54
+ errors.push("README first screen must position the open-source personal AI collaboration workspace");
55
+ }
56
+ if (!/START_HERE\.md/.test(firstScreen)) {
57
+ errors.push("README first screen must point to START_HERE.md");
58
+ }
59
+ if (/doctor|diagnos/i.test(firstScreen)) {
60
+ errors.push("README first screen must not lead with diagnosis or doctor framing");
61
+ }
62
+ }
63
+
64
+ if (existsSync(path.join(repoRoot, "START_HERE.md"))) {
65
+ const startHere = readFileSync(path.join(repoRoot, "START_HERE.md"), "utf8");
66
+ for (const phrase of ["10 minutes", "30 minutes", "60 minutes"]) {
67
+ if (!new RegExp(phrase, "i").test(startHere)) {
68
+ errors.push(`root START_HERE.md missing ${phrase}`);
69
+ }
70
+ }
71
+ }
72
+
73
+ return errors;
74
+ }
75
+
76
+ function listFiles(root, base = root, files = []) {
77
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
78
+ const fullPath = path.join(root, entry.name);
79
+ if (entry.isDirectory()) listFiles(fullPath, base, files);
80
+ else if (entry.isFile()) files.push(path.relative(base, fullPath).split(path.sep).join("/"));
81
+ }
82
+ return files.sort();
83
+ }
84
+
85
+ function compareGeneratedWorkspace(generatedWorkspace, committedWorkspace) {
86
+ const errors = [];
87
+ if (!existsSync(committedWorkspace) || !statSync(committedWorkspace).isDirectory()) {
88
+ return [`missing committed workspace ${committedWorkspace}`];
89
+ }
90
+
91
+ const generatedFiles = listFiles(generatedWorkspace);
92
+ const committedFiles = listFiles(committedWorkspace);
93
+ const generatedSet = new Set(generatedFiles);
94
+ const committedSet = new Set(committedFiles);
95
+
96
+ for (const file of generatedFiles) {
97
+ if (!committedSet.has(file)) errors.push(`committed .aict missing generated file ${file}`);
98
+ }
99
+ for (const file of committedFiles) {
100
+ if (!generatedSet.has(file)) errors.push(`committed .aict has non-generated file ${file}`);
101
+ }
102
+ for (const file of generatedFiles.filter((item) => committedSet.has(item))) {
103
+ const generated = readFileSync(path.join(generatedWorkspace, file), "utf8");
104
+ const committed = readFileSync(path.join(committedWorkspace, file), "utf8");
105
+ if (generated !== committed) errors.push(`committed .aict differs from generator for ${file}`);
106
+ }
107
+ return errors;
108
+ }
109
+
110
+ // Generate a fresh workspace into a writable temp dir so we can diff it against
111
+ // the committed .aict. In a strict read-only sandbox the temp dir is not writable;
112
+ // rather than crash with a cryptic mkdtemp EPERM/ENOENT we fall back to validating
113
+ // the committed .aict in place (or an explicit --workspace) and tell the caller how
114
+ // to get the full generate-and-compare check back.
115
+ function makeGeneratedWorkspace() {
116
+ try {
117
+ return createWorkspace(mkdtempSync(path.join(tmpdir(), "aicos-contract-")), { force: true }).workspaceRoot;
118
+ } catch (error) {
119
+ if (["EPERM", "EACCES", "EROFS", "ENOENT"].includes(error.code)) {
120
+ console.warn(
121
+ `Contract check: cannot create a temp workspace (${error.message}).\n` +
122
+ `Falling back to validating the committed .aict in place — the generate-and-compare check is skipped.\n` +
123
+ `To run the full check in a read-only sandbox, generate a workspace in a writable dir first and pass it:\n` +
124
+ ` node bin/ai-collab.js init --target <writable-dir> --force\n` +
125
+ ` node scripts/validate-contract.js --workspace <writable-dir>/.aict`
126
+ );
127
+ return null;
128
+ }
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
134
+ const args = parseArgs(process.argv.slice(2));
135
+ const generatedWorkspace = args.workspace ? null : makeGeneratedWorkspace();
136
+ const workspaces = args.workspace
137
+ ? [path.resolve(args.workspace)]
138
+ : [
139
+ generatedWorkspace,
140
+ path.join(repoRoot, ".aict")
141
+ ].filter((workspace) => workspace && existsSync(workspace));
142
+
143
+ const rootErrors = checkRoot(repoRoot);
144
+ const workspaceResults = workspaces.map((workspace) => ({ workspace, result: validateWorkspace(workspace) }));
145
+ const errors = [
146
+ ...rootErrors,
147
+ ...(generatedWorkspace ? compareGeneratedWorkspace(generatedWorkspace, path.join(repoRoot, ".aict")) : []),
148
+ ...workspaceResults.flatMap(({ workspace, result }) =>
149
+ result.errors.map((error) => `${workspace}: ${error}`)
150
+ )
151
+ ];
152
+
153
+ if (errors.length > 0) {
154
+ fail(errors);
155
+ }
156
+
157
+ console.log(`Contract check passed.
158
+ Workspaces: ${workspaces.join(", ")}
159
+ Checks: ${workspaceResults.reduce((total, item) => total + item.result.checks, 0) + 4}
160
+ `);