@jamie-tam/forge 6.0.0 → 6.2.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 (69) hide show
  1. package/README.md +77 -59
  2. package/agents/dreamer.md +10 -7
  3. package/agents/gotcha-hunter.md +1 -1
  4. package/agents/prototype-codifier.md +2 -2
  5. package/commands/{forge.md → discover.md} +13 -9
  6. package/commands/dream.md +71 -0
  7. package/commands/feature.md +57 -10
  8. package/commands/{evolve.md → forge-evolve.md} +3 -3
  9. package/commands/greenfield.md +5 -5
  10. package/commands/note.md +64 -0
  11. package/commands/{task-force.md → parallel.md} +15 -15
  12. package/commands/resume.md +2 -2
  13. package/commands/setup.md +18 -17
  14. package/commands/status.md +2 -2
  15. package/commands/wrap.md +130 -0
  16. package/dist/__tests__/hooks.test.js +334 -0
  17. package/dist/__tests__/init.test.js +110 -0
  18. package/dist/__tests__/work-manifest.test.js +48 -14
  19. package/dist/cli.js +0 -0
  20. package/dist/hooks.js +88 -6
  21. package/dist/init.js +39 -1
  22. package/dist/uninstall.js +11 -5
  23. package/dist/work-manifest.js +63 -24
  24. package/hooks/config/gate-requirements.json +1 -1
  25. package/hooks/hooks.json +14 -1
  26. package/hooks/scripts/gate-enforcer.sh +51 -6
  27. package/hooks/scripts/pre-compact.sh +120 -55
  28. package/hooks/scripts/session-start.sh +43 -4
  29. package/hooks/scripts/telemetry.sh +32 -2
  30. package/hooks/templates/CLAUDE.md.template +6 -3
  31. package/package.json +1 -1
  32. package/references/common/phases.md +8 -6
  33. package/references/common/skill-authoring.md +1 -1
  34. package/rules/common/forge-system.md +64 -6
  35. package/rules/common/quality-gates.md +2 -0
  36. package/skills/build-prototype/SKILL.md +4 -4
  37. package/skills/build-tdd/SKILL.md +14 -0
  38. package/skills/concept-slides/SKILL.md +11 -11
  39. package/skills/deliver-deploy/SKILL.md +10 -1
  40. package/skills/harden/SKILL.md +22 -8
  41. package/skills/iterate-prototype/SKILL.md +22 -0
  42. package/skills/quality-test-execution/SKILL.md +26 -1
  43. package/skills/quality-test-plan/SKILL.md +21 -1
  44. package/skills/support-debug/SKILL.md +1 -1
  45. package/skills/support-dream/SKILL.md +8 -7
  46. package/skills/support-gotcha/SKILL.md +3 -3
  47. package/skills/{support-task-force → support-parallel}/SKILL.md +22 -22
  48. package/skills/{support-task-force → support-parallel}/references/dispatch-pattern.md +10 -10
  49. package/skills/{support-task-force → support-parallel}/references/synthesis-template.md +10 -10
  50. package/skills/support-skill-validator/SKILL.md +5 -5
  51. package/skills/support-skill-validator/references/validation-checks.md +1 -1
  52. package/skills/support-system-guide/SKILL.md +4 -3
  53. package/skills/support-wiki-lint/scripts/lint.mjs +52 -0
  54. package/templates/README.md +1 -1
  55. package/templates/aiwiki/CLAUDE.md.template +48 -22
  56. package/templates/aiwiki/schemas/session.md +134 -49
  57. package/templates/manifests/bugfix.yaml +1 -1
  58. package/templates/manifests/feature.yaml +1 -1
  59. package/templates/manifests/greenfield.yaml +1 -1
  60. package/templates/manifests/hotfix.yaml +1 -1
  61. package/templates/manifests/refactor.yaml +1 -1
  62. package/templates/manifests/v5/SCHEMA.md +14 -17
  63. package/templates/manifests/v5/feature.yaml +1 -1
  64. package/templates/manifests/v6/SCHEMA.md +14 -10
  65. package/commands/abort.md +0 -25
  66. package/dist/__tests__/active-manifest.test.js +0 -272
  67. package/dist/__tests__/gate-check.test.js +0 -384
  68. package/dist/active-manifest.js +0 -229
  69. package/dist/gate-check.js +0 -326
@@ -1,272 +0,0 @@
1
- /**
2
- * Smoke test for src/active-manifest.ts.
3
- *
4
- * Run via: npm run build && node dist/__tests__/active-manifest.test.js
5
- *
6
- * Uses a tmp dir as cwd so the real .forge/state is untouched.
7
- */
8
- import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
- import { clearActiveManifest, getActiveManifest, getActivePointerPath, setActiveManifest, } from "../active-manifest.js";
12
- let passed = 0;
13
- let failed = 0;
14
- const fails = [];
15
- function test(name, fn) {
16
- try {
17
- fn();
18
- passed++;
19
- console.log(` PASS ${name}`);
20
- }
21
- catch (e) {
22
- failed++;
23
- const msg = e instanceof Error ? e.message : String(e);
24
- fails.push(`${name}: ${msg}`);
25
- console.log(` FAIL ${name}\n ${msg}`);
26
- }
27
- }
28
- function withTmpProject(fn) {
29
- const tmp = mkdtempSync(join(tmpdir(), "forge-active-"));
30
- // Make it a git repo so getStateDir resolves cleanly
31
- try {
32
- require("node:child_process").execSync("git init -q", { cwd: tmp });
33
- }
34
- catch {
35
- /* ignore */
36
- }
37
- const manifestPath = join(tmp, "manifest.yaml");
38
- writeFileSync(manifestPath, "schema_version: \"5\"\n");
39
- try {
40
- fn(tmp, manifestPath);
41
- }
42
- finally {
43
- rmSync(tmp, { recursive: true, force: true });
44
- }
45
- }
46
- console.log("\n=== active-manifest.ts smoke tests ===\n");
47
- test("get when not set returns null", () => {
48
- withTmpProject((cwd) => {
49
- const ptr = getActiveManifest(cwd);
50
- if (ptr !== null)
51
- throw new Error(`expected null, got ${JSON.stringify(ptr)}`);
52
- });
53
- });
54
- test("set + get roundtrip", () => {
55
- withTmpProject((cwd, manifestPath) => {
56
- const setResult = setActiveManifest(manifestPath, { cwd, setBy: "test" });
57
- if (setResult.manifest_path !== manifestPath) {
58
- throw new Error(`set returned wrong path: ${setResult.manifest_path}`);
59
- }
60
- if (setResult.set_by !== "test") {
61
- throw new Error(`set_by mismatch: ${setResult.set_by}`);
62
- }
63
- const got = getActiveManifest(cwd);
64
- if (!got)
65
- throw new Error("expected pointer, got null");
66
- if (got.manifest_path !== manifestPath) {
67
- throw new Error(`get returned wrong path: ${got.manifest_path}`);
68
- }
69
- if (!got.set_at.match(/^\d{4}-\d{2}-\d{2}T/)) {
70
- throw new Error(`set_at not ISO-8601: ${got.set_at}`);
71
- }
72
- });
73
- });
74
- test("set rejects non-existent path", () => {
75
- withTmpProject((cwd) => {
76
- let threw = false;
77
- try {
78
- setActiveManifest(join(cwd, "does-not-exist.yaml"), { cwd });
79
- }
80
- catch {
81
- threw = true;
82
- }
83
- if (!threw)
84
- throw new Error("expected throw on non-existent path");
85
- });
86
- });
87
- test("set with skipExistsCheck allows missing path (for tests)", () => {
88
- withTmpProject((cwd) => {
89
- const r = setActiveManifest(join(cwd, "future.yaml"), { cwd, skipExistsCheck: true });
90
- if (!r.manifest_path.endsWith("future.yaml")) {
91
- throw new Error("path not set");
92
- }
93
- });
94
- });
95
- test("clear when not set returns false (idempotent)", () => {
96
- withTmpProject((cwd) => {
97
- const r = clearActiveManifest(cwd);
98
- if (r !== false)
99
- throw new Error(`expected false, got ${r}`);
100
- });
101
- });
102
- test("set + clear + get returns null", () => {
103
- withTmpProject((cwd, manifestPath) => {
104
- setActiveManifest(manifestPath, { cwd });
105
- const cleared = clearActiveManifest(cwd);
106
- if (cleared !== true)
107
- throw new Error("clear should return true after a set");
108
- const got = getActiveManifest(cwd);
109
- if (got !== null)
110
- throw new Error("expected null after clear");
111
- });
112
- });
113
- test("corrupt JSON throws on get (no silent degradation)", () => {
114
- withTmpProject((cwd) => {
115
- const stateDir = join(cwd, ".forge", "state");
116
- mkdirSync(stateDir, { recursive: true });
117
- writeFileSync(join(stateDir, "active-manifest.json"), "{ this is not valid json }");
118
- let threw = false;
119
- try {
120
- getActiveManifest(cwd);
121
- }
122
- catch (e) {
123
- threw = true;
124
- const msg = e instanceof Error ? e.message : String(e);
125
- if (!msg.includes("corrupt")) {
126
- throw new Error(`expected 'corrupt' in error message, got: ${msg}`);
127
- }
128
- }
129
- if (!threw)
130
- throw new Error("expected throw on corrupt JSON");
131
- });
132
- });
133
- test("missing required fields throws on get", () => {
134
- withTmpProject((cwd) => {
135
- const stateDir = join(cwd, ".forge", "state");
136
- mkdirSync(stateDir, { recursive: true });
137
- writeFileSync(join(stateDir, "active-manifest.json"), JSON.stringify({ manifest_path: "x" }));
138
- let threw = false;
139
- try {
140
- getActiveManifest(cwd);
141
- }
142
- catch (e) {
143
- threw = true;
144
- const msg = e instanceof Error ? e.message : String(e);
145
- if (!msg.includes("missing required fields")) {
146
- throw new Error(`expected 'missing required fields', got: ${msg}`);
147
- }
148
- }
149
- if (!threw)
150
- throw new Error("expected throw on missing fields");
151
- });
152
- });
153
- test("atomic write: pointer file ends with newline + valid JSON", () => {
154
- withTmpProject((cwd, manifestPath) => {
155
- setActiveManifest(manifestPath, { cwd });
156
- const path = getActivePointerPath(cwd);
157
- const raw = readFileSync(path, "utf-8");
158
- if (!raw.endsWith("\n"))
159
- throw new Error("pointer file should end with newline");
160
- JSON.parse(raw); // throws if invalid
161
- });
162
- });
163
- test("set rejects directory paths (not a regular file)", () => {
164
- withTmpProject((cwd) => {
165
- let threw = false;
166
- try {
167
- setActiveManifest(cwd, { cwd });
168
- }
169
- catch (e) {
170
- threw = true;
171
- const msg = e instanceof Error ? e.message : String(e);
172
- if (!msg.includes("not a regular file")) {
173
- throw new Error(`expected 'not a regular file', got: ${msg}`);
174
- }
175
- }
176
- if (!threw)
177
- throw new Error("expected throw on directory path");
178
- });
179
- });
180
- test("get throws when pointed-to manifest has been deleted", () => {
181
- withTmpProject((cwd, manifestPath) => {
182
- setActiveManifest(manifestPath, { cwd });
183
- rmSync(manifestPath); // simulate deletion / branch switch
184
- let threw = false;
185
- try {
186
- getActiveManifest(cwd);
187
- }
188
- catch (e) {
189
- threw = true;
190
- const msg = e instanceof Error ? e.message : String(e);
191
- if (!msg.includes("cannot be read")) {
192
- throw new Error(`expected 'cannot be read', got: ${msg}`);
193
- }
194
- }
195
- if (!threw)
196
- throw new Error("expected throw on stale pointer");
197
- });
198
- });
199
- test("get throws when manifest_path is relative in pointer JSON", () => {
200
- withTmpProject((cwd) => {
201
- const stateDir = join(cwd, ".forge", "state");
202
- mkdirSync(stateDir, { recursive: true });
203
- writeFileSync(join(stateDir, "active-manifest.json"), JSON.stringify({ manifest_path: "relative/path.yaml", set_at: "2026-05-04T00:00:00Z", set_by: "test" }));
204
- let threw = false;
205
- try {
206
- getActiveManifest(cwd);
207
- }
208
- catch (e) {
209
- threw = true;
210
- const msg = e instanceof Error ? e.message : String(e);
211
- if (!msg.includes("must be absolute")) {
212
- throw new Error(`expected 'must be absolute', got: ${msg}`);
213
- }
214
- }
215
- if (!threw)
216
- throw new Error("expected throw on relative manifest_path");
217
- });
218
- });
219
- test("get throws when manifest_path is wrong type (e.g. number)", () => {
220
- withTmpProject((cwd) => {
221
- const stateDir = join(cwd, ".forge", "state");
222
- mkdirSync(stateDir, { recursive: true });
223
- writeFileSync(join(stateDir, "active-manifest.json"), JSON.stringify({ manifest_path: 42, set_at: "2026-05-04T00:00:00Z", set_by: "test" }));
224
- let threw = false;
225
- try {
226
- getActiveManifest(cwd);
227
- }
228
- catch (e) {
229
- threw = true;
230
- const msg = e instanceof Error ? e.message : String(e);
231
- if (!msg.includes("wrong types") && !msg.includes("missing required fields")) {
232
- throw new Error(`expected type-validation error, got: ${msg}`);
233
- }
234
- }
235
- if (!threw)
236
- throw new Error("expected throw on wrong type");
237
- });
238
- });
239
- test("set + clear leaves no orphan tmp files", () => {
240
- withTmpProject((cwd, manifestPath) => {
241
- setActiveManifest(manifestPath, { cwd });
242
- clearActiveManifest(cwd);
243
- const stateDir = join(cwd, ".forge", "state");
244
- const entries = readdirSync(stateDir);
245
- const orphans = entries.filter((e) => e.includes(".tmp"));
246
- if (orphans.length > 0)
247
- throw new Error(`found orphan tmp files: ${orphans.join(", ")}`);
248
- });
249
- });
250
- test("set replaces existing pointer (last writer wins)", () => {
251
- withTmpProject((cwd, manifestPath) => {
252
- const otherManifest = join(cwd, "other.yaml");
253
- writeFileSync(otherManifest, "schema_version: \"5\"\n");
254
- setActiveManifest(manifestPath, { cwd, setBy: "first" });
255
- setActiveManifest(otherManifest, { cwd, setBy: "second" });
256
- const got = getActiveManifest(cwd);
257
- if (!got)
258
- throw new Error("expected pointer");
259
- if (got.manifest_path !== otherManifest) {
260
- throw new Error(`expected second writer to win, got ${got.manifest_path}`);
261
- }
262
- if (got.set_by !== "second")
263
- throw new Error(`set_by should be 'second', got ${got.set_by}`);
264
- });
265
- });
266
- console.log(`\n=== ${passed} passed, ${failed} failed ===`);
267
- if (failed > 0) {
268
- console.log("\nFailures:");
269
- fails.forEach((f) => console.log(" - " + f));
270
- process.exit(1);
271
- }
272
- process.exit(0);
@@ -1,384 +0,0 @@
1
- /**
2
- * Smoke test for src/gate-check.ts.
3
- * Calls gateCheck() programmatically (avoids stdin plumbing in tests).
4
- */
5
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { join } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- import { dirname } from "node:path";
10
- import { gateCheck } from "../gate-check.js";
11
- const __filename = fileURLToPath(import.meta.url);
12
- const __dirname = dirname(__filename);
13
- const FIXTURES = join(__dirname, "..", "..", "src", "__fixtures__", "work-manifest");
14
- let passed = 0;
15
- let failed = 0;
16
- const fails = [];
17
- function test(name, fn) {
18
- try {
19
- fn();
20
- passed++;
21
- console.log(` PASS ${name}`);
22
- }
23
- catch (e) {
24
- failed++;
25
- const msg = e instanceof Error ? e.message : String(e);
26
- fails.push(`${name}: ${msg}`);
27
- console.log(` FAIL ${name}\n ${msg}`);
28
- }
29
- }
30
- function withTmpFile(seed, fn) {
31
- const tmpDir = mkdtempSync(join(tmpdir(), "forge-gatecheck-"));
32
- const path = join(tmpDir, "manifest.yaml");
33
- writeFileSync(path, seed);
34
- try {
35
- fn(path);
36
- }
37
- finally {
38
- rmSync(tmpDir, { recursive: true, force: true });
39
- }
40
- }
41
- const goodV5 = readFileSync(join(FIXTURES, "v5-good.yaml"), "utf-8");
42
- console.log("\n=== gate-check.ts smoke tests ===\n");
43
- test("Edit that flips a slice gate from false → true emits transition", () => {
44
- withTmpFile(goodV5, (path) => {
45
- const result = gateCheck({
46
- tool_name: "Edit",
47
- tool_input: {
48
- file_path: path,
49
- old_string: "skeleton-runs: { status: pending, gate-passed: false }",
50
- new_string: "skeleton-runs: { status: complete, gate-passed: true }",
51
- },
52
- });
53
- if (!result.ok)
54
- throw new Error(`expected ok, got: ${result.error}`);
55
- if (result.transitions.length !== 1) {
56
- throw new Error(`expected 1 transition, got ${result.transitions.length}`);
57
- }
58
- const t = result.transitions[0];
59
- if (t.gate !== "skeleton-runs")
60
- throw new Error(`expected gate=skeleton-runs, got ${t.gate}`);
61
- if (t.slice_id !== "skeleton")
62
- throw new Error(`expected slice=skeleton, got ${t.slice_id}`);
63
- if (t.scope !== "slice")
64
- throw new Error(`expected scope=slice, got ${t.scope}`);
65
- if (t.transition !== "false-to-true")
66
- throw new Error(`expected false-to-true, got ${t.transition}`);
67
- });
68
- });
69
- test("Edit that flips a manifest-level (phase) gate emits scope=manifest", () => {
70
- withTmpFile(goodV5, (path) => {
71
- const result = gateCheck({
72
- tool_name: "Edit",
73
- tool_input: {
74
- file_path: path,
75
- old_string: "test-plan: { status: pending, gate-passed: false }",
76
- new_string: "test-plan: { status: complete, gate-passed: true }",
77
- },
78
- });
79
- if (!result.ok)
80
- throw new Error(`expected ok, got: ${result.error}`);
81
- if (result.transitions.length !== 1) {
82
- throw new Error(`expected 1 transition, got ${result.transitions.length}`);
83
- }
84
- const t = result.transitions[0];
85
- if (t.gate !== "test-plan")
86
- throw new Error(`gate mismatch: ${t.gate}`);
87
- if (t.scope !== "manifest")
88
- throw new Error(`expected scope=manifest, got ${t.scope}`);
89
- if (t.slice_id !== undefined)
90
- throw new Error(`phase gate must not have slice_id`);
91
- if (t.path !== "phases.quality.test-plan")
92
- throw new Error(`path mismatch: ${t.path}`);
93
- });
94
- });
95
- test("ambiguous Edit (old_string matches multiple times) fails closed", () => {
96
- withTmpFile(goodV5, (path) => {
97
- const result = gateCheck({
98
- tool_name: "Edit",
99
- tool_input: {
100
- file_path: path,
101
- // build-tdd appears in 3 slices' gate blocks
102
- old_string: "build-tdd: { status: pending, gate-passed: false }",
103
- new_string: "build-tdd: { status: complete, gate-passed: true }",
104
- },
105
- });
106
- if (result.ok)
107
- throw new Error("expected fail-closed on ambiguous edit");
108
- if (!result.error.includes("ambiguous")) {
109
- throw new Error(`expected 'ambiguous' in error, got: ${result.error}`);
110
- }
111
- if (result.exitCode !== 1)
112
- throw new Error(`expected exitCode 1, got ${result.exitCode}`);
113
- });
114
- });
115
- test("Edit with non-matching old_string fails closed", () => {
116
- withTmpFile(goodV5, (path) => {
117
- const result = gateCheck({
118
- tool_name: "Edit",
119
- tool_input: {
120
- file_path: path,
121
- old_string: "this string is not in the file",
122
- new_string: "replacement",
123
- },
124
- });
125
- if (result.ok)
126
- throw new Error("expected fail-closed on no match");
127
- if (!result.error.includes("not found")) {
128
- throw new Error(`expected 'not found' in error, got: ${result.error}`);
129
- }
130
- });
131
- });
132
- test("Edit that doesn't change any gate-passed value emits empty transitions", () => {
133
- withTmpFile(goodV5, (path) => {
134
- const result = gateCheck({
135
- tool_name: "Edit",
136
- tool_input: {
137
- file_path: path,
138
- old_string: 'description: "End-to-end happy-path manifest covering skeleton + 3 slices"',
139
- new_string: 'description: "Edited description, no gate impact"',
140
- },
141
- });
142
- if (!result.ok)
143
- throw new Error(`expected ok, got: ${result.error}`);
144
- if (result.transitions.length !== 0) {
145
- throw new Error(`expected 0 transitions, got ${result.transitions.length}`);
146
- }
147
- });
148
- });
149
- test("Write that introduces fully-passed gates from scratch emits transitions", () => {
150
- withTmpFile("", (path) => {
151
- // Write from empty/non-existent baseline: only delete actually existing
152
- rmSync(path);
153
- const newContent = goodV5.replace(/gate-passed: false/g, "gate-passed: true");
154
- const result = gateCheck({
155
- tool_name: "Write",
156
- tool_input: { file_path: path, content: newContent },
157
- });
158
- if (!result.ok)
159
- throw new Error(`expected ok, got: ${result.error}`);
160
- if (result.transitions.length === 0)
161
- throw new Error("expected transitions on Write");
162
- for (const t of result.transitions) {
163
- if (t.transition !== "false-to-true") {
164
- throw new Error(`unexpected direction: ${t.transition}`);
165
- }
166
- }
167
- });
168
- });
169
- test("non-Edit, non-Write tool returns ok with no transitions (no-op)", () => {
170
- const result = gateCheck({ tool_name: "Bash", tool_input: { file_path: "anything" } });
171
- if (!result.ok)
172
- throw new Error(`expected ok no-op, got: ${result.error}`);
173
- if (result.transitions.length !== 0)
174
- throw new Error("expected no transitions on non-edit tool");
175
- });
176
- test("malformed YAML in proposed content returns exit 2", () => {
177
- withTmpFile(goodV5, (path) => {
178
- const result = gateCheck({
179
- tool_name: "Write",
180
- tool_input: { file_path: path, content: "foo: [unclosed\nbar" },
181
- });
182
- if (result.ok)
183
- throw new Error("expected error on malformed YAML");
184
- if (result.exitCode !== 2)
185
- throw new Error(`expected exitCode 2, got ${result.exitCode}`);
186
- });
187
- });
188
- test("v4 manifest: phase gate transitions still detected (no slice_graph)", () => {
189
- const v4 = readFileSync(join(FIXTURES, "v4-legacy.yaml"), "utf-8");
190
- withTmpFile(v4, (path) => {
191
- const result = gateCheck({
192
- tool_name: "Edit",
193
- tool_input: {
194
- file_path: path,
195
- old_string: "code-review-final: { status: pending, gate-passed: false }",
196
- new_string: "code-review-final: { status: complete, gate-passed: true }",
197
- },
198
- });
199
- if (!result.ok)
200
- throw new Error(`expected ok, got: ${result.error}`);
201
- const t = result.transitions.find((x) => x.gate === "code-review-final");
202
- if (!t)
203
- throw new Error("expected code-review-final transition");
204
- if (t.scope !== "manifest")
205
- throw new Error(`expected scope=manifest, got ${t.scope}`);
206
- });
207
- });
208
- test("Edit reverting true → false emits true-to-false transition", () => {
209
- // Seed with a gate already passed, then revert it.
210
- const seed = goodV5.replace("skeleton-runs: { status: pending, gate-passed: false }", "skeleton-runs: { status: complete, gate-passed: true }");
211
- withTmpFile(seed, (path) => {
212
- const result = gateCheck({
213
- tool_name: "Edit",
214
- tool_input: {
215
- file_path: path,
216
- old_string: "skeleton-runs: { status: complete, gate-passed: true }",
217
- new_string: "skeleton-runs: { status: pending, gate-passed: false }",
218
- },
219
- });
220
- if (!result.ok)
221
- throw new Error(`expected ok, got: ${result.error}`);
222
- if (result.transitions.length !== 1) {
223
- throw new Error(`expected 1 transition, got ${result.transitions.length}`);
224
- }
225
- if (result.transitions[0].transition !== "true-to-false") {
226
- throw new Error(`expected true-to-false, got ${result.transitions[0].transition}`);
227
- }
228
- });
229
- });
230
- test("null payload is rejected (defends stdin null/garbage)", () => {
231
- // Cast to bypass the typed signature; this models what JSON.parse("null") returns.
232
- const result = gateCheck(null);
233
- if (result.ok)
234
- throw new Error("expected error on null payload");
235
- if (!result.error.includes("must be a JSON object")) {
236
- throw new Error(`expected JSON-object error, got: ${result.error}`);
237
- }
238
- });
239
- test("array payload is rejected", () => {
240
- const result = gateCheck([]);
241
- if (result.ok)
242
- throw new Error("expected error on array payload");
243
- if (!result.error.includes("must be a JSON object")) {
244
- throw new Error(`expected JSON-object error, got: ${result.error}`);
245
- }
246
- });
247
- test("YAML date-scalar root rejected (js-yaml parses as Date object)", () => {
248
- withTmpFile(goodV5, (path) => {
249
- const result = gateCheck({
250
- tool_name: "Write",
251
- tool_input: { file_path: path, content: "2026-05-04\n" }, // bare ISO date
252
- });
253
- if (result.ok)
254
- throw new Error("expected error on date-scalar root");
255
- if (!result.error.includes("not a YAML mapping")) {
256
- throw new Error(`expected mapping error, got: ${result.error}`);
257
- }
258
- });
259
- });
260
- test("YAML array root is rejected as not-a-mapping", () => {
261
- withTmpFile("- one\n- two\n- three\n", (path) => {
262
- const result = gateCheck({
263
- tool_name: "Write",
264
- tool_input: { file_path: path, content: "- a\n- b\n" },
265
- });
266
- if (result.ok)
267
- throw new Error("expected error on YAML array root");
268
- if (!result.error.includes("not a YAML mapping")) {
269
- throw new Error(`expected mapping error, got: ${result.error}`);
270
- }
271
- });
272
- });
273
- test("string-typed gate-passed fails closed (no Boolean coercion)", () => {
274
- // Seed uniquely-occurring skeleton-runs gate with a string value.
275
- const seed = goodV5.replace("skeleton-runs: { status: pending, gate-passed: false }", 'skeleton-runs: { status: pending, gate-passed: "false" }');
276
- withTmpFile(seed, (path) => {
277
- // Any unambiguous edit triggers the manifest walk; the walk catches the
278
- // non-boolean gate-passed regardless of whether the edit touches it.
279
- const result = gateCheck({
280
- tool_name: "Edit",
281
- tool_input: {
282
- file_path: path,
283
- old_string: 'description: "End-to-end happy-path manifest covering skeleton + 3 slices"',
284
- new_string: 'description: "Edited"',
285
- },
286
- });
287
- if (result.ok)
288
- throw new Error("expected fail-closed on string-typed gate-passed");
289
- if (!result.error.includes("must be a boolean")) {
290
- throw new Error(`expected boolean-error, got: ${result.error}`);
291
- }
292
- });
293
- });
294
- test("number-typed gate-passed fails closed", () => {
295
- const seed = goodV5.replace("skeleton-runs: { status: pending, gate-passed: false }", "skeleton-runs: { status: pending, gate-passed: 0 }");
296
- withTmpFile(seed, (path) => {
297
- const result = gateCheck({
298
- tool_name: "Edit",
299
- tool_input: {
300
- file_path: path,
301
- old_string: 'description: "End-to-end happy-path manifest covering skeleton + 3 slices"',
302
- new_string: 'description: "Edited"',
303
- },
304
- });
305
- if (result.ok)
306
- throw new Error("expected fail-closed on number gate-passed");
307
- if (!result.error.includes("must be a boolean")) {
308
- throw new Error(`expected boolean-error, got: ${result.error}`);
309
- }
310
- });
311
- });
312
- test("transitions are sorted by yaml-path (deterministic output)", () => {
313
- withTmpFile(goodV5, (path) => {
314
- // Flip three slice gates simultaneously across different slices.
315
- const oldBlock = ` auth-login:
316
- type: feature-slice
317
- depends_on: [skeleton]
318
- status: pending
319
- gates:
320
- build-tdd: { status: pending, gate-passed: false }
321
- wiki-lint: { status: pending, gate-passed: false }
322
- runtime-reach: { status: pending, gate-passed: false }
323
- code-review: { status: pending, gate-passed: false }`;
324
- const newBlock = ` auth-login:
325
- type: feature-slice
326
- depends_on: [skeleton]
327
- status: pending
328
- gates:
329
- build-tdd: { status: complete, gate-passed: true }
330
- wiki-lint: { status: complete, gate-passed: true }
331
- runtime-reach: { status: complete, gate-passed: true }
332
- code-review: { status: complete, gate-passed: true }`;
333
- const result = gateCheck({
334
- tool_name: "Edit",
335
- tool_input: { file_path: path, old_string: oldBlock, new_string: newBlock },
336
- });
337
- if (!result.ok)
338
- throw new Error(`expected ok, got: ${result.error}`);
339
- const paths = result.transitions.map((t) => t.path);
340
- const sorted = [...paths].sort();
341
- if (JSON.stringify(paths) !== JSON.stringify(sorted)) {
342
- throw new Error(`paths not sorted: ${paths.join(",")}`);
343
- }
344
- });
345
- });
346
- test("Edit with multiple gate transitions in one new_string emits all", () => {
347
- withTmpFile(goodV5, (path) => {
348
- // Replace the entire skeleton.gates block, flipping all three gates at once.
349
- const oldBlock = ` gates:
350
- skeleton-runs: { status: pending, gate-passed: false }
351
- wiki-lint: { status: pending, gate-passed: false }
352
- runtime-reach: { status: pending, gate-passed: false }`;
353
- const newBlock = ` gates:
354
- skeleton-runs: { status: complete, gate-passed: true }
355
- wiki-lint: { status: complete, gate-passed: true }
356
- runtime-reach: { status: complete, gate-passed: true }`;
357
- const result = gateCheck({
358
- tool_name: "Edit",
359
- tool_input: { file_path: path, old_string: oldBlock, new_string: newBlock },
360
- });
361
- if (!result.ok)
362
- throw new Error(`expected ok, got: ${result.error}`);
363
- if (result.transitions.length !== 3) {
364
- throw new Error(`expected 3 transitions, got ${result.transitions.length}`);
365
- }
366
- const names = result.transitions.map((t) => t.gate).sort();
367
- if (JSON.stringify(names) !== JSON.stringify(["runtime-reach", "skeleton-runs", "wiki-lint"])) {
368
- throw new Error(`gate names mismatch: ${names.join(",")}`);
369
- }
370
- for (const t of result.transitions) {
371
- if (t.slice_id !== "skeleton")
372
- throw new Error(`expected slice=skeleton, got ${t.slice_id}`);
373
- if (t.scope !== "slice")
374
- throw new Error(`expected scope=slice, got ${t.scope}`);
375
- }
376
- });
377
- });
378
- console.log(`\n=== ${passed} passed, ${failed} failed ===`);
379
- if (failed > 0) {
380
- console.log("\nFailures:");
381
- fails.forEach((f) => console.log(" - " + f));
382
- process.exit(1);
383
- }
384
- process.exit(0);