@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
package/dist/wiki.js ADDED
@@ -0,0 +1,364 @@
1
+ // Pure wiki review operations: list, detail, accept, reject pending dreams.
2
+ // Used by both the CLI subcommands (forge wiki status/accept/reject) and the
3
+ // local web UI (forge wiki ui). One implementation, two interfaces.
4
+ import * as crypto from "node:crypto";
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ const execFileAsync = promisify(execFile);
10
+ // ---- Public API ------------------------------------------------------------
11
+ export async function listDreams(root) {
12
+ const proposedDir = path.join(root, "aiwiki", "proposed");
13
+ if (!fs.existsSync(proposedDir))
14
+ return [];
15
+ const dreams = [];
16
+ for (const entry of fs.readdirSync(proposedDir)) {
17
+ const manifestPath = path.join(proposedDir, entry, ".dream-manifest.json");
18
+ if (!fs.existsSync(manifestPath))
19
+ continue;
20
+ try {
21
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
22
+ if (m.review_status !== "pending")
23
+ continue;
24
+ dreams.push({
25
+ dream_id: m.dream_id,
26
+ trigger: m.trigger,
27
+ trigger_detail: m.trigger_detail,
28
+ created_at: m.created_at,
29
+ age_human: humanAge(m.created_at),
30
+ changed_pages: m.changed_pages,
31
+ new_pages: m.new_pages,
32
+ deleted_pages: m.deleted_pages,
33
+ lint_status: m.lint_status,
34
+ scope: m.scope ?? [],
35
+ });
36
+ }
37
+ catch {
38
+ // Skip malformed manifests; surface in CLI as a warning later if needed.
39
+ }
40
+ }
41
+ return dreams.sort((a, b) => a.created_at.localeCompare(b.created_at));
42
+ }
43
+ export async function getDreamDetails(dreamId, root) {
44
+ const proposedRoot = path.join(root, "aiwiki", "proposed", dreamId);
45
+ const manifestPath = path.join(proposedRoot, ".dream-manifest.json");
46
+ if (!fs.existsSync(manifestPath))
47
+ return null;
48
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
49
+ const files = [];
50
+ // Walk proposed/ for new + changed files (anything that exists in proposed)
51
+ for (const proposedFile of walkFiles(proposedRoot)) {
52
+ if (path.basename(proposedFile) === ".dream-manifest.json")
53
+ continue;
54
+ const relPath = path.relative(proposedRoot, proposedFile);
55
+ const currentPath = path.join(root, "aiwiki", relPath);
56
+ const kind = fs.existsSync(currentPath)
57
+ ? "changed"
58
+ : "new";
59
+ const diff = await runGitDiff(fs.existsSync(currentPath) ? currentPath : "/dev/null", proposedFile, root);
60
+ files.push({ path: relPath, kind, diff });
61
+ }
62
+ // Find deletions from manifest's prune operations
63
+ for (const op of manifest.operations ?? []) {
64
+ if (op.op === "prune" && op.file) {
65
+ const currentPath = path.join(root, op.file);
66
+ if (!fs.existsSync(currentPath))
67
+ continue;
68
+ const aiwikiPath = path.join(root, "aiwiki");
69
+ const relToAiwiki = path.relative(aiwikiPath, currentPath);
70
+ const diff = await runGitDiff(currentPath, "/dev/null", root);
71
+ files.push({ path: relToAiwiki, kind: "deleted", diff });
72
+ }
73
+ }
74
+ // Sort: new first, then changed, then deleted; alphabetical within each group
75
+ files.sort((a, b) => {
76
+ const order = { new: 0, changed: 1, deleted: 2 };
77
+ const cmp = order[a.kind] - order[b.kind];
78
+ return cmp !== 0 ? cmp : a.path.localeCompare(b.path);
79
+ });
80
+ // Detect concurrent changes: for each file the dream proposes to change/delete,
81
+ // compare base hash (recorded at dream-creation) to current aiwiki/ state.
82
+ // A mismatch means something else edited the file between dream creation and now —
83
+ // accept would silently overwrite that change, so we flag the conflict here.
84
+ const baseHashes = manifest.base_file_hashes ?? {};
85
+ for (const f of files) {
86
+ if (f.kind === "new")
87
+ continue; // no base content to compare against
88
+ const aiwikiPath = path.join(root, "aiwiki", f.path);
89
+ const recordedHash = baseHashes[`aiwiki/${f.path}`];
90
+ if (!recordedHash)
91
+ continue; // older dreams without per-file hashes — skip the check
92
+ const currentHash = fs.existsSync(aiwikiPath)
93
+ ? sha256File(aiwikiPath)
94
+ : ""; // file deleted by something else; that's also a conflict
95
+ if (currentHash !== recordedHash) {
96
+ f.conflict = { expected_hash: recordedHash, actual_hash: currentHash };
97
+ }
98
+ }
99
+ const hasConflicts = files.some((f) => f.conflict !== undefined);
100
+ return { manifest, files, has_conflicts: hasConflicts };
101
+ }
102
+ export async function acceptDream(dreamId, root) {
103
+ // Path containment: resolve root once and require every constructed path
104
+ // to stay inside it. dreamId and manifest op fields are untrusted inputs —
105
+ // a malicious dream-manifest could otherwise read or overwrite files
106
+ // outside aiwiki/ via `..` or absolute paths. Use path.resolve (not
107
+ // path.join) when combining root with an untrusted segment: resolve honors
108
+ // an absolute segment by replacing the base, which then trips the
109
+ // containment check; join would silently treat the absolute segment as
110
+ // relative.
111
+ const rootAbs = path.resolve(root);
112
+ const proposedRoot = assertInsideRoot(rootAbs, path.resolve(rootAbs, "aiwiki", "proposed", dreamId));
113
+ const manifestPath = path.join(proposedRoot, ".dream-manifest.json");
114
+ if (!fs.existsSync(manifestPath)) {
115
+ throw new Error(`Dream not found: ${dreamId}`);
116
+ }
117
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
118
+ if (manifest.review_status !== "pending") {
119
+ throw new Error(`Dream ${dreamId} already ${manifest.review_status}; cannot accept`);
120
+ }
121
+ // Conflict check: detect any concurrent edits to files this dream touches.
122
+ // Refuses with structured error so the user can rerun the dream against current
123
+ // state rather than silently overwriting whatever changed.
124
+ const conflicts = [];
125
+ const baseHashes = manifest.base_file_hashes ?? {};
126
+ for (const [key, expectedHash] of Object.entries(baseHashes)) {
127
+ const filePath = assertInsideRoot(rootAbs, path.resolve(rootAbs, key));
128
+ const currentHash = fs.existsSync(filePath) ? sha256File(filePath) : "";
129
+ if (currentHash !== expectedHash) {
130
+ conflicts.push(key);
131
+ }
132
+ }
133
+ if (conflicts.length > 0) {
134
+ throw new Error(`Concurrent changes detected on ${conflicts.length} file(s):\n` +
135
+ conflicts.map((f) => ` - ${f}`).join("\n") +
136
+ `\n\nThe wiki state changed between when this dream was created and now. ` +
137
+ `Either rerun the dream to incorporate the new state, or reject this dream and create a fresh one.`);
138
+ }
139
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
140
+ // Timestamp is internally generated, but resolve to stay consistent with
141
+ // the surrounding containment checks.
142
+ const archiveRoot = assertInsideRoot(rootAbs, path.resolve(rootAbs, ".forge", "wiki-history", ts));
143
+ fs.mkdirSync(archiveRoot, { recursive: true });
144
+ const acceptedFiles = [];
145
+ // Apply prune operations: delete from aiwiki/, archive originals
146
+ for (const op of manifest.operations ?? []) {
147
+ if (op.op === "prune" && op.file) {
148
+ const currentPath = assertInsideRoot(rootAbs, path.resolve(rootAbs, op.file));
149
+ if (!fs.existsSync(currentPath))
150
+ continue;
151
+ const archiveDest = assertInsideRoot(rootAbs, path.resolve(archiveRoot, op.file));
152
+ fs.mkdirSync(path.dirname(archiveDest), { recursive: true });
153
+ fs.copyFileSync(currentPath, archiveDest);
154
+ fs.unlinkSync(currentPath);
155
+ acceptedFiles.push(op.file);
156
+ }
157
+ }
158
+ // Apply file replacements: copy proposed/{id}/* to aiwiki/*; archive originals.
159
+ // relPath is computed from already-validated paths inside proposedRoot, but we
160
+ // keep the containment check as defense-in-depth.
161
+ for (const proposedFile of walkFiles(proposedRoot)) {
162
+ if (path.basename(proposedFile) === ".dream-manifest.json")
163
+ continue;
164
+ const relPath = path.relative(proposedRoot, proposedFile);
165
+ const targetPath = assertInsideRoot(rootAbs, path.resolve(rootAbs, "aiwiki", relPath));
166
+ if (fs.existsSync(targetPath)) {
167
+ const archiveDest = assertInsideRoot(rootAbs, path.resolve(archiveRoot, "aiwiki", relPath));
168
+ fs.mkdirSync(path.dirname(archiveDest), { recursive: true });
169
+ fs.copyFileSync(targetPath, archiveDest);
170
+ }
171
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
172
+ fs.copyFileSync(proposedFile, targetPath);
173
+ acceptedFiles.push(path.join("aiwiki", relPath));
174
+ }
175
+ // Update manifest with accepted state, then move proposed dir to archive
176
+ manifest.review_status = "accepted";
177
+ manifest.reviewed_at = new Date().toISOString();
178
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
179
+ // dreamId is untrusted — route through the containment helper. archiveRoot
180
+ // is already validated, but `proposed-${dreamId}` could embed traversal.
181
+ const archivedProposedDest = assertInsideRoot(rootAbs, path.resolve(archiveRoot, `proposed-${dreamId}`));
182
+ fs.renameSync(proposedRoot, archivedProposedDest);
183
+ appendDreamHistory(root, {
184
+ dream_id: dreamId,
185
+ action: "accepted",
186
+ timestamp: new Date().toISOString(),
187
+ files_count: acceptedFiles.length,
188
+ archived_to: path.relative(root, archiveRoot),
189
+ });
190
+ return {
191
+ dream_id: dreamId,
192
+ accepted_files: acceptedFiles,
193
+ archived_to: path.relative(root, archiveRoot),
194
+ };
195
+ }
196
+ export async function rejectDream(dreamId, reason, root) {
197
+ const proposedRoot = path.join(root, "aiwiki", "proposed", dreamId);
198
+ if (!fs.existsSync(proposedRoot)) {
199
+ throw new Error(`Dream not found: ${dreamId}`);
200
+ }
201
+ const manifestPath = path.join(proposedRoot, ".dream-manifest.json");
202
+ if (fs.existsSync(manifestPath)) {
203
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
204
+ manifest.review_status = "rejected";
205
+ manifest.reviewed_at = new Date().toISOString();
206
+ manifest.rejection_reason = reason;
207
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
208
+ }
209
+ appendDreamHistory(root, {
210
+ dream_id: dreamId,
211
+ action: "rejected",
212
+ timestamp: new Date().toISOString(),
213
+ reason,
214
+ });
215
+ fs.rmSync(proposedRoot, { recursive: true, force: true });
216
+ return { dream_id: dreamId, reason };
217
+ }
218
+ // ---- Helpers ---------------------------------------------------------------
219
+ /**
220
+ * Asserts that `candidate` resolves to a path inside `rootAbs`. Returns the
221
+ * resolved absolute path on success. Throws on traversal attempts (absolute
222
+ * paths pointing elsewhere, `..` escapes, NUL byte injection, or symlinks
223
+ * pointing outside the root).
224
+ *
225
+ * `rootAbs` MUST already be absolute (use `path.resolve()` on the caller side
226
+ * exactly once). The check is `resolved === rootAbs || resolved.startsWith(rootAbs + sep)`
227
+ * so siblings like `/foo-bar` vs root `/foo` cannot slip through a prefix match.
228
+ *
229
+ * Symlink handling: lexical resolution (path.resolve) is not enough — an
230
+ * attacker can plant a symlink at `aiwiki/target.md` → `/etc/passwd` and
231
+ * fs.copyFileSync will follow it. We resolve symlinks before re-asserting
232
+ * containment:
233
+ * - If `candidate` exists, realpath the candidate itself.
234
+ * - If `candidate` doesn't exist yet (e.g. archive destination), realpath
235
+ * its closest existing ancestor and rebuild the path. This catches the
236
+ * case where the *parent* directory is a symlink pointing outside root.
237
+ *
238
+ * NUL bytes are rejected early — Node string paths can be smuggled with `\0`
239
+ * to truncate at the syscall layer, bypassing whatever lexical checks ran first.
240
+ */
241
+ export function assertInsideRoot(rootAbs, candidate) {
242
+ if (candidate.includes("\0")) {
243
+ throw new Error(`Path contains NUL byte (potential truncation attack): "${candidate}"`);
244
+ }
245
+ const resolved = path.resolve(candidate);
246
+ if (resolved !== rootAbs && !resolved.startsWith(rootAbs + path.sep)) {
247
+ throw new Error(`Path containment violation: "${candidate}" resolves to "${resolved}", ` +
248
+ `which is outside root "${rootAbs}".`);
249
+ }
250
+ // Symlink check: resolve any symlinks in the path components, then re-assert.
251
+ // We compare against the realpath of rootAbs — on platforms like macOS,
252
+ // /var is itself a symlink to /private/var, so a lexical rootAbs would
253
+ // false-positive against a realpath'd candidate.
254
+ let realRootAbs;
255
+ try {
256
+ realRootAbs = fs.realpathSync.native(rootAbs);
257
+ }
258
+ catch {
259
+ // rootAbs doesn't exist — caller hands us a path under a yet-to-be-
260
+ // created root; trust the lexical check above.
261
+ return resolved;
262
+ }
263
+ // For a path that exists, realpathSync the path itself. For a nonexistent
264
+ // path, realpath the closest existing ancestor — that catches a symlinked
265
+ // parent directory even if the final segment hasn't been written yet.
266
+ let realResolved;
267
+ try {
268
+ realResolved = fs.realpathSync.native(resolved);
269
+ }
270
+ catch (err) {
271
+ const code = err.code;
272
+ if (code !== "ENOENT")
273
+ throw err;
274
+ // Walk up until we hit an existing ancestor, then re-append the
275
+ // non-existent tail. If every ancestor is fine, the only sneaky symlink
276
+ // would have to be the path itself — which doesn't exist yet, so it
277
+ // can't be followed by a copyFileSync until something creates it.
278
+ let parent = path.dirname(resolved);
279
+ const tailSegments = [path.basename(resolved)];
280
+ while (parent !== path.dirname(parent)) {
281
+ try {
282
+ const realParent = fs.realpathSync.native(parent);
283
+ const rebuilt = path.join(realParent, ...tailSegments.reverse());
284
+ if (rebuilt !== realRootAbs &&
285
+ !rebuilt.startsWith(realRootAbs + path.sep)) {
286
+ throw new Error(`Path containment violation via symlinked parent: "${candidate}" ` +
287
+ `resolves through realpath to "${rebuilt}", outside root "${realRootAbs}".`);
288
+ }
289
+ return resolved;
290
+ }
291
+ catch (e2) {
292
+ const c2 = e2.code;
293
+ if (c2 !== "ENOENT")
294
+ throw e2;
295
+ tailSegments.push(path.basename(parent));
296
+ parent = path.dirname(parent);
297
+ }
298
+ }
299
+ // Couldn't find any existing ancestor (path is fully fresh, e.g. inside
300
+ // rootAbs which we trust). Return the lexical resolution.
301
+ return resolved;
302
+ }
303
+ if (realResolved !== realRootAbs &&
304
+ !realResolved.startsWith(realRootAbs + path.sep)) {
305
+ throw new Error(`Path containment violation via symlink: "${candidate}" realpath ` +
306
+ `resolves to "${realResolved}", outside root "${realRootAbs}".`);
307
+ }
308
+ return resolved;
309
+ }
310
+ async function runGitDiff(a, b, cwd) {
311
+ try {
312
+ const { stdout } = await execFileAsync("git", ["diff", "--no-index", "--no-color", "--", a, b], { cwd, maxBuffer: 10 * 1024 * 1024 });
313
+ return stdout;
314
+ }
315
+ catch (err) {
316
+ // git diff exits 1 when there are differences — that's expected; capture stdout.
317
+ const e = err;
318
+ if (e.code === 1 && typeof e.stdout === "string") {
319
+ return e.stdout;
320
+ }
321
+ return null;
322
+ }
323
+ }
324
+ function appendDreamHistory(root, entry) {
325
+ const historyPath = path.join(root, ".forge", "dream-history.jsonl");
326
+ fs.mkdirSync(path.dirname(historyPath), { recursive: true });
327
+ fs.appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
328
+ }
329
+ function walkFiles(dir) {
330
+ const out = [];
331
+ function walk(d) {
332
+ if (!fs.existsSync(d) || !fs.statSync(d).isDirectory())
333
+ return;
334
+ for (const entry of fs.readdirSync(d)) {
335
+ const full = path.join(d, entry);
336
+ const stat = fs.statSync(full);
337
+ if (stat.isDirectory())
338
+ walk(full);
339
+ else
340
+ out.push(full);
341
+ }
342
+ }
343
+ walk(dir);
344
+ return out;
345
+ }
346
+ function sha256File(filePath) {
347
+ const content = fs.readFileSync(filePath);
348
+ return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
349
+ }
350
+ function humanAge(iso) {
351
+ const ms = Date.now() - new Date(iso).getTime();
352
+ if (Number.isNaN(ms))
353
+ return "?";
354
+ const minutes = Math.floor(ms / 60000);
355
+ if (minutes < 1)
356
+ return "just now";
357
+ if (minutes < 60)
358
+ return `${minutes}m`;
359
+ const hours = Math.floor(minutes / 60);
360
+ if (hours < 24)
361
+ return `${hours}h`;
362
+ const days = Math.floor(hours / 24);
363
+ return `${days}d`;
364
+ }