@izkac/forgekit 0.3.10 → 0.3.12

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/bin/forge.mjs CHANGED
@@ -63,6 +63,7 @@ Commands:
63
63
  change new|archive Specs-engine change scaffold / archive
64
64
  spine init|check Capability→runtime spine matrix (spine.json)
65
65
  e2e init|run|check Executable product-loop acceptance (e2e.json)
66
+ e2e disable|enable Operator-only project e2e off switch
66
67
  defer add|resolve|list Deferral registry (deferred wiring = tracked debt)
67
68
  integrity-check Mechanical integrity gate (runs at phase done)
68
69
  score [--write] L2 session scorecard (auto-written at phase done)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -73,3 +73,25 @@ test('e2e harness --set requires a description', () => {
73
73
  makeFixture(root);
74
74
  assert.throws(() => run(root, ['harness', '--set']), /Usage: forge e2e harness --set/);
75
75
  });
76
+
77
+ test('e2e disable/enable toggles the project off switch; check honors it', () => {
78
+ const root = tmp('e2e-disable-');
79
+ makeFixture(root);
80
+
81
+ assert.match(run(root, ['disable', 'slow legacy stack']), /E2E disabled/);
82
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, '.forge', 'config.json'), 'utf8'));
83
+ assert.equal(cfg.e2e.disabled, 'slow legacy stack');
84
+
85
+ // check passes without any e2e.json while disabled
86
+ const check = JSON.parse(run(root, ['check']));
87
+ assert.equal(check.ok, true);
88
+ assert.equal(check.disabled, 'slow legacy stack');
89
+
90
+ assert.throws(() => run(root, ['disable']), /reason is required/);
91
+
92
+ run(root, ['enable']);
93
+ const cfg2 = JSON.parse(fs.readFileSync(path.join(root, '.forge', 'config.json'), 'utf8'));
94
+ assert.equal(cfg2.e2e.disabled, null);
95
+ // gate demands e2e.json again once re-enabled → non-zero exit
96
+ assert.throws(() => run(root, ['check']));
97
+ });
package/src/e2e.mjs CHANGED
@@ -9,6 +9,8 @@
9
9
  * forge e2e check # gate check: green + current results; exit 1 with problems
10
10
  * forge e2e harness # show recorded project harness (reuse it!)
11
11
  * forge e2e harness --set "<desc>" [--start "<cmd>"] [--dir <path>]
12
+ * forge e2e disable "<reason>" # OPERATOR ONLY: project-level e2e off switch
13
+ * forge e2e enable # re-enable the executed-run requirement
12
14
  * [--session <id>]
13
15
  *
14
16
  * e2e.json lives next to spine.json (change dir, falling back to the session
@@ -22,6 +24,7 @@ import { loadSession, readActive, readJson } from './lib.mjs';
22
24
  import { loadProjectConfig, saveProjectConfig } from './config.mjs';
23
25
  import {
24
26
  checkE2eGate,
27
+ e2eDisabledReason,
25
28
  e2ePath,
26
29
  e2eResultsPath,
27
30
  e2eStepsHash,
@@ -37,7 +40,7 @@ const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'status';
37
40
 
38
41
  if (args[0] === '--help' || sub === 'help') {
39
42
  process.stdout.write(
40
- 'Usage: forge e2e [init [--force] | run | check | status | harness [--set <desc> --start <cmd> --dir <path>]] [--session <id>]\n',
43
+ 'Usage: forge e2e [init [--force] | run | check | status | harness [--set <desc> --start <cmd> --dir <path>] | disable "<reason>" | enable] [--session <id>]\n',
41
44
  );
42
45
  process.exit(0);
43
46
  }
@@ -88,6 +91,28 @@ if (sub === 'harness') {
88
91
  process.exit(0);
89
92
  }
90
93
 
94
+ // Project-level, no session needed. Operator-only: agents must never run
95
+ // `disable` themselves — it is the human's call to trade e2e time away.
96
+ if (sub === 'disable') {
97
+ const reason = args.slice(1).filter((a) => !a.startsWith('--')).join(' ').trim();
98
+ if (!reason) {
99
+ process.stderr.write('Usage: forge e2e disable "<reason>" — a reason is required.\n');
100
+ process.exit(1);
101
+ }
102
+ saveProjectConfig(process.cwd(), { e2e: { disabled: reason } }, { mergeKeys: ['adr', 'plan', 'e2e'] });
103
+ process.stdout.write(
104
+ `E2E disabled for this project (.forge/config.json): ${reason}\n` +
105
+ `Integrity gates stop demanding executed e2e runs. Re-enable: forge e2e enable\n`,
106
+ );
107
+ process.exit(0);
108
+ }
109
+
110
+ if (sub === 'enable') {
111
+ saveProjectConfig(process.cwd(), { e2e: { disabled: null } }, { mergeKeys: ['adr', 'plan', 'e2e'] });
112
+ process.stdout.write('E2E re-enabled — executed green runs are required again where the spine has rows.\n');
113
+ process.exit(0);
114
+ }
115
+
91
116
  let sessionId = null;
92
117
  let force = false;
93
118
  for (let i = 0; i < args.length; i += 1) {
@@ -170,6 +195,13 @@ if (sub === 'run') {
170
195
  }
171
196
 
172
197
  if (sub === 'check') {
198
+ const disabled = e2eDisabledReason(process.cwd());
199
+ if (disabled) {
200
+ process.stdout.write(
201
+ `${JSON.stringify({ file, ok: true, disabled, problems: [] }, null, 2)}\n`,
202
+ );
203
+ process.exit(0);
204
+ }
173
205
  const gate = checkE2eGate({ e2eFile: file, sessionDir: dir });
174
206
  process.stdout.write(
175
207
  JSON.stringify(
@@ -184,7 +216,13 @@ if (sub === 'check') {
184
216
 
185
217
  if (sub === 'status') {
186
218
  if (!fs.existsSync(file)) {
187
- process.stdout.write(JSON.stringify({ file, exists: false, harness: loadHarness() }, null, 2));
219
+ process.stdout.write(
220
+ JSON.stringify(
221
+ { file, exists: false, disabled: e2eDisabledReason(process.cwd()), harness: loadHarness() },
222
+ null,
223
+ 2,
224
+ ),
225
+ );
188
226
  process.stdout.write('\n');
189
227
  process.exit(0);
190
228
  }
@@ -204,6 +242,7 @@ if (sub === 'status') {
204
242
  exists: true,
205
243
  ok: valid.ok,
206
244
  problems: valid.problems,
245
+ disabled: e2eDisabledReason(process.cwd()),
207
246
  harness: loadHarness(),
208
247
  results: results
209
248
  ? {
package/src/integrity.mjs CHANGED
@@ -25,6 +25,7 @@ import fs from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import { readJson, writeJson } from './lib.mjs';
28
+ import { loadProjectConfig } from './config.mjs';
28
29
  import { DEFAULT_SPECS_DIR, resolveProjectPlanEngine } from './plan-engine.mjs';
29
30
 
30
31
  /** Signals that a change involves jobs/workers and therefore needs a spine. */
@@ -602,6 +603,25 @@ export function sessionJobsSignalText(session) {
602
603
  * @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
603
604
  * @returns {{ ok: boolean, problems: string[], spineFile: string, spineExists: boolean, e2eFile: string | null }}
604
605
  */
606
+ /**
607
+ * Project-level e2e off switch: `.forge/config.json` → `e2e.disabled` set to a
608
+ * non-empty reason string. Operator-set via `forge e2e disable "<reason>"` —
609
+ * agents must never set it themselves. When set, the integrity gate stops
610
+ * demanding an executed green e2e run (the most time-consuming part of a
611
+ * session); spine, deferrals, evidence, and BLOCKED checks still apply.
612
+ *
613
+ * @param {string} [cwd]
614
+ * @returns {string | null} the reason, or null when e2e is enabled
615
+ */
616
+ export function e2eDisabledReason(cwd = process.cwd()) {
617
+ try {
618
+ const reason = loadProjectConfig(cwd)?.e2e?.disabled;
619
+ return isNonEmptyString(reason) ? reason : null;
620
+ } catch {
621
+ return null;
622
+ }
623
+ }
624
+
605
625
  export function runIntegrityChecks(opts) {
606
626
  /** @type {string[]} */
607
627
  const problems = [];
@@ -645,9 +665,12 @@ export function runIntegrityChecks(opts) {
645
665
  }
646
666
 
647
667
  let e2eFile = null;
668
+ const e2eDisabled = e2eDisabledReason(cwd);
648
669
  if (spineExists && spineHasRows) {
649
- e2eFile = e2ePath({ cwd, session, sessionDir });
650
- problems.push(...checkE2eGate({ e2eFile, sessionDir }).problems);
670
+ if (!e2eDisabled) {
671
+ e2eFile = e2ePath({ cwd, session, sessionDir });
672
+ problems.push(...checkE2eGate({ e2eFile, sessionDir }).problems);
673
+ }
651
674
 
652
675
  const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
653
676
  if (fs.existsSync(evidenceFile) && /\bBLOCKED\b/.test(fs.readFileSync(evidenceFile, 'utf8'))) {
@@ -655,5 +678,5 @@ export function runIntegrityChecks(opts) {
655
678
  }
656
679
  }
657
680
 
658
- return { ok: problems.length === 0, problems, spineFile, spineExists, e2eFile };
681
+ return { ok: problems.length === 0, problems, spineFile, spineExists, e2eFile, e2eDisabled };
659
682
  }
@@ -470,6 +470,36 @@ test('runIntegrityChecks: spine rows demand an executed green e2e run', () => {
470
470
  }
471
471
  });
472
472
 
473
+ test('runIntegrityChecks: project-level e2e disable skips the executed-run demand', () => {
474
+ const cwd = tmp('forge-int-e2eoff-');
475
+ try {
476
+ const sessionDir = makeSessionDir(cwd);
477
+ writeSpineWithRows(sessionDir);
478
+ const session = { slug: 'wire-worker-jobs', openspecChange: null };
479
+
480
+ let result = runIntegrityChecks({ cwd, sessionDir, session });
481
+ assert.equal(result.ok, false);
482
+ assert.match(result.problems.join('\n'), /e2e\.json required/);
483
+
484
+ fs.writeFileSync(
485
+ path.join(cwd, '.forge', 'config.json'),
486
+ `${JSON.stringify({ e2e: { disabled: 'operator accepts manual verification' } }, null, 2)}\n`,
487
+ 'utf8',
488
+ );
489
+ result = runIntegrityChecks({ cwd, sessionDir, session });
490
+ assert.equal(result.ok, true);
491
+ assert.equal(result.e2eDisabled, 'operator accepts manual verification');
492
+
493
+ // BLOCKED evidence still blocks — the off switch only drops the run demand.
494
+ fs.writeFileSync(path.join(sessionDir, 'verify-evidence.md'), 'BLOCKED: cannot verify\n', 'utf8');
495
+ result = runIntegrityChecks({ cwd, sessionDir, session });
496
+ assert.equal(result.ok, false);
497
+ assert.match(result.problems.join('\n'), /BLOCKED/);
498
+ } finally {
499
+ fs.rmSync(cwd, { recursive: true, force: true });
500
+ }
501
+ });
502
+
473
503
  test('runIntegrityChecks: BLOCKED in verify-evidence blocks even with green e2e', () => {
474
504
  const cwd = tmp('forge-int-blocked-');
475
505
  try {
package/src/score.mjs CHANGED
@@ -12,6 +12,7 @@ import { readJson, writeJson } from './lib.mjs';
12
12
  import {
13
13
  JOBS_SIGNAL_RE,
14
14
  checkE2eGate,
15
+ e2eDisabledReason,
15
16
  e2ePath,
16
17
  loadDeferrals,
17
18
  openDeferrals,
@@ -153,11 +154,13 @@ function scoreProductLoopBody(body, executedGreen = false) {
153
154
  notes.push('no executed green e2e run and no product-loop section in verify-evidence');
154
155
  return { points: 0, max, notes };
155
156
  }
156
- if (/\b(fixture|OP\d+|testdata|sample)\b/i.test(body)) {
157
+ // \w* suffixes: a trailing \b silently rejected inflected forms ("asserts",
158
+ // "fixtures", "ratify") and cost honest sessions real points.
159
+ if (/\b(fixture\w*|OP\d+|testdata\w*|sample\w*)/i.test(body)) {
157
160
  pts += 4;
158
161
  notes.push('names a fixture / corpus');
159
162
  }
160
- if (/\b(differ|baseline|ratif|assert|before.?after|output changed)\b/i.test(body)) {
163
+ if (/\b(differ\w*|baseline\w*|ratif\w*|assert\w*|before.?after|output changed)/i.test(body)) {
161
164
  pts += 5;
162
165
  notes.push('asserts decision/output change vs baseline');
163
166
  } else {
@@ -262,7 +265,8 @@ export function scoreSession(opts) {
262
265
  loopNotes = ['verify-evidence.md missing'];
263
266
  } else {
264
267
  const body = fs.readFileSync(evidenceFile, 'utf8');
265
- const executedGreen = e2eRunGreen({ cwd, session, sessionDir });
268
+ const e2eOff = e2eDisabledReason(cwd);
269
+ const executedGreen = !e2eOff && e2eRunGreen({ cwd, session, sessionDir });
266
270
  if (spineHasRows || JOBS_SIGNAL_RE.test(sessionJobsSignalText(session))) {
267
271
  const scored = scoreProductLoopBody(body, executedGreen);
268
272
  loopPts = scored.points;
@@ -278,6 +282,9 @@ export function scoreSession(opts) {
278
282
  loopNotes = ['no spine rows and no jobs signal — partial credit without product-loop'];
279
283
  }
280
284
  }
285
+ if (e2eOff) {
286
+ loopNotes.push(`e2e disabled by project config ("${e2eOff}") — scored from evidence text only`);
287
+ }
281
288
  }
282
289
  checks.push({
283
290
  id: 'product_loop',
@@ -179,6 +179,32 @@ Fixture: OP1086
179
179
  }
180
180
  });
181
181
 
182
+ test('scoreSession: product-loop bonuses match inflected forms (asserts, fixtures, ratify)', () => {
183
+ const root = tmp('forge-score-inflect-');
184
+ try {
185
+ const { sessionDir, session } = makeSession(root, { slug: 'jobs-loop' });
186
+ fs.writeFileSync(
187
+ path.join(sessionDir, 'spine.json'),
188
+ `${JSON.stringify(validSpine([validRow()]), null, 2)}\n`,
189
+ 'utf8',
190
+ );
191
+ // Every keyword deliberately inflected — a trailing \b used to reject all of these.
192
+ fs.writeFileSync(
193
+ path.join(sessionDir, 'verify-evidence.md'),
194
+ '# Verify\n\n## Product loop\n\n1. seed fixtures\n2. run the loop\n3. asserts the ratify output differs from stored baselines\n',
195
+ 'utf8',
196
+ );
197
+ const card = scoreSession({ cwd: root, sessionDir, session });
198
+ const loop = card.checks.find((c) => c.id === 'product_loop');
199
+ const notes = loop.notes.join('\n');
200
+ assert.match(notes, /names a fixture/);
201
+ assert.match(notes, /asserts decision\/output change/);
202
+ assert.equal(loop.points, 20);
203
+ } finally {
204
+ fs.rmSync(root, { recursive: true, force: true });
205
+ }
206
+ });
207
+
182
208
  test('scoreSession: green e2e run proves the loop even without the phrase', () => {
183
209
  const root = tmp('forge-score-e2e-');
184
210
  try {
@@ -80,6 +80,15 @@ driven by any command (e.g. requires a physical device). Reviewers police the
80
80
  reason; "no time" or "covered by unit tests" is a REJECT. Keep a short loop
81
81
  narrative under `## Product loop` in `verify-evidence.md` as reviewer context.
82
82
 
83
+ **Project-level off switch (operator only).** `forge e2e disable "<reason>"`
84
+ records `e2e.disabled` in `.forge/config.json` and the gate stops demanding
85
+ executed runs project-wide (`forge e2e enable` restores it). This is the
86
+ **human's** trade-off to make — an agent must NEVER run `forge e2e disable`,
87
+ suggest it to dodge a red gate, or edit the config key. If the gate blocks
88
+ you, fix the loop or ask the operator. When it is disabled, keep the
89
+ `## Product loop` narrative in `verify-evidence.md` — with no executed run,
90
+ prose is the only loop evidence the scorecard can grade.
91
+
83
92
  ### Keeping the loop cheap (cost policy)
84
93
 
85
94
  "Too complex to test" is usually a signal to test it, not to wave it through —
@@ -154,6 +163,13 @@ forge defer resolve --task <id> # when the wiring task actually lands
154
163
  - `forge phase done|finish` **refuses** while any deferral is unresolved.
155
164
  - An unregistered "later" in a brief or review is a REJECT.
156
165
 
166
+ **Prose caveats don't survive — checkboxes and deferrals do.** Never tick a
167
+ `tasks.md` box while a note says the work is unproven ("gate was never taken",
168
+ "not yet verified", "should work"). Scorers, gates, and future sessions see
169
+ the `[x]`, not the sentence above it. If a task is incomplete in any way, the
170
+ box **stays open** or the gap becomes a `forge defer` entry — a ticked box
171
+ documenting its own incompleteness is worse than an open one.
172
+
157
173
  ## Spine matrix (mechanical — `forge spine`)
158
174
 
159
175
  **Spine is mandatory for every Forge change** — not gated on pace, and not