@jamie-tam/forge 6.0.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +389 -0
  3. package/agents/architect.md +92 -0
  4. package/agents/builder.md +122 -0
  5. package/agents/code-reviewer.md +107 -0
  6. package/agents/concept-designer.md +207 -0
  7. package/agents/craft-reviewer.md +132 -0
  8. package/agents/critic.md +130 -0
  9. package/agents/doc-writer.md +85 -0
  10. package/agents/dreamer.md +129 -0
  11. package/agents/e2e-runner.md +89 -0
  12. package/agents/gotcha-hunter.md +127 -0
  13. package/agents/prototype-builder.md +193 -0
  14. package/agents/prototype-codifier.md +204 -0
  15. package/agents/prototype-reviewer.md +163 -0
  16. package/agents/security-reviewer.md +108 -0
  17. package/agents/spec-reviewer.md +94 -0
  18. package/agents/tracer.md +98 -0
  19. package/agents/wireframer.md +109 -0
  20. package/commands/abort.md +25 -0
  21. package/commands/bugfix.md +151 -0
  22. package/commands/evolve.md +118 -0
  23. package/commands/feature.md +236 -0
  24. package/commands/forge.md +100 -0
  25. package/commands/greenfield.md +185 -0
  26. package/commands/hotfix.md +98 -0
  27. package/commands/refactor.md +147 -0
  28. package/commands/resume.md +25 -0
  29. package/commands/setup.md +201 -0
  30. package/commands/status.md +27 -0
  31. package/commands/task-force.md +110 -0
  32. package/commands/validate.md +12 -0
  33. package/dist/__tests__/active-manifest.test.js +272 -0
  34. package/dist/__tests__/copy.test.js +96 -0
  35. package/dist/__tests__/gate-check.test.js +384 -0
  36. package/dist/__tests__/wiki.test.js +472 -0
  37. package/dist/__tests__/work-manifest.test.js +304 -0
  38. package/dist/active-manifest.js +229 -0
  39. package/dist/cli.js +158 -0
  40. package/dist/copy.js +124 -0
  41. package/dist/gate-check.js +326 -0
  42. package/dist/hooks.js +60 -0
  43. package/dist/init.js +140 -0
  44. package/dist/manifest.js +90 -0
  45. package/dist/merge.js +77 -0
  46. package/dist/paths.js +36 -0
  47. package/dist/uninstall.js +216 -0
  48. package/dist/update.js +158 -0
  49. package/dist/verify-manifest.js +65 -0
  50. package/dist/verify.js +98 -0
  51. package/dist/wiki-ui.js +310 -0
  52. package/dist/wiki.js +364 -0
  53. package/dist/work-manifest.js +798 -0
  54. package/hooks/config/gate-requirements.json +79 -0
  55. package/hooks/hooks.json +143 -0
  56. package/hooks/scripts/analyze-telemetry.sh +114 -0
  57. package/hooks/scripts/gate-enforcer.sh +164 -0
  58. package/hooks/scripts/pre-compact.sh +90 -0
  59. package/hooks/scripts/session-start.sh +81 -0
  60. package/hooks/scripts/telemetry.sh +41 -0
  61. package/hooks/scripts/wiki-lint.sh +87 -0
  62. package/hooks/templates/AGENTS.md.template +48 -0
  63. package/hooks/templates/CLAUDE.md.template +45 -0
  64. package/package.json +55 -0
  65. package/protocols/README.md +40 -0
  66. package/protocols/codex.md +151 -0
  67. package/protocols/graphify.md +156 -0
  68. package/references/common/agent-coordination.md +65 -0
  69. package/references/common/coding-standards.md +54 -0
  70. package/references/common/feature-tracking.md +21 -0
  71. package/references/common/io-protocol.md +36 -0
  72. package/references/common/phases.md +57 -0
  73. package/references/common/quality-gates.md +130 -0
  74. package/references/common/skill-authoring.md +154 -0
  75. package/references/common/skill-compliance.md +30 -0
  76. package/references/python/standards.md +44 -0
  77. package/references/react/standards.md +61 -0
  78. package/references/typescript/standards.md +42 -0
  79. package/rules/common/forge-system.md +59 -0
  80. package/rules/common/git-workflow.md +40 -0
  81. package/rules/common/guardrails.md +37 -0
  82. package/rules/common/quality-gates.md +18 -0
  83. package/rules/common/security.md +50 -0
  84. package/rules/common/skill-selection.md +78 -0
  85. package/rules/common/testing.md +58 -0
  86. package/rules/common/verification.md +39 -0
  87. package/skills/build-pr-workflow/SKILL.md +301 -0
  88. package/skills/build-pr-workflow/references/pr-template.md +62 -0
  89. package/skills/build-pr-workflow/references/subagent-merge.md +47 -0
  90. package/skills/build-pr-workflow/references/worktree-setup.md +125 -0
  91. package/skills/build-prototype/SKILL.md +264 -0
  92. package/skills/build-scaffold/SKILL.md +340 -0
  93. package/skills/build-tdd/SKILL.md +89 -0
  94. package/skills/build-wireframe/SKILL.md +110 -0
  95. package/skills/build-wireframe/assets/baseline-template.html +486 -0
  96. package/skills/build-wireframe/references/demo-walkthroughs.md +170 -0
  97. package/skills/build-wireframe/references/gotchas.md +188 -0
  98. package/skills/build-wireframe/references/legend-lines.md +141 -0
  99. package/skills/concept-slides/SKILL.md +192 -0
  100. package/skills/deliver-db-migration/SKILL.md +466 -0
  101. package/skills/deliver-deploy/SKILL.md +407 -0
  102. package/skills/deliver-onboarding/SKILL.md +198 -0
  103. package/skills/deliver-onboarding/references/document-templates.md +393 -0
  104. package/skills/deliver-onboarding/templates/getting-started.md +122 -0
  105. package/skills/discover-codebase-analysis/SKILL.md +448 -0
  106. package/skills/discover-requirements/SKILL.md +418 -0
  107. package/skills/discover-requirements/templates/prd.md +99 -0
  108. package/skills/discover-requirements/templates/technical-spec.md +123 -0
  109. package/skills/discover-requirements/templates/user-stories.md +76 -0
  110. package/skills/harden/SKILL.md +214 -0
  111. package/skills/iterate-prototype/SKILL.md +241 -0
  112. package/skills/plan-architecture/SKILL.md +457 -0
  113. package/skills/plan-architecture/templates/adr-template.md +52 -0
  114. package/skills/plan-architecture/templates/api-contract.md +99 -0
  115. package/skills/plan-architecture/templates/db-schema.md +81 -0
  116. package/skills/plan-architecture/templates/system-design.md +111 -0
  117. package/skills/plan-brainstorm/SKILL.md +433 -0
  118. package/skills/plan-design-system/SKILL.md +279 -0
  119. package/skills/plan-task-decompose/SKILL.md +454 -0
  120. package/skills/quality-code-review/SKILL.md +286 -0
  121. package/skills/quality-security-audit/SKILL.md +292 -0
  122. package/skills/quality-security-audit/references/audit-report-template.md +89 -0
  123. package/skills/quality-security-audit/references/owasp-checks.md +178 -0
  124. package/skills/quality-test-execution/SKILL.md +435 -0
  125. package/skills/quality-test-plan/SKILL.md +297 -0
  126. package/skills/quality-test-plan/references/test-type-guide.md +263 -0
  127. package/skills/quality-test-plan/templates/e2e-test-plan.md +72 -0
  128. package/skills/quality-test-plan/templates/integration-test-plan.md +74 -0
  129. package/skills/quality-test-plan/templates/load-test-plan.md +111 -0
  130. package/skills/quality-test-plan/templates/smoke-test-plan.md +68 -0
  131. package/skills/quality-test-plan/templates/unit-test-plan.md +56 -0
  132. package/skills/quality-uiux/SKILL.md +481 -0
  133. package/skills/support-debug/SKILL.md +464 -0
  134. package/skills/support-dream/SKILL.md +213 -0
  135. package/skills/support-gotcha/SKILL.md +249 -0
  136. package/skills/support-runtime-reachability/SKILL.md +190 -0
  137. package/skills/support-runtime-reachability/scripts/__fixtures__/case-01-passes-app-use/src/app.ts +7 -0
  138. package/skills/support-runtime-reachability/scripts/__fixtures__/case-01-passes-app-use/src/handlers/cases.ts +7 -0
  139. package/skills/support-runtime-reachability/scripts/__fixtures__/case-02-orphan-no-app-use/src/app.ts +8 -0
  140. package/skills/support-runtime-reachability/scripts/__fixtures__/case-02-orphan-no-app-use/src/handlers/cases.ts +7 -0
  141. package/skills/support-runtime-reachability/scripts/__fixtures__/case-03-orphan-import-only/src/App.tsx +5 -0
  142. package/skills/support-runtime-reachability/scripts/__fixtures__/case-03-orphan-import-only/src/components/RingingBanner.tsx +7 -0
  143. package/skills/support-runtime-reachability/scripts/__fixtures__/case-03-orphan-import-only/src/hooks/useTwilio.ts +6 -0
  144. package/skills/support-runtime-reachability/scripts/__fixtures__/case-04-jsx-component-rendered/src/App.tsx +5 -0
  145. package/skills/support-runtime-reachability/scripts/__fixtures__/case-04-jsx-component-rendered/src/components/MyComp.tsx +3 -0
  146. package/skills/support-runtime-reachability/scripts/__fixtures__/case-05-jsx-component-not-rendered/src/App.tsx +3 -0
  147. package/skills/support-runtime-reachability/scripts/__fixtures__/case-05-jsx-component-not-rendered/src/components/Orphan.tsx +3 -0
  148. package/skills/support-runtime-reachability/scripts/__fixtures__/case-06-class-instantiated/src/lib/Service.ts +6 -0
  149. package/skills/support-runtime-reachability/scripts/__fixtures__/case-06-class-instantiated/src/main.ts +4 -0
  150. package/skills/support-runtime-reachability/scripts/__fixtures__/case-07-class-not-instantiated/src/lib/Lonely.ts +5 -0
  151. package/skills/support-runtime-reachability/scripts/__fixtures__/case-07-class-not-instantiated/src/main.ts +2 -0
  152. package/skills/support-runtime-reachability/scripts/__fixtures__/case-08-default-export-imported-and-called/src/handler.ts +3 -0
  153. package/skills/support-runtime-reachability/scripts/__fixtures__/case-08-default-export-imported-and-called/src/main.ts +3 -0
  154. package/skills/support-runtime-reachability/scripts/__fixtures__/case-09-default-export-orphan/src/handler.ts +3 -0
  155. package/skills/support-runtime-reachability/scripts/__fixtures__/case-09-default-export-orphan/src/main.ts +2 -0
  156. package/skills/support-runtime-reachability/scripts/__fixtures__/case-10-aliased-named-export/src/lib.ts +5 -0
  157. package/skills/support-runtime-reachability/scripts/__fixtures__/case-10-aliased-named-export/src/main.ts +3 -0
  158. package/skills/support-runtime-reachability/scripts/__fixtures__/case-11-re-export-chain/src/lib/index.ts +1 -0
  159. package/skills/support-runtime-reachability/scripts/__fixtures__/case-11-re-export-chain/src/lib/internal.ts +3 -0
  160. package/skills/support-runtime-reachability/scripts/__fixtures__/case-11-re-export-chain/src/main.ts +3 -0
  161. package/skills/support-runtime-reachability/scripts/__fixtures__/case-12-test-only-caller/src/util.test.ts +5 -0
  162. package/skills/support-runtime-reachability/scripts/__fixtures__/case-12-test-only-caller/src/util.ts +3 -0
  163. package/skills/support-runtime-reachability/scripts/__fixtures__/case-13-gated-pending-annotation/src/future.ts +4 -0
  164. package/skills/support-runtime-reachability/scripts/__fixtures__/case-14-untraceable-annotation/src/decorated.ts +4 -0
  165. package/skills/support-runtime-reachability/scripts/__fixtures__/case-15-untraceable-empty/src/lazy.ts +4 -0
  166. package/skills/support-runtime-reachability/scripts/__fixtures__/case-16-python-module/src/lib.py +15 -0
  167. package/skills/support-runtime-reachability/scripts/__fixtures__/case-16-python-module/src/main.py +5 -0
  168. package/skills/support-runtime-reachability/scripts/__fixtures__/case-17-router-use/src/parent.ts +5 -0
  169. package/skills/support-runtime-reachability/scripts/__fixtures__/case-17-router-use/src/routes/cases.ts +5 -0
  170. package/skills/support-runtime-reachability/scripts/__fixtures__/case-18-shadowed-name-fp/src/lib/foo.ts +3 -0
  171. package/skills/support-runtime-reachability/scripts/__fixtures__/case-18-shadowed-name-fp/src/other.ts +8 -0
  172. package/skills/support-runtime-reachability/scripts/__fixtures__/case-19-same-name-different-module/src/handlers/cases.ts +4 -0
  173. package/skills/support-runtime-reachability/scripts/__fixtures__/case-19-same-name-different-module/src/handlers/users.ts +4 -0
  174. package/skills/support-runtime-reachability/scripts/__fixtures__/case-19-same-name-different-module/src/main.ts +5 -0
  175. package/skills/support-runtime-reachability/scripts/__fixtures__/case-20-aliased-import-usage/src/handlers/cases.ts +3 -0
  176. package/skills/support-runtime-reachability/scripts/__fixtures__/case-20-aliased-import-usage/src/main.ts +4 -0
  177. package/skills/support-runtime-reachability/scripts/__fixtures__/case-21-mixed-default-and-named/src/lib.ts +5 -0
  178. package/skills/support-runtime-reachability/scripts/__fixtures__/case-21-mixed-default-and-named/src/main.ts +5 -0
  179. package/skills/support-runtime-reachability/scripts/__fixtures__/case-22-dynamic-import-then-caller/src/lib.ts +3 -0
  180. package/skills/support-runtime-reachability/scripts/__fixtures__/case-22-dynamic-import-then-caller/src/main.ts +8 -0
  181. package/skills/support-runtime-reachability/scripts/__fixtures__/case-23-dynamic-import-with-space/src/lib.ts +3 -0
  182. package/skills/support-runtime-reachability/scripts/__fixtures__/case-23-dynamic-import-with-space/src/main.ts +7 -0
  183. package/skills/support-runtime-reachability/scripts/check.mjs +638 -0
  184. package/skills/support-runtime-reachability/scripts/check.test.mjs +244 -0
  185. package/skills/support-skill-validator/SKILL.md +194 -0
  186. package/skills/support-skill-validator/references/false-positives.md +59 -0
  187. package/skills/support-skill-validator/references/validation-checks.md +280 -0
  188. package/skills/support-system-guide/SKILL.md +311 -0
  189. package/skills/support-task-force/SKILL.md +265 -0
  190. package/skills/support-task-force/references/dispatch-pattern.md +178 -0
  191. package/skills/support-task-force/references/synthesis-template.md +126 -0
  192. package/skills/support-wiki-bootstrap/SKILL.md +37 -0
  193. package/skills/support-wiki-lint/SKILL.md +196 -0
  194. package/skills/support-wiki-lint/scripts/lint.mjs +488 -0
  195. package/skills/support-wiki-lint/scripts/lint.test.mjs +196 -0
  196. package/templates/README.md +23 -0
  197. package/templates/aiwiki/CLAUDE.md.template +78 -0
  198. package/templates/aiwiki/schemas/architecture.md +118 -0
  199. package/templates/aiwiki/schemas/convention.md +112 -0
  200. package/templates/aiwiki/schemas/decision.md +144 -0
  201. package/templates/aiwiki/schemas/gotcha.md +118 -0
  202. package/templates/aiwiki/schemas/oracle.md +105 -0
  203. package/templates/aiwiki/schemas/session.md +125 -0
  204. package/templates/manifests/bugfix.yaml +41 -0
  205. package/templates/manifests/feature.yaml +69 -0
  206. package/templates/manifests/greenfield.yaml +61 -0
  207. package/templates/manifests/hotfix.yaml +45 -0
  208. package/templates/manifests/refactor.yaml +44 -0
  209. package/templates/manifests/v5/SCHEMA.md +327 -0
  210. package/templates/manifests/v5/feature.yaml +77 -0
  211. package/templates/manifests/v6/SCHEMA.md +199 -0
  212. package/templates/wiki-html/dream-detail.html +378 -0
  213. package/templates/wiki-html/dreams-list.html +155 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Smoke tests for src/wiki.ts (listDreams, getDreamDetails, acceptDream,
3
+ * rejectDream). Same zero-framework pattern as work-manifest.test.ts —
4
+ * each test sets up a temp aiwiki/ tree, exercises the function, asserts
5
+ * by throwing.
6
+ *
7
+ * Run with: npm run build && node dist/__tests__/wiki.test.js
8
+ */
9
+ import * as crypto from "node:crypto";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { execFileSync } from "node:child_process";
14
+ import { acceptDream, assertInsideRoot, getDreamDetails, listDreams, rejectDream, } from "../wiki.js";
15
+ let passed = 0;
16
+ let failed = 0;
17
+ const fails = [];
18
+ function test(name, fn) {
19
+ const run = async () => {
20
+ try {
21
+ await fn();
22
+ passed++;
23
+ console.log(` PASS ${name}`);
24
+ }
25
+ catch (e) {
26
+ failed++;
27
+ const msg = e instanceof Error ? e.message : String(e);
28
+ fails.push(`${name}: ${msg}`);
29
+ console.log(` FAIL ${name}\n ${msg}`);
30
+ }
31
+ };
32
+ return run();
33
+ }
34
+ function mkTempRoot() {
35
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "forge-wiki-test-"));
36
+ // Git init so wiki.ts's git diff helper has a repo to operate in.
37
+ execFileSync("git", ["init", "-q"], { cwd: root });
38
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root });
39
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: root });
40
+ return root;
41
+ }
42
+ function sha256(content) {
43
+ return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
44
+ }
45
+ function writeFile(root, rel, content) {
46
+ const full = path.join(root, rel);
47
+ fs.mkdirSync(path.dirname(full), { recursive: true });
48
+ fs.writeFileSync(full, content);
49
+ }
50
+ function writeManifest(root, dreamId, overrides = {}) {
51
+ const m = {
52
+ dream_id: dreamId,
53
+ trigger: "manual",
54
+ scope: ["aiwiki/gotchas/"],
55
+ created_at: new Date().toISOString(),
56
+ changed_pages: 0,
57
+ new_pages: 0,
58
+ deleted_pages: 0,
59
+ lint_status: "passed",
60
+ review_status: "pending",
61
+ reviewed_at: null,
62
+ operations: [],
63
+ ...overrides,
64
+ };
65
+ writeFile(root, `aiwiki/proposed/${dreamId}/.dream-manifest.json`, JSON.stringify(m, null, 2));
66
+ return m;
67
+ }
68
+ console.log("\n=== wiki.ts smoke tests ===\n");
69
+ console.log("listDreams");
70
+ await test("returns [] when proposed dir missing", async () => {
71
+ const root = mkTempRoot();
72
+ const dreams = await listDreams(root);
73
+ if (dreams.length !== 0)
74
+ throw new Error(`expected 0 dreams, got ${dreams.length}`);
75
+ });
76
+ await test("returns one entry for a single pending dream", async () => {
77
+ const root = mkTempRoot();
78
+ writeManifest(root, "2026-05-12-first", { new_pages: 1 });
79
+ const dreams = await listDreams(root);
80
+ if (dreams.length !== 1)
81
+ throw new Error(`expected 1, got ${dreams.length}`);
82
+ if (dreams[0].dream_id !== "2026-05-12-first") {
83
+ throw new Error(`got dream_id=${dreams[0].dream_id}`);
84
+ }
85
+ if (dreams[0].new_pages !== 1)
86
+ throw new Error(`expected new_pages=1`);
87
+ });
88
+ await test("excludes dreams with review_status != pending", async () => {
89
+ const root = mkTempRoot();
90
+ writeManifest(root, "accepted-1", { review_status: "accepted" });
91
+ writeManifest(root, "rejected-1", { review_status: "rejected" });
92
+ writeManifest(root, "pending-1", { review_status: "pending" });
93
+ const dreams = await listDreams(root);
94
+ if (dreams.length !== 1)
95
+ throw new Error(`expected 1 pending, got ${dreams.length}`);
96
+ if (dreams[0].dream_id !== "pending-1")
97
+ throw new Error(`got ${dreams[0].dream_id}`);
98
+ });
99
+ await test("sorts by created_at ascending (oldest first)", async () => {
100
+ const root = mkTempRoot();
101
+ writeManifest(root, "newest", { created_at: "2026-05-12T15:00:00Z" });
102
+ writeManifest(root, "oldest", { created_at: "2026-05-10T10:00:00Z" });
103
+ writeManifest(root, "middle", { created_at: "2026-05-11T12:00:00Z" });
104
+ const dreams = await listDreams(root);
105
+ if (dreams.map((d) => d.dream_id).join(",") !== "oldest,middle,newest") {
106
+ throw new Error(`order: ${dreams.map((d) => d.dream_id).join(",")}`);
107
+ }
108
+ });
109
+ await test("ignores malformed manifests without crashing", async () => {
110
+ const root = mkTempRoot();
111
+ writeFile(root, "aiwiki/proposed/broken/.dream-manifest.json", "{ not valid json");
112
+ writeManifest(root, "good-1");
113
+ const dreams = await listDreams(root);
114
+ if (dreams.length !== 1)
115
+ throw new Error(`expected 1 good, got ${dreams.length}`);
116
+ });
117
+ console.log("\ngetDreamDetails");
118
+ await test("returns null when dream does not exist", async () => {
119
+ const root = mkTempRoot();
120
+ const detail = await getDreamDetails("nope", root);
121
+ if (detail !== null)
122
+ throw new Error("expected null");
123
+ });
124
+ await test("classifies new vs changed files correctly", async () => {
125
+ const root = mkTempRoot();
126
+ const dreamId = "2026-05-12-changed-file";
127
+ // Existing file in aiwiki
128
+ writeFile(root, "aiwiki/gotchas/2026-05-10-foo.md", "original content\n");
129
+ // Proposed: one changed, one new
130
+ writeFile(root, `aiwiki/proposed/${dreamId}/gotchas/2026-05-10-foo.md`, "new content\n");
131
+ writeFile(root, `aiwiki/proposed/${dreamId}/gotchas/2026-05-12-new.md`, "brand new\n");
132
+ writeManifest(root, dreamId, {
133
+ base_file_hashes: {
134
+ "aiwiki/gotchas/2026-05-10-foo.md": sha256("original content\n"),
135
+ },
136
+ });
137
+ const detail = await getDreamDetails(dreamId, root);
138
+ if (!detail)
139
+ throw new Error("expected detail, got null");
140
+ const kinds = detail.files.map((f) => `${f.kind}:${f.path}`).sort();
141
+ if (!kinds.includes("changed:gotchas/2026-05-10-foo.md")) {
142
+ throw new Error(`missing changed file; got: ${kinds.join(", ")}`);
143
+ }
144
+ if (!kinds.includes("new:gotchas/2026-05-12-new.md")) {
145
+ throw new Error(`missing new file; got: ${kinds.join(", ")}`);
146
+ }
147
+ });
148
+ await test("flags conflict when current aiwiki file hash differs from base", async () => {
149
+ const root = mkTempRoot();
150
+ const dreamId = "conflict-dream";
151
+ // Original content recorded at dream-creation time
152
+ const originalContent = "original\n";
153
+ const baseHash = sha256(originalContent);
154
+ // User edited it after the dream was created — content now differs
155
+ writeFile(root, "aiwiki/gotchas/foo.md", "user-edited\n");
156
+ writeFile(root, `aiwiki/proposed/${dreamId}/gotchas/foo.md`, "dream-proposes\n");
157
+ writeManifest(root, dreamId, {
158
+ base_file_hashes: { "aiwiki/gotchas/foo.md": baseHash },
159
+ });
160
+ const detail = await getDreamDetails(dreamId, root);
161
+ if (!detail)
162
+ throw new Error("expected detail");
163
+ if (!detail.has_conflicts)
164
+ throw new Error("expected has_conflicts=true");
165
+ const conflicted = detail.files.find((f) => f.conflict !== undefined);
166
+ if (!conflicted)
167
+ throw new Error("expected one file with conflict info");
168
+ });
169
+ await test("surfaces deletions from prune operations", async () => {
170
+ const root = mkTempRoot();
171
+ const dreamId = "prune-dream";
172
+ writeFile(root, "aiwiki/raw/stale.md", "stale entry\n");
173
+ writeManifest(root, dreamId, {
174
+ deleted_pages: 1,
175
+ operations: [{ op: "prune", file: "aiwiki/raw/stale.md", reason: "investigated; superseded" }],
176
+ });
177
+ const detail = await getDreamDetails(dreamId, root);
178
+ if (!detail)
179
+ throw new Error("expected detail");
180
+ const deleted = detail.files.find((f) => f.kind === "deleted");
181
+ if (!deleted)
182
+ throw new Error("expected one deleted file");
183
+ if (deleted.path !== "raw/stale.md")
184
+ throw new Error(`got path=${deleted.path}`);
185
+ });
186
+ console.log("\nacceptDream");
187
+ await test("happy path: applies file replacements + prunes + archives originals", async () => {
188
+ const root = mkTempRoot();
189
+ const dreamId = "happy-accept";
190
+ const oldContent = "old\n";
191
+ writeFile(root, "aiwiki/gotchas/foo.md", oldContent);
192
+ writeFile(root, "aiwiki/raw/stale.md", "stale\n");
193
+ writeFile(root, `aiwiki/proposed/${dreamId}/gotchas/foo.md`, "new\n");
194
+ writeFile(root, `aiwiki/proposed/${dreamId}/conventions/handler-naming.md`, "convention body\n");
195
+ writeManifest(root, dreamId, {
196
+ base_file_hashes: { "aiwiki/gotchas/foo.md": sha256(oldContent) },
197
+ operations: [{ op: "prune", file: "aiwiki/raw/stale.md", reason: "consolidated" }],
198
+ });
199
+ const result = await acceptDream(dreamId, root);
200
+ // Replaced file
201
+ const replaced = fs.readFileSync(path.join(root, "aiwiki/gotchas/foo.md"), "utf8");
202
+ if (replaced !== "new\n")
203
+ throw new Error(`expected new content, got "${replaced}"`);
204
+ // New file written
205
+ if (!fs.existsSync(path.join(root, "aiwiki/conventions/handler-naming.md"))) {
206
+ throw new Error("expected new file written");
207
+ }
208
+ // Pruned file gone
209
+ if (fs.existsSync(path.join(root, "aiwiki/raw/stale.md"))) {
210
+ throw new Error("expected prune to remove stale.md");
211
+ }
212
+ // Archive contains the originals
213
+ if (!result.archived_to)
214
+ throw new Error("expected archived_to in result");
215
+ if (!fs.existsSync(path.join(root, result.archived_to, "aiwiki/gotchas/foo.md"))) {
216
+ throw new Error("expected archived original gotcha");
217
+ }
218
+ if (!fs.existsSync(path.join(root, result.archived_to, "aiwiki/raw/stale.md"))) {
219
+ throw new Error("expected archived pruned file");
220
+ }
221
+ // Proposed dir moved to archive
222
+ if (fs.existsSync(path.join(root, "aiwiki/proposed", dreamId))) {
223
+ throw new Error("expected proposed dir moved to archive");
224
+ }
225
+ // History appended
226
+ const history = fs.readFileSync(path.join(root, ".forge/dream-history.jsonl"), "utf8");
227
+ if (!history.includes("accepted"))
228
+ throw new Error("expected accepted entry in history");
229
+ });
230
+ await test("refuses to accept when base hashes mismatch (conflict)", async () => {
231
+ const root = mkTempRoot();
232
+ const dreamId = "conflict-accept";
233
+ const baseHash = sha256("recorded content\n");
234
+ writeFile(root, "aiwiki/gotchas/foo.md", "current-different-content\n");
235
+ writeFile(root, `aiwiki/proposed/${dreamId}/gotchas/foo.md`, "dream content\n");
236
+ writeManifest(root, dreamId, {
237
+ base_file_hashes: { "aiwiki/gotchas/foo.md": baseHash },
238
+ });
239
+ let threw = false;
240
+ try {
241
+ await acceptDream(dreamId, root);
242
+ }
243
+ catch (e) {
244
+ threw = true;
245
+ const msg = e.message;
246
+ if (!msg.includes("Concurrent changes")) {
247
+ throw new Error(`expected concurrent-changes error, got: ${msg}`);
248
+ }
249
+ }
250
+ if (!threw)
251
+ throw new Error("expected acceptDream to throw on conflict");
252
+ // State unchanged: original file content preserved
253
+ const current = fs.readFileSync(path.join(root, "aiwiki/gotchas/foo.md"), "utf8");
254
+ if (current !== "current-different-content\n") {
255
+ throw new Error("expected aiwiki file unchanged after refused accept");
256
+ }
257
+ });
258
+ await test("refuses to accept a dream that's already accepted", async () => {
259
+ const root = mkTempRoot();
260
+ writeManifest(root, "already-done", { review_status: "accepted" });
261
+ let threw = false;
262
+ try {
263
+ await acceptDream("already-done", root);
264
+ }
265
+ catch (e) {
266
+ threw = true;
267
+ if (!e.message.includes("already accepted")) {
268
+ throw new Error(`got: ${e.message}`);
269
+ }
270
+ }
271
+ if (!threw)
272
+ throw new Error("expected refusal");
273
+ });
274
+ console.log("\nrejectDream");
275
+ await test("removes proposed dir, appends rejection to history", async () => {
276
+ const root = mkTempRoot();
277
+ writeManifest(root, "to-reject");
278
+ writeFile(root, "aiwiki/proposed/to-reject/gotchas/foo.md", "body\n");
279
+ const result = await rejectDream("to-reject", "duplicate of another dream", root);
280
+ if (result.dream_id !== "to-reject")
281
+ throw new Error(`got dream_id=${result.dream_id}`);
282
+ if (fs.existsSync(path.join(root, "aiwiki/proposed/to-reject"))) {
283
+ throw new Error("expected proposed dir removed");
284
+ }
285
+ const history = fs.readFileSync(path.join(root, ".forge/dream-history.jsonl"), "utf8");
286
+ if (!history.includes("rejected"))
287
+ throw new Error("expected rejected entry");
288
+ if (!history.includes("duplicate of another dream")) {
289
+ throw new Error("expected reason in history");
290
+ }
291
+ });
292
+ await test("rejectDream throws when dream does not exist", async () => {
293
+ const root = mkTempRoot();
294
+ let threw = false;
295
+ try {
296
+ await rejectDream("ghost", "irrelevant", root);
297
+ }
298
+ catch {
299
+ threw = true;
300
+ }
301
+ if (!threw)
302
+ throw new Error("expected throw for nonexistent dream");
303
+ });
304
+ console.log("\npath containment");
305
+ await test("acceptDream rejects dreamId containing path traversal", async () => {
306
+ const root = mkTempRoot();
307
+ // Even though no manifest exists at the escaped location, acceptDream must
308
+ // reject the traversal before any FS lookup that could read outside root.
309
+ // Sibling file we want to prove untouched.
310
+ const sibling = path.join(path.dirname(root), "sibling-secret.md");
311
+ fs.writeFileSync(sibling, "secret\n");
312
+ let threw = false;
313
+ try {
314
+ await acceptDream("../../../etc", root);
315
+ }
316
+ catch (e) {
317
+ threw = true;
318
+ if (!/containment/i.test(e.message)) {
319
+ throw new Error(`expected containment error, got: ${e.message}`);
320
+ }
321
+ }
322
+ if (!threw)
323
+ throw new Error("expected acceptDream to throw on traversal in dreamId");
324
+ // Sibling untouched
325
+ if (fs.readFileSync(sibling, "utf8") !== "secret\n") {
326
+ throw new Error("sibling file was modified");
327
+ }
328
+ fs.unlinkSync(sibling);
329
+ });
330
+ await test("acceptDream rejects op.file with absolute path", async () => {
331
+ const root = mkTempRoot();
332
+ const dreamId = "abs-path-attack";
333
+ // Sibling target the manifest tries to delete. Must not be touched.
334
+ const sibling = path.join(path.dirname(root), "victim.md");
335
+ fs.writeFileSync(sibling, "do-not-delete\n");
336
+ writeManifest(root, dreamId, {
337
+ operations: [{ op: "prune", file: sibling, reason: "malicious absolute path" }],
338
+ });
339
+ let threw = false;
340
+ try {
341
+ await acceptDream(dreamId, root);
342
+ }
343
+ catch (e) {
344
+ threw = true;
345
+ if (!/containment/i.test(e.message)) {
346
+ throw new Error(`expected containment error, got: ${e.message}`);
347
+ }
348
+ }
349
+ if (!threw)
350
+ throw new Error("expected acceptDream to throw on absolute op.file");
351
+ if (!fs.existsSync(sibling) || fs.readFileSync(sibling, "utf8") !== "do-not-delete\n") {
352
+ throw new Error("sibling file was deleted or modified");
353
+ }
354
+ fs.unlinkSync(sibling);
355
+ });
356
+ await test("acceptDream rejects op.file with .. traversal", async () => {
357
+ const root = mkTempRoot();
358
+ const dreamId = "dotdot-attack";
359
+ const sibling = path.join(path.dirname(root), "victim2.md");
360
+ fs.writeFileSync(sibling, "do-not-delete\n");
361
+ const rootBasename = path.basename(root);
362
+ writeManifest(root, dreamId, {
363
+ operations: [
364
+ {
365
+ op: "prune",
366
+ // Relative path that escapes via ..
367
+ file: path.join("..", path.relative(path.dirname(root), sibling)),
368
+ reason: "malicious .. traversal",
369
+ },
370
+ ],
371
+ });
372
+ // Silence the unused var warning — keep the breadcrumb for debugging.
373
+ void rootBasename;
374
+ let threw = false;
375
+ try {
376
+ await acceptDream(dreamId, root);
377
+ }
378
+ catch (e) {
379
+ threw = true;
380
+ if (!/containment/i.test(e.message)) {
381
+ throw new Error(`expected containment error, got: ${e.message}`);
382
+ }
383
+ }
384
+ if (!threw)
385
+ throw new Error("expected acceptDream to throw on .. in op.file");
386
+ if (!fs.existsSync(sibling) || fs.readFileSync(sibling, "utf8") !== "do-not-delete\n") {
387
+ throw new Error("sibling file was deleted or modified");
388
+ }
389
+ fs.unlinkSync(sibling);
390
+ });
391
+ await test("assertInsideRoot returns resolved path for inside-root inputs", () => {
392
+ const root = mkTempRoot();
393
+ const rootAbs = path.resolve(root);
394
+ const inside = assertInsideRoot(rootAbs, path.join(rootAbs, "aiwiki", "foo.md"));
395
+ if (!inside.startsWith(rootAbs + path.sep)) {
396
+ throw new Error(`expected resolved path inside root, got ${inside}`);
397
+ }
398
+ });
399
+ await test("assertInsideRoot rejects NUL byte injection", () => {
400
+ const root = mkTempRoot();
401
+ const rootAbs = path.resolve(root);
402
+ // Inject NUL via string concat — path.join strips NUL via path.normalize,
403
+ // so it would mask the test. We pass the raw string to assertInsideRoot
404
+ // directly to verify the helper's own NUL guard fires.
405
+ const candidate = `${rootAbs}/aiwiki/foo\0.md`;
406
+ let threw = false;
407
+ try {
408
+ assertInsideRoot(rootAbs, candidate);
409
+ }
410
+ catch (e) {
411
+ threw = true;
412
+ if (!/NUL byte/i.test(e.message)) {
413
+ throw new Error(`expected NUL byte error, got: ${e.message}`);
414
+ }
415
+ }
416
+ if (!threw)
417
+ throw new Error("expected assertInsideRoot to reject NUL byte path");
418
+ });
419
+ await test("acceptDream rejects symlink pointing outside root", async () => {
420
+ const root = mkTempRoot();
421
+ const dreamId = "symlink-attack";
422
+ // Sibling file the symlink would target. Must not be touched.
423
+ const sibling = path.join(path.dirname(root), "symlink-victim.md");
424
+ fs.writeFileSync(sibling, "do-not-touch\n");
425
+ // Plant a symlink at aiwiki/gotchas/target.md → sibling. We make the
426
+ // directory so the *parent* exists and the symlink itself is the dangerous
427
+ // component. acceptDream copies proposed/{id}/gotchas/target.md → that
428
+ // symlink path, which without realpath checking would follow the link.
429
+ const aiwikiGotchas = path.join(root, "aiwiki", "gotchas");
430
+ fs.mkdirSync(aiwikiGotchas, { recursive: true });
431
+ const symlinkPath = path.join(aiwikiGotchas, "target.md");
432
+ try {
433
+ fs.symlinkSync(sibling, symlinkPath);
434
+ }
435
+ catch (e) {
436
+ const code = e.code;
437
+ if (code === "EPERM") {
438
+ // Some FS / OS don't allow symlinks without admin (e.g. Windows). Skip.
439
+ console.log(" SKIPPED (symlink not permitted on this FS)");
440
+ return;
441
+ }
442
+ throw e;
443
+ }
444
+ // Dream proposes to overwrite that path with new content.
445
+ writeFile(root, `aiwiki/proposed/${dreamId}/gotchas/target.md`, "dream-payload\n");
446
+ writeManifest(root, dreamId);
447
+ let threw = false;
448
+ try {
449
+ await acceptDream(dreamId, root);
450
+ }
451
+ catch (e) {
452
+ threw = true;
453
+ if (!/containment/i.test(e.message)) {
454
+ throw new Error(`expected containment error, got: ${e.message}`);
455
+ }
456
+ }
457
+ if (!threw) {
458
+ throw new Error("expected acceptDream to reject symlink-out-of-root");
459
+ }
460
+ // Sibling untouched
461
+ if (fs.readFileSync(sibling, "utf8") !== "do-not-touch\n") {
462
+ throw new Error("symlink-targeted sibling file was modified");
463
+ }
464
+ fs.unlinkSync(sibling);
465
+ });
466
+ console.log(`\n=== ${passed} passed, ${failed} failed ===`);
467
+ if (failed > 0) {
468
+ console.log("\nFailures:");
469
+ fails.forEach((f) => console.log(" - " + f));
470
+ process.exit(1);
471
+ }
472
+ process.exit(0);