@renzrollon/interlock 1.0.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/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/ready.mjs
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
// The continuity gate (§F1, §F6.3) — may this change skip the human checkpoint?
|
|
2
|
+
//
|
|
3
|
+
// Everywhere else in Interlock a human reads the artifacts before ship starts.
|
|
4
|
+
// The continuity path (WS-F) can skip that read. A gate that removes a person
|
|
5
|
+
// from the loop cannot be a paragraph in a skill: a model asked "are these
|
|
6
|
+
// artifacts implementable?" answers from vibes, differently every run, and the
|
|
7
|
+
// one run where it answers optimistically is the run nobody checked. So the
|
|
8
|
+
// checklist in §F1 lives here, as code, and every answer carries its evidence.
|
|
9
|
+
//
|
|
10
|
+
// Three properties are load-bearing:
|
|
11
|
+
//
|
|
12
|
+
// 1. FAIL CLOSED, WITHOUT EXCEPTION. `ready` is true only when every
|
|
13
|
+
// implemented check affirmatively passed. A check that could not run is
|
|
14
|
+
// not a pass — an unreadable ledger, an artifact that would not read, a
|
|
15
|
+
// classifier that threw, all become blockers. Nothing in this module can
|
|
16
|
+
// turn an error into permission.
|
|
17
|
+
//
|
|
18
|
+
// 2. AN ABSENT INPUT IS NOT A CLEAN INPUT. The two ways this gate would
|
|
19
|
+
// otherwise be silently defeated are a missing `decisions.md` ("nobody
|
|
20
|
+
// recorded anything, so nothing needs a human") and a caller that simply
|
|
21
|
+
// forgets to pass the artifact-review result ("no blockers were reported,
|
|
22
|
+
// so there are no blockers"). Both are blockers here, by name.
|
|
23
|
+
//
|
|
24
|
+
// 3. COMPOSITION, NOT REIMPLEMENTATION. Artifact structure is
|
|
25
|
+
// `lib/artifacts.mjs`, the ledger audit is `lib/ledger.mjs`, blast radius
|
|
26
|
+
// is `lib/risk.mjs`. This module owns only the *policy* that combines
|
|
27
|
+
// them — §4.14 "balanced", encoded once in `STRICTNESS` so re-tuning to
|
|
28
|
+
// strict or loose is a single edit rather than a hunt.
|
|
29
|
+
//
|
|
30
|
+
// Deliberately unimplemented checks (§F1 items A, part of C, E's agent-tool
|
|
31
|
+
// probe, and all of F) are reported as `status: 'skip'` with a reason rather
|
|
32
|
+
// than omitted, so the printed checklist never implies they passed. They do
|
|
33
|
+
// not block: continuity knowingly accepts wrong-idea risk (§F0) in exchange
|
|
34
|
+
// for speed, and a skip that blocked would make the gate unreachable forever.
|
|
35
|
+
// A skip is "we chose not to check this"; a fail is "we tried and could not".
|
|
36
|
+
//
|
|
37
|
+
// Exposed to skills as `interlock ready --change <name> --json`.
|
|
38
|
+
|
|
39
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
40
|
+
import { join } from 'node:path'
|
|
41
|
+
import { inspectChange, parseTasks, resolveChange } from './artifacts.mjs'
|
|
42
|
+
import { readLedger, summarize } from './ledger.mjs'
|
|
43
|
+
import { evaluateGate } from './findings.mjs'
|
|
44
|
+
import { CONTINUITY_ALLOWED_CLASSES, classifyRisk } from './risk.mjs'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* §4.14 **balanced**, and nothing else. Every threshold the gate applies is a
|
|
48
|
+
* field here: re-tuning to (strict) or (loose) is one edit to this object, not
|
|
49
|
+
* a search through the checks below.
|
|
50
|
+
*/
|
|
51
|
+
export const STRICTNESS = Object.freeze({
|
|
52
|
+
/** Which §4.14 option this encodes. Printed, so a run says what it obeyed. */
|
|
53
|
+
name: 'balanced',
|
|
54
|
+
/** Artifact-review blockers tolerated. */
|
|
55
|
+
maxArtifactBlockers: 0,
|
|
56
|
+
/** Balanced: warnings are reported and do not block. Strict would flip this. */
|
|
57
|
+
artifactWarningsBlock: false,
|
|
58
|
+
/** `needs_human` ledger rows tolerated. */
|
|
59
|
+
maxNeedsHuman: 0,
|
|
60
|
+
/** Malformed / unsubstantiated ledger rows tolerated (see lib/ledger.mjs). */
|
|
61
|
+
maxInvalidDecisions: 0,
|
|
62
|
+
/** A change with no ledger at all has recorded no decisions — that blocks. */
|
|
63
|
+
requireLedger: true,
|
|
64
|
+
/** Risk classes that may continue past the checkpoint. */
|
|
65
|
+
allowedRiskClasses: CONTINUITY_ALLOWED_CLASSES,
|
|
66
|
+
/** `.claude/testing/profile.json` must exist before ship runs unattended. */
|
|
67
|
+
requireTestProfile: true
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/** Where the test profile lives, relative to the repo root. */
|
|
71
|
+
export const TEST_PROFILE_PATH = join('.claude', 'testing', 'profile.json')
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Task phrases that describe an intention rather than a change (§F1 C). A task
|
|
75
|
+
* carrying one of these is not implementable by an agent that cannot ask a
|
|
76
|
+
* question, so it blocks continuity. A trailing `.` in a phrase is optional
|
|
77
|
+
* when matching, so `etc.` also catches a bare `etc`.
|
|
78
|
+
*/
|
|
79
|
+
export const WEASEL_PHRASES = Object.freeze([
|
|
80
|
+
'appropriately',
|
|
81
|
+
'as needed',
|
|
82
|
+
'etc.',
|
|
83
|
+
'handle edge cases',
|
|
84
|
+
'and so on',
|
|
85
|
+
'where applicable',
|
|
86
|
+
'if necessary',
|
|
87
|
+
'update accordingly'
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The single escape hatch for the weasel lint: a task may keep a vague phrase
|
|
92
|
+
* when it also states what "done" means. One greppable marker rather than a
|
|
93
|
+
* heuristic, so nobody has to guess whether their wording qualifies.
|
|
94
|
+
*/
|
|
95
|
+
export const ACCEPTANCE_MARKER = /\bacceptance\s*:/i
|
|
96
|
+
|
|
97
|
+
/** The §F1 escape hatch for a scenario a task covers without naming it. */
|
|
98
|
+
export const TASK_COVERAGE_MARKER = /\bcovered\s+by\s+tasks?\s*[:#]?\s*\d[\d.,\s]*/i
|
|
99
|
+
|
|
100
|
+
/** Artifacts read from the change directory when the caller supplies none. */
|
|
101
|
+
const ARTIFACT_FILES = Object.freeze(['proposal.md', 'design.md', 'tasks.md'])
|
|
102
|
+
|
|
103
|
+
// Tolerate a mis-levelled heading: §F1 says `#### Scenario:`, but a scenario
|
|
104
|
+
// written with three hashes is still a scenario, and skipping it would be a
|
|
105
|
+
// silent hole in the mapping check.
|
|
106
|
+
const SCENARIO_HEADING = /^#{2,6}\s*Scenario\s*:\s*(.+?)\s*$/i
|
|
107
|
+
const ANY_HEADING = /^#{1,6}\s/
|
|
108
|
+
|
|
109
|
+
// How much of an offending line is quoted back as evidence.
|
|
110
|
+
const EVIDENCE_CHARS = 160
|
|
111
|
+
// Scenario titles shorter than this are too generic to match against a task by
|
|
112
|
+
// substring, so they must use the explicit coverage marker instead.
|
|
113
|
+
const MIN_TITLE_CHARS = 3
|
|
114
|
+
|
|
115
|
+
function fail(message) {
|
|
116
|
+
const err = new Error(message)
|
|
117
|
+
err.userFacing = true
|
|
118
|
+
throw err
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function messageOf(err) {
|
|
122
|
+
return (err && err.message) || String(err)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clip(text, max = EVIDENCE_CHARS) {
|
|
126
|
+
const s = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
127
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Normalised for substring comparison: case, punctuation and spacing all stop
|
|
131
|
+
// mattering, so "Scenario: User signs in" matches a task written "user signs-in".
|
|
132
|
+
function normalize(text) {
|
|
133
|
+
return String(text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function escapeRegExp(s) {
|
|
137
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const WEASEL_PATTERNS = WEASEL_PHRASES.map(phrase => ({
|
|
141
|
+
phrase,
|
|
142
|
+
// A trailing literal dot becomes optional; word-ish boundaries stop
|
|
143
|
+
// "appropriately" from firing inside a longer identifier.
|
|
144
|
+
re: new RegExp(`(^|[^\\w])${escapeRegExp(phrase).replace(/\\\.$/, '\\.?')}(?![\\w])`, 'i')
|
|
145
|
+
}))
|
|
146
|
+
|
|
147
|
+
// --- accumulator ---------------------------------------------------------
|
|
148
|
+
//
|
|
149
|
+
// Every check pushes exactly one entry, so the printed checklist has one line
|
|
150
|
+
// per §F1 item whatever happened.
|
|
151
|
+
|
|
152
|
+
function pass(run, id, detail) {
|
|
153
|
+
run.checks.push({ id, status: 'pass', detail })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function skip(run, id, detail) {
|
|
157
|
+
run.checks.push({ id, status: 'skip', detail })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function warn(run, code, message) {
|
|
161
|
+
run.warnings.push({ code, message })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function block(run, id, code, message, evidence = []) {
|
|
165
|
+
run.checks.push({ id, status: 'fail', detail: message })
|
|
166
|
+
run.blockers.push({ code, message, evidence: evidence.slice(0, 10) })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A gather step failed: mark every check that depended on it as failed, and
|
|
170
|
+
// raise ONE blocker naming the cause. Dependent checks are 'fail', never
|
|
171
|
+
// 'skip' — they were meant to run.
|
|
172
|
+
function blockAll(run, ids, code, message, evidence = []) {
|
|
173
|
+
for (const id of ids) run.checks.push({ id, status: 'fail', detail: message })
|
|
174
|
+
run.blockers.push({ code, message, evidence: evidence.slice(0, 10) })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Run an evidence-gathering step without letting it escape. An exception here
|
|
178
|
+
// must become a blocker, never an unhandled throw and never a silent pass.
|
|
179
|
+
function attempt(fn) {
|
|
180
|
+
try {
|
|
181
|
+
return { ok: true, value: fn(), error: null }
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return { ok: false, value: null, error: messageOf(err) }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- evidence gathering --------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/** Exported for `lib/conformance.mjs`, for the same anti-drift reason as `extractScenarios`. */
|
|
190
|
+
export function readArtifactsFromDisk(inspection) {
|
|
191
|
+
const out = {}
|
|
192
|
+
for (const name of ARTIFACT_FILES) {
|
|
193
|
+
const p = join(inspection.path, name)
|
|
194
|
+
if (existsSync(p)) out[name] = readFileSync(p, 'utf8')
|
|
195
|
+
}
|
|
196
|
+
for (const rel of inspection.specFiles || []) {
|
|
197
|
+
const p = join(inspection.path, 'specs', rel)
|
|
198
|
+
if (existsSync(p)) out[`specs/${rel}`] = readFileSync(p, 'utf8')
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A caller-supplied artifact map is filtered to strings: a stray object would
|
|
204
|
+
// otherwise reach the risk classifier's text matching as "[object Object]".
|
|
205
|
+
function normalizeArtifacts(input) {
|
|
206
|
+
const out = {}
|
|
207
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return out
|
|
208
|
+
for (const key of Object.keys(input)) {
|
|
209
|
+
if (typeof input[key] === 'string') out[key] = input[key]
|
|
210
|
+
}
|
|
211
|
+
return out
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Compute the blocker count from the artifact review's own findings.
|
|
216
|
+
*
|
|
217
|
+
* This is the whole of §continuity-provenance. The number that decides whether
|
|
218
|
+
* a human reads a specification used to be written by the party being gated —
|
|
219
|
+
* `skills/spec/continuity.md` had the agent `printf` it into a file, and this
|
|
220
|
+
* module validated its *shape* but never its *provenance*. Meanwhile
|
|
221
|
+
* `evaluateGate` was already computing the real count from the real findings
|
|
222
|
+
* and the value was thrown away.
|
|
223
|
+
*
|
|
224
|
+
* `evaluateGate` is reused rather than reimplemented for the same reason
|
|
225
|
+
* `extractScenarios` is shared with lib/conformance.mjs: a second counting
|
|
226
|
+
* implementation lets the readiness gate and the review gate disagree about
|
|
227
|
+
* one findings file, and nobody notices until the two contradict each other.
|
|
228
|
+
*
|
|
229
|
+
* Fails closed on anything it cannot read. Absent, blank and unparseable all
|
|
230
|
+
* resolve to *the review did not run*, never to zero — an unreadable findings
|
|
231
|
+
* file is not a findings file with no blockers.
|
|
232
|
+
*
|
|
233
|
+
* @param {unknown} findings parsed findings, or the raw text of the file
|
|
234
|
+
* @returns {{ran: boolean, blockers?: number, warnings?: number, reason?: string}}
|
|
235
|
+
*/
|
|
236
|
+
function readFindings(findings) {
|
|
237
|
+
if (findings === undefined || findings === null) {
|
|
238
|
+
return { ran: false, reason: 'no artifact-review findings file was supplied' }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let parsed = findings
|
|
242
|
+
if (typeof findings === 'string') {
|
|
243
|
+
if (!findings.trim()) {
|
|
244
|
+
return { ran: false, reason: 'the artifact-review findings file is empty' }
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
parsed = JSON.parse(findings)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return { ran: false, reason: `the artifact-review findings file is not valid JSON: ${messageOf(err)}` }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
253
|
+
return { ran: false, reason: 'the artifact-review findings are not an object or an array' }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const got = attempt(() => evaluateGate(parsed))
|
|
257
|
+
if (!got.ok) {
|
|
258
|
+
return { ran: false, reason: `the artifact-review findings could not be evaluated: ${got.error}` }
|
|
259
|
+
}
|
|
260
|
+
const gate = got.value
|
|
261
|
+
return {
|
|
262
|
+
ran: true,
|
|
263
|
+
blockers: gate.blockers.length,
|
|
264
|
+
warnings: gate.counts.warning,
|
|
265
|
+
malformed: gate.malformed.length,
|
|
266
|
+
total: gate.total
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Interpret a hand-written artifact-review count.
|
|
272
|
+
*
|
|
273
|
+
* Retained for one release, and no longer sufficient on its own: its
|
|
274
|
+
* provenance is the agent being gated. Only an object carrying a usable
|
|
275
|
+
* `blockers` count counts as "supplied at all" — `undefined`, `true`, an empty
|
|
276
|
+
* object and a negative count are all *unrun*, never zero.
|
|
277
|
+
*/
|
|
278
|
+
function readReview(review) {
|
|
279
|
+
if (!review || typeof review !== 'object' || Array.isArray(review)) {
|
|
280
|
+
return { ran: false, reason: 'no artifact-review result was supplied' }
|
|
281
|
+
}
|
|
282
|
+
const blockers = countOf(review.blockers)
|
|
283
|
+
if (blockers === null) {
|
|
284
|
+
return { ran: false, reason: 'the artifact-review result carried no usable blocker count' }
|
|
285
|
+
}
|
|
286
|
+
const warnings = countOf(review.warnings)
|
|
287
|
+
return { ran: true, blockers, warnings: warnings === null ? 0 : warnings, reason: null }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A count may arrive as a number or as the array it counts. Anything else is
|
|
291
|
+
// "not a count" (null), which the caller above turns into "the review did not
|
|
292
|
+
// run" rather than into a zero.
|
|
293
|
+
function countOf(value) {
|
|
294
|
+
if (Array.isArray(value)) return value.length
|
|
295
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.trunc(value)
|
|
296
|
+
return null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Every `#### Scenario:` in the artifacts, with the section text under it.
|
|
301
|
+
*
|
|
302
|
+
* Exported because `lib/conformance.mjs` needs exactly this parse and a second
|
|
303
|
+
* implementation would drift from it — the two would then disagree about what
|
|
304
|
+
* counts as a scenario, which is the kind of divergence nobody notices until a
|
|
305
|
+
* conformance report and a readiness gate contradict each other.
|
|
306
|
+
*/
|
|
307
|
+
export function extractScenarios(artifacts) {
|
|
308
|
+
const found = []
|
|
309
|
+
for (const name of Object.keys(artifacts)) {
|
|
310
|
+
if (name === 'tasks.md') continue
|
|
311
|
+
const lines = artifacts[name].split('\n')
|
|
312
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
313
|
+
const m = SCENARIO_HEADING.exec(lines[i].trim())
|
|
314
|
+
if (!m) continue
|
|
315
|
+
const body = []
|
|
316
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
317
|
+
if (ANY_HEADING.test(lines[j])) break
|
|
318
|
+
body.push(lines[j])
|
|
319
|
+
}
|
|
320
|
+
found.push({ artifact: name, line: i + 1, title: m[1], body: body.join('\n') })
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return found
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- the checks ----------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
function checkDecisions(run, root, change) {
|
|
329
|
+
const ids = ['decisions.ledger-present', 'decisions.needs-human', 'decisions.rows-valid']
|
|
330
|
+
const got = attempt(() => {
|
|
331
|
+
const parsed = readLedger(root, change)
|
|
332
|
+
return { parsed, summary: summarize(parsed) }
|
|
333
|
+
})
|
|
334
|
+
if (!got.ok) {
|
|
335
|
+
blockAll(run, ids, 'LEDGER_UNREADABLE', `the decision ledger could not be read: ${got.error}`, [got.error])
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const { parsed, summary } = got.value
|
|
340
|
+
|
|
341
|
+
if (!summary.exists) {
|
|
342
|
+
if (STRICTNESS.requireLedger) {
|
|
343
|
+
// The quiet failure mode this exists to catch: no ledger reads as
|
|
344
|
+
// "nothing needed a human" only if you assume explore and spec had
|
|
345
|
+
// nothing to record. Under fail-closed it reads as "unknown".
|
|
346
|
+
blockAll(
|
|
347
|
+
run,
|
|
348
|
+
ids,
|
|
349
|
+
'LEDGER_MISSING',
|
|
350
|
+
`no decision ledger at ${parsed.path} — explore and spec never recorded their decisions, ` +
|
|
351
|
+
'so there is nothing to prove no human is needed. Run /interlock:spec again to write decisions.md.',
|
|
352
|
+
[parsed.path]
|
|
353
|
+
)
|
|
354
|
+
return summary
|
|
355
|
+
}
|
|
356
|
+
for (const id of ids) skip(run, id, 'no decision ledger, and the current strictness does not require one')
|
|
357
|
+
return summary
|
|
358
|
+
}
|
|
359
|
+
pass(run, ids[0], `decision ledger present with ${summary.total} row(s)`)
|
|
360
|
+
|
|
361
|
+
if (summary.needsHuman > STRICTNESS.maxNeedsHuman) {
|
|
362
|
+
const rows = (parsed.rows || [])
|
|
363
|
+
.filter(r => r.valid && r.class === 'needs_human')
|
|
364
|
+
.map(r => `${r.id || `line ${r.line}`}: ${clip(r.question)}`)
|
|
365
|
+
block(
|
|
366
|
+
run,
|
|
367
|
+
ids[1],
|
|
368
|
+
'DECISIONS_NEED_HUMAN',
|
|
369
|
+
`${summary.needsHuman} decision(s) still need a human (max ${STRICTNESS.maxNeedsHuman})`,
|
|
370
|
+
rows
|
|
371
|
+
)
|
|
372
|
+
} else {
|
|
373
|
+
pass(run, ids[1], `no needs_human rows (${summary.agentResolved} agent_resolved)`)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (summary.invalid > STRICTNESS.maxInvalidDecisions) {
|
|
377
|
+
const rows = (parsed.invalid || []).map(({ row, reason }) => `${row.id || `line ${row.line}`}: ${clip(reason)}`)
|
|
378
|
+
block(
|
|
379
|
+
run,
|
|
380
|
+
ids[2],
|
|
381
|
+
'DECISIONS_INVALID',
|
|
382
|
+
`${summary.invalid} ledger row(s) are malformed or unsubstantiated — an unreadable row counts as unresolved`,
|
|
383
|
+
rows
|
|
384
|
+
)
|
|
385
|
+
} else {
|
|
386
|
+
pass(run, ids[2], 'every ledger row parses and substantiates its class')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return summary
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function checkArtifactsImplementable(run, inspection) {
|
|
393
|
+
if (inspection.ready) {
|
|
394
|
+
pass(
|
|
395
|
+
run,
|
|
396
|
+
'artifacts.implementable',
|
|
397
|
+
`proposal.md, design.md and tasks.md present and non-empty; ${inspection.tasks.total} task(s)`
|
|
398
|
+
)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
block(
|
|
402
|
+
run,
|
|
403
|
+
'artifacts.implementable',
|
|
404
|
+
'ARTIFACTS_INCOMPLETE',
|
|
405
|
+
`change artifacts are not implementable as written (interlock validate would fail)`,
|
|
406
|
+
inspection.problems || []
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function checkReview(run, findings, review) {
|
|
411
|
+
const derived = readFindings(findings)
|
|
412
|
+
const transcribed = readReview(review)
|
|
413
|
+
|
|
414
|
+
// Both supplied and disagreeing: the findings decide, and the disagreement is
|
|
415
|
+
// reported rather than resolved silently in either direction. A silent
|
|
416
|
+
// resolution here would hide exactly the case this change exists for — the
|
|
417
|
+
// gated agent's number differing from the review's own.
|
|
418
|
+
if (derived.ran && transcribed.ran && derived.blockers !== transcribed.blockers) {
|
|
419
|
+
warn(
|
|
420
|
+
run,
|
|
421
|
+
'REVIEW_COUNT_DISAGREEMENT',
|
|
422
|
+
`the findings file yields ${derived.blockers} blocker(s) but the supplied count says ` +
|
|
423
|
+
`${transcribed.blockers} — the findings decide, because the transcribed count's ` +
|
|
424
|
+
`provenance is the agent being gated`
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!derived.ran) {
|
|
429
|
+
if (transcribed.ran) {
|
|
430
|
+
// The deprecated input, on its own. Not a pass: a count written by the
|
|
431
|
+
// party being gated is a self-report, not a review result.
|
|
432
|
+
block(
|
|
433
|
+
run,
|
|
434
|
+
'artifacts.review-blockers',
|
|
435
|
+
'REVIEW_SELF_REPORTED',
|
|
436
|
+
`${derived.reason}, and the only artifact-review input is a transcribed count whose ` +
|
|
437
|
+
`provenance is the gated agent itself — pass the review's findings file to ` +
|
|
438
|
+
`--findings so the count is derived from the review rather than reported by it`,
|
|
439
|
+
[]
|
|
440
|
+
)
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
// An unrun check is not a clean check. Without this branch the whole
|
|
444
|
+
// artifact-review arm of §F1 C is bypassed by omitting one argument.
|
|
445
|
+
block(
|
|
446
|
+
run,
|
|
447
|
+
'artifacts.review-blockers',
|
|
448
|
+
'REVIEW_NOT_RUN',
|
|
449
|
+
`${derived.reason} — an artifact review that did not run is not an artifact review with zero blockers`,
|
|
450
|
+
[]
|
|
451
|
+
)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const r = derived
|
|
456
|
+
if (r.malformed > 0) {
|
|
457
|
+
block(
|
|
458
|
+
run,
|
|
459
|
+
'artifacts.review-findings-malformed',
|
|
460
|
+
'REVIEW_FINDINGS_MALFORMED',
|
|
461
|
+
`${r.malformed} artifact-review finding(s) carry a severity outside the defined set — ` +
|
|
462
|
+
'a finding the gate cannot interpret is not a finding the gate may ignore'
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
if (r.warnings > 0) {
|
|
466
|
+
if (STRICTNESS.artifactWarningsBlock) {
|
|
467
|
+
block(
|
|
468
|
+
run,
|
|
469
|
+
'artifacts.review-warnings',
|
|
470
|
+
'REVIEW_WARNINGS',
|
|
471
|
+
`${r.warnings} artifact-review warning(s), and strictness "${STRICTNESS.name}" blocks on warnings`
|
|
472
|
+
)
|
|
473
|
+
} else {
|
|
474
|
+
warn(run, 'REVIEW_WARNINGS', `${r.warnings} artifact-review warning(s) — not blocking under "${STRICTNESS.name}"`)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (r.blockers > STRICTNESS.maxArtifactBlockers) {
|
|
478
|
+
block(
|
|
479
|
+
run,
|
|
480
|
+
'artifacts.review-blockers',
|
|
481
|
+
'REVIEW_BLOCKERS',
|
|
482
|
+
`${r.blockers} artifact-review blocker(s) (max ${STRICTNESS.maxArtifactBlockers})`
|
|
483
|
+
)
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
pass(
|
|
487
|
+
run,
|
|
488
|
+
'artifacts.review-blockers',
|
|
489
|
+
`artifact review ran: ${r.blockers} blocker(s), ${r.warnings} warning(s), ` +
|
|
490
|
+
`derived from the review's own findings (${r.total} reportable finding(s))`
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function checkScenarioCoverage(run, artifacts, tasks) {
|
|
495
|
+
const scenarios = extractScenarios(artifacts)
|
|
496
|
+
if (scenarios.length === 0) {
|
|
497
|
+
// Not a blocker: a change may legitimately carry no delta specs. Loud
|
|
498
|
+
// enough that a missing specs/ directory is visible at the checkpoint.
|
|
499
|
+
warn(run, 'NO_SCENARIOS', 'no "#### Scenario:" blocks found in the change artifacts — nothing to map to tasks')
|
|
500
|
+
pass(run, 'specs.scenarios-mapped', 'no scenarios to map')
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const taskTexts = tasks.map(t => normalize(t.text)).filter(Boolean)
|
|
505
|
+
const unmapped = []
|
|
506
|
+
for (const s of scenarios) {
|
|
507
|
+
const title = normalize(s.title)
|
|
508
|
+
const explicit = TASK_COVERAGE_MARKER.test(s.title) || TASK_COVERAGE_MARKER.test(s.body)
|
|
509
|
+
const named = title.length >= MIN_TITLE_CHARS && taskTexts.some(t => t.includes(title))
|
|
510
|
+
if (!explicit && !named) {
|
|
511
|
+
unmapped.push(`${s.artifact}:${s.line} Scenario: ${clip(s.title)}`)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (unmapped.length) {
|
|
516
|
+
block(
|
|
517
|
+
run,
|
|
518
|
+
'specs.scenarios-mapped',
|
|
519
|
+
'SCENARIO_UNMAPPED',
|
|
520
|
+
`${unmapped.length} of ${scenarios.length} scenario(s) map to no task — name the scenario in a task, ` +
|
|
521
|
+
'or write "covered by task N" under the scenario',
|
|
522
|
+
unmapped
|
|
523
|
+
)
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
pass(run, 'specs.scenarios-mapped', `all ${scenarios.length} scenario(s) map to at least one task`)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function checkWeaselPhrases(run, tasks) {
|
|
530
|
+
const offenders = []
|
|
531
|
+
for (const task of tasks) {
|
|
532
|
+
const text = String(task.text ?? '')
|
|
533
|
+
if (ACCEPTANCE_MARKER.test(text)) continue
|
|
534
|
+
for (const { phrase, re } of WEASEL_PATTERNS) {
|
|
535
|
+
if (re.test(text)) {
|
|
536
|
+
offenders.push(`tasks.md:${task.line} "${phrase}" — ${clip(text)}`)
|
|
537
|
+
break
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (offenders.length) {
|
|
542
|
+
block(
|
|
543
|
+
run,
|
|
544
|
+
'tasks.no-weasel-phrases',
|
|
545
|
+
'TASK_UNSPECIFIC',
|
|
546
|
+
`${offenders.length} task(s) describe an intention rather than a change — ` +
|
|
547
|
+
'rewrite them, or state what done means with "acceptance: …"',
|
|
548
|
+
offenders
|
|
549
|
+
)
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
pass(run, 'tasks.no-weasel-phrases', `${tasks.length} task(s) carry no unqualified vague phrasing`)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function checkRisk(run, risk) {
|
|
556
|
+
if (!risk.ok) {
|
|
557
|
+
blockAll(run, ['risk.class-allowed'], 'RISK_UNCLASSIFIABLE', `risk could not be classified: ${risk.error}`, [
|
|
558
|
+
risk.error
|
|
559
|
+
])
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
const result = risk.value
|
|
563
|
+
// `continuityAllowed` is computed by lib/risk.mjs from the allowlist we hand
|
|
564
|
+
// it; the explicit membership test is deliberate redundancy, so a future
|
|
565
|
+
// caller passing its own allowedClasses cannot widen this gate by accident.
|
|
566
|
+
const allowed = STRICTNESS.allowedRiskClasses.includes(result.riskClass) && result.continuityAllowed === true
|
|
567
|
+
if (!allowed) {
|
|
568
|
+
block(
|
|
569
|
+
run,
|
|
570
|
+
'risk.class-allowed',
|
|
571
|
+
'RISK_CLASS_BLOCKED',
|
|
572
|
+
`risk class "${result.riskClass}" is outside {${STRICTNESS.allowedRiskClasses.join(', ')}} — human checkpoint required`,
|
|
573
|
+
result.signals.filter(s => s.class === result.riskClass).map(s => `${s.signal}: ${clip(s.evidence)}`)
|
|
574
|
+
)
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
pass(run, 'risk.class-allowed', `risk class "${result.riskClass}" may continue under "${STRICTNESS.name}"`)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function checkTestProfile(run, root) {
|
|
581
|
+
if (!STRICTNESS.requireTestProfile) {
|
|
582
|
+
skip(run, 'runtime.test-profile', 'test profile not required by the current strictness')
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
const path = join(root, TEST_PROFILE_PATH)
|
|
586
|
+
const got = attempt(() => {
|
|
587
|
+
if (!existsSync(path)) return { present: false, parsed: false }
|
|
588
|
+
if (statSync(path).size === 0) return { present: true, parsed: false }
|
|
589
|
+
JSON.parse(readFileSync(path, 'utf8'))
|
|
590
|
+
return { present: true, parsed: true }
|
|
591
|
+
})
|
|
592
|
+
if (!got.ok) {
|
|
593
|
+
block(run, 'runtime.test-profile', 'TEST_PROFILE_UNREADABLE', `${TEST_PROFILE_PATH} could not be read: ${got.error}`, [
|
|
594
|
+
got.error
|
|
595
|
+
])
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
if (!got.value.present) {
|
|
599
|
+
block(
|
|
600
|
+
run,
|
|
601
|
+
'runtime.test-profile',
|
|
602
|
+
'TEST_PROFILE_MISSING',
|
|
603
|
+
`no ${TEST_PROFILE_PATH} — ship would have to guess how to run the tests. Run /interlock:fix-tests --reconfigure once.`,
|
|
604
|
+
[path]
|
|
605
|
+
)
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
if (!got.value.parsed) {
|
|
609
|
+
block(run, 'runtime.test-profile', 'TEST_PROFILE_UNREADABLE', `${TEST_PROFILE_PATH} is empty or not valid JSON`, [path])
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
pass(run, 'runtime.test-profile', `${TEST_PROFILE_PATH} present and parseable`)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// §F1 items this release deliberately does not check. Listed rather than
|
|
616
|
+
// omitted so the printed checklist is the whole checklist, and so nobody reads
|
|
617
|
+
// a short green list as "everything in F1 passed".
|
|
618
|
+
function addSkips(run) {
|
|
619
|
+
skip(
|
|
620
|
+
run,
|
|
621
|
+
'intent.lock',
|
|
622
|
+
'not machine-checkable from artifacts (§F0): whether this is the *right* change, and whether a bug-fix ' +
|
|
623
|
+
'carries repro evidence, stay with the human. Continuity knowingly accepts this residual risk.'
|
|
624
|
+
)
|
|
625
|
+
skip(
|
|
626
|
+
run,
|
|
627
|
+
'tasks.wave-classifiable',
|
|
628
|
+
'not implemented in this release — wave classification runs in lib/waves.mjs at ship time, not here'
|
|
629
|
+
)
|
|
630
|
+
skip(
|
|
631
|
+
run,
|
|
632
|
+
'invariants.consumers-enumerated',
|
|
633
|
+
'not implemented in this release — enumerating sweep consumers needs the graph, which this gate does not read'
|
|
634
|
+
)
|
|
635
|
+
skip(
|
|
636
|
+
run,
|
|
637
|
+
'runtime.agent-tool',
|
|
638
|
+
'not observable from this process — the ship workflow reports the Agent tool being unavailable at run time'
|
|
639
|
+
)
|
|
640
|
+
skip(
|
|
641
|
+
run,
|
|
642
|
+
'learning.failure-streak',
|
|
643
|
+
'eligibility gating is deliberately out of scope for this release (§4.15a / F6.6): outcomes are recorded, nothing gates on them'
|
|
644
|
+
)
|
|
645
|
+
skip(
|
|
646
|
+
run,
|
|
647
|
+
'learning.path-autonomy',
|
|
648
|
+
'eligibility gating is deliberately out of scope for this release (§4.15a / F6.6): autonomy level never implies continuity (§F7)'
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// --- public API ----------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Assess whether one change may continue past the human checkpoint (§F1).
|
|
656
|
+
*
|
|
657
|
+
* Composes `lib/artifacts.mjs`, `lib/ledger.mjs` and `lib/risk.mjs` under the
|
|
658
|
+
* §4.14 "balanced" policy in `STRICTNESS`. Nothing here re-derives what those
|
|
659
|
+
* modules already decide.
|
|
660
|
+
*
|
|
661
|
+
* @param {string} root repo root
|
|
662
|
+
* @param {string} [change] change name; resolved via `resolveChange` when omitted
|
|
663
|
+
* @param {{
|
|
664
|
+
* findings?: unknown,
|
|
665
|
+
* review?: {blockers: number|Array, warnings?: number|Array},
|
|
666
|
+
* changedPaths?: string[],
|
|
667
|
+
* plannedPaths?: string[],
|
|
668
|
+
* artifacts?: Record<string,string>
|
|
669
|
+
* }} [opts]
|
|
670
|
+
* `findings` is the artifact review's own findings output — parsed, or the
|
|
671
|
+
* raw text of the file. The blocker count is derived from it by
|
|
672
|
+
* `evaluateGate`. **Omitting it blocks**, and so does supplying only
|
|
673
|
+
* `review`, which is the deprecated hand-written count: its provenance is
|
|
674
|
+
* the agent being gated. `changedPaths`/`plannedPaths` feed `classifyRisk`.
|
|
675
|
+
* `artifacts` overrides reading the change directory (mostly for tests).
|
|
676
|
+
* @returns {{
|
|
677
|
+
* ready: boolean,
|
|
678
|
+
* change: string,
|
|
679
|
+
* strictness: string,
|
|
680
|
+
* blockers: Array<{code: string, message: string, evidence: string[]}>,
|
|
681
|
+
* warnings: Array<{code: string, message: string}>,
|
|
682
|
+
* decisions: {needsHuman: number|null, agentResolved: number|null, invalid: number|null, exists: boolean|null},
|
|
683
|
+
* riskClass: string|null,
|
|
684
|
+
* continuityAllowed: boolean,
|
|
685
|
+
* // `continuityAllowed` is the risk module's answer ANDed with `ready`, not
|
|
686
|
+
* // the raw risk verdict: an allowed risk class is necessary but nowhere
|
|
687
|
+
* // near sufficient, and a caller reading this field as the gate must not
|
|
688
|
+
* // end up bypassing the ledger and artifact checks. `riskClass` and the
|
|
689
|
+
* // `risk.class-allowed` check carry the risk-only answer.
|
|
690
|
+
* checks: Array<{id: string, status: 'pass'|'fail'|'skip', detail: string}>,
|
|
691
|
+
* scores: object
|
|
692
|
+
* }}
|
|
693
|
+
*/
|
|
694
|
+
export function assessReadiness(root, change, opts = {}) {
|
|
695
|
+
if (typeof root !== 'string' || !root.trim()) fail('readiness requires a repo root')
|
|
696
|
+
const options = opts && typeof opts === 'object' ? opts : {}
|
|
697
|
+
|
|
698
|
+
const resolved = resolveChange(root, change)
|
|
699
|
+
if (resolved.error) {
|
|
700
|
+
fail(
|
|
701
|
+
`${resolved.error}${resolved.candidates && resolved.candidates.length ? `\n candidates: ${resolved.candidates.join(', ')}` : ''}`
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
const name = resolved.name
|
|
705
|
+
|
|
706
|
+
const run = { checks: [], blockers: [], warnings: [] }
|
|
707
|
+
|
|
708
|
+
// 1. Artifacts. Everything downstream of them (scenarios, tasks, risk text)
|
|
709
|
+
// fails together when they cannot be read.
|
|
710
|
+
const inspected = attempt(() => inspectChange(root, name))
|
|
711
|
+
const artifactsGot = attempt(() => {
|
|
712
|
+
if (!inspected.ok) throw new Error(inspected.error)
|
|
713
|
+
return options.artifacts !== undefined
|
|
714
|
+
? normalizeArtifacts(options.artifacts)
|
|
715
|
+
: readArtifactsFromDisk(inspected.value)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
const artifactDependents = [
|
|
719
|
+
'artifacts.implementable',
|
|
720
|
+
'specs.scenarios-mapped',
|
|
721
|
+
'tasks.no-weasel-phrases',
|
|
722
|
+
'risk.class-allowed'
|
|
723
|
+
]
|
|
724
|
+
|
|
725
|
+
let riskResult = null
|
|
726
|
+
|
|
727
|
+
if (!inspected.ok || !artifactsGot.ok) {
|
|
728
|
+
const reason = inspected.ok ? artifactsGot.error : inspected.error
|
|
729
|
+
blockAll(run, artifactDependents, 'ARTIFACTS_UNREADABLE', `change artifacts could not be read: ${reason}`, [reason])
|
|
730
|
+
} else {
|
|
731
|
+
const inspection = inspected.value
|
|
732
|
+
const artifacts = artifactsGot.value
|
|
733
|
+
const tasks = typeof artifacts['tasks.md'] === 'string' ? parseTasks(artifacts['tasks.md']) : inspection.tasks.items
|
|
734
|
+
|
|
735
|
+
checkArtifactsImplementable(run, inspection)
|
|
736
|
+
checkScenarioCoverage(run, artifacts, tasks)
|
|
737
|
+
checkWeaselPhrases(run, tasks)
|
|
738
|
+
|
|
739
|
+
const paths = Array.isArray(options.changedPaths)
|
|
740
|
+
? options.changedPaths
|
|
741
|
+
: Array.isArray(options.plannedPaths)
|
|
742
|
+
? options.plannedPaths
|
|
743
|
+
: []
|
|
744
|
+
const risk = attempt(() =>
|
|
745
|
+
classifyRisk({ changedPaths: paths, artifacts }, { allowedClasses: STRICTNESS.allowedRiskClasses })
|
|
746
|
+
)
|
|
747
|
+
checkRisk(run, risk)
|
|
748
|
+
riskResult = risk.ok ? risk.value : null
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 2. The ledger, and the artifact review the caller ran for us.
|
|
752
|
+
const decisions = checkDecisions(run, root, name)
|
|
753
|
+
checkReview(run, options.findings, options.review)
|
|
754
|
+
|
|
755
|
+
// 3. Runtime readiness, then the checklist items we chose not to automate.
|
|
756
|
+
checkTestProfile(run, root)
|
|
757
|
+
addSkips(run)
|
|
758
|
+
|
|
759
|
+
const failed = run.checks.filter(c => c.status === 'fail').length
|
|
760
|
+
const passed = run.checks.filter(c => c.status === 'pass').length
|
|
761
|
+
const skipped = run.checks.filter(c => c.status === 'skip').length
|
|
762
|
+
|
|
763
|
+
// `ready` is an AND of affirmative passes, not the absence of a complaint.
|
|
764
|
+
const ready = run.blockers.length === 0 && failed === 0 && passed > 0
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
ready,
|
|
768
|
+
change: name,
|
|
769
|
+
strictness: STRICTNESS.name,
|
|
770
|
+
blockers: run.blockers,
|
|
771
|
+
warnings: run.warnings,
|
|
772
|
+
decisions: {
|
|
773
|
+
needsHuman: decisions ? decisions.needsHuman : null,
|
|
774
|
+
agentResolved: decisions ? decisions.agentResolved : null,
|
|
775
|
+
invalid: decisions ? decisions.invalid : null,
|
|
776
|
+
exists: decisions ? decisions.exists : null
|
|
777
|
+
},
|
|
778
|
+
riskClass: riskResult ? riskResult.riskClass : null,
|
|
779
|
+
continuityAllowed: Boolean(ready && riskResult && riskResult.continuityAllowed),
|
|
780
|
+
checks: run.checks,
|
|
781
|
+
scores: {
|
|
782
|
+
checksTotal: run.checks.length,
|
|
783
|
+
checksPassed: passed,
|
|
784
|
+
checksFailed: failed,
|
|
785
|
+
checksSkipped: skipped,
|
|
786
|
+
blockers: run.blockers.length,
|
|
787
|
+
warnings: run.warnings.length
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const STATUS_MARK = { pass: 'ok ', fail: 'FAIL', skip: 'skip' }
|
|
793
|
+
|
|
794
|
+
/** Human-readable readiness, for a skill to echo before it decides. */
|
|
795
|
+
export function formatReadiness(result) {
|
|
796
|
+
const lines = []
|
|
797
|
+
lines.push(
|
|
798
|
+
result.ready
|
|
799
|
+
? `READY — ${result.change} may continue (risk ${result.riskClass}, strictness ${result.strictness})`
|
|
800
|
+
: `NOT READY — ${result.change}: ${result.blockers.length} blocker(s) ` +
|
|
801
|
+
`(risk ${result.riskClass ?? 'unknown'}, strictness ${result.strictness})`
|
|
802
|
+
)
|
|
803
|
+
for (const c of result.checks) {
|
|
804
|
+
lines.push(` [${STATUS_MARK[c.status] || c.status}] ${c.id}: ${c.detail}`)
|
|
805
|
+
}
|
|
806
|
+
for (const b of result.blockers) {
|
|
807
|
+
lines.push(` blocker ${b.code}: ${b.message}`)
|
|
808
|
+
for (const e of b.evidence || []) lines.push(` ${e}`)
|
|
809
|
+
}
|
|
810
|
+
for (const w of result.warnings) lines.push(` warning ${w.code}: ${w.message}`)
|
|
811
|
+
return lines.join('\n') + '\n'
|
|
812
|
+
}
|