@renzrollon/interlock 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +12 -1
  2. package/CHANGELOG.md +93 -0
  3. package/README.md +5 -6
  4. package/bin/interlock +1655 -29
  5. package/hooks/mod.mjs +20 -23
  6. package/hooks/notify-option.mjs +69 -0
  7. package/hooks/preflight.mjs +6 -42
  8. package/lib/conformance.mjs +75 -0
  9. package/lib/coverage.mjs +387 -0
  10. package/lib/doctor.mjs +17 -13
  11. package/lib/drift.mjs +36 -12
  12. package/lib/findings.mjs +40 -3
  13. package/lib/graph/extract/openspec.mjs +6 -15
  14. package/lib/limits.mjs +78 -1
  15. package/lib/merge-confidence.mjs +1302 -0
  16. package/lib/merge-evidence.mjs +527 -0
  17. package/lib/notify.mjs +10 -20
  18. package/lib/outcomes.mjs +26 -5
  19. package/lib/pipeline.mjs +380 -0
  20. package/lib/prompts/dimensions.mjs +24 -2
  21. package/lib/prompts/domain-context.mjs +271 -0
  22. package/lib/prompts/handoff.mjs +32 -1
  23. package/lib/prompts/index.mjs +57 -0
  24. package/lib/prompts/remediate.mjs +29 -5
  25. package/lib/prompts/review.mjs +21 -7
  26. package/lib/prompts/schemas.mjs +2 -0
  27. package/lib/ready.mjs +13 -1
  28. package/lib/receipt.mjs +72 -2
  29. package/lib/review-core.mjs +224 -21
  30. package/lib/risk.mjs +8 -0
  31. package/lib/run-log.mjs +44 -0
  32. package/lib/run.mjs +859 -27
  33. package/lib/spec-audit.mjs +232 -0
  34. package/lib/spec-coverage.mjs +105 -0
  35. package/lib/spec-text.mjs +257 -0
  36. package/lib/verify.mjs +17 -4
  37. package/package.json +1 -1
  38. package/shared/SESSION-SHAPES.md +1 -1
  39. package/shared/TEST-PROFILE.md +39 -2
  40. package/skills/mr/SKILL.md +161 -11
  41. package/skills/review-code/REVIEW.template.md +24 -6
  42. package/skills/review-code/SKILL.md +16 -6
  43. package/skills/review-code/dimensions/domain.md +21 -0
package/bin/interlock CHANGED
@@ -26,9 +26,10 @@ import {
26
26
  realpathSync,
27
27
  renameSync,
28
28
  rmSync,
29
+ statSync,
29
30
  writeFileSync
30
31
  } from 'node:fs'
31
- import { dirname, isAbsolute, join, relative, sep } from 'node:path'
32
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
32
33
  import { constants as osConstants, cpus, tmpdir } from 'node:os'
33
34
  import { performance } from 'node:perf_hooks'
34
35
  import {
@@ -46,11 +47,13 @@ import {
46
47
  } from '../lib/waves.mjs'
47
48
  import {
48
49
  CLASSIFIED_PATH,
50
+ GRAPH_FILE_PATH,
49
51
  LAST_STEP_PATH,
50
52
  MANIFEST_PATH,
51
53
  STATE_PATH,
52
54
  VERIFY_TIMINGS_PATH,
53
55
  checkSequence,
56
+ locateInput,
54
57
  readManifest,
55
58
  relayStep,
56
59
  runClassified,
@@ -80,8 +83,23 @@ import {
80
83
  DEFAULT_NTFY_URL
81
84
  } from '../lib/notify.mjs'
82
85
  import { classifySurface } from '../lib/surface.mjs'
83
- import { evaluateGate, formatGate, TOLERANCE_BAND } from '../lib/findings.mjs'
84
- import { inspectChange, resolveChange, listChanges, formatInspection, tickTasks, planCoverage } from '../lib/artifacts.mjs'
86
+ import {
87
+ applyToleranceBand,
88
+ carriesFindingsList,
89
+ evaluateGate,
90
+ formatGate,
91
+ NO_FINDINGS_LIST,
92
+ TOLERANCE_BAND
93
+ } from '../lib/findings.mjs'
94
+ import {
95
+ inspectChange,
96
+ isCoverableSource,
97
+ resolveChange,
98
+ listChanges,
99
+ formatInspection,
100
+ tickTasks,
101
+ planCoverage
102
+ } from '../lib/artifacts.mjs'
85
103
  import { record, recordCleanFlow, getLevel, getState } from '../lib/autonomy.mjs'
86
104
  import {
87
105
  LIMITS,
@@ -92,10 +110,14 @@ import {
92
110
  EFFORT,
93
111
  LANE_CAPS,
94
112
  SOLO,
113
+ MERGE_BANDS,
95
114
  formatLimits
96
115
  } from '../lib/limits.mjs'
97
116
  import { planRemediation, formatRemediationPlan } from '../lib/remediate.mjs'
98
117
  import {
118
+ REVIEW_POLICY_FILE,
119
+ composePolicy,
120
+ parseReviewPolicy,
99
121
  resolveReview,
100
122
  formatReview,
101
123
  readReviewPolicy,
@@ -114,10 +136,36 @@ import {
114
136
  VERIFY_CONTEXTS
115
137
  } from '../lib/verify.mjs'
116
138
  import { spillBytes } from '../lib/spill.mjs'
117
- import { classifyRisk, formatRisk } from '../lib/risk.mjs'
139
+ import { canonicalizePath, classifyRisk, formatRisk } from '../lib/risk.mjs'
118
140
  import { mergeDecision, foldMutationPaths } from '../lib/merge-lanes.mjs'
119
- import { analyzeDrift, formatDrift } from '../lib/drift.mjs'
141
+ import { analyzeDrift, detectStaleSpecs, formatDrift, livingSpecEdges, readGraphAt } from '../lib/drift.mjs'
142
+ import {
143
+ BASELINE_SCHEMA,
144
+ buildCoverageInput,
145
+ changedLineCoverage,
146
+ coverageTotals,
147
+ parseAddedLines,
148
+ parseLcov,
149
+ unreportedChangedSource,
150
+ validateBaseline
151
+ } from '../lib/coverage.mjs'
152
+ import { capabilityOf, parseSpec } from '../lib/spec-text.mjs'
153
+ import { assembleDomainContext, selectDomainCapabilities } from '../lib/prompts/domain-context.mjs'
154
+ import { auditSpecs, formatSpecAudit } from '../lib/spec-audit.mjs'
120
155
  import { buildChecklist, formatChecklist } from '../lib/conformance.mjs'
156
+ import {
157
+ assessMergeConfidence,
158
+ formatMergeConfidence,
159
+ formatMergeConfidenceMarkdown,
160
+ mergeLabelNames
161
+ } from '../lib/merge-confidence.mjs'
162
+ import {
163
+ pendingFromGitlabMr,
164
+ pipelineFromGithub,
165
+ pipelineFromGitlab,
166
+ validatePipelineInput
167
+ } from '../lib/pipeline.mjs'
168
+ import { gatherLivingList, gatherMergeEvidence, readRunEvidence } from '../lib/merge-evidence.mjs'
121
169
  import { readLedger, summarize as summarizeLedger, formatLedger } from '../lib/ledger.mjs'
122
170
  import {
123
171
  computeFingerprint,
@@ -172,6 +220,36 @@ Usage:
172
220
  interlock drift [--changed <files>] Report unarchived changes, broken spec
173
221
  references, aging specs, and orphan code
174
222
  interlock conformance [change] List the spec scenarios a change must satisfy
223
+ [--changed <files>] and, with --living, the requirements
224
+ [--living] [--base <ref>] already in force for the capabilities
225
+ it touches (see "conformance:" below)
226
+ interlock domain-context [change] The living requirements the domain
227
+ --changed <files> review dimension judges a diff
228
+ [--base <ref>] against: which capabilities, and
229
+ their bounded text (see
230
+ "domain-context:" below)
231
+ interlock spec-audit Count what the living specs state
232
+ and how they link to source. Gates
233
+ nothing and always exits 0
234
+ interlock merge-confidence [change] Decide whether a change can merge:
235
+ --changed <files...> MERGE | HUMAN | BLOCK, a score bounded
236
+ [--review <f>] by the worst band, and every signal
237
+ [--verify <f>] with its evidence. Offline: reads the
238
+ [--handoff <f>] repository and the files named, never
239
+ [--pipeline <f>] a hosting platform (see
240
+ [--coverage <f>] "merge-confidence:" below)
241
+ [--run <f>]
242
+ [--base <ref>] [--require-merge]
243
+ [--markdown]
244
+ interlock pipeline --github <file|-> Turn a platform's own check output
245
+ [--required <name>]... into the pipeline input "merge-
246
+ [--exclude <name>]... confidence --pipeline" reads. Offline:
247
+ [--out <file>] reads the document it is handed, never
248
+ interlock pipeline --gitlab-mr <file|-> gh, glab or the network (see
249
+ --out <file> "pipeline:" below)
250
+ interlock pipeline --gitlab <file|->
251
+ --mr <file>
252
+ [--exclude <name>]... [--out <file>]
175
253
  interlock report [--since <iso>] Indicators over the three recorded
176
254
  [--change <name>] corpora — outcome records, run
177
255
  trajectories, review metrics. Every
@@ -278,12 +356,14 @@ Usage:
278
356
  failed close
279
357
  interlock remediate --findings <file> Plan one remediation round, or the verdict
280
358
  interlock review --findings <f> --verdicts <f>
281
- Resolve a review against skeptic verdicts.
359
+ [--base <ref>] Resolve a review against skeptic verdicts.
282
360
  Also reads repo-root REVIEW.md and drops
283
- findings on its do-not-report paths
361
+ findings on its do-not-report paths;
362
+ --base reads those paths from the ref
284
363
  interlock review-policy [--json] Print the parsed repo-root REVIEW.md:
285
- owner + prose (advice for the reviewer)
286
- and any parse problems. Absent → empty
364
+ [--base <ref>] owner + prose (advice for the reviewer),
365
+ both path lists and any parse problems.
366
+ Absent → empty
287
367
  interlock risk [change] Classify blast radius for the continuity gate
288
368
  interlock ledger [change] Summarize the decision ledger
289
369
  interlock ready [change] --findings <f> Decide whether continuity may skip the human
@@ -307,6 +387,12 @@ Usage:
307
387
  Spill oversized stdout/stderr to disk
308
388
  interlock verify exec --kind <k> --command <cmd>
309
389
  Run one planned check and time it
390
+ interlock verify baseline [--report <lcov>] [--baseline <file>]
391
+ Record a coverage report's project total
392
+ for this commit (see "verify baseline:")
393
+ interlock verify coverage --base <ref> [--report <lcov>] [--baseline <file>]
394
+ [--compute-base] Print the coverage input the merge
395
+ verdict reads: figures, never a band
310
396
  interlock verify cluster --failures <f> Group failures by root-cause signature
311
397
  interlock verify repair --state <file> Decide repair / halt / accept next
312
398
  interlock plan fingerprint [change] Fingerprint a change's planning inputs
@@ -351,6 +437,23 @@ Exit codes:
351
437
  ledger the ledger is missing or unparseable, or
352
438
  needs_human or invalid rows remain
353
439
  ready the change may not skip the human checkpoint
440
+ merge-confidence the verdict is BLOCK, or with --require-merge
441
+ anything but MERGE; an unreadable input, a
442
+ --pipeline file that is not well-formed, or
443
+ --markdown with --json also exits 1, with no
444
+ verdict on stdout
445
+ pipeline the document is not JSON or not the one the
446
+ platform flag expects, --out or --mr is missing
447
+ where required, the pipeline is not the merge
448
+ request's head pipeline, or a branch pipeline ran
449
+ on another commit; nothing is printed on stdout and
450
+ an earlier file at --out is removed
451
+ verify baseline the report cannot be read or has no project total;
452
+ the previous baseline is left in place
453
+ verify coverage --base is missing or names no commit, the report is
454
+ missing, unreadable or not lcov, the baseline file
455
+ fails its schema, or a --compute-base measurement
456
+ failed; nothing is printed on stdout
354
457
  merge-lanes a lane worktree collided with another on a real
355
458
  path, or a lane's worktree could not be located —
356
459
  a clean fold is the only zero exit
@@ -381,7 +484,9 @@ Exit codes:
381
484
  it, so an exit code would be a verdict it is forbidden to issue. Low agreement
382
485
  is stated with its denominator and the command still succeeds; evals promote is
383
486
  the only reader that turns that number into a decision.
384
- drift, conformance and report never exit non-zero either. For report that is
487
+ domain-context exits 0 on a completed report — a skip is stated, not failed —
488
+ and 1 only on a --base that names no commit.
489
+ drift, conformance, spec-audit and report never exit non-zero either. For report that is
385
490
  a policy, not a convenience: §4.15(a) forbids gating on the recorded corpora,
386
491
  and a non-zero status is exactly the comparison it forbids. Nothing in the
387
492
  loop reads the report.
@@ -462,8 +567,191 @@ gate:
462
567
  limits:
463
568
  Prints the caps the loop obeys and the workflow-runtime ceilings above them.
464
569
  Cite these rather than restating a number in prose. With --json the per-tier
465
- lane-cap table and the solo envelope are their own top-level groups (laneCaps,
466
- solo), never entries of the iteration-count limits.
570
+ lane-cap table, the solo envelope and the merge bands are their own top-level
571
+ groups (laneCaps, solo, mergeBands), never entries of the iteration-count
572
+ limits.
573
+
574
+ conformance:
575
+ [change] The change whose delta-spec scenarios are listed (or
576
+ --change <name>); with none named, the single active
577
+ change. Each scenario is a question, C1..Cn, with the
578
+ artifact and line it is written at.
579
+ --changed <files> Repo-relative changed paths. Listed as the source files
580
+ to check against; with --living they also select
581
+ capabilities through the graph.
582
+ --living Also list, after the scenarios, the living requirements
583
+ in force for the capabilities the change touches:
584
+ L1..Ln, each with its capability, the living spec's
585
+ path and the line of its heading, never its body. The
586
+ capabilities, the text and the size bound are the ones
587
+ "domain-context" gives the domain reviewer, so the two
588
+ cannot disagree. A requirement the change's own delta
589
+ specs modify, remove or rename is not listed; one past
590
+ the bound is named as left out. --json carries the
591
+ block as "living": { requirements, requirementsRequired,
592
+ omitted, source, skipped, partial }.
593
+ --base <ref> With --living: read the living text from this ref
594
+ instead of the working tree. The output names which it
595
+ read. A ref that names no commit is the reason the list
596
+ was not built; the scenarios are still listed.
597
+ Questions, never verdicts: nothing here decides whether a scenario or a
598
+ requirement is satisfied. Without --living the output is what it was before the
599
+ option existed. Always exits 0.
600
+
601
+ pipeline:
602
+ --github <file|-> The document "gh pr view <n> --json
603
+ headRefOid,statusCheckRollup" prints: the head commit
604
+ and its checks in one response. A document with no
605
+ statusCheckRollup is refused; an empty one is "no
606
+ checks". A check run that is not completed reads
607
+ pending; SUCCESS reads pass; NEUTRAL and SKIPPED read
608
+ skipped; FAILURE, TIMED_OUT, ACTION_REQUIRED and
609
+ STARTUP_FAILURE read fail; CANCELLED, STALE and anything
610
+ else read pending. A commit status reads pass for
611
+ SUCCESS, fail for FAILURE and ERROR, pending otherwise.
612
+ A re-run is read from the run that started last. With a
613
+ start time among them that cannot be read there is no
614
+ last run: the check reads the least favourable state
615
+ among them (fail, pending, skipped, pass, in that
616
+ order) and stderr says so.
617
+ --required <name> With --github: mark the check of that exact name as
618
+ required. Repeatable, and every name is kept. A name
619
+ that matches no reported check becomes a pending,
620
+ required entry. Absent, every check is not required and
621
+ stderr says the required set was not given.
622
+ --gitlab-mr <file|-> The document "glab mr view <iid> --output json" prints.
623
+ Needs --out. With no head pipeline it writes an input
624
+ with the merge request's head commit and no checks;
625
+ with one it writes a pending record { sha, headPipeline }
626
+ for the next step, which "merge-confidence --pipeline"
627
+ refuses. A document with no sha or no head_pipeline key
628
+ is refused.
629
+ --gitlab <file|-> The document "glab ci get --merge-request <iid> --output
630
+ json" prints. Needs --mr. A job reads pass for success,
631
+ fail for failed, skipped for skipped and for a manual
632
+ job allowed to fail, pending otherwise; it is required
633
+ when its allow_failure is false. The input's commit is
634
+ the record's, never typed: a pipeline that is not the
635
+ record's, or a branch pipeline for another commit, is
636
+ refused.
637
+ --mr <file> With --gitlab: the pending record --gitlab-mr wrote. May
638
+ be the same path as --out.
639
+ --exclude <name> With --github or --gitlab: leave the check of that exact
640
+ name out and record it under "excluded". Repeatable. No
641
+ character is special: a name that matches nothing is
642
+ said on stderr and not recorded. For the check whose own
643
+ result waits on the verdict.
644
+ --out <file> Write the input (or the pending record) here, creating
645
+ the directories above it. When no input is produced, an
646
+ earlier file at this path is removed, and stderr says so.
647
+ --json Print the input (or the pending record) instead of the
648
+ one-line summary.
649
+ Prints "pipeline <sha>: <n> check(s) (… pass, … fail, … pending, … skipped);
650
+ required: …; excluded: …", or "pending <sha>: head pipeline <id>". The input
651
+ carries names and states only. The GitLab reader is written from GitLab's and
652
+ glab's documentation; see docs/15-merge-confidence.md.
653
+
654
+ merge-confidence:
655
+ [change] The change under assessment (or --change <name>); with
656
+ none named, the single active change. Its artifacts,
657
+ decision ledger, delta-spec scenario count and
658
+ capabilities are read from the repository. With no
659
+ change, that is said on stderr and those signals read
660
+ amber rather than clean.
661
+ --changed <files...> Repo-relative changed paths ("-" for stdin). The risk
662
+ class, the conformance checklist, the living list and
663
+ drift read them.
664
+ --review <file> The adjudicated review: "interlock review --json", or
665
+ the review.json a ship run writes.
666
+ --verify <file> The final verification verdict: "interlock verify
667
+ judge --context final --json".
668
+ --handoff <file> The handoff's scenariosChecked and scenariosUnconfirmed,
669
+ and its requirementsChecked and requirementsUnconfirmed
670
+ for the living list. A verdictsRequired or a
671
+ requirementsRequired in the file is ignored: each
672
+ denominator is the command's own count.
673
+ --pipeline <file> { sha, checks: [{ name, state, required }], excluded }
674
+ for the head commit, state pass | fail | pending |
675
+ skipped. Produced by "interlock pipeline" from the
676
+ platform's own output. A file that is not well-formed
677
+ (no checks list, an entry in another state, a pending
678
+ GitLab record) exits 1 naming the fault, with nothing
679
+ on stdout: an absent list is never read as no checks.
680
+ --coverage <file> { configured, headTotalPct, baseTotalPct, baseSha,
681
+ baseIsAncestor, diffPct, changedLines, unreportedFiles,
682
+ unreportedAddedLines }. Produced by "interlock verify
683
+ coverage".
684
+ --run <file> The evidence file a ship run's close writes
685
+ (.claude/ship/merge-confidence.json). Supplies what only
686
+ the run observed: the adjudicated review, the final
687
+ verification verdict, the handoff counts, the coverage
688
+ figures and the run record. Used only when it carries
689
+ the published schema, names this change, says the run
690
+ committed and records HEAD as that commit; and, with
691
+ --base, only when the base it records is the merge base
692
+ of --base and HEAD, because a run's evidence covers the
693
+ diff that run reviewed and no other. Otherwise nothing
694
+ is taken from it and stderr says which of those failed.
695
+ Pass it with --base, as in
696
+ --base origin/main --run .claude/ship/merge-confidence.json
697
+ No base is taken from the file: without --base the
698
+ signals that need one stay unobserved, so the verdict
699
+ cannot be MERGE. An explicit --review, --verify,
700
+ --handoff or --coverage replaces its entry. The verdict
701
+ recorded in the file is never read: the command always
702
+ reassesses.
703
+ --base <ref> Diff against this ref (git diff --numstat
704
+ <ref>...HEAD) for the diff shape. Every path git
705
+ reports is assessed too, whether or not --changed
706
+ names it; the ones it did not are listed on stderr.
707
+ Absent, or a ref that is HEAD or ahead of it (an
708
+ empty diff by construction), the diff shape is
709
+ unobserved. The same ref is where REVIEW.md's
710
+ always-human-review list and the living requirements
711
+ are read from, and what spec.coverage tells an added
712
+ file from an edited one against. A ref that names no
713
+ commit exits 1 with nothing on stdout.
714
+ --require-merge Exit 1 unless the verdict is MERGE.
715
+ --markdown Print the description block for a merge request and
716
+ nothing else: the verdict line, the commit and the base,
717
+ what is driving by kind, the flags, the checks excluded
718
+ from the pipeline input, the two labels, and every
719
+ signal in a collapsed table. Each quoted reason, evidence
720
+ line and name is an inline code span, so nothing a
721
+ platform, a review or a ledger supplied renders as a
722
+ mention, a link or markup. The exit status is what it is
723
+ without the flag. With --json it exits 1.
724
+ --label-names Print how to recognise the labels and assess nothing:
725
+ the three verdict label names and the score label
726
+ prefix (with --json, "labels": { verdictNames,
727
+ scorePrefix }). It reads no input and no repository, so
728
+ it answers when an assessment could not run, which is
729
+ when a caller has an earlier assessment's labels to
730
+ remove and no verdict to read their names from. Exit 0.
731
+ --json carries "head" ({ sha }, or null when the head cannot be read), "base"
732
+ ({ ref, sha }: the reference given and the merge base the diff was taken from,
733
+ sha null when it cannot be read; null with no --base) and "labels" (the verdict
734
+ and score labels for a merge request, each { name, color, description }, with
735
+ verdictNames and scorePrefix). The text form prints the two label names.
736
+ Every input not given reads unobserved with that reason, and unobserved ranks
737
+ as amber. The command never calls gh, glab or the network. Every gating signal
738
+ has a producer, so a clean, reviewed, verified change reads MERGE once the
739
+ run's evidence (--run) and a passing pipeline for HEAD (--pipeline) are given.
740
+ conformance.living counts the handoff's answers against the living list the
741
+ command builds itself (the list "conformance --living" prints). spec.coverage
742
+ needs --base: an added source file that no living spec and none of the
743
+ change's delta specs names is amber; it reads spec text, never the graph.
744
+ policy.humanPaths is observed when --base is given: the always-human-review
745
+ list is read from the REVIEW.md committed at that ref, never from the working
746
+ copy, and the signal is unobserved ("no --base") without it. A fact the
747
+ repository could not supply (a diff git cannot compute, a file unreadable at
748
+ the base) is named on stderr and its signal reads unobserved. Every threshold
749
+ is a merge-band row of "interlock limits", cited by name in the reason that
750
+ applies it.
751
+ HUMAN exits 0: a change that needs a person is not a failed command. Exit 1
752
+ is BLOCK, and, as for gate, also an input that could not be read or parsed,
753
+ which prints nothing on stdout. Exit 1 alone does not tell the two apart; a
754
+ verdict on stdout does.
467
755
 
468
756
  doctor:
469
757
  The preflight. Every condition here would otherwise be discovered mid-run, by
@@ -509,6 +797,18 @@ review:
509
797
  A verdict that a finding IS real needs no citation.
510
798
  --min-quality <n> Quality-band floor, as for gate
511
799
  --no-tolerance Disable the quality band
800
+ --base <ref> Read REVIEW.md's two path lists (Do Not Report, Always
801
+ Human Review) from the commit at this ref instead of
802
+ the working tree, so a change cannot exclude its own
803
+ paths by editing the copy applied to it. The prose
804
+ still comes from the working tree. A working copy that
805
+ differs from the ref's, ignoring line endings, is said
806
+ so; a ref that names no commit exits 1 with nothing on
807
+ stdout. A finding's optional "requirement"
808
+ (openspec/specs/<capability>/spec.md#<heading>) is read
809
+ at the ref too; a citation naming no requirement there
810
+ is reported under anomalies.requirementCitations and
811
+ never drops the finding.
512
812
  --changed also drives nothing else; REVIEW.md is located from --root (default
513
813
  cwd). Findings on its do-not-report paths are dropped BEFORE the band, counted
514
814
  as "droppedByPolicy" with the excluding path, and "policyProblems" reports a
@@ -519,10 +819,43 @@ review:
519
819
 
520
820
  review-policy:
521
821
  --root <dir> Repository root to read REVIEW.md from (default cwd).
522
- --json Emit { prose, excludePaths, owner, problems }.
822
+ --base <ref> Read both path lists from the commit at this ref and the
823
+ prose from the working tree; the output says which
824
+ ref the lists came from and whether the copies differ.
825
+ --json Emit { prose, excludePaths, humanPaths, owner, problems }
826
+ (with --base also listsFrom and differsFromBase).
523
827
  The prose is advice a reviewer reads; it can never lower survival. The band and
524
828
  nit cap are NOT policy fields — a threshold-shaped key is reported, not adopted.
525
- Always exits 0; an absent REVIEW.md is the empty policy, not an error.
829
+ Always exits 0; an absent REVIEW.md is the empty policy, not an error. A --base
830
+ that names no commit is the one exit 1.
831
+
832
+ domain-context:
833
+ [change] The change whose delta specs declare the capabilities it
834
+ modifies (or --change <name>). None resolved is said on
835
+ stderr; only the graph then selects a capability.
836
+ --changed <files...> Repo-relative changed paths ("-" for stdin).
837
+ --base <ref> Read the living requirement text from the commit at this
838
+ ref. Absent, the working tree is read and the output
839
+ names it as the source.
840
+ Selects capabilities two ways: a changed path that a reliable graph edge links
841
+ to a living spec ("linked"), and a capability the change's delta specs modify
842
+ that has a living spec ("declared"). The graph is found root first, then the
843
+ state home. No graph with something declared is PARTIAL; with nothing declared
844
+ it is SKIPPED, stated with its reason. A graph that links none of the changed
845
+ paths records them as notSelected and is neither. The text is whole
846
+ requirements, the change's superseded ones removed, bounded by the published
847
+ domain-spec cap (see "interlock limits"); what is left out is named. Exit 0 on
848
+ a completed report; exit 1, with nothing on stdout, on a --base that names no
849
+ commit.
850
+
851
+ spec-audit:
852
+ Walks openspec/specs/**/spec.md and counts what the living specs state:
853
+ requirements, scenarios, bound and prohibition lines, failure and edge-case
854
+ scenarios, numeric literals in requirement prose. With a graph it adds how many
855
+ coverable source files no living spec names and which spec links name a file
856
+ that no longer exists; without one those measures are absent with that reason,
857
+ never zero. A spec it cannot read is named and left out of the totals. Always
858
+ exits 0. --json carries capabilities[], totals, unreadable[] and graph.
526
859
 
527
860
  risk:
528
861
  [change] OpenSpec change name (resolved like validate when omitted)
@@ -709,6 +1042,43 @@ verify spill:
709
1042
  head/tail slice bounded by \`interlock limits\`' verify preview budget.
710
1043
  Always exits 0.
711
1044
 
1045
+ verify baseline:
1046
+ --report <lcov> The coverage report taken on this commit. Absent, the
1047
+ test profile's coverage.report_path under unit.cwd.
1048
+ --baseline <file> Where to write it (default
1049
+ .claude/testing/coverage-baseline.json in the working
1050
+ root; never the state home).
1051
+ Writes { schema, sha, ref, totalPct, generatedAt }: the report's project total
1052
+ with the commit and branch it was taken on. A report that cannot be read or
1053
+ instruments no line has no total: exit 1, the previous baseline untouched.
1054
+
1055
+ verify coverage:
1056
+ --base <ref> REQUIRED. The diff from its merge base to HEAD is the
1057
+ changed-line figure.
1058
+ --report <lcov> Use this report instead of the profile's. Names a report
1059
+ for a repository whose profile declares no coverage.
1060
+ --baseline <file> Read this baseline instead of root then state home. A
1061
+ baseline file that fails its schema exits 1, naming it.
1062
+ --compute-base When no usable baseline exists (none, or its commit is
1063
+ not an ancestor of HEAD), check the merge base out in a
1064
+ temporary worktree, run the profile's coverage command
1065
+ there, record its total as the baseline and use it. The
1066
+ worktree is removed whether the command passed or not;
1067
+ a failed measurement exits 1 naming the command and its
1068
+ status. The worktree has no gitignored files and no
1069
+ installed dependencies; a shallow clone may lack the
1070
+ baseline's commit, which reads as not an ancestor.
1071
+ Prints { configured, headTotalPct, baseTotalPct, baseSha, baseIsAncestor,
1072
+ diffPct, changedLines, unreportedFiles, unreportedAddedLines } — figures only,
1073
+ no band — for "merge-confidence --coverage". unreportedFiles lists the changed
1074
+ source files the report holds no record for (a file the diff adds a line to
1075
+ that the project counts as coverable source: never a test, a configuration
1076
+ file or a document), and unreportedAddedLines the lines the diff adds in them;
1077
+ the changed-line figure still counts only lines the report instruments. No
1078
+ coverage command declared prints configured: false, with both of those null.
1079
+ A declared report that is missing, unreadable or not lcov exits 1 with nothing
1080
+ on stdout. Runs no coverage command unless --compute-base asks it to.
1081
+
712
1082
  verify exec:
713
1083
  --kind <k> The planned step this runs, e.g. typecheck/unit/lint (required)
714
1084
  --command <cmd> The planned command, verbatim; run through the shell (required)
@@ -805,6 +1175,12 @@ Examples:
805
1175
  interlock ledger add-auth || echo "a human still owes a decision"
806
1176
  interlock verify plan --profile .claude/testing/profile.json --typecheck-command "npx tsc --noEmit" --json > vplan.json
807
1177
  interlock verify judge --plan vplan.json --results results.json --context inter-wave || echo "halt"
1178
+ interlock verify baseline --report coverage/lcov.info
1179
+ interlock verify coverage --base origin/main --json > coverage-input.json
1180
+ interlock domain-context add-auth --changed src/auth/session.ts --base origin/main --json
1181
+ interlock conformance add-auth --changed src/auth/session.ts --living --base origin/main
1182
+ interlock merge-confidence add-auth --base origin/main --run .claude/ship/merge-confidence.json --pipeline pipeline.json --require-merge
1183
+ interlock spec-audit
808
1184
  interlock plan reuse add-auth --json || true
809
1185
  interlock plan fingerprint add-auth --write --json
810
1186
  interlock waves --classified plan.json --json | interlock wave-state create --plan - --json > run.json
@@ -841,7 +1217,21 @@ const BOOLEAN_FLAGS = new Set([
841
1217
  'no-tdd',
842
1218
  // `run close --notify`: both drivers pass it unconditionally, and the CLI —
843
1219
  // not the driver — decides whether a topic is configured (design D1).
844
- 'notify'
1220
+ 'notify',
1221
+ // `merge-confidence --require-merge`: exit 1 unless the verdict is MERGE.
1222
+ 'require-merge',
1223
+ // `verify coverage --compute-base`: measure the merge base in a temporary worktree
1224
+ // when no usable baseline exists. A flag, not a value: it must not swallow the next argument.
1225
+ 'compute-base',
1226
+ // `conformance --living`: add the living requirements to the checklist. A
1227
+ // flag, so a change name may follow it.
1228
+ 'living',
1229
+ // `merge-confidence --markdown`: print the description block. A flag, so it
1230
+ // never swallows a change name that follows it.
1231
+ 'markdown',
1232
+ // `merge-confidence --label-names`: print how to recognise the labels, and
1233
+ // assess nothing.
1234
+ 'label-names'
845
1235
  // `--review`, `--handoff`, `--conformance` and `--strict` are deliberately
846
1236
  // NOT here. `interlock ready --review <findings.json>` takes a value under
847
1237
  // the same spelling, and making the name boolean everywhere would silently
@@ -872,12 +1262,14 @@ function parseArgs(argv) {
872
1262
  out.flags.changed = vals
873
1263
  continue
874
1264
  }
875
- if (key === 'mode') {
1265
+ if (key === 'mode' || key === 'required' || key === 'exclude') {
876
1266
  // Accumulated rather than last-wins: `--mode solo --mode waves` must reach
877
1267
  // `decideMode` as an array so it is rejected as contradictory, not silently
878
1268
  // resolved to the last flag. A single `--mode solo` stays a scalar string.
1269
+ // `pipeline --required` and `--exclude` accumulate for the opposite reason:
1270
+ // every name given is kept, and a last-wins parse would drop all but one.
879
1271
  const val = next === undefined || next.startsWith('--') ? true : argv[++i]
880
- out.flags.mode = out.flags.mode === undefined ? val : [].concat(out.flags.mode, val)
1272
+ out.flags[key] = out.flags[key] === undefined ? val : [].concat(out.flags[key], val)
881
1273
  continue
882
1274
  }
883
1275
  if (next === undefined || next.startsWith('--')) {
@@ -961,6 +1353,20 @@ function readInput(value, what) {
961
1353
  }
962
1354
  }
963
1355
 
1356
+ /**
1357
+ * A findings input, for `gate`, `review` and `remediate`: read as `readInput`
1358
+ * reads, then refused when it carries no findings list (design D10 of
1359
+ * label-and-harden-the-merge-verdict). The shape reader returns nothing for an
1360
+ * object it does not recognise, and each of those commands used to read that as
1361
+ * a clean review. Exit 1, the reason on stderr, nothing on stdout — as for a
1362
+ * file that cannot be read.
1363
+ */
1364
+ function readFindingsInput(value, what = '--findings') {
1365
+ const input = readInput(value, what)
1366
+ if (!carriesFindingsList(input)) die(`${what} ${value === '-' ? '(stdin)' : value}: ${NO_FINDINGS_LIST}`)
1367
+ return input
1368
+ }
1369
+
964
1370
  // Like `readInput`, but for a payload that is bytes to spill rather than JSON
965
1371
  // to parse — `verify spill` hands this raw suite output, which is exactly the
966
1372
  // text the rest of the CLI must never try to JSON.parse.
@@ -969,11 +1375,34 @@ function readRawInput(value, what) {
969
1375
  return value === '-' ? readStdin() : readFileSync(value, 'utf8')
970
1376
  }
971
1377
 
1378
+ /** What `die` raises inside `reasonInsteadOfExit`, in place of ending the process. */
1379
+ class Refusal extends Error {}
1380
+ let refusalDepth = 0
1381
+
972
1382
  function die(message) {
1383
+ if (refusalDepth > 0) throw new Refusal(message)
973
1384
  process.stderr.write(`interlock: ${message}\n`)
974
1385
  process.exit(1)
975
1386
  }
976
1387
 
1388
+ /**
1389
+ * Run `fn` so that a `die()` anywhere beneath it returns `{ reason }` rather
1390
+ * than ending the process. A subcommand's body can then be called from a ship
1391
+ * run's close, where a refusal is a fact to report and never an exit: the
1392
+ * subcommand dies with the reason, the close records it.
1393
+ */
1394
+ function reasonInsteadOfExit(fn) {
1395
+ refusalDepth += 1
1396
+ try {
1397
+ return fn()
1398
+ } catch (err) {
1399
+ if (err instanceof Refusal) return { reason: err.message }
1400
+ throw err
1401
+ } finally {
1402
+ refusalDepth -= 1
1403
+ }
1404
+ }
1405
+
977
1406
  function warn(message) {
978
1407
  process.stderr.write(`interlock: ${message}\n`)
979
1408
  }
@@ -1844,6 +2273,767 @@ function readChangeArtifacts(root, change) {
1844
2273
  return artifacts
1845
2274
  }
1846
2275
 
2276
+ /**
2277
+ * One caller-supplied input to `merge-confidence`, read before any assessment
2278
+ * (design D9). Not given is `null`, which the signal reads as unobserved. An
2279
+ * input that cannot be read or parsed ends the command through `die()`, as
2280
+ * `gate` does: exit 1, the reason on stderr naming the file, nothing on stdout.
2281
+ */
2282
+ function readMergeInput(flags, key) {
2283
+ const value = flags[key]
2284
+ if (value === undefined) return null
2285
+ if (value === true) die(`--${key} needs a file (or "-" for stdin)`)
2286
+ let raw
2287
+ if (value === '-') raw = readStdin()
2288
+ else {
2289
+ try {
2290
+ raw = readFileSync(value, 'utf8')
2291
+ } catch (err) {
2292
+ die(`--${key} ${value} could not be read: ${err.message}`)
2293
+ }
2294
+ }
2295
+ let parsed
2296
+ try {
2297
+ parsed = JSON.parse(raw)
2298
+ } catch (err) {
2299
+ die(`--${key} ${value} is not valid JSON: ${err.message}`)
2300
+ }
2301
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) die(`--${key} ${value} must hold a JSON object`)
2302
+ return parsed
2303
+ }
2304
+
2305
+ /**
2306
+ * One file as `ref` has it, for a reader that must not trust the working tree
2307
+ * (judge-against-base-and-living-specs D3): `{ text }`, `{ absent: true }` when
2308
+ * the commit has no such file, or `{ tooLarge: true, size }` over `maxBytes`.
2309
+ * Throws, naming the ref, when `ref` is not a commit — a base nobody can read is
2310
+ * the caller's to degrade on, never a guess. Handed to the run as
2311
+ * `ctx.deps.readAtRef`.
2312
+ */
2313
+ function readAtRef(root, ref, path, { maxBytes } = {}) {
2314
+ const git = args =>
2315
+ execFileSync('git', args, {
2316
+ cwd: root,
2317
+ encoding: 'utf8',
2318
+ stdio: ['ignore', 'pipe', 'ignore'],
2319
+ maxBuffer: 64 * 1024 * 1024
2320
+ })
2321
+ if (typeof ref !== 'string' || !ref || ref.startsWith('-')) {
2322
+ throw new Error(`cannot resolve ${JSON.stringify(ref)} to a commit`)
2323
+ }
2324
+ try {
2325
+ git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
2326
+ } catch {
2327
+ throw new Error(`cannot resolve "${ref}" to a commit`)
2328
+ }
2329
+ try {
2330
+ git(['cat-file', '-e', `${ref}:${path}`])
2331
+ } catch {
2332
+ return { absent: true }
2333
+ }
2334
+ if (Number.isFinite(maxBytes)) {
2335
+ const size = Number(git(['cat-file', '-s', `${ref}:${path}`]).trim())
2336
+ if (size > maxBytes) return { tooLarge: true, size }
2337
+ }
2338
+ return { text: git(['show', `${ref}:${path}`]) }
2339
+ }
2340
+
2341
+ /** Run git in `root`; stdout on success, an Error carrying git's own words on failure. */
2342
+ function gitText(root, args) {
2343
+ try {
2344
+ return execFileSync('git', args, {
2345
+ cwd: root,
2346
+ encoding: 'utf8',
2347
+ stdio: ['ignore', 'pipe', 'pipe'],
2348
+ maxBuffer: 256 * 1024 * 1024
2349
+ })
2350
+ } catch (err) {
2351
+ const detail = (err.stderr && String(err.stderr).trim()) || err.message
2352
+ throw new Error(detail)
2353
+ }
2354
+ }
2355
+
2356
+ /** The commit `ref` names, or null when it names none (never a guess). */
2357
+ function commitOf(root, ref) {
2358
+ if (typeof ref !== 'string' || !ref || ref.startsWith('-')) return null
2359
+ try {
2360
+ return gitText(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim() || null
2361
+ } catch {
2362
+ return null
2363
+ }
2364
+ }
2365
+
2366
+ /**
2367
+ * `--base <ref>` on the commands that read policy, specs or a diff from a ref.
2368
+ * Null when absent; a flag with no value, a value that reads as an option, and a
2369
+ * ref that names no commit each end the command with nothing on stdout.
2370
+ */
2371
+ function baseRefOf(root, flags) {
2372
+ if (flags.base === undefined) return null
2373
+ if (flags.base === true) die('--base needs a ref')
2374
+ const base = String(flags.base)
2375
+ if (base.startsWith('-')) die(`--base ${base}: a base must name a ref, not an option`)
2376
+ if (!commitOf(root, base)) die(`--base ${base}: cannot resolve it to a commit`)
2377
+ return base
2378
+ }
2379
+
2380
+ /** The working tree's `REVIEW.md` as text plus its parsed policy, or null when absent. */
2381
+ function workingPolicyCopy(root) {
2382
+ const path = join(root, REVIEW_POLICY_FILE)
2383
+ if (!existsSync(path)) return null
2384
+ let text = ''
2385
+ try {
2386
+ if (statSync(path).size <= LIMITS.maxReviewPolicyBytes) text = readFileSync(path, 'utf8')
2387
+ } catch {
2388
+ // `readReviewPolicy` reports an unreadable file as a problem.
2389
+ }
2390
+ return { text, policy: readReviewPolicy(root) }
2391
+ }
2392
+
2393
+ /**
2394
+ * The committed `REVIEW.md` at `ref` as text plus its parsed policy, or null when
2395
+ * the commit has none. An oversized or unreadable copy is an empty policy that
2396
+ * carries the reason as a problem, as the working-tree reader does.
2397
+ */
2398
+ function basePolicyCopy(root, ref) {
2399
+ let read
2400
+ try {
2401
+ read = readAtRef(root, ref, REVIEW_POLICY_FILE, { maxBytes: LIMITS.maxReviewPolicyBytes })
2402
+ } catch (err) {
2403
+ read = { error: (err && err.message) || String(err) }
2404
+ }
2405
+ if (typeof read.text === 'string') return { text: read.text, policy: parseReviewPolicy(read.text) }
2406
+ if (read.absent) return null
2407
+ const policy = parseReviewPolicy('')
2408
+ policy.problems.push(
2409
+ read.tooLarge
2410
+ ? `${REVIEW_POLICY_FILE} is ${read.size} bytes, over the ${LIMITS.maxReviewPolicyBytes}-byte scan ` +
2411
+ `cap — not parsed; review proceeds under default policy`
2412
+ : `${REVIEW_POLICY_FILE} at ${ref} could not be read: ${read.error || 'unreadable'}`
2413
+ )
2414
+ return { text: '', policy }
2415
+ }
2416
+
2417
+ /**
2418
+ * The policy a command judges by (judge-against-base-and-living-specs D3): with a
2419
+ * base, prose and owner from the working tree and both enforced path lists from
2420
+ * the base; without one, the working tree supplies everything, as before.
2421
+ * `baseProblems` are the base copy's alone — the merge verdict names those.
2422
+ */
2423
+ function reviewPolicyAt(root, base) {
2424
+ if (!base) return { policy: readReviewPolicy(root), ref: null, differsFromBase: null, baseProblems: [] }
2425
+ const working = workingPolicyCopy(root)
2426
+ const committed = basePolicyCopy(root, base)
2427
+ const policy = composePolicy({ working, base: committed, ref: base })
2428
+ return {
2429
+ policy,
2430
+ ref: base,
2431
+ differsFromBase: policy.differsFromBase,
2432
+ baseProblems: committed ? committed.policy.problems : []
2433
+ }
2434
+ }
2435
+
2436
+ const SPEC_CITATION_PATH = /^openspec\/specs\/.+\/spec\.md$/
2437
+
2438
+ /**
2439
+ * For every living spec a finding cites, that spec's requirement keys at `base`
2440
+ * (the working tree with no base), or `{ unreadable }`. Only canonical
2441
+ * `openspec/specs/**\/spec.md` paths are read, so a citation cannot make the
2442
+ * command read an arbitrary file.
2443
+ */
2444
+ function requirementHeadingsAt(root, base, findings) {
2445
+ const headings = new Map()
2446
+ let cited = []
2447
+ try {
2448
+ cited = applyToleranceBand(findings, null).reportable
2449
+ } catch {
2450
+ // A findings file the gate cannot flatten has no citations to read.
2451
+ }
2452
+ for (const finding of cited) {
2453
+ const text = finding && typeof finding.requirement === 'string' ? finding.requirement : ''
2454
+ const hash = text.indexOf('#')
2455
+ if (hash === -1) continue
2456
+ const path = canonicalizePath(text.slice(0, hash).trim())
2457
+ if (!path || !SPEC_CITATION_PATH.test(path) || headings.has(path)) continue
2458
+ const read = readSpecText(root, base, path)
2459
+ if (typeof read.text === 'string') headings.set(path, parseSpec(read.text).requirements.map(r => r.key))
2460
+ else if (read.absent) headings.set(path, { unreadable: `${path} does not exist ${base ? `at ${base}` : 'in the working tree'}` })
2461
+ else headings.set(path, { unreadable: read.error || 'unreadable' })
2462
+ }
2463
+ return headings
2464
+ }
2465
+
2466
+ /** One file at `base` (null = the working tree): `{ text }`, `{ absent }` or `{ error }`; never throws. */
2467
+ function readSpecText(root, base, relPath) {
2468
+ if (base) {
2469
+ try {
2470
+ return readAtRef(root, base, relPath)
2471
+ } catch (err) {
2472
+ return { error: (err && err.message) || String(err) }
2473
+ }
2474
+ }
2475
+ const path = join(root, relPath)
2476
+ try {
2477
+ if (!existsSync(path)) return { absent: true }
2478
+ return { text: readFileSync(path, 'utf8') }
2479
+ } catch (err) {
2480
+ return { error: (err && err.message) || String(err) }
2481
+ }
2482
+ }
2483
+
2484
+ /**
2485
+ * The living list `conformance --living` adds to the checklist (design D5 of
2486
+ * complete-and-record-the-merge-verdict): the graph read root first then the
2487
+ * state home, the living text from `--base` when one is named and from the
2488
+ * working tree otherwise. Never ends the command, which exits 0 in every case:
2489
+ * a base nobody can read is the reason the list was not built.
2490
+ */
2491
+ function livingListFor(root, flags, { change, changed }) {
2492
+ const notBuilt = (reason, source) => ({
2493
+ skipped: reason,
2494
+ partial: null,
2495
+ requirements: [],
2496
+ requirementsRequired: 0,
2497
+ omitted: [],
2498
+ source
2499
+ })
2500
+ let base = null
2501
+ if (flags.base !== undefined) {
2502
+ if (flags.base === true) return notBuilt('--base needs a ref, so there is no base to read the living requirements from', 'no base')
2503
+ base = String(flags.base)
2504
+ if (base.startsWith('-') || !commitOf(root, base)) {
2505
+ return notBuilt(`--base ${base} names no commit, so the living requirements could not be read from it`, `base ${base}`)
2506
+ }
2507
+ }
2508
+ const graph = locateInput(root, startStateHome(root, flags).home, GRAPH_FILE_PATH)
2509
+ const { living, notes } = gatherLivingList(root, {
2510
+ change,
2511
+ changedPaths: changed,
2512
+ base,
2513
+ deps: { readAtRef },
2514
+ ...(graph.path ? { graphPath: graph.path } : {})
2515
+ })
2516
+ if (living) return living
2517
+ return notBuilt(
2518
+ notes.map(note => `${note.what}: ${note.reason}`).join('; ') || 'the living requirements could not be read',
2519
+ base ? `base ${base}` : 'the working tree'
2520
+ )
2521
+ }
2522
+
2523
+ /** The graph, root first then the state home, as `run start` locates it. */
2524
+ function readGraphFor(root, flags) {
2525
+ const located = locateInput(root, startStateHome(root, flags).home, GRAPH_FILE_PATH)
2526
+ if (!located.path) {
2527
+ return { links: null, reason: 'no graph has been built — run /interlock:bootstrap once', source: 'none' }
2528
+ }
2529
+ return { ...readGraphAt(located.path), source: located.source }
2530
+ }
2531
+
2532
+ // --- coverage (judge-against-base-and-living-specs D2) ------------------------
2533
+
2534
+ const COVERAGE_BASELINE_PATH = join('.claude', 'testing', 'coverage-baseline.json')
2535
+
2536
+ /** The test profile as parsed JSON plus where it was found; null when there is none. */
2537
+ function readTestProfile(root, flags) {
2538
+ const found = locateInput(root, startStateHome(root, flags).home, TEST_PROFILE_PATH)
2539
+ if (!found.path) return null
2540
+ let parsed
2541
+ try {
2542
+ parsed = JSON.parse(readFileSync(found.path, 'utf8'))
2543
+ } catch (err) {
2544
+ die(`the test profile ${found.path} could not be read: ${err.message}`)
2545
+ }
2546
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
2547
+ die(`the test profile ${found.path} must hold a JSON object`)
2548
+ }
2549
+ return { path: found.path, profile: parsed }
2550
+ }
2551
+
2552
+ /** The directory a profile's `report_path` is relative to, kept inside `base`. */
2553
+ function unitCwdOf(base, profile) {
2554
+ const cwd = profile && profile.unit && typeof profile.unit.cwd === 'string' && profile.unit.cwd.trim() ? profile.unit.cwd : '.'
2555
+ const target = resolve(base, cwd)
2556
+ if (target !== resolve(base) && !target.startsWith(resolve(base) + sep)) {
2557
+ die(`the test profile's unit.cwd ${JSON.stringify(cwd)} leaves the repository`)
2558
+ }
2559
+ return target
2560
+ }
2561
+
2562
+ /** Real paths, so an lcov `SF:` written absolute and a repository root given through a symlink still match. */
2563
+ function realOr(path) {
2564
+ try {
2565
+ return realpathSync(path)
2566
+ } catch {
2567
+ return resolve(path)
2568
+ }
2569
+ }
2570
+
2571
+ /** Parse an lcov report text into records plus its total, relative to `root`/`reportDir`. */
2572
+ function readLcovFigures(text, root, reportDir) {
2573
+ const parsed = parseLcov(text, { root: realOr(root), reportDir: realOr(reportDir) })
2574
+ return { ...parsed, totals: coverageTotals(parsed.records) }
2575
+ }
2576
+
2577
+ /** Write JSON beside its destination and rename it into place: a failure leaves the old file. */
2578
+ function writeJsonAtomic(path, value) {
2579
+ mkdirSync(dirname(path), { recursive: true })
2580
+ const staging = `${path}.interlock-partial`
2581
+ try {
2582
+ writeFileSync(staging, JSON.stringify(value, null, 2) + '\n')
2583
+ renameSync(staging, path)
2584
+ } catch (err) {
2585
+ try {
2586
+ rmSync(staging, { force: true })
2587
+ } catch {
2588
+ // Cleaning up a file that may never have existed cannot be the reported failure.
2589
+ }
2590
+ throw err
2591
+ }
2592
+ }
2593
+
2594
+ /** The baseline as the caller names it, else root then state home; null when there is none. */
2595
+ function readCoverageBaseline(root, flags) {
2596
+ let path
2597
+ if (flags.baseline !== undefined) {
2598
+ if (flags.baseline === true) die('--baseline needs a file')
2599
+ path = String(flags.baseline)
2600
+ if (!existsSync(path)) die(`--baseline ${path} does not exist`)
2601
+ } else {
2602
+ path = locateInput(root, startStateHome(root, flags).home, COVERAGE_BASELINE_PATH).path
2603
+ if (!path) return null
2604
+ }
2605
+ let parsed
2606
+ try {
2607
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
2608
+ } catch (err) {
2609
+ die(`the coverage baseline ${path} could not be read: ${err.message}`)
2610
+ }
2611
+ const verdict = validateBaseline(parsed)
2612
+ if (!verdict.ok) die(`the coverage baseline ${path} is not valid: ${verdict.problems.join('; ')}`)
2613
+ return { path, ...parsed }
2614
+ }
2615
+
2616
+ /**
2617
+ * Whether `sha` is an ancestor of HEAD, read from the repository: exit 0 true, 1
2618
+ * false; any other status (an unknown commit in a shallow clone) is false, with
2619
+ * the likely cause on stderr.
2620
+ */
2621
+ function isAncestorOfHead(root, sha) {
2622
+ const r = spawnSync('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], { cwd: root, encoding: 'utf8' })
2623
+ if (r.status === 0) return true
2624
+ if (r.status === 1) return false
2625
+ warn(`could not tell whether ${sha} is an ancestor of HEAD (git exited ${r.status ?? 'abnormally'}); a shallow clone that lacks the commit is the usual cause — fetch more history`)
2626
+ return false
2627
+ }
2628
+
2629
+ /**
2630
+ * Measure the coverage of the merge base in a temporary worktree and record it as
2631
+ * the baseline (design D2). The worktree is removed in `finally`, whether the
2632
+ * command passed, failed or could not start; every failure is thrown to the
2633
+ * caller, which exits only after the cleanup, because `process.exit` would skip it.
2634
+ */
2635
+ function computeBaselineAt(root, base, profile) {
2636
+ const coverage = profile && profile.coverage
2637
+ const command = coverage && typeof coverage.command === 'string' ? coverage.command : ''
2638
+ if (!command.trim()) throw new Error('--compute-base needs a coverage command in the test profile')
2639
+ const reportRel = typeof coverage.report_path === 'string' && coverage.report_path ? coverage.report_path : 'coverage/lcov.info'
2640
+ if (isAbsolute(reportRel)) throw new Error(`coverage.report_path ${reportRel} must be relative to compute a base figure`)
2641
+
2642
+ const mergeBase = gitText(root, ['merge-base', base, 'HEAD']).trim()
2643
+ const tmp = realOr(mkdtempSync(join(tmpdir(), 'interlock-cov-base-')))
2644
+ let added = false
2645
+ try {
2646
+ gitText(root, ['worktree', 'add', '--detach', tmp, mergeBase])
2647
+ added = true
2648
+ const cwd = resolve(tmp, (profile.unit && typeof profile.unit.cwd === 'string' && profile.unit.cwd.trim()) || '.')
2649
+ if (cwd !== tmp && !cwd.startsWith(tmp + sep)) throw new Error(`unit.cwd leaves the repository`)
2650
+ // The command's own output goes to stderr: stdout is the coverage input.
2651
+ const ran = spawnSync(command, { shell: true, cwd, stdio: ['ignore', 2, 2] })
2652
+ if (ran.error) throw new Error(`the coverage command "${command}" could not start on merge base ${mergeBase.slice(0, 7)}: ${ran.error.message}`)
2653
+ if (ran.status !== 0) {
2654
+ throw new Error(
2655
+ `the coverage command "${command}" exited with status ${ran.status ?? `signal ${ran.signal}`} on merge base ${mergeBase.slice(0, 7)}`
2656
+ )
2657
+ }
2658
+ const reportPath = resolve(cwd, reportRel)
2659
+ let text
2660
+ try {
2661
+ text = readFileSync(reportPath, 'utf8')
2662
+ } catch (err) {
2663
+ throw new Error(`the coverage command "${command}" passed on merge base ${mergeBase.slice(0, 7)} but its report ${reportRel} could not be read: ${err.message}`)
2664
+ }
2665
+ const figures = readLcovFigures(text, tmp, cwd)
2666
+ if (figures.totals.totalPct === null) {
2667
+ throw new Error(`the report of "${command}" on merge base ${mergeBase.slice(0, 7)} instruments no line, so it has no project total`)
2668
+ }
2669
+ return { sha: mergeBase, totalPct: figures.totals.totalPct }
2670
+ } finally {
2671
+ if (added) {
2672
+ try {
2673
+ gitText(root, ['worktree', 'remove', '--force', tmp])
2674
+ } catch {
2675
+ // Falls through to the directory removal and the prune below.
2676
+ }
2677
+ }
2678
+ try {
2679
+ rmSync(tmp, { recursive: true, force: true })
2680
+ } catch {
2681
+ // Best effort: the prune below drops the registration either way.
2682
+ }
2683
+ try {
2684
+ gitText(root, ['worktree', 'prune'])
2685
+ } catch {
2686
+ // A prune that fails leaves a stale registration, never a stale directory.
2687
+ }
2688
+ }
2689
+ }
2690
+
2691
+ /**
2692
+ * The coverage input the merge verdict reads, computed from the head report, the
2693
+ * baseline and the diff against `base`: figures only, no band (design D2 of
2694
+ * judge-against-base-and-living-specs). This is the body of `interlock verify
2695
+ * coverage`, and a ship run's close calls it too (`ctx.deps.coverageInput`,
2696
+ * design D3 of complete-and-record-the-merge-verdict), so it never ends the
2697
+ * process: it returns `{ input, text }`, or `{ reason }` with the words the
2698
+ * subcommand exits on.
2699
+ *
2700
+ * `flags` are the subcommand's own (`--report`, `--baseline`, `--compute-base`,
2701
+ * `--state-home`); a close passes none of the first three, so it reads the
2702
+ * profile's report and the recorded baseline and runs no coverage command.
2703
+ */
2704
+ function coverageInputFor(root, base, flags = {}) {
2705
+ return reasonInsteadOfExit(() => {
2706
+ if (!commitOf(root, base)) die(`--base ${base}: cannot resolve it to a commit`)
2707
+ const found = readTestProfile(root, flags)
2708
+ const profile = found ? found.profile : null
2709
+ const coverage = profile && profile.coverage && typeof profile.coverage === 'object' ? profile.coverage : null
2710
+ const named = flags.report !== undefined
2711
+ if (flags.report === true) die('--report needs an lcov file')
2712
+ const declared = Boolean(coverage && coverage.enabled !== false && typeof coverage.command === 'string' && coverage.command.trim())
2713
+ if (!named && !declared) {
2714
+ return {
2715
+ input: buildCoverageInput({ configured: false }),
2716
+ text: 'coverage: not configured (no coverage command in the test profile)\n'
2717
+ }
2718
+ }
2719
+
2720
+ const reportRel = named ? null : typeof coverage.report_path === 'string' && coverage.report_path ? coverage.report_path : 'coverage/lcov.info'
2721
+ const reportDir = unitCwdOf(root, profile)
2722
+ const reportPath = named ? String(flags.report) : resolve(reportDir, reportRel)
2723
+ const format = named ? 'lcov' : coverage.format || 'lcov'
2724
+ if (format !== 'lcov') die(`the coverage report ${reportRel} is ${format}: only lcov is supported`)
2725
+ let reportText
2726
+ try {
2727
+ reportText = readFileSync(reportPath, 'utf8')
2728
+ } catch (err) {
2729
+ die(
2730
+ `the coverage report ${named ? reportPath : reportRel} could not be read (${err.code || err.message}) — ` +
2731
+ `run the coverage command${declared ? ` (${coverage.command})` : ''} first`
2732
+ )
2733
+ }
2734
+ const head = readLcovFigures(reportText, root, reportDir)
2735
+ if (head.outsideRoot) warn(`${head.outsideRoot} record(s) in the report lie outside the repository and were left out of both figures`)
2736
+
2737
+ let diffText
2738
+ try {
2739
+ diffText = gitText(root, ['diff', '-U0', '--no-color', '--no-ext-diff', `${base}...HEAD`])
2740
+ } catch (err) {
2741
+ die(`--base ${base}: git could not diff it against HEAD: ${err.message}`)
2742
+ }
2743
+ const added = parseAddedLines(diffText)
2744
+ const changed = changedLineCoverage(head.records, added)
2745
+ // The changed source the report holds no record for: named, never judged
2746
+ // (design D13 of label-and-harden-the-merge-verdict).
2747
+ const unreported = unreportedChangedSource(head.records, added)
2748
+
2749
+ let baseline = readCoverageBaseline(root, flags)
2750
+ let baseIsAncestor = baseline ? isAncestorOfHead(root, baseline.sha) : null
2751
+ if (flags['compute-base'] === true && (!baseline || baseIsAncestor !== true)) {
2752
+ let computed
2753
+ try {
2754
+ computed = computeBaselineAt(root, base, profile)
2755
+ } catch (err) {
2756
+ die(`--compute-base: ${(err && err.message) || err}`)
2757
+ }
2758
+ const target = typeof flags.baseline === 'string' ? flags.baseline : join(root, COVERAGE_BASELINE_PATH)
2759
+ const recorded = {
2760
+ schema: BASELINE_SCHEMA,
2761
+ sha: computed.sha,
2762
+ ref: base,
2763
+ totalPct: computed.totalPct,
2764
+ generatedAt: new Date().toISOString()
2765
+ }
2766
+ try {
2767
+ writeJsonAtomic(target, recorded)
2768
+ } catch (err) {
2769
+ die(`--compute-base: the baseline could not be written to ${target}: ${err.message}`)
2770
+ }
2771
+ warn(`recorded a coverage baseline: ${recorded.totalPct}% at merge base ${computed.sha.slice(0, 7)} → ${target}`)
2772
+ baseline = recorded
2773
+ baseIsAncestor = isAncestorOfHead(root, computed.sha)
2774
+ }
2775
+
2776
+ const input = buildCoverageInput({ configured: true, head: head.totals, baseline, baseIsAncestor, changed, unreported })
2777
+ const fmt = v => (v === null ? 'unobserved' : v)
2778
+ return {
2779
+ input,
2780
+ text: [
2781
+ 'COVERAGE INPUT',
2782
+ ` head total: ${fmt(input.headTotalPct)}%`,
2783
+ ` base total: ${fmt(input.baseTotalPct)}%${input.baseSha ? ` at ${input.baseSha.slice(0, 7)} (${input.baseIsAncestor ? 'an ancestor of HEAD' : 'NOT an ancestor of HEAD'})` : ' (no baseline)'}`,
2784
+ ` changed lines: ${fmt(input.changedLines)}, covered ${fmt(input.diffPct)}%`,
2785
+ input.unreportedFiles.length
2786
+ ? ` changed source the report does not list: ${input.unreportedFiles.length} file(s), ${input.unreportedAddedLines} added line(s): ${input.unreportedFiles.join(', ')}`
2787
+ : ' changed source the report does not list: none',
2788
+ ''
2789
+ ].join('\n')
2790
+ }
2791
+ })
2792
+ }
2793
+
2794
+ /** HEAD's sha, or null when the root is not a git checkout — never a guess. */
2795
+ function headShaOf(root) {
2796
+ try {
2797
+ const out = execFileSync('git', ['rev-parse', 'HEAD'], {
2798
+ cwd: root,
2799
+ encoding: 'utf8',
2800
+ stdio: ['ignore', 'pipe', 'ignore']
2801
+ })
2802
+ return out.trim() || null
2803
+ } catch {
2804
+ return null
2805
+ }
2806
+ }
2807
+
2808
+ // --- git, as the merge-confidence gather reaches it ---------------------------
2809
+ //
2810
+ // `lib/merge-evidence.mjs` reads git only through four functions, each taking
2811
+ // the root first: `diffShape`, `nameStatus`, `listFiles` and `readAtRef` (above).
2812
+ // They are handed to the `merge-confidence` command's gather and to a ship run's
2813
+ // close alike (`ctx.deps`). None of them ends the process: a git that cannot
2814
+ // answer THROWS, in git's own words, and the gather turns that into a note and
2815
+ // an unobserved signal. Whether an unreadable base ends a command is the
2816
+ // command's decision, made before it gathers.
2817
+
2818
+ /**
2819
+ * The diff shape against a base: `git diff --numstat -z <base>...HEAD`. A
2820
+ * binary row (`-\t-\tpath`) counts as one file and no lines; a rename counts
2821
+ * as git reports it, and both of its paths are changed paths. A base that is
2822
+ * HEAD or a descendant of it diffs empty by construction, so it carries
2823
+ * `nothingToDiff` instead of counts — an empty diff there proves nothing about
2824
+ * the change. Throws when git cannot diff the base against HEAD.
2825
+ */
2826
+ function diffShape(root, base, headSha) {
2827
+ const explain = err => new Error(`git could not diff ${base} against HEAD: ${err.message}`)
2828
+ // HEAD as the repository has it; the caller's sha only when it cannot be read.
2829
+ const head = headShaOf(root) || headSha
2830
+ let mergeBase
2831
+ try {
2832
+ mergeBase = gitText(root, ['merge-base', base, 'HEAD']).trim()
2833
+ } catch (err) {
2834
+ throw explain(err)
2835
+ }
2836
+ if (head && mergeBase === head) {
2837
+ return {
2838
+ base,
2839
+ paths: [],
2840
+ nothingToDiff: `--base ${base} is HEAD or a descendant of it, so ${base}...HEAD is empty by construction and says nothing about the change`
2841
+ }
2842
+ }
2843
+ let out
2844
+ try {
2845
+ out = gitText(root, ['diff', '--numstat', '-z', `${base}...HEAD`])
2846
+ } catch (err) {
2847
+ throw explain(err)
2848
+ }
2849
+ // -z: `added\tdeleted\tpath\0`, or for a rename `added\tdeleted\t\0old\0new\0`.
2850
+ let changedLines = 0
2851
+ let changedFiles = 0
2852
+ let binaryFiles = 0
2853
+ const paths = []
2854
+ const tokens = out.split('\0')
2855
+ for (let i = 0; i < tokens.length; i++) {
2856
+ if (!tokens[i]) continue
2857
+ const [added, deleted, path] = tokens[i].split('\t')
2858
+ if (path) {
2859
+ paths.push(path)
2860
+ } else {
2861
+ paths.push(...tokens.slice(i + 1, i + 3).filter(Boolean))
2862
+ i += 2
2863
+ }
2864
+ changedFiles += 1
2865
+ if (added === '-' || deleted === '-') {
2866
+ binaryFiles += 1
2867
+ continue
2868
+ }
2869
+ changedLines += (Number(added) || 0) + (Number(deleted) || 0)
2870
+ }
2871
+ return { base, changedLines, changedFiles, binaryFiles, paths }
2872
+ }
2873
+
2874
+ /**
2875
+ * The paths `<base>...HEAD` adds, edits and deletes, for the spec-coverage
2876
+ * facts. Renames are not detected (`--no-renames`), so a rename is a delete of
2877
+ * the old path and an add of the new one — which is how the signal reads it. A
2878
+ * type change is an edit. Throws when git cannot diff the base against HEAD.
2879
+ */
2880
+ function nameStatus(root, base) {
2881
+ let out
2882
+ try {
2883
+ out = gitText(root, ['diff', '--name-status', '--no-renames', '-z', `${base}...HEAD`])
2884
+ } catch (err) {
2885
+ throw new Error(`git could not list the files ${base}...HEAD changes: ${err.message}`)
2886
+ }
2887
+ const result = { added: [], modified: [], deleted: [] }
2888
+ // -z: `status\0path\0`, one pair per file.
2889
+ const tokens = out.split('\0')
2890
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
2891
+ const status = tokens[i]
2892
+ const path = tokens[i + 1]
2893
+ if (!status || !path) continue
2894
+ if (status.startsWith('A')) result.added.push(path)
2895
+ else if (status.startsWith('D')) result.deleted.push(path)
2896
+ else result.modified.push(path)
2897
+ }
2898
+ return result
2899
+ }
2900
+
2901
+ /** Every path tracked at `ref`. Throws when `ref` names no tree git can list. */
2902
+ function listFiles(root, ref) {
2903
+ if (typeof ref !== 'string' || !ref || ref.startsWith('-')) {
2904
+ throw new Error(`cannot list the files at ${JSON.stringify(ref)}: it names no commit`)
2905
+ }
2906
+ let out
2907
+ try {
2908
+ out = gitText(root, ['ls-tree', '-r', '--name-only', '-z', ref])
2909
+ } catch (err) {
2910
+ throw new Error(`git could not list the files tracked at ${ref}: ${err.message}`)
2911
+ }
2912
+ return out.split('\0').filter(Boolean)
2913
+ }
2914
+
2915
+ /** A repeatable name flag as a list: absent is none, one use is a list of one. A use with no value ends the command. */
2916
+ function nameList(flags, key, fail) {
2917
+ const value = flags[key]
2918
+ if (value === undefined) return []
2919
+ const given = [].concat(value)
2920
+ if (given.some(v => typeof v !== 'string' || !v)) fail(`--${key} needs a check name`)
2921
+ return given
2922
+ }
2923
+
2924
+ /**
2925
+ * `interlock pipeline`: a platform's own check document in, the pipeline input
2926
+ * `merge-confidence --pipeline` reads out (design D2 of
2927
+ * label-and-harden-the-merge-verdict). Offline by construction: it reads the
2928
+ * document it is handed, from a file or standard input, and never gh, glab or
2929
+ * the network. `lib/pipeline.mjs` decides everything; this owns the files.
2930
+ *
2931
+ * Every way this command produces no input goes through `refuse`, which removes
2932
+ * an earlier file at `--out` first and says so. A fixed output path that kept
2933
+ * its last good file would hand the next command an earlier moment's checks,
2934
+ * with a commit that may still match.
2935
+ */
2936
+ function pipelineCommand(flags, json) {
2937
+ const outPath = typeof flags.out === 'string' && flags.out ? flags.out : null
2938
+ const refuse = message => {
2939
+ if (outPath && existsSync(outPath)) {
2940
+ try {
2941
+ rmSync(outPath, { force: true })
2942
+ warn(`removed ${outPath}: no pipeline input was produced, so the earlier file there is not left for the next command to read`)
2943
+ } catch (err) {
2944
+ warn(`${outPath} could not be removed (${err.message}): it still holds an earlier file, which describes an earlier moment`)
2945
+ }
2946
+ }
2947
+ die(message)
2948
+ }
2949
+ if (flags.out === true) refuse('--out needs a file')
2950
+
2951
+ const platforms = ['github', 'gitlab-mr', 'gitlab'].filter(key => flags[key] !== undefined)
2952
+ if (platforms.length !== 1) {
2953
+ refuse(
2954
+ `pipeline needs exactly one of --github, --gitlab-mr or --gitlab, each with the platform's document ` +
2955
+ `(a file, or "-" for stdin)${platforms.length ? `; got ${platforms.map(p => `--${p}`).join(' and ')}` : ''}`
2956
+ )
2957
+ }
2958
+ const platform = platforms[0]
2959
+ const source = flags[platform]
2960
+ if (source === true) refuse(`--${platform} needs the platform's document: a file, or "-" for stdin`)
2961
+
2962
+ const required = nameList(flags, 'required', refuse)
2963
+ const exclude = nameList(flags, 'exclude', refuse)
2964
+ if (platform !== 'github' && required.length) {
2965
+ refuse(`--required is read with --github only: on GitLab a job is required when its allow_failure is false`)
2966
+ }
2967
+ if (platform === 'gitlab-mr' && exclude.length) {
2968
+ refuse('--exclude is read with --github and --gitlab: the merge request document carries no checks to leave out')
2969
+ }
2970
+ if (platform === 'gitlab-mr' && !outPath) {
2971
+ refuse('--gitlab-mr needs --out <file>: the head commit travels to "interlock pipeline --gitlab --mr <file>" in that file')
2972
+ }
2973
+
2974
+ // The record of the first GitLab step, read before anything at --out is
2975
+ // touched: --mr and --out may name the same path.
2976
+ let record = null
2977
+ if (platform === 'gitlab') {
2978
+ if (typeof flags.mr !== 'string' || !flags.mr || flags.mr === '-') {
2979
+ refuse('--gitlab needs --mr <file>: the file "interlock pipeline --gitlab-mr --out <file>" wrote, which carries the head commit')
2980
+ }
2981
+ try {
2982
+ record = JSON.parse(readFileSync(flags.mr, 'utf8'))
2983
+ } catch (err) {
2984
+ refuse(`--mr ${flags.mr} could not be read as JSON: ${err.message}`)
2985
+ }
2986
+ }
2987
+
2988
+ let raw
2989
+ try {
2990
+ raw = source === '-' ? readStdin() : readFileSync(source, 'utf8')
2991
+ } catch (err) {
2992
+ refuse(`--${platform} ${source} could not be read: ${err.message}`)
2993
+ }
2994
+ let doc
2995
+ try {
2996
+ doc = JSON.parse(raw)
2997
+ } catch (err) {
2998
+ refuse(`--${platform} ${source === '-' ? '(stdin)' : source} is not valid JSON: ${err.message}`)
2999
+ }
3000
+
3001
+ const read =
3002
+ platform === 'github'
3003
+ ? pipelineFromGithub(doc, { required, exclude })
3004
+ : platform === 'gitlab-mr'
3005
+ ? pendingFromGitlabMr(doc)
3006
+ : pipelineFromGitlab(doc, record, { exclude })
3007
+ if (!read.ok) refuse(`--${platform} ${source === '-' ? '(stdin)' : source}: ${read.reason}`)
3008
+ for (const note of read.notes || []) warn(note)
3009
+
3010
+ const produced = read.pending || read.input
3011
+ if (outPath) {
3012
+ try {
3013
+ writeJsonAtomic(outPath, produced)
3014
+ } catch (err) {
3015
+ refuse(`--out ${outPath} could not be written: ${err.message}`)
3016
+ }
3017
+ }
3018
+
3019
+ if (read.pending) {
3020
+ emit(json, read.pending, `pending ${read.pending.sha}: head pipeline ${read.pending.headPipeline}\n`)
3021
+ return
3022
+ }
3023
+ const { input } = read
3024
+ const count = state => input.checks.filter(c => c.state === state).length
3025
+ const requiredNames = input.checks.filter(c => c.required).map(c => c.name)
3026
+ // GitHub's required set is the caller's to give; GitLab states it per job.
3027
+ const requiredText = platform === 'github' && !required.length ? 'not given' : requiredNames.join(', ') || 'none'
3028
+ emit(
3029
+ json,
3030
+ input,
3031
+ `pipeline ${input.sha}: ${input.checks.length} check(s) (${count('pass')} pass, ${count('fail')} fail, ` +
3032
+ `${count('pending')} pending, ${count('skipped')} skipped); required: ${requiredText}; ` +
3033
+ `excluded: ${input.excluded.join(', ') || 'none'}\n`
3034
+ )
3035
+ }
3036
+
1847
3037
  function splitPaths(value) {
1848
3038
  if (value === undefined || value === true) return []
1849
3039
  const raw = value === '-' ? readStdin().split('\n') : String(value).split(',')
@@ -1936,7 +3126,7 @@ async function main() {
1936
3126
  }
1937
3127
 
1938
3128
  case 'gate': {
1939
- const input = readInput(flags.findings, '--findings')
3129
+ const input = readFindingsInput(flags.findings)
1940
3130
  const dismissed =
1941
3131
  typeof flags.dismissed === 'string'
1942
3132
  ? flags.dismissed.split(',').map(s => s.trim()).filter(Boolean)
@@ -2066,14 +3256,185 @@ async function main() {
2066
3256
 
2067
3257
  case 'conformance': {
2068
3258
  // Emits questions, never verdicts, and never exits non-zero — see the
2069
- // header of lib/conformance.mjs.
3259
+ // header of lib/conformance.mjs. That holds for `--living` and a `--base`
3260
+ // nobody can read too: the reason is printed in the list's place.
2070
3261
  const name = typeof flags.change === 'string' ? flags.change : positional[1]
2071
3262
  const changed = flags.changed === undefined ? [] : splitPaths(flags.changed)
2072
3263
  const result = buildChecklist(root, name, { changed })
3264
+ // `--living` adds the requirements already in force, after the scenarios
3265
+ // (design D5 of complete-and-record-the-merge-verdict). Without it the
3266
+ // checklist is field for field what it was, whatever else is given.
3267
+ if (flags.living === true) result.living = livingListFor(root, flags, { change: result.change, changed })
2073
3268
  emit(json, result, formatChecklist(result) + '\n')
2074
3269
  break
2075
3270
  }
2076
3271
 
3272
+ case 'pipeline': {
3273
+ pipelineCommand(flags, json)
3274
+ break
3275
+ }
3276
+
3277
+ case 'merge-confidence': {
3278
+ // Offline by construction: the repository, the files the caller names and
3279
+ // the published limits. Never gh, glab or the network (design D4).
3280
+ //
3281
+ // Two output forms and one stdout: asking for both is a usage error.
3282
+ const markdown = flags.markdown === true
3283
+ if (markdown && json) die('--markdown and --json cannot be given together: one prints the description block, the other the assessment')
3284
+ // How to recognise the labels, and nothing else. It is asked for when an
3285
+ // assessment stopped: a merge request may still carry the labels of an
3286
+ // earlier one, and there is no verdict to read their names from. So it
3287
+ // reads no file and nothing of the repository, and answers where every
3288
+ // other form of this command would end on the input that stopped it.
3289
+ if (flags['label-names'] === true) {
3290
+ if (markdown) die('--label-names and --markdown cannot be given together: one prints the label names, the other the description block')
3291
+ const labelNames = mergeLabelNames()
3292
+ emit(
3293
+ json,
3294
+ { labels: labelNames },
3295
+ `verdict labels: ${labelNames.verdictNames.join(', ')}\nscore label prefix: ${labelNames.scorePrefix}\n`
3296
+ )
3297
+ break
3298
+ }
3299
+ // Every caller file is read FIRST, so an unreadable one ends the command
3300
+ // before any assessment and nothing reaches stdout (design D9).
3301
+ const review = readMergeInput(flags, 'review')
3302
+ const verify = readMergeInput(flags, 'verify')
3303
+ const handoffFile = readMergeInput(flags, 'handoff')
3304
+ const pipeline = readMergeInput(flags, 'pipeline')
3305
+ // A pipeline file that is not well-formed is an input error, as one that is
3306
+ // not JSON is (design D7 of label-and-harden-the-merge-verdict). An absent
3307
+ // or misspelt check list read as "no checks" would hide a failed required
3308
+ // check behind a green signal.
3309
+ if (pipeline) {
3310
+ const shape = validatePipelineInput(pipeline)
3311
+ if (!shape.ok) {
3312
+ die(`--pipeline ${flags.pipeline} is not a pipeline input: ${shape.reason} (produce it with "interlock pipeline")`)
3313
+ }
3314
+ }
3315
+ const coverage = readMergeInput(flags, 'coverage')
3316
+ const runFile = readMergeInput(flags, 'run')
3317
+ if (flags.base === true) die('--base needs a ref')
3318
+ const base = typeof flags.base === 'string' ? flags.base : null
3319
+ // A ref is handed to git as an argument; one that reads as an option is
3320
+ // refused rather than passed through. A ref that names no commit is input
3321
+ // the command could not read, so it ends here, before any assessment. A
3322
+ // base that resolves and still cannot be diffed (a shallow clone with no
3323
+ // merge base) is the gather's to note: those signals read unobserved.
3324
+ if (base && base.startsWith('-')) die(`--base ${base}: a base must name a ref, not an option`)
3325
+ if (base && !commitOf(root, base)) die(`--base ${base}: cannot resolve it to a commit, so git cannot diff it against HEAD`)
3326
+ let changedPaths = []
3327
+ if (flags.changed !== undefined) {
3328
+ const given = [].concat(flags.changed)
3329
+ changedPaths =
3330
+ given.length === 1 && given[0] === '-'
3331
+ ? readStdin().split('\n').map(p => p.trim()).filter(Boolean)
3332
+ : splitPaths(given.join(','))
3333
+ }
3334
+
3335
+ const headSha = headShaOf(root)
3336
+ const requested = typeof flags.change === 'string' ? flags.change : positional[1]
3337
+ const resolved = resolveChange(root, requested)
3338
+ const name = resolved.error ? null : resolved.name
3339
+
3340
+ // The diff under assessment runs from the merge base of --base and the
3341
+ // head. The output names both, and a run's evidence is judged against it.
3342
+ // A base that resolves and has no merge base to read (a shallow clone) is
3343
+ // named with no commit.
3344
+ let assessedBase = null
3345
+ if (base) {
3346
+ let mergeBase = null
3347
+ try {
3348
+ mergeBase = gitText(root, ['merge-base', base, 'HEAD']).trim() || null
3349
+ } catch {
3350
+ mergeBase = null
3351
+ }
3352
+ assessedBase = { ref: base, sha: mergeBase }
3353
+ }
3354
+
3355
+ // A run's evidence file (design D9 of complete-and-record-the-merge-verdict)
3356
+ // supplies what only that run observed, and only for the commit it made:
3357
+ // a file for another commit, another change or a run that made no commit
3358
+ // is refused whole, out loud, and the signals read as with no file. With a
3359
+ // base it is also refused unless that is the diff the run reviewed (design
3360
+ // D8 of label-and-harden-the-merge-verdict); no base is ever taken from
3361
+ // the file. The assessment recorded in it is never read: the command
3362
+ // always reassesses.
3363
+ let fromRun = {}
3364
+ if (runFile) {
3365
+ const read = readRunEvidence(runFile, {
3366
+ change: name,
3367
+ headSha,
3368
+ base: assessedBase ? (assessedBase.sha ? { sha: assessedBase.sha } : { unreadable: true }) : undefined
3369
+ })
3370
+ if (read.usable) fromRun = read.evidence
3371
+ else warn(`--run ${flags.run} is not used: ${read.reason}`)
3372
+ }
3373
+ // The conformance denominators are the command's own counts. A count the
3374
+ // handoff carries is dropped here (and ignored by the module), so a
3375
+ // caller cannot shrink one.
3376
+ const handoffGiven = handoffFile ?? fromRun.handoff ?? null
3377
+ let handoff = null
3378
+ if (handoffGiven && typeof handoffGiven === 'object' && !Array.isArray(handoffGiven)) {
3379
+ const { verdictsRequired: _ignored, ...counts } = handoffGiven
3380
+ handoff = counts
3381
+ }
3382
+
3383
+ // One gather, shared with a ship run's close (design D1): the repository's
3384
+ // own facts, read here, and git through the four functions handed in. An
3385
+ // explicit input replaces the evidence file's entry for it.
3386
+ const graph = locateInput(root, startStateHome(root, flags).home, GRAPH_FILE_PATH)
3387
+ const { input, notes } = gatherMergeEvidence(root, {
3388
+ change: requested,
3389
+ changedPaths,
3390
+ base,
3391
+ headSha,
3392
+ supplied: {
3393
+ review: review ?? fromRun.review ?? null,
3394
+ verify: verify ?? fromRun.verify ?? null,
3395
+ handoff,
3396
+ pipeline,
3397
+ coverage: coverage ?? fromRun.coverage ?? null,
3398
+ // A run record exists only where a run wrote one; by hand there is none.
3399
+ run: fromRun.run ?? null
3400
+ },
3401
+ deps: { diffShape, nameStatus, listFiles, readAtRef },
3402
+ ...(graph.path ? { graphPath: graph.path } : {})
3403
+ })
3404
+
3405
+ // With a base, git's own file list is a changed-path source: a path the
3406
+ // caller left out of --changed still reaches risk, conformance and drift.
3407
+ const named = new Set(changedPaths)
3408
+ const added = input.changedPaths.filter(p => !named.has(p))
3409
+ if (added.length) {
3410
+ warn(`${added.length} path(s) git reports in ${base}...HEAD were not in --changed and are assessed too: ${added.join(', ')}`)
3411
+ }
3412
+ // What the gather could not read, with the reason. The signal that needed
3413
+ // it reads unobserved; this is where a reader learns why.
3414
+ for (const note of notes) warn(`${note.what}: ${note.reason}`)
3415
+
3416
+ const result = assessMergeConfidence(input)
3417
+ // The commit and the base the verdict is about: a label is a claim about
3418
+ // one commit, so the output says which (design D14).
3419
+ const assessedHead = headSha ? { sha: headSha } : null
3420
+ if (markdown) {
3421
+ // The block is rendered by the module from what the command holds, so
3422
+ // the description shows what was computed and nothing a model retyped.
3423
+ const excluded = input.pipeline && Array.isArray(input.pipeline.excluded) ? input.pipeline.excluded : []
3424
+ process.stdout.write(formatMergeConfidenceMarkdown(result, { head: assessedHead, base: assessedBase, excluded }))
3425
+ } else {
3426
+ emit(
3427
+ json,
3428
+ { change: input.change ? input.change.name : null, ...result, head: assessedHead, base: assessedBase },
3429
+ formatMergeConfidence(result)
3430
+ )
3431
+ }
3432
+ // HUMAN exits 0: a change that needs a reviewer is not a failed command.
3433
+ const failed = flags['require-merge'] === true ? result.verdict !== 'MERGE' : result.verdict === 'BLOCK'
3434
+ if (failed) process.exit(1)
3435
+ break
3436
+ }
3437
+
2077
3438
  case 'limits': {
2078
3439
  // `laneCaps` and `solo` are their OWN top-level groups, not entries of
2079
3440
  // `limits`: LIMITS holds positive-integer loop counts (its invariant test
@@ -2097,7 +3458,10 @@ async function main() {
2097
3458
  report: REPORT_CAPS,
2098
3459
  effort: EFFORT,
2099
3460
  laneCaps: LANE_CAPS,
2100
- solo: SOLO
3461
+ solo: SOLO,
3462
+ // The merge verdict's thresholds: their own group, a ceiling table
3463
+ // and seven scalars, never entries of the iteration-count limits.
3464
+ mergeBands: MERGE_BANDS
2101
3465
  },
2102
3466
  formatLimits({ runtimeObserved })
2103
3467
  )
@@ -2263,7 +3627,7 @@ async function main() {
2263
3627
  }
2264
3628
 
2265
3629
  case 'remediate': {
2266
- const input = readInput(flags.findings, '--findings')
3630
+ const input = readFindingsInput(flags.findings)
2267
3631
  const round = num(flags, 'round')
2268
3632
  const plan = planRemediation(input, { round, band: parseBand(flags) })
2269
3633
  emit(json, plan, formatRemediationPlan(plan))
@@ -2272,7 +3636,7 @@ async function main() {
2272
3636
  }
2273
3637
 
2274
3638
  case 'review': {
2275
- const findings = readInput(flags.findings, '--findings')
3639
+ const findings = readFindingsInput(flags.findings)
2276
3640
  const verdicts = readInput(flags.verdicts, '--verdicts')
2277
3641
  // The reviewed diff. A dismissing verdict has to cite a path that is in
2278
3642
  // it — shape alone is satisfied by inventing `lib/nowhere.ts:1`. Optional
@@ -2284,12 +3648,20 @@ async function main() {
2284
3648
  // here as a survival filter — never handed to a model as a request. Its
2285
3649
  // problems (a malformed file, a band-like key) are reported alongside the
2286
3650
  // review, so a broken policy is diagnosable rather than silent.
2287
- const policy = readReviewPolicy(root)
3651
+ //
3652
+ // With --base the two enforced lists are read from the commit at that ref,
3653
+ // so a change cannot exclude its own paths by editing the copy applied to
3654
+ // it; the prose still comes from the working tree (design D3).
3655
+ const base = baseRefOf(root, flags)
3656
+ const { policy, differsFromBase } = reviewPolicyAt(root, base)
2288
3657
 
2289
3658
  const result = resolveReview(findings, verdicts, {
2290
3659
  band: parseBand(flags),
2291
3660
  changedPaths,
2292
- excludePaths: policy.excludePaths
3661
+ excludePaths: policy.excludePaths,
3662
+ // A requirement a finding cites is checked against the spec as the base
3663
+ // has it, so a citation cannot lean on a requirement the change added.
3664
+ requirementHeadings: requirementHeadingsAt(root, base, findings)
2293
3665
  })
2294
3666
 
2295
3667
  // Metrics are bookkeeping: they report their own failure and never touch
@@ -2305,11 +3677,22 @@ async function main() {
2305
3677
  ? ` policy: ${policy.problems.length} problem(s) in REVIEW.md\n` +
2306
3678
  policy.problems.map(p => ` - ${p}`).join('\n') + '\n'
2307
3679
  : ''
3680
+ const policyBase = base
3681
+ ? { ref: base, differsFromBase }
3682
+ : null
3683
+ const policyDiffers = differsFromBase
3684
+ ? ` policy: ${REVIEW_POLICY_FILE} differs from ${base}; its Do Not Report paths were read from ${base}\n`
3685
+ : ''
2308
3686
  const text =
2309
3687
  formatReview(result) +
3688
+ policyDiffers +
2310
3689
  policyProblems +
2311
3690
  (metrics ? ` metrics: ${metrics.written ? metrics.path : `not written (${metrics.reason})`}\n` : '')
2312
- emit(json, { ...result, policyProblems: policy.problems, ...(metrics ? { metrics } : {}) }, text)
3691
+ emit(
3692
+ json,
3693
+ { ...result, policyProblems: policy.problems, ...(policyBase ? { policyBase } : {}), ...(metrics ? { metrics } : {}) },
3694
+ text
3695
+ )
2313
3696
  break
2314
3697
  }
2315
3698
 
@@ -2317,9 +3700,174 @@ async function main() {
2317
3700
  // Read-only view of the parsed REVIEW.md, so the workflow can inject the
2318
3701
  // prose + owner into the reviewer instructions without re-parsing the file
2319
3702
  // (the runtime cannot import lib modules). Always exits 0: a missing or
2320
- // malformed policy is reported, never fatal.
2321
- const policy = readReviewPolicy(root)
2322
- emit(json, policy, formatReviewPolicy(policy))
3703
+ // malformed policy is reported, never fatal. A --base that names no commit
3704
+ // is the one exception: a base nobody can read is not a policy.
3705
+ const base = baseRefOf(root, flags)
3706
+ const { policy, differsFromBase } = reviewPolicyAt(root, base)
3707
+ const tail = base
3708
+ ? ` lists read from ${base}${differsFromBase ? `; ${REVIEW_POLICY_FILE} differs from ${base}` : ''}\n`
3709
+ : ''
3710
+ emit(json, policy, formatReviewPolicy(policy) + tail)
3711
+ break
3712
+ }
3713
+
3714
+ case 'domain-context': {
3715
+ // The same selection and assembly a ship run's review step makes, for a
3716
+ // hand-run review (design D4). Selected from the graph and from the
3717
+ // capabilities the change's delta specs declare; read from the base when
3718
+ // one is named, the working tree otherwise, and the output says which.
3719
+ // Exit 0 on a completed report — a skip is spoken, not a failure — and
3720
+ // exit 1 only on a base that names no commit.
3721
+ const base = baseRefOf(root, flags)
3722
+ const changed =
3723
+ flags.changed === undefined
3724
+ ? []
3725
+ : (() => {
3726
+ const given = [].concat(flags.changed)
3727
+ return given.length === 1 && given[0] === '-'
3728
+ ? readStdin().split('\n').map(p => p.trim()).filter(Boolean)
3729
+ : splitPaths(given.join(','))
3730
+ })()
3731
+
3732
+ const requested = typeof flags.change === 'string' ? flags.change : positional[1]
3733
+ const resolved = resolveChange(root, requested)
3734
+ const name = resolved.error ? null : resolved.name
3735
+ if (!name) warn(`no change under assessment: ${resolved.error} — only the graph selects a capability`)
3736
+
3737
+ const graph = readGraphFor(root, flags)
3738
+ const edges = Array.isArray(graph.links) ? livingSpecEdges(graph) : null
3739
+
3740
+ const living = {}
3741
+ const declared = []
3742
+ let specFiles = []
3743
+ if (name) {
3744
+ try {
3745
+ specFiles = inspectChange(root, name).specFiles
3746
+ } catch {
3747
+ // A change that cannot be inspected declares nothing.
3748
+ }
3749
+ }
3750
+ for (const rel of specFiles) {
3751
+ const capability = capabilityOf(rel)
3752
+ if (!capability || declared.includes(capability)) continue
3753
+ const read = readSpecText(root, base, `openspec/specs/${capability}/spec.md`)
3754
+ if (typeof read.text === 'string') {
3755
+ living[capability] = read.text
3756
+ declared.push(capability)
3757
+ }
3758
+ }
3759
+
3760
+ const selection = selectDomainCapabilities({ changedPaths: changed, edges, graphReason: graph.reason, declared })
3761
+ let assembled = null
3762
+ let skipped = selection.skipped
3763
+ if (selection.capabilities.length) {
3764
+ const deltas = {}
3765
+ const readErrors = []
3766
+ for (const { capability } of selection.capabilities) {
3767
+ if (!(capability in living)) {
3768
+ const read = readSpecText(root, base, `openspec/specs/${capability}/spec.md`)
3769
+ if (typeof read.text === 'string') living[capability] = read.text
3770
+ else if (read.error) readErrors.push(`openspec/specs/${capability}/spec.md: ${read.error}`)
3771
+ }
3772
+ if (name) {
3773
+ const delta = readSpecText(root, null, `openspec/changes/${name}/specs/${capability}/spec.md`)
3774
+ if (typeof delta.text === 'string') deltas[capability] = delta.text
3775
+ }
3776
+ }
3777
+ assembled = assembleDomainContext({ capabilities: selection.capabilities, living, deltas, ref: base })
3778
+ if (assembled.skipped) {
3779
+ skipped = readErrors.length ? `${assembled.skipped} (${readErrors.join('; ')})` : assembled.skipped
3780
+ }
3781
+ }
3782
+ // `selected` says capabilities were chosen; `skipped` says whether any
3783
+ // requirement text came of it. Both can hold: a capability the change
3784
+ // rewrites entirely is selected and has nothing in force to judge against.
3785
+ const selected = selection.capabilities.length > 0
3786
+ const hasText = Boolean(assembled) && !assembled.skipped
3787
+ const source = assembled ? assembled.source : base ? `base ${base}` : 'the working tree'
3788
+ const out = {
3789
+ change: name,
3790
+ selected,
3791
+ ref: base,
3792
+ source,
3793
+ capabilities: selection.capabilities,
3794
+ skipped: skipped || null,
3795
+ partial: selection.partial || null,
3796
+ notSelected: selection.notSelected || null,
3797
+ text: hasText ? assembled.text : '',
3798
+ superseded: assembled ? assembled.superseded : [],
3799
+ omitted: assembled ? assembled.omitted : [],
3800
+ specifiedByChange: assembled ? assembled.specifiedByChange : []
3801
+ }
3802
+
3803
+ const lines = []
3804
+ if (out.skipped) lines.push(`DOMAIN REVIEW SKIPPED: ${out.skipped} (base: ${source})`)
3805
+ if (selected) {
3806
+ lines.push(`DOMAIN CONTEXT — ${out.capabilities.length} capabilit${out.capabilities.length === 1 ? 'y' : 'ies'}, read from ${source}`)
3807
+ for (const c of out.capabilities) {
3808
+ lines.push(` ${c.capability} (${c.via}${c.files.length ? `: ${c.files.join(', ')}` : ''})`)
3809
+ }
3810
+ if (out.partial && hasText) lines.push(`DOMAIN REVIEW PARTIAL: ${out.partial}`)
3811
+ if (out.omitted.length) lines.push(` ${out.omitted.length} requirement(s) left out to fit the size cap`)
3812
+ if (hasText) lines.push('', out.text)
3813
+ } else if (!out.skipped && out.notSelected) {
3814
+ lines.push(`DOMAIN NOT SELECTED: ${out.notSelected.reason} (base: ${source})`)
3815
+ for (const path of out.notSelected.unlinkedPaths) lines.push(` unlinked: ${path}`)
3816
+ } else if (!out.skipped) {
3817
+ lines.push(`DOMAIN NOT SELECTED: no changed path was given (base: ${source})`)
3818
+ }
3819
+ emit(json, out, lines.join('\n') + '\n')
3820
+ break
3821
+ }
3822
+
3823
+ case 'spec-audit': {
3824
+ // Counts over the living specs and, with a graph, over how they link to
3825
+ // source. Judges nothing and blocks nothing: it never exits non-zero, so a
3826
+ // measure the repository cannot yet take is reported absent with its
3827
+ // reason (design D5).
3828
+ const specsDir = join(root, 'openspec', 'specs')
3829
+ const specPaths = []
3830
+ const walk = dir => {
3831
+ let entries
3832
+ try {
3833
+ entries = readdirSync(dir, { withFileTypes: true })
3834
+ } catch {
3835
+ return
3836
+ }
3837
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
3838
+ const full = join(dir, entry.name)
3839
+ if (entry.isDirectory()) walk(full)
3840
+ else if (entry.name === 'spec.md') specPaths.push(relative(root, full).split(sep).join('/'))
3841
+ }
3842
+ }
3843
+ walk(specsDir)
3844
+ const specs = specPaths.map(path => {
3845
+ try {
3846
+ return { path, text: readFileSync(join(root, path), 'utf8') }
3847
+ } catch (err) {
3848
+ return { path, error: (err && err.message) || String(err) }
3849
+ }
3850
+ })
3851
+
3852
+ const graph = readGraphFor(root, flags)
3853
+ const edges = Array.isArray(graph.links) ? livingSpecEdges(graph) : null
3854
+ let broken = []
3855
+ if (edges) {
3856
+ const stale = detectStaleSpecs(root, { lastCommit: () => null })
3857
+ broken = stale.skipped
3858
+ ? edges
3859
+ .map(e => ({ spec: e.source_file, file: e.target.slice('file:'.length) }))
3860
+ .filter(b => !existsSync(join(root, b.file)))
3861
+ : stale.broken
3862
+ }
3863
+ const graphFiles = Array.isArray(graph.nodes)
3864
+ ? graph.nodes
3865
+ .filter(n => n && n.type === 'file' && typeof n.source_file === 'string')
3866
+ .map(n => n.source_file)
3867
+ .filter(isCoverableSource)
3868
+ : []
3869
+ const report = auditSpecs({ specs, edges, graphReason: graph.reason, broken, graphFiles })
3870
+ emit(json, report, formatSpecAudit(report) + '\n')
2323
3871
  break
2324
3872
  }
2325
3873
 
@@ -2692,6 +4240,75 @@ async function main() {
2692
4240
  process.stderr.write(`interlock: verify timing not recorded: ${(err && err.message) || err}\n`)
2693
4241
  }
2694
4242
  process.exit(exitCode)
4243
+ } else if (sub === 'baseline') {
4244
+ // Record the project total of a coverage report taken on this commit, so a
4245
+ // later `verify coverage` has a base figure. Refuses, leaving any previous
4246
+ // baseline in place, when the report yields no total (design D2).
4247
+ const found = readTestProfile(root, flags)
4248
+ const profile = found ? found.profile : null
4249
+ let reportPath
4250
+ if (flags.report !== undefined) {
4251
+ if (flags.report === true) die('--report needs an lcov file')
4252
+ reportPath = String(flags.report)
4253
+ } else {
4254
+ const rel = profile && profile.coverage && typeof profile.coverage.report_path === 'string' ? profile.coverage.report_path : ''
4255
+ if (!rel) die('verify baseline needs --report <lcov>, or coverage.report_path in the test profile')
4256
+ reportPath = resolve(unitCwdOf(root, profile), rel)
4257
+ }
4258
+ if (flags.report === undefined) {
4259
+ const format = profile.coverage.format || 'lcov'
4260
+ if (format !== 'lcov') die(`the report ${reportPath} is ${format}: only lcov is supported`)
4261
+ }
4262
+ let text
4263
+ try {
4264
+ text = readFileSync(reportPath, 'utf8')
4265
+ } catch (err) {
4266
+ die(`the coverage report ${reportPath} could not be read: ${err.message}`)
4267
+ }
4268
+ const figures = readLcovFigures(text, root, unitCwdOf(root, profile))
4269
+ if (figures.totals.totalPct === null) {
4270
+ die(`the coverage report ${reportPath} instruments no line, so it has no project total — no baseline written`)
4271
+ }
4272
+ const sha = headShaOf(root)
4273
+ if (!sha) die('verify baseline needs a git checkout with a commit to record the report against')
4274
+ let ref = null
4275
+ try {
4276
+ ref = gitText(root, ['symbolic-ref', '--short', '-q', 'HEAD']).trim() || null
4277
+ } catch {
4278
+ // A detached HEAD has no branch.
4279
+ }
4280
+ const baseline = {
4281
+ schema: BASELINE_SCHEMA,
4282
+ sha,
4283
+ ref,
4284
+ totalPct: figures.totals.totalPct,
4285
+ generatedAt: new Date().toISOString()
4286
+ }
4287
+ if (flags.baseline === true) die('--baseline needs a file')
4288
+ const target = typeof flags.baseline === 'string' ? flags.baseline : join(root, COVERAGE_BASELINE_PATH)
4289
+ try {
4290
+ writeJsonAtomic(target, baseline)
4291
+ } catch (err) {
4292
+ die(`the baseline could not be written to ${target}: ${err.message}`)
4293
+ }
4294
+ if (figures.outsideRoot) warn(`${figures.outsideRoot} record(s) in the report lie outside the repository and were left out of the total`)
4295
+ emit(
4296
+ json,
4297
+ { ...baseline, path: target },
4298
+ `coverage baseline recorded: ${baseline.totalPct}% at ${sha.slice(0, 7)}${ref ? ` (${ref})` : ''} → ${target}\n`
4299
+ )
4300
+ } else if (sub === 'coverage') {
4301
+ // The coverage input the merge verdict reads, computed from the head
4302
+ // report, the baseline and the diff: figures only, no band (design D2).
4303
+ if (flags.base === undefined) {
4304
+ die('verify coverage: --base <ref> is required — the diff against it is the changed-line figure')
4305
+ }
4306
+ const base = baseRefOf(root, flags)
4307
+ // The body is `coverageInputFor`, which a ship run's close calls too. Here
4308
+ // a refusal is unreadable input: exit 1, nothing on stdout.
4309
+ const computed = coverageInputFor(root, base, flags)
4310
+ if (computed.reason) die(computed.reason)
4311
+ emit(json, computed.input, computed.text)
2695
4312
  } else if (sub === 'cluster') {
2696
4313
  const failures = readInput(flags.failures, '--failures <file|->')
2697
4314
  if (!Array.isArray(failures)) {
@@ -2709,7 +4326,7 @@ async function main() {
2709
4326
  if (action.action === 'halt') process.exit(1)
2710
4327
  } else {
2711
4328
  die(
2712
- `unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair|exec`
4329
+ `unknown verify subcommand: ${sub || '(none)'} — expected plan|judge|unit|spill|cluster|repair|exec|baseline|coverage`
2713
4330
  )
2714
4331
  }
2715
4332
  break
@@ -2841,6 +4458,15 @@ async function main() {
2841
4458
  return null
2842
4459
  }
2843
4460
  },
4461
+ readAtRef,
4462
+ // Git and coverage, as the close's merge-confidence gather reaches them
4463
+ // (complete-and-record-the-merge-verdict, D1 and D3): the same
4464
+ // functions the `merge-confidence` command hands its own gather.
4465
+ diffShape,
4466
+ nameStatus,
4467
+ listFiles,
4468
+ coverageInput: (repoRoot, { base } = {}) =>
4469
+ coverageInputFor(repoRoot, base, { 'state-home': flags['state-home'] }),
2844
4470
  snapshotTree,
2845
4471
  worktreeHead,
2846
4472
  logWaveMutation,