@skitterbyte/skitterspec-linear 14.0.0 → 17.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 (49) hide show
  1. package/MIGRATION.md +304 -4
  2. package/README.md +34 -1
  3. package/assets/claude-md-section.md +29 -18
  4. package/assets/commands/spec-remote-review.md +22 -0
  5. package/assets/core/SETUP.md +10 -6
  6. package/assets/core/env.config.json.example +4 -2
  7. package/assets/core/env.config.md +103 -25
  8. package/assets/core/linear.config.json.example +2 -1
  9. package/assets/core/linear.config.md +49 -22
  10. package/assets/review/page.html +1101 -108
  11. package/assets/rules/spec-planning.md +39 -7
  12. package/assets/rules/spec-reports.md +210 -31
  13. package/assets/skills/spec/SKILL.md +161 -4
  14. package/assets/skills/spec-bug/SKILL.md +102 -49
  15. package/assets/skills/spec-cancel/SKILL.md +2 -2
  16. package/assets/skills/spec-claim/SKILL.md +12 -4
  17. package/assets/skills/spec-complete/SKILL.md +2 -2
  18. package/assets/skills/spec-diff/SKILL.md +183 -39
  19. package/assets/skills/spec-hotfix/SKILL.md +96 -49
  20. package/assets/skills/spec-init/SKILL.md +18 -6
  21. package/assets/skills/spec-linear-setup/SKILL.md +19 -11
  22. package/assets/skills/spec-next/SKILL.md +147 -62
  23. package/assets/skills/spec-push/SKILL.md +45 -0
  24. package/assets/skills/spec-review/SKILL.md +89 -2
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +26 -3
  27. package/assets/skills/spec-status/SKILL.md +20 -6
  28. package/assets/skills/spec-sync/SKILL.md +1 -0
  29. package/package.json +2 -2
  30. package/src/cli.js +940 -116
  31. package/src/env/classify.js +87 -2
  32. package/src/env/config.js +214 -17
  33. package/src/env/hooks.js +49 -9
  34. package/src/env/live.js +94 -0
  35. package/src/env/resolve.js +36 -2
  36. package/src/env/review.js +581 -21
  37. package/src/env/serve.js +298 -19
  38. package/src/env/supervise.js +8 -1
  39. package/src/init.js +88 -13
  40. package/src/vendor/linear/api.js +111 -2
  41. package/src/vendor/linear/cli-sync.js +661 -11
  42. package/src/vendor/linear/config.js +41 -13
  43. package/src/vendor/linear/doctor.js +6 -5
  44. package/src/vendor/sync-core/index.js +11 -3
  45. package/src/vendor/sync-core/src/compare.js +65 -0
  46. package/src/vendor/sync-core/src/normalize.js +26 -0
  47. package/src/vendor/sync-core/src/retarget.js +1 -1
  48. package/src/vendor/sync-core/src/task-block.js +2 -2
  49. /package/assets/hooks/{review-gate.js → review-gate.cjs} +0 -0
@@ -45,7 +45,18 @@ function expandCompanion(pattern, spec, config) {
45
45
  const tokens = { slug: spec.slug }
46
46
  if (/\{identifier\}/.test(pattern)) {
47
47
  const field = config && config.branch && config.branch.identifierField
48
- const identifier = readFrontmatterField(spec.path, field)
48
+ // NO PATH IS CANNOT-TELL, not an error. Callers hand in spec objects of two
49
+ // shapes: `resolveSpec` returns a full one, and `allSpecs` returns
50
+ // `{folder, slug, worktreePath}` with no `path` at all. Reading the
51
+ // frontmatter of `undefined` threw, and the throw surfaced as
52
+ // `render failed: The "path" argument must be of type string` on the review
53
+ // server's index — every spec, not just one.
54
+ //
55
+ // It routes to the same answer a missing identifier already routes to:
56
+ // expand nothing, so the pattern matches nothing and the path is not
57
+ // claimed as this spec's. Being wrong here costs a companion file left
58
+ // unstaged; throwing cost the whole page.
59
+ const identifier = spec.path ? readFrontmatterField(spec.path, field) : null
49
60
  if (!identifier) return null
50
61
  tokens.identifier = identifier
51
62
  }
@@ -88,4 +99,78 @@ function classifyDirtyTree(spec, dirtyPaths, config) {
88
99
  return { owned, foreign }
89
100
  }
90
101
 
91
- module.exports = { classifyDirtyTree, expandCompanion }
102
+ // git quotes a path containing unusual bytes and C-escapes it. Unquote what we
103
+ // can; anything we cannot parse confidently is returned as-is, which makes it
104
+ // fail the spec-folder comparison and land in `foreign` — a refusal, which is the
105
+ // safe direction to be wrong in.
106
+ function unquotePath(p) {
107
+ if (!p.startsWith('"') || !p.endsWith('"')) return p
108
+ try {
109
+ return JSON.parse(p)
110
+ } catch {
111
+ return p
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Repo-relative paths of everything uncommitted. Returns null when git could not
117
+ * be read at all — the caller must treat that as "nobody looked", never "clean".
118
+ *
119
+ * Two prefix-free listings rather than `git status --porcelain`, deliberately.
120
+ * Porcelain prefixes every path with a two-character status field, and the shared
121
+ * git reader TRIMS its output — which eats the leading space of the first line
122
+ * only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
123
+ * These emit bare paths, so there is no offset to get wrong. `--others` also
124
+ * lists untracked files INDIVIDUALLY, where porcelain collapses them into their
125
+ * topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
126
+ * an ancestor attributable to no single spec, and so refusing the very tree this
127
+ * gate exists to accept. Both were found by running it, not by reading it.
128
+ */
129
+ function dirtyPaths(git) {
130
+ const lists = [
131
+ git(['diff', '--name-only', 'HEAD']),
132
+ git(['ls-files', '--others', '--exclude-standard']),
133
+ ]
134
+ if (lists.every((l) => l === null)) return null
135
+ const out = []
136
+ for (const list of lists) {
137
+ if (!list) continue
138
+ for (const line of list.split('\n')) {
139
+ const q = line.trim()
140
+ if (q) out.push(unquotePath(q))
141
+ }
142
+ }
143
+ return out
144
+ }
145
+
146
+ /**
147
+ * A spec's own uncommitted documents in one tree: `{tree, owned}`, or a reason.
148
+ *
149
+ * SHARED ON PURPOSE. Two surfaces render a spec's documents — the CLI's
150
+ * `review --docs` and the review server's route for a spec with no worktree —
151
+ * and if they classified separately they could disagree about what the page
152
+ * shows. A reader's verdict is about the page they read, so the served page and
153
+ * the written page have to be the same page.
154
+ *
155
+ * Three states, never two (`.claude/rules/negative-checks.md` rule 4). An
156
+ * unreadable git is `cannot tell`, and must not collapse into an empty file
157
+ * set: an empty set renders a page saying nothing changed, which is the one
158
+ * reading that is certainly wrong.
159
+ *
160
+ * WHAT WOULD FOOL THIS: a document of this spec's that is already committed.
161
+ * `dirtyPaths` answers about the uncommitted tree only, so a spec whose files
162
+ * are all committed reports `empty` rather than showing its own text. That is
163
+ * the intended reading — there is no change to review — and it is why `empty`
164
+ * is a distinct answer rather than an error.
165
+ *
166
+ * @returns {{tree: string, owned: string[]}|{error: string}|{empty: true}}
167
+ */
168
+ function specDocsIn(tree, spec, config, git) {
169
+ const paths = dirtyPaths(git)
170
+ if (paths === null) return { error: 'git could not be read' }
171
+ const { owned } = classifyDirtyTree(spec, paths, config)
172
+ if (!owned.length) return { empty: true }
173
+ return { tree, owned }
174
+ }
175
+
176
+ module.exports = { classifyDirtyTree, expandCompanion, unquotePath, dirtyPaths, specDocsIn }
package/src/env/config.js CHANGED
@@ -43,11 +43,98 @@
43
43
  * }
44
44
  */
45
45
 
46
- const { readFileSync } = require('node:fs')
47
- const { join } = require('node:path')
46
+ const { createHash } = require('node:crypto')
47
+ const { readFileSync, realpathSync } = require('node:fs')
48
+ const { join, resolve } = require('node:path')
48
49
 
49
50
  const CONFIG_FILE = join('specs', '.core', 'env.config.json')
50
51
 
52
+ // The window `servePort: "auto"` derives a port from. 7700-7799 keeps the
53
+ // familiar neighbourhood — 7777, the old shared default, is inside it — while
54
+ // giving every repo on a machine its own slot without anyone configuring one.
55
+ //
56
+ // A hundred slots is a SMALL chance of two repos landing together, not no
57
+ // chance. That case is not papered over: the server still refuses the busy
58
+ // port and names `servePort` as the durable fix. Walking up to the next free
59
+ // port would remove the refusal and keep the staleness, because the port would
60
+ // then depend on which repo started first.
61
+ const PORT_BASE = 7700
62
+ const PORT_SPAN = 100
63
+
64
+ /**
65
+ * The repo path the derivation hashes — `realpath`ed ONCE, here.
66
+ *
67
+ * A symlinked spelling of one tree (`/tmp` → `/private/tmp` on macOS, a
68
+ * convenience symlink into a worktree root) is the same repo, and hashing the
69
+ * two spellings separately would hand out two different ports for it — two
70
+ * different URLs, one of them dead. Resolving once is what makes the port a
71
+ * property of the tree rather than of how you typed it.
72
+ *
73
+ * WHAT WOULD FOOL THIS: `realpathSync` throws on a path that does not exist, so
74
+ * it falls back to `resolve`. That is the cannot-tell branch and it is routed
75
+ * to inaction (.claude/rules/negative-checks.md rule 4) — an absolute path is
76
+ * still deterministic, so the worst case is a stable port for a directory that
77
+ * is not there, never a crash on an unrelated command.
78
+ */
79
+ function servePortRoot(dir) {
80
+ try {
81
+ return realpathSync(resolve(dir))
82
+ } catch {
83
+ return resolve(dir)
84
+ }
85
+ }
86
+
87
+ /**
88
+ * `PORT_BASE + hash(path) % PORT_SPAN` — a PURE function of the path.
89
+ *
90
+ * Purity is the whole point, not an implementation detail. A port that is
91
+ * merely *free* is not a port a link handed out yesterday can still resolve, so
92
+ * nothing here may read the registry, `.spec-env/`, or any other state on disk:
93
+ * a port that depends on a file changes when the file is deleted, and this
94
+ * exists precisely so it does not change. The same tree gets the same port
95
+ * across a restart, a reboot, and a `--stop`.
96
+ *
97
+ * sha256 rather than a hand-rolled hash because its distribution is not
98
+ * something this file has to argue for — with a hundred slots, clustering is
99
+ * the only way the derivation could fail at its job.
100
+ */
101
+ function derivedServePort(root) {
102
+ const digest = createHash('sha256').update(root).digest()
103
+ return PORT_BASE + (digest.readUInt32BE(0) % PORT_SPAN)
104
+ }
105
+
106
+ /**
107
+ * The port `spec-env review serve` should use, and HOW it was chosen.
108
+ *
109
+ * Three sources, in precedence order: `--port` on this run, an explicit number
110
+ * in `review.servePort`, else the derivation. The `source` rides along so
111
+ * `--status` can answer "why is this on 7742?" from the tool rather than from
112
+ * reading this file.
113
+ *
114
+ * Returns `{ port, source, root? }`; `root` is the resolved path that was
115
+ * hashed, present only on a derived port.
116
+ */
117
+ function resolveServePort(config, dir, override) {
118
+ const flag = Number(override)
119
+ if (override != null && override !== '' && Number.isFinite(flag)) {
120
+ return { port: flag, source: 'flag' }
121
+ }
122
+ const configured = config && config.review ? config.review.servePort : undefined
123
+ if (typeof configured === 'number' && Number.isFinite(configured)) {
124
+ return { port: configured, source: 'configured' }
125
+ }
126
+ const root = servePortRoot(dir)
127
+ return { port: derivedServePort(root), source: 'derived', root }
128
+ }
129
+
130
+ /** One sentence naming how a resolved port was chosen, for `--status`. */
131
+ function servePortReason(source) {
132
+ if (source === 'flag') return 'this run only, from --port'
133
+ if (source === 'configured') return 'pinned by review.servePort'
134
+ if (source === 'derived') return "derived from this repo's path"
135
+ return ''
136
+ }
137
+
51
138
  const DEFAULT_CONFIG = Object.freeze({
52
139
  // Where a spec's branch is built. "worktree" gives every spec its own checkout
53
140
  // — parallel specs, `main` left free — at the cost of a terminal session per
@@ -110,9 +197,11 @@ const DEFAULT_CONFIG = Object.freeze({
110
197
  // planned for a LANDED branch — see teardown.js.
111
198
  teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
112
199
 
113
- // `reader` decides how a diff's location is worded, and via
114
- // `serveOnRemote` whether the engine stands the local server up so a remote
115
- // reader gets a link that opens. It never decides to PUBLISH: publishing
200
+ // `reader` decides how a diff's location is WORDED, and nothing else. It
201
+ // once also decided whether the engine served a gate that produced the
202
+ // `file://` link on a local machine, where a `file://` page cannot POST and
203
+ // so the verdict buttons had nowhere to go. `serve` owns that now.
204
+ // It never decides to PUBLISH: publishing
116
205
  // leaves a page this tooling cannot remove, so it stays an explicit ask.
117
206
  // `detect` sniffs; `local`/`remote` are the operator's own answer and are
118
207
  // believed without sniffing, because they know where they are reading and no
@@ -132,10 +221,34 @@ const DEFAULT_CONFIG = Object.freeze({
132
221
  // toward reading the diff is the point, and a project that would rather not
133
222
  // be pushed says so once. It is the only key anything reads to decide
134
223
  // whether the gate refuses, so turning it off turns off the hook with it.
224
+ // `serve` decides whether a render stands the local server up: `always`
225
+ // (the default) or `never`. It replaced `serveOnRemote`, whose name would now
226
+ // claim to govern remote renders while governing every one of them — and a
227
+ // config key that lies is what made a wrong fix look right. A legacy
228
+ // `serveOnRemote: false` is still read as `serve: "never"`.
229
+ //
230
+ // THE BIND IS NOT THIS KEY'S BUSINESS, and that separation is what keeps
231
+ // serving-everywhere free of new exposure: `reader` still decides it, so a
232
+ // remote reader binds every interface exactly as before and a local or
233
+ // unknown one binds loopback. Serving more never means listening wider.
234
+ // `allowNetwork` and `allowRemote` are the two tiers a project permits. They
235
+ // exist because the engine CANNOT KNOW where the reader is sitting and kept
236
+ // being asked to guess: detection reported `unknown` on a local session and
237
+ // produced a page whose buttons cannot POST, reported `remote` and produced a
238
+ // LAN URL a phone off the network could not reach, and flipped mid-session
239
+ // and changed the address underneath a reader. Each was fixed on its own; the
240
+ // next reader position would have produced a fourth.
241
+ //
242
+ // So the reader picks instead. `allowNetwork` decides THE BIND — on binds
243
+ // every interface, off binds loopback — which is the last decision detection
244
+ // had. `allowRemote` PERMITS publishing; it never publishes, because each
245
+ // publish leaves a page skitterspec cannot delete.
135
246
  review: Object.freeze({
136
247
  reader: 'detect',
137
- servePort: 7777,
138
- serveOnRemote: true,
248
+ servePort: 'auto',
249
+ serve: 'always',
250
+ allowNetwork: true,
251
+ allowRemote: false,
139
252
  commitWith: '/commit',
140
253
  required: true,
141
254
  }),
@@ -155,6 +268,47 @@ function isObject(value) {
155
268
  return value !== null && typeof value === 'object' && !Array.isArray(value)
156
269
  }
157
270
 
271
+ /**
272
+ * List the keys in a parsed config that `mergeConfig` will not read, as dotted
273
+ * paths (`open`, `review.readr`). Advisory only — nothing here refuses, and the
274
+ * merge below is unchanged: unknown keys are still dropped, they are just no
275
+ * longer dropped in silence.
276
+ *
277
+ * The known set is DEFAULT_CONFIG itself rather than a list written out beside
278
+ * it. That is not a shortcut: every key `mergeConfig` reads is necessarily a key
279
+ * of the defaults, because a key with no default has nothing to merge onto — so
280
+ * a hand-written list would be a second copy of the same contract, and a second
281
+ * copy is how the two come to disagree. Adding a key to `mergeConfig` without
282
+ * adding its default is already impossible; this check inherits that.
283
+ *
284
+ * It descends only where the DEFAULT is a plain object. An array's elements are
285
+ * data, not keys — `dev` entries, `setup` commands, `spec.companionPaths`,
286
+ * `live.migrations`, `hotfix.targets` — and walking into them would report every
287
+ * path in the list as an unknown key.
288
+ *
289
+ * WHAT IT DELIBERATELY DOES NOT REPORT: a KNOWN key whose value was rejected for
290
+ * its type or for not matching an enum (`mode: "Checkout"`,
291
+ * `teardown.deleteRemoteBranch: "yes"`). Those already fall through to a
292
+ * documented conservative default, each with a comment saying why, and folding
293
+ * them in here would change what this line means from "I ignored a key you wrote"
294
+ * to "I disagreed with a value you wrote".
295
+ */
296
+ function collectUnknownKeys(parsed, known = DEFAULT_CONFIG, prefix = '') {
297
+ if (!isObject(parsed)) return []
298
+ const out = []
299
+ for (const key of Object.keys(parsed)) {
300
+ const dotted = prefix ? `${prefix}.${key}` : key
301
+ if (!Object.prototype.hasOwnProperty.call(known, key)) {
302
+ out.push(dotted)
303
+ continue
304
+ }
305
+ if (isObject(known[key]) && isObject(parsed[key])) {
306
+ out.push(...collectUnknownKeys(parsed[key], known[key], dotted))
307
+ }
308
+ }
309
+ return out
310
+ }
311
+
158
312
  // A fresh, deeply-mutable copy of the defaults to merge onto.
159
313
  function defaults() {
160
314
  return {
@@ -341,11 +495,38 @@ function mergeConfig(base, parsed) {
341
495
  if (reader === 'local' || reader === 'remote' || reader === 'detect') {
342
496
  base.review.reader = reader
343
497
  }
344
- assign(base.review, parsed.review, 'servePort', 'number')
345
- // Opting OUT is the only thing this key can do a non-boolean leaves the
346
- // default in place rather than being read as a refusal, so a typo cannot
347
- // quietly restore the dead `file://` link on a remote reader.
348
- assign(base.review, parsed.review, 'serveOnRemote', 'boolean')
498
+ // Two accepted forms: a finite number pins the port, and the string "auto"
499
+ // derives it from the repo's path. Anything else is refused BY NAME the
500
+ // default stands exactly as `reader`, `mode` and `deleteRemoteBranch`
501
+ // refuse a value they do not recognise.
502
+ //
503
+ // Falling through costs nothing here, and that is worth stating rather than
504
+ // assuming: the only value a typo can fall through to is "auto", which is
505
+ // also the only string that would have been accepted. So a misspelt "auto"
506
+ // behaves identically to the spelling that was meant, and a misspelt number
507
+ // was never a number. There is no reading of this key where silence hides a
508
+ // port the author pinned.
509
+ if (typeof parsed.review.servePort === 'number' && Number.isFinite(parsed.review.servePort)) {
510
+ base.review.servePort = parsed.review.servePort
511
+ } else if (parsed.review.servePort === 'auto') {
512
+ base.review.servePort = 'auto'
513
+ }
514
+ // `always` | `never`; anything else leaves the default standing, so a typo
515
+ // cannot quietly restore the dead `file://` link this key exists to end.
516
+ if (parsed.review.serve === 'always' || parsed.review.serve === 'never') {
517
+ base.review.serve = parsed.review.serve
518
+ }
519
+ // TOLERANCE, NOT MIGRATION — the same rule `readVerdict` follows for the
520
+ // old `approve` spelling. These configs are committed, so a rename with no
521
+ // tolerance breaks every other checkout on the next pull. Only `false` is
522
+ // read: `serveOnRemote: true` said "serve where it matters", which is what
523
+ // `always` now does anyway, so it needs no translation.
524
+ //
525
+ // An explicit `serve` wins, so a config carrying both is read the way its
526
+ // author most recently meant.
527
+ if (parsed.review.serve === undefined && parsed.review.serveOnRemote === false) {
528
+ base.review.serve = 'never'
529
+ }
349
530
  // An empty string leaves `/commit` standing, like every other string key
350
531
  // here. There is nothing it could mean instead: the hand-off has no off
351
532
  // switch, so a blank value is a typo rather than an instruction.
@@ -354,6 +535,10 @@ function mergeConfig(base, parsed) {
354
535
  // leaves the gate ON. Turning off a check that refuses must be something
355
536
  // someone WROTE, never something a typo achieved on their behalf.
356
537
  assign(base.review, parsed.review, 'required', 'boolean')
538
+ // Same shape as the two above: a non-boolean leaves the default standing, so
539
+ // a typo cannot quietly widen a bind or permit a publish.
540
+ assign(base.review, parsed.review, 'allowNetwork', 'boolean')
541
+ assign(base.review, parsed.review, 'allowRemote', 'boolean')
357
542
  }
358
543
 
359
544
  if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
@@ -377,10 +562,15 @@ function mergeConfig(base, parsed) {
377
562
 
378
563
  /**
379
564
  * Load and normalise `specs/.core/env.config.json` from `dir` (default cwd).
380
- * Returns `{ config, present }`:
381
- * - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
382
- * - present → `{ config: merged, present: true }`
565
+ * Returns `{ config, present, unknown }`:
566
+ * - missing file → `{ config: defaults, present: false, unknown: [] }`
567
+ * (opt-out; never throws)
568
+ * - present → `{ config: merged, present: true, unknown: [...] }`
383
569
  * Malformed JSON → throws a clear Error (callers exit non-zero).
570
+ *
571
+ * `unknown` lists the dotted paths the merge ignored, for a caller to report.
572
+ * It is advisory in the strongest sense: nothing here acts on it, and a config
573
+ * full of strays loads exactly as it always did.
384
574
  */
385
575
  function loadEnvConfig(dir = process.cwd()) {
386
576
  const base = defaults()
@@ -390,7 +580,7 @@ function loadEnvConfig(dir = process.cwd()) {
390
580
  try {
391
581
  raw = readFileSync(file, 'utf-8')
392
582
  } catch (error) {
393
- if (error.code === 'ENOENT') return { config: base, present: false }
583
+ if (error.code === 'ENOENT') return { config: base, present: false, unknown: [] }
394
584
  throw error
395
585
  }
396
586
 
@@ -401,11 +591,18 @@ function loadEnvConfig(dir = process.cwd()) {
401
591
  throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
402
592
  }
403
593
 
404
- return { config: mergeConfig(base, parsed), present: true }
594
+ return { config: mergeConfig(base, parsed), present: true, unknown: collectUnknownKeys(parsed) }
405
595
  }
406
596
 
407
597
  module.exports = {
408
598
  loadEnvConfig,
599
+ resolveServePort,
600
+ derivedServePort,
601
+ servePortRoot,
602
+ servePortReason,
603
+ PORT_BASE,
604
+ PORT_SPAN,
605
+ collectUnknownKeys,
409
606
  mergeConfig,
410
607
  DEFAULT_CONFIG,
411
608
  CONFIG_FILE,
package/src/env/hooks.js CHANGED
@@ -18,8 +18,21 @@
18
18
  const fs = require('node:fs')
19
19
  const path = require('node:path')
20
20
 
21
- const HOOK_SCRIPT = '.claude/hooks/review-gate.js'
21
+ // `.cjs`, and the extension is load-bearing. This file is copied INTO the target
22
+ // project, where that project's `package.json` decides how node parses a `.js` —
23
+ // so the CommonJS script shipped as `review-gate.js` died on its own first
24
+ // `require` in every `"type": "module"` project, printing a stack trace over
25
+ // every Bash tool call. `.cjs` settles the parse mode at the file, independently
26
+ // of the one file skitterspec does not control.
27
+ const HOOK_STEM = '.claude/hooks/review-gate'
28
+ const HOOK_SCRIPT = `${HOOK_STEM}.cjs`
22
29
  const HOOK_COMMAND = `node "\${CLAUDE_PROJECT_DIR}/${HOOK_SCRIPT}"`
30
+
31
+ // Our script under ANY extension, so a registration naming the retired `.js` is
32
+ // recognised as ours and migrated rather than duplicated. The trailing lookahead
33
+ // is the boundary: without it `review-gate-extra.js` — somebody else's hook —
34
+ // matches on the stem and gets silently rewritten.
35
+ const HOOK_SCRIPT_RE = /[.]claude[/\\]hooks[/\\]review-gate(?:[.][A-Za-z0-9]+)?(?![\w.-])/
23
36
  // Seconds. The engine call behind this is one git-free read of a small JSON
24
37
  // file, so anything approaching this is a wedge rather than slow work — and a
25
38
  // hook that times out fails OPEN, which is the answer we want for a wedge.
@@ -55,13 +68,21 @@ function hookEntry() {
55
68
  * run it twice and look like a bug in the gate.
56
69
  */
57
70
  function alreadyRegistered(preToolUse) {
58
- if (!Array.isArray(preToolUse)) return false
59
- return preToolUse.some(
60
- (group) =>
61
- isObject(group) &&
62
- Array.isArray(group.hooks) &&
63
- group.hooks.some((h) => isObject(h) && typeof h.command === 'string' && h.command.includes(HOOK_SCRIPT)),
64
- )
71
+ return registeredHooks(preToolUse).length > 0
72
+ }
73
+
74
+ // Every hook object under `PreToolUse` whose command names our script, whatever
75
+ // extension it names it under. Pure.
76
+ function registeredHooks(preToolUse) {
77
+ if (!Array.isArray(preToolUse)) return []
78
+ const out = []
79
+ for (const group of preToolUse) {
80
+ if (!isObject(group) || !Array.isArray(group.hooks)) continue
81
+ for (const h of group.hooks) {
82
+ if (isObject(h) && typeof h.command === 'string' && HOOK_SCRIPT_RE.test(h.command)) out.push(h)
83
+ }
84
+ }
85
+ return out
65
86
  }
66
87
 
67
88
  /**
@@ -69,6 +90,7 @@ function alreadyRegistered(preToolUse) {
69
90
  * Idempotent and non-destructive. Returns `{ changed, reason }`:
70
91
  * - `created` — no settings file; one was written
71
92
  * - `added` — merged into an existing file
93
+ * - `migrated` — registered under a retired path; the path was rewritten
72
94
  * - `present` — already registered (no write)
73
95
  * - `malformed` — the file exists but is not parseable JSON (left untouched)
74
96
  */
@@ -98,7 +120,22 @@ function ensureReviewGateHook(dir) {
98
120
 
99
121
  const hooks = isObject(parsed.hooks) ? parsed.hooks : {}
100
122
  const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
101
- if (alreadyRegistered(preToolUse)) return { changed: false, reason: 'present' }
123
+
124
+ // Registered already — but possibly under a path that no longer exists, since
125
+ // the script's extension changed. Rewrite the path IN PLACE rather than
126
+ // replacing the command: an operator who wrapped our hook, added a flag or
127
+ // changed the interpreter has registered it their way, and the thing that
128
+ // moved is the file, not their command. Adding a fresh entry beside theirs
129
+ // would run the gate twice and read as a bug in the gate; leaving the old one
130
+ // alone would aim the harness at a file this same upgrade retires.
131
+ const registered = registeredHooks(preToolUse)
132
+ if (registered.length) {
133
+ const stale = registered.filter((h) => !h.command.includes(HOOK_SCRIPT))
134
+ if (!stale.length) return { changed: false, reason: 'present' }
135
+ for (const h of stale) h.command = h.command.replace(HOOK_SCRIPT_RE, HOOK_SCRIPT)
136
+ writeSettings(file, parsed)
137
+ return { changed: true, reason: 'migrated' }
138
+ }
102
139
 
103
140
  writeSettings(file, {
104
141
  ...parsed,
@@ -110,8 +147,11 @@ function ensureReviewGateHook(dir) {
110
147
  module.exports = {
111
148
  ensureReviewGateHook,
112
149
  alreadyRegistered,
150
+ registeredHooks,
113
151
  hookEntry,
114
152
  settingsPath,
153
+ HOOK_STEM,
115
154
  HOOK_SCRIPT,
155
+ HOOK_SCRIPT_RE,
116
156
  HOOK_COMMAND,
117
157
  }
package/src/env/live.js CHANGED
@@ -85,6 +85,98 @@ function summarizeReceipt(receipt) {
85
85
  )
86
86
  }
87
87
 
88
+ // --- is this spec live? ---------------------------------------------------
89
+
90
+ /**
91
+ * Whether a spec is currently live, in a form a page can carry. Pure.
92
+ *
93
+ * FOUR STATES, NOT TWO, and the fourth is what keeps this quiet in a healthy
94
+ * repo. `on` and `off` are the two a reader acts on; `held` is another spec
95
+ * holding the workbench, which is a refusal with a named way out; and
96
+ * `unavailable` is **cannot tell** — routed to silence rather than to a warning
97
+ * (`.claude/rules/negative-checks.md` rule 4). A project with no isolation and a
98
+ * spec with no worktree both land there, and both are perfectly healthy.
99
+ *
100
+ * THE BRANCH IS THE AUTHORITY, NOT THE RECEIPT, exactly as this file's header
101
+ * says: the receipt is metadata for `live status` and crash recovery. So an
102
+ * unreadable or absent receipt costs the *holder's name* and nothing else — the
103
+ * state still comes from which branch the primary checkout is on. Deriving the
104
+ * state from the receipt instead would report `off` for a checkout plainly
105
+ * sitting on a feature branch, which is the one answer that would let something
106
+ * act.
107
+ *
108
+ * WHAT WOULD FOOL THIS: `onBase` and `primaryBranch` are read from the primary
109
+ * checkout by the caller. A caller that passes a *worktree's* git reader would
110
+ * describe the worktree's HEAD and call it the workbench — so `ctx` is built by
111
+ * `assertPrimaryOnMain` against the repo root and nowhere else.
112
+ *
113
+ * ctx: { isolated, onBase, primaryBranch, receipt, worktreeExists, url }
114
+ * @returns {{state:'on'|'off'|'held'|'unavailable', holder:string|null, url:string|null, reason:string|null}}
115
+ */
116
+ function liveStateFor(spec, ctx) {
117
+ const c = ctx || {}
118
+ const unavailable = (reason) => ({ state: 'unavailable', holder: null, url: null, reason })
119
+
120
+ if (!spec || !spec.branch) return unavailable('no spec to ask about')
121
+ if (c.isolated === false) return unavailable('isolation is not configured')
122
+
123
+ // Not on base — something holds the workbench. Which something is the only
124
+ // question left, and the branch answers it without the receipt.
125
+ if (c.onBase === false) {
126
+ const branch = c.primaryBranch || null
127
+ if (branch && branch === spec.branch) {
128
+ return { state: 'on', holder: null, url: c.url || null, reason: null }
129
+ }
130
+ const holder = c.receipt && c.receipt.spec ? String(c.receipt.spec) : null
131
+ const on = branch || '(detached)'
132
+ return {
133
+ state: 'held',
134
+ holder,
135
+ url: null,
136
+ // Every way out, because this is the only explanation the reader gets —
137
+ // the same reasoning `planTake`'s guard 1 records for its own refusal.
138
+ reason: holder
139
+ ? `${holder} holds the workbench (branch ${on})`
140
+ : `the workbench is on ${on} — no receipt; switched by hand?`,
141
+ }
142
+ }
143
+
144
+ // `onBase` is deliberately three-valued: anything but a definite `true` here
145
+ // is a checkout we could not read, and that is not evidence the spec is off.
146
+ if (c.onBase !== true) return unavailable('could not read the primary checkout')
147
+ if (!c.worktreeExists) return unavailable('no worktree to put live')
148
+
149
+ return { state: 'off', holder: null, url: null, reason: null }
150
+ }
151
+
152
+ /**
153
+ * The one line the render prints for a live state, or null for `unavailable`.
154
+ * Pure.
155
+ *
156
+ * NULL IS THE POINT. `unavailable` prints nothing at all — not a dash, not an
157
+ * explanation — because every input that produces it is a healthy repo that
158
+ * simply has no live surface, and a line about one is an accusation.
159
+ */
160
+ function liveStateLine(live) {
161
+ if (!live || live.state === 'unavailable') return null
162
+ const label = ' live:'.padEnd(11)
163
+ // IT NAMES THE COMMAND, not the capability. This line read
164
+ // `off — the page can put it live`, which told the reader a page somewhere
165
+ // could do it and left them to find the verb — and the verb is the thing they
166
+ // actually need when they are in a terminal rather than on the page. It is
167
+ // `/spec-live` rather than `spec-env live take` because that command is what a
168
+ // person types: it is user-only by its own marking, and the model cannot run
169
+ // it either way.
170
+ if (live.state === 'on') {
171
+ return (
172
+ `${label}on${live.url ? ` — running at ${live.url}` : ''}` +
173
+ '; /spec-live main to restore main'
174
+ )
175
+ }
176
+ if (live.state === 'held') return `${label}held — ${live.reason}`
177
+ return `${label}off — /spec-live to put it live`
178
+ }
179
+
88
180
  // --- stateful detection (glob matching) -----------------------------------
89
181
 
90
182
  // Translate a restricted glob into an anchored RegExp: `**` matches across path
@@ -378,4 +470,6 @@ module.exports = {
378
470
  planTake,
379
471
  planRelease,
380
472
  planAbort,
473
+ liveStateFor,
474
+ liveStateLine,
381
475
  }
@@ -167,7 +167,37 @@ function parsePhase(text, name) {
167
167
  }
168
168
 
169
169
  /**
170
- * The spec's own `## Problem` and `## Impact`, for the page's header.
170
+ * The tracker ticket a spec is linked to, as `{ id, url }`, or null.
171
+ *
172
+ * Read straight out of the overview's frontmatter, where every provider stamps
173
+ * it. The base is tracker-free, so this knows nothing about Linear beyond the
174
+ * two key names a provider writes — an unlinked spec, or a project with no
175
+ * provider installed, simply has neither and gets `null`.
176
+ *
177
+ * ONLY http(s) URLS SURVIVE. The value comes out of a file someone edits, and
178
+ * the page turns it into an `href`; `javascript:` in that position is script
179
+ * execution on a page served over the network. An id with an unusable url is
180
+ * still worth having, so the id is kept and the link dropped rather than the
181
+ * whole ticket.
182
+ */
183
+ function readTicket(text) {
184
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text)
185
+ if (!fm) return null
186
+ const field = (name) => {
187
+ const m = new RegExp(`^${name}:\\s*(.*)$`, 'm').exec(fm[1])
188
+ if (!m) return null
189
+ const v = m[1].trim().replace(/^["']|["']$/g, '').trim()
190
+ return v || null
191
+ }
192
+ const id = field('linear_identifier')
193
+ if (!id) return null
194
+ const url = field('linear_url')
195
+ return { id, url: url && /^https?:\/\//i.test(url) ? url : null }
196
+ }
197
+
198
+ /**
199
+ * The spec's own `## Problem` and `## Impact`, plus its tracker ticket, for the
200
+ * page's header.
171
201
  *
172
202
  * WHAT WOULD FOOL THIS: a spec that renames those headings, or a legacy bare
173
203
  * `<name>.md` with no overview at all. Both yield `null`, which the caller
@@ -183,10 +213,14 @@ function readOverview(specDir, { overviewFile = '00-overview.md' } = {}) {
183
213
  }
184
214
  const problem = sectionOf(text, 'Problem') || sectionOf(text, 'Symptom')
185
215
  const impact = impactRows(sectionOf(text, 'Impact'))
186
- if (!problem && !impact) return null
216
+ const ticket = readTicket(text)
217
+ // The ticket counts towards "is there anything to say": a spec linked to a
218
+ // tracker but carrying neither section still has a header worth drawing.
219
+ if (!problem && !impact && !ticket) return null
187
220
  return {
188
221
  ...(problem ? { problem } : {}),
189
222
  ...(impact ? { impact } : {}),
223
+ ...(ticket ? { ticket } : {}),
190
224
  }
191
225
  }
192
226