@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.
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/agents/architect.md +92 -0
- package/agents/builder.md +122 -0
- package/agents/code-reviewer.md +107 -0
- package/agents/concept-designer.md +207 -0
- package/agents/craft-reviewer.md +132 -0
- package/agents/critic.md +130 -0
- package/agents/doc-writer.md +85 -0
- package/agents/dreamer.md +129 -0
- package/agents/e2e-runner.md +89 -0
- package/agents/gotcha-hunter.md +127 -0
- package/agents/prototype-builder.md +193 -0
- package/agents/prototype-codifier.md +204 -0
- package/agents/prototype-reviewer.md +163 -0
- package/agents/security-reviewer.md +108 -0
- package/agents/spec-reviewer.md +94 -0
- package/agents/tracer.md +98 -0
- package/agents/wireframer.md +109 -0
- package/commands/abort.md +25 -0
- package/commands/bugfix.md +151 -0
- package/commands/evolve.md +118 -0
- package/commands/feature.md +236 -0
- package/commands/forge.md +100 -0
- package/commands/greenfield.md +185 -0
- package/commands/hotfix.md +98 -0
- package/commands/refactor.md +147 -0
- package/commands/resume.md +25 -0
- package/commands/setup.md +201 -0
- package/commands/status.md +27 -0
- package/commands/task-force.md +110 -0
- package/commands/validate.md +12 -0
- package/dist/__tests__/active-manifest.test.js +272 -0
- package/dist/__tests__/copy.test.js +96 -0
- package/dist/__tests__/gate-check.test.js +384 -0
- package/dist/__tests__/wiki.test.js +472 -0
- package/dist/__tests__/work-manifest.test.js +304 -0
- package/dist/active-manifest.js +229 -0
- package/dist/cli.js +158 -0
- package/dist/copy.js +124 -0
- package/dist/gate-check.js +326 -0
- package/dist/hooks.js +60 -0
- package/dist/init.js +140 -0
- package/dist/manifest.js +90 -0
- package/dist/merge.js +77 -0
- package/dist/paths.js +36 -0
- package/dist/uninstall.js +216 -0
- package/dist/update.js +158 -0
- package/dist/verify-manifest.js +65 -0
- package/dist/verify.js +98 -0
- package/dist/wiki-ui.js +310 -0
- package/dist/wiki.js +364 -0
- package/dist/work-manifest.js +798 -0
- package/hooks/config/gate-requirements.json +79 -0
- package/hooks/hooks.json +143 -0
- package/hooks/scripts/analyze-telemetry.sh +114 -0
- package/hooks/scripts/gate-enforcer.sh +164 -0
- package/hooks/scripts/pre-compact.sh +90 -0
- package/hooks/scripts/session-start.sh +81 -0
- package/hooks/scripts/telemetry.sh +41 -0
- package/hooks/scripts/wiki-lint.sh +87 -0
- package/hooks/templates/AGENTS.md.template +48 -0
- package/hooks/templates/CLAUDE.md.template +45 -0
- package/package.json +55 -0
- package/protocols/README.md +40 -0
- package/protocols/codex.md +151 -0
- package/protocols/graphify.md +156 -0
- package/references/common/agent-coordination.md +65 -0
- package/references/common/coding-standards.md +54 -0
- package/references/common/feature-tracking.md +21 -0
- package/references/common/io-protocol.md +36 -0
- package/references/common/phases.md +57 -0
- package/references/common/quality-gates.md +130 -0
- package/references/common/skill-authoring.md +154 -0
- package/references/common/skill-compliance.md +30 -0
- package/references/python/standards.md +44 -0
- package/references/react/standards.md +61 -0
- package/references/typescript/standards.md +42 -0
- package/rules/common/forge-system.md +59 -0
- package/rules/common/git-workflow.md +40 -0
- package/rules/common/guardrails.md +37 -0
- package/rules/common/quality-gates.md +18 -0
- package/rules/common/security.md +50 -0
- package/rules/common/skill-selection.md +78 -0
- package/rules/common/testing.md +58 -0
- package/rules/common/verification.md +39 -0
- package/skills/build-pr-workflow/SKILL.md +301 -0
- package/skills/build-pr-workflow/references/pr-template.md +62 -0
- package/skills/build-pr-workflow/references/subagent-merge.md +47 -0
- package/skills/build-pr-workflow/references/worktree-setup.md +125 -0
- package/skills/build-prototype/SKILL.md +264 -0
- package/skills/build-scaffold/SKILL.md +340 -0
- package/skills/build-tdd/SKILL.md +89 -0
- package/skills/build-wireframe/SKILL.md +110 -0
- package/skills/build-wireframe/assets/baseline-template.html +486 -0
- package/skills/build-wireframe/references/demo-walkthroughs.md +170 -0
- package/skills/build-wireframe/references/gotchas.md +188 -0
- package/skills/build-wireframe/references/legend-lines.md +141 -0
- package/skills/concept-slides/SKILL.md +192 -0
- package/skills/deliver-db-migration/SKILL.md +466 -0
- package/skills/deliver-deploy/SKILL.md +407 -0
- package/skills/deliver-onboarding/SKILL.md +198 -0
- package/skills/deliver-onboarding/references/document-templates.md +393 -0
- package/skills/deliver-onboarding/templates/getting-started.md +122 -0
- package/skills/discover-codebase-analysis/SKILL.md +448 -0
- package/skills/discover-requirements/SKILL.md +418 -0
- package/skills/discover-requirements/templates/prd.md +99 -0
- package/skills/discover-requirements/templates/technical-spec.md +123 -0
- package/skills/discover-requirements/templates/user-stories.md +76 -0
- package/skills/harden/SKILL.md +214 -0
- package/skills/iterate-prototype/SKILL.md +241 -0
- package/skills/plan-architecture/SKILL.md +457 -0
- package/skills/plan-architecture/templates/adr-template.md +52 -0
- package/skills/plan-architecture/templates/api-contract.md +99 -0
- package/skills/plan-architecture/templates/db-schema.md +81 -0
- package/skills/plan-architecture/templates/system-design.md +111 -0
- package/skills/plan-brainstorm/SKILL.md +433 -0
- package/skills/plan-design-system/SKILL.md +279 -0
- package/skills/plan-task-decompose/SKILL.md +454 -0
- package/skills/quality-code-review/SKILL.md +286 -0
- package/skills/quality-security-audit/SKILL.md +292 -0
- package/skills/quality-security-audit/references/audit-report-template.md +89 -0
- package/skills/quality-security-audit/references/owasp-checks.md +178 -0
- package/skills/quality-test-execution/SKILL.md +435 -0
- package/skills/quality-test-plan/SKILL.md +297 -0
- package/skills/quality-test-plan/references/test-type-guide.md +263 -0
- package/skills/quality-test-plan/templates/e2e-test-plan.md +72 -0
- package/skills/quality-test-plan/templates/integration-test-plan.md +74 -0
- package/skills/quality-test-plan/templates/load-test-plan.md +111 -0
- package/skills/quality-test-plan/templates/smoke-test-plan.md +68 -0
- package/skills/quality-test-plan/templates/unit-test-plan.md +56 -0
- package/skills/quality-uiux/SKILL.md +481 -0
- package/skills/support-debug/SKILL.md +464 -0
- package/skills/support-dream/SKILL.md +213 -0
- package/skills/support-gotcha/SKILL.md +249 -0
- package/skills/support-runtime-reachability/SKILL.md +190 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-01-passes-app-use/src/app.ts +7 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-01-passes-app-use/src/handlers/cases.ts +7 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-02-orphan-no-app-use/src/app.ts +8 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-02-orphan-no-app-use/src/handlers/cases.ts +7 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-03-orphan-import-only/src/App.tsx +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-03-orphan-import-only/src/components/RingingBanner.tsx +7 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-03-orphan-import-only/src/hooks/useTwilio.ts +6 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-04-jsx-component-rendered/src/App.tsx +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-04-jsx-component-rendered/src/components/MyComp.tsx +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-05-jsx-component-not-rendered/src/App.tsx +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-05-jsx-component-not-rendered/src/components/Orphan.tsx +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-06-class-instantiated/src/lib/Service.ts +6 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-06-class-instantiated/src/main.ts +4 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-07-class-not-instantiated/src/lib/Lonely.ts +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-07-class-not-instantiated/src/main.ts +2 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-08-default-export-imported-and-called/src/handler.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-08-default-export-imported-and-called/src/main.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-09-default-export-orphan/src/handler.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-09-default-export-orphan/src/main.ts +2 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-10-aliased-named-export/src/lib.ts +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-10-aliased-named-export/src/main.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-11-re-export-chain/src/lib/index.ts +1 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-11-re-export-chain/src/lib/internal.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-11-re-export-chain/src/main.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-12-test-only-caller/src/util.test.ts +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-12-test-only-caller/src/util.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-13-gated-pending-annotation/src/future.ts +4 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-14-untraceable-annotation/src/decorated.ts +4 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-15-untraceable-empty/src/lazy.ts +4 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-16-python-module/src/lib.py +15 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-16-python-module/src/main.py +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-17-router-use/src/parent.ts +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-17-router-use/src/routes/cases.ts +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-18-shadowed-name-fp/src/lib/foo.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-18-shadowed-name-fp/src/other.ts +8 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-19-same-name-different-module/src/handlers/cases.ts +4 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-19-same-name-different-module/src/handlers/users.ts +4 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-19-same-name-different-module/src/main.ts +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-20-aliased-import-usage/src/handlers/cases.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-20-aliased-import-usage/src/main.ts +4 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-21-mixed-default-and-named/src/lib.ts +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-21-mixed-default-and-named/src/main.ts +5 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-22-dynamic-import-then-caller/src/lib.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-22-dynamic-import-then-caller/src/main.ts +8 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-23-dynamic-import-with-space/src/lib.ts +3 -0
- package/skills/support-runtime-reachability/scripts/__fixtures__/case-23-dynamic-import-with-space/src/main.ts +7 -0
- package/skills/support-runtime-reachability/scripts/check.mjs +638 -0
- package/skills/support-runtime-reachability/scripts/check.test.mjs +244 -0
- package/skills/support-skill-validator/SKILL.md +194 -0
- package/skills/support-skill-validator/references/false-positives.md +59 -0
- package/skills/support-skill-validator/references/validation-checks.md +280 -0
- package/skills/support-system-guide/SKILL.md +311 -0
- package/skills/support-task-force/SKILL.md +265 -0
- package/skills/support-task-force/references/dispatch-pattern.md +178 -0
- package/skills/support-task-force/references/synthesis-template.md +126 -0
- package/skills/support-wiki-bootstrap/SKILL.md +37 -0
- package/skills/support-wiki-lint/SKILL.md +196 -0
- package/skills/support-wiki-lint/scripts/lint.mjs +488 -0
- package/skills/support-wiki-lint/scripts/lint.test.mjs +196 -0
- package/templates/README.md +23 -0
- package/templates/aiwiki/CLAUDE.md.template +78 -0
- package/templates/aiwiki/schemas/architecture.md +118 -0
- package/templates/aiwiki/schemas/convention.md +112 -0
- package/templates/aiwiki/schemas/decision.md +144 -0
- package/templates/aiwiki/schemas/gotcha.md +118 -0
- package/templates/aiwiki/schemas/oracle.md +105 -0
- package/templates/aiwiki/schemas/session.md +125 -0
- package/templates/manifests/bugfix.yaml +41 -0
- package/templates/manifests/feature.yaml +69 -0
- package/templates/manifests/greenfield.yaml +61 -0
- package/templates/manifests/hotfix.yaml +45 -0
- package/templates/manifests/refactor.yaml +44 -0
- package/templates/manifests/v5/SCHEMA.md +327 -0
- package/templates/manifests/v5/feature.yaml +77 -0
- package/templates/manifests/v6/SCHEMA.md +199 -0
- package/templates/wiki-html/dream-detail.html +378 -0
- 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
|
+
}
|