@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.
Files changed (61) hide show
  1. package/README.md +73 -59
  2. package/agents/dreamer.md +5 -6
  3. package/agents/gotcha-hunter.md +1 -1
  4. package/agents/prototype-codifier.md +2 -2
  5. package/commands/{forge.md → discover.md} +11 -9
  6. package/commands/feature.md +50 -8
  7. package/commands/{evolve.md → forge-evolve.md} +3 -3
  8. package/commands/greenfield.md +5 -5
  9. package/commands/note.md +64 -0
  10. package/commands/{task-force.md → parallel.md} +15 -15
  11. package/commands/resume.md +2 -2
  12. package/commands/setup.md +18 -17
  13. package/commands/status.md +2 -2
  14. package/dist/__tests__/hooks.test.js +334 -0
  15. package/dist/__tests__/init.test.js +110 -0
  16. package/dist/__tests__/work-manifest.test.js +48 -14
  17. package/dist/cli.js +0 -0
  18. package/dist/hooks.js +88 -6
  19. package/dist/init.js +39 -1
  20. package/dist/uninstall.js +11 -5
  21. package/dist/work-manifest.js +63 -24
  22. package/hooks/hooks.json +14 -1
  23. package/hooks/scripts/pre-compact.sh +3 -6
  24. package/hooks/scripts/session-start.sh +1 -1
  25. package/hooks/templates/CLAUDE.md.template +6 -3
  26. package/package.json +1 -1
  27. package/references/common/phases.md +8 -6
  28. package/references/common/skill-authoring.md +1 -1
  29. package/rules/common/forge-system.md +42 -4
  30. package/skills/build-prototype/SKILL.md +4 -4
  31. package/skills/build-tdd/SKILL.md +14 -0
  32. package/skills/concept-slides/SKILL.md +11 -11
  33. package/skills/deliver-deploy/SKILL.md +1 -1
  34. package/skills/harden/SKILL.md +6 -6
  35. package/skills/quality-test-execution/SKILL.md +26 -1
  36. package/skills/quality-test-plan/SKILL.md +21 -1
  37. package/skills/support-debug/SKILL.md +1 -1
  38. package/skills/support-dream/SKILL.md +5 -5
  39. package/skills/support-gotcha/SKILL.md +3 -3
  40. package/skills/{support-task-force → support-parallel}/SKILL.md +22 -22
  41. package/skills/{support-task-force → support-parallel}/references/dispatch-pattern.md +10 -10
  42. package/skills/{support-task-force → support-parallel}/references/synthesis-template.md +10 -10
  43. package/skills/support-skill-validator/SKILL.md +5 -5
  44. package/skills/support-skill-validator/references/validation-checks.md +1 -1
  45. package/skills/support-system-guide/SKILL.md +4 -3
  46. package/skills/support-wiki-lint/scripts/lint.mjs +52 -0
  47. package/templates/README.md +1 -1
  48. package/templates/aiwiki/schemas/session.md +15 -14
  49. package/templates/manifests/bugfix.yaml +1 -1
  50. package/templates/manifests/feature.yaml +1 -1
  51. package/templates/manifests/greenfield.yaml +1 -1
  52. package/templates/manifests/hotfix.yaml +1 -1
  53. package/templates/manifests/refactor.yaml +1 -1
  54. package/templates/manifests/v5/SCHEMA.md +14 -17
  55. package/templates/manifests/v5/feature.yaml +1 -1
  56. package/templates/manifests/v6/SCHEMA.md +14 -10
  57. package/commands/abort.md +0 -25
  58. package/dist/__tests__/active-manifest.test.js +0 -272
  59. package/dist/__tests__/gate-check.test.js +0 -384
  60. package/dist/active-manifest.js +0 -229
  61. 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 W_UNKNOWN_PHASE_PLAN_KEY warning", () => {
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 still succeed (warning, not error) and the warning should name the
204
- // bad key so command authors can act on it.
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 (!r.ok) {
234
- throw new Error(`expected parse to succeed (warning, not error); got errors: ${r.errors.map((e) => e.code).join(", ")}`);
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.warnings.filter((w) => w.code === "W_UNKNOWN_PHASE_PLAN_KEY");
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 W_UNKNOWN_PHASE_PLAN_KEY warning, got ${matching.length}: ${JSON.stringify(r.warnings)}`);
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 warning path to mention the typo'd key, got ${matching[0].path}`);
242
+ throw new Error(`expected error path to mention the typo'd key, got ${matching[0].path}`);
242
243
  }
243
244
  });
244
- test("v6 happy-path manifest emits no W_UNKNOWN_PHASE_PLAN_KEY warnings", () => {
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
- throw new Error("expected v6-good to parse");
248
- const unknown = r.warnings.filter((w) => w.code === "W_UNKNOWN_PHASE_PLAN_KEY");
249
- if (unknown.length !== 0) {
250
- throw new Error(`expected zero W_UNKNOWN_PHASE_PLAN_KEY warnings on v6-good, got: ${JSON.stringify(unknown)}`);
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 that exactly
47
- * matches a forge entry (old or new), then append the new forge entries.
48
- * User-defined entries that don't match any forge entry are preserved.
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 identical to a forge entry
56
- const userEntries = existingEntries.filter((entry) => !forgeStrings.has(JSON.stringify(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
- const settingsLocal = join(t.claude, "settings.local.json");
164
- if (existsSync(settingsLocal)) {
165
- if (!opts.dryRun)
166
- rmSync(settingsLocal);
167
- console.log(` ${prefix}: settings.local.json`);
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) {
@@ -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 recommended phase_plan keys, sourced from v6 SCHEMA.md §3.2's
76
- // recommended-keys table. Keys NOT in this set produce W_UNKNOWN_PHASE_PLAN_KEY
77
- // warnings (advisory only typos are silent failures by spec, so a warning
78
- // gives commands a chance to catch them at preflight without breaking workflows
79
- // that legitimately extend their plan with custom keys).
80
- const RECOMMENDED_PHASE_PLAN_KEYS = new Map([
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
+ // errorsstrict 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, warnings);
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
- * Advisory check: warn on phase_plan keys not in the recommended-keys table for
625
- * this manifest's work type (v6 SCHEMA.md §3.2). Typos in keys are silent
626
- * failures by spec (the parser does not validate against a canonical
627
- * vocabulary), so this warning gives commands a chance to catch them at
628
- * preflight without breaking workflows that legitimately extend the plan with
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
- * Implemented as a warning, not an error: SCHEMA.md §3.2 reserves promotion to
632
- * a hard enum if real usage shows typos causing failures. Until then, command
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, warnings) {
631
+ function validatePhasePlanKeysForType(manifest, errors) {
636
632
  if (!manifest.phase_plan)
637
633
  return;
638
- const allowed = RECOMMENDED_PHASE_PLAN_KEYS.get(manifest.type);
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
- warnings.push({
644
- code: "W_UNKNOWN_PHASE_PLAN_KEY",
645
- message: `phase_plan.${key} is not a recommended key for work type "${manifest.type}" (likely typo; SCHEMA.md §3.2 lists allowed keys)`,
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" && s.status !== "abandoned");
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
- # Ensure .forge/state/ is gitignored
22
- if [ -f "$PROJECT_ROOT/.gitignore" ]; then
23
- if ! grep -q '.forge/state' "$PROJECT_ROOT/.gitignore" 2>/dev/null; then
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
- - `/forge` — one-screen orientation: what's installed, what's in flight, what to run next
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
- - `/task-force <list>` — parallel agents for a punch list of ad-hoc tasks
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
- System structureskills (prefix-grouped), commands, `.forge/` layout, context recovery lives in **`.claude/rules/common/forge-system.md`** (always-on rule, auto-loaded every session).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jamie-tam/forge",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "AI Development Life Cycle — structured AI-assisted development with quality gates, feature manifests, and agent coordination",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- ## Phase-conditional rules
27
+ ## Which rules apply when
28
28
 
29
- The following rules apply from Phase 5 (codify) onward, per their own phase headers:
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
- - `rules/common/testing.md`TDD becomes mandatory
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 operate under the safety-floor rules only (`rules/common/security.md`, `guardrails.md`, `verification.md`, `skill-selection.md`).
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