@renzrollon/interlock 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
|
@@ -0,0 +1,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
|
+
}
|
package/lib/metrics.mjs
ADDED
|
@@ -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
|
+
}
|