@renzrollon/interlock 1.2.0 → 1.2.1
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/icon.png +0 -0
- package/.claude-plugin/plugin.json +16 -1
- package/CHANGELOG.md +15 -10
- package/bin/interlock +12 -4
- package/hooks/preflight.mjs +42 -1
- package/lib/doctor.mjs +23 -18
- package/lib/evals-triage.mjs +1 -1
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/package.json +3 -2
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +2 -2
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/.claude-plugin/marketplace.json +0 -19
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "interlock",
|
|
3
3
|
"displayName": "Interlock",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.1",
|
|
5
5
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Carl Rollon",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"homepage": "https://github.com/renzrollon/interlock",
|
|
11
11
|
"repository": "https://github.com/renzrollon/interlock",
|
|
12
|
+
"icon": ".claude-plugin/icon.png",
|
|
12
13
|
"license": "MIT",
|
|
13
14
|
"keywords": [
|
|
14
15
|
"openspec",
|
|
@@ -18,6 +19,20 @@
|
|
|
18
19
|
"code-review",
|
|
19
20
|
"knowledge-graph"
|
|
20
21
|
],
|
|
22
|
+
"userConfig": {
|
|
23
|
+
"ntfy_topic": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"title": "ntfy topic",
|
|
26
|
+
"description": "Optional. A topic for one push when a ship run stops or finishes. Leave empty to send nothing. Anyone who knows the topic can read the messages.",
|
|
27
|
+
"sensitive": true
|
|
28
|
+
},
|
|
29
|
+
"ntfy_url": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"title": "ntfy server",
|
|
32
|
+
"description": "Optional. Server to post to. Leave empty to use the public server.",
|
|
33
|
+
"sensitive": false
|
|
34
|
+
}
|
|
35
|
+
},
|
|
21
36
|
"workflows": "./workflows",
|
|
22
37
|
"types": "./types/index.d.ts",
|
|
23
38
|
"hooks": {
|
package/CHANGELOG.md
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
-
The format follows
|
|
6
|
-
this project adheres to
|
|
5
|
+
The format follows Keep a Changelog 1.1.0, and
|
|
6
|
+
this project adheres to Semantic Versioning 2.0.0.
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## 1.2.1 — 2026-10-06
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
+
- **A listing icon, and a push topic the operator chooses to share.** `.claude-plugin/icon.png` is the listing image. A Claude Code user can set an ntfy topic and an optional server in the plugin's options; the topic is sensitive. Session start copies a set option into the session for `interlock notify` when the operator has not already provided one. `INTERLOCK_NTFY_TOPIC` and `INTERLOCK_NTFY_URL` still work, and a value already provided that way wins. `/interlock:fix-tests` no longer pre-approves every shell command. `/interlock:spec` filters `openspec instructions` through a bundled Node script.
|
|
13
|
+
|
|
12
14
|
- **The ship meter says how long a run has been quiet.** A subscription run that reaches a plan window pauses instead of failing, and from the status line that pause looked exactly like a hang. While a run is live, `hooks/mod.mjs` now stamps the engine's time of each thing the run does: the launch, each step the CLI prints, each request a run agent starts and each answer it gets, each agent's turn end, each spawn. Once nothing has moved for `meter quiet after (ms)`, ` · quiet <n> min` follows the position on the status line and the spinner, and the pane gains a `last activity <time>` line with the word beside it. The next activity clears it. A lane thinking through one long request is not called quiet, because the request's start and its answer both count. The word names no cause: the plan windows stay beside it on the pane as the session reports them, with no threshold and no colour.
|
|
13
15
|
- **Two caps, published, not restated.** `interlock limits` prints `meter quiet after (ms)` and `meter tick (ms)`. Both are keys in `lib/limits.mjs`, read by a new pure, Node-free `lib/meter-quiet.mjs` that the hooks module imports, and `test/spine/limits.test.mjs` fails if the module restates either.
|
|
14
16
|
- **One timer, the host's own.** The figure is recomputed on `$.clock.every`, the engine's periodic dispatch, which the module starts at the launch and cancels at the close, a halt and the session boundary. A redraw happens only when the word changes. A sleep inside a hook would have been charged to that hook's own time budget, so `$.clock.sleep` joins the module's forbidden tokens. The pins' allow-list gains `$.clock.every` and nothing else.
|
|
@@ -105,6 +107,11 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
105
107
|
|
|
106
108
|
### Changed
|
|
107
109
|
|
|
110
|
+
- **Plugin installs now receive exactly what npm publishes, at the version npm carries.** The marketplace entry used `"source": "./"`, so `/plugin install interlock@interlock` copied the whole checkout from the default branch: 847 files, including `openspec/`, `docs/`, `test/` and `site/`, and any untagged commit on main. It now names the `release` branch of this repository. A new workflow, `.github/workflows/plugin-bundle.yml`, fills that branch after every successful Release run: it fetches the published tarball back from the registry, checks that its `plugin.json` carries the released version, validates it with `claude plugin validate --strict`, and commits it unpacked and unchanged. Plugin users move only when a release does. The same branch is what Anthropic's plugin directory tracks, and the bundle fits the directory's limits (138 files, none over 256 KiB) where the checkout raised two review holds.
|
|
111
|
+
- **The tarball is the whole plugin now, so a test says so.** `test/spine/package.test.mjs` walks every path the plugin reaches at runtime: the component directories, the manifest's `workflows` and `types`, each CLAUDE_PLUGIN_ROOT path that a hook command, skill, agent, shared contract, workflow or briefing names, and the import closure of every script among them. It fails on any path that no `files` entry ships or that does not exist. It also pins the marketplace entry's source, the bundle workflow's trigger, package and branch, and that both release workflows stay tracked past `.gitignore`.
|
|
112
|
+
- **`files` ships `.claude-plugin/plugin.json` alone.** The bundle is one plugin, so it carries no marketplace manifest naming a source of its own.
|
|
113
|
+
- **The branch is seeded, and rolled back, by hand.** Dispatch the workflow with a version already on npm. Re-running it for a version the branch already holds commits nothing.
|
|
114
|
+
|
|
108
115
|
- **The launch rule moved into one Node-free module that both forms of the guard import.** `lib/launch-rule.mjs` holds what a ship launch is, what a human prompt is, the decision and its words, and three record transforms, `emptyRecord`, `withLaunch` and `withPrompt`. It imports only `./limits.mjs`. `lib/launch-ledger.mjs` keeps the file and re-exports every rule name unchanged, so `hooks/guard-relaunch.mjs` and `test/hooks.test.mjs` import what they imported before. `recordLaunch` and `recordPrompt` now apply `withLaunch` and `withPrompt`, so the file and the session record change by the same functions. One visible effect: a launch written to a ledger now drops earlier launches past the published age, which never changed a verdict. `test/spine/launch-ledger.test.mjs` pins that each re-exported name is the rule module's own object, that the rule names no `node:` import, and that `decideLaunch` over a plain record and over the same facts read from a file returns the same verdict and reason.
|
|
109
116
|
|
|
110
117
|
- **The hooks module's observe-only pin now allows exactly one refusal instead of none.** `test/spine/mod-pins.test.mjs` no longer forbids the token `deny` outright. It allows one line, `if (launch && verdict.decision === 'deny') return { deny: verdict.reason }`, with `verdict` assigned once from `decideLaunch` imported from `lib/launch-rule.mjs`, and fails naming any other. The allow-list grows by exactly `$.clock.now`, `$.state.get` and `$.state.set`. The module's session-state keys must equal the contract's, and its hooked events are pinned, `prompt.submit` and `session.receive` included.
|
|
@@ -196,14 +203,14 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
196
203
|
|
|
197
204
|
- **An unattended ship run that stops can now reach you.** Four independent "quick win" gaps closed at the one seam both hosts already pass through — `interlock run close`:
|
|
198
205
|
|
|
199
|
-
- **Push on halt or completion.** With `INTERLOCK_NTFY_TOPIC` set, `run close --notify` — which both `workflows/ship.js` and `bin/interlock-run` now pass on every close — posts one
|
|
206
|
+
- **Push on halt or completion.** With `INTERLOCK_NTFY_TOPIC` set, `run close --notify` — which both `workflows/ship.js` and `bin/interlock-run` now pass on every close — posts one ntfy message per terminal outcome: `high` priority on `SHIP HALTED`, default otherwise. The message carries the summary's first line, the change and the run id, and nothing that identifies the machine — never the topic, the cwd or the project slug. `INTERLOCK_NTFY_URL` selects a self-hosted server (default the public server); leaving the topic unset makes the default path, and the test suite, open no connection at all. A failed push prints `push: failed — <reason>` and a `PUSH FAILED: <reason>` degradation banner and never moves the exit code. `interlock notify` and the `interlock notify checkpoint <change>` sugar (used by `/interlock:spec`'s checkpoint) are also callable standalone.
|
|
200
207
|
- **The summary names the run and where it closed.** Every terminal summary now carries `run: <runId>` (or `run: none — the run halted before a plan was adopted`), `project: <slug>` and `cwd: <absolute path>` rows, so a reader who was not watching can find the trajectory file and the checkout afterwards. `<slug>` is a pure function of the closing directory (`lib/project-slug.mjs`), matching the directory name Claude Code uses under `~/.claude/projects`.
|
|
201
208
|
- **A clean completion reminds you to archive.** When every task is ticked and the run did not halt, the summary prints `ARCHIVE PENDING — <change>: after merge, run openspec archive <change>` — plus `also unarchived: <n> completed change(s) — run interlock drift` when other changes are also sitting there — right before the closing "do not start another run" line. It is a reminder, never a gate: the exit code never moves, because the shipped change satisfies the unarchived check by construction at every clean close.
|
|
202
209
|
- **`interlock doctor` stops reporting green for a run whose first ping prompts.** `REQUIRED_COMMANDS` gains `test`, `printenv` and `mkdir` — the commands the environment probe actually shells out to — each with a `why`, and a new `notify` row (`ok` naming the server, `skip` naming `INTERLOCK_NTFY_TOPIC`, never the value). A repo-fact test now checks this repository's own `.claude/settings.json` against the derived list, and a drift test extracts every instructed command out of `workflows/ship.js` and `lib/prompts/*.mjs` and fails, naming the file and the command, the moment one falls outside `REQUIRED_COMMANDS`, the runner commands, or the documented host read-only set (`HOST_READ_ONLY`).
|
|
203
210
|
|
|
204
211
|
All four are additive: unset `INTERLOCK_NTFY_TOPIC` and a run behaves exactly as before, plus the three new identity rows and, on a clean ship, the archive line. See [when it stops](docs/04-when-it-stops.md) for `ARCHIVE PENDING` and `PUSH FAILED`, and [push notifications](docs/07-cli-and-configuration.md#the-one-network-call-push-notifications) for the two environment variables.
|
|
205
212
|
|
|
206
|
-
- **The CLIs install without Claude Code.** `package.json` was `private: true`, so the only way onto a PATH was a git checkout or the plugin install — even though the policy engine, the graph and the runner are stdlib Node with no dependencies and each is useful outside a Claude Code session. The package publishes as **`@renzrollon/interlock`** (
|
|
213
|
+
- **The CLIs install without Claude Code.** `package.json` was `private: true`, so the only way onto a PATH was a git checkout or the plugin install — even though the policy engine, the graph and the runner are stdlib Node with no dependencies and each is useful outside a Claude Code session. The package publishes as **`@renzrollon/interlock`** (install that package globally), and the plugin install path is untouched.
|
|
207
214
|
|
|
208
215
|
What ships is a **whitelist**, not an ignore list: `files` names `bin`, `lib`, `workflows`, `skills`, `hooks`, `agents`, `shared`, `.claude-plugin` and the three root documents, so a new directory cannot ship by accident. The failure that whitelist invites is the opposite one — a reachable `lib/` file left out packs cleanly, installs cleanly, and dies at the user's first command — so `test/spine/package.test.mjs` walks the transitive relative-import closure of **every declared `bin` entry** (including the bare side-effect `import` that is how `bin/interlock-graph` reaches the whole graph CLI) and fails naming any reached file no `files` entry covers. It also asserts each `bin` entry exists, is executable and carries the node shebang, and that `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` carry **one** `MAJOR.MINOR.PATCH` version — the `bump-release` skill keeps bumping them together, and a partial bump is now a red build rather than a habit that held.
|
|
209
216
|
|
|
@@ -283,7 +290,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
283
290
|
|
|
284
291
|
### Removed
|
|
285
292
|
|
|
286
|
-
- **`/interlock:session-retro`** moved to
|
|
293
|
+
- **`/interlock:session-retro`** moved to renzrollon/shippable-skills. It is no longer part of this plugin. Install it from that repository (or the Claude Code plugin on that repo). The live-session retro was never Interlock-specific; shipping it here blocked Cursor, Copilot, and Codex.
|
|
287
294
|
|
|
288
295
|
### Changed
|
|
289
296
|
|
|
@@ -299,7 +306,7 @@ The second **made them true** — the ship loop moved out of prose and into a
|
|
|
299
306
|
dynamic-workflow script backed by a policy CLI, and the opt-in continuity path
|
|
300
307
|
gained the fail-closed readiness gate it needs.
|
|
301
308
|
|
|
302
|
-
> **Breaking:** `/interlock:ship` is now a
|
|
309
|
+
> **Breaking:** `/interlock:ship` is now a dynamic workflow
|
|
303
310
|
> rather than a skill. Interlock requires **Claude Code v2.1.154+ with dynamic
|
|
304
311
|
> workflows enabled**. Where workflows are disabled — `disableWorkflows`, org
|
|
305
312
|
> policy, `CLAUDE_CODE_DISABLE_WORKFLOWS`, or a Pro plan that has not enabled
|
|
@@ -482,7 +489,7 @@ that on should be a decision made against real data rather than an intuition.
|
|
|
482
489
|
And earned autonomy stays storage-only (§4.2 D) for the same reason: a feature
|
|
483
490
|
that gates a workflow should earn its own trust first.
|
|
484
491
|
|
|
485
|
-
##
|
|
492
|
+
## 0.1.0 — 2026-08-12
|
|
486
493
|
|
|
487
494
|
Initial release. Interlock is a Claude Code plugin that layers autonomous
|
|
488
495
|
spec-driven orchestration on top of the OpenSpec CLI.
|
|
@@ -540,5 +547,3 @@ spec-driven orchestration on top of the OpenSpec CLI.
|
|
|
540
547
|
languages get docs and spec indexing plus the full workflow, but no
|
|
541
548
|
import/symbol edges — the skills state this rather than implying completeness.
|
|
542
549
|
- The autonomy ladder ships opt-in until it has accumulated real run data.
|
|
543
|
-
|
|
544
|
-
[0.1.0]: https://github.com/renzrollon/interlock/releases/tag/v0.1.0
|
package/bin/interlock
CHANGED
|
@@ -68,7 +68,13 @@ import { haltStep } from '../lib/run.mjs'
|
|
|
68
68
|
import { explicitStateHome, resolveStateHome } from '../lib/state-home.mjs'
|
|
69
69
|
import { probeClaudeEffort, probeClaudeVersion } from '../lib/host/claude-cli.mjs'
|
|
70
70
|
import { observeClaudeEnv, runtimeSlotsOf } from '../lib/host/claude-env.mjs'
|
|
71
|
-
import {
|
|
71
|
+
import {
|
|
72
|
+
composeCheckpointMessage,
|
|
73
|
+
postNtfy,
|
|
74
|
+
readNotifyConfig,
|
|
75
|
+
NOTIFY_TOPIC_ENV,
|
|
76
|
+
DEFAULT_NTFY_URL
|
|
77
|
+
} from '../lib/notify.mjs'
|
|
72
78
|
import { classifySurface } from '../lib/surface.mjs'
|
|
73
79
|
import { evaluateGate, formatGate, TOLERANCE_BAND } from '../lib/findings.mjs'
|
|
74
80
|
import { inspectChange, resolveChange, listChanges, formatInspection, tickTasks, planCoverage } from '../lib/artifacts.mjs'
|
|
@@ -171,9 +177,11 @@ Usage:
|
|
|
171
177
|
[--priority high|default] checkpoint reached while nobody was
|
|
172
178
|
interlock notify checkpoint <change> watching still reaches someone. The
|
|
173
179
|
only outbound request this CLI makes.
|
|
174
|
-
Topic from
|
|
175
|
-
server from
|
|
176
|
-
|
|
180
|
+
Topic from the plugin option or
|
|
181
|
+
INTERLOCK_NTFY_TOPIC, server from
|
|
182
|
+
the plugin option or
|
|
183
|
+
INTERLOCK_NTFY_URL
|
|
184
|
+
(default ${DEFAULT_NTFY_URL}). Unset
|
|
177
185
|
topic prints "not configured" and
|
|
178
186
|
exits 0; a failed push exits 1.
|
|
179
187
|
--json emits { sent, reason }
|
package/hooks/preflight.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// directory alone, as before.
|
|
25
25
|
|
|
26
26
|
import { execFileSync } from 'node:child_process'
|
|
27
|
-
import { existsSync, realpathSync } from 'node:fs'
|
|
27
|
+
import { appendFileSync, existsSync, realpathSync } from 'node:fs'
|
|
28
28
|
import { dirname, join, resolve } from 'node:path'
|
|
29
29
|
import { fileURLToPath } from 'node:url'
|
|
30
30
|
|
|
@@ -166,7 +166,48 @@ function report(message) {
|
|
|
166
166
|
process.stdout.write(JSON.stringify(out) + '\n')
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/** A single-quoted shell word. A value with a newline or a NUL is refused. */
|
|
170
|
+
function shellQuote(value) {
|
|
171
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function publishable(value) {
|
|
175
|
+
return typeof value === 'string' && value.trim() && !/[\0\r\n]/.test(value)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Copy a plugin option into the session so `interlock notify` can see it.
|
|
180
|
+
*
|
|
181
|
+
* The host injects `userConfig` into hook processes only. A ship run's notify
|
|
182
|
+
* call is not a hook, so without this copy a topic typed into the plugin
|
|
183
|
+
* options would never reach the CLI. An operator who already provided the
|
|
184
|
+
* shell variable keeps that value. Nothing is printed, and a failure here
|
|
185
|
+
* does not change the preflight.
|
|
186
|
+
*/
|
|
187
|
+
function publishNotifyOptions() {
|
|
188
|
+
const file = process.env.CLAUDE_ENV_FILE
|
|
189
|
+
if (typeof file !== 'string' || !file) return
|
|
190
|
+
const lines = []
|
|
191
|
+
const topic = process.env.CLAUDE_PLUGIN_OPTION_NTFY_TOPIC
|
|
192
|
+
const url = process.env.CLAUDE_PLUGIN_OPTION_NTFY_URL
|
|
193
|
+
if (publishable(topic) && !publishable(process.env.INTERLOCK_NTFY_TOPIC)) {
|
|
194
|
+
lines.push(`export INTERLOCK_NTFY_TOPIC=${shellQuote(topic.trim())}`)
|
|
195
|
+
}
|
|
196
|
+
if (publishable(url) && !publishable(process.env.INTERLOCK_NTFY_URL)) {
|
|
197
|
+
lines.push(`export INTERLOCK_NTFY_URL=${shellQuote(url.trim())}`)
|
|
198
|
+
}
|
|
199
|
+
if (!lines.length) return
|
|
200
|
+
appendFileSync(file, lines.join('\n') + '\n')
|
|
201
|
+
}
|
|
202
|
+
|
|
169
203
|
try {
|
|
204
|
+
try {
|
|
205
|
+
publishNotifyOptions()
|
|
206
|
+
} catch (err) {
|
|
207
|
+
process.stderr.write(
|
|
208
|
+
`interlock preflight could not publish notify options (ignored): ${(err && err.message) || err}\n`
|
|
209
|
+
)
|
|
210
|
+
}
|
|
170
211
|
const { message, stateHome } = run()
|
|
171
212
|
const lines = [message, ...(await interruptedLines(process.cwd(), stateHome))].filter(Boolean)
|
|
172
213
|
if (lines.length) report(lines.join('\n'))
|
package/lib/doctor.mjs
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
readModelRouting
|
|
79
79
|
} from './host/claude-env.mjs'
|
|
80
80
|
import { RUNTIME } from './limits.mjs'
|
|
81
|
+
import { DEFAULT_NTFY_URL } from './notify-defaults.mjs'
|
|
81
82
|
import { STATE_HOME_ENV, resolveStateHome } from './state-home.mjs'
|
|
82
83
|
|
|
83
84
|
/** The installed copy of the plugin — the one whose workflow and agents run. */
|
|
@@ -203,8 +204,7 @@ export const REQUIRED_COMMANDS = Object.freeze([
|
|
|
203
204
|
* Commands Claude Code auto-approves as read-only no matter what the
|
|
204
205
|
* project's own allowlist says, so a briefing that instructs one of these
|
|
205
206
|
* needs no rule of its own and the drift test below must not demand one.
|
|
206
|
-
* Source: the Claude Code permissions documentation
|
|
207
|
-
* (https://docs.claude.com/en/docs/claude-code/permissions), checked
|
|
207
|
+
* Source: the Claude Code permissions documentation, checked
|
|
208
208
|
* 2026-09-07 — a host claim this module cannot itself verify, so it is dated
|
|
209
209
|
* and cited rather than asserted bare. If the host narrows this list the
|
|
210
210
|
* failure is loud: the drift test below goes red naming the command, never a
|
|
@@ -586,11 +586,11 @@ function checkOpenSpec(root, env, probe) {
|
|
|
586
586
|
if (configured) {
|
|
587
587
|
return check('openspec', 'warn', 'the openspec CLI is not on PATH; ship reads artifacts from disk, but /interlock:spec and /interlock:bootstrap need it', {
|
|
588
588
|
evidence: ['openspec/config.yaml is present, so existing changes are still readable'],
|
|
589
|
-
fix: '
|
|
589
|
+
fix: 'Install the openspec command globally. It needs Node.js 20.19.0 or newer.'
|
|
590
590
|
})
|
|
591
591
|
}
|
|
592
592
|
return check('openspec', 'fail', 'no openspec CLI on PATH and no openspec/ project in this repo — neither authoring nor shipping a change can start', {
|
|
593
|
-
fix: '
|
|
593
|
+
fix: 'Install the openspec command globally (Node.js 20.19.0 or newer), then run `openspec init` in the project root.'
|
|
594
594
|
})
|
|
595
595
|
}
|
|
596
596
|
|
|
@@ -915,17 +915,22 @@ function checkPermissions(root, required, { home, sources } = {}) {
|
|
|
915
915
|
/**
|
|
916
916
|
* Advice, never a gate: push notification is an optional feature, so this row
|
|
917
917
|
* is `ok` or `skip` and NEVER `fail` — an operator who never configured it has
|
|
918
|
-
* not degraded their run. Reads the same
|
|
919
|
-
* `
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
* never read into a string that could reach the report;
|
|
924
|
-
* which is not a capability, is echoed.
|
|
918
|
+
* not degraded their run. Reads the same names `lib/notify.mjs` reads — the
|
|
919
|
+
* plugin options `CLAUDE_PLUGIN_OPTION_NTFY_TOPIC` / `CLAUDE_PLUGIN_OPTION_NTFY_URL`
|
|
920
|
+
* and the shell variables `INTERLOCK_NTFY_TOPIC` / `INTERLOCK_NTFY_URL` — by
|
|
921
|
+
* literal string rather than by importing the transport. The default server
|
|
922
|
+
* comes from `notify-defaults.mjs`, so this row cannot name a different one.
|
|
923
|
+
* The topic value is never read into a string that could reach the report;
|
|
924
|
+
* only the server URL, which is not a capability, is echoed.
|
|
925
925
|
*/
|
|
926
|
+
function configuredValue(env, name) {
|
|
927
|
+
return env && typeof env[name] === 'string' && env[name].trim() ? env[name].trim() : ''
|
|
928
|
+
}
|
|
929
|
+
|
|
926
930
|
function checkNotify(env) {
|
|
927
931
|
try {
|
|
928
|
-
const topic =
|
|
932
|
+
const topic =
|
|
933
|
+
configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC') || configuredValue(env, 'INTERLOCK_NTFY_TOPIC')
|
|
929
934
|
if (!topic) {
|
|
930
935
|
return check(
|
|
931
936
|
'notify',
|
|
@@ -934,9 +939,9 @@ function checkNotify(env) {
|
|
|
934
939
|
)
|
|
935
940
|
}
|
|
936
941
|
const url =
|
|
937
|
-
env
|
|
938
|
-
|
|
939
|
-
|
|
942
|
+
configuredValue(env, 'CLAUDE_PLUGIN_OPTION_NTFY_URL') ||
|
|
943
|
+
configuredValue(env, 'INTERLOCK_NTFY_URL') ||
|
|
944
|
+
DEFAULT_NTFY_URL
|
|
940
945
|
return check('notify', 'ok', `push configured: ${url}`)
|
|
941
946
|
} catch (err) {
|
|
942
947
|
// Fail-open, and never `fail`: an unreadable env is a reason to skip the
|
|
@@ -1427,12 +1432,12 @@ export function evalPrerequisiteChecks(pluginRoot, env = process.env) {
|
|
|
1427
1432
|
|
|
1428
1433
|
return [
|
|
1429
1434
|
enabled
|
|
1430
|
-
? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so
|
|
1435
|
+
? check('evals-harness', 'ok', `${EVAL_ENABLEMENT_VAR} is set, so the early-access harness is enabled`)
|
|
1431
1436
|
: check(
|
|
1432
1437
|
'evals-harness',
|
|
1433
1438
|
'skip',
|
|
1434
|
-
`${EVAL_ENABLEMENT_VAR} is not set, so the
|
|
1435
|
-
{ fix: `
|
|
1439
|
+
`${EVAL_ENABLEMENT_VAR} is not set, so the early-access harness is unavailable — no ship run needs it`,
|
|
1440
|
+
{ fix: `Set one of the harness prerequisites by assigning ${EVAL_ENABLEMENT_VAR} the value 1. Nothing else changes.` }
|
|
1436
1441
|
),
|
|
1437
1442
|
credential
|
|
1438
1443
|
? // The NAME of the variable, never its value. Reporting which one is
|
package/lib/evals-triage.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function graderName(grader, i) {
|
|
|
49
49
|
return grader && typeof grader.name === 'string' && grader.name ? grader.name : `grader-${i + 1}`
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// Two results shapes reach triage: the one
|
|
52
|
+
// Two results shapes reach triage: the one the harness writes at
|
|
53
53
|
// schemaVersion 1 (`name`, `arms.with` / `arms['with-only']`) and the legacy
|
|
54
54
|
// `id` / `runs` shape the offline unit tests construct. Normalizing both at the
|
|
55
55
|
// case boundary keeps `classifyCase` the single classification rule — a second
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The server used when an operator names a topic and no server.
|
|
2
|
+
// One spelling, imported by the transport, the doctor and the CLI help,
|
|
3
|
+
// so those three cannot drift.
|
|
4
|
+
|
|
5
|
+
/** The public server, used when no server was configured. */
|
|
6
|
+
export const DEFAULT_NTFY_URL = 'https://ntfy.sh'
|
package/lib/notify.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The one network call this CLI ever makes: a push to
|
|
2
|
-
//
|
|
3
|
-
// still reach someone.
|
|
1
|
+
// The one network call this CLI ever makes: a push to the configured server
|
|
2
|
+
// (the public default, or a server the operator named) so a ship run that
|
|
3
|
+
// stops while nobody is watching can still reach someone.
|
|
4
4
|
//
|
|
5
5
|
// Three properties are load-bearing, and every one of them exists because
|
|
6
6
|
// this module is the CLI's only exception to "no network":
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// so there is no settings file this module could misread in the first
|
|
12
12
|
// place.
|
|
13
13
|
//
|
|
14
|
-
// 2. THE TOPIC NEVER LEAVES. The topic is the only auth
|
|
15
|
-
//
|
|
14
|
+
// 2. THE TOPIC NEVER LEAVES. The topic is the only auth the public server
|
|
15
|
+
// has; it is a capability, not a label. `readNotifyConfig`
|
|
16
16
|
// never echoes it, `sanitizeReason` strips it (and the server URL) from
|
|
17
17
|
// every failure reason before that reason reaches a summary line, a
|
|
18
18
|
// receipt event or the trajectory, because a `fetch` failure's own
|
|
@@ -29,15 +29,23 @@
|
|
|
29
29
|
// design D1, D2, D4, D5, D14 (add-harden-unattended-ship-runs).
|
|
30
30
|
|
|
31
31
|
import { LIMITS } from './limits.mjs'
|
|
32
|
+
import { DEFAULT_NTFY_URL } from './notify-defaults.mjs'
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
export { DEFAULT_NTFY_URL }
|
|
35
|
+
|
|
36
|
+
/** Where the topic is read from. Chosen by the operator, never by the repo. */
|
|
34
37
|
export const NOTIFY_TOPIC_ENV = 'INTERLOCK_NTFY_TOPIC'
|
|
35
38
|
|
|
36
|
-
/** Where a self-hosted server is read from.
|
|
39
|
+
/** Where a self-hosted server is read from. Absent means the public default. */
|
|
37
40
|
export const NOTIFY_URL_ENV = 'INTERLOCK_NTFY_URL'
|
|
38
41
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Plugin options the host injects into a hook process, from `userConfig`
|
|
44
|
+
* `ntfy_topic` and `ntfy_url`. Preferred over the shell variables when present,
|
|
45
|
+
* so a value the operator typed into the plugin is the one that is shared.
|
|
46
|
+
*/
|
|
47
|
+
export const NOTIFY_TOPIC_OPTION = 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC'
|
|
48
|
+
export const NOTIFY_URL_OPTION = 'CLAUDE_PLUGIN_OPTION_NTFY_URL'
|
|
41
49
|
|
|
42
50
|
function messageOf(err) {
|
|
43
51
|
return (err && err.message) || String(err)
|
|
@@ -60,26 +68,34 @@ function urlShapeInvalid(url) {
|
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
function trimmed(source, name) {
|
|
72
|
+
if (!source || typeof source[name] !== 'string') return ''
|
|
73
|
+
return source[name].trim()
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
|
-
* Read push configuration
|
|
65
|
-
*
|
|
77
|
+
* Read push configuration. Never the tree. A plugin option the host injected
|
|
78
|
+
* wins over the shell variable of the same meaning, and an absent topic is
|
|
79
|
+
* unconfigured rather than an error (design D2). The shell variables remain
|
|
80
|
+
* the channel for a terminal, CI, and any host that does not inject options.
|
|
66
81
|
*
|
|
67
|
-
* @param {object}
|
|
82
|
+
* @param {object} source values to read (injected in tests; the process values at call sites)
|
|
68
83
|
* @returns {{topic: string, url: string} | {topic: null} | {invalid: string}}
|
|
69
84
|
* `{ topic: null }` when push is simply unconfigured — not an error, the
|
|
70
85
|
* default and by far the common case. `{ invalid: '<VAR>' }` names the
|
|
71
86
|
* offending variable and never its value.
|
|
72
87
|
*/
|
|
73
|
-
export function readNotifyConfig(
|
|
74
|
-
const
|
|
88
|
+
export function readNotifyConfig(source) {
|
|
89
|
+
const optionTopic = trimmed(source, NOTIFY_TOPIC_OPTION)
|
|
90
|
+
const shellTopic = trimmed(source, NOTIFY_TOPIC_ENV)
|
|
91
|
+
const rawTopic = optionTopic || shellTopic
|
|
75
92
|
if (!rawTopic) return { topic: null }
|
|
76
|
-
if (topicShapeInvalid(rawTopic)) return { invalid: NOTIFY_TOPIC_ENV }
|
|
93
|
+
if (topicShapeInvalid(rawTopic)) return { invalid: optionTopic ? NOTIFY_TOPIC_OPTION : NOTIFY_TOPIC_ENV }
|
|
77
94
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (urlShapeInvalid(rawUrl)) return { invalid: NOTIFY_URL_ENV }
|
|
95
|
+
const optionUrl = trimmed(source, NOTIFY_URL_OPTION)
|
|
96
|
+
const shellUrl = trimmed(source, NOTIFY_URL_ENV)
|
|
97
|
+
const rawUrl = optionUrl || shellUrl || DEFAULT_NTFY_URL
|
|
98
|
+
if (urlShapeInvalid(rawUrl)) return { invalid: optionUrl ? NOTIFY_URL_OPTION : NOTIFY_URL_ENV }
|
|
83
99
|
|
|
84
100
|
return { topic: rawTopic, url: rawUrl }
|
|
85
101
|
}
|
|
@@ -156,10 +172,10 @@ export function sanitizeReason(reason, { topic, url } = {}) {
|
|
|
156
172
|
* all: EVERY headline this module sends contains an em dash (`SHIP COMPLETE —
|
|
157
173
|
* <change>`), HTTP header values are ByteStrings, and Node's `fetch` throws
|
|
158
174
|
* `Cannot convert argument to a ByteString` rather than transmitting one. So
|
|
159
|
-
* a non-ASCII value is sent as an RFC 2047 base64 encoded-word, which
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
175
|
+
* a non-ASCII value is sent as an RFC 2047 base64 encoded-word, which the
|
|
176
|
+
* server's publish documentation names as the supported encoding for exactly
|
|
177
|
+
* this case ("UTF-8 in HTTP headers" is not accepted by every client,
|
|
178
|
+
* checked 2026-09-07).
|
|
163
179
|
*
|
|
164
180
|
* A pure-ASCII value is passed through untouched, so the common `interlock
|
|
165
181
|
* notify --title` case stays readable on the wire.
|
|
@@ -175,7 +191,7 @@ export function encodeHeaderValue(value) {
|
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
/**
|
|
178
|
-
* Post one
|
|
194
|
+
* Post one message. Never throws: every failure mode — invalid
|
|
179
195
|
* configuration, a non-2xx status, a network error, the timeout — resolves
|
|
180
196
|
* to `{ sent: false, reason }`, sanitized, so the caller can always print
|
|
181
197
|
* something and never has to catch anything.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@renzrollon/interlock",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"types",
|
|
22
22
|
"agents",
|
|
23
23
|
"shared",
|
|
24
|
-
".claude-plugin",
|
|
24
|
+
".claude-plugin/plugin.json",
|
|
25
|
+
".claude-plugin/icon.png",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE",
|
|
27
28
|
"CHANGELOG.md"
|
package/skills/evals/SKILL.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
name: evals
|
|
3
3
|
description: Author an Interlock eval case from an observed model-behaviour failure, and read a completed eval results run. Use when you have a reproduced failure — an archived proposal, a changelog entry, a run artifact, or a transcript — worth regression-testing against a real model, or when an eval run has finished and you need to know whether it is a regression, run-to-run variance, or no signal. Authoring needs cited evidence; the verdict comes from `interlock evals triage`, never from your own reading.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility: Requires the early-access
|
|
6
|
-
allowed-tools: Bash(interlock *) Bash(claude plugin eval
|
|
5
|
+
compatibility: Requires the early-access harness (claude plugin eval, enabled with CLAUDE_CODE_WALNUT_SPIRE=1) to RUN a suite. Authoring a case and triaging an existing results file need only Node.js >= 18 and the bundled interlock CLI.
|
|
6
|
+
allowed-tools: Bash(interlock *) Bash(claude plugin eval:*) Read Grep Glob Write
|
|
7
7
|
metadata:
|
|
8
8
|
type: authoring
|
|
9
9
|
outputs:
|
|
@@ -148,7 +148,7 @@ test until it passes" failure the suite exists to prevent, one layer up.
|
|
|
148
148
|
|
|
149
149
|
## 5. When the harness is gated
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
The host command claude plugin eval prints that it is currently in early access and
|
|
152
152
|
does nothing until `CLAUDE_CODE_WALNUT_SPIRE=1` is set in the environment — never
|
|
153
153
|
in committed `.claude/settings.json`, which would produce a suite that looks
|
|
154
154
|
configured and does not run.
|
|
@@ -4,7 +4,7 @@ description: Discover and persist how a project's tests run, then run the suite
|
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires the project's own test tooling. Node.js >= 18 for the bundled interlock CLI.
|
|
6
6
|
argument-hint: "[--e2e] [--dry-run] [--baseline <ref>] [--from-log <path>] [--reconfigure]"
|
|
7
|
-
allowed-tools:
|
|
7
|
+
allowed-tools: Read Write Edit Glob Grep
|
|
8
8
|
metadata:
|
|
9
9
|
type: execution
|
|
10
10
|
outputs:
|
|
@@ -40,7 +40,7 @@ Persist the profile and mirror it into the `## Testing` block of the first instr
|
|
|
40
40
|
|
|
41
41
|
## 2. Run and baseline
|
|
42
42
|
|
|
43
|
-
Run the unit command. Record `total`, `passed`, `failed`, `skipped` — this is the baseline that step 6 asserts against.
|
|
43
|
+
Run the unit command under the permissions already granted to this project. This skill does not pre-approve a shell. Record `total`, `passed`, `failed`, `skipped` — this is the baseline that step 6 asserts against.
|
|
44
44
|
|
|
45
45
|
Split failures into:
|
|
46
46
|
|
package/skills/spec/SKILL.md
CHANGED
|
@@ -128,12 +128,7 @@ Parse `applyRequires` (artifacts needed before implementation), `artifacts` (eac
|
|
|
128
128
|
Then loop, in dependency order, over every artifact that is `ready`:
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
|
-
openspec instructions <artifact-id> --change "<name>" --json |
|
|
132
|
-
import json,sys
|
|
133
|
-
d=json.load(sys.stdin)
|
|
134
|
-
keep=("instruction","template","resolvedOutputPath","context","rules")
|
|
135
|
-
print(json.dumps({k:d[k] for k in keep if d.get(k)}, separators=(",",":")))
|
|
136
|
-
'
|
|
131
|
+
openspec instructions <artifact-id> --change "<name>" --json | node "${CLAUDE_PLUGIN_ROOT}/skills/spec/scripts/compact-instructions.mjs"
|
|
137
132
|
```
|
|
138
133
|
|
|
139
134
|
Filter each ready artifact independently; do not dump the full instructions response or prefetch every artifact. The compact response carries `instruction` (schema guidance), `template` (the structure to fill), `resolvedOutputPath`, and non-empty `context` / `rules`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Compact an `openspec instructions --json` payload to the fields an author fills.
|
|
2
|
+
// Shipped in the plugin so the spec skill does not inline another interpreter.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
|
|
6
|
+
const keep = ['instruction', 'template', 'resolvedOutputPath', 'context', 'rules']
|
|
7
|
+
|
|
8
|
+
let raw
|
|
9
|
+
try {
|
|
10
|
+
raw = readFileSync(0, 'utf8')
|
|
11
|
+
} catch (err) {
|
|
12
|
+
process.stderr.write(`compact-instructions: could not read stdin: ${(err && err.message) || err}\n`)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let data
|
|
17
|
+
try {
|
|
18
|
+
data = JSON.parse(raw)
|
|
19
|
+
} catch (err) {
|
|
20
|
+
process.stderr.write(`compact-instructions: stdin is not JSON: ${(err && err.message) || err}\n`)
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const out = {}
|
|
25
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
26
|
+
for (const key of keep) {
|
|
27
|
+
if (data[key]) out[key] = data[key]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
process.stdout.write(JSON.stringify(out))
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "interlock",
|
|
3
|
-
"description": "Spec-driven development orchestration for Claude Code. Layers autonomous multi-agent workflows on top of OpenSpec: repo onboarding, reviewed specs, and start-to-commit shipping.",
|
|
4
|
-
"owner": {
|
|
5
|
-
"name": "Carl Rollon",
|
|
6
|
-
"url": "https://github.com/renzrollon"
|
|
7
|
-
},
|
|
8
|
-
"plugins": [
|
|
9
|
-
{
|
|
10
|
-
"name": "interlock",
|
|
11
|
-
"source": "./",
|
|
12
|
-
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec.",
|
|
13
|
-
"version": "1.2.0",
|
|
14
|
-
"author": {
|
|
15
|
-
"name": "Carl Rollon"
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
]
|
|
19
|
-
}
|