spexcode 0.1.6 → 0.2.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/README.md +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +49 -13
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-cli/src/specs.ts
CHANGED
|
@@ -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|
|
|
242
|
-
// `
|
|
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 `
|
|
246
|
-
// surface, loadHookConfig the `hook` surface, loadSkillConfig the `skill` surface
|
|
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`/`
|
|
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
|
|
289
|
-
function loadSurface(surface: '
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
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
|
package/spec-cli/src/tsx-bin.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import {
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
# @@@
|
|
3
|
-
#
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
27
|
-
|
|
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 !
|
|
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
|
|
3
|
-
# reads the `Session:` trailer as a version's attribution (git is the database)
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
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.
|