@renzrollon/interlock 1.5.0 → 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.
- package/.claude-plugin/plugin.json +12 -1
- package/CHANGELOG.md +104 -0
- package/README.md +7 -8
- package/bin/interlock +1655 -29
- package/hooks/mod.mjs +21 -20
- package/hooks/notify-option.mjs +69 -0
- package/hooks/preflight.mjs +6 -42
- package/lib/conformance.mjs +75 -0
- package/lib/coverage.mjs +387 -0
- package/lib/doctor.mjs +17 -13
- package/lib/drift.mjs +36 -12
- package/lib/findings.mjs +40 -3
- package/lib/graph/extract/openspec.mjs +6 -15
- package/lib/limits.mjs +78 -1
- package/lib/merge-confidence.mjs +1302 -0
- package/lib/merge-evidence.mjs +527 -0
- package/lib/notify.mjs +10 -20
- package/lib/outcomes.mjs +26 -5
- package/lib/pipeline.mjs +380 -0
- package/lib/prompts/dimensions.mjs +24 -2
- package/lib/prompts/domain-context.mjs +271 -0
- package/lib/prompts/handoff.mjs +32 -1
- package/lib/prompts/index.mjs +57 -0
- package/lib/prompts/remediate.mjs +29 -5
- package/lib/prompts/review.mjs +21 -7
- package/lib/prompts/schemas.mjs +2 -0
- package/lib/ready.mjs +13 -1
- package/lib/receipt.mjs +72 -2
- package/lib/review-core.mjs +224 -21
- package/lib/risk.mjs +8 -0
- package/lib/run-log.mjs +44 -0
- package/lib/run.mjs +859 -27
- package/lib/spec-audit.mjs +232 -0
- package/lib/spec-coverage.mjs +105 -0
- package/lib/spec-text.mjs +257 -0
- package/lib/verify.mjs +17 -4
- package/package.json +1 -1
- package/shared/SESSION-SHAPES.md +1 -1
- package/shared/TEST-PROFILE.md +39 -2
- package/skills/mr/SKILL.md +161 -11
- package/skills/review-code/REVIEW.template.md +24 -6
- package/skills/review-code/SKILL.md +16 -6
- package/skills/review-code/dimensions/domain.md +21 -0
package/lib/receipt.mjs
CHANGED
|
@@ -87,6 +87,35 @@ export function tailFromManifest(manifest) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The merge-confidence block a receipt carries (design D11): the verdict and its
|
|
92
|
+
* shape, and no text. The close hands over a whole assessment whose reasons and
|
|
93
|
+
* evidence lines can quote a finding's title and the file it names, so this
|
|
94
|
+
* reduces it to the verdict, the score, the worst band, the counts, the ids of
|
|
95
|
+
* the signals that drove it and the flag names. `undefined` when the run did not
|
|
96
|
+
* assess — never a zero score and never a default verdict.
|
|
97
|
+
*
|
|
98
|
+
* Import-free like the rest of this module; `lib/run-log.mjs`'s whitelist bounds
|
|
99
|
+
* and validates the same fields again on the way to disk.
|
|
100
|
+
*/
|
|
101
|
+
function mergeConfidenceBlock(assessment) {
|
|
102
|
+
if (!assessment || typeof assessment !== 'object' || Array.isArray(assessment)) return undefined
|
|
103
|
+
const ids = list =>
|
|
104
|
+
(Array.isArray(list) ? list : [])
|
|
105
|
+
.map(entry => (entry && typeof entry === 'object' ? entry.id : entry))
|
|
106
|
+
.filter(id => typeof id === 'string' && id.length > 0)
|
|
107
|
+
return {
|
|
108
|
+
verdict: assessment.verdict,
|
|
109
|
+
score: assessment.score,
|
|
110
|
+
worstBand: assessment.worstBand,
|
|
111
|
+
counts: assessment.counts && typeof assessment.counts === 'object' ? { ...assessment.counts } : undefined,
|
|
112
|
+
driving: ids(assessment.driving),
|
|
113
|
+
flags: (Array.isArray(assessment.flags) ? assessment.flags : [])
|
|
114
|
+
.filter(f => f && typeof f === 'object' && typeof f.id === 'string' && typeof f.flag === 'string')
|
|
115
|
+
.map(f => ({ id: f.id, flag: f.flag }))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
/**
|
|
91
120
|
* The run's receipt payload: what this run observed about itself, in the field
|
|
92
121
|
* names `lib/run-log.mjs`'s `run-receipt` whitelist copies by name.
|
|
@@ -108,7 +137,7 @@ export function tailFromManifest(manifest) {
|
|
|
108
137
|
* lanesStoppedByHost?: number, schemaResultsMissing?: number,
|
|
109
138
|
* toolsDeniedInLanes?: number, modelSubstitutions?: number,
|
|
110
139
|
* permissionPrompts?: number, autoModeDenials?: number,
|
|
111
|
-
* surface?: string, stateHome?: string
|
|
140
|
+
* surface?: string, stateHome?: string, mergeConfidence?: object
|
|
112
141
|
* }} input the six counts are passed only when the run observed agents at all
|
|
113
142
|
* (a host record or a sidecar); leaving one out records it as unobserved
|
|
114
143
|
*/
|
|
@@ -225,6 +254,7 @@ export function buildReceipt(input) {
|
|
|
225
254
|
capExhaustedVerifications: closing ? closing.capExhaustedVerifications : undefined,
|
|
226
255
|
unresolvedErrors: closing ? closing.unresolvedErrors : undefined,
|
|
227
256
|
leftoverTaskIds: source.leftoverTaskIds,
|
|
257
|
+
mergeConfidence: mergeConfidenceBlock(source.mergeConfidence),
|
|
228
258
|
halted: Boolean(summary.halted),
|
|
229
259
|
haltReason: summary.halted || undefined,
|
|
230
260
|
// Tri-state, and the reason the field is not just the sha: a run told not to
|
|
@@ -373,7 +403,8 @@ export function formatRunSummary({
|
|
|
373
403
|
stateHome = undefined,
|
|
374
404
|
push = null,
|
|
375
405
|
unarchived = null,
|
|
376
|
-
resumeCard = null
|
|
406
|
+
resumeCard = null,
|
|
407
|
+
mergeConfidence = null
|
|
377
408
|
} = {}) {
|
|
378
409
|
const leftover = Array.isArray(leftoverTaskIds) ? leftoverTaskIds : []
|
|
379
410
|
const lines = []
|
|
@@ -439,6 +470,19 @@ export function formatRunSummary({
|
|
|
439
470
|
(unconfirmed ? ` — ${unconfirmed} unconfirmed, see conformance.md` : '')
|
|
440
471
|
)
|
|
441
472
|
}
|
|
473
|
+
// The living requirements, counted on their own terms beside the scenarios
|
|
474
|
+
// (design D5). Absent when the count is: a run that was not asked about
|
|
475
|
+
// them, or whose handoff did not report, prints nothing rather than 0/0.
|
|
476
|
+
const requirementsChecked = summary.handoff.requirementsChecked
|
|
477
|
+
if (Number.isFinite(requirementsChecked) && requirementsChecked > 0) {
|
|
478
|
+
const unconfirmedReqs = Number.isFinite(summary.handoff.requirementsUnconfirmed)
|
|
479
|
+
? summary.handoff.requirementsUnconfirmed
|
|
480
|
+
: 0
|
|
481
|
+
lines.push(
|
|
482
|
+
` living requirements: ${requirementsChecked - unconfirmedReqs}/${requirementsChecked} confirmed` +
|
|
483
|
+
(unconfirmedReqs ? ` — ${unconfirmedReqs} unconfirmed, see conformance.md` : '')
|
|
484
|
+
)
|
|
485
|
+
}
|
|
442
486
|
}
|
|
443
487
|
if (summary.commit && summary.commit.ok) lines.push(` commit: ${summary.commit.sha || 'created'}`)
|
|
444
488
|
for (const note of summary.notes || []) lines.push(` ${note}`)
|
|
@@ -462,6 +506,32 @@ export function formatRunSummary({
|
|
|
462
506
|
lines.push(` state home: ${stateHome}`)
|
|
463
507
|
}
|
|
464
508
|
|
|
509
|
+
// The merge-confidence block (design D10), on a completed run only: a halt
|
|
510
|
+
// never assessed, and a line here would read as if it had. The close hands
|
|
511
|
+
// over the headline already rendered and the ids already grouped, so this
|
|
512
|
+
// module derives neither. A group prints only when it has an entry; reasons
|
|
513
|
+
// and evidence stay in the assessment file.
|
|
514
|
+
if (!summary.halted && mergeConfidence && typeof mergeConfidence === 'object') {
|
|
515
|
+
const mc = mergeConfidence
|
|
516
|
+
if (typeof mc.headline === 'string' && mc.headline.length > 0) {
|
|
517
|
+
lines.push(mc.headline)
|
|
518
|
+
const groups = mc.groups && typeof mc.groups === 'object' ? mc.groups : {}
|
|
519
|
+
const rows = [
|
|
520
|
+
['needs a fix', groups.fix],
|
|
521
|
+
['needs a person', groups.person],
|
|
522
|
+
['needs more evidence', groups.evidence]
|
|
523
|
+
]
|
|
524
|
+
for (const [label, ids] of rows) {
|
|
525
|
+
if (Array.isArray(ids) && ids.length) lines.push(` ${label}: ${ids.join(', ')}`)
|
|
526
|
+
}
|
|
527
|
+
const flagged = (Array.isArray(mc.flags) ? mc.flags : []).filter(f => f && f.id && f.flag)
|
|
528
|
+
if (flagged.length) lines.push(` flags: ${flagged.map(f => `${f.id} ${f.flag}`).join(', ')}`)
|
|
529
|
+
if (typeof mc.assessmentPath === 'string' && mc.assessmentPath.length > 0) {
|
|
530
|
+
lines.push(` assessment: ${mc.assessmentPath}`)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
465
535
|
// The push outcome (design D5): a row only when a push was actually
|
|
466
536
|
// attempted — an unconfigured relay is an optional feature left off, not a
|
|
467
537
|
// degraded run, so it prints nothing here.
|
package/lib/review-core.mjs
CHANGED
|
@@ -32,12 +32,15 @@ import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
|
32
32
|
import { join } from 'node:path'
|
|
33
33
|
|
|
34
34
|
import { LIMITS } from './limits.mjs'
|
|
35
|
-
import { SEVERITIES, TOLERANCE_BAND, applyToleranceBand } from './findings.mjs'
|
|
35
|
+
import { SEVERITIES, TOLERANCE_BAND, applyToleranceBand, normalizeSeverity } from './findings.mjs'
|
|
36
36
|
// The locator vocabulary lives in one module because a second surface now
|
|
37
37
|
// audits citations the same way (`auditHandoffEvidence` in lib/waves.mjs). Two
|
|
38
38
|
// definitions of "what a locator looks like" drift silently, each still passing
|
|
39
39
|
// its own tests. See lib/locators.mjs for what is shared and what is not.
|
|
40
40
|
import { CITATION_TOKEN, canonicalizePath, citesLineIn, diffIndex } from './locators.mjs'
|
|
41
|
+
// The one canonical requirement-heading form (design D1): a citation and the
|
|
42
|
+
// spec text it names are compared on `requirementKey`, never on raw spelling.
|
|
43
|
+
import { requirementKey } from './spec-text.mjs'
|
|
41
44
|
|
|
42
45
|
// Accept the same three input shapes `evaluateGate` accepts (bare array, one
|
|
43
46
|
// `{dimension, findings}` object, or an array of those). `applyToleranceBand`
|
|
@@ -205,11 +208,16 @@ function numericScore(value) {
|
|
|
205
208
|
* `REVIEW.md`); a finding whose canonical file lies at or under one is dropped
|
|
206
209
|
* BEFORE the band is applied, so exclusions shrink the input but never alter
|
|
207
210
|
* the arithmetic on what remains (add-interlock-review-policy spec §5).
|
|
211
|
+
* `requirementHeadings` maps each cited spec path (canonical) to that file's
|
|
212
|
+
* requirement keys, or to `{ unreadable: reason }`; with it, a finding's
|
|
213
|
+
* `requirement` citation is shape-checked (see `checkRequirementCitation`).
|
|
214
|
+
* A failed check is reported under `anomalies.requirementCitations` and
|
|
215
|
+
* changes nothing about how the finding is adjudicated.
|
|
208
216
|
* @returns {{
|
|
209
217
|
* surviving: Array, dismissed: Array, droppedByQuality: Array, droppedByPolicy: Array,
|
|
210
218
|
* counts: {raised: number, dismissed: number, droppedByQuality: number, droppedByPolicy: number, surviving: number},
|
|
211
219
|
* dimensionStats: Object<string, {raised: number, surviving: number}>,
|
|
212
|
-
* anomalies: {orphanVerdicts: Array, malformedVerdicts: Array}
|
|
220
|
+
* anomalies: {orphanVerdicts: Array, malformedVerdicts: Array, requirementCitations: Array}
|
|
213
221
|
* }}
|
|
214
222
|
*/
|
|
215
223
|
export function resolveReview(findings, verdicts, opts = {}) {
|
|
@@ -233,6 +241,24 @@ export function resolveReview(findings, verdicts, opts = {}) {
|
|
|
233
241
|
else all.push(finding)
|
|
234
242
|
}
|
|
235
243
|
|
|
244
|
+
// Requirement citations are checked on every finding that reaches
|
|
245
|
+
// adjudication, before survival is decided and without feeding into it: the
|
|
246
|
+
// check is a SHAPE check, a broken citation must never delete a real defect,
|
|
247
|
+
// and whether the cited requirement forbids the diff is the skeptics' question.
|
|
248
|
+
const requirementCitations = []
|
|
249
|
+
for (const finding of all) {
|
|
250
|
+
const problem = checkRequirementCitation(finding, opts.requirementHeadings)
|
|
251
|
+
if (problem) {
|
|
252
|
+
requirementCitations.push({
|
|
253
|
+
dimension: finding.dimension || 'unknown',
|
|
254
|
+
file: typeof finding.file === 'string' ? finding.file : null,
|
|
255
|
+
title: titleOf(finding),
|
|
256
|
+
requirement: typeof finding.requirement === 'string' ? finding.requirement : null,
|
|
257
|
+
problem
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
236
262
|
// Index verdicts by what they claim to adjudicate: title, plus file when the
|
|
237
263
|
// skeptic named one.
|
|
238
264
|
const byKey = new Map()
|
|
@@ -354,7 +380,7 @@ export function resolveReview(findings, verdicts, opts = {}) {
|
|
|
354
380
|
droppedByPolicy,
|
|
355
381
|
counts,
|
|
356
382
|
dimensionStats,
|
|
357
|
-
anomalies: { orphanVerdicts, malformedVerdicts }
|
|
383
|
+
anomalies: { orphanVerdicts, malformedVerdicts, requirementCitations }
|
|
358
384
|
}
|
|
359
385
|
}
|
|
360
386
|
|
|
@@ -375,12 +401,82 @@ export function resolveReview(findings, verdicts, opts = {}) {
|
|
|
375
401
|
* root) is not excluded: an exclusion is an affirmative scope decision, and a
|
|
376
402
|
* path we cannot place cannot be affirmatively matched.
|
|
377
403
|
*/
|
|
378
|
-
function pathExcludedBy(file, canonicalPrefix) {
|
|
404
|
+
export function pathExcludedBy(file, canonicalPrefix) {
|
|
379
405
|
const canonicalFile = canonicalizePath(file)
|
|
380
406
|
if (!canonicalFile) return false
|
|
381
407
|
return canonicalFile === canonicalPrefix || canonicalFile.startsWith(canonicalPrefix + '/')
|
|
382
408
|
}
|
|
383
409
|
|
|
410
|
+
const SPEC_CITATION_PATH = /^openspec\/specs\/.+\/spec\.md$/
|
|
411
|
+
|
|
412
|
+
// A cited file's entry in `requirementHeadings`: a Map or a plain object.
|
|
413
|
+
function headingsEntry(headings, path) {
|
|
414
|
+
if (headings instanceof Map) return headings.get(path)
|
|
415
|
+
if (headings && typeof headings === 'object') {
|
|
416
|
+
return Object.prototype.hasOwnProperty.call(headings, path) ? headings[path] : undefined
|
|
417
|
+
}
|
|
418
|
+
return undefined
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The shape check on one finding's requirement citation, or null when it passes
|
|
423
|
+
* (or there is nothing to check).
|
|
424
|
+
*
|
|
425
|
+
* A citation is `openspec/specs/<capability>/spec.md#<heading>`. Three checks,
|
|
426
|
+
* in order: the path is canonical, under `openspec/specs/` and names a
|
|
427
|
+
* `spec.md`; the file was read at the base (`requirementHeadings` has it, and it
|
|
428
|
+
* is not `{ unreadable }`); the heading, on `requirementKey`, names a
|
|
429
|
+
* requirement in it. Case is part of the heading: a different case is a
|
|
430
|
+
* different requirement, as OpenSpec itself reads it.
|
|
431
|
+
*
|
|
432
|
+
* A `domain` blocker with no citation is also a problem, because contradicting a
|
|
433
|
+
* requirement in force is the only blocker that dimension raises. A `domain`
|
|
434
|
+
* warning without one is not: behaviour no requirement describes has nothing to
|
|
435
|
+
* cite.
|
|
436
|
+
*/
|
|
437
|
+
function checkRequirementCitation(finding, headings) {
|
|
438
|
+
const cited = finding.requirement
|
|
439
|
+
const present = typeof cited === 'string' ? cited.trim() !== '' : cited !== undefined && cited !== null
|
|
440
|
+
if (!present) {
|
|
441
|
+
// On the canonical severity, as the gate and the merge verdict read it:
|
|
442
|
+
// "BLOCKER" blocks there, so it is a blocker here.
|
|
443
|
+
return finding.dimension === 'domain' && normalizeSeverity(finding.severity) === 'blocker'
|
|
444
|
+
? 'domain blocker cites no requirement'
|
|
445
|
+
: null
|
|
446
|
+
}
|
|
447
|
+
if (typeof cited !== 'string') return 'requirement citation is not a string'
|
|
448
|
+
|
|
449
|
+
const hash = cited.indexOf('#')
|
|
450
|
+
if (hash === -1) {
|
|
451
|
+
return `requirement citation "${cited}" has no #heading naming a requirement`
|
|
452
|
+
}
|
|
453
|
+
const path = canonicalizePath(cited.slice(0, hash).trim())
|
|
454
|
+
if (!path || !SPEC_CITATION_PATH.test(path)) {
|
|
455
|
+
return (
|
|
456
|
+
`requirement citation "${cited.slice(0, hash).trim()}" is not a canonical ` +
|
|
457
|
+
`openspec/specs/<capability>/spec.md path`
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const entry = headingsEntry(headings, path)
|
|
462
|
+
if (headings === undefined || headings === null) {
|
|
463
|
+
return `unchecked: ${path} was not read, so the cited requirement could not be checked`
|
|
464
|
+
}
|
|
465
|
+
if (entry === undefined || entry === null) {
|
|
466
|
+
return `unchecked: ${path} was not among the spec files read at the base`
|
|
467
|
+
}
|
|
468
|
+
if (!Array.isArray(entry) && !(entry instanceof Set)) {
|
|
469
|
+
const reason = typeof entry.unreadable === 'string' && entry.unreadable ? entry.unreadable : 'unreadable'
|
|
470
|
+
return `unchecked: ${path} could not be read at the base: ${reason}`
|
|
471
|
+
}
|
|
472
|
+
const wanted = requirementKey(cited.slice(hash + 1))
|
|
473
|
+
const keys = [...entry].map(requirementKey)
|
|
474
|
+
if (!wanted || !keys.includes(wanted)) {
|
|
475
|
+
return `names no requirement in ${path}: "${cited.slice(hash + 1).trim()}"`
|
|
476
|
+
}
|
|
477
|
+
return null
|
|
478
|
+
}
|
|
479
|
+
|
|
384
480
|
/** Human-readable review summary. */
|
|
385
481
|
export function formatReview(result) {
|
|
386
482
|
const { raised, dismissed, droppedByQuality, droppedByPolicy, surviving, dismissalsRejected } =
|
|
@@ -416,6 +512,11 @@ export function formatReview(result) {
|
|
|
416
512
|
}
|
|
417
513
|
|
|
418
514
|
const { orphanVerdicts, malformedVerdicts } = result.anomalies
|
|
515
|
+
for (const c of result.anomalies.requirementCitations || []) {
|
|
516
|
+
const where = c.file ? `${c.file} — ` : ''
|
|
517
|
+
const cite = c.requirement ? ` [${c.requirement}]` : ''
|
|
518
|
+
lines.push(` ANOMALY: requirement citation on ${where}${c.title || '(untitled)'}: ${c.problem}${cite}`)
|
|
519
|
+
}
|
|
419
520
|
if (orphanVerdicts.length) {
|
|
420
521
|
const titles = [...new Set(orphanVerdicts.map(v => v.findingTitle))]
|
|
421
522
|
lines.push(` ANOMALY: ${orphanVerdicts.length} verdict(s) matched no finding: ${titles.join(', ')}`)
|
|
@@ -439,7 +540,7 @@ export function formatReview(result) {
|
|
|
439
540
|
/** The one location that is read as policy: the repository root. */
|
|
440
541
|
export const REVIEW_POLICY_FILE = 'REVIEW.md'
|
|
441
542
|
|
|
442
|
-
const EMPTY_POLICY = () => ({ prose: '', excludePaths: [], owner: null, problems: [] })
|
|
543
|
+
const EMPTY_POLICY = () => ({ prose: '', excludePaths: [], humanPaths: [], owner: null, problems: [] })
|
|
443
544
|
|
|
444
545
|
// The only recognized front-matter key. The band and nit cap deliberately are
|
|
445
546
|
// NOT here — they live in the CLI so they are not re-argued (spec §5). Any other
|
|
@@ -465,6 +566,28 @@ function normalizeHeading(text) {
|
|
|
465
566
|
|
|
466
567
|
const PROSE_HEADINGS = new Set(['owner', 'what important means', 'exclusions rationale'])
|
|
467
568
|
const EXCLUSIONS_HEADING = 'do not report'
|
|
569
|
+
const HUMAN_REVIEW_HEADING = 'always human review'
|
|
570
|
+
|
|
571
|
+
// The two path-list sections share one grammar, one canonical form and one set
|
|
572
|
+
// of problem wordings; only the nouns differ. `humanPaths` is scope, read by the
|
|
573
|
+
// merge verdict alone — it never reaches `resolveReview` (design D3).
|
|
574
|
+
const EXCLUSIONS_SECTION = {
|
|
575
|
+
label: 'Do Not Report',
|
|
576
|
+
noPaths: 'no exclusions applied',
|
|
577
|
+
entry: 'exclusion',
|
|
578
|
+
nothing: 'excludes nothing'
|
|
579
|
+
}
|
|
580
|
+
const HUMAN_REVIEW_SECTION = {
|
|
581
|
+
label: 'Always Human Review',
|
|
582
|
+
noPaths: 'no paths declared',
|
|
583
|
+
entry: 'entry',
|
|
584
|
+
nothing: 'declares nothing'
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// What marks a list item as a pattern rather than a path (design D11 of
|
|
588
|
+
// label-and-harden-the-merge-verdict). Only these two: brackets and braces
|
|
589
|
+
// occur in real paths, such as a route directory.
|
|
590
|
+
const PATTERN_ITEM = /[*?]/
|
|
468
591
|
|
|
469
592
|
/**
|
|
470
593
|
* Read the repo-root `REVIEW.md` and parse it into a policy object.
|
|
@@ -475,7 +598,7 @@ const EXCLUSIONS_HEADING = 'do not report'
|
|
|
475
598
|
* run proceeds under default policy rather than parsing unbounded input.
|
|
476
599
|
*
|
|
477
600
|
* @param {string} repoRoot repository root to look under (defaults to cwd)
|
|
478
|
-
* @returns {{prose: string, excludePaths: string[], owner: string|null, problems: string[]}}
|
|
601
|
+
* @returns {{prose: string, excludePaths: string[], humanPaths: string[], owner: string|null, problems: string[]}}
|
|
479
602
|
*/
|
|
480
603
|
export function readReviewPolicy(repoRoot) {
|
|
481
604
|
const root = typeof repoRoot === 'string' && repoRoot.trim() ? repoRoot : '.'
|
|
@@ -509,7 +632,7 @@ export function readReviewPolicy(repoRoot) {
|
|
|
509
632
|
}
|
|
510
633
|
|
|
511
634
|
/**
|
|
512
|
-
* Parse `REVIEW.md` text into `{ prose, excludePaths, owner, problems }`.
|
|
635
|
+
* Parse `REVIEW.md` text into `{ prose, excludePaths, humanPaths, owner, problems }`.
|
|
513
636
|
*
|
|
514
637
|
* Each half is validated independently (design.md D3): a broken exclusions block
|
|
515
638
|
* does not stop valid prose from being returned, and a broken prose section does
|
|
@@ -561,11 +684,17 @@ export function parseReviewPolicy(text) {
|
|
|
561
684
|
for (const section of sections) {
|
|
562
685
|
const norm = normalizeHeading(section.heading)
|
|
563
686
|
if (norm === EXCLUSIONS_HEADING) {
|
|
564
|
-
const { paths, problems } =
|
|
687
|
+
const { paths, problems } = parsePathList(section.content, EXCLUSIONS_SECTION)
|
|
565
688
|
policy.excludePaths.push(...paths)
|
|
566
689
|
policy.problems.push(...problems)
|
|
567
690
|
continue
|
|
568
691
|
}
|
|
692
|
+
if (norm === HUMAN_REVIEW_HEADING) {
|
|
693
|
+
const { paths, problems } = parsePathList(section.content, HUMAN_REVIEW_SECTION)
|
|
694
|
+
policy.humanPaths.push(...paths)
|
|
695
|
+
policy.problems.push(...problems)
|
|
696
|
+
continue
|
|
697
|
+
}
|
|
569
698
|
if (PROSE_HEADINGS.has(norm)) {
|
|
570
699
|
const content = section.content.trim()
|
|
571
700
|
if (norm === 'owner') {
|
|
@@ -589,6 +718,7 @@ export function parseReviewPolicy(text) {
|
|
|
589
718
|
policy.owner = owner
|
|
590
719
|
// `./dist` and `dist/` canonicalize to one prefix; keep it once.
|
|
591
720
|
policy.excludePaths = [...new Set(policy.excludePaths)]
|
|
721
|
+
policy.humanPaths = [...new Set(policy.humanPaths)]
|
|
592
722
|
return policy
|
|
593
723
|
}
|
|
594
724
|
|
|
@@ -644,22 +774,32 @@ function splitSections(body) {
|
|
|
644
774
|
}
|
|
645
775
|
|
|
646
776
|
/**
|
|
647
|
-
* Parse a `## Do Not Report
|
|
777
|
+
* Parse a path-list section (`## Do Not Report`, `## Always Human Review`) into
|
|
778
|
+
* canonical path prefixes.
|
|
648
779
|
*
|
|
649
780
|
* Entries are markdown list items (`- path` / `* path`). Each is canonicalized
|
|
650
781
|
* with the same `canonicalizePath` findings go through, so a finding cannot slip
|
|
651
|
-
*
|
|
652
|
-
* (absolute, escaping the root)
|
|
653
|
-
* 1.3).
|
|
654
|
-
*
|
|
655
|
-
*
|
|
782
|
+
* past a listed prefix on an alternate spelling; an entry that cannot be
|
|
783
|
+
* canonicalized (absolute, escaping the root) declares nothing and is reported
|
|
784
|
+
* (spec §3, task 1.3). An entry shaped like a survival threshold (`- nitCap: 0`)
|
|
785
|
+
* is reported and never read as a path, in either section. So is an entry that
|
|
786
|
+
* holds `*` or `?`: the lists hold prefixes, not patterns, and a pattern read as
|
|
787
|
+
* a literal path matches no file, so `- src/**` would list nothing and say
|
|
788
|
+
* nothing. Square brackets and braces are characters a path may hold
|
|
789
|
+
* (`app/[id]/`) and are read as part of it. A structurally broken
|
|
790
|
+
* block — a fenced code block, or content with no list entries at all — is
|
|
791
|
+
* reported unusable as a whole and yields no paths, rather than being
|
|
792
|
+
* half-guessed (spec §4, the partially-valid file).
|
|
793
|
+
*
|
|
794
|
+
* @param {string} content the section body
|
|
795
|
+
* @param {{label: string, noPaths: string, entry: string, nothing: string}} section
|
|
656
796
|
*/
|
|
657
|
-
function
|
|
797
|
+
function parsePathList(content, section) {
|
|
658
798
|
const problems = []
|
|
659
799
|
if (/^\s*```/m.test(content)) {
|
|
660
800
|
problems.push(
|
|
661
|
-
`the "
|
|
662
|
-
`
|
|
801
|
+
`the "${section.label}" block contains a code fence and could not be parsed as a path list; ` +
|
|
802
|
+
`${section.noPaths}`
|
|
663
803
|
)
|
|
664
804
|
return { paths: [], problems }
|
|
665
805
|
}
|
|
@@ -681,32 +821,95 @@ function parseExclusions(content) {
|
|
|
681
821
|
// Non-blank content but not a single list item: the block is not a path list.
|
|
682
822
|
if (!entries.length) {
|
|
683
823
|
problems.push(
|
|
684
|
-
`the "
|
|
685
|
-
`
|
|
824
|
+
`the "${section.label}" block has content but no path list items (\`- path\`); ` +
|
|
825
|
+
`${section.noPaths}`
|
|
686
826
|
)
|
|
687
827
|
return { paths: [], problems }
|
|
688
828
|
}
|
|
689
829
|
if (sawNonEntry) {
|
|
690
830
|
problems.push(
|
|
691
|
-
`the "
|
|
831
|
+
`the "${section.label}" block mixes list items with non-list text; only the \`- path\` items ` +
|
|
692
832
|
`were read`
|
|
693
833
|
)
|
|
694
834
|
}
|
|
695
835
|
|
|
696
836
|
const paths = []
|
|
697
837
|
for (const entry of entries) {
|
|
838
|
+
if (BAND_KEY_LINE.test(entry)) {
|
|
839
|
+
problems.push(
|
|
840
|
+
`"${section.label}" item "${entry}" looks like a survival threshold — the band and nit cap ` +
|
|
841
|
+
`live in the CLI, not REVIEW.md; ignored, not read as a path`
|
|
842
|
+
)
|
|
843
|
+
continue
|
|
844
|
+
}
|
|
845
|
+
if (PATTERN_ITEM.test(entry)) {
|
|
846
|
+
// The problem names its section in the form the threshold-shaped one
|
|
847
|
+
// does: the merge verdict tells the two sections' problems apart by it.
|
|
848
|
+
problems.push(
|
|
849
|
+
`"${section.label}" item "${entry}" is a pattern, and only a path or a directory prefix is read ` +
|
|
850
|
+
`here; ignored, not read as a path (list the directory instead, such as \`src/\`)`
|
|
851
|
+
)
|
|
852
|
+
continue
|
|
853
|
+
}
|
|
698
854
|
const canonical = canonicalizePath(stripQuotes(entry.replace(/\/+$/, '')))
|
|
699
855
|
if (canonical) paths.push(canonical)
|
|
700
|
-
else problems.push(
|
|
856
|
+
else problems.push(`${section.entry} "${entry}" is not a repo-relative path and ${section.nothing}`)
|
|
701
857
|
}
|
|
702
858
|
return { paths, problems }
|
|
703
859
|
}
|
|
704
860
|
|
|
861
|
+
// Line endings and trailing newlines are not a policy difference.
|
|
862
|
+
function normalizePolicyText(text) {
|
|
863
|
+
if (typeof text !== 'string') return ''
|
|
864
|
+
const lf = text.replace(/\r\n?/g, '\n')
|
|
865
|
+
return lf.trim() ? lf.replace(/\s+$/, '') + '\n' : ''
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Compose the policy a review is judged by from two copies of `REVIEW.md`.
|
|
870
|
+
*
|
|
871
|
+
* The trust split `docs/12` draws decides where each part comes from. The two
|
|
872
|
+
* path lists are enforced, so a change must not edit the copy applied to it:
|
|
873
|
+
* they come from `base`. The prose is advice that cannot relax the gate, so it
|
|
874
|
+
* (and the owner it names) comes from `working` — an uncommitted edit still
|
|
875
|
+
* reaches the reviewer. `differsFromBase` says the two copies differ, so a
|
|
876
|
+
* caller can speak it; it is the only thing that makes an edited policy visible.
|
|
877
|
+
*
|
|
878
|
+
* Pure: each copy is `{ text, policy }` (`policy` as `parseReviewPolicy`
|
|
879
|
+
* returns it, `text` carried so line endings can be compared) or `null` for an
|
|
880
|
+
* absent file, which is the empty policy with no problem — absence is the off
|
|
881
|
+
* switch. Reading a file at a ref is I/O and lives with the caller.
|
|
882
|
+
*
|
|
883
|
+
* @param {{working: ?{text: string, policy: object}, base: ?{text: string, policy: object}, ref: ?string}} input
|
|
884
|
+
* @returns {{prose: string, owner: ?string, excludePaths: string[], humanPaths: string[],
|
|
885
|
+
* problems: string[], listsFrom: ?string, differsFromBase: boolean}}
|
|
886
|
+
*/
|
|
887
|
+
export function composePolicy({ working = null, base = null, ref = null } = {}) {
|
|
888
|
+
const workingPolicy = working?.policy ?? EMPTY_POLICY()
|
|
889
|
+
const basePolicy = base?.policy ?? EMPTY_POLICY()
|
|
890
|
+
const baseLabel = ref ? `(${ref})` : '(base)'
|
|
891
|
+
const problems = [
|
|
892
|
+
...(workingPolicy.problems || []).map(p => `${p} (working tree)`),
|
|
893
|
+
...(basePolicy.problems || []).map(p => `${p} ${baseLabel}`)
|
|
894
|
+
]
|
|
895
|
+
return {
|
|
896
|
+
prose: workingPolicy.prose || '',
|
|
897
|
+
owner: workingPolicy.owner ?? null,
|
|
898
|
+
excludePaths: [...(basePolicy.excludePaths || [])],
|
|
899
|
+
humanPaths: [...(basePolicy.humanPaths || [])],
|
|
900
|
+
problems,
|
|
901
|
+
listsFrom: ref ?? null,
|
|
902
|
+
differsFromBase: normalizePolicyText(working?.text) !== normalizePolicyText(base?.text)
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
705
906
|
/** Human-readable dump of a parsed policy, for `interlock review-policy`. */
|
|
706
907
|
export function formatReviewPolicy(policy) {
|
|
707
908
|
const lines = []
|
|
708
909
|
lines.push(`REVIEW POLICY — ${policy.owner ? `owner: ${policy.owner}` : 'no owner declared'}`)
|
|
709
910
|
lines.push(` exclusions: ${policy.excludePaths.length ? policy.excludePaths.join(', ') : '(none)'}`)
|
|
911
|
+
const humanPaths = policy.humanPaths || []
|
|
912
|
+
lines.push(` human-review: ${humanPaths.length ? humanPaths.join(', ') : '(none)'}`)
|
|
710
913
|
lines.push(` prose: ${policy.prose ? `${policy.prose.length} char(s)` : '(none)'}`)
|
|
711
914
|
for (const problem of policy.problems) lines.push(` PROBLEM: ${problem}`)
|
|
712
915
|
return lines.join('\n') + '\n'
|
package/lib/risk.mjs
CHANGED
|
@@ -45,6 +45,14 @@ const RANK = { low: 0, medium: 1, high: 2, critical: 3 }
|
|
|
45
45
|
// to strict (['low']) or loose (['low','medium','high']) is this one edit.
|
|
46
46
|
export const CONTINUITY_ALLOWED_CLASSES = Object.freeze(['low', 'medium'])
|
|
47
47
|
|
|
48
|
+
// The classes the merge verdict (`lib/merge-confidence.mjs`, signal
|
|
49
|
+
// `risk.class`) reads green; anything above them is amber, marked as needing a
|
|
50
|
+
// person, and never red. The same two classes as continuity today, but a
|
|
51
|
+
// separate constant on purpose: skipping the spec checkpoint and merging a pull
|
|
52
|
+
// request are different decisions, and re-tuning one must not silently move the
|
|
53
|
+
// other. The two policies may diverge.
|
|
54
|
+
export const MERGE_ALLOWED_CLASSES = Object.freeze(['low', 'medium'])
|
|
55
|
+
|
|
48
56
|
// Where an input with no usable signal at all lands. Deliberately outside
|
|
49
57
|
// CONTINUITY_ALLOWED_CLASSES: "we could not tell" must stop the machine.
|
|
50
58
|
export const UNCLASSIFIABLE_CLASS = 'high'
|
package/lib/run-log.mjs
CHANGED
|
@@ -92,6 +92,13 @@ export const MODEL_SCOPES = Object.freeze(['turns', 'session'])
|
|
|
92
92
|
*/
|
|
93
93
|
export const RUN_SURFACES = Object.freeze(['main', 'linked-worktree', 'lane-worktree', 'unknown'])
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The three words a merge verdict may be recorded as on a receipt (design D11).
|
|
97
|
+
* Mirrors `lib/merge-confidence.mjs`'s published verdicts; restated here because
|
|
98
|
+
* this module's whitelist is the place a stray word is refused.
|
|
99
|
+
*/
|
|
100
|
+
export const MERGE_VERDICTS = Object.freeze(['MERGE', 'HUMAN', 'BLOCK'])
|
|
101
|
+
|
|
95
102
|
/** The two modes a run starts in — mirrors `lib/outcomes.mjs`'s `MODES`. */
|
|
96
103
|
export const RUN_MODES = Object.freeze(['checkpoint', 'continue'])
|
|
97
104
|
|
|
@@ -390,6 +397,39 @@ function agentKind(value) {
|
|
|
390
397
|
return enumOf(AGENT_KINDS, value, 'other')
|
|
391
398
|
}
|
|
392
399
|
|
|
400
|
+
// The merge-confidence block (design D11), built field by field. The source at
|
|
401
|
+
// the close is a whole assessment whose reasons and evidence lines can quote a
|
|
402
|
+
// finding's title and the file it names, so nothing is spread and the only
|
|
403
|
+
// strings kept are the verdict (against the three published words), the band,
|
|
404
|
+
// signal ids and flag names, each bounded. `null` when the receipt carries no
|
|
405
|
+
// block: a run that never assessed is not a run with a zero score.
|
|
406
|
+
const MERGE_COUNT_KEYS = Object.freeze(['gating', 'clean', 'green', 'amber', 'red', 'unobserved', 'flagged'])
|
|
407
|
+
|
|
408
|
+
function mergeCounts(value) {
|
|
409
|
+
const source = isObject(value) ? value : {}
|
|
410
|
+
const out = {}
|
|
411
|
+
for (const key of MERGE_COUNT_KEYS) out[key] = nullableCount(source[key])
|
|
412
|
+
return out
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function mergeConfidenceBlock(value) {
|
|
416
|
+
if (!isObject(value)) return null
|
|
417
|
+
return {
|
|
418
|
+
verdict: enumOf(MERGE_VERDICTS, value.verdict, null),
|
|
419
|
+
score: nullableCount(value.score),
|
|
420
|
+
worstBand: nullableText(value.worstBand, MAX_NAME),
|
|
421
|
+
counts: mergeCounts(value.counts),
|
|
422
|
+
driving: Array.isArray(value.driving)
|
|
423
|
+
? value.driving.filter(v => typeof v === 'string' && v.trim()).map(v => text(v, MAX_NAME))
|
|
424
|
+
: [],
|
|
425
|
+
flags: Array.isArray(value.flags)
|
|
426
|
+
? value.flags
|
|
427
|
+
.filter(f => isObject(f) && typeof f.id === 'string' && f.id.trim() && typeof f.flag === 'string' && f.flag.trim())
|
|
428
|
+
.map(f => ({ id: text(f.id, MAX_NAME), flag: text(f.flag, MAX_NAME) }))
|
|
429
|
+
: []
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
393
433
|
function stringArray(value, max = MAX_TEXT) {
|
|
394
434
|
if (!Array.isArray(value)) return []
|
|
395
435
|
return value.filter(v => typeof v === 'string').map(v => text(v, max))
|
|
@@ -587,6 +627,10 @@ const TYPE_FIELDS = {
|
|
|
587
627
|
capExhaustedVerifications: source => nullableCount(source.capExhaustedVerifications),
|
|
588
628
|
unresolvedErrors: source => nullableCount(source.unresolvedErrors),
|
|
589
629
|
leftoverTaskIds: source => stringArray(source.leftoverTaskIds, MAX_NAME),
|
|
630
|
+
// The merge verdict and its shape, no text (design D11): ids, counts and
|
|
631
|
+
// flag names only. The receipt module hands over a block already reduced;
|
|
632
|
+
// this whitelist is what holds if a caller hands a whole assessment.
|
|
633
|
+
mergeConfidence: source => mergeConfidenceBlock(source.mergeConfidence),
|
|
590
634
|
// Output-token spend, per wave and for the run. The figure is a cumulative
|
|
591
635
|
// process-wide delta over the wave's span, so it includes the
|
|
592
636
|
// orchestrator's own turns as well as the implementers' — it is the wave's
|