@renzrollon/interlock 1.1.0 → 1.2.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 +1 -1
- package/.claude-plugin/plugin.json +94 -1
- package/CHANGELOG.md +142 -0
- package/bin/interlock +413 -65
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +83 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +488 -28
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +153 -45
- package/lib/meter-quiet.mjs +45 -0
- package/lib/plan-fingerprint.mjs +83 -13
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1193 -85
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +285 -83
- package/package.json +2 -1
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/fix-tests/SKILL.md +1 -1
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
package/bin/interlock-run
CHANGED
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
// this file runs `git worktree add`; `run record-batch` folds
|
|
23
23
|
// and removes them, and halts on a real collision (design D7).
|
|
24
24
|
// the transport one agent process per lane, prompt inline on stdin.
|
|
25
|
-
// the banners which host, which billing path, which guards are absent,
|
|
26
|
-
// which spawns could not have their model
|
|
25
|
+
// the banners which host, which billing path, which guards are absent,
|
|
26
|
+
// which spawns could not have their model or their effort
|
|
27
|
+
// applied, and whether an exported effort override outranks
|
|
28
|
+
// the plan (design D8; dispatch-published-effort D2, D9, D16).
|
|
27
29
|
//
|
|
28
30
|
// Everything else — wave order, caps, verdicts, the review, the receipt — is
|
|
29
31
|
// behind `runCli` on purpose, so a second host cannot quietly grow a second copy
|
|
@@ -36,8 +38,10 @@
|
|
|
36
38
|
import { execFileSync } from 'node:child_process'
|
|
37
39
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
38
40
|
import { dirname, join } from 'node:path'
|
|
41
|
+
import { laneEnv } from '../lib/host.mjs'
|
|
39
42
|
import { createHost, drivesClaudeBinary, HOST_IDS } from '../lib/host/registry.mjs'
|
|
40
43
|
import { MODEL_MAP_ENV } from '../lib/host/model-map.mjs'
|
|
44
|
+
import { EFFORT_LEVEL_ENV, effortLevelOverride } from '../lib/host/effort.mjs'
|
|
41
45
|
import { CODEX_KEY_ENVS, onChatGptPlan } from '../lib/host/codex.mjs'
|
|
42
46
|
|
|
43
47
|
const WORK = '.claude/ship'
|
|
@@ -222,6 +226,41 @@ const hostId =
|
|
|
222
226
|
// because the adapter is the only party that saw the wire.
|
|
223
227
|
const routing = []
|
|
224
228
|
|
|
229
|
+
// Every spawn that named an effort, and what the adapter managed to do with it.
|
|
230
|
+
// A list of its own beside `routing`, folded the same way: on a negotiated host
|
|
231
|
+
// the outcome differs per spawn, so the verdict is the adapter's event and never
|
|
232
|
+
// one this file derives from the host id or the step (design D2, D27).
|
|
233
|
+
const effortRouting = []
|
|
234
|
+
|
|
235
|
+
// What the host observed about each spawn of the CURRENT step, copied off the
|
|
236
|
+
// adapter's `spawn-done` / `spawn-failed` events as `{ label, ...host }` and
|
|
237
|
+
// handed to the CLI on its own channel, `--host-records`, beside — never inside
|
|
238
|
+
// — the results (design D7). This file reads no field of a record: which lane
|
|
239
|
+
// was stopped, denied or served by another model is the CLI's decision. An
|
|
240
|
+
// adapter that reports no host leaves the list empty and every lane unobserved.
|
|
241
|
+
let hostRecords = []
|
|
242
|
+
|
|
243
|
+
// A runner killed with SIGINT or SIGTERM closes the run through `stop()` like
|
|
244
|
+
// any other halt, so it leaves a receipt, a terminal trajectory event and a
|
|
245
|
+
// resume card instead of a run nobody can reconstruct. Registered before the
|
|
246
|
+
// host is created: a signal that lands during the adapter's synchronous probe is
|
|
247
|
+
// held until the first await — the wait on `run start` — and handled there,
|
|
248
|
+
// rather than killing the process by default. `stopping` makes it one close: a
|
|
249
|
+
// second signal, or a signal during a close already under way, starts nothing.
|
|
250
|
+
// In-flight lane processes are not killed here; each adapter's spawn timeout
|
|
251
|
+
// bounds them.
|
|
252
|
+
let stopping = false
|
|
253
|
+
function onSignal(signal) {
|
|
254
|
+
if (stopping) return
|
|
255
|
+
stopping = true
|
|
256
|
+
stop(`interlock-run received ${signal}`).catch(err => {
|
|
257
|
+
process.stderr.write(`interlock-run: the close after ${signal} failed: ${(err && err.message) || err}\n`)
|
|
258
|
+
process.exit(1)
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
process.once('SIGINT', () => onSignal('SIGINT'))
|
|
262
|
+
process.once('SIGTERM', () => onSignal('SIGTERM'))
|
|
263
|
+
|
|
225
264
|
// Refusal comes BEFORE anything is created or written. A run that started and
|
|
226
265
|
// then discovered it had no host would already have a manifest, a briefing and a
|
|
227
266
|
// trajectory describing a run that never happened.
|
|
@@ -234,6 +273,7 @@ try {
|
|
|
234
273
|
...(hostId === 'acp' && typeof flags['acp-command'] === 'string'
|
|
235
274
|
? { command: flags['acp-command'] }
|
|
236
275
|
: {}),
|
|
276
|
+
env: laneEnv(process.env),
|
|
237
277
|
cwd: root,
|
|
238
278
|
timeoutMs: Number.isInteger(Number.parseInt(String(flags['timeout-ms'] ?? ''), 10))
|
|
239
279
|
? Number.parseInt(String(flags['timeout-ms']), 10)
|
|
@@ -250,6 +290,25 @@ try {
|
|
|
250
290
|
}
|
|
251
291
|
return
|
|
252
292
|
}
|
|
293
|
+
if (
|
|
294
|
+
event &&
|
|
295
|
+
(event.type === 'spawn-done' || event.type === 'spawn-failed') &&
|
|
296
|
+
event.host &&
|
|
297
|
+
typeof event.host === 'object'
|
|
298
|
+
) {
|
|
299
|
+
hostRecords.push({ label: event.label, ...event.host })
|
|
300
|
+
}
|
|
301
|
+
if (event && event.type === 'effort-routing') {
|
|
302
|
+
effortRouting.push(event)
|
|
303
|
+
if (verbose) {
|
|
304
|
+
process.stderr.write(
|
|
305
|
+
`${hostId}: effort routing ${event.label}: ${event.requested} → ` +
|
|
306
|
+
(event.applied ? `${event.value} via ${event.via}` : `not applied (${event.reason})`) +
|
|
307
|
+
'\n'
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
return
|
|
311
|
+
}
|
|
253
312
|
if (verbose) process.stderr.write(`${hostId}: ${JSON.stringify(event)}\n`)
|
|
254
313
|
}
|
|
255
314
|
})
|
|
@@ -268,13 +327,29 @@ const capabilities = host.capabilities || {}
|
|
|
268
327
|
// them in one block with everything else the run degraded on (design D8).
|
|
269
328
|
const banners = [`RUNNER HOST: ${hostId} (experimental)`]
|
|
270
329
|
|
|
271
|
-
|
|
330
|
+
const claudeBinary = drivesClaudeBinary(
|
|
331
|
+
hostId,
|
|
332
|
+
process.env,
|
|
333
|
+
typeof flags['acp-command'] === 'string' ? flags['acp-command'] : undefined
|
|
334
|
+
)
|
|
335
|
+
if (claudeBinary) {
|
|
272
336
|
banners.push(
|
|
273
337
|
`SUBSCRIPTION PATH: programmatic (${hostId}) — claude -p, the Agent SDK and ACP are the usage ` +
|
|
274
338
|
`Anthropic has flagged for separate billing; /interlock:ship on the Workflow runtime is the ` +
|
|
275
339
|
`exempted path, see docs/04-when-it-stops.md`
|
|
276
340
|
)
|
|
277
341
|
}
|
|
342
|
+
// The Claude CLI's effort variable outranks `--effort` and every per-agent
|
|
343
|
+
// effort, so while it is set the plan's effort is not in effect. It means
|
|
344
|
+
// nothing to a host that is not the Claude binary (D22). It is the operator's
|
|
345
|
+
// environment: bannered, never stripped from the child's (D26).
|
|
346
|
+
const effortOverride = claudeBinary ? effortLevelOverride(process.env) : ''
|
|
347
|
+
if (effortOverride) {
|
|
348
|
+
banners.push(
|
|
349
|
+
`EFFORT ROUTING OVERRIDDEN: ${EFFORT_LEVEL_ENV}=${effortOverride} — every agent runs at ` +
|
|
350
|
+
`that effort, so the per-step effort in the plan is not in effect`
|
|
351
|
+
)
|
|
352
|
+
}
|
|
278
353
|
if (hostId === 'codex' && onChatGptPlan(process.env)) {
|
|
279
354
|
banners.push(
|
|
280
355
|
`CHATGPT PLAN PATH (codex): neither ${CODEX_KEY_ENVS.join(' nor ')} is set, so this run spends ` +
|
|
@@ -282,6 +357,12 @@ if (hostId === 'codex' && onChatGptPlan(process.env)) {
|
|
|
282
357
|
`unattended volume to an API key`
|
|
283
358
|
)
|
|
284
359
|
}
|
|
360
|
+
// Whatever the adapter itself degraded on at creation — on the claude host, a
|
|
361
|
+
// mode that can prompt on a CLI that cannot be told not to (design D10). The
|
|
362
|
+
// adapter wrote the words; this file only carries them to the close.
|
|
363
|
+
for (const banner of Array.isArray(host.banners) ? host.banners : []) {
|
|
364
|
+
if (typeof banner === 'string' && banner) banners.push(banner)
|
|
365
|
+
}
|
|
285
366
|
if (capabilities.hooks === false) {
|
|
286
367
|
banners.push(
|
|
287
368
|
`HOOKS NOT IN FORCE (${hostId}): this repository's PreToolUse guards are Claude Code's, so ` +
|
|
@@ -310,6 +391,27 @@ function routingBanners() {
|
|
|
310
391
|
]
|
|
311
392
|
}
|
|
312
393
|
|
|
394
|
+
/**
|
|
395
|
+
* What the run has to say about effort routing, read off the adapter's events.
|
|
396
|
+
*
|
|
397
|
+
* The model twin's shape, for the same reason: a run on which every named
|
|
398
|
+
* effort was applied says so, and a run on which none was named says nothing.
|
|
399
|
+
* Each banner line states the level as requested, taken from the event, so a
|
|
400
|
+
* host that could not apply an effort cannot be read as one that ran at it.
|
|
401
|
+
*/
|
|
402
|
+
function effortBanners() {
|
|
403
|
+
if (!effortRouting.length) return []
|
|
404
|
+
const unapplied = effortRouting.filter(event => event.applied === false)
|
|
405
|
+
if (!unapplied.length) {
|
|
406
|
+
return [`effort routing: applied on ${effortRouting.length}/${effortRouting.length} spawns`]
|
|
407
|
+
}
|
|
408
|
+
return [
|
|
409
|
+
`EFFORT ROUTING UNAVAILABLE (${hostId}): the plan's per-step effort assignment is not in effect ` +
|
|
410
|
+
`for the spawns below — each ran at the host's own default`,
|
|
411
|
+
...unapplied.map(event => ` — ${event.label}: ${event.requested} requested, ${event.reason}`)
|
|
412
|
+
]
|
|
413
|
+
}
|
|
414
|
+
|
|
313
415
|
let resolvedChange = changeArg || '(unresolved)'
|
|
314
416
|
let steps = 0
|
|
315
417
|
|
|
@@ -323,6 +425,9 @@ function write(rel, data) {
|
|
|
323
425
|
|
|
324
426
|
/** Run the CLI through the host port and parse `--json` stdout when present. */
|
|
325
427
|
async function cli(argv) {
|
|
428
|
+
// From the first close on, a signal starts no second one — whichever path
|
|
429
|
+
// reached the close: the loop's own continuation, or `stop()`.
|
|
430
|
+
if (argv[0] === 'run' && argv[1] === 'close') stopping = true
|
|
326
431
|
const result = await host.runCli([...argv, '--json'])
|
|
327
432
|
if (!result.stdout.trim()) return null
|
|
328
433
|
try {
|
|
@@ -389,6 +494,9 @@ function spawnOne(s) {
|
|
|
389
494
|
prompt: s.prompt,
|
|
390
495
|
schema: s.schema,
|
|
391
496
|
...(s.model ? { model: s.model } : {}),
|
|
497
|
+
// Forwarded exactly as the step carries it, and only when it names one: a
|
|
498
|
+
// null effort reaches the host as no key at all, so it keeps its default.
|
|
499
|
+
...(s.effort ? { effort: s.effort } : {}),
|
|
392
500
|
...(s.worktree && s.worktree.path ? { cwd: join(root, s.worktree.path) } : {}),
|
|
393
501
|
type: s.type,
|
|
394
502
|
tools: s.tools
|
|
@@ -442,9 +550,11 @@ while (step && step.then) {
|
|
|
442
550
|
const worktreeProblem = createWorktrees(step)
|
|
443
551
|
if (worktreeProblem) await stop(worktreeProblem)
|
|
444
552
|
|
|
445
|
-
for (const s of step.spawns || []) log(` ${step.action}: ${s.label}`)
|
|
553
|
+
for (const s of step.spawns || []) log(` ${step.action}: ${s.title || s.label}`)
|
|
554
|
+
hostRecords = []
|
|
446
555
|
const results = await spawnAll(step)
|
|
447
556
|
write(`${WORK}/results.json`, results)
|
|
557
|
+
write(`${WORK}/host-records.json`, hostRecords)
|
|
448
558
|
// The close is the one continuation that needs more than the spawn results:
|
|
449
559
|
// it reads the banners only this host could raise. Appended here as well as
|
|
450
560
|
// at `stop()`, so a CLEAN run reports them too — the path least likely to
|
|
@@ -455,6 +565,8 @@ while (step && step.then) {
|
|
|
455
565
|
...(isClose ? closeArgs() : []),
|
|
456
566
|
'--results',
|
|
457
567
|
`${WORK}/results.json`,
|
|
568
|
+
'--host-records',
|
|
569
|
+
`${WORK}/host-records.json`,
|
|
458
570
|
'--root',
|
|
459
571
|
'.'
|
|
460
572
|
])
|
|
@@ -488,13 +600,13 @@ async function stop(reason) {
|
|
|
488
600
|
/** The flags the close reads for what only this host observed. */
|
|
489
601
|
function closeArgs() {
|
|
490
602
|
// Routing is resolved here rather than pushed as it happened, because the
|
|
491
|
-
// question it answers — did EVERY spawn get its model — is
|
|
492
|
-
// once the last spawn has returned.
|
|
603
|
+
// question it answers — did EVERY spawn get its model, and its effort — is
|
|
604
|
+
// only answerable once the last spawn has returned.
|
|
493
605
|
//
|
|
494
606
|
// `--notify` is unconditional: this driver knows nothing about topics,
|
|
495
607
|
// servers or notification text. It requests the push; the CLI decides
|
|
496
608
|
// whether one is configured and what it says (design D1).
|
|
497
|
-
return ['--host-banners', write(`${WORK}/host-banners.json`, [...banners, ...routingBanners()]), '--notify']
|
|
609
|
+
return ['--host-banners', write(`${WORK}/host-banners.json`, [...banners, ...routingBanners(), ...effortBanners()]), '--notify']
|
|
498
610
|
}
|
|
499
611
|
|
|
500
612
|
/** Print the close's summary and exit on its status. */
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launch guard: a second ship launch in one session, with no human prompt since
|
|
3
|
+
// the last, is denied (guard-ship-relaunch; spec: hooks/launch-guard).
|
|
4
|
+
//
|
|
5
|
+
// The most expensive recorded mistake was a parent chat calling Workflow again
|
|
6
|
+
// over leftover checkboxes — another 20+ agents, a full review cycle included —
|
|
7
|
+
// and the skill's sentence forbidding it was the only thing in the way. This
|
|
8
|
+
// bounds it outside the model. The prose and `evals/trampoline-launch` stay: the
|
|
9
|
+
// eval measures whether the model obeys, this bounds what happens when it does
|
|
10
|
+
// not.
|
|
11
|
+
//
|
|
12
|
+
// ONE SCRIPT, FOUR REGISTRATIONS, dispatching on `hook_event_name`:
|
|
13
|
+
//
|
|
14
|
+
// PreToolUse (Workflow) the only branch that may deny: a ship launch whose
|
|
15
|
+
// session ledger holds a launch newer than the last
|
|
16
|
+
// human prompt.
|
|
17
|
+
// PostToolUse (Workflow) records a ship launch the runtime accepted, then
|
|
18
|
+
// sweeps ledgers past the published age.
|
|
19
|
+
// UserPromptSubmit, record the time of a human prompt — only when the
|
|
20
|
+
// UserPromptExpansion session already has a ledger. The host's completion
|
|
21
|
+
// wake fires UserPromptSubmit too; it is not a human
|
|
22
|
+
// and is not counted (lib/launch-ledger.mjs).
|
|
23
|
+
//
|
|
24
|
+
// EVERY UNKNOWN ALLOWS: no session id, an unsafe one, a missing, malformed or
|
|
25
|
+
// stale ledger, a tool input that is not a ship launch, a tool that is not
|
|
26
|
+
// Workflow, an event with no branch, and the guard's own crash. A false deny
|
|
27
|
+
// costs one typed message; that is still a denial, so it is the only branch that
|
|
28
|
+
// can speak one. The recorder branches print no decision on any path; a ledger
|
|
29
|
+
// write that fails is a stderr line, because a guard that silently stopped
|
|
30
|
+
// recording is a guard that silently stopped denying.
|
|
31
|
+
//
|
|
32
|
+
// The rule and its words live in lib/launch-ledger.mjs. This file is plumbing.
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
decideLaunch,
|
|
36
|
+
isAcceptedLaunch,
|
|
37
|
+
isHumanPrompt,
|
|
38
|
+
isShipLaunch,
|
|
39
|
+
readLedger,
|
|
40
|
+
recordLaunch,
|
|
41
|
+
recordPrompt,
|
|
42
|
+
sweepStale
|
|
43
|
+
} from '../lib/launch-ledger.mjs'
|
|
44
|
+
import { readEvent, allow, deny, toolName, toolInput, projectRoot } from './_shared.mjs'
|
|
45
|
+
|
|
46
|
+
const GUARD = 'guard-relaunch'
|
|
47
|
+
|
|
48
|
+
/** The session id as the host sent it, or '' — an unsafe one is refused by the ledger module. */
|
|
49
|
+
const sessionOf = event => (typeof event.session_id === 'string' ? event.session_id.trim() : '')
|
|
50
|
+
|
|
51
|
+
function onPreToolUse(event) {
|
|
52
|
+
if (toolName(event) !== 'Workflow') allow()
|
|
53
|
+
const sessionId = sessionOf(event)
|
|
54
|
+
if (!sessionId) allow()
|
|
55
|
+
const root = projectRoot(event)
|
|
56
|
+
|
|
57
|
+
const problems = []
|
|
58
|
+
const ledger = readLedger(root, sessionId, { onProblem: message => problems.push(message) })
|
|
59
|
+
if (!isShipLaunch(toolInput(event), ledger)) allow()
|
|
60
|
+
if (problems.length) allow(`${GUARD}: ${problems.join('; ')} — allowing`)
|
|
61
|
+
|
|
62
|
+
const verdict = decideLaunch(ledger)
|
|
63
|
+
if (verdict.decision !== 'deny') allow()
|
|
64
|
+
deny(verdict.reason, {
|
|
65
|
+
guard: GUARD,
|
|
66
|
+
sessionId,
|
|
67
|
+
lastLaunchAt: verdict.lastLaunchAt,
|
|
68
|
+
lastHumanPromptAt: verdict.lastHumanPromptAt
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onPostToolUse(event) {
|
|
73
|
+
if (toolName(event) !== 'Workflow') return
|
|
74
|
+
const sessionId = sessionOf(event)
|
|
75
|
+
if (!sessionId) return
|
|
76
|
+
const root = projectRoot(event)
|
|
77
|
+
|
|
78
|
+
if (!isShipLaunch(toolInput(event), readLedger(root, sessionId))) return
|
|
79
|
+
if (!isAcceptedLaunch(event.tool_response)) return
|
|
80
|
+
const written = recordLaunch(root, sessionId, event.tool_response)
|
|
81
|
+
if (!written.written) {
|
|
82
|
+
process.stderr.write(`${GUARD}: ${written.reason}\n`)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
sweepStale(root, { keep: sessionId })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function onPrompt(event) {
|
|
89
|
+
const sessionId = sessionOf(event)
|
|
90
|
+
if (!sessionId || !isHumanPrompt(event.prompt)) return
|
|
91
|
+
const written = recordPrompt(projectRoot(event), sessionId)
|
|
92
|
+
// Only a ledger that exists and failed to take the write is worth a line;
|
|
93
|
+
// "no ledger for this session" is every prompt in every repository.
|
|
94
|
+
if (!written.written && written.reason && written.reason.startsWith('launch ledger not written')) {
|
|
95
|
+
process.stderr.write(`${GUARD}: ${written.reason}\n`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const BRANCHES = {
|
|
100
|
+
PreToolUse: onPreToolUse,
|
|
101
|
+
PostToolUse: onPostToolUse,
|
|
102
|
+
UserPromptSubmit: onPrompt,
|
|
103
|
+
UserPromptExpansion: onPrompt
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const event = await readEvent()
|
|
108
|
+
const branch = BRANCHES[event.hook_event_name]
|
|
109
|
+
if (branch) branch(event)
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// The guard's own crash must never block a launch or a prompt. Allow, and
|
|
112
|
+
// leave a diagnostic on stderr rather than a wedged session.
|
|
113
|
+
allow(`${GUARD}: internal error, allowing by default: ${(err && err.message) || String(err)}`)
|
|
114
|
+
}
|
|
115
|
+
process.exit(0)
|
package/hooks/guard-tests.mjs
CHANGED
|
@@ -14,8 +14,19 @@
|
|
|
14
14
|
// whenever the marker is missing would break ordinary TDD the moment the plugin
|
|
15
15
|
// is installed — a cost strictly larger than the guard's benefit, which exists
|
|
16
16
|
// only inside a ship run's remediation window.
|
|
17
|
+
//
|
|
18
|
+
// A linked worktree has no test profile of its own. `run start` records the one
|
|
19
|
+
// it found — the root's, else the main checkout's — as the manifest's
|
|
20
|
+
// `testProfilePath`, and when the root has none the guard reads that file, so a
|
|
21
|
+
// worktree run keeps its profile-derived test roots. It is read from the
|
|
22
|
+
// manifest, never found by running version control. A recorded path that cannot
|
|
23
|
+
// be read leaves the `.test.`/`.spec.` filename rule alone: the run had a
|
|
24
|
+
// profile, so test files exist, and the filename shape needs no roots. A
|
|
25
|
+
// manifest that records no path changes nothing.
|
|
17
26
|
|
|
18
|
-
import {
|
|
27
|
+
import { readFileSync } from 'node:fs'
|
|
28
|
+
import { join, resolve } from 'node:path'
|
|
29
|
+
import { readStage, SHIP_DIR } from '../lib/ship-stage.mjs'
|
|
19
30
|
import {
|
|
20
31
|
readEvent,
|
|
21
32
|
allow,
|
|
@@ -32,6 +43,33 @@ import {
|
|
|
32
43
|
const GUARD = 'guard-tests'
|
|
33
44
|
const BLOCKED_STAGES = new Set(['remediation', 'fix-tests'])
|
|
34
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The test profile the run recorded at start, read through the manifest's
|
|
48
|
+
* `testProfilePath`. `recorded: false` when there is no manifest or it records
|
|
49
|
+
* no path — the guard then behaves exactly as it did without one. `profile:
|
|
50
|
+
* null` with `recorded: true`, and the reason, when the path names nothing
|
|
51
|
+
* readable as JSON.
|
|
52
|
+
*
|
|
53
|
+
* @returns {{ recorded: boolean, profile: object|null, reason?: string }}
|
|
54
|
+
*/
|
|
55
|
+
function recordedTestProfile(root) {
|
|
56
|
+
let manifest
|
|
57
|
+
try {
|
|
58
|
+
manifest = JSON.parse(readFileSync(join(root, SHIP_DIR, 'run.json'), 'utf8'))
|
|
59
|
+
} catch {
|
|
60
|
+
return { recorded: false, profile: null }
|
|
61
|
+
}
|
|
62
|
+
const path = manifest && typeof manifest === 'object' ? manifest.testProfilePath : null
|
|
63
|
+
if (typeof path !== 'string' || !path) return { recorded: false, profile: null }
|
|
64
|
+
try {
|
|
65
|
+
const value = JSON.parse(readFileSync(resolve(root, path), 'utf8'))
|
|
66
|
+
if (value && typeof value === 'object') return { recorded: true, profile: value }
|
|
67
|
+
return { recorded: true, profile: null, reason: `${path}: not a profile object` }
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { recorded: true, profile: null, reason: `${path}: ${(err && err.message) || String(err)}` }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
try {
|
|
36
74
|
const event = await readEvent()
|
|
37
75
|
const tool = toolName(event)
|
|
@@ -44,8 +82,18 @@ try {
|
|
|
44
82
|
const target = editTargetPath(event)
|
|
45
83
|
if (!target) allow() // no concrete path to protect
|
|
46
84
|
|
|
47
|
-
const
|
|
48
|
-
|
|
85
|
+
const fromRoot = testRootsFromProfile(loadTestProfile(root))
|
|
86
|
+
let roots = fromRoot.roots
|
|
87
|
+
if (!fromRoot.hasProfile) {
|
|
88
|
+
const recorded = recordedTestProfile(root)
|
|
89
|
+
if (!recorded.recorded) allow() // no profile, none recorded → nothing names where tests live → allow
|
|
90
|
+
// The run's profile, derived exactly as a root profile is; unreadable → no
|
|
91
|
+
// roots, and the filename rule alone — said, not silent.
|
|
92
|
+
if (!recorded.profile) {
|
|
93
|
+
process.stderr.write(`${GUARD}: the test profile the run recorded could not be read, so only the filename rule applies: ${recorded.reason}\n`)
|
|
94
|
+
}
|
|
95
|
+
roots = testRootsFromProfile(recorded.profile).roots
|
|
96
|
+
}
|
|
49
97
|
|
|
50
98
|
if (!isTestPath(target, roots, root)) allow() // not a test file → allow (source repair is expected)
|
|
51
99
|
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "hooks": {}, "modules": ["./mod.mjs"] }
|