@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.
Binary file
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "interlock",
3
3
  "displayName": "Interlock",
4
- "version": "1.2.0",
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 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
- this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ The format follows Keep a Changelog 1.1.0, and
6
+ this project adheres to Semantic Versioning 2.0.0.
7
7
 
8
- ## [Unreleased]
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 [ntfy](https://ntfy.sh) 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 `https://ntfy.sh`); 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.
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`** (`npm i -g @renzrollon/interlock`, or `npx -p @renzrollon/interlock interlock ...`), and the plugin install path is untouched.
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 [renzrollon/shippable-skills](https://github.com/renzrollon/shippable-skills). It is no longer part of this plugin. Install it with `npx skills add renzrollon/shippable-skills` (or the Claude Code plugin on that repo). The live-session retro was never Interlock-specific; shipping it here blocked Cursor, Copilot, and Codex.
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 [dynamic workflow](https://code.claude.com/docs/en/workflows)
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
- ## [0.1.0] — 2026-08-12
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 { composeCheckpointMessage, postNtfy, readNotifyConfig, NOTIFY_TOPIC_ENV } from '../lib/notify.mjs'
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 INTERLOCK_NTFY_TOPIC,
175
- server from INTERLOCK_NTFY_URL
176
- (default https://ntfy.sh). Unset
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 }
@@ -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: 'npm install -g openspec (requires Node 20.19.0+).'
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: 'npm install -g openspec && openspec init'
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 two environment variable NAMES
919
- * `lib/notify.mjs` reads (`INTERLOCK_NTFY_TOPIC`, `INTERLOCK_NTFY_URL`) by
920
- * literal string rather than by importing that module: this file has no
921
- * other reason to depend on the network transport, and the two spellings are
922
- * pinned identically in both modules' own test suites. The topic value is
923
- * never read into a string that could reach the report; only the server URL,
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 = env && typeof env.INTERLOCK_NTFY_TOPIC === 'string' ? env.INTERLOCK_NTFY_TOPIC.trim() : ''
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 && typeof env.INTERLOCK_NTFY_URL === 'string' && env.INTERLOCK_NTFY_URL.trim()
938
- ? env.INTERLOCK_NTFY_URL.trim()
939
- : 'https://ntfy.sh'
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 \`claude plugin eval\` is enabled`)
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 eval harness is unavailable — no ship run needs it`,
1435
- { fix: `export ${EVAL_ENABLEMENT_VAR}=1 to run \`claude plugin eval\`. Nothing else changes.` }
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
@@ -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 `claude plugin eval` writes at
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 ntfy.sh (or a
2
- // self-hosted server) so a ship run that stops while nobody is watching can
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 ntfy.sh has on a
15
- // public server; it is a capability, not a label. `readNotifyConfig`
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
- /** Where the topic is read from. Set by the operator, never by the repo. */
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. Unset means the public default. */
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
- /** The public ntfy server, used when `NOTIFY_URL_ENV` is unset. */
40
- export const DEFAULT_NTFY_URL = 'https://ntfy.sh'
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 from the environment. Never the tree — `INTERLOCK_
65
- * NTFY_TOPIC` and `INTERLOCK_NTFY_URL` are the whole of it (design D2).
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} env environment to read (injected in tests; `process.env` at call sites)
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(env) {
74
- const rawTopic = env && typeof env[NOTIFY_TOPIC_ENV] === 'string' ? env[NOTIFY_TOPIC_ENV].trim() : ''
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 rawUrl =
79
- env && typeof env[NOTIFY_URL_ENV] === 'string' && env[NOTIFY_URL_ENV].trim()
80
- ? env[NOTIFY_URL_ENV].trim()
81
- : DEFAULT_NTFY_URL
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 ntfy
160
- * documents as the supported encoding for exactly this case (docs.ntfy.sh
161
- * §Publish → "ntfy supports UTF-8 in HTTP headers, but not every library or
162
- * programming language does", checked 2026-09-07).
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 ntfy message. Never throws: every failure mode — invalid
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.0",
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"
@@ -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 eval 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
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
- `claude plugin eval` prints `` `plugin eval` is currently in early access `` and
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: Bash Read Write Edit Glob Grep
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
 
@@ -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 | python3 -c '
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
- }