@renzrollon/interlock 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,148 @@
1
+ // The fold decision for `interlock merge-lanes`.
2
+ //
3
+ // `lib/waves.mjs` makes lanes in a batch path-disjoint over PREDICTED paths —
4
+ // but a prediction is a model's guess, and its own comment says a task that
5
+ // writes a file it never declared is invisible to that check. Running each
6
+ // lane in its own git worktree (workflows/ship.js `isolation:'worktree'`)
7
+ // turns isolation between lanes from a probabilistic property of the
8
+ // predictor into a filesystem fact; this module is the other half — folding
9
+ // those worktrees' ACTUAL writes back into the shared tree, and answering one
10
+ // question: did the batch's lanes really stay disjoint, or did two of them
11
+ // write the same file despite a disjoint prediction?
12
+ //
13
+ // Pure: no fs, no clock, no random — consistent with `adjudicateBatches` /
14
+ // `laneOutcomes` in workflows/ship.js. The caller (`bin/interlock`) does the
15
+ // git plumbing: locating each lane's worktree, diffing it against the shared
16
+ // base commit to build `changedByLane`, and applying a clean fold to the
17
+ // shared tree. This module only decides `clean` | `collision` | `error` from
18
+ // those already-observed facts.
19
+ //
20
+ // Fails closed in one direction: an uncertain fold (a label that cannot be
21
+ // resolved, a lane with no changed-file report) is an `error`, and a real
22
+ // path collision is a `collision` — neither ever resolves itself into a
23
+ // `clean` fold. A silent overwrite here would be the same defect
24
+ // `wave-isolation` exists to prevent, wearing a green check.
25
+
26
+ import { canonicalizePath } from './risk.mjs'
27
+
28
+ /**
29
+ * @typedef {{ label: string }} LaneRef
30
+ * The batch's lanes, identified by their stable label (`laneLabel(lane)` in
31
+ * workflows/ship.js) — this module never sees the task objects themselves.
32
+ */
33
+
34
+ /**
35
+ * Decide how a completed batch's lane worktrees fold into the shared tree.
36
+ *
37
+ * @param {object} input
38
+ * @param {LaneRef[]} input.lanes the batch's lanes, in dispatch order
39
+ * @param {Record<string, string[]|null|undefined>} input.changedByLane maps a
40
+ * lane label to the files that lane actually changed vs `base` (raw, as git
41
+ * reported them — not yet canonicalized). `null`/`undefined`/absent means
42
+ * the lane's changed-file set could not be observed (its worktree could not
43
+ * be located), which is a fold-time error, not an empty write.
44
+ * @param {string} [input.base] the shared-tree commit every lane's diff was
45
+ * taken against. Carried through for provenance; the decision itself is a
46
+ * function of `changedByLane`, which already reflects that diff.
47
+ * @returns {{
48
+ * status: 'clean'|'collision'|'error',
49
+ * folds: Array<{ lane: string, files: string[] }>,
50
+ * collisions: Array<{ canonicalPath: string, lanes: string[] }>,
51
+ * unresolved: Array<{ label: string, reason: string }>
52
+ * }}
53
+ */
54
+ export function mergeDecision({ lanes, changedByLane, base } = {}) {
55
+ void base // provenance only — the decision reads changedByLane, not base itself
56
+
57
+ const laneList = Array.isArray(lanes) ? lanes : []
58
+ const changed = changedByLane && typeof changedByLane === 'object' ? changedByLane : {}
59
+
60
+ const empty = { folds: [], collisions: [], unresolved: [] }
61
+
62
+ // Labels first: a lane this module cannot name is a lane it cannot fold —
63
+ // never a filesystem scan for a best guess. Absent, empty, non-string, and
64
+ // duplicate labels are all named rather than defaulted past.
65
+ const seen = new Map()
66
+ const unresolved = []
67
+ for (const lane of laneList) {
68
+ const label = lane && typeof lane.label === 'string' ? lane.label.trim() : ''
69
+ if (!label) {
70
+ unresolved.push({ label: '', reason: 'lane has no label — cannot locate its worktree' })
71
+ continue
72
+ }
73
+ if (seen.has(label)) {
74
+ unresolved.push({ label, reason: 'duplicate lane label within this batch' })
75
+ continue
76
+ }
77
+ seen.set(label, lane)
78
+ }
79
+ if (unresolved.length) return { status: 'error', ...empty, unresolved }
80
+
81
+ // Every named lane needs an observed changed-file set. Missing (rather than
82
+ // empty) means its worktree could not be located — an empty-write lane is
83
+ // reported as `files: []` below, never conflated with "unknown".
84
+ const filesByLabel = new Map()
85
+ for (const label of seen.keys()) {
86
+ const files = changed[label]
87
+ if (!Array.isArray(files)) {
88
+ unresolved.push({
89
+ label,
90
+ reason: 'no changed-file report for this lane — its worktree could not be located'
91
+ })
92
+ continue
93
+ }
94
+ filesByLabel.set(label, files.filter(f => typeof f === 'string' && f.trim()))
95
+ }
96
+ if (unresolved.length) return { status: 'error', ...empty, unresolved }
97
+
98
+ // Canonicalize each lane's actual writes for CONTENTION KEYING ONLY — the
99
+ // reported `files` below stay in the spelling git reported them in, the
100
+ // same split `canonicalizePath` / `normalizePath` draw in lib/risk.mjs.
101
+ // A path that fails to canonicalize (should not happen for a git-reported,
102
+ // repo-relative path) contends with nothing rather than being silently
103
+ // dropped from the report.
104
+ const canonicalByLabel = new Map()
105
+ for (const [label, files] of filesByLabel) {
106
+ const set = new Set()
107
+ for (const f of files) {
108
+ const c = canonicalizePath(f)
109
+ if (c !== null) set.add(c)
110
+ }
111
+ canonicalByLabel.set(label, set)
112
+ }
113
+
114
+ // Pairwise intersection across every lane, over canonical paths — never raw
115
+ // spellings, so `src/a.ts` and `./src/a.ts` written by two lanes are one
116
+ // collision, not two near-misses that both look disjoint.
117
+ const contenders = new Map() // canonicalPath -> Set<label>
118
+ const labels = [...canonicalByLabel.keys()]
119
+ for (let i = 0; i < labels.length; i++) {
120
+ for (let j = i + 1; j < labels.length; j++) {
121
+ const a = labels[i]
122
+ const b = labels[j]
123
+ for (const path of canonicalByLabel.get(a)) {
124
+ if (canonicalByLabel.get(b).has(path)) {
125
+ if (!contenders.has(path)) contenders.set(path, new Set())
126
+ contenders.get(path).add(a).add(b)
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ if (contenders.size) {
133
+ const collisions = [...contenders.entries()]
134
+ .map(([canonicalPath, laneSet]) => ({ canonicalPath, lanes: [...laneSet].sort() }))
135
+ .sort((x, y) => x.canonicalPath.localeCompare(y.canonicalPath))
136
+ return { status: 'collision', folds: [], collisions, unresolved: [] }
137
+ }
138
+
139
+ // Disjoint, by construction of the check above: apply the union. An
140
+ // empty-write lane contributes `files: []` and is reported, not dropped —
141
+ // it ran and touched nothing, which is a fact worth keeping, not a lane to
142
+ // silently skip.
143
+ const folds = laneList
144
+ .filter(lane => typeof lane.label === 'string' && lane.label.trim())
145
+ .map(lane => ({ lane: lane.label.trim(), files: filesByLabel.get(lane.label.trim()) || [] }))
146
+
147
+ return { status: 'clean', folds, collisions: [], unresolved: [] }
148
+ }
@@ -0,0 +1,99 @@
1
+ // The one impure module in lib/.
2
+ //
3
+ // Everything else in `lib/` is a pure decision — data in, decision out, no fs,
4
+ // no clock, no I/O — precisely so it can be tested without a fixture and called
5
+ // from a runtime that has no filesystem of its own. This module deliberately
6
+ // breaks that rule: it writes a file. It is isolated here so the exception is
7
+ // visible, and so nothing pure ever grows a write path by accident.
8
+ //
9
+ // Two consequences of being the exception:
10
+ //
11
+ // 1. **It never throws.** A metrics write is a side effect of a run, never part
12
+ // of it. A read-only checkout, a missing `.claude/`, a full disk — each
13
+ // degrades to a no-op that *reports* itself via the return value. A run must
14
+ // never fail because bookkeeping failed.
15
+ // 2. **It writes counts, not content.** Per decision §4.7 the payload is a
16
+ // timestamp, a change name and four integers. No transcripts, no finding
17
+ // bodies, no prompts, no file paths from the diff. The fields are copied out
18
+ // by name rather than spread in, so handing this function an entire review
19
+ // result cannot leak a finding's text into `.claude/metrics/`.
20
+ //
21
+ // Exposed to skills as a `interlock` subcommand.
22
+
23
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+
26
+ /** Where metrics land, relative to the repo root. */
27
+ export const METRICS_DIR = join('.claude', 'metrics')
28
+
29
+ /** Bump when the payload shape changes, so old files stay readable. */
30
+ export const REVIEW_METRICS_SCHEMA = 'interlock.review-metrics/1'
31
+
32
+ /** The only counts that are ever written. */
33
+ const COUNT_KEYS = ['raised', 'dismissed', 'droppedByQuality', 'surviving']
34
+
35
+ // A change name reaches us from a branch, a directory or a model, so it is
36
+ // untrusted as a path component: `../` or a slash would put the file somewhere
37
+ // other than the metrics directory. Reduce it to a filename-safe slug.
38
+ function slugify(change) {
39
+ const s = typeof change === 'string' ? change.trim() : ''
40
+ const safe = s.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '')
41
+ return safe.slice(0, 80) || 'unnamed'
42
+ }
43
+
44
+ // ISO 8601 with the characters that are awkward in filenames replaced, so the
45
+ // name still sorts chronologically as a string.
46
+ function stampOf(iso) {
47
+ return iso.replace(/[:.]/g, '-')
48
+ }
49
+
50
+ function toCount(value) {
51
+ const n = Number(value)
52
+ if (!Number.isFinite(n) || n < 0) return 0
53
+ return Math.trunc(n)
54
+ }
55
+
56
+ /**
57
+ * Append a review's counts to `.claude/metrics/`.
58
+ *
59
+ * @param {string} root repo root; `.claude/metrics` is created beneath it
60
+ * @param {{change?: string, counts?: object, now?: Date|string}} [input]
61
+ * `counts` is a `resolveReview` result's `counts` (extra keys are ignored);
62
+ * `now` is an injection point for deterministic tests
63
+ * @returns {{written: boolean, path: string|null, reason: string|null}}
64
+ * `reason` explains a `written: false` — it is never an exception
65
+ */
66
+ export function writeReviewMetrics(root, input = {}) {
67
+ try {
68
+ if (typeof root !== 'string' || !root.trim()) {
69
+ return { written: false, path: null, reason: 'no root directory given' }
70
+ }
71
+ if (!existsSync(root)) {
72
+ return { written: false, path: null, reason: `root does not exist: ${root}` }
73
+ }
74
+
75
+ const when = input.now instanceof Date ? input.now : new Date(input.now || Date.now())
76
+ const iso = Number.isNaN(when.getTime()) ? new Date().toISOString() : when.toISOString()
77
+
78
+ const counts = {}
79
+ for (const k of COUNT_KEYS) counts[k] = toCount(input.counts && input.counts[k])
80
+
81
+ // Built key by key: whatever else the caller passed cannot ride along.
82
+ const payload = {
83
+ schema: REVIEW_METRICS_SCHEMA,
84
+ timestamp: iso,
85
+ change: slugify(input.change),
86
+ counts
87
+ }
88
+
89
+ const dir = join(root, METRICS_DIR)
90
+ mkdirSync(dir, { recursive: true })
91
+ const file = join(dir, `review-${payload.change}-${stampOf(iso)}.json`)
92
+ writeFileSync(file, JSON.stringify(payload, null, 2) + '\n')
93
+ return { written: true, path: file, reason: null }
94
+ } catch (err) {
95
+ // Deliberately swallowed: see the module header. The caller gets a reason,
96
+ // never an exception.
97
+ return { written: false, path: null, reason: (err && err.message) || String(err) }
98
+ }
99
+ }
package/lib/notify.mjs ADDED
@@ -0,0 +1,229 @@
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.
4
+ //
5
+ // Three properties are load-bearing, and every one of them exists because
6
+ // this module is the CLI's only exception to "no network":
7
+ //
8
+ // 1. PURE TRANSPORT. Nothing here reads a file or the tree. Configuration
9
+ // is two environment variables and nothing else (design D2) — a typo in
10
+ // `.claude/settings.local.json` must never masquerade as a push failure,
11
+ // so there is no settings file this module could misread in the first
12
+ // place.
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`
16
+ // never echoes it, `sanitizeReason` strips it (and the server URL) from
17
+ // every failure reason before that reason reaches a summary line, a
18
+ // receipt event or the trajectory, because a `fetch` failure's own
19
+ // `cause` can carry the request host and a malformed-URL error can carry
20
+ // the whole URL.
21
+ //
22
+ // 3. BOUNDED AND NEVER THE REASON A CLOSE HANGS. `postNtfy` awaits `fetch`
23
+ // under an `AbortController` timing out at `LIMITS.notifyTimeoutMs`, and
24
+ // every failure mode — a non-2xx status, a network error, the timeout,
25
+ // invalid configuration — resolves to `{ sent: false, reason }` rather
26
+ // than throwing. Nothing here changes an exit code; the caller decides
27
+ // what a failed push means for the run.
28
+ //
29
+ // design D1, D2, D4, D5, D14 (add-harden-unattended-ship-runs).
30
+
31
+ import { LIMITS } from './limits.mjs'
32
+
33
+ /** Where the topic is read from. Set by the operator, never by the repo. */
34
+ export const NOTIFY_TOPIC_ENV = 'INTERLOCK_NTFY_TOPIC'
35
+
36
+ /** Where a self-hosted server is read from. Unset means the public default. */
37
+ export const NOTIFY_URL_ENV = 'INTERLOCK_NTFY_URL'
38
+
39
+ /** The public ntfy server, used when `NOTIFY_URL_ENV` is unset. */
40
+ export const DEFAULT_NTFY_URL = 'https://ntfy.sh'
41
+
42
+ function messageOf(err) {
43
+ return (err && err.message) || String(err)
44
+ }
45
+
46
+ /** A topic containing whitespace or a slash cannot be a single ntfy path segment. */
47
+ function topicShapeInvalid(topic) {
48
+ return typeof topic !== 'string' || !topic || /\s/.test(topic) || topic.includes('/')
49
+ }
50
+
51
+ /** Does `url` parse as an absolute URL `fetch` could target? */
52
+ function urlShapeInvalid(url) {
53
+ if (typeof url !== 'string' || !url) return true
54
+ try {
55
+ // eslint-disable-next-line no-new
56
+ new URL(url)
57
+ return false
58
+ } catch {
59
+ return true
60
+ }
61
+ }
62
+
63
+ /**
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).
66
+ *
67
+ * @param {object} env environment to read (injected in tests; `process.env` at call sites)
68
+ * @returns {{topic: string, url: string} | {topic: null} | {invalid: string}}
69
+ * `{ topic: null }` when push is simply unconfigured — not an error, the
70
+ * default and by far the common case. `{ invalid: '<VAR>' }` names the
71
+ * offending variable and never its value.
72
+ */
73
+ export function readNotifyConfig(env) {
74
+ const rawTopic = env && typeof env[NOTIFY_TOPIC_ENV] === 'string' ? env[NOTIFY_TOPIC_ENV].trim() : ''
75
+ if (!rawTopic) return { topic: null }
76
+ if (topicShapeInvalid(rawTopic)) return { invalid: NOTIFY_TOPIC_ENV }
77
+
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 }
83
+
84
+ return { topic: rawTopic, url: rawUrl }
85
+ }
86
+
87
+ /**
88
+ * The message for a ship run's terminal close — the summary's own first line
89
+ * as the title, so the push and the printed line can never disagree, and a
90
+ * body that names the change and the run id or says plainly that none was
91
+ * ever minted (design D4).
92
+ *
93
+ * @param {object} input
94
+ * @param {string} input.headline the summary's first line, verbatim
95
+ * @param {string} input.change the change name
96
+ * @param {string|null} [input.runId] minted at `adoptPlan`; null before one was
97
+ * @returns {{title: string, body: string, priority: 'high'|'default'}}
98
+ */
99
+ export function composeCloseMessage({ headline, change, runId } = {}) {
100
+ const runLine = runId ? `run: ${runId}` : 'run: none — the run halted before a plan was adopted'
101
+ return {
102
+ title: headline,
103
+ body: `change: ${change}\n${runLine}`,
104
+ priority: typeof headline === 'string' && headline.startsWith('SHIP HALTED') ? 'high' : 'default'
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The message for `/interlock:spec`'s human checkpoint (design D6) — pushed
110
+ * the same way a halt is, so a checkpoint reached while nobody is watching
111
+ * still reaches someone.
112
+ *
113
+ * @param {string} change
114
+ * @returns {{title: string, body: string, priority: 'default'}}
115
+ */
116
+ export function composeCheckpointMessage(change) {
117
+ return {
118
+ title: `SPEC CHECKPOINT — ${change}`,
119
+ body: `change: ${change}`,
120
+ priority: 'default'
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Strip anything the message could otherwise carry that identifies the
126
+ * server or the topic out of a failure reason, before that reason can reach
127
+ * a summary line, a receipt event or the trajectory. Two more precautions
128
+ * beyond the exact strings: the URL's bare host, because a network error's
129
+ * own `cause` often carries only the host and not the scheme, and a hard cap
130
+ * so one hostile or malformed message cannot blow up a printed report.
131
+ *
132
+ * @param {string} reason
133
+ * @param {{topic?: string, url?: string}} [config]
134
+ * @returns {string}
135
+ */
136
+ export function sanitizeReason(reason, { topic, url } = {}) {
137
+ let text = typeof reason === 'string' ? reason : String(reason)
138
+ if (topic) text = text.split(topic).join('<topic>')
139
+ if (url) {
140
+ text = text.split(url).join('<url>')
141
+ try {
142
+ const host = new URL(url).host
143
+ if (host) text = text.split(host).join('<host>')
144
+ } catch {
145
+ // url did not parse; nothing further to strip on its account.
146
+ }
147
+ }
148
+ text = text.replace(/\r?\n+/g, ' ').trim()
149
+ return text.length > 200 ? text.slice(0, 200) : text
150
+ }
151
+
152
+ /**
153
+ * Encode a header value that may carry non-ASCII text.
154
+ *
155
+ * This is not defensive tidying, it is the only way the title can travel at
156
+ * all: EVERY headline this module sends contains an em dash (`SHIP COMPLETE —
157
+ * <change>`), HTTP header values are ByteStrings, and Node's `fetch` throws
158
+ * `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).
163
+ *
164
+ * A pure-ASCII value is passed through untouched, so the common `interlock
165
+ * notify --title` case stays readable on the wire.
166
+ *
167
+ * @param {string} value
168
+ * @returns {string}
169
+ */
170
+ export function encodeHeaderValue(value) {
171
+ const text = typeof value === 'string' ? value : String(value ?? '')
172
+ // eslint-disable-next-line no-control-regex
173
+ if (/^[\x20-\x7e]*$/.test(text)) return text
174
+ return `=?UTF-8?B?${Buffer.from(text, 'utf8').toString('base64')}?=`
175
+ }
176
+
177
+ /**
178
+ * Post one ntfy message. Never throws: every failure mode — invalid
179
+ * configuration, a non-2xx status, a network error, the timeout — resolves
180
+ * to `{ sent: false, reason }`, sanitized, so the caller can always print
181
+ * something and never has to catch anything.
182
+ *
183
+ * @param {object} input
184
+ * @param {string} input.url
185
+ * @param {string} input.topic
186
+ * @param {string} input.title
187
+ * @param {string} input.body
188
+ * @param {'high'|'default'} [input.priority]
189
+ * @param {object} [opts]
190
+ * @param {typeof fetch} [opts.fetchImpl] injected for tests; no network in the suite
191
+ * @param {number} [opts.timeoutMs] defaults to the published `LIMITS.notifyTimeoutMs`
192
+ * @returns {Promise<{sent: true} | {sent: false, reason: string}>}
193
+ */
194
+ export async function postNtfy(
195
+ { url, topic, title, body, priority } = {},
196
+ { fetchImpl = globalThis.fetch, timeoutMs = LIMITS.notifyTimeoutMs } = {}
197
+ ) {
198
+ const invalidVar = topicShapeInvalid(topic) ? NOTIFY_TOPIC_ENV : urlShapeInvalid(url) ? NOTIFY_URL_ENV : null
199
+ if (invalidVar) {
200
+ return { sent: false, reason: sanitizeReason(`invalid configuration: ${invalidVar}`, { topic, url }) }
201
+ }
202
+
203
+ const target = `${url.replace(/\/+$/, '')}/${topic}`
204
+ const controller = new AbortController()
205
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
206
+ try {
207
+ const response = await fetchImpl(target, {
208
+ method: 'POST',
209
+ headers: {
210
+ Title: encodeHeaderValue(title || ''),
211
+ Priority: priority || 'default'
212
+ },
213
+ body: body || '',
214
+ signal: controller.signal
215
+ })
216
+ if (!response || !response.ok) {
217
+ const status = response ? response.status : 'unknown'
218
+ return { sent: false, reason: sanitizeReason(`HTTP ${status}`, { topic, url }) }
219
+ }
220
+ return { sent: true }
221
+ } catch (err) {
222
+ if (err && err.name === 'AbortError') {
223
+ return { sent: false, reason: sanitizeReason(`timed out after ${timeoutMs}ms`, { topic, url }) }
224
+ }
225
+ return { sent: false, reason: sanitizeReason(`network error: ${messageOf(err)}`, { topic, url }) }
226
+ } finally {
227
+ clearTimeout(timer)
228
+ }
229
+ }