@jamie-tam/forge 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -59
- package/agents/dreamer.md +5 -6
- package/agents/gotcha-hunter.md +1 -1
- package/agents/prototype-codifier.md +2 -2
- package/commands/{forge.md → discover.md} +11 -9
- package/commands/feature.md +50 -8
- package/commands/{evolve.md → forge-evolve.md} +3 -3
- package/commands/greenfield.md +5 -5
- package/commands/note.md +64 -0
- package/commands/{task-force.md → parallel.md} +15 -15
- package/commands/resume.md +2 -2
- package/commands/setup.md +18 -17
- package/commands/status.md +2 -2
- package/dist/__tests__/hooks.test.js +334 -0
- package/dist/__tests__/init.test.js +110 -0
- package/dist/__tests__/work-manifest.test.js +48 -14
- package/dist/cli.js +0 -0
- package/dist/hooks.js +88 -6
- package/dist/init.js +39 -1
- package/dist/uninstall.js +11 -5
- package/dist/work-manifest.js +63 -24
- package/hooks/hooks.json +14 -1
- package/hooks/scripts/pre-compact.sh +3 -6
- package/hooks/scripts/session-start.sh +1 -1
- package/hooks/templates/CLAUDE.md.template +6 -3
- package/package.json +1 -1
- package/references/common/phases.md +8 -6
- package/references/common/skill-authoring.md +1 -1
- package/rules/common/forge-system.md +42 -4
- package/skills/build-prototype/SKILL.md +4 -4
- package/skills/build-tdd/SKILL.md +14 -0
- package/skills/concept-slides/SKILL.md +11 -11
- package/skills/deliver-deploy/SKILL.md +1 -1
- package/skills/harden/SKILL.md +6 -6
- package/skills/quality-test-execution/SKILL.md +26 -1
- package/skills/quality-test-plan/SKILL.md +21 -1
- package/skills/support-debug/SKILL.md +1 -1
- package/skills/support-dream/SKILL.md +5 -5
- package/skills/support-gotcha/SKILL.md +3 -3
- package/skills/{support-task-force → support-parallel}/SKILL.md +22 -22
- package/skills/{support-task-force → support-parallel}/references/dispatch-pattern.md +10 -10
- package/skills/{support-task-force → support-parallel}/references/synthesis-template.md +10 -10
- package/skills/support-skill-validator/SKILL.md +5 -5
- package/skills/support-skill-validator/references/validation-checks.md +1 -1
- package/skills/support-system-guide/SKILL.md +4 -3
- package/skills/support-wiki-lint/scripts/lint.mjs +52 -0
- package/templates/README.md +1 -1
- package/templates/aiwiki/schemas/session.md +15 -14
- package/templates/manifests/bugfix.yaml +1 -1
- package/templates/manifests/feature.yaml +1 -1
- package/templates/manifests/greenfield.yaml +1 -1
- package/templates/manifests/hotfix.yaml +1 -1
- package/templates/manifests/refactor.yaml +1 -1
- package/templates/manifests/v5/SCHEMA.md +14 -17
- package/templates/manifests/v5/feature.yaml +1 -1
- package/templates/manifests/v6/SCHEMA.md +14 -10
- package/commands/abort.md +0 -25
- package/dist/__tests__/active-manifest.test.js +0 -272
- package/dist/__tests__/gate-check.test.js +0 -384
- package/dist/active-manifest.js +0 -229
- package/dist/gate-check.js +0 -326
|
@@ -198,10 +198,11 @@ test("v5 manifest parsed under v6 reader leaves phase_plan undefined", () => {
|
|
|
198
198
|
throw new Error(`expected v5 manifest to have phase_plan=undefined, got ${JSON.stringify(m.phase_plan)}`);
|
|
199
199
|
}
|
|
200
200
|
});
|
|
201
|
-
test("v6 typo'd phase_plan key triggers
|
|
201
|
+
test("v6 typo'd phase_plan key triggers E_UNKNOWN_PHASE_PLAN_KEY error", () => {
|
|
202
202
|
// Same as v6-good.yaml but with one key intentionally misspelled. Parse
|
|
203
|
-
// should
|
|
204
|
-
//
|
|
203
|
+
// should FAIL (was a warning pre-2026-05-17; promoted to error after dogfood
|
|
204
|
+
// showed silent typos breaking downstream routing). The error should name
|
|
205
|
+
// the bad key so authors can act on it.
|
|
205
206
|
const yaml = `schema_version: "6"
|
|
206
207
|
name: typo-feature
|
|
207
208
|
type: feature
|
|
@@ -230,24 +231,57 @@ slice_graph:
|
|
|
230
231
|
a: { type: feature-slice, depends_on: [], status: pending, gates: { build-tdd: { status: pending, gate-passed: false } } }
|
|
231
232
|
`;
|
|
232
233
|
const r = parseManifest(yaml);
|
|
233
|
-
if (
|
|
234
|
-
throw new Error(
|
|
234
|
+
if (r.ok) {
|
|
235
|
+
throw new Error("expected parse to fail on phase_plan typo, but it succeeded");
|
|
235
236
|
}
|
|
236
|
-
const matching = r.
|
|
237
|
+
const matching = r.errors.filter((e) => e.code === "E_UNKNOWN_PHASE_PLAN_KEY");
|
|
237
238
|
if (matching.length !== 1) {
|
|
238
|
-
throw new Error(`expected exactly 1
|
|
239
|
+
throw new Error(`expected exactly 1 E_UNKNOWN_PHASE_PLAN_KEY error, got ${matching.length}: ${JSON.stringify(r.errors)}`);
|
|
239
240
|
}
|
|
240
241
|
if (!matching[0].path.includes("prototpe")) {
|
|
241
|
-
throw new Error(`expected
|
|
242
|
+
throw new Error(`expected error path to mention the typo'd key, got ${matching[0].path}`);
|
|
242
243
|
}
|
|
243
244
|
});
|
|
244
|
-
test("v6
|
|
245
|
+
test("v6 typo'd phase_plan key error suggests the nearest allowed key", () => {
|
|
246
|
+
// The "did you mean?" hint should fire for a single-edit typo on a known key.
|
|
247
|
+
const yaml = `schema_version: "6"
|
|
248
|
+
name: typo-feature
|
|
249
|
+
type: feature
|
|
250
|
+
description: "phase_plan with a misspelled key"
|
|
251
|
+
status: in-progress
|
|
252
|
+
created: "2026-05-12"
|
|
253
|
+
command: feature
|
|
254
|
+
phase_plan:
|
|
255
|
+
prototpe: active
|
|
256
|
+
phases:
|
|
257
|
+
discover: { codebase-analysis: { status: pending, gate-passed: false } }
|
|
258
|
+
plan: { brainstorm: { status: pending, gate-passed: false } }
|
|
259
|
+
quality: { code-review-final: { status: pending, gate-passed: false } }
|
|
260
|
+
deliver: { pr-created: false }
|
|
261
|
+
support: { gotchas-recorded: false }
|
|
262
|
+
slice_graph:
|
|
263
|
+
current_slice: a
|
|
264
|
+
slices:
|
|
265
|
+
a: { type: feature-slice, depends_on: [], status: pending, gates: { build-tdd: { status: pending, gate-passed: false } } }
|
|
266
|
+
`;
|
|
267
|
+
const r = parseManifest(yaml);
|
|
268
|
+
if (r.ok)
|
|
269
|
+
throw new Error("expected parse to fail");
|
|
270
|
+
const err = r.errors.find((e) => e.code === "E_UNKNOWN_PHASE_PLAN_KEY");
|
|
271
|
+
if (!err)
|
|
272
|
+
throw new Error("expected E_UNKNOWN_PHASE_PLAN_KEY error");
|
|
273
|
+
if (!err.message.includes(`did you mean "prototype"`)) {
|
|
274
|
+
throw new Error(`expected 'did you mean "prototype"' suggestion in message, got: ${err.message}`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
test("v6 happy-path manifest emits no E_UNKNOWN_PHASE_PLAN_KEY errors", () => {
|
|
245
278
|
const r = parseManifest(loadFixture("v6-good.yaml"));
|
|
246
|
-
if (!r.ok)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
279
|
+
if (!r.ok) {
|
|
280
|
+
const unknown = r.errors.filter((e) => e.code === "E_UNKNOWN_PHASE_PLAN_KEY");
|
|
281
|
+
if (unknown.length > 0) {
|
|
282
|
+
throw new Error(`v6-good fixture should have no unknown phase_plan keys; got: ${JSON.stringify(unknown)}`);
|
|
283
|
+
}
|
|
284
|
+
throw new Error(`v6-good failed with other errors: ${r.errors.map((e) => e.code).join(", ")}`);
|
|
251
285
|
}
|
|
252
286
|
});
|
|
253
287
|
test("variant: empty-string on non-skeleton is forbidden", () => {
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/hooks.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, rmSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { SOURCE } from "./paths.js";
|
|
4
|
+
function isForgeOwned(entry, forgeEntries, forgeStrings) {
|
|
5
|
+
if (entry.forge_owned === true)
|
|
6
|
+
return true;
|
|
7
|
+
// Identity fallback: entries installed before the marker shipped lack the
|
|
8
|
+
// field; they match the current forge entry set verbatim. Once replaced
|
|
9
|
+
// with the new (marked) entry, future runs use the marker path.
|
|
10
|
+
return forgeStrings.has(JSON.stringify(entry));
|
|
11
|
+
}
|
|
4
12
|
/**
|
|
5
13
|
* Install forge hooks into the project's .claude/settings.local.json.
|
|
6
14
|
* Copies hook scripts to .claude/hooks/ and merges hook definitions
|
|
@@ -43,18 +51,92 @@ export function installHooks(projectRoot, opts) {
|
|
|
43
51
|
}
|
|
44
52
|
/**
|
|
45
53
|
* Merge forge hooks into existing hooks by event type.
|
|
46
|
-
* For each event forge defines, remove any existing entry
|
|
47
|
-
*
|
|
48
|
-
*
|
|
54
|
+
* For each event forge defines, remove any existing entry identified as
|
|
55
|
+
* forge-owned (via the `forge_owned: true` marker, or via exact JSON match
|
|
56
|
+
* against the current forge entry set for backward compat), then append the
|
|
57
|
+
* new forge entries. User-defined entries are preserved.
|
|
58
|
+
*
|
|
59
|
+
* The marker-based detection is what makes the merge tolerant of changing
|
|
60
|
+
* forge entries between releases: a v6 entry with command "X.sh" and a v7
|
|
61
|
+
* entry with command "X.sh --strict" both carry `forge_owned: true`, so v7's
|
|
62
|
+
* install correctly replaces v6's entry rather than letting it survive as
|
|
63
|
+
* "user-defined" forever.
|
|
49
64
|
*/
|
|
50
65
|
function mergeHooks(existing, forge) {
|
|
51
66
|
const merged = { ...existing };
|
|
52
67
|
for (const [event, forgeEntries] of Object.entries(forge)) {
|
|
53
68
|
const existingEntries = merged[event] ?? [];
|
|
54
69
|
const forgeStrings = new Set(forgeEntries.map((e) => JSON.stringify(e)));
|
|
55
|
-
// Keep only entries that are NOT
|
|
56
|
-
const userEntries = existingEntries.filter((entry) => !
|
|
70
|
+
// Keep only entries that are NOT forge-owned (by marker or by identity fallback)
|
|
71
|
+
const userEntries = existingEntries.filter((entry) => !isForgeOwned(entry, forgeEntries, forgeStrings));
|
|
57
72
|
merged[event] = [...userEntries, ...forgeEntries];
|
|
58
73
|
}
|
|
59
74
|
return merged;
|
|
60
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Mirror of mergeHooks. Remove every entry identified as forge-owned for each
|
|
78
|
+
* event. User-defined entries are preserved in their original order. Events
|
|
79
|
+
* whose entries were entirely forge-owned have their key dropped from the
|
|
80
|
+
* result. Events forge doesn't define are passed through untouched.
|
|
81
|
+
*
|
|
82
|
+
* Detection uses the same dual rule as mergeHooks: `forge_owned: true` marker,
|
|
83
|
+
* with exact-JSON-match fallback for installations made before the marker
|
|
84
|
+
* shipped.
|
|
85
|
+
*/
|
|
86
|
+
export function unmergeHooks(existing, forge) {
|
|
87
|
+
const result = {};
|
|
88
|
+
for (const [event, existingEntries] of Object.entries(existing)) {
|
|
89
|
+
const forgeEntries = forge[event];
|
|
90
|
+
if (!forgeEntries) {
|
|
91
|
+
// Forge doesn't manage this event; leave it alone
|
|
92
|
+
result[event] = existingEntries;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const forgeStrings = new Set(forgeEntries.map((e) => JSON.stringify(e)));
|
|
96
|
+
const userEntries = existingEntries.filter((entry) => !isForgeOwned(entry, forgeEntries, forgeStrings));
|
|
97
|
+
if (userEntries.length > 0) {
|
|
98
|
+
result[event] = userEntries;
|
|
99
|
+
}
|
|
100
|
+
// else drop the event key entirely — nothing user-owned left
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reverse of installHooks. Strip forge-installed hook entries from
|
|
106
|
+
* .claude/settings.local.json while preserving user-added hooks, permissions,
|
|
107
|
+
* env vars, and any other keys. Deletes the file only when nothing remains.
|
|
108
|
+
*
|
|
109
|
+
* Returns the action taken so callers can log it consistently.
|
|
110
|
+
*/
|
|
111
|
+
export function uninstallHooks(projectRoot, opts) {
|
|
112
|
+
const claudeDir = resolve(projectRoot, ".claude");
|
|
113
|
+
const settingsPath = join(claudeDir, "settings.local.json");
|
|
114
|
+
if (!existsSync(settingsPath)) {
|
|
115
|
+
return { settingsPath, action: "absent" };
|
|
116
|
+
}
|
|
117
|
+
const srcHooksJson = join(SOURCE.hooks, "hooks.json");
|
|
118
|
+
if (!existsSync(srcHooksJson)) {
|
|
119
|
+
// No source — can't precisely identify forge entries; leave file untouched
|
|
120
|
+
return { settingsPath, action: "absent" };
|
|
121
|
+
}
|
|
122
|
+
const forgeHooks = JSON.parse(readFileSync(srcHooksJson, "utf-8")).hooks;
|
|
123
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
124
|
+
const existingHooks = (settings.hooks ?? {});
|
|
125
|
+
const remainingHooks = unmergeHooks(existingHooks, forgeHooks);
|
|
126
|
+
if (Object.keys(remainingHooks).length > 0) {
|
|
127
|
+
settings.hooks = remainingHooks;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
delete settings.hooks;
|
|
131
|
+
}
|
|
132
|
+
// Only delete the file if nothing user-meaningful remains.
|
|
133
|
+
if (Object.keys(settings).length === 0) {
|
|
134
|
+
if (!opts.dryRun)
|
|
135
|
+
rmSync(settingsPath);
|
|
136
|
+
return { settingsPath, action: "deleted" };
|
|
137
|
+
}
|
|
138
|
+
if (!opts.dryRun) {
|
|
139
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
140
|
+
}
|
|
141
|
+
return { settingsPath, action: "updated" };
|
|
142
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import { cwd } from "node:process";
|
|
4
4
|
import { SOURCE, targets } from "./paths.js";
|
|
5
5
|
import { copyFlatDir, copyRules, copyTreeDir } from "./copy.js";
|
|
@@ -7,6 +7,38 @@ import { installHooks } from "./hooks.js";
|
|
|
7
7
|
import { verify } from "./verify.js";
|
|
8
8
|
import { collectHashes, writeManifest } from "./manifest.js";
|
|
9
9
|
import { hasForgeMarkers, mergeForgeBlock } from "./merge.js";
|
|
10
|
+
/**
|
|
11
|
+
* Ensure runtime-only forge paths are gitignored. Idempotent: appends only
|
|
12
|
+
* entries the user does not already have. Creates .gitignore if absent.
|
|
13
|
+
*
|
|
14
|
+
* Entries:
|
|
15
|
+
* .forge/state/ — operational state (notepad, telemetry, dream history)
|
|
16
|
+
* .forge/local.yaml — per-user preferences (Codex/Graphify consent, etc.)
|
|
17
|
+
*
|
|
18
|
+
* Why at install time: pre-compact.sh used to add `.forge/state/` from the
|
|
19
|
+
* runtime hook, which surprised users (writes to a tracked file from a hook).
|
|
20
|
+
* Moving this to init means a single intentional touch at user-invoked
|
|
21
|
+
* install time instead of an unexpected mutation mid-session.
|
|
22
|
+
*
|
|
23
|
+
* Returns the list of entries that were added (empty if all already present).
|
|
24
|
+
*/
|
|
25
|
+
export function ensureGitignoreEntries(projectRoot) {
|
|
26
|
+
const entries = [".forge/state/", ".forge/local.yaml"];
|
|
27
|
+
const path = join(projectRoot, ".gitignore");
|
|
28
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
29
|
+
const lines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
30
|
+
// Anything matching `.forge/` (full-dir ignore) covers both entries already.
|
|
31
|
+
if (lines.has(".forge/") || lines.has(".forge"))
|
|
32
|
+
return [];
|
|
33
|
+
const toAdd = entries.filter((e) => !lines.has(e));
|
|
34
|
+
if (toAdd.length === 0)
|
|
35
|
+
return [];
|
|
36
|
+
// Preserve trailing newline discipline of the existing file.
|
|
37
|
+
const needsLeadingNewline = existing.length > 0 && !existing.endsWith("\n");
|
|
38
|
+
const block = (needsLeadingNewline ? "\n" : "") + toAdd.join("\n") + "\n";
|
|
39
|
+
writeFileSync(path, existing + block);
|
|
40
|
+
return toAdd;
|
|
41
|
+
}
|
|
10
42
|
function summarize(label, r) {
|
|
11
43
|
const parts = [];
|
|
12
44
|
if (r.added.length)
|
|
@@ -52,6 +84,12 @@ export async function init(opts) {
|
|
|
52
84
|
}
|
|
53
85
|
}
|
|
54
86
|
mkdirSync(t.claude, { recursive: true });
|
|
87
|
+
// Ensure forge runtime paths are gitignored. Done here (not from a runtime
|
|
88
|
+
// hook) so the mutation of a tracked file is intentional and user-visible.
|
|
89
|
+
const added = ensureGitignoreEntries(projectRoot);
|
|
90
|
+
if (added.length > 0) {
|
|
91
|
+
console.log(` gitignore: added ${added.join(", ")}`);
|
|
92
|
+
}
|
|
55
93
|
// Copy assets
|
|
56
94
|
const agents = copyFlatDir(SOURCE.agents, t.agents, opts);
|
|
57
95
|
summarize("agents:", agents);
|
package/dist/uninstall.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { cwd } from "node:process";
|
|
4
4
|
import { SOURCE, targets } from "./paths.js";
|
|
5
5
|
import { readManifest } from "./manifest.js";
|
|
6
|
+
import { uninstallHooks } from "./hooks.js";
|
|
6
7
|
/**
|
|
7
8
|
* Remove only forge-managed files from a project.
|
|
8
9
|
* Uses the installed manifest (forge.json) when available for precision.
|
|
@@ -160,11 +161,16 @@ export async function uninstall(opts) {
|
|
|
160
161
|
rmSync(hooksDir, { recursive: true });
|
|
161
162
|
console.log(` ${prefix}: hooks/`);
|
|
162
163
|
}
|
|
163
|
-
|
|
164
|
-
if
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
// settings.local.json: strip forge-installed hook entries via reverse-merge,
|
|
165
|
+
// preserve user-added hooks/permissions/env. Delete the file only if nothing
|
|
166
|
+
// user-owned remains.
|
|
167
|
+
const settingsResult = uninstallHooks(projectRoot, { dryRun: opts.dryRun });
|
|
168
|
+
if (settingsResult.action === "deleted") {
|
|
169
|
+
console.log(` ${prefix}: settings.local.json (no user content remained)`);
|
|
170
|
+
}
|
|
171
|
+
else if (settingsResult.action === "updated") {
|
|
172
|
+
const updatePrefix = opts.dryRun ? "Would update" : "Updated";
|
|
173
|
+
console.log(` ${updatePrefix}: settings.local.json (forge hooks removed; user content preserved)`);
|
|
168
174
|
}
|
|
169
175
|
// CLAUDE.md and AGENTS.md — only with --force
|
|
170
176
|
if (opts.force) {
|
package/dist/work-manifest.js
CHANGED
|
@@ -49,7 +49,6 @@ const VALID_SLICE_STATUSES = [
|
|
|
49
49
|
"in-progress",
|
|
50
50
|
"gated",
|
|
51
51
|
"complete",
|
|
52
|
-
"abandoned",
|
|
53
52
|
];
|
|
54
53
|
const VALID_GATE_STATUSES = [
|
|
55
54
|
"pending",
|
|
@@ -70,14 +69,13 @@ const VALID_MANIFEST_STATUSES = [
|
|
|
70
69
|
"paused",
|
|
71
70
|
"completed",
|
|
72
71
|
"escalated",
|
|
73
|
-
"abandoned",
|
|
74
72
|
];
|
|
75
|
-
// Per-work-type
|
|
76
|
-
// recommended-keys table. Keys NOT in this set produce
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const
|
|
73
|
+
// Per-work-type allowed phase_plan keys, sourced from v6 SCHEMA.md §3.2's
|
|
74
|
+
// recommended-keys table. Keys NOT in this set produce E_UNKNOWN_PHASE_PLAN_KEY
|
|
75
|
+
// errors — strict validation per the v6 follow-up decision (2026-05-17) after
|
|
76
|
+
// dogfood signal showed silent typos breaking downstream routing. To add a
|
|
77
|
+
// workflow-specific key, extend the set here AND update SCHEMA.md §3.2.
|
|
78
|
+
const ALLOWED_PHASE_PLAN_KEYS = new Map([
|
|
81
79
|
[
|
|
82
80
|
"feature",
|
|
83
81
|
new Set([
|
|
@@ -221,7 +219,7 @@ export function parseManifest(yaml) {
|
|
|
221
219
|
validatePhaseGatePremature(manifest, errors);
|
|
222
220
|
if (detectedSchema === "v6") {
|
|
223
221
|
validateAndNormalizePhasePlan(manifest, errors);
|
|
224
|
-
validatePhasePlanKeysForType(manifest,
|
|
222
|
+
validatePhasePlanKeysForType(manifest, errors);
|
|
225
223
|
}
|
|
226
224
|
if (errors.length > 0) {
|
|
227
225
|
return { ok: false, errors, warnings };
|
|
@@ -621,33 +619,74 @@ function validateAndNormalizePhasePlan(manifest, errors) {
|
|
|
621
619
|
manifest.phase_plan = normalized;
|
|
622
620
|
}
|
|
623
621
|
/**
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
* custom keys.
|
|
622
|
+
* Strict check: phase_plan keys MUST be in the allowed-keys table for this
|
|
623
|
+
* manifest's work type (v6 SCHEMA.md §3.2). Was a warning until the 2026-05-17
|
|
624
|
+
* follow-up decision — promoted to error because dogfood evidence showed
|
|
625
|
+
* silent typos breaking downstream routing (e.g. `phase_plan.prototpe` parses
|
|
626
|
+
* fine but `rules/common/skill-selection.md` reads `phase_plan.prototype`).
|
|
630
627
|
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
* authors can read `result.warnings` and surface them however they want.
|
|
628
|
+
* To extend a workflow with a new phase_plan key, add it to
|
|
629
|
+
* ALLOWED_PHASE_PLAN_KEYS for the relevant work type AND update SCHEMA.md §3.2.
|
|
634
630
|
*/
|
|
635
|
-
function validatePhasePlanKeysForType(manifest,
|
|
631
|
+
function validatePhasePlanKeysForType(manifest, errors) {
|
|
636
632
|
if (!manifest.phase_plan)
|
|
637
633
|
return;
|
|
638
|
-
const allowed =
|
|
634
|
+
const allowed = ALLOWED_PHASE_PLAN_KEYS.get(manifest.type);
|
|
639
635
|
if (!allowed)
|
|
640
636
|
return; // unknown work type — E_BAD_ENUM_VALUE already caught that
|
|
641
637
|
for (const key of Object.keys(manifest.phase_plan)) {
|
|
642
638
|
if (!allowed.has(key)) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
639
|
+
const suggestion = nearestAllowedKey(key, allowed);
|
|
640
|
+
const suggestionHint = suggestion ? ` (did you mean "${suggestion}"?)` : "";
|
|
641
|
+
errors.push({
|
|
642
|
+
code: "E_UNKNOWN_PHASE_PLAN_KEY",
|
|
643
|
+
message: `phase_plan.${key} is not an allowed key for work type "${manifest.type}"${suggestionHint}. Allowed keys: ${[...allowed].sort().join(", ")}`,
|
|
646
644
|
path: `phase_plan.${key}`,
|
|
647
645
|
});
|
|
648
646
|
}
|
|
649
647
|
}
|
|
650
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* Return the closest allowed key by Levenshtein distance, but only if it's
|
|
651
|
+
* within a small distance (likely typo, not a different word entirely). Helps
|
|
652
|
+
* the "did you mean?" hint in E_UNKNOWN_PHASE_PLAN_KEY messages.
|
|
653
|
+
*/
|
|
654
|
+
function nearestAllowedKey(typo, allowed) {
|
|
655
|
+
let best = null;
|
|
656
|
+
for (const k of allowed) {
|
|
657
|
+
const d = levenshtein(typo, k);
|
|
658
|
+
if (best === null || d < best.dist)
|
|
659
|
+
best = { key: k, dist: d };
|
|
660
|
+
}
|
|
661
|
+
// Only suggest if the edit distance is small relative to key length.
|
|
662
|
+
if (!best)
|
|
663
|
+
return null;
|
|
664
|
+
const threshold = Math.max(2, Math.floor(best.key.length / 3));
|
|
665
|
+
return best.dist <= threshold ? best.key : null;
|
|
666
|
+
}
|
|
667
|
+
function levenshtein(a, b) {
|
|
668
|
+
if (a === b)
|
|
669
|
+
return 0;
|
|
670
|
+
if (a.length === 0)
|
|
671
|
+
return b.length;
|
|
672
|
+
if (b.length === 0)
|
|
673
|
+
return a.length;
|
|
674
|
+
let prev = new Array(b.length + 1);
|
|
675
|
+
let curr = new Array(b.length + 1);
|
|
676
|
+
for (let j = 0; j <= b.length; j++)
|
|
677
|
+
prev[j] = j;
|
|
678
|
+
for (let i = 1; i <= a.length; i++) {
|
|
679
|
+
curr[0] = i;
|
|
680
|
+
for (let j = 1; j <= b.length; j++) {
|
|
681
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
682
|
+
curr[j] = Math.min(curr[j - 1] + 1, // insertion
|
|
683
|
+
prev[j] + 1, // deletion
|
|
684
|
+
prev[j - 1] + cost);
|
|
685
|
+
}
|
|
686
|
+
[prev, curr] = [curr, prev];
|
|
687
|
+
}
|
|
688
|
+
return prev[b.length];
|
|
689
|
+
}
|
|
651
690
|
function validatePhaseGatePremature(manifest, errors) {
|
|
652
691
|
// §3.6: code-review-final cannot be passed until all slices are terminal.
|
|
653
692
|
// Only applies when a slice_graph exists — v6 allows manifests without one
|
|
@@ -660,7 +699,7 @@ function validatePhaseGatePremature(manifest, errors) {
|
|
|
660
699
|
if (!manifest.slice_graph)
|
|
661
700
|
return;
|
|
662
701
|
const slices = manifest.slice_graph.slices;
|
|
663
|
-
const nonTerminal = Object.entries(slices).filter(([, s]) => s.status !== "complete"
|
|
702
|
+
const nonTerminal = Object.entries(slices).filter(([, s]) => s.status !== "complete");
|
|
664
703
|
if (nonTerminal.length > 0) {
|
|
665
704
|
errors.push({
|
|
666
705
|
code: "E_PHASE_GATE_PREMATURE",
|
package/hooks/hooks.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Guardrails for safe git operations and context preservation",
|
|
2
|
+
"description": "Guardrails for safe git operations and context preservation. Every entry carries forge_owned: true so installHooks/uninstallHooks can identify forge-managed entries by provenance marker rather than exact string match — this lets future forge versions change command strings (paths, flags, timeouts) without leaving stale entries in user settings.local.json.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
6
|
+
"forge_owned": true,
|
|
6
7
|
"matcher": "Bash",
|
|
7
8
|
"hooks": [
|
|
8
9
|
{
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
]
|
|
14
15
|
},
|
|
15
16
|
{
|
|
17
|
+
"forge_owned": true,
|
|
16
18
|
"matcher": "Bash",
|
|
17
19
|
"hooks": [
|
|
18
20
|
{
|
|
@@ -23,6 +25,7 @@
|
|
|
23
25
|
]
|
|
24
26
|
},
|
|
25
27
|
{
|
|
28
|
+
"forge_owned": true,
|
|
26
29
|
"matcher": "Edit",
|
|
27
30
|
"hooks": [
|
|
28
31
|
{
|
|
@@ -33,6 +36,7 @@
|
|
|
33
36
|
]
|
|
34
37
|
},
|
|
35
38
|
{
|
|
39
|
+
"forge_owned": true,
|
|
36
40
|
"matcher": "Write",
|
|
37
41
|
"hooks": [
|
|
38
42
|
{
|
|
@@ -43,6 +47,7 @@
|
|
|
43
47
|
]
|
|
44
48
|
},
|
|
45
49
|
{
|
|
50
|
+
"forge_owned": true,
|
|
46
51
|
"matcher": "MultiEdit",
|
|
47
52
|
"hooks": [
|
|
48
53
|
{
|
|
@@ -55,6 +60,7 @@
|
|
|
55
60
|
],
|
|
56
61
|
"PostToolUse": [
|
|
57
62
|
{
|
|
63
|
+
"forge_owned": true,
|
|
58
64
|
"matcher": "Bash",
|
|
59
65
|
"hooks": [
|
|
60
66
|
{
|
|
@@ -65,6 +71,7 @@
|
|
|
65
71
|
]
|
|
66
72
|
},
|
|
67
73
|
{
|
|
74
|
+
"forge_owned": true,
|
|
68
75
|
"matcher": "Skill",
|
|
69
76
|
"hooks": [
|
|
70
77
|
{
|
|
@@ -75,6 +82,7 @@
|
|
|
75
82
|
]
|
|
76
83
|
},
|
|
77
84
|
{
|
|
85
|
+
"forge_owned": true,
|
|
78
86
|
"matcher": "Task",
|
|
79
87
|
"hooks": [
|
|
80
88
|
{
|
|
@@ -85,6 +93,7 @@
|
|
|
85
93
|
]
|
|
86
94
|
},
|
|
87
95
|
{
|
|
96
|
+
"forge_owned": true,
|
|
88
97
|
"matcher": "Edit",
|
|
89
98
|
"hooks": [
|
|
90
99
|
{
|
|
@@ -95,6 +104,7 @@
|
|
|
95
104
|
]
|
|
96
105
|
},
|
|
97
106
|
{
|
|
107
|
+
"forge_owned": true,
|
|
98
108
|
"matcher": "Write",
|
|
99
109
|
"hooks": [
|
|
100
110
|
{
|
|
@@ -105,6 +115,7 @@
|
|
|
105
115
|
]
|
|
106
116
|
},
|
|
107
117
|
{
|
|
118
|
+
"forge_owned": true,
|
|
108
119
|
"matcher": "MultiEdit",
|
|
109
120
|
"hooks": [
|
|
110
121
|
{
|
|
@@ -117,6 +128,7 @@
|
|
|
117
128
|
],
|
|
118
129
|
"SessionStart": [
|
|
119
130
|
{
|
|
131
|
+
"forge_owned": true,
|
|
120
132
|
"matcher": "*",
|
|
121
133
|
"hooks": [
|
|
122
134
|
{
|
|
@@ -129,6 +141,7 @@
|
|
|
129
141
|
],
|
|
130
142
|
"PreCompact": [
|
|
131
143
|
{
|
|
144
|
+
"forge_owned": true,
|
|
132
145
|
"matcher": "*",
|
|
133
146
|
"hooks": [
|
|
134
147
|
{
|
|
@@ -18,12 +18,9 @@ STATE_DIR="$PROJECT_ROOT/.forge/state"
|
|
|
18
18
|
# Create state directory if needed
|
|
19
19
|
mkdir -p "$STATE_DIR"
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
echo '.forge/state/' >> "$PROJECT_ROOT/.gitignore"
|
|
25
|
-
fi
|
|
26
|
-
fi
|
|
21
|
+
# Note: .forge/state/ gitignore management lives in `forge init` (src/init.ts
|
|
22
|
+
# ensureGitignoreEntries). This hook used to append from here, but mutating a
|
|
23
|
+
# tracked user file from a runtime hook is surprising — moved to install time.
|
|
27
24
|
|
|
28
25
|
# Find active work item (status: in-progress), scanning typed subdirs
|
|
29
26
|
NOTEPAD="$STATE_DIR/notepad.md"
|
|
@@ -52,7 +52,7 @@ if [ -d "$PROJECT_ROOT/aiwiki/gotchas" ]; then
|
|
|
52
52
|
|
|
53
53
|
if [ -n "$HOTFIX_FILES" ]; then
|
|
54
54
|
COUNT=$(echo "$HOTFIX_FILES" | wc -l | tr -d ' ')
|
|
55
|
-
WARNINGS="${WARNINGS}WARNING: ${COUNT} unresolved hotfix workaround(s) in aiwiki/gotchas/. Run /evolve or address these before starting new work.\n"
|
|
55
|
+
WARNINGS="${WARNINGS}WARNING: ${COUNT} unresolved hotfix workaround(s) in aiwiki/gotchas/. Run /forge-evolve or address these before starting new work.\n"
|
|
56
56
|
fi
|
|
57
57
|
fi
|
|
58
58
|
|
|
@@ -19,12 +19,13 @@ project:
|
|
|
19
19
|
|
|
20
20
|
New to this project? Describe your goal in plain English — forge auto-routes via skills, OR invoke a command directly:
|
|
21
21
|
|
|
22
|
-
- `/
|
|
22
|
+
- `/discover` — one-screen orientation: what's installed, what's in flight, what to run next
|
|
23
23
|
- `/setup` — detect your stack and install matching language rules (run this first)
|
|
24
24
|
- `/feature <name>` — develop a feature end-to-end with quality gates
|
|
25
25
|
- `/bugfix <name>` — systematic debugging with root-cause analysis
|
|
26
26
|
- `/refactor <name>` — restructure code without new functionality
|
|
27
|
-
- `/
|
|
27
|
+
- `/note <text>` — capture an ad-hoc research note or brainstorm to `aiwiki/raw/`
|
|
28
|
+
- `/parallel <list>` — parallel agents for a punch list of ad-hoc tasks
|
|
28
29
|
- `/validate` — check skill/rule/command consistency
|
|
29
30
|
|
|
30
31
|
Operational state lives in `.forge/state/`; project knowledge in `aiwiki/`.
|
|
@@ -36,7 +37,9 @@ Claude Code's auto-memory (v2.1.59+) lives in `~/.claude/projects/<project>/memo
|
|
|
36
37
|
|
|
37
38
|
This project uses **forge** as its development workflow. Every session should follow it: route via skills, use the `/feature`, `/bugfix`, `/refactor` etc. commands, and keep work artifacts in `.forge/`.
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Knowledge accumulates in **`aiwiki/`** — typed pages (`decisions/`, `gotchas/`, `conventions/`, `architecture/`, `sessions/`) read by subagents during their work, plus `raw/` for free-form research and brainstorm capture (use `/note <text>`). The `wiki-lint` hook validates every `aiwiki/**` write against its schema, and `support-dream` consolidates raw → typed at phase-close and PreCompact (~85% context). Operational state (manifests, telemetry) stays separate in **`.forge/`**.
|
|
41
|
+
|
|
42
|
+
System structure — skills (prefix-grouped), commands, `.forge/` layout, `aiwiki/` integration, context recovery — lives in **`.claude/rules/common/forge-system.md`** (always-on rule, auto-loaded every session).
|
|
40
43
|
|
|
41
44
|
<!-- FORGE:END -->
|
|
42
45
|
|
package/package.json
CHANGED
|
@@ -24,15 +24,17 @@ Phases are **defaults, not requirements**. The work-item manifest at `.forge/wor
|
|
|
24
24
|
|
|
25
25
|
Commands (`/feature`, `/bugfix`, `/greenfield`, `/refactor`) ask the AI to propose phase skips at preflight; the user confirms. Forcing all seven on every work item is wrong shape with current model capability.
|
|
26
26
|
|
|
27
|
-
##
|
|
27
|
+
## Which rules apply when
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
All eight common rules auto-load every session except `testing.md`, which is paths-conditional (loads only on test files and `src/` code).
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
- `references/common/quality-gates.md` — full gate set (the rule-tier stub at `rules/common/quality-gates.md` fans out to this reference)
|
|
33
|
-
- `rules/common/git-workflow.md` — conventional commits + branch hygiene
|
|
31
|
+
The content of the following rules is **scoped to Phase 5+ work** even though they auto-load — agents reading them should treat their guidance as applying from codify onward:
|
|
34
32
|
|
|
35
|
-
Phases 1–4
|
|
33
|
+
- `rules/common/testing.md` — TDD becomes mandatory at Phase 5; Phases 1–4 (prototype work) are exempt because mock-heavy tests under-catch wiring bugs (see `skills/build-tdd` and `skills/iterate-prototype`)
|
|
34
|
+
- `references/common/quality-gates.md` — full gate set (the rule stub at `rules/common/quality-gates.md` fans out to this reference)
|
|
35
|
+
- `rules/common/git-workflow.md` — conventional commits + branch hygiene; relevant once Phase 5 codify produces code-shaped artifacts
|
|
36
|
+
|
|
37
|
+
The other five rules (`security`, `guardrails`, `verification`, `skill-selection`, `forge-system`) apply across all phases.
|
|
36
38
|
|
|
37
39
|
## How to reference this in agent/skill files
|
|
38
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Skill Authoring Guidelines
|
|
2
2
|
|
|
3
|
-
Principles for creating and modifying skills. Reference this when using the skill-creator or `/evolve`.
|
|
3
|
+
Principles for creating and modifying skills. Reference this when using the skill-creator or `/forge-evolve`.
|
|
4
4
|
|
|
5
5
|
## Atomic Single-Responsibility
|
|
6
6
|
|