@surlymochan/evalid 0.5.18 → 0.5.20

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/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  All notable changes to this project are documented here. Detailed notes live in
4
4
  [RELEASE_NOTES.md](RELEASE_NOTES.md).
5
5
 
6
+ ## [0.5.20] - 2026-11-10
7
+
8
+ ### Added
9
+ - **M4 slice 3: workflow run auto-selects lite (#102)**: `xflow workflow run` with no explicit flag runs the slice-1 classifier first and picks the track itself — small diff → lite (spec ritual skipped, seal + replay only), big diff → full, explicit `--lite` / `--full` always wins (both flags together resolve to full), classifier failure falls back to full and records the failure, never silently lite. The decision rides in the workflow events as `lite_track_decision` (`{source, track, reason, files, added, removed, total, base, force_full}`, source `auto` / `explicit-lite` / `explicit-full` / `auto-fallback-full`), next to slice 1's `lite_fast_track` field; the CLI prints `Lite fast track: <source> → <track>`. Test evidence: 19 new tests in `test/lite-track-auto.test.js` (resolver, flag priority, executor event recording with mock atoms, CLI dry-run wiring) RED→GREEN; PR CI runs verify 42702996913 (3m31s) and ledger 42702997046 (8s) both pass; merged main CI run 42703634292 success.
10
+ - **M4 slice 4: classifier ignores workflow-written change artifacts (#104)**: the auto end-to-end acceptance demo on main HEAD caught a real bug — run 1 auto-decided lite and sealed the red proof, but the resume flipped to full because run 1 had rewritten the tracked `specs/changes/<id>/status.json` itself (+268/−2 lines) and the force-full rule fired on the `specs/` path. `classify_repo` in `xflow/atoms/lite_track.py` now excludes workflow-generated artifacts under `specs/changes/<id>/` (`status.json`, `verify_proof.json`, `spec_delta_review.json/.md`, `tdd/`, `cr/`) from the measured stats before the verdict; human-authored spec files (`proposal.md` / `plan.md` / `tasks.md`) still force full. Test evidence: 4 new classifier unit tests in `test/lite-track.test.js` (status.json churn, generated proof artifacts, proposal.md still forces full, committed status.json churn) 4/4 fail without the fix and 4/4 pass with it; new end-to-end regression test `test/lite-track-auto-acceptance.test.js` (real small fix, no flag, red → fix → resume stays lite with `source: auto, track: lite` in both runs' events, seal + clean replay with binding hash; 400-line control stays auto-full across resume) fails on main HEAD before the fix and passes after it; full `npm run verify` exit 0 — 61 test files, 560 tests, 559 pass, 0 fail (1 skipped); PR CI runs verify 42722307306 (3m31s) and ledger 42722307186 (8s) both pass; merged main CI run 42722893282 success.
11
+
12
+ Wall clock, stated plainly as preliminary (docs/evidence/m4-slice4-auto-resume.md, n=1 per track, same auto demo flow, Linux VM / node v24.22.1 / python 3.12.3): auto demo lite 4,995 ms vs full 6,363 ms (ratio 0.785) isolated, and lite 8,239 ms vs full 7,124 ms (ratio 1.157) inside `npm run verify`; the slice-2 demo re-measured 1.291 in the same suite run. Same demo, same machine, ratios 0.785–1.291 minutes apart — n=1 demo timings are dominated by per-process startup and machine load (the suite runs test files in parallel), not by the spec ritual. The formal ≤1.3× verdict for issue #5 P1-2 waits for the bench full-matrix numbers; P1-2 stays unticked, and this release makes no ≤1.3× claim.
13
+
14
+ Milestone M4 "lite fast track" — **not yet accepted**: slices 1–4 are merged checkpoints; the auto end-to-end acceptance is now demonstrated (real small change through auto-lite across the red→green resume, real large change judged auto-full, seal + clean replay), but the wall-clock verdict still awaits bench numbers. This release makes no M4 acceptance claim.
15
+
16
+ Acceptance debt, stated plainly: the bench-number acceptance for P1-2 / P1-3 / P2-1 / P2-3 still awaits the MiniMax full-matrix batch (15 instances × 4 arms × reps 3), which is at 82/135 with the 12th relaunch in flight. No bench numbers are claimed in this release.
17
+
18
+ ## [0.5.19] - 2026-11-10
19
+
20
+ ### Added
21
+ - **M4 slice 1: lite fast-track classifier and `--lite` switch (#98)**: `xflow/atoms/lite_track.py` classifies a diff `lite` / `full` against the merge-base (`git diff --numstat`, added + removed); thresholds `max_files = 10`, `max_lines = 300`; any touch under `specs/` or `workflows/` forces `full`; empty diff is `lite`; unified-diff text mode (`--diff-file`) also supported. `xflow workflow run` gains an explicit `--lite` flag — the classifier is the decision procedure, no silent auto-switching. Executor `lite_skip` (`src/core/workflow-executor.js` + schema): phases/atoms marked `lite_skip: true` are skipped in a `--lite` run and logged `phase_skipped` / `atom_skipped` (reason `lite_track`); a deterministic gate on a lite-skipped atom passes with verdict `lite_skipped`; `workflow_started` / `workflow_completed` record `lite_fast_track: true/false`. `workflows/yolo.yaml` wiring: `proposal-consistency-check` phase is `lite_skip`; `J4a` / `J4b` atoms in `verify-consistency` are `lite_skip`. Kept in lite mode: TDD run + proof seal + i6d gate (`tdd-green`), full test run (`J1`), code review (`J6`), and `xflow audit` replay. Test evidence: 11 new tests in `test/lite-track.test.js` (classifier on real git fixtures, yolo.yaml wiring, `WorkflowExecutor` lite/full runs with mock atoms) RED→GREEN; full `npm run verify` exit 0 (58 test files); PR CI runs verify 42664527969 and ledger 42664528087 both pass; merged main CI run 42665066351 success.
22
+ - **M4 slice 2: lite acceptance demo + wall-clock preliminary numbers (#99)**: `test/lite-track-acceptance.test.js` proves the lite track end to end through the real `WorkflowExecutor` and real `workflows/yolo.yaml` (dogfood fix: `classify_repo --uncommitted` in `lite_track.py` merged committed and worktree numstats with `max()`, overcounting partially reverted lines — now reads the worktree diff against the base directly; regression test in `test/lite-track.test.js`, 11/12 pass without the fix). Lite run: `proposal-consistency-check` logged `phase_skipped` (reason `lite_track`); `J4a` / `J4b` logged `atom_skipped`; the `verify-consistency` gate passed with verdict `lite_skipped`; `workflow_completed` recorded `lite_fast_track: true`. Seal + clean replay: red proof `passed: false` (`failure_kind: assertion`); green proof `passed: true` with binding hash and snapshot scope; I6b validated both. I6d, I6c, J1 and J6 all passed in both tracks. Full-track control (same flow, `--lite` off): J4a / J4d / J4b all executed and passed; no `lite_track` skip anywhere; `lite_fast_track: false`. Classifier controls: the real 2-file fix classifies `lite`; a 400-line addition classifies `full`; a one-line touch under `specs/` classifies `full` (force-full). Test evidence: 2 new tests (regression + acceptance) RED→GREEN; full `npm run verify` exit 0 — 59 test files, 536 tests, 535 pass, 0 fail (1 pre-existing todo); PR CI runs verify and ledger both pass; merged main CI run 42689050732 success.
23
+
24
+ Wall clock, stated plainly as preliminary (docs/evidence/m4-slice2-wallclock.md, n=1 per track, same demo flow, Linux VM / node v24.22.1 / python 3.12.3): standalone acceptance test lite 5,050 ms vs full 6,330 ms (ratio 0.798); inside `npm run verify` lite 10,420 ms vs full 16,191 ms (ratio 0.644). The two runs disagree by ~0.15 on the ratio minutes apart on the same machine — that spread is why these numbers are preliminary. The formal ≤1.3× verdict for issue #5 P1-2 waits for the bench full-matrix numbers; P1-2 stays unticked, and this release makes no ≤1.3× claim.
25
+
26
+ Milestone M4 "lite fast track" — **not yet accepted**: slices 1 and 2 are merged checkpoints only; the end-to-end acceptance evidence (real small change through `--lite`, real large change judged `full`, wall-clock verdict from bench numbers) is not yet in place. This release makes no M4 acceptance claim.
27
+
28
+ Acceptance debt, stated plainly: the bench-number acceptance for P1-2 / P1-3 / P2-1 / P2-3 still awaits the MiniMax full-matrix batch (15 instances × 4 arms × reps 3), which is still running. No bench numbers are claimed in this release.
29
+
6
30
  ## [0.5.18] - 2026-11-09
7
31
 
8
32
  ### Added
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Release Notes
2
2
 
3
+ ## 0.5.20 - M4 lite fast track, slices 3–4 (milestone M4 not yet accepted)
4
+
5
+ Patch release. Two merged PRs since 0.5.19, both verified end to end in CI and locally.
6
+
7
+ **M4 slice 3: workflow run auto-selects lite (#102).** `xflow workflow run` with no explicit flag runs the classifier first and picks the track itself — small diff → lite, big diff → full, explicit `--lite` / `--full` always wins, classifier failure falls back to full and records it. The decision is written into the workflow events as `lite_track_decision`, next to `lite_fast_track`. Test evidence: 19 new tests in `test/lite-track-auto.test.js` RED→GREEN; PR CI runs verify 42702996913 and ledger 42702997046 both pass; merged main CI run 42703634292 success.
8
+
9
+ **M4 slice 4: classifier ignores workflow-written change artifacts (#104).** The auto end-to-end acceptance demo on main HEAD caught a real bug: run 1 auto-decided lite and sealed the red proof, but the resume flipped to full because run 1 had rewritten the tracked `specs/changes/<id>/status.json` itself (+268/−2 lines) and the force-full rule fired on the `specs/` path. `classify_repo` now excludes workflow-generated artifacts under `specs/changes/<id>/` (`status.json`, `verify_proof.json`, `spec_delta_review.*`, `tdd/`, `cr/`) from the measured stats; human spec files still force full. Test evidence: 4 new classifier unit tests RED→GREEN (4/4 fail without the fix); new end-to-end regression test `test/lite-track-auto-acceptance.test.js` (real small fix, no flag, resume stays lite; 400-line control stays auto-full) fails before the fix, passes after; full `npm run verify` exit 0 — 61 test files, 560 tests, 559 pass, 0 fail; PR CI runs verify 42722307306 and ledger 42722307186 both pass; merged main CI run 42722893282 success.
10
+
11
+ Wall clock, stated plainly as preliminary (docs/evidence/m4-slice4-auto-resume.md, n=1 per track, same auto demo flow, Linux VM / node v24.22.1 / python 3.12.3): auto demo lite 4,995 ms vs full 6,363 ms (ratio 0.785) isolated, lite 8,239 ms vs full 7,124 ms (ratio 1.157) inside `npm run verify`; slice-2 demo re-measured 1.291 in the same suite run. Same demo, same machine, ratios 0.785–1.291 — n=1 demo timings are dominated by startup and machine load, not by the spec ritual. The formal ≤1.3× verdict for issue #5 P1-2 waits for the bench full-matrix numbers; P1-2 stays unticked, and this release makes no ≤1.3× claim.
12
+
13
+ **Milestone M4 "lite fast track" — not yet accepted.** Slices 1–4 are merged checkpoints; the auto end-to-end acceptance is now demonstrated (real small change through auto-lite across the red→green resume, real large change judged auto-full, seal + clean replay), but the wall-clock verdict still awaits bench numbers.
14
+
15
+ Acceptance debt, stated plainly: the bench-number acceptance for P1-2, P1-3, P2-1 and P2-3 — the wall-clock/token/resolved lines from issue #5 — still awaits the MiniMax full-matrix batch (15 instances × 4 arms × reps 3), at 82/135 with the 12th relaunch in flight. No bench numbers are claimed in this release.
16
+
17
+ ## 0.5.19 - M4 lite fast track, slices 1–2 (milestone M4 not yet accepted)
18
+
19
+ Patch release. Two merged PRs since 0.5.18, both verified end to end in CI and locally.
20
+
21
+ **M4 slice 1: lite fast-track classifier and `--lite` switch (#98).** `xflow/atoms/lite_track.py` classifies a diff `lite` / `full` against the merge-base; thresholds `max_files = 10`, `max_lines = 300`; any touch under `specs/` or `workflows/` forces `full`; empty diff is `lite`. `xflow workflow run` gains an explicit `--lite` flag — the classifier decides, no silent auto-switching. Executor `lite_skip` skips the spec ritual in a `--lite` run (`proposal-consistency-check` phase, `J4a` / `J4b` atoms) and logs the skips; kept in lite mode: TDD run + proof seal + i6d gate, full test run (`J1`), code review (`J6`), and `xflow audit` replay. Test evidence: 11 new tests in `test/lite-track.test.js` RED→GREEN; full `npm run verify` exit 0 (58 test files); PR CI runs verify 42664527969 and ledger 42664528087 both pass; merged main CI run 42665066351 success.
22
+
23
+ **M4 slice 2: lite acceptance demo + wall-clock preliminary numbers (#99).** `test/lite-track-acceptance.test.js` proves the lite track end to end through the real executor and real `workflows/yolo.yaml` (dogfood fix: `classify_repo --uncommitted` overcounted partially reverted lines — now reads the worktree diff against the base directly; regression test, 11/12 pass without the fix). Lite run: `proposal-consistency-check` skipped (reason `lite_track`), `J4a` / `J4b` skipped, `verify-consistency` gate passed with verdict `lite_skipped`, `lite_fast_track: true`; seal + clean replay (red proof `passed: false`, green proof `passed: true`, I6b validated both); I6d, I6c, J1 and J6 passed in both tracks. Full-track control ran all spec atoms, `lite_fast_track: false`. Classifier controls: 2-file fix → `lite`; 400-line addition → `full`; one-line `specs/` touch → `full`. Test evidence: 2 new tests RED→GREEN; full `npm run verify` exit 0 — 59 test files, 536 tests, 535 pass, 0 fail (1 pre-existing todo); PR CI verify and ledger both pass; merged main CI run 42689050732 success.
24
+
25
+ Wall clock, stated plainly as preliminary (docs/evidence/m4-slice2-wallclock.md, n=1 per track, same demo flow, Linux VM / node v24.22.1 / python 3.12.3): standalone acceptance test lite 5,050 ms vs full 6,330 ms (ratio 0.798); inside `npm run verify` lite 10,420 ms vs full 16,191 ms (ratio 0.644). The two runs disagree by ~0.15 on the ratio minutes apart on the same machine — that spread is why these numbers are preliminary. The formal ≤1.3× verdict for issue #5 P1-2 waits for the bench full-matrix numbers; P1-2 stays unticked, and this release makes no ≤1.3× claim.
26
+
27
+ **Milestone M4 "lite fast track" — not yet accepted.** Slices 1 and 2 are merged checkpoints only; the end-to-end acceptance evidence (real small change through `--lite`, real large change judged `full`, wall-clock verdict from bench numbers) is not yet in place.
28
+
29
+ Acceptance debt, stated plainly: the bench-number acceptance for P1-2, P1-3, P2-1 and P2-3 — the wall-clock/token/resolved lines from issue #5 — still awaits the MiniMax full-matrix batch (15 instances × 4 arms × reps 3), which is still running. No bench numbers are claimed in this release.
30
+
3
31
  ## 0.5.18 - Proof verdicts as SARIF for code scanning (issue #73 closed)
4
32
 
5
33
  Patch release. One merged PR since 0.5.17, verified end to end in CI and locally, with the Security-tab acceptance evidence from a demo PR.
package/bin/xflow.js CHANGED
@@ -5478,6 +5478,21 @@ async function handleWorkflow(sub, args) {
5478
5478
  }
5479
5479
  }
5480
5480
  ensureChangeId(changeId, 'workflow run', projectRoot);
5481
+ // M4 slice 3 (evalid #101): auto-select the lite track. With no
5482
+ // explicit flag the classifier decides (small diff -> lite, big diff
5483
+ // -> full, classifier failure -> full); --lite / --full always win.
5484
+ // The decision is attached to the workflow input so the executor
5485
+ // records it in the workflow_started / workflow_completed events.
5486
+ const { decideLiteTrack, resolveLiteTrackAuto } = await import('../src/core/lite-track-auto.js');
5487
+ const autoDecision = (parsedArgs.lite === true || parsedArgs.full === true)
5488
+ ? null
5489
+ : resolveLiteTrackAuto({ projectRoot });
5490
+ const { lite: effectiveLite, decision: liteDecision } = decideLiteTrack({
5491
+ lite: parsedArgs.lite, full: parsedArgs.full, autoDecision,
5492
+ });
5493
+ if (effectiveLite) input.lite = true;
5494
+ input.lite_track_decision = liteDecision;
5495
+ console.log(`Lite fast track: ${liteDecision.source} → ${liteDecision.track} (${liteDecision.reason})`);
5481
5496
  const { load, dryRun } = await import('../src/core/workflow-loader.js');
5482
5497
  if (isDryRun) {
5483
5498
  dryRun(yamlPath);
@@ -6692,6 +6707,20 @@ Commands:
6692
6707
  validate <yaml|yolo|corps> Validate workflow YAML as preflight only
6693
6708
 
6694
6709
  Options:
6710
+ --lite M4 lite fast track: skip spec-ritual steps
6711
+ (proposal-consistency-check phase, J4a/J4b atoms)
6712
+ and keep seal + replay only. Always wins over the
6713
+ auto verdict (see below).
6714
+ --full M4 lite fast track: force the full track, skipping
6715
+ the auto verdict. Wins over --lite when both are
6716
+ given.
6717
+ With neither --lite nor --full, the run classifies
6718
+ the working-tree diff first
6719
+ (python3 xflow/atoms/lite_track.py --repo .)
6720
+ and auto-selects lite for a small diff, full for a
6721
+ big one; a classifier failure falls back to full.
6722
+ The decision is printed and recorded in the run's
6723
+ workflow events as lite_track_decision.
6695
6724
  help, -h, --help Show this help message
6696
6725
  `);
6697
6726
  }
@@ -0,0 +1,61 @@
1
+ # M4 slice 2: lite vs full wall-clock — local preliminary numbers
2
+
3
+ Date: 2026-11-10 (Asia/Singapore). Source of truth for the demo itself is
4
+ `test/lite-track-acceptance.test.js` (re-run it to reproduce).
5
+
6
+ ## Status: preliminary — NOT the ≤1.3× conclusion
7
+
8
+ These are local n=1 measurements of the acceptance demo harness. The formal
9
+ wall-clock verdict for issue #5 P1-2 (small tasks ≤1.3× baseline) waits for
10
+ the bench full-matrix numbers; P1-2 stays unticked and no ≤1.3× claim is
11
+ made here.
12
+
13
+ ## Method
14
+
15
+ Same demo flow per track, in a throwaway git repo holding a buggy copy of
16
+ `lite_track.py`: the real `WorkflowExecutor` runs the real
17
+ `workflows/yolo.yaml` phases (`proposal-consistency-check`, `tdd-red`,
18
+ `tdd-green`, `verify-consistency`) with the real atom registry. First run
19
+ (red seals, green stops pre-fix), fix applied, resume run (green seals,
20
+ verify runs). The lite run skips the spec ritual; the full run executes it.
21
+ Each total below is first run + resume run.
22
+
23
+ Machine: Linux x86_64 VM, AMD EPYC 9D25, 4 vCPU; node v24.22.1, python 3.12.3.
24
+
25
+ ## Numbers
26
+
27
+ | Run | lite (first + resume) | full (first + resume) | lite/full |
28
+ | --- | --- | --- | --- |
29
+ | Standalone acceptance test | 2,198 ms + 2,852 ms = 5,050 ms | 2,816 ms + 3,514 ms = 6,330 ms | 0.798 |
30
+ | Inside `npm run verify` (full suite) | 4,120 ms + 6,300 ms = 10,420 ms | 7,601 ms + 8,590 ms = 16,191 ms | 0.644 |
31
+
32
+ n=1 per track per run. The two runs, minutes apart on the same machine,
33
+ disagree by ~0.15 on the ratio — that spread is itself the reason these
34
+ numbers are preliminary: the demo's absolute times are dominated by
35
+ per-process startup (node + python atoms) and machine load, not by the
36
+ spec ritual alone. Do not quote 0.64 or 0.80 as the lite-track speedup.
37
+
38
+ ## What the demo proved (functional, not timing)
39
+
40
+ - Classifier: the real fix (2 files, `lite_track.py` + `test/demo.test.mjs`)
41
+ classifies `lite`; a 400-line addition classifies `full`; a one-line
42
+ touch under `specs/` classifies `full` (force-full).
43
+ - Lite run: `proposal-consistency-check` logged `phase_skipped`
44
+ (reason `lite_track`); `J4a` / `J4b` logged `atom_skipped` (reason
45
+ `lite_track`); the `verify-consistency` gate passed with verdict
46
+ `lite_skipped`; `workflow_completed` recorded `lite_fast_track: true`.
47
+ - Seal + clean replay: red proof `passed: false`
48
+ (`failure_kind: assertion`); green proof `passed: true` with binding
49
+ hash and snapshot scope; I6b validated both (binding recomputed,
50
+ snapshot hashes checked, command re-run on the snapshot).
51
+ I6d, I6c, J1 and J6 all passed in both tracks.
52
+ - Full run (control): J4a / J4d / J4b all executed and passed; no
53
+ `lite_track` skip anywhere; `workflow_completed` recorded
54
+ `lite_fast_track: false`.
55
+
56
+ ## Reproduce
57
+
58
+ ```sh
59
+ node test/lite-track-acceptance.test.js # prints a [m4-slice2-wallclock] line
60
+ npm run verify # full suite; same line appears
61
+ ```
@@ -0,0 +1,96 @@
1
+ # M4 slice 4: auto-lite resume flips to full on workflow-written status.json
2
+
3
+ Date: 2026-11-10 (Asia/Singapore). Source of truth for the demo itself is
4
+ `test/lite-track-auto-acceptance.test.js` (re-run it to reproduce).
5
+ Parent issue: #97; slice issue: #103; PR: slice 4 (this change).
6
+
7
+ ## Status: preliminary wall clock — NOT the ≤1.3× conclusion
8
+
9
+ The wall-clock numbers below are local n=1 measurements of the acceptance
10
+ demo harness. The formal wall-clock verdict for issue #5 P1-2 (small tasks
11
+ ≤1.3× baseline) waits for the bench full-matrix numbers; P1-2 stays
12
+ unticked and no ≤1.3× claim is made here.
13
+
14
+ ## The bug (found by the auto acceptance demo on main HEAD 4668b6ab)
15
+
16
+ A real small fix through `xflow workflow run` with **no flag**: run 1
17
+ (pre-fix, clean tree) auto-decides lite, seals the red proof, stops at
18
+ tdd-green. The fix is applied, run 2 (resume) re-runs the classifier —
19
+ and flips to **full**:
20
+
21
+ | Run | `lite_track_decision` (from the workflow events) | `lite_fast_track` |
22
+ | --- | --- | --- |
23
+ | Run 1, pre-fix | `{source: auto, track: lite, reason: 'empty diff: nothing to ritualise', files: 0, total: 0}` | `true` |
24
+ | Run 2, resume (pre-fix code) | `{source: auto, track: full, reason: 'touches specs/ paths (specs/changes/chg-m4-auto-demo/status.json): spec/workflow drift needs the full track', files: 3, total: 281}` | `false` |
25
+
26
+ Root cause: run 1 rewrites the tracked
27
+ `specs/changes/<id>/status.json` itself (stage transitions +
28
+ `workflow_integrity` block, +268/−2 lines in the demo; the worktree diff
29
+ at resume time is 3 files, 277 insertions / 13 deletions). The classifier
30
+ measures the worktree diff against the merge-base, sees a `specs/` path,
31
+ and the force-full rule fires. The actual user change is still the same
32
+ 2-file, 20-line fix. The two halves of one change then run on different
33
+ tracks: the resume re-executes the spec ritual (J4a/J4b) that run 1
34
+ skipped.
35
+
36
+ Scope note: evalid's own repo gitignores `specs/changes/`, so dogfooding
37
+ does not hit this — but any repo that tracks change state does, and the
38
+ repo's own acceptance demo (the M4 bar) fails on main HEAD. The defect is
39
+ in the shipped classifier, not the demo, so the fix is in the classifier.
40
+
41
+ ## The fix
42
+
43
+ `classify_repo` in `xflow/atoms/lite_track.py` now excludes
44
+ workflow-generated artifacts under `specs/changes/<id>/` from the measured
45
+ stats before the verdict: `status.json`, `verify_proof.json`,
46
+ `spec_delta_review.json` / `.md`, and the `tdd/` / `cr/` trees.
47
+ Human-authored spec files (`proposal.md` / `plan.md` / `tasks.md`) still
48
+ force full. No CLI / executor / env / phanes call-surface change.
49
+
50
+ ## Numbers (post-fix demo runs, n=1 each, preliminary)
51
+
52
+ | Run | lite (first + resume) | full (first + resume) | lite/full |
53
+ | --- | --- | --- | --- |
54
+ | Auto acceptance test, isolated | 2,446 ms + 2,549 ms = 4,995 ms | 2,854 ms + 3,509 ms = 6,363 ms | 0.785 |
55
+ | Auto acceptance test, inside `npm run verify` | 4,135 ms + 4,104 ms = 8,239 ms | 4,126 ms + 2,998 ms = 7,124 ms | 1.157 |
56
+
57
+ Same demo, same machine, two runs minutes apart: 0.785 vs 1.157. The
58
+ slice-2 demo re-measured inside the same suite run gives 1.291 this time
59
+ (7,655 ms vs 5,928 ms), against its original 0.644 / 0.798. The spread
60
+ itself is the finding: n=1 demo timings are dominated by per-process
61
+ startup and machine load (the suite runs test files in parallel), not by
62
+ the spec ritual. Do not quote any of these ratios as the lite-track
63
+ speedup; the formal ≤1.3× verdict still waits for the bench full-matrix
64
+ numbers.
65
+
66
+ Machine for all runs above: Linux x86_64 VM, AMD EPYC 9D25, 4 vCPU;
67
+ node v24.22.1, python 3.12.3.
68
+
69
+ ## What the demo proved (functional, not timing)
70
+
71
+ - Post-fix resume decision: `{source: auto, track: lite, reason:
72
+ 'within thresholds (2 files, 20 lines <= 10 files, 300 lines)',
73
+ files: 2, added: 9, removed: 11, total: 20, force_full: []}` — the
74
+ resume stays lite on the same fix that run 1 lite'd.
75
+ - Lite run: `proposal-consistency-check` logged `phase_skipped` (reason
76
+ `lite_track`); `J4a` / `J4b` logged `atom_skipped` (reason
77
+ `lite_track`); the `verify-consistency` gate passed with verdict
78
+ `lite_skipped`; `workflow_completed` recorded `lite_fast_track: true`
79
+ with the auto decision in both `workflow_started` and
80
+ `workflow_completed`.
81
+ - Seal + clean replay: red proof `passed: false`; green proof
82
+ `passed: true` with binding hash `8f5f78b44be55c3d` and snapshot scope
83
+ (`lite_track.py`, `test/demo.test.mjs`); I6b validated both; I6d, I6c,
84
+ J1 and J6 all passed.
85
+ - Full control (400-line file staged, no flag): first run auto-full
86
+ (`400 lines > 300 max_lines`, 1 file), resume auto-full (420 lines,
87
+ 3 files); J4a / J4d / J4b all executed and passed; no `lite_track`
88
+ skip anywhere; `workflow_completed` recorded `lite_fast_track: false`.
89
+
90
+ ## Reproduce
91
+
92
+ ```sh
93
+ node --test test/lite-track-auto-acceptance.test.js # prints [m4-auto-*] lines
94
+ node --test test/lite-track.test.js # classifier unit tests
95
+ npm run verify # full suite
96
+ ```
@@ -1,6 +1,6 @@
1
1
  # evalid Launch Dossier
2
2
 
3
- Generated: 2026-11-09T05:51:48.645Z
3
+ Generated: 2026-11-09T20:25:08.664Z
4
4
  Mode: post_publish
5
5
  Verdict: conditionally ready: engine and adoption evidence are strong, registry publication remains required for a splash launch
6
6
  Launch ready: not yet
@@ -117,7 +117,7 @@ Evidence:
117
117
  - Adoption records: 1; status: pass
118
118
  - Goal audit: pass; goal: Keep evalid credible as an open-source agent delivery runtime: repo-owned workflow contracts, executable verification gates, durable project context, and honest benchmark evidence.
119
119
  - Package evidence: missing; source: registry_json
120
- - Package name/version: @surlymochan/evalid@0.5.18
120
+ - Package name/version: @surlymochan/evalid@0.5.20
121
121
 
122
122
  ## Passing Evidence
123
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surlymochan/evalid",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "description": "Claimed != verified: a runtime verification layer for coding agents. It seals each test run and replays it from a clean snapshot, with a zero-judge gate pipeline (spec clarification, code review, TDD proof) and the xflow CLI.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -40,6 +40,10 @@
40
40
  "label": { "type": "string" },
41
41
  "catalog_ref": { "type": "integer", "minimum": 1, "maximum": 27 },
42
42
  "required": { "type": "boolean" },
43
+ "lite_skip": {
44
+ "type": "boolean",
45
+ "description": "M4 lite fast track: skip this whole phase when the run has input.lite=true (spec ritual only; the verification spine never carries this flag)"
46
+ },
43
47
  "condition": {
44
48
  "type": "object",
45
49
  "description": "Skip phase unless condition met",
@@ -56,6 +60,10 @@
56
60
  "properties": {
57
61
  "id": { "type": "string" },
58
62
  "with": { "type": "object" },
63
+ "lite_skip": {
64
+ "type": "boolean",
65
+ "description": "M4 lite fast track: skip this atom when the run has input.lite=true (spec ritual atoms only; a deterministic gate on a lite-skipped atom passes with verdict lite_skipped)"
66
+ },
59
67
  "parallel_group": { "type": "string" },
60
68
  "parallel_weight": { "type": "integer", "minimum": 1, "maximum": 16 }
61
69
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * M4 lite fast track, slice 3 (evalid #101, parent #97): auto-select the
3
+ * track for `xflow workflow run`.
4
+ *
5
+ * With no explicit flag the run classifies the change first (slice 1's
6
+ * `xflow/atoms/lite_track.py`, working-tree diff against the merge-base,
7
+ * `--uncommitted`): a small diff walks the lite track, a big diff stays
8
+ * full. Explicit `--lite` / `--full` always wins; both flags together
9
+ * resolve to full (conservative). Any classifier failure falls back to
10
+ * full and records the failure -- a run must never silently go lite.
11
+ *
12
+ * The returned decision object is JSON-serialisable on purpose: the CLI
13
+ * attaches it to the workflow input as `lite_track_decision` and the
14
+ * executor records it in the `workflow_started` / `workflow_completed`
15
+ * events, next to slice 1's `lite_fast_track` field.
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+ import { dirname, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ export const DEFAULT_CLASSIFIER_PATH = resolve(__dirname, '..', '..', 'xflow', 'atoms', 'lite_track.py');
23
+ const DEFAULT_TIMEOUT_MS = 60_000;
24
+
25
+ /**
26
+ * Run the classifier against `projectRoot` and return the auto decision.
27
+ *
28
+ * `runClassifier` is injectable (defaults to `spawnSync`) so tests can
29
+ * stub the classifier invocation without touching git or python.
30
+ */
31
+ export function resolveLiteTrackAuto({
32
+ projectRoot,
33
+ classifierPath = DEFAULT_CLASSIFIER_PATH,
34
+ python = process.env.PYTHON || 'python3',
35
+ runClassifier = spawnSync,
36
+ timeoutMs = DEFAULT_TIMEOUT_MS,
37
+ } = {}) {
38
+ const fallback = (detail) => ({
39
+ source: 'auto-fallback-full',
40
+ track: 'full',
41
+ reason: `classifier failed (${detail}); falling back to full track`,
42
+ files: null,
43
+ added: null,
44
+ removed: null,
45
+ total: null,
46
+ base: null,
47
+ force_full: [],
48
+ });
49
+
50
+ let result;
51
+ try {
52
+ result = runClassifier(
53
+ python,
54
+ [classifierPath, '--repo', projectRoot, '--uncommitted', '--json'],
55
+ { encoding: 'utf8', timeout: timeoutMs },
56
+ );
57
+ } catch (err) {
58
+ return fallback(`spawn error: ${err?.message || String(err)}`);
59
+ }
60
+ if (!result || result.status !== 0) {
61
+ const detail = String(
62
+ result?.stderr?.trim() || result?.error?.message || `exit ${result?.status ?? 'unknown'}`,
63
+ ).slice(0, 200);
64
+ return fallback(detail);
65
+ }
66
+ let verdict;
67
+ try {
68
+ verdict = JSON.parse(result.stdout);
69
+ } catch {
70
+ return fallback('unparseable classifier output');
71
+ }
72
+ if (verdict?.track !== 'lite' && verdict?.track !== 'full') {
73
+ return fallback(`unexpected verdict: ${JSON.stringify(verdict).slice(0, 200)}`);
74
+ }
75
+ return {
76
+ source: 'auto',
77
+ track: verdict.track,
78
+ reason: verdict.reason || '',
79
+ files: verdict.files ?? null,
80
+ added: verdict.added ?? null,
81
+ removed: verdict.removed ?? null,
82
+ total: verdict.total ?? null,
83
+ base: verdict.base ?? null,
84
+ force_full: verdict.force_full || [],
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Resolve the effective track from explicit flags and (when neither flag
90
+ * is given) the auto decision. Returns `{ lite, decision }`.
91
+ */
92
+ export function decideLiteTrack({ lite, full, autoDecision }) {
93
+ if (lite === true && full === true) {
94
+ return {
95
+ lite: false,
96
+ decision: {
97
+ source: 'explicit-full',
98
+ track: 'full',
99
+ reason: 'both --lite and --full given; --full wins (conservative)',
100
+ },
101
+ };
102
+ }
103
+ if (lite === true) {
104
+ return {
105
+ lite: true,
106
+ decision: { source: 'explicit-lite', track: 'lite', reason: 'explicit --lite flag' },
107
+ };
108
+ }
109
+ if (full === true) {
110
+ return {
111
+ lite: false,
112
+ decision: { source: 'explicit-full', track: 'full', reason: 'explicit --full flag' },
113
+ };
114
+ }
115
+ if (!autoDecision) {
116
+ return {
117
+ lite: false,
118
+ decision: {
119
+ source: 'auto-fallback-full',
120
+ track: 'full',
121
+ reason: 'no classifier verdict available; falling back to full track',
122
+ },
123
+ };
124
+ }
125
+ return { lite: autoDecision.track === 'lite', decision: autoDecision };
126
+ }
@@ -11,6 +11,14 @@
11
11
  *
12
12
  * E6 ≡ E1+E2 invariant enforced: executor routes ALL deterministic gate checks
13
13
  * through E6.gate.local_precheck Python atom, never re-implementing logic here.
14
+ *
15
+ * M4 lite fast track (evalid #97): when input.lite is true, phases and atoms
16
+ * marked `lite_skip: true` in the workflow YAML are skipped (the spec ritual:
17
+ * J4a/J4b/J4d), while the verification spine (TDD run + proof seal + i6d
18
+ * gate + full test run + CR review) still runs. Skips are logged as
19
+ * `phase_skipped` / `atom_skipped` with reason `lite_track`, and a
20
+ * deterministic gate whose gate atom is lite-skipped passes with verdict
21
+ * `lite_skipped` instead of re-running the spec check.
14
22
  */
15
23
 
16
24
  import { existsSync, statSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
@@ -67,6 +75,17 @@ export class WorkflowExecutor {
67
75
 
68
76
  // ─── Main execution loop ─────────────────────────────────────────────────
69
77
 
78
+ // M4 lite fast track: `input.lite === true` engages it. Slice 1 set this
79
+ // only from the explicit `--lite` flag; slice 3 (evalid #101) lets the
80
+ // CLI resolve it first -- `decideLiteTrack` in lite-track-auto.js runs
81
+ // the classifier when no explicit flag is given and sets `input.lite`
82
+ // when the verdict is lite, so the executor itself never decides. The
83
+ // decision itself arrives as `input.lite_track_decision` and is recorded
84
+ // in the workflow_started / workflow_completed events.
85
+ isLiteFastTrack() {
86
+ return this.input?.lite === true;
87
+ }
88
+
70
89
  // ─── Phase cost accounting ─────────────────────────────────────────────
71
90
 
72
91
  recordPhaseCost(phase, status, startedAtMs) {
@@ -121,6 +140,11 @@ export class WorkflowExecutor {
121
140
  workflow_run_id: this.runtime.workflowRunId,
122
141
  workflow: this.workflow.name,
123
142
  track: this.workflow.track,
143
+ lite_fast_track: this.isLiteFastTrack(),
144
+ // M4 slice 3: how the track was chosen (auto / explicit-lite /
145
+ // explicit-full / auto-fallback-full); null when the run was started
146
+ // without the CLI decision step (e.g. direct executor use).
147
+ lite_track_decision: this.input?.lite_track_decision ?? null,
124
148
  workflow_integrity: this.workflowManifest,
125
149
  max_parallel_agents: this.maxParallelAgents,
126
150
  parallel_policy: this.concurrencyPolicy.mode,
@@ -189,6 +213,28 @@ export class WorkflowExecutor {
189
213
  }
190
214
  }
191
215
 
216
+ // M4 lite fast track: skip whole phases marked lite_skip (the spec
217
+ // ritual). Logged like a condition skip so proof still sees the phase
218
+ // as accounted for; the reason distinguishes the two.
219
+ if (phase.lite_skip && this.isLiteFastTrack()) {
220
+ console.log(` ↷ Skipping (lite fast track: phase "${phase.id}" is spec ritual)`);
221
+ await this.logExecutionEvent('phase_skipped', {
222
+ workflow_run_id: this.runtime.workflowRunId,
223
+ workflow: this.workflow.name,
224
+ track: this.workflow.track,
225
+ phase_id: phase.id,
226
+ phase_label: phase.label || null,
227
+ phase_index: i,
228
+ reason: 'lite_track',
229
+ change_id: this.changeId || null,
230
+ mission_id: this.runtime.missionId || null,
231
+ workflow_integrity_digest: this.workflowManifest.digest,
232
+ });
233
+ this.recordPhaseCost(phase, 'skipped', phaseStartedAtMs);
234
+ this.saveState({ ...state, last_completed_phase: phase.id });
235
+ continue;
236
+ }
237
+
192
238
  // Preconditions
193
239
  if (phase.preconditions?.length > 0) {
194
240
  const precondOk = this.checkPreconditions(phase.preconditions);
@@ -203,12 +249,33 @@ export class WorkflowExecutor {
203
249
  this.ensurePlanArtifact();
204
250
  }
205
251
 
206
- // Run atoms
252
+ // Run atoms (M4 lite: atoms marked lite_skip are filtered out first
253
+ // and logged individually, so mixed phases keep their spine).
207
254
  const atomsToRun = rerunRequest?.phase_id === phase.id && rerunRequest?.atom_id
208
255
  ? (phase.atoms || []).filter((atomRef) => atomRef.id === rerunRequest.atom_id)
209
256
  : (phase.atoms || []);
210
- if (atomsToRun.length > 0) {
211
- for (const batch of this.buildAtomBatches(atomsToRun)) {
257
+ const runnableAtoms = [];
258
+ for (const atomRef of atomsToRun) {
259
+ if (atomRef.lite_skip && this.isLiteFastTrack()) {
260
+ console.log(` ↷ Skipping atom (lite fast track): ${atomRef.id}`);
261
+ await this.logExecutionEvent('atom_skipped', {
262
+ workflow_run_id: this.runtime.workflowRunId,
263
+ workflow: this.workflow.name,
264
+ track: this.workflow.track,
265
+ phase_id: phase.id,
266
+ phase_label: phase.label || null,
267
+ atom_id: atomRef.id,
268
+ reason: 'lite_track',
269
+ change_id: this.changeId || null,
270
+ mission_id: this.runtime.missionId || null,
271
+ workflow_integrity_digest: this.workflowManifest.digest,
272
+ });
273
+ continue;
274
+ }
275
+ runnableAtoms.push(atomRef);
276
+ }
277
+ if (runnableAtoms.length > 0) {
278
+ for (const batch of this.buildAtomBatches(runnableAtoms)) {
212
279
  this.ensureNotAborted();
213
280
  const results = batch.parallel
214
281
  ? await this.runParallelBatch(batch.atoms, phase)
@@ -291,6 +358,8 @@ export class WorkflowExecutor {
291
358
  workflow_run_id: this.runtime.workflowRunId,
292
359
  workflow: this.workflow.name,
293
360
  track: this.workflow.track,
361
+ lite_fast_track: this.isLiteFastTrack(),
362
+ lite_track_decision: this.input?.lite_track_decision ?? null,
294
363
  workflow_integrity: this.workflowManifest,
295
364
  change_id: this.changeId || null,
296
365
  mission_id: this.runtime.missionId || null,
@@ -567,6 +636,12 @@ export class WorkflowExecutor {
567
636
 
568
637
  switch (gate.type) {
569
638
  case 'deterministic': {
639
+ // M4 lite: a gate whose atom is lite-skipped in this phase must not
640
+ // re-run the spec check it gates — the skip is the verdict.
641
+ if (gate.atom && this.isLiteFastTrack()
642
+ && (phase.atoms || []).some((atomRef) => atomRef.id === gate.atom && atomRef.lite_skip)) {
643
+ return { ok: true, verdict: 'lite_skipped' };
644
+ }
570
645
  // Route through E6 (Python) for ALL structural checks — invariant enforced
571
646
  if (gate.atom) {
572
647
  const result = await this.runAtom({ id: gate.atom, with: gate.with }, phase);
@@ -80,10 +80,14 @@ phases:
80
80
  type: artifact-verify
81
81
  on_fail: stop
82
82
 
83
+ # M4 lite fast track (evalid #97): this whole phase is spec ritual — a
84
+ # `--lite` run skips it (executor logs phase_skipped, reason lite_track)
85
+ # and keeps the verification spine (tdd seal + i6d + J1 + J6).
83
86
  - id: proposal-consistency-check
84
87
  label: "07 · Proposal consistency check"
85
88
  catalog_ref: 7
86
89
  required: true
90
+ lite_skip: true
87
91
  atoms:
88
92
  - id: J4a.spec_consistency.structural
89
93
  - id: J4d.spec_clarify.gate
@@ -205,8 +209,14 @@ phases:
205
209
  required: true
206
210
  atoms:
207
211
  - id: J1.tests.run
212
+ # M4 lite fast track (evalid #97): J4a/J4b are spec ritual — a `--lite`
213
+ # run skips them (atom_skipped, reason lite_track) and the phase's
214
+ # deterministic J4a gate passes with verdict lite_skipped. J1 (full
215
+ # test run) and J6 (code review) stay: they are verification, not ritual.
208
216
  - id: J4a.spec_consistency.structural
217
+ lite_skip: true
209
218
  - id: J4b.spec_delta.review
219
+ lite_skip: true
210
220
  - id: J6.cr.review
211
221
  - id: B3.status.transition
212
222
  with: { to_stage: "verify" }
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ M4 lite fast-track: diff-size classifier (slice 1).
4
+
5
+ Decides whether a change is small enough to skip the spec ritual and run
6
+ seal (i6b) + clean replay only. The verdict is measured against the change's
7
+ merge-base, reusing M3 slice 2's importable parts from
8
+ ``i6d_assertion_execution``: ``resolve_diff_base`` for the branch point,
9
+ ``changed_lines`` / ``changed_lines_from_diff`` for the changed-line map.
10
+ Line/file counts come from ``git diff --numstat`` (added + removed, which
11
+ also catches deletion-only and binary files that the changed-line map
12
+ excludes by design).
13
+
14
+ Thresholds (initial values, see evalid #97 for the calibration):
15
+
16
+ * ``max_files = 10``, ``max_lines = 300`` (added + removed). This repo's own
17
+ merged PRs calibrate it: routine slices / fixes / releases land at 4-28
18
+ lines; the only recent feature slice above the line is P1-3 (#34, 330
19
+ lines), which correctly stays full-track.
20
+ * Force-full overrides: any touched path under ``specs/`` or ``workflows/``
21
+ flips the verdict to ``full`` -- the spec ritual exists precisely to catch
22
+ drift there, so a small diff touching spec/workflow definitions must not
23
+ skip it. An empty diff classifies ``lite``.
24
+ * Workflow-generated artifacts under ``specs/changes/<id>/`` do not count
25
+ (slice 4, evalid #103): the workflow itself rewrites ``status.json``
26
+ (+268 lines in the M4 demo) and writes proof artifacts (``tdd/``,
27
+ ``cr/``, ``verify_proof.json``, ``spec_delta_review.*``) during a run,
28
+ so a resume re-measuring the worktree would otherwise flip an auto-lite
29
+ change to full on its own state. Human-authored spec files
30
+ (``proposal.md`` / ``plan.md`` / ``tasks.md``) still force full.
31
+
32
+ CLI (always prints one JSON object; exit 0, exit 2 on git/usage errors)::
33
+
34
+ python3 lite_track.py --repo . [--diff-base main] [--json]
35
+ python3 lite_track.py --repo . --diff-file patch.diff [--json]
36
+ python3 lite_track.py --repo . --max-files 10 --max-lines 300
37
+
38
+ The classifier is the *decision procedure*; the enforcement switch is the
39
+ explicit ``--lite`` flag on ``xflow workflow run``. Run the classifier
40
+ first and add ``--lite`` only when it says ``lite`` -- slice 1 does no
41
+ silent auto-switching.
42
+ """
43
+
44
+ import argparse
45
+ import json
46
+ import os
47
+ import subprocess
48
+ import sys
49
+
50
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
51
+
52
+ from i6d_assertion_execution import ( # noqa: E402
53
+ changed_lines,
54
+ changed_lines_from_diff,
55
+ resolve_diff_base,
56
+ )
57
+
58
+ DEFAULT_MAX_FILES = 10
59
+ DEFAULT_MAX_LINES = 300
60
+ FORCE_FULL_PREFIXES = ("specs/", "workflows/")
61
+ VERSION = 1
62
+
63
+ # Files the workflow itself writes under specs/changes/<id>/ -- machine
64
+ # state and proof artifacts, not spec prose (slice 4, evalid #103).
65
+ GENERATED_CHANGE_FILES = (
66
+ "status.json",
67
+ "verify_proof.json",
68
+ "spec_delta_review.json",
69
+ "spec_delta_review.md",
70
+ )
71
+ GENERATED_CHANGE_DIRS = ("tdd/", "cr/")
72
+
73
+
74
+ def _is_generated_change_artifact(path):
75
+ """True when ``path`` is workflow-generated state under a change dir.
76
+
77
+ ``specs/changes/<id>/status.json`` is rewritten by the workflow on
78
+ every run (stage transitions, integrity block); ``tdd/`` / ``cr/``
79
+ hold sealed proofs and review output, ``verify_proof.json`` and
80
+ ``spec_delta_review.*`` likewise. None of these is a human spec edit,
81
+ so none of them may force the full track or inflate the measured
82
+ diff. Anything else under ``specs/changes/<id>/`` -- in particular
83
+ ``proposal.md`` / ``plan.md`` / ``tasks.md`` -- still counts.
84
+ """
85
+ parts = path.split("/")
86
+ if len(parts) < 4 or parts[0] != "specs" or parts[1] != "changes":
87
+ return False
88
+ rest = parts[3:]
89
+ if len(rest) == 1 and rest[0] in GENERATED_CHANGE_FILES:
90
+ return True
91
+ return bool(rest) and (rest[0] + "/") in GENERATED_CHANGE_DIRS
92
+
93
+
94
+ def _git(repo, *argv):
95
+ proc = subprocess.run(
96
+ ["git", *argv], cwd=repo, capture_output=True, text=True, timeout=30,
97
+ )
98
+ if proc.returncode != 0:
99
+ raise RuntimeError(
100
+ f"git {' '.join(argv)} failed ({proc.returncode}): "
101
+ f"{proc.stderr.strip()}"
102
+ )
103
+ return proc.stdout
104
+
105
+
106
+ def _numstat(repo, base_sha):
107
+ """``{relpath: (added, removed)}`` from ``git diff --numstat``.
108
+
109
+ Binary files report ``-`` counts; they count as a touched file with
110
+ zero counted lines.
111
+ """
112
+ raw = _git(repo, "diff", "--numstat", base_sha, "HEAD", "--")
113
+ stats = {}
114
+ for line in raw.splitlines():
115
+ if not line.strip():
116
+ continue
117
+ added_s, removed_s, path = line.split("\t", 2)
118
+ added = 0 if added_s == "-" else int(added_s)
119
+ removed = 0 if removed_s == "-" else int(removed_s)
120
+ stats[path] = (added, removed)
121
+ return stats
122
+
123
+
124
+ def _diff_text_files_and_counts(diff_text):
125
+ """``(files, added, removed)`` parsed from a unified diff text.
126
+
127
+ Counts ``+``/``-`` lines (excluding the ``+++``/``---`` headers);
128
+ files come from the ``+++`` headers so deletion-only files are
129
+ included, unlike the changed-line map.
130
+ """
131
+ files = []
132
+ added = 0
133
+ removed = 0
134
+ for line in diff_text.splitlines():
135
+ if line.startswith("+++ "):
136
+ target = line[4:].strip()
137
+ if target in ("/dev/null", ""):
138
+ continue
139
+ if target.startswith("b/"):
140
+ target = target[2:]
141
+ if len(target) >= 2 and target.startswith('"') and target.endswith('"'):
142
+ target = json.loads(target)
143
+ files.append(target)
144
+ elif line.startswith("+") and not line.startswith("+++"):
145
+ added += 1
146
+ elif line.startswith("-") and not line.startswith("---"):
147
+ removed += 1
148
+ return files, added, removed
149
+
150
+
151
+ def _verdict(files, added, removed, max_files, max_lines):
152
+ """Shared verdict logic. ``files`` are repo-relative paths."""
153
+ total = added + removed
154
+ force_full = sorted({path for path in files if path.startswith(FORCE_FULL_PREFIXES)})
155
+ if force_full:
156
+ which = "specs/" if any(p.startswith("specs/") for p in force_full) else "workflows/"
157
+ return "full", (
158
+ f"touches {which} paths ({', '.join(force_full)}): "
159
+ "spec/workflow drift needs the full track"
160
+ ), force_full
161
+ if len(files) > max_files:
162
+ return "full", (
163
+ f"exceeds file budget: {len(files)} files > {max_files} max_files"
164
+ ), []
165
+ if total > max_lines:
166
+ return "full", (
167
+ f"exceeds line budget: {total} lines > {max_lines} max_lines"
168
+ ), []
169
+ if not files:
170
+ return "lite", "empty diff: nothing to ritualise", []
171
+ return "lite", (
172
+ f"within thresholds ({len(files)} files, {total} lines "
173
+ f"<= {max_files} files, {max_lines} lines)"
174
+ ), []
175
+
176
+
177
+ def _numstat_worktree(repo, base_sha):
178
+ """``{relpath: (added, removed)}`` from ``git diff --numstat`` of the
179
+ working tree (staged + unstaged) against ``base_sha``.
180
+
181
+ Binary files report ``-`` counts; they count as a touched file with
182
+ zero counted lines.
183
+ """
184
+ raw = _git(repo, "diff", "--numstat", base_sha, "--")
185
+ stats = {}
186
+ for line in raw.splitlines():
187
+ if not line.strip():
188
+ continue
189
+ added_s, removed_s, path = line.split("\t", 2)
190
+ stats[path] = (
191
+ 0 if added_s == "-" else int(added_s),
192
+ 0 if removed_s == "-" else int(removed_s),
193
+ )
194
+ return stats
195
+
196
+
197
+ def classify_repo(repo, base="main", max_files=DEFAULT_MAX_FILES,
198
+ max_lines=DEFAULT_MAX_LINES, uncommitted=False):
199
+ """Classify the committed (or, with ``uncommitted``, working-tree) diff."""
200
+ base_sha = resolve_diff_base(repo, base)
201
+ _, changed = changed_lines(repo, base, uncommitted=uncommitted)
202
+ if uncommitted:
203
+ # changed_lines(..., uncommitted=True) unions the working tree, so
204
+ # the numstat side must measure that same union: the worktree diff
205
+ # against the base. Merging the committed and worktree numstats
206
+ # with max() overcounts whenever the worktree partially reverts
207
+ # committed lines (a branch that added 10 lines, cut back by 5 in
208
+ # the worktree, read as 15 changed lines instead of the true 5).
209
+ stats = _numstat_worktree(repo, base_sha)
210
+ else:
211
+ stats = _numstat(repo, base_sha)
212
+ # Workflow-generated artifacts under specs/changes/<id>/ are the
213
+ # workflow's own state, not the change under review (slice 4): drop
214
+ # them before the verdict so a resume cannot flip to full on its own
215
+ # status.json rewrite, and so their lines cannot inflate the budget.
216
+ stats = {
217
+ path: counts
218
+ for path, counts in stats.items()
219
+ if not _is_generated_change_artifact(path)
220
+ }
221
+ files = sorted(stats)
222
+ added = sum(a for a, _ in stats.values())
223
+ removed = sum(r for _, r in stats.values())
224
+ track, reason, force_full = _verdict(files, added, removed, max_files, max_lines)
225
+ return {
226
+ "version": VERSION,
227
+ "track": track,
228
+ "reason": reason,
229
+ "files": len(files),
230
+ "added": added,
231
+ "removed": removed,
232
+ "total": added + removed,
233
+ "max_files": max_files,
234
+ "max_lines": max_lines,
235
+ "base": base_sha,
236
+ "force_full": force_full,
237
+ "changed_new_lines": sum(len(lines) for lines in changed.values()),
238
+ "source": "repo",
239
+ }
240
+
241
+
242
+ def classify_diff_text(diff_text, repo, max_files=DEFAULT_MAX_FILES,
243
+ max_lines=DEFAULT_MAX_LINES):
244
+ """Classify a unified diff *text* (e.g. ``gh pr diff`` output)."""
245
+ changed = changed_lines_from_diff(diff_text, repo)
246
+ files, added, removed = _diff_text_files_and_counts(diff_text)
247
+ track, reason, force_full = _verdict(files, added, removed, max_files, max_lines)
248
+ return {
249
+ "version": VERSION,
250
+ "track": track,
251
+ "reason": reason,
252
+ "files": len(files),
253
+ "added": added,
254
+ "removed": removed,
255
+ "total": added + removed,
256
+ "max_files": max_files,
257
+ "max_lines": max_lines,
258
+ "base": None,
259
+ "force_full": force_full,
260
+ "changed_new_lines": sum(len(lines) for lines in changed.values()),
261
+ "source": "diff-text",
262
+ }
263
+
264
+
265
+ def main(argv=None):
266
+ parser = argparse.ArgumentParser(description="M4 lite fast-track diff-size classifier.")
267
+ parser.add_argument("--repo", default=".", help="repo to classify (default: .)")
268
+ parser.add_argument("--diff-base", default="main", help="base branch (default: main)")
269
+ parser.add_argument("--diff-file", default=None, help="classify a unified diff file instead of the repo diff")
270
+ parser.add_argument("--max-files", type=int, default=DEFAULT_MAX_FILES)
271
+ parser.add_argument("--max-lines", type=int, default=DEFAULT_MAX_LINES)
272
+ parser.add_argument("--uncommitted", action="store_true",
273
+ help="include uncommitted working-tree edits (repo mode)")
274
+ parser.add_argument("--json", action="store_true", help="print the verdict as JSON (default output)")
275
+ args = parser.parse_args(argv)
276
+
277
+ repo = os.path.realpath(args.repo)
278
+ try:
279
+ if args.diff_file:
280
+ with open(args.diff_file, encoding="utf-8") as handle:
281
+ diff_text = handle.read()
282
+ result = classify_diff_text(diff_text, repo, args.max_files, args.max_lines)
283
+ else:
284
+ result = classify_repo(repo, args.diff_base, args.max_files, args.max_lines, args.uncommitted)
285
+ except (RuntimeError, OSError) as exc:
286
+ print(f"lite_track: {exc}", file=sys.stderr)
287
+ return 2
288
+ print(json.dumps(result, indent=2))
289
+ return 0
290
+
291
+
292
+ if __name__ == "__main__":
293
+ raise SystemExit(main())