@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
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// OpenSpec change-artifact inspection.
|
|
2
|
+
//
|
|
3
|
+
// Answers two questions the flow asks constantly, without a model call:
|
|
4
|
+
// "is this change ready to implement?" and "how far through tasks.md are we?"
|
|
5
|
+
//
|
|
6
|
+
// Deliberately thin. OpenSpec's own CLI (`openspec status --json`,
|
|
7
|
+
// `openspec validate`) is the authority on schema conformance; this module only
|
|
8
|
+
// covers the filesystem-level checks the flow gates on, so a skill can branch
|
|
9
|
+
// before spending a turn.
|
|
10
|
+
//
|
|
11
|
+
// Exposed to skills as `interlock validate --change <name>`.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
// Extensions whose lines a coverage tool can meaningfully attribute. Anything
|
|
17
|
+
// outside this set (docs, JSON/YAML/TOML config, lockfiles, shell) is dropped
|
|
18
|
+
// before we ever ask an agent to measure it.
|
|
19
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
20
|
+
'.js', '.jsx', '.mjs', '.cjs',
|
|
21
|
+
'.ts', '.tsx', '.mts', '.cts',
|
|
22
|
+
'.py', '.go', '.rs', '.rb', '.java', '.kt', '.kts',
|
|
23
|
+
'.swift', '.php', '.cs', '.scala', '.vue', '.svelte'
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const EXCLUDED_DIR =
|
|
27
|
+
/(^|\/)(node_modules|dist|build|out|coverage|vendor|__tests__|__mocks__|__fixtures__|tests?|e2e|fixtures)(\/|$)/i
|
|
28
|
+
|
|
29
|
+
function extensionOf(path) {
|
|
30
|
+
const base = path.slice(path.lastIndexOf('/') + 1)
|
|
31
|
+
const dot = base.lastIndexOf('.')
|
|
32
|
+
return dot <= 0 ? '' : base.slice(dot).toLowerCase()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Diff coverage is only reported for real source files. Test files, config
|
|
36
|
+
// files, generated output and docs are skipped — covering them is meaningless
|
|
37
|
+
// and warning about them is noise.
|
|
38
|
+
export function isCoverableSource(path) {
|
|
39
|
+
if (typeof path !== 'string' || path.length === 0) return false
|
|
40
|
+
const p = path.replace(/\\/g, '/')
|
|
41
|
+
if (p.split('/').some(seg => seg.startsWith('.'))) return false
|
|
42
|
+
if (!SOURCE_EXTENSIONS.has(extensionOf(p))) return false
|
|
43
|
+
if (EXCLUDED_DIR.test(p)) return false
|
|
44
|
+
const base = p.slice(p.lastIndexOf('/') + 1)
|
|
45
|
+
if (/\.(test|spec)\./i.test(base)) return false
|
|
46
|
+
if (/\.(config|conf|d)\./i.test(base)) return false
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CHANGES_DIR = join('openspec', 'changes')
|
|
51
|
+
|
|
52
|
+
/** List change directories under openspec/changes/, excluding archive/. */
|
|
53
|
+
export function listChanges(root = '.') {
|
|
54
|
+
const dir = join(root, CHANGES_DIR)
|
|
55
|
+
if (!existsSync(dir)) return []
|
|
56
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
57
|
+
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
58
|
+
.map(e => e.name)
|
|
59
|
+
.sort()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve which change to act on.
|
|
64
|
+
* Returns { name } on success, or { error, candidates } when ambiguous/absent —
|
|
65
|
+
* the caller decides whether to ask a human or halt.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveChange(root = '.', requested) {
|
|
68
|
+
const changes = listChanges(root)
|
|
69
|
+
if (requested) {
|
|
70
|
+
if (changes.includes(requested)) return { name: requested }
|
|
71
|
+
return { error: `change "${requested}" not found under ${CHANGES_DIR}/`, candidates: changes }
|
|
72
|
+
}
|
|
73
|
+
if (changes.length === 1) return { name: changes[0] }
|
|
74
|
+
if (changes.length === 0) return { error: `no changes found under ${CHANGES_DIR}/`, candidates: [] }
|
|
75
|
+
return { error: 'multiple active changes — name one explicitly', candidates: changes }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A tasks.md checkbox line: "- [ ] 1.1 Do the thing" / "- [x] ..."
|
|
79
|
+
const TASK_LINE = /^\s*[-*]\s*\[( |x|X)\]\s*(.+?)\s*$/
|
|
80
|
+
|
|
81
|
+
// The leading `1.2` / `1.2.3` of a checkbox line: exactly the token
|
|
82
|
+
// `taskMatchesId` compares against, so an id read here is an id `tasks tick`
|
|
83
|
+
// would act on. A bare `3` counts, because the ticker accepts it — agreeing
|
|
84
|
+
// with the ticker matters more than guessing which numbers were meant as ids.
|
|
85
|
+
const TASK_ID = /^(\d+(?:\.\d+)*)(?:\s|$)/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Rewrite every checkbox marker to the unchecked form, leaving the rest of the
|
|
89
|
+
* line byte-identical.
|
|
90
|
+
*
|
|
91
|
+
* The plan fingerprint (`lib/plan-fingerprint.mjs`) hashes `tasks.md` through
|
|
92
|
+
* this, so ticking a task does not invalidate the plan covering the tasks that
|
|
93
|
+
* remain — while adding, removing, reordering or rewording one still does,
|
|
94
|
+
* because every other byte of the line is hashed as written. It lives here
|
|
95
|
+
* rather than there so `TASK_LINE` stays the single reader of what a checkbox
|
|
96
|
+
* looks like; a second regex for the same shape is how the two drift apart.
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeTaskMarkers(markdown) {
|
|
99
|
+
return (markdown || '')
|
|
100
|
+
.split('\n')
|
|
101
|
+
.map(line => (TASK_LINE.test(line) ? line.replace(/\[[xX]\]/, '[ ]') : line))
|
|
102
|
+
.join('\n')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The heading token `/interlock:spec` writes above a TDD change's leading
|
|
107
|
+
* failing-test section.
|
|
108
|
+
*
|
|
109
|
+
* Exported because it is the ONE contract between a prose surface and a code
|
|
110
|
+
* surface in this repo: `skills/spec/SKILL.md` writes it and this module reads
|
|
111
|
+
* it, so a reword on either side would silently stop every TDD change from
|
|
112
|
+
* being detected as one. `test/skills.test.mjs` asserts the skill still writes
|
|
113
|
+
* this exact value rather than a copy of it.
|
|
114
|
+
*/
|
|
115
|
+
export const RED_SECTION_MARKER = 'Failing tests first'
|
|
116
|
+
|
|
117
|
+
// A numbered tasks.md section heading: "## 1. Failing tests first". Separate
|
|
118
|
+
// from TASK_LINE because it reads a different line shape, and kept beside it so
|
|
119
|
+
// the two readers of tasks.md structure stay together.
|
|
120
|
+
const SECTION_HEADING = /^\s*##\s+(\d+)\s*\.\s*(.*?)\s*$/
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The group number of the leading failing-test section, or `null`.
|
|
124
|
+
*
|
|
125
|
+
* Deterministic and pure: the shape of a TDD run is decided by what the author
|
|
126
|
+
* wrote in `tasks.md`, not by a model re-reading it at plan time. Only the
|
|
127
|
+
* LOWEST matching section counts — a marker further down the file describes
|
|
128
|
+
* tests that run after implementation, which is not a red wave, and treating it
|
|
129
|
+
* as one would reorder a change its author did not write for TDD.
|
|
130
|
+
*
|
|
131
|
+
* The caller decides what to do with the answer; nothing here validates it
|
|
132
|
+
* against a classification (see `resolveRedWave` in `lib/run.mjs`).
|
|
133
|
+
*/
|
|
134
|
+
export function detectRedSection(markdown) {
|
|
135
|
+
const marker = RED_SECTION_MARKER.toLowerCase()
|
|
136
|
+
let lowest = null
|
|
137
|
+
for (const line of (markdown || '').split('\n')) {
|
|
138
|
+
const m = SECTION_HEADING.exec(line)
|
|
139
|
+
if (!m) continue
|
|
140
|
+
if (!m[2].toLowerCase().includes(marker)) continue
|
|
141
|
+
const group = Number(m[1])
|
|
142
|
+
if (!Number.isInteger(group)) continue
|
|
143
|
+
if (lowest === null || group < lowest) lowest = group
|
|
144
|
+
}
|
|
145
|
+
return lowest
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseTasks(markdown) {
|
|
149
|
+
const tasks = []
|
|
150
|
+
const lines = (markdown || '').split('\n')
|
|
151
|
+
lines.forEach((line, i) => {
|
|
152
|
+
const m = TASK_LINE.exec(line)
|
|
153
|
+
if (!m) return
|
|
154
|
+
tasks.push({
|
|
155
|
+
done: m[1].toLowerCase() === 'x',
|
|
156
|
+
text: m[2],
|
|
157
|
+
// The id a caller would tick, resolved here rather than by every reader.
|
|
158
|
+
// A host that has to split the text itself is a host that can get it
|
|
159
|
+
// wrong, and `null` says "this line carries no id" instead of inventing
|
|
160
|
+
// one from prose.
|
|
161
|
+
id: TASK_ID.exec(m[2]) ? TASK_ID.exec(m[2])[1] : null,
|
|
162
|
+
line: i + 1
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
return tasks
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True when checkbox text is exactly `id` or starts with `id` plus a space. */
|
|
169
|
+
export function taskMatchesId(text, id) {
|
|
170
|
+
if (typeof text !== 'string' || typeof id !== 'string' || !id) return false
|
|
171
|
+
return text === id || text.startsWith(`${id} `)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Flip `- [ ]` to `- [x]` for checkbox lines whose text matches an id.
|
|
176
|
+
* Missing ids are reported, never invented. Already-checked matches are listed
|
|
177
|
+
* as already, not re-written.
|
|
178
|
+
*/
|
|
179
|
+
export function tickTasks(root, changeName, ids) {
|
|
180
|
+
const wanted = [...new Set((ids || []).map(String).map(s => s.trim()).filter(Boolean))]
|
|
181
|
+
const path = join(root, CHANGES_DIR, changeName, 'tasks.md')
|
|
182
|
+
if (!existsSync(path)) {
|
|
183
|
+
return { change: changeName, path, ticked: [], already: [], missing: wanted, written: false }
|
|
184
|
+
}
|
|
185
|
+
const lines = readFileSync(path, 'utf8').split('\n')
|
|
186
|
+
const ticked = []
|
|
187
|
+
const already = []
|
|
188
|
+
const found = new Set()
|
|
189
|
+
const next = lines.map(line => {
|
|
190
|
+
const m = TASK_LINE.exec(line)
|
|
191
|
+
if (!m) return line
|
|
192
|
+
const id = wanted.find(i => taskMatchesId(m[2], i))
|
|
193
|
+
if (!id) return line
|
|
194
|
+
found.add(id)
|
|
195
|
+
if (m[1].toLowerCase() === 'x') {
|
|
196
|
+
if (!already.includes(id)) already.push(id)
|
|
197
|
+
return line
|
|
198
|
+
}
|
|
199
|
+
if (!ticked.includes(id)) ticked.push(id)
|
|
200
|
+
return line.replace(/\[ \]/, '[x]')
|
|
201
|
+
})
|
|
202
|
+
const missing = wanted.filter(id => !found.has(id))
|
|
203
|
+
if (ticked.length) writeFileSync(path, next.join('\n'))
|
|
204
|
+
return { change: changeName, path, ticked, already, missing, written: ticked.length > 0 }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Every remaining unchecked checkbox must be claimed by a classified id.
|
|
209
|
+
* Omitted entries are the checkbox text, so a halt names what was dropped.
|
|
210
|
+
*/
|
|
211
|
+
export function planCoverage(parsedTasks, classifiedTasks) {
|
|
212
|
+
const items = Array.isArray(parsedTasks) ? parsedTasks : []
|
|
213
|
+
const classified = Array.isArray(classifiedTasks) ? classifiedTasks : []
|
|
214
|
+
const ids = classified.map(t => t && t.id).filter(Boolean)
|
|
215
|
+
const remaining = items.filter(t => !t.done)
|
|
216
|
+
const omitted = remaining
|
|
217
|
+
.filter(t => !ids.some(id => taskMatchesId(t.text, id)))
|
|
218
|
+
.map(t => t.text)
|
|
219
|
+
return {
|
|
220
|
+
ok: omitted.length === 0,
|
|
221
|
+
remaining: remaining.length,
|
|
222
|
+
classified: ids.length,
|
|
223
|
+
omitted
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const REQUIRED = ['proposal.md', 'design.md', 'tasks.md']
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Filesystem readiness of one change.
|
|
231
|
+
* @returns {{
|
|
232
|
+
* change: string, path: string, exists: boolean, ready: boolean,
|
|
233
|
+
* present: string[], missing: string[],
|
|
234
|
+
* tasks: {total: number, done: number, remaining: number, items: Array},
|
|
235
|
+
* specFiles: string[], problems: string[]
|
|
236
|
+
* }}
|
|
237
|
+
*/
|
|
238
|
+
export function inspectChange(root = '.', changeName) {
|
|
239
|
+
const dir = join(root, CHANGES_DIR, changeName)
|
|
240
|
+
const problems = []
|
|
241
|
+
|
|
242
|
+
if (!existsSync(dir)) {
|
|
243
|
+
return {
|
|
244
|
+
change: changeName,
|
|
245
|
+
path: dir,
|
|
246
|
+
exists: false,
|
|
247
|
+
ready: false,
|
|
248
|
+
present: [],
|
|
249
|
+
missing: [...REQUIRED],
|
|
250
|
+
tasks: { total: 0, done: 0, remaining: 0, items: [], redSection: null },
|
|
251
|
+
specFiles: [],
|
|
252
|
+
problems: [`change directory does not exist: ${dir}`]
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const present = []
|
|
257
|
+
const missing = []
|
|
258
|
+
for (const f of REQUIRED) {
|
|
259
|
+
const p = join(dir, f)
|
|
260
|
+
if (existsSync(p) && statSync(p).size > 0) present.push(f)
|
|
261
|
+
else missing.push(f)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let items = []
|
|
265
|
+
// The leading failing-test section, read from the same bytes as the tasks so
|
|
266
|
+
// the shape of a change and its task list can never disagree about which file
|
|
267
|
+
// they came from. `null` for a change with no tasks.md at all.
|
|
268
|
+
let redSection = null
|
|
269
|
+
if (present.includes('tasks.md')) {
|
|
270
|
+
const markdown = readFileSync(join(dir, 'tasks.md'), 'utf8')
|
|
271
|
+
items = parseTasks(markdown)
|
|
272
|
+
redSection = detectRedSection(markdown)
|
|
273
|
+
if (items.length === 0) problems.push('tasks.md contains no checkbox task lines')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const specDir = join(dir, 'specs')
|
|
277
|
+
const specFiles = existsSync(specDir) ? walkSpecs(specDir, specDir) : []
|
|
278
|
+
|
|
279
|
+
for (const f of missing) problems.push(`missing or empty: ${f}`)
|
|
280
|
+
|
|
281
|
+
const done = items.filter(t => t.done).length
|
|
282
|
+
return {
|
|
283
|
+
change: changeName,
|
|
284
|
+
path: dir,
|
|
285
|
+
exists: true,
|
|
286
|
+
ready: missing.length === 0 && items.length > 0,
|
|
287
|
+
present,
|
|
288
|
+
missing,
|
|
289
|
+
tasks: { total: items.length, done, remaining: items.length - done, items, redSection },
|
|
290
|
+
specFiles,
|
|
291
|
+
problems
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function walkSpecs(dir, base) {
|
|
296
|
+
const out = []
|
|
297
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
298
|
+
const p = join(dir, entry.name)
|
|
299
|
+
if (entry.isDirectory()) out.push(...walkSpecs(p, base))
|
|
300
|
+
else if (entry.name.endsWith('.md')) out.push(p.slice(base.length + 1))
|
|
301
|
+
}
|
|
302
|
+
return out.sort()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function formatInspection(r) {
|
|
306
|
+
const lines = []
|
|
307
|
+
lines.push(
|
|
308
|
+
r.ready
|
|
309
|
+
? `READY — ${r.change}: ${r.tasks.remaining} of ${r.tasks.total} task(s) remaining`
|
|
310
|
+
: `NOT READY — ${r.change}`
|
|
311
|
+
)
|
|
312
|
+
if (r.present.length) lines.push(` present: ${r.present.join(', ')}`)
|
|
313
|
+
if (r.specFiles.length) lines.push(` specs: ${r.specFiles.length} file(s)`)
|
|
314
|
+
for (const p of r.problems) lines.push(` problem: ${p}`)
|
|
315
|
+
return lines.join('\n') + '\n'
|
|
316
|
+
}
|
package/lib/autonomy.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The earned-autonomy ladder.
|
|
2
|
+
//
|
|
3
|
+
// Every gated path starts at L2 (human in the loop) and is promoted to L3
|
|
4
|
+
// (autonomous) only after PROMOTE_THRESHOLD consecutive clean runs. A blocker or
|
|
5
|
+
// a human override resets the counter and demotes L3 back to L2.
|
|
6
|
+
//
|
|
7
|
+
// Autonomy changes *whether the flow waits for a human*, never *whether the
|
|
8
|
+
// quality gates run*. A blocker is a hard stop at every level.
|
|
9
|
+
//
|
|
10
|
+
// Pure fs + JSON: no agent, no network, no clock beyond timestamps.
|
|
11
|
+
// Exposed to skills as `interlock autonomy <level|record|state> ...`.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
const STATE_FILE = join('.claude', 'autonomy.json')
|
|
17
|
+
const PROMOTE_THRESHOLD = 3
|
|
18
|
+
|
|
19
|
+
// Upstream paths whose counters reset when a downstream gate fails.
|
|
20
|
+
// Key = downstream gate, Value = upstream paths that get blamed.
|
|
21
|
+
//
|
|
22
|
+
// This is what stops a path gaming its way to L3: `spec` cannot earn autonomy by
|
|
23
|
+
// emitting shallow "no open questions" output, because when the artifact gate
|
|
24
|
+
// catches the resulting bad spec it resets `spec` and `explore` too. Clean
|
|
25
|
+
// passes credit the same chain in reverse via recordCleanFlow().
|
|
26
|
+
//
|
|
27
|
+
// Blame is one hop, deliberately: a review-code blocker blames `ship`, and does
|
|
28
|
+
// not cascade further back to `spec` / `explore`.
|
|
29
|
+
const TRANSITIVE_BLAME = {
|
|
30
|
+
'review-artifacts': ['explore', 'spec'],
|
|
31
|
+
'review-code': ['ship']
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statePath(root) {
|
|
35
|
+
return root ? join(root, STATE_FILE) : STATE_FILE
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function load(root) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(statePath(root), 'utf8'))
|
|
41
|
+
} catch {
|
|
42
|
+
return {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function save(state, root) {
|
|
47
|
+
const p = statePath(root)
|
|
48
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
49
|
+
writeFileSync(p, JSON.stringify(state, null, 2) + '\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureEntry(state, path) {
|
|
53
|
+
if (!state[path]) {
|
|
54
|
+
state[path] = {
|
|
55
|
+
runs: 0,
|
|
56
|
+
human_overrides: 0,
|
|
57
|
+
blockers_caught: 0,
|
|
58
|
+
consecutive_clean: 0,
|
|
59
|
+
current_level: 'L2',
|
|
60
|
+
last_promoted_at: null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return state[path]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function record(path, outcome, opts = {}) {
|
|
67
|
+
const { root } = opts
|
|
68
|
+
const state = load(root)
|
|
69
|
+
const entry = ensureEntry(state, path)
|
|
70
|
+
|
|
71
|
+
entry.runs++
|
|
72
|
+
|
|
73
|
+
if (outcome.human_override) {
|
|
74
|
+
entry.human_overrides++
|
|
75
|
+
entry.consecutive_clean = 0
|
|
76
|
+
if (entry.current_level === 'L3') {
|
|
77
|
+
entry.current_level = 'L2'
|
|
78
|
+
entry.last_demoted_at = new Date().toISOString()
|
|
79
|
+
}
|
|
80
|
+
} else if (outcome.blockers > 0) {
|
|
81
|
+
entry.blockers_caught += outcome.blockers
|
|
82
|
+
entry.consecutive_clean = 0
|
|
83
|
+
|
|
84
|
+
// Transitive blame: reset upstream paths that fed into this gate
|
|
85
|
+
const upstreams = TRANSITIVE_BLAME[path] || []
|
|
86
|
+
for (const upstream of upstreams) {
|
|
87
|
+
const upEntry = ensureEntry(state, upstream)
|
|
88
|
+
upEntry.consecutive_clean = 0
|
|
89
|
+
if (upEntry.current_level === 'L3') {
|
|
90
|
+
upEntry.current_level = 'L2'
|
|
91
|
+
upEntry.last_demoted_at = new Date().toISOString()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
entry.consecutive_clean++
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
state[path] = entry
|
|
99
|
+
save(state, root)
|
|
100
|
+
|
|
101
|
+
return promote(path, state, opts)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function recordCleanFlow(paths, opts = {}) {
|
|
105
|
+
const { root } = opts
|
|
106
|
+
const state = load(root)
|
|
107
|
+
const results = []
|
|
108
|
+
for (const path of paths) {
|
|
109
|
+
const entry = ensureEntry(state, path)
|
|
110
|
+
entry.runs++
|
|
111
|
+
entry.consecutive_clean++
|
|
112
|
+
state[path] = entry
|
|
113
|
+
results.push({ path, consecutive_clean: entry.consecutive_clean })
|
|
114
|
+
}
|
|
115
|
+
save(state, root)
|
|
116
|
+
return results.map(r => ({ ...r, ...promote(r.path, state, opts) }))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function promote(path, stateOverride, opts = {}) {
|
|
120
|
+
const { root } = opts
|
|
121
|
+
const state = stateOverride || load(root)
|
|
122
|
+
const entry = state[path]
|
|
123
|
+
if (!entry) return { promoted: false, reason: 'no-entry' }
|
|
124
|
+
|
|
125
|
+
if (entry.current_level === 'L2' && entry.consecutive_clean >= PROMOTE_THRESHOLD) {
|
|
126
|
+
entry.current_level = 'L3'
|
|
127
|
+
entry.last_promoted_at = new Date().toISOString()
|
|
128
|
+
save(state, root)
|
|
129
|
+
return { promoted: true, from: 'L2', to: 'L3', consecutive_clean: entry.consecutive_clean }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
promoted: false,
|
|
134
|
+
current_level: entry.current_level,
|
|
135
|
+
consecutive_clean: entry.consecutive_clean,
|
|
136
|
+
needed: PROMOTE_THRESHOLD
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getLevel(path, opts = {}) {
|
|
141
|
+
const state = load(opts.root)
|
|
142
|
+
return state[path]?.current_level || 'L2'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getState(opts = {}) {
|
|
146
|
+
return load(opts.root)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export { PROMOTE_THRESHOLD, TRANSITIVE_BLAME }
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Spec conformance — does the code actually do what the delta specs say?
|
|
2
|
+
//
|
|
3
|
+
// The oldest criticism of spec-driven anything: prose specifications are not
|
|
4
|
+
// unambiguous, and nothing proves the code conforms to them. Interlock checks
|
|
5
|
+
// that tests pass and that the diff survives review, but neither of those asks
|
|
6
|
+
// the question the specs were written to answer — *was the described behaviour
|
|
7
|
+
// built?* A change can be green, reviewed, shipped, and still not implement the
|
|
8
|
+
// scenario its own spec set out.
|
|
9
|
+
//
|
|
10
|
+
// This module does the deterministic half: enumerate every scenario the change
|
|
11
|
+
// promised and pair it with the files the change touched. It renders a
|
|
12
|
+
// checklist. It NEVER decides whether a scenario is satisfied — that is a
|
|
13
|
+
// judgement about prose against code, which is the model's job, and pretending
|
|
14
|
+
// otherwise would be inventing a verdict from a regex.
|
|
15
|
+
//
|
|
16
|
+
// WHY IT DOES NOT BLOCK. Same reason as lib/drift.mjs, and the reasoning is not
|
|
17
|
+
// worth restating twice: a model matching prose scenarios to code produces a
|
|
18
|
+
// probabilistic answer, and a gate that halts a run on one would be wrong often
|
|
19
|
+
// enough to be switched off. A gate everyone disables protects nothing. So the
|
|
20
|
+
// checklist is emitted, an agent fills it in, and a person reads the result.
|
|
21
|
+
//
|
|
22
|
+
// Exposed to skills as `interlock conformance --change <name>`.
|
|
23
|
+
|
|
24
|
+
import { inspectChange, isCoverableSource, resolveChange } from './artifacts.mjs'
|
|
25
|
+
import { extractScenarios, readArtifactsFromDisk } from './ready.mjs'
|
|
26
|
+
|
|
27
|
+
/** Delta specs are the behavioural contract. A scenario in prose elsewhere is context. */
|
|
28
|
+
const SPEC_ARTIFACT = /^specs\//
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the conformance checklist for a change.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} root
|
|
34
|
+
* @param {string} [requested] change name; resolved when omitted
|
|
35
|
+
* @param {{changed?: string[]}} [opts] the run's diff, for pairing
|
|
36
|
+
*/
|
|
37
|
+
export function buildChecklist(root = '.', requested, opts = {}) {
|
|
38
|
+
const resolved = resolveChange(root, requested)
|
|
39
|
+
if (resolved.error) {
|
|
40
|
+
return { skipped: true, reason: resolved.error, change: null, scenarios: [], changedFiles: [] }
|
|
41
|
+
}
|
|
42
|
+
const change = resolved.name
|
|
43
|
+
|
|
44
|
+
const inspection = inspectChange(root, change)
|
|
45
|
+
if (!inspection || !inspection.exists) {
|
|
46
|
+
return { skipped: true, reason: `change "${change}" has no artifacts on disk`, change, scenarios: [], changedFiles: [] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const artifacts = readArtifactsFromDisk(inspection)
|
|
50
|
+
const all = extractScenarios(artifacts)
|
|
51
|
+
const fromSpecs = all.filter(s => SPEC_ARTIFACT.test(s.artifact))
|
|
52
|
+
|
|
53
|
+
// Scenarios written into proposal.md or design.md are not the contract, but
|
|
54
|
+
// silently dropping them would hide a change that put its only scenarios in
|
|
55
|
+
// the wrong file — which looks identical to a change with no scenarios.
|
|
56
|
+
const outsideSpecs = all.length - fromSpecs.length
|
|
57
|
+
|
|
58
|
+
const scenarios = fromSpecs.map((s, i) => ({
|
|
59
|
+
id: `C${i + 1}`,
|
|
60
|
+
artifact: s.artifact,
|
|
61
|
+
line: s.line,
|
|
62
|
+
title: s.title,
|
|
63
|
+
body: s.body.trim()
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
const changedFiles = (Array.isArray(opts.changed) ? opts.changed : []).filter(isCoverableSource)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
skipped: false,
|
|
70
|
+
reason: null,
|
|
71
|
+
change,
|
|
72
|
+
scenarios,
|
|
73
|
+
outsideSpecs,
|
|
74
|
+
changedFiles,
|
|
75
|
+
// Stated rather than implied: this module produced the questions, not the
|
|
76
|
+
// answers, and a consumer that treats an unanswered checklist as a pass has
|
|
77
|
+
// misread it.
|
|
78
|
+
verdictsRequired: scenarios.length
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Human-readable checklist. One numbered question per scenario, nothing decided. */
|
|
83
|
+
export function formatChecklist(result) {
|
|
84
|
+
const lines = []
|
|
85
|
+
if (result.skipped) {
|
|
86
|
+
lines.push(`CONFORMANCE — not built: ${result.reason}`)
|
|
87
|
+
lines.push('')
|
|
88
|
+
lines.push('Nothing here blocks.')
|
|
89
|
+
return lines.join('\n')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!result.scenarios.length) {
|
|
93
|
+
lines.push(`CONFORMANCE — no scenarios in the delta specs for "${result.change}"`)
|
|
94
|
+
if (result.outsideSpecs) {
|
|
95
|
+
lines.push('')
|
|
96
|
+
lines.push(
|
|
97
|
+
` ${result.outsideSpecs} scenario heading${result.outsideSpecs === 1 ? '' : 's'} exist outside specs/ ` +
|
|
98
|
+
`(proposal.md or design.md). Those are context, not contract — if they describe`
|
|
99
|
+
)
|
|
100
|
+
lines.push(' behaviour this change promises, they belong in a delta spec.')
|
|
101
|
+
} else {
|
|
102
|
+
lines.push('')
|
|
103
|
+
lines.push(' A change may legitimately carry none. But a change that promised behaviour and')
|
|
104
|
+
lines.push(' wrote no scenario has nothing to conform to, which is worth noticing.')
|
|
105
|
+
}
|
|
106
|
+
lines.push('')
|
|
107
|
+
lines.push('Nothing here blocks.')
|
|
108
|
+
return lines.join('\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
lines.push(
|
|
112
|
+
`CONFORMANCE — ${result.scenarios.length} scenario${result.scenarios.length === 1 ? '' : 's'} to verify for "${result.change}"`
|
|
113
|
+
)
|
|
114
|
+
lines.push('')
|
|
115
|
+
for (const s of result.scenarios) {
|
|
116
|
+
lines.push(` ${s.id} ${s.title}`)
|
|
117
|
+
lines.push(` ${s.artifact}:${s.line}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (result.changedFiles.length) {
|
|
121
|
+
lines.push('')
|
|
122
|
+
lines.push(` Changed source files to check against (${result.changedFiles.length}):`)
|
|
123
|
+
for (const f of result.changedFiles) lines.push(` ${f}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
lines.push('')
|
|
127
|
+
lines.push(' These are questions, not results. Each needs an agent to read the implementation')
|
|
128
|
+
lines.push(' and answer with a file:line citation. An unanswered checklist is not a pass.')
|
|
129
|
+
lines.push('')
|
|
130
|
+
lines.push('Nothing here blocks. Conformance is reported so a person can decide.')
|
|
131
|
+
return lines.join('\n')
|
|
132
|
+
}
|