@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 +1 -0
- package/package.json +1 -1
- package/src/e2e-cli.test.mjs +22 -0
- package/src/e2e.mjs +41 -2
- package/src/integrity.mjs +26 -3
- package/src/integrity.test.mjs +30 -0
- package/src/score.mjs +6 -1
- package/vendor/skills/forge/references/runtime-integrity.md +9 -0
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
package/src/e2e-cli.test.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
650
|
-
|
|
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
|
}
|
package/src/integrity.test.mjs
CHANGED
|
@@ -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
|
|
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 —
|