spexcode 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +99 -35
  2. package/README.zh-CN.md +135 -0
  3. package/package.json +5 -6
  4. package/spec-cli/README.md +86 -0
  5. package/spec-cli/bin/spex.mjs +15 -3
  6. package/spec-cli/hooks/dispatch.sh +20 -8
  7. package/spec-cli/hooks/harness.sh +18 -11
  8. package/spec-cli/src/board.ts +47 -18
  9. package/spec-cli/src/boardCache.ts +70 -0
  10. package/spec-cli/src/boardDelta.ts +90 -0
  11. package/spec-cli/src/boardStream.ts +178 -0
  12. package/spec-cli/src/cli.ts +184 -122
  13. package/spec-cli/src/client.ts +6 -4
  14. package/spec-cli/src/gateway.ts +64 -24
  15. package/spec-cli/src/git.ts +105 -92
  16. package/spec-cli/src/guide.ts +186 -19
  17. package/spec-cli/src/harness-select.ts +63 -0
  18. package/spec-cli/src/harness.ts +506 -100
  19. package/spec-cli/src/help.ts +362 -0
  20. package/spec-cli/src/hooks.ts +0 -14
  21. package/spec-cli/src/index.ts +279 -32
  22. package/spec-cli/src/init.ts +41 -1
  23. package/spec-cli/src/issues.ts +301 -0
  24. package/spec-cli/src/layout.ts +70 -28
  25. package/spec-cli/src/lint.ts +12 -10
  26. package/spec-cli/src/listen.ts +28 -0
  27. package/spec-cli/src/localIssues.ts +700 -0
  28. package/spec-cli/src/materialize.ts +182 -27
  29. package/spec-cli/src/mentions.ts +192 -0
  30. package/spec-cli/src/plugin-harness.ts +145 -0
  31. package/spec-cli/src/pty-bridge.ts +378 -81
  32. package/spec-cli/src/self.ts +123 -20
  33. package/spec-cli/src/sessions.ts +461 -298
  34. package/spec-cli/src/specs.ts +55 -14
  35. package/spec-cli/src/supervise.ts +23 -3
  36. package/spec-cli/src/tsx-bin.ts +14 -5
  37. package/spec-cli/src/uninstall.ts +146 -0
  38. package/spec-cli/templates/hooks/post-merge +27 -0
  39. package/spec-cli/templates/hooks/pre-commit +51 -31
  40. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  41. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  42. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  43. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  44. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  45. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  47. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  48. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  49. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  50. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  51. package/spec-cli/templates/spec/project/spec.md +2 -2
  52. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
  53. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
  54. package/spec-dashboard/dist/index.html +17 -5
  55. package/spec-forge/src/cache.ts +16 -0
  56. package/spec-forge/src/cli.ts +4 -10
  57. package/spec-forge/src/drivers/github.ts +74 -4
  58. package/spec-forge/src/drivers.ts +13 -0
  59. package/spec-forge/src/port.ts +25 -0
  60. package/spec-forge/src/resident.ts +40 -6
  61. package/spec-yatsu/src/cli.ts +227 -38
  62. package/spec-yatsu/src/evaltab.ts +169 -19
  63. package/spec-yatsu/src/filing.ts +48 -0
  64. package/spec-yatsu/src/freshness.ts +55 -20
  65. package/spec-yatsu/src/proof.ts +89 -3
  66. package/spec-yatsu/src/scenariofresh.ts +92 -0
  67. package/spec-yatsu/src/sidecar.ts +75 -11
  68. package/spec-yatsu/src/timeline.ts +47 -0
  69. package/spec-yatsu/src/yatsu.ts +47 -3
  70. package/spec-cli/src/relay.ts +0 -28
  71. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  72. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  73. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { readFile, readdir } from 'node:fs/promises'
2
3
  import { join, relative, basename } from 'node:path'
3
4
  import { repoRoot, historyIndex, rowsFor, statsFor, pathsStats, driftIndex, driftFor, fileDiffAt } from './git.js'
4
5
 
@@ -93,9 +94,14 @@ function walk(dir: string, parent: string | null, acc: Raw[]) {
93
94
 
94
95
  // re-key each node to the shortest globally-unique trailing path-suffix (overrides walk's placeholder
95
96
  // basename id/parent); the second loop recomputes parent by path-ancestry.
97
+ // A node id is a URL-safe single token ([[id-url-safe]]) — never a '/'-joined path, which would break
98
+ // every `:id` route and fetch that treats an id as one path segment. So the disambiguation separator is
99
+ // '_': like '/' it never occurs inside a dir basename (so the join stays unambiguous), but unlike '/' it
100
+ // is a URL/wikilink/DOM-safe unreserved char, so a collision-qualified id (e.g. `.config_spec-scout`)
101
+ // stays a single token everywhere it is resolved.
96
102
  function reId(acc: Raw[]): void {
97
103
  const segs = acc.map((r) => r.relPath.split(/[/\\]/).slice(1, -1)) // path under .spec, minus 'spec.md'
98
- const suffix = (s: string[], k: number) => s.slice(s.length - k).join('/')
104
+ const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_')
99
105
  for (let i = 0; i < acc.length; i++) {
100
106
  const s = segs[i]
101
107
  let k = 1
@@ -119,6 +125,30 @@ function raws(): Raw[] {
119
125
  return acc
120
126
  }
121
127
 
128
+ // async twin of walk/raws for the HOT board build ([[board-cache]]): reading each spec.md through
129
+ // fs/promises YIELDS the event loop between files, so a build never stalls a `/health` liveness probe the
130
+ // way the sync walk (one ~450ms uninterrupted stretch) did. Same output as raws() — identical push order
131
+ // (pre-order DFS, dir before children) and the same reId — so every caller reads the same nodes; only
132
+ // loadSpecs (already async, on the hot path) uses it, the light one-shot callers keep the sync raws().
133
+ async function walkAsync(dir: string, parent: string | null, acc: Raw[]): Promise<void> {
134
+ let myId = parent
135
+ if (existsSync(join(dir, 'spec.md'))) {
136
+ myId = basename(dir)
137
+ const relPath = relative(ROOT, join(dir, 'spec.md'))
138
+ const { fm, body } = parseFrontmatter(await readFile(join(dir, 'spec.md'), 'utf8'))
139
+ acc.push({ id: myId, parent, relPath, fm, body })
140
+ }
141
+ for (const e of await readdir(dir, { withFileTypes: true })) {
142
+ if (e.isDirectory()) await walkAsync(join(dir, e.name), myId, acc)
143
+ }
144
+ }
145
+ async function rawsAsync(): Promise<Raw[]> {
146
+ const acc: Raw[] = []
147
+ if (existsSync(SPEC_DIR)) await walkAsync(SPEC_DIR, null, acc)
148
+ reId(acc)
149
+ return acc
150
+ }
151
+
122
152
  // spec node(s) that GOVERN a file by the claim rule (exact path, dir-prefix, or *-glob); reads only
123
153
  // frontmatter `code:` (cheap, no git) so a per-edit hook can call it. See [[governed-related]].
124
154
  export function specOwners(file: string): { id: string; desc: string }[] {
@@ -158,11 +188,17 @@ export function loadSpecsLite(): SpecLite[] {
158
188
  }))
159
189
  }
160
190
 
191
+ // one node's body + parsed parts, filesystem-only (no git). The board omits both to stay lean
192
+ // ([[board-lean]]); the detail view fetches them here when a node opens. null when the id isn't a node.
193
+ export function specContent(id: string): { body: string; parts: ReturnType<typeof parseParts> } | null {
194
+ const r = raws().find((x) => x.id === id)
195
+ return r ? { body: r.body.trim(), parts: parseParts(r.body) } : null
196
+ }
197
+
161
198
  export async function loadSpecs() {
162
199
  // both indexes are one cached git walk each and independent — fetch them in parallel (async git, off
163
200
  // the event loop). Every node below is then a pure lookup.
164
- const [idx, didx] = await Promise.all([historyIndex(ROOT), driftIndex(ROOT)])
165
- const allRaws = raws()
201
+ const [idx, didx, allRaws] = await Promise.all([historyIndex(ROOT), driftIndex(ROOT), rawsAsync()])
166
202
  return Promise.all(allRaws.map(async (r) => {
167
203
  const h = rowsFor(idx, r.relPath)
168
204
  // session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
@@ -236,15 +272,15 @@ export async function specDiffAt(id: string, hash: string) {
236
272
  // `events`/`order`/`block` are populated only for the `hook` surface (empty/0/false otherwise): which
237
273
  // harness lifecycle events the node binds, its deterministic intra-event order, and whether it intends to
238
274
  // block (honored only on block-capable events). See loadHookConfig + the hook compiler/dispatcher.
239
- export type ConfigPreset = { name: string; title: string; desc: string; kind: string; dir: string; files: string[]; body: string; events: string[]; order: number; block: boolean }
275
+ export type ConfigPreset = { name: string; title: string; desc: string; kind: string; dir: string; files: string[]; body: string; events: string[]; order: number; block: boolean; tools: string[] }
240
276
  // field-driven surface - a config plugin is a FLAT direct child of a config root (`<root>/<name>/spec.md`)
241
- // that carries a `surface: system|slash|hook|skill` frontmatter field naming where it plugs in. There are no
242
- // `slash/`/`system/`/`hook/`/`skill/` bucket dirs (those were graph-invisible grouping dirs with no spec.md, so
277
+ // that carries a `surface: system|command|hook|skill|agent` frontmatter field naming where it plugs in. There are no
278
+ // `command/`/`system/`/`hook/`/`skill/`/`agent/` bucket dirs (those were graph-invisible grouping dirs with no spec.md, so
243
279
  // the spec graph skipped them — path != graph); the surface is a FIELD on the node, so the plugin is a real
244
280
  // graph child of its root. BOTH config roots participate: `.config` (the instance — DIY dev-flow plugins) and
245
- // `config` (the project system spec). loadConfig gathers the `slash` surface, loadSystemConfig the `system`
246
- // surface, loadHookConfig the `hook` surface, loadSkillConfig the `skill` surface; each scans the children under
247
- // every root and filters by the field. The plugins also show on the board as ordinary spec nodes (via loadSpecs).
281
+ // `config` (the project system spec). loadConfig gathers the `command` surface, loadSystemConfig the `system`
282
+ // surface, loadHookConfig the `hook` surface, loadSkillConfig the `skill` surface, loadAgentConfig the `agent`
283
+ // surface (sub-agent definitions); each scans the children under every root and filters by the field. The plugins also show on the board as ordinary spec nodes (via loadSpecs).
248
284
  // root node - the spec tree's single top-level node: the one directory directly under .spec/ that
249
285
  // holds a spec.md. The dogfood repo names it 'spexcode'; a repo scaffolded by `spex init` names it
250
286
  // 'project' (or whatever the adopter renames it to). Detected DYNAMICALLY so the config loaders resolve
@@ -283,16 +319,16 @@ function bundleFiles(dir: string): string[] {
283
319
  // grouping parent may itself be a plugin (e.g. `.config/core` is a `surface: system` contract whose CHILDREN
284
320
  // are `surface: hook` nodes). The field filter keeps it safe: a node only gathers if it declares THIS
285
321
  // surface, so descending past a matched node never double-counts (children carry a different surface). For
286
- // `system`/`slash` the result is identical to the old one-level scan on the current tree — every existing
322
+ // `system`/`command` the result is identical to the old one-level scan on the current tree — every existing
287
323
  // such node is a flat direct child and no nested node declares those surfaces — so the gather set (hence
288
- // the appended system prompt and the slash dropdown) is byte-for-byte unchanged.
289
- function loadSurface(surface: 'slash' | 'system' | 'hook' | 'skill'): ConfigPreset[] {
324
+ // the appended system prompt and the command dropdown) is byte-for-byte unchanged.
325
+ function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent'): ConfigPreset[] {
290
326
  const out: ConfigPreset[] = []
291
327
  const visit = (nodeDir: string, name: string) => {
292
328
  if (existsSync(join(nodeDir, 'spec.md'))) {
293
329
  const { fm, body } = parseFrontmatter(readFileSync(join(nodeDir, 'spec.md'), 'utf8'))
294
330
  // @@@ skip pending - a `status: pending` plugin is DECLARED INTENT, not yet active. It renders on the
295
- // board (via loadSpecs) but must NOT gather: neither a slash preset, nor folded into a system prompt,
331
+ // board (via loadSpecs) but must NOT gather: neither a command preset, nor folded into a system prompt,
296
332
  // nor a live hook. Only built/active plugins surface here, so pending stubs stay inert.
297
333
  if (str(fm.surface) === surface && str(fm.status) !== 'pending') {
298
334
  out.push({
@@ -306,6 +342,7 @@ function loadSurface(surface: 'slash' | 'system' | 'hook' | 'skill'): ConfigPres
306
342
  events: list(fm.events),
307
343
  order: Number(str(fm.order, '0')) || 0,
308
344
  block: str(fm.block) === 'true',
345
+ tools: list(fm.tools),
309
346
  })
310
347
  }
311
348
  }
@@ -321,7 +358,7 @@ function loadSurface(surface: 'slash' | 'system' | 'hook' | 'skill'): ConfigPres
321
358
  }
322
359
  return out.sort((a, b) => a.name.localeCompare(b.name))
323
360
  }
324
- export function loadConfig(): ConfigPreset[] { return loadSurface('slash') }
361
+ export function loadConfig(): ConfigPreset[] { return loadSurface('command') }
325
362
  export function loadSystemConfig(): ConfigPreset[] { return loadSurface('system') }
326
363
  // the hook handlers (compiled into the per-session hook manifest the dispatcher reads). Each carries its
327
364
  // `events`/`order`/`block` binding + co-located script `files`.
@@ -329,3 +366,7 @@ export function loadHookConfig(): ConfigPreset[] { return loadSurface('hook') }
329
366
  // the skill bundles (rendered into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
330
367
  // load-trigger and its `body` is the on-demand instructions; loadSurface passes the folder basename as `name`.
331
368
  export function loadSkillConfig(): ConfigPreset[] { return loadSurface('skill') }
369
+ // the sub-agent definitions (rendered into each harness's auto-discovered agent dir, e.g. claude's
370
+ // .claude/agents/<name>.md). Like a skill, the node's `desc` is the on-demand load-trigger and its `body` is the
371
+ // agent's system prompt; additionally its `tools` field is the harness tool allowlist for the spawned agent.
372
+ export function loadAgentConfig(): ConfigPreset[] { return loadSurface('agent') }
@@ -7,6 +7,7 @@ import { statSync, readdirSync, type Dirent } from 'node:fs'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { dirname, join } from 'node:path'
9
9
  import { installProcessGuards } from './resilience.js'
10
+ import { listenOrExit } from './listen.js'
10
11
  import { resolvePublicConfig, startGateway, ensureDashboardBuilt, resolveDistDir } from './gateway.js'
11
12
  import { tsxBin } from './tsx-bin.js'
12
13
 
@@ -105,6 +106,14 @@ async function reload(reason: string): Promise<void> {
105
106
  }
106
107
 
107
108
  // the public port: a raw byte pipe to the current backend. Works for HTTP and WS upgrades alike.
109
+ // @@@ connection reaping ([[spec-cli]]) - a raw TCP proxy must tear down the PAIR when EITHER end goes, or it
110
+ // leaks the still-open half. The wedge that started the mass-restore incident was exactly this: every
111
+ // client-side timeout-kill left the client's UPSTREAM socket half-open to the child (the old handler bailed
112
+ // only on `error`, so a clean FIN / a silent drop never reaped the upstream) — 135 leaked conns piled on the
113
+ // child and it looked dead. So a close on either side destroys BOTH (idempotent). The abandoned-but-silent
114
+ // case (no FIN/RST ever arrives) is reaped from the CHILD instead — its HTTP keepAliveTimeout/requestTimeout
115
+ // close an idle/stalled socket, whose close then propagates here — so an active WS/SSE (not idle keep-alive)
116
+ // is never mistaken for abandoned and cut.
108
117
  const proxy = net.createServer((client) => {
109
118
  const target = current
110
119
  if (!target) { client.destroy(); return }
@@ -112,6 +121,12 @@ const proxy = net.createServer((client) => {
112
121
  client.setNoDelay(true); up.setNoDelay(true) // proxy a request promptly — don't let Nagle add latency
113
122
  const bail = () => { client.destroy(); up.destroy() }
114
123
  client.on('error', bail); up.on('error', bail)
124
+ client.once('close', () => up.destroy()) // client abandoned → reap its upstream (THE leak that wedged :8787); nothing left to flush to a gone client
125
+ // upstream gone → drop the client half, but ONLY force it when the close was ABNORMAL: on a normal FIN,
126
+ // `up.pipe(client)` has already called `client.end()` (writableEnded), so let the client flush the last of a
127
+ // large response (e.g. /api/board) rather than truncate it with a destroy; a crash/half-open close (no prior
128
+ // end) still gets reaped.
129
+ up.once('close', () => { if (!client.writableEnded) client.destroy() })
115
130
  client.pipe(up); up.pipe(client)
116
131
  })
117
132
 
@@ -122,14 +137,19 @@ process.on('SIGTERM', shutdown)
122
137
  const first = await boot()
123
138
  if (!first) { console.error('[supervisor] initial backend failed to start'); process.exit(1) }
124
139
  current = first
140
+ // reap the booted child if a port bind fails, so a "can't own my port → exit" never leaves a zombie child.
141
+ // SIGTERM (not SIGKILL): the child is a `tsx` wrapper around the real node server — SIGKILL kills the wrapper
142
+ // instantly and ORPHANS the server still bound to its port, whereas tsx FORWARDS SIGTERM to it (the same
143
+ // signal the reload drain uses). Sent synchronously here, before the process.exit that follows.
144
+ const reapChild = () => { try { current?.child.kill('SIGTERM') } catch { /* already gone */ } }
125
145
  if (publicCfg) {
126
146
  // public mode: the raw proxy stays on loopback; the password-gated gateway owns the public port.
127
147
  const distDir = resolveDistDir() // bundled <pkg>/dashboard-dist when installed, else monorepo spec-dashboard/dist
128
148
  ensureDashboardBuilt(repoRoot, distDir)
129
- proxy.listen(proxyPort, '127.0.0.1', () => console.log(`spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})`))
130
- startGateway({ publicPort, upstreamPort: proxyPort, password: publicCfg.password, tls: publicCfg.tls, distDir })
149
+ listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () => console.log(`spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})`) })
150
+ startGateway({ publicPort, upstreamPort: proxyPort, password: publicCfg.password, tls: publicCfg.tls, distDir, onBindFail: reapChild })
131
151
  } else {
132
- proxy.listen(publicPort, () => console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`))
152
+ listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`) })
133
153
  }
134
154
 
135
155
  // watch every imported source tree; debounce a burst of writes (a merge touching several files across
@@ -1,11 +1,20 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
3
4
 
4
5
  // @@@ tsxBin - where the tsx executable lives, dev-or-published. In the dev monorepo it sits in
5
- // spec-cli/node_modules; in the PUBLISHED `spexcode` tarball spec-cli is a SUBDIR of the package, so tsx
6
- // installs one level up at the package ROOT's node_modules. Resolve against both so the supervisor's child
7
- // spawn and the baked launch/hook SPEX commands work in either layout. `pkgDir` is the spec-cli directory.
6
+ // spec-cli/node_modules; in an installed `spexcode` package npm may hoist it to the consumer's node_modules
7
+ // instead of nesting it under the package. Resolve explicit local candidates first, then let Node's own
8
+ // package resolver walk upward from spec-cli so dev, global, and project-local installs share one mechanism.
9
+ // `pkgDir` is the spec-cli directory.
8
10
  export function tsxBin(pkgDir: string): string {
9
11
  const candidates = [join(pkgDir, 'node_modules', '.bin', 'tsx'), join(pkgDir, '..', 'node_modules', '.bin', 'tsx')]
10
- return candidates.find(existsSync) ?? candidates[0]
12
+ const local = candidates.find(existsSync)
13
+ if (local) return local
14
+ try {
15
+ const req = createRequire(join(pkgDir, 'package.json'))
16
+ return join(dirname(req.resolve('tsx/package.json')), 'dist', 'cli.mjs')
17
+ } catch {
18
+ return candidates[0]
19
+ }
11
20
  }
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
2
+ import { join, resolve, relative } from 'node:path'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { HARNESSES, removeManagedBlock, type HarnessArtifacts } from './harness.js'
5
+ import { runtimeRoot, readConfig, mainCheckout } from './layout.js'
6
+ import { resolveHarnessTargets } from './harness-select.js'
7
+ import { loadSkillConfig, loadAgentConfig } from './specs.js'
8
+
9
+ // @@@ spex-uninstall - the surgical inverse of spex-init/materialize. init + harness-delivery WRITE the
10
+ // SpexCode footprint into a repo; this REMOVES it, so a project can fully back out. EVERY removal is gated on a
11
+ // SpexCode IDENTITY STAMP (the managed-block sentinels, the shim's own dispatch.sh command line, the trust
12
+ // sentinels, the name-scoped on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode
13
+ // itself generated. The one inviolable rule: the user's spec ASSET (.spec/.config) is NEVER touched — uninstall
14
+ // removes only generated WIRING, not the spec graph that wiring served.
15
+
16
+ // the standard plugin-host folders a host agent scans (in addition to any named in spexcode.json's `harnesses`).
17
+ const DEFAULT_PLUGIN_HOSTS = ['.claude', '.codex'] as const
18
+
19
+ // is this dir a SpexCode plugin bundle? Either its folder name is the identity stamp, or its
20
+ // `.claude-plugin/plugin.json` declares `name: spexcode`. Read-gated so a user's other plugin is never touched.
21
+ function isSpexcodeBundle(dir: string): boolean {
22
+ if (dir.split('/').pop() === 'spexcode') return true
23
+ const manifest = join(dir, '.claude-plugin', 'plugin.json')
24
+ if (!existsSync(manifest)) return false
25
+ try {
26
+ return (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name === 'spexcode'
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ // sweep any spexcode-stamped plugin bundle (`<host>/plugins/spexcode`, or a `.claude-plugin/plugin.json` named
33
+ // spexcode) under the project's plugin-host folders. The bundle EMITTER is a later node, so a native-only install
34
+ // has nothing here today; the sweep keeps uninstall a true inverse once it lands (and cleans a hand-dropped one).
35
+ function sweepPluginBundles(proj: string, hosts: readonly string[]): string[] {
36
+ const removed: string[] = []
37
+ for (const host of new Set(hosts)) {
38
+ const pluginsDir = join(proj, host, 'plugins')
39
+ if (!existsSync(pluginsDir)) continue
40
+ for (const e of readdirSync(pluginsDir, { withFileTypes: true })) {
41
+ if (!e.isDirectory()) continue
42
+ const dir = join(pluginsDir, e.name)
43
+ if (isSpexcodeBundle(dir)) {
44
+ rmSync(dir, { recursive: true, force: true })
45
+ removed.push(relative(proj, dir))
46
+ }
47
+ }
48
+ }
49
+ return removed
50
+ }
51
+
52
+ // resolve the repo's shared git hooks dir (the common dir's hooks/), or null when <dir> isn't a git repo.
53
+ function hooksDir(proj: string): string | null {
54
+ try {
55
+ const common = execFileSync('git', ['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
56
+ encoding: 'utf8',
57
+ stdio: ['ignore', 'pipe', 'ignore'],
58
+ }).trim()
59
+ return join(common, 'hooks')
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ // remove the SpexCode git hooks — but ONLY a hook whose content carries a SpexCode identity marker, so a user's
66
+ // own pre-commit is never deleted. Per-clone hooks are preserved by default; this runs only under `--hooks`.
67
+ function removeHooks(proj: string): string[] {
68
+ const dir = hooksDir(proj)
69
+ if (!dir) return []
70
+ const removed: string[] = []
71
+ for (const name of ['pre-commit', 'prepare-commit-msg']) {
72
+ const f = join(dir, name)
73
+ if (existsSync(f) && /spexcode/i.test(readFileSync(f, 'utf8'))) {
74
+ rmSync(f, { force: true })
75
+ removed.push(name)
76
+ }
77
+ }
78
+ return removed
79
+ }
80
+
81
+ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean } = {}): void {
82
+ const proj = resolve(targetArg ?? process.cwd())
83
+ console.log(`spex uninstall → ${proj}`)
84
+
85
+ // cwd = the project so the .config loaders read THIS tree's surface nodes (the live skill/agent names tell
86
+ // each adapter's clean() exactly which name-scoped on-demand files were its to remove).
87
+ const prevCwd = process.cwd()
88
+ let arts: HarnessArtifacts = { skills: [], agents: [] }
89
+ try {
90
+ process.chdir(proj)
91
+ arts = { skills: loadSkillConfig().map((s) => s.name), agents: loadAgentConfig().map((a) => a.name) }
92
+ } catch {
93
+ // no readable .config (already partly torn down, or never adopted) — clean still strips the harness wiring.
94
+ } finally {
95
+ process.chdir(prevCwd)
96
+ }
97
+
98
+ // 1. every harness's own artifacts — clean() is the adapter's surgical inverse (managed contract block w/
99
+ // deleteIfEmpty, generated shim, trust block, named skill/agent files). materialize cleans only UNSELECTED
100
+ // harnesses; uninstall cleans EVERY one. clean() already calls removeTrust, so it is the full inverse.
101
+ for (const h of HARNESSES) h.clean(proj, arts)
102
+
103
+ // 2. the shared .gitignore block — the one in-tree artifact no adapter owns (materialize writes it directly),
104
+ // so strip it directly too: the managed `#` block, deleteIfEmpty so a wholly-ours .gitignore is removed.
105
+ removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], true)
106
+
107
+ // 3. the global per-project store — manifest + content-hash marker + gate lock + session records. This is the
108
+ // runtime tier, not the user's spec asset, so the whole dir is ours.
109
+ let store: string | null = null
110
+ try {
111
+ store = runtimeRoot(proj)
112
+ rmSync(store, { recursive: true, force: true })
113
+ } catch {
114
+ store = null
115
+ }
116
+
117
+ // 4. any spexcode-stamped plugin bundle, under the configured + standard plugin-host folders.
118
+ let pluginHosts: string[] = [...DEFAULT_PLUGIN_HOSTS]
119
+ try {
120
+ const targets = resolveHarnessTargets(readConfig(mainCheckout(proj)).harnesses)
121
+ pluginHosts = [...pluginHosts, ...targets.filter((t) => t.kind === 'plugin').map((t) => (t as { folder: string }).folder)]
122
+ } catch {
123
+ // an illegal harnesses set doesn't block backout — fall through to the standard hosts.
124
+ }
125
+ const bundles = sweepPluginBundles(proj, pluginHosts)
126
+
127
+ console.log(`✓ pruned harness artifacts (CLAUDE.md/AGENTS.md block, shims, Codex trust, skills, sub-agents) for ${HARNESSES.map((h) => h.id).join(', ')}`)
128
+ console.log('✓ stripped the .gitignore spexcode block')
129
+ if (store) console.log(`✓ removed the global per-project store (${store})`)
130
+ if (bundles.length) console.log(`✓ removed plugin bundle(s): ${bundles.join(', ')}`)
131
+
132
+ // git hooks are per-clone and may carry user logic → preserved unless --hooks (and even then only a hook that
133
+ // carries a SpexCode identity marker).
134
+ if (opts.hooks) {
135
+ const removed = removeHooks(proj)
136
+ if (removed.length) console.log(`✓ removed git hooks (${removed.join(', ')})`)
137
+ else console.log('• no spexcode git hooks to remove')
138
+ } else {
139
+ console.log('• left git hooks in place (per-clone; pass --hooks to remove the spexcode ones)')
140
+ }
141
+
142
+ console.log(`
143
+ SpexCode wiring removed. Your spec data is untouched:
144
+ • .spec/ and .config/ remain — your spec graph is YOURS, never deleted by uninstall.
145
+ • To re-adopt later: \`spex init\` regenerates the shims, contract, trust, and global store.`)
146
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # @@@ issue nudge (post-merge) - the taste store ([[local-issues]]) fires AFTER the work lands, not while the
3
+ # agent is finishing it: the agent's own task comes first. Merge is dispatched to the session's OWN agent
4
+ # (see [[dispatch]]), so this hook runs in that live merge turn and its stdout reaches the agent as the merge
5
+ # command's own output — a git-native nudge that costs no harness block-cap and reaches a self-launched agent
6
+ # too. GUARDED to the `merge node/<id>:` commit, so an ordinary `git pull`/unrelated merge never nags.
7
+ #
8
+ # The nudge TEXT and the on/off toggle both live in the CLI (`spex issues nudge <node>` prints nothing when
9
+ # the feature is OFF in spexcode.json), so this hook is a THIN resolver + caller: resolve `spex` the same
10
+ # three ways the pre-commit hook does, pass the merged node, echo whatever it prints. No CLI resolvable → no
11
+ # nudge (safe degradation, same as the lint shim's advisory mode).
12
+ subj=$(git log -1 --format=%s 2>/dev/null)
13
+ case "$subj" in
14
+ "merge node/"*) ;;
15
+ *) exit 0 ;;
16
+ esac
17
+ node=$(printf '%s' "$subj" | sed -n 's#^merge \(node/[^:]*\):.*#\1#p')
18
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
19
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
20
+ if command -v spex >/dev/null 2>&1; then
21
+ spex issues nudge "$node"
22
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
23
+ "$repo_root/node_modules/.bin/spex" issues nudge "$node"
24
+ elif [ -x "$main_root/spec-cli/node_modules/.bin/tsx" ] && [ -f "$main_root/spec-cli/src/cli.ts" ]; then
25
+ "$main_root/spec-cli/node_modules/.bin/tsx" "$main_root/spec-cli/src/cli.ts" issues nudge "$node"
26
+ fi
27
+ exit 0
@@ -1,51 +1,71 @@
1
1
  #!/usr/bin/env bash
2
- # @@@ main-guard - the worktree model: never author directly on main; main only RECEIVES merges.
3
- # Reject a direct commit while HEAD is main. Merges pass (MERGE_HEAD present).
2
+ # @@@ shared spex runner - resolve the `spex` CLI ONCE, into spex_cli(), so BOTH the main-guard trunk
3
+ # lookup and the spec-lint shim shim through the same mechanism (no duplicated, drift-prone resolution).
4
+ # This must work for BOTH ways this hook ships: a real project that installed @spexcode/spec-cli, and the
5
+ # SpexCode monorepo dogfooding itself from source. cwd stays the committing worktree either way. main_root
6
+ # is the parent of the shared git *common* dir (= layout.ts mainCheckout()); a fresh session worktree has
7
+ # no node_modules of its own, so the dogfood tsx path reaches the main checkout's. First that exists wins:
8
+ # 1. `spex` on PATH - installed globally / on the shell PATH.
9
+ # 2. <repo>/node_modules/.bin/spex - a project that did `npm i @spexcode/spec-cli` (the dep's bin).
10
+ # 3. monorepo tsx + cli.ts by path - dogfood: run the CLI from source.
11
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
12
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
13
+ spex_kind=
14
+ if command -v spex >/dev/null 2>&1; then
15
+ spex_kind=path
16
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
17
+ spex_kind=local
18
+ elif [ -x "$main_root/spec-cli/node_modules/.bin/tsx" ] && [ -f "$main_root/spec-cli/src/cli.ts" ]; then
19
+ spex_kind=tsx
20
+ fi
21
+ spex_cli() {
22
+ case "$spex_kind" in
23
+ path) spex "$@" ;;
24
+ local) "$repo_root/node_modules/.bin/spex" "$@" ;;
25
+ tsx) "$main_root/spec-cli/node_modules/.bin/tsx" "$main_root/spec-cli/src/cli.ts" "$@" ;;
26
+ *) return 127 ;;
27
+ esac
28
+ }
29
+
30
+ # @@@ main-guard - the worktree model: never author directly on the trunk; the trunk only RECEIVES merges.
31
+ # Reject a direct commit while HEAD is the trunk. Merges pass (MERGE_HEAD present).
4
32
  # Escape hatch for seeding / eager topology: SPEXCODE_ALLOW_MAIN=1 git commit …
33
+ #
34
+ # The trunk is resolved the SAME way the rest of SpexCode resolves it — `spex internal trunk` = layout.ts
35
+ # mainBranch() (config override → the main checkout's current branch → 'main') — so the guard protects
36
+ # whatever the repo's trunk is actually named, NOT a hardcoded 'main' (which gave a `master`/non-`main`
37
+ # trunk zero protection). When the CLI isn't resolvable (advisory mode), fall back to a pure-git
38
+ # auto-detect of the main checkout's current branch, then 'main' — still protecting a non-`main` trunk.
5
39
  branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo)
6
40
  git_dir=$(git rev-parse --git-dir 2>/dev/null)
7
- if [ "$branch" = "main" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCODE_ALLOW_MAIN:-}" ]; then
8
- echo "✗ SpexCode: direct commits on main are blocked." >&2
41
+ trunk=$(spex_cli internal trunk 2>/dev/null | head -1)
42
+ # Fallback auto-detect: the main checkout's current branch. Strip the hook's exported GIT_DIR/
43
+ # GIT_INDEX_FILE first — otherwise they pin discovery to the COMMITTING worktree and `-C "$main_root"`
44
+ # is ignored, so it would read THIS branch and think every node branch is the trunk. (`spex internal
45
+ # trunk` above is immune: layout.ts mainBranch() resolves through git.ts's env-stripping git().)
46
+ [ -n "$trunk" ] || trunk=$(env -u GIT_DIR -u GIT_INDEX_FILE git -C "$main_root" symbolic-ref --short HEAD 2>/dev/null)
47
+ [ -n "$trunk" ] || trunk=main
48
+ if [ "$branch" = "$trunk" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCODE_ALLOW_MAIN:-}" ]; then
49
+ echo "✗ SpexCode: direct commits on $trunk (the trunk) are blocked." >&2
9
50
  echo " Work in a worktree (.worktrees/<node>, branch node/<id>) and merge back." >&2
10
51
  echo " Merges pass automatically; for seeding/topology: SPEXCODE_ALLOW_MAIN=1 git commit …" >&2
11
52
  exit 1
12
53
  fi
13
54
 
14
55
  # @@@ spec-lint - the hook is just a thin shim over `spex lint`. It blocks on errors only (broken
15
- # spec↔code links); coverage/drift are warnings. Bypass with SPEXCODE_SKIP_LINT=1.
16
- #
17
- # Resolving the `spex` CLI must work for BOTH ways this hook ships: a real project that installed
18
- # @spexcode/spec-cli, and the SpexCode monorepo dogfooding itself from source. cwd stays the committing
19
- # worktree either way, so lint validates THIS worktree's `.spec` (repoRoot() discovers the cwd's toplevel
20
- # via git.ts, which strips the hook's GIT_DIR). First resolution that exists wins:
21
- # 1. `spex` on PATH - installed globally / on the shell PATH.
22
- # 2. <repo>/node_modules/.bin/spex - a project that did `npm i @spexcode/spec-cli` (the dep's bin).
23
- # 3. monorepo tsx + cli.ts by path - dogfood: run the CLI from source. main is the parent of the shared
24
- # git *common* dir (a fresh session worktree has no node_modules of its own, so we reach the main one).
56
+ # spec↔code links); coverage/drift are warnings. Bypass with SPEXCODE_SKIP_LINT=1. It shims through the
57
+ # shared spex_cli() resolved above (repoRoot() discovers the cwd's toplevel via git.ts, which strips the
58
+ # hook's GIT_DIR, so lint validates THIS worktree's `.spec`).
25
59
  if [ -z "${SPEXCODE_SKIP_LINT:-}" ]; then
26
- main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
27
- repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
28
- # @@@ one resolved runner - resolve `spex` ONCE into spex_run(), so every spec-cli pre-commit check
29
- # (lint, the yatsu stray-blob backstop) shims through the same mechanism instead of re-resolving.
30
- spex_found=1
31
- if command -v spex >/dev/null 2>&1; then
32
- spex_run() { spex "$@" >&2; }
33
- elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
34
- spex_run() { "$repo_root/node_modules/.bin/spex" "$@" >&2; }
35
- elif [ -x "$main_root/spec-cli/node_modules/.bin/tsx" ] && [ -f "$main_root/spec-cli/src/cli.ts" ]; then
36
- spex_run() { "$main_root/spec-cli/node_modules/.bin/tsx" "$main_root/spec-cli/src/cli.ts" "$@" >&2; }
37
- else
38
- spex_found=0
39
- fi
40
- if [ "$spex_found" = 1 ]; then
41
- if ! spex_run lint; then
60
+ if [ -n "$spex_kind" ]; then
61
+ if ! spex_cli lint >&2; then
42
62
  echo "✗ SpexCode: spec-lint failed — fix the spec↔code links or bypass with SPEXCODE_SKIP_LINT=1." >&2
43
63
  exit 1
44
64
  fi
45
65
  # @@@ yatsu backstop - reject a staged yatsu offender: a stray pixel blob copied into the tree (blobs
46
66
  # belong in the shared git common dir, never committed) OR a malformed yatsu.md (a scenario schema
47
67
  # violation). Logic lives in spec-yatsu; this is the lint-shim's twin. (this node's contract)
48
- if ! spex_run yatsu check-staged; then
68
+ if ! spex_cli yatsu check-staged >&2; then
49
69
  echo "✗ SpexCode: yatsu check-staged failed (see above) — fix it or bypass with SPEXCODE_SKIP_LINT=1." >&2
50
70
  exit 1
51
71
  fi
@@ -1,14 +1,37 @@
1
1
  #!/usr/bin/env bash
2
- # @@@ session-stamp - auto-attribute a commit to the Claude Code session that authored it. SpexCode
3
- # reads the `Session:` trailer as a version's attribution (git is the database). CLAUDE_CODE_SESSION_ID
4
- # is the live session id Claude Code exports and it EQUALS the `--session-id` we launch worktree
5
- # sessions with, so a spec node's version attribution links to the live session in the dashboard's
6
- # session window for free. No-op outside Claude Code, or if a Session: trailer is already present
7
- # (so the dogfood ritual / a human can still set it explicitly).
2
+ # @@@ session-stamp - auto-attribute a commit to the SESSION that authored it, ACROSS HARNESSES. SpexCode
3
+ # reads the `Session:` trailer as a version's attribution (git is the database), keyed by the SpexCode record
4
+ # id so the dashboard's session window links to it. Resolving that id is a harness-adapter concern, mirroring
5
+ # `envSessionId` / the shell `hp_store_dir` alias:
6
+ # - Claude exports CLAUDE_CODE_SESSION_ID, which EQUALS the record id stamp it directly.
7
+ # - Codex has no such equality: its per-command CODEX_THREAD_ID is the THREAD id, not the record key, and the
8
+ # SHARED per-project app-server means SPEXCODE_SESSION_ID is contaminated with the FIRST session's id — so
9
+ # it must NOT be trusted. Resolve the thread id → record via the SAME `harness_session_id` alias the shell
10
+ # hooks use, AT COMMIT TIME (the record is swept on close, so read-time aliasing would fail), and stamp the
11
+ # resolved RECORD id.
12
+ # No-op outside a governed session, or if a Session: trailer is already present (so the dogfood ritual / a
13
+ # human can still set it explicitly).
8
14
  set -euo pipefail
9
15
  msg_file="$1"
10
- sid="${CLAUDE_CODE_SESSION_ID:-}"
11
- [ -z "$sid" ] && exit 0
12
16
  grep -qiE '^Session:[[:space:]]' "$msg_file" && exit 0
17
+
18
+ sid="${CLAUDE_CODE_SESSION_ID:-}" # claude: the exported id IS the record id
19
+ if [ -z "$sid" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then # codex: alias the thread id → record id
20
+ gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
21
+ || gcd=$(realpath "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null) || gcd=""
22
+ if [ -n "$gcd" ]; then
23
+ # the per-project store (mirrors layout.ts runtimeRoot / harness.sh hp_runtime_dir): keyed by the project
24
+ # (dirname of the ABSOLUTE git-common-dir), '/' and '.' → '-'.
25
+ rd="${SPEXCODE_HOME:-$HOME/.spexcode}/projects/$(printf '%s' "$(dirname "$gcd")" | sed 's#[/.]#-#g')"
26
+ if [ -e "$rd/sessions/$CODEX_THREAD_ID/session.json" ]; then
27
+ sid="$CODEX_THREAD_ID" # a governed record keyed by the thread id (rare/direct)
28
+ else # else the record that captured it as harness_session_id
29
+ hit=$(grep -lF "\"harness_session_id\": \"$CODEX_THREAD_ID\"" "$rd"/sessions/*/session.json 2>/dev/null | head -1)
30
+ [ -n "$hit" ] && sid=$(basename "$(dirname "$hit")")
31
+ fi
32
+ fi
33
+ fi
34
+
35
+ [ -z "$sid" ] && exit 0
13
36
  printf '\nSession: %s\n' "$sid" >> "$msg_file"
14
37
  exit 0
@@ -0,0 +1,11 @@
1
+ ---
2
+ title: clarify-before-code
3
+ surface: system
4
+ status: active
5
+ hue: 30
6
+ desc: A config plugin (careful preset) — before coding, the worker surfaces ambiguities as explicit assumptions in its proposal, and blocks for a live question only on a load-bearing one.
7
+ code:
8
+ ---
9
+ Before you write code, enumerate the ambiguities, contradictions, and technical risks in your task. Most of them are cheap: resolve a cheap one by **stating your assumption explicitly** in the work you propose. SpexCode's manager-merge review is already the hard gate — a misread surfaces there, in the proposal, not in a live interrogation of the human. So clarification shifts **left into the artifact**: the diff and the spec body say what you assumed, and the reviewer catches a wrong assumption at merge.
10
+
11
+ Block for a live question (the `needs-input` channel) **only on a load-bearing ambiguity** — one where guessing wrong would waste the whole node. A small or clear task proceeds without asking. This is deliberately the opposite of "every agent asks the user": the default is to proceed on a stated assumption, and the human is interrupted only when proceeding blind would burn the work.
@@ -16,6 +16,30 @@ S="${SPEX:-spex}"
16
16
  payload=$(cat 2>/dev/null)
17
17
  sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
18
18
  sdir=$(hp_store_dir "$sid") || exit 0
19
+ repo=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
20
+ repo=$(cd "$repo" 2>/dev/null && pwd -P) || exit 0
21
+
22
+ hp_actionable_repo_path() {
23
+ local raw="$1" target dir base abs rel
24
+ case "$raw" in
25
+ /*) target="$raw" ;;
26
+ *) target="$PWD/$raw" ;;
27
+ esac
28
+ dir=${target%/*}; base=${target##*/}
29
+ [ "$dir" = "$target" ] && dir=.
30
+ abs=$(cd "$dir" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$base") || return 1
31
+ case "$abs" in
32
+ "$repo"/*) rel=${abs#"$repo"/} ;;
33
+ *) return 1 ;;
34
+ esac
35
+ [ -n "$rel" ] || return 1
36
+ # editing the spec itself is not a governed-code edit -> nothing to annotate.
37
+ case "$rel" in .spec/*|*/.spec/*|*/spec.md|spec.md|.git/*) return 1 ;; esac
38
+ # Speak only for files Git would care about: tracked files, or untracked files that are not ignored.
39
+ git -C "$repo" ls-files --error-unmatch -- "$rel" >/dev/null 2>&1 && { printf '%s' "$rel"; return 0; }
40
+ git -C "$repo" check-ignore -q -- "$rel" && return 1
41
+ printf '%s' "$rel"
42
+ }
19
43
 
20
44
  # the code file(s) just MUTATED (empty when this tool didn't mutate a file, e.g. a pure read). A codex
21
45
  # multi-file apply_patch yields several paths (one per line) — annotate EACH governed code file, once.
@@ -25,11 +49,10 @@ led="$sdir/spec-of-file-seen" # dedupe: once per session per file. Lists alrea
25
49
  msg=""
26
50
  while IFS= read -r path; do
27
51
  [ -n "$path" ] || continue
28
- # editing the spec itself is not a governed-code edit → nothing to annotate.
29
- case "$path" in */.spec/*|.spec/*|*/spec.md|spec.md) continue ;; esac
52
+ path=$(hp_actionable_repo_path "$path") || continue
30
53
  [ -f "$led" ] && grep -qxF -- "$path" "$led" && continue
31
54
  mkdir -p "$sdir"; echo "$path" >> "$led"
32
- m=$($S owner "$path" --actionable 2>/dev/null) # --actionable: silent on a sanely-owned file; speaks only for an OVER-owned / uncovered file
55
+ m=$(cd "$repo" && $S owner "$path" --actionable 2>/dev/null) # --actionable: silent on a sanely-owned file; speaks only for an OVER-owned / uncovered file
33
56
  [ -n "$m" ] || continue
34
57
  msg="${msg:+$msg
35
58
  }$m"
@@ -11,3 +11,7 @@ Commit your spec node and the code it justifies BEFORE you declare done or propo
11
11
  A spec body is a living current-state document: it states the node's PRESENT intent and is rewritten in place. Never accrete a "## vN" changelog heading, and never add current-state or verdict sections — version history is git's job, not the body's.
12
12
 
13
13
  An independently-scoped feature gets its OWN spec node: if you build something separately scoped while working, create a sibling node for it rather than bundling it into your assigned node's commit (cosmetic polish riding along is the smell).
14
+
15
+ Keep the loss signal honest for what you changed — yatsu is the signal the optimizer reads, so a gap is a blind spot. Changed a node that carries a `yatsu.md`? Re-measure it: run its scenario, compare to the expected, and file the result with `spex yatsu eval <node>`. Made an obvious frontend change to a node with NO `yatsu.md`? Give it one — a scenario (description + expected) — so its loss can be measured. A frontend scenario is measured through the **actual running product** — drive a real browser, read the real DOM and capture a screenshot (or video), never reason about the code — and that real observation is filed as the reading, not left as an ad-hoc check you ran but never recorded. `spex yatsu scan --changed` shows the gaps in exactly the nodes you touched.
16
+
17
+ Don't reverse-engineer the file formats: `spex guide spec` and `spex guide yatsu` print the full spec.md and yatsu.md schema on demand. This prompt is the clue; that manual carries the detail. The CLI explains itself the same way: `spex help` is the command map (grouped by the loop each verb serves), `spex help <command>` one command's usage — when unsure of a verb, ask the tool, don't guess.