@izkac/forgekit 0.3.10 → 0.3.11

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.11",
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,
@@ -262,7 +263,8 @@ export function scoreSession(opts) {
262
263
  loopNotes = ['verify-evidence.md missing'];
263
264
  } else {
264
265
  const body = fs.readFileSync(evidenceFile, 'utf8');
265
- const executedGreen = e2eRunGreen({ cwd, session, sessionDir });
266
+ const e2eOff = e2eDisabledReason(cwd);
267
+ const executedGreen = !e2eOff && e2eRunGreen({ cwd, session, sessionDir });
266
268
  if (spineHasRows || JOBS_SIGNAL_RE.test(sessionJobsSignalText(session))) {
267
269
  const scored = scoreProductLoopBody(body, executedGreen);
268
270
  loopPts = scored.points;
@@ -278,6 +280,9 @@ export function scoreSession(opts) {
278
280
  loopNotes = ['no spine rows and no jobs signal — partial credit without product-loop'];
279
281
  }
280
282
  }
283
+ if (e2eOff) {
284
+ loopNotes.push(`e2e disabled by project config ("${e2eOff}") — scored from evidence text only`);
285
+ }
281
286
  }
282
287
  checks.push({
283
288
  id: 'product_loop',
@@ -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 —