spexcode 0.2.4 → 0.2.6
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +43 -7
- package/spec-cli/src/board.ts +2 -1
- package/spec-cli/src/boardCache.ts +27 -1
- package/spec-cli/src/boardStream.ts +5 -4
- package/spec-cli/src/cli.ts +93 -20
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +156 -0
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +94 -38
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +22 -8
- package/spec-cli/src/help.ts +28 -16
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +17 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +31 -13
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +214 -144
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/ranker.ts +25 -8
- package/spec-cli/src/runtime-guard.ts +44 -0
- package/spec-cli/src/search.bench.mjs +15 -5
- package/spec-cli/src/sessions.ts +96 -22
- package/spec-cli/src/specs.ts +38 -18
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +18 -19
- package/spec-cli/src/worktree-sources.ts +52 -13
- package/spec-cli/templates/hooks/post-checkout +22 -0
- package/spec-cli/templates/hooks/post-merge +15 -9
- package/spec-cli/templates/hooks/pre-commit +10 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
- package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
- package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +2 -2
- package/spec-forge/src/drivers/gitlab.ts +168 -0
- package/spec-forge/src/drivers.ts +80 -2
- package/spec-forge/src/resident.ts +10 -5
- package/spec-yatsu/src/cli.ts +37 -16
- package/spec-yatsu/src/evaltab.ts +6 -3
- package/spec-yatsu/src/filing.ts +13 -8
- package/spec-yatsu/src/freshness.ts +97 -22
- package/spec-yatsu/src/proof.ts +14 -3
- package/spec-yatsu/src/scenariofresh.ts +38 -11
- package/spec-yatsu/src/yatsu.ts +52 -28
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
- package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
package/spec-cli/src/help.ts
CHANGED
|
@@ -72,11 +72,12 @@ session list. Identical to GET /api/board; needs the backend (spex serve) reacha
|
|
|
72
72
|
see: 'spex tree (the same graph, human-readable) · spex ls (just the sessions, as a table) · spex search (find one node instead of dumping all)',
|
|
73
73
|
},
|
|
74
74
|
guide: {
|
|
75
|
-
line: 'guide [topic] the manuals: setup workflow · spec/yatsu file formats · spexcode.json',
|
|
75
|
+
line: 'guide [topic] the manuals: setup workflow · spec/yatsu file formats · spexcode.json · footprint',
|
|
76
76
|
body: `Usage: spex guide the human setup workflow (install once, adopt a repo, serve)
|
|
77
77
|
spex guide spec the spec.md file format + every lint rule
|
|
78
78
|
spex guide yatsu the yatsu.md scenario format + how loss is measured and filed
|
|
79
79
|
spex guide config every spexcode.json / spexcode.local.json field, and which file it belongs in
|
|
80
|
+
spex guide footprint the footprint model: never-tracked artifacts, exclude + content filter, anchors
|
|
80
81
|
|
|
81
82
|
guide is the SKILL layer — workflows and formats. Command usage lives here in help
|
|
82
83
|
(\`spex help <cmd>\`); guide carries what the commands assume you know.`,
|
|
@@ -180,16 +181,21 @@ a node/<id> branch links its PR for free). Read-only — git/.spec stays the sin
|
|
|
180
181
|
},
|
|
181
182
|
// ── dispatch & manage sessions (manager loop) ─────────────────────────────
|
|
182
183
|
new: {
|
|
183
|
-
line: 'new "<prompt>" launch a worker session in its own node worktree [--node <id>] [--launcher <name>]',
|
|
184
|
+
line: 'new "<prompt>" launch a worker session in its own node worktree [--prompt-file <path>|-] [--node <id>] [--launcher <name>]',
|
|
184
185
|
body: `Usage: spex new "<task prompt>" [--node <id>] [--launcher <name>]
|
|
186
|
+
spex new --prompt-file <path>|- [--node <id>] [--launcher <name>]
|
|
185
187
|
|
|
186
188
|
Creates a session: node branch + worktree + a launched agent carrying your prompt (= session new).
|
|
187
189
|
Give it ONLY its task — the dev-flow contract reaches it through the materialized system prompt.
|
|
190
|
+
--prompt-file <path> reads the prompt from a file (- = stdin), so a long multi-paragraph prompt never
|
|
191
|
+
fights shell quoting; it is exclusive with the inline prompt (both given = error), and an unreadable
|
|
192
|
+
or empty file refuses the launch.
|
|
188
193
|
The launcher name selects both the agent harness and the command/auth profile (built-ins: claude, codex);
|
|
189
194
|
omitting it requires sessions.defaultLauncher in spexcode.json or spexcode.local.json.
|
|
190
195
|
Routes through the running backend (auth env + concurrency cap); prints the created session JSON.
|
|
191
|
-
Then MONITOR it: background \`spex wait <id>\`, or \`spex watch\` for the whole stream
|
|
192
|
-
|
|
196
|
+
Then MONITOR it: background \`spex wait <id>\`, or \`spex watch\` for the whole stream.
|
|
197
|
+
Talk to it with \`spex send <id> "<msg>"\` — never raw tmux keystrokes.`,
|
|
198
|
+
see: 'spex wait / spex watch (monitor) · spex send (talk to it) · spex review (when it proposes) · ' + SEL_NOTE.split('\n')[0],
|
|
193
199
|
},
|
|
194
200
|
ls: {
|
|
195
201
|
line: 'ls [SEL…] living-sessions table [--status a,b] [--json] [--api URL]',
|
|
@@ -312,7 +318,9 @@ ${ROUTING_NOTE}`,
|
|
|
312
318
|
|
|
313
319
|
Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .config plugins), plants
|
|
314
320
|
spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
|
|
315
|
-
shims). Additive — never overwrites your files. --preset picks the .config plugin tier (cumulative)
|
|
321
|
+
shims). Additive — never overwrites your files. --preset picks the .config plugin tier (cumulative).
|
|
322
|
+
Footprint needs no vote: materialized artifacts are never tracked — hidden via the per-clone .git/info/exclude, with
|
|
323
|
+
a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex guide footprint).`,
|
|
316
324
|
see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex lint (adoption TODO)',
|
|
317
325
|
},
|
|
318
326
|
uninstall: {
|
|
@@ -320,30 +328,32 @@ shims). Additive — never overwrites your files. --preset picks the .config plu
|
|
|
320
328
|
body: `Usage: spex uninstall [dir=cwd] [--hooks]
|
|
321
329
|
|
|
322
330
|
Removes every SpexCode-GENERATED artifact (harness shims · contract blocks · trust entries ·
|
|
323
|
-
|
|
331
|
+
exclude/ignore blocks · global store · plugin bundle) and never your .spec/.config data or your own
|
|
324
332
|
prose. Git hooks are preserved unless --hooks.`,
|
|
325
333
|
see: 'spex init (re-adopt later — your .spec survives)',
|
|
326
334
|
},
|
|
327
335
|
materialize: {
|
|
328
|
-
line: 'materialize re-
|
|
336
|
+
line: 'materialize re-materialize the harness artifacts (contract block · shims) for cwd’s project',
|
|
329
337
|
body: `Usage: spex materialize
|
|
330
338
|
|
|
331
|
-
|
|
332
|
-
CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
339
|
+
Materializes the surface:system config nodes into the managed <!-- spexcode --> block of
|
|
340
|
+
CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash. The materialize anchors on
|
|
341
|
+
git-native events only (init · this verb · session-worktree creation · the pre-commit/post-checkout/
|
|
342
|
+
post-merge hooks) — run it by hand after a toolchain update, or in the setup step of any clone that
|
|
343
|
+
has no spex-planted hooks yet (CI, a cloud agent): the artifacts are generated and excluded, so they
|
|
344
|
+
never arrive via git.`,
|
|
345
|
+
see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
|
|
336
346
|
},
|
|
337
347
|
doctor: {
|
|
338
348
|
line: 'doctor diagnose whether the workflow actually reaches this agent — per-layer, per-harness',
|
|
339
349
|
body: `Usage: spex doctor per-layer coverage report: preconditions · git-hook floor · contract ·
|
|
340
|
-
hooks + handler existence · backend — for every harness materialize
|
|
350
|
+
hooks + handler existence · backend — for every harness materialize delivers to
|
|
341
351
|
spex doctor contract print the composed surface:system text any agent here reads
|
|
342
352
|
spex doctor conflicts detect double-delivery (loose artifacts beside the managed ones)
|
|
343
353
|
|
|
344
354
|
Run it when a worker seems to be missing its contract or hooks — it names the broken layer and the
|
|
345
355
|
repair, instead of you diffing materialized files by hand.`,
|
|
346
|
-
see: 'spex materialize (re-
|
|
356
|
+
see: 'spex materialize (re-materialize the artifacts doctor checks)',
|
|
347
357
|
},
|
|
348
358
|
serve: {
|
|
349
359
|
line: 'serve run the API backend (default :8787) [--port N] [--public --password pw]',
|
|
@@ -372,10 +382,12 @@ so bind wide only on a network you trust (for the internet, use \`spex serve --p
|
|
|
372
382
|
// ── plumbing ──────────────────────────────────────────────────────────────
|
|
373
383
|
internal: {
|
|
374
384
|
line: '', // deliberately not on the map
|
|
375
|
-
body: `Usage: spex internal <trunk | codex-launch | codex-turn>
|
|
385
|
+
body: `Usage: spex internal <trunk | commit-surgery | refresh-footprint | codex-launch | codex-turn>
|
|
376
386
|
|
|
377
387
|
Machine plumbing — called by generated hooks and launch scripts, never typed by a human or agent:
|
|
378
|
-
trunk
|
|
388
|
+
trunk print the resolved source-of-truth branch (the pre-commit main-guard captures it)
|
|
389
|
+
commit-surgery pre-commit footprint anchor: unconditional materialize + staged-index repair
|
|
390
|
+
refresh-footprint quiet materialize — the post-checkout/post-merge freshness anchor
|
|
379
391
|
codex-launch <sock> <cwd> [prompt…] backend-owned codex thread/start + first turn (launch script)
|
|
380
392
|
codex-turn <sock> <threadId> <text…> fire a follow-up turn on an owned thread (tests/scripts)
|
|
381
393
|
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadCon
|
|
|
7
7
|
import { issuesEnabled, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
|
|
8
8
|
import { closeIssue, createIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
|
|
9
9
|
import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
|
|
10
|
+
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
10
11
|
import { summarize } from './mentions.js'
|
|
11
12
|
import { resolveLayout, mainBranch } from './layout.js'
|
|
12
13
|
import { getBoardJson } from './boardCache.js'
|
|
@@ -44,7 +45,8 @@ app.get('/health', (c) => c.text('ok'))
|
|
|
44
45
|
// shares ONE build instead of each running its own — the poll-frequency cut (push channel) and the
|
|
45
46
|
// build-coalescing cut compound. A hard timeout bounds a wedged build to a loud 503 rather than an
|
|
46
47
|
// unboundedly-held connection (the wall sits well above the legitimately-several-seconds cold first build);
|
|
47
|
-
//
|
|
48
|
+
// a merely-slow single-flight build keeps running and caches for the next poll, while a NEVER-settling one
|
|
49
|
+
// is bounded by [[board-cache]]'s own build watchdog, so the next poll retries a fresh build.
|
|
48
50
|
const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000)
|
|
49
51
|
app.get('/api/board', etag(), async (c) => {
|
|
50
52
|
const timeout = Symbol('timeout')
|
|
@@ -160,7 +162,7 @@ app.get('/api/issues', etag(), (c) =>
|
|
|
160
162
|
c.json({
|
|
161
163
|
enabled: issuesEnabled(),
|
|
162
164
|
stores: issueStores(),
|
|
163
|
-
issues: mergedIssues({ host:
|
|
165
|
+
issues: mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id)),
|
|
164
166
|
}))
|
|
165
167
|
// the WRITE surface ([[local-issues]] / [[issues-view]]) — the human reply path, STORE-ROUTED through the one
|
|
166
168
|
// reply verb ([[issues]] replyIssue): a local id git-commits to the trunk store, a forge id ('github#N')
|
|
@@ -181,7 +183,7 @@ app.post('/api/issues/:id/reply', async (c) => {
|
|
|
181
183
|
try {
|
|
182
184
|
// the mention prompt's node context, from the same resident merge the GET serves
|
|
183
185
|
const node = id.includes('#')
|
|
184
|
-
? mergedIssues({ host:
|
|
186
|
+
? mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id)).find((i) => i.id === id)?.nodes[0] ?? null
|
|
185
187
|
: null
|
|
186
188
|
const r = await replyIssue(id, text, { author: 'human', node, evidence })
|
|
187
189
|
if (r.store !== 'local') await refreshForgeNow()
|
|
@@ -446,9 +448,10 @@ app.post('/api/sessions/:id/rawkey', async (c) => {
|
|
|
446
448
|
// removes the worktree. {ok:false} = no such session.
|
|
447
449
|
app.post('/api/sessions/:id/exit', async (c) => c.json({ ok: await exitSession(c.req.param('id')) }))
|
|
448
450
|
app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
|
|
449
|
-
// set (or clear, with a blank) a session's display-name override; persists to the
|
|
450
|
-
// it survives a restart. Unknown id → 404.
|
|
451
|
-
//
|
|
451
|
+
// set (or clear, with a blank) a session's display-name override; persists to the session's global record
|
|
452
|
+
// (`session.json`) so it survives a restart. Unknown id → 404. That record sits INSIDE the watched store, but
|
|
453
|
+
// the store watch is best-effort (it can fail to attach), so the route still nudges the stream explicitly
|
|
454
|
+
// ([[board-stream]]) — the rename shows in ~150ms deterministically, never waiting out a cold tick.
|
|
452
455
|
app.post('/api/sessions/:id/rename', async (c) => {
|
|
453
456
|
const body = await c.req.json().catch(() => ({}))
|
|
454
457
|
const ok = await renameSession(c.req.param('id'), typeof body?.name === 'string' ? body.name : '')
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync } from 'node:fs'
|
|
1
|
+
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join, resolve, relative } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { execFileSync } from 'node:child_process'
|
|
5
|
-
import { readConfig } from './layout.js'
|
|
5
|
+
import { readConfig, readJsonConfig } from './layout.js'
|
|
6
6
|
import { resolveHarnessTargets } from './harness-select.js'
|
|
7
7
|
|
|
8
8
|
// this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
|
|
@@ -99,13 +99,16 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// 1b. plant a starter spexcode.json (the lint/layout knob)
|
|
102
|
+
// 1b. plant a starter spexcode.json (the lint/layout knob). The success message reports the value the
|
|
103
|
+
// template ACTUALLY ships (read from the planted file, never restated as a string literal here — the two
|
|
104
|
+
// once drifted: the message claimed ["src"] while the template seeded ["."]).
|
|
103
105
|
const cfgDest = join(targetDir, 'spexcode.json')
|
|
104
106
|
if (existsSync(cfgDest)) {
|
|
105
107
|
console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched.`)
|
|
106
108
|
} else {
|
|
107
109
|
copyFileSync(join(TEMPLATES, 'spexcode.json'), cfgDest)
|
|
108
|
-
|
|
110
|
+
const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
|
|
111
|
+
console.log(`✓ planted spexcode.json — lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded); curate explicit roots later if you want a narrower graph`)
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
// validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
|
|
@@ -141,29 +144,33 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
141
144
|
if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
|
|
142
145
|
}
|
|
143
146
|
|
|
144
|
-
// 2c.
|
|
147
|
+
// 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched claude/codex works with zero further
|
|
145
148
|
// steps: the hook manifest (in the GLOBAL per-project store, not the worktree), the AGENTS.md/CLAUDE.md
|
|
146
149
|
// <spexcode> contract block (user content preserved), the .claude/.codex shims, and the Codex trust (global,
|
|
147
150
|
// scoped) so codex self-launch is prompt-free. Runs with cwd = the target so the loaders read the just-seeded
|
|
148
|
-
// .config. Idempotent — the
|
|
151
|
+
// .config. Idempotent — the planted git hooks (pre-commit/post-checkout/post-merge) keep it fresh
|
|
152
|
+
// thereafter on the git-native anchors ([[commit-surgery]]); no harness event ever triggers a materialize.
|
|
149
153
|
const prevCwd = process.cwd()
|
|
150
154
|
try {
|
|
151
155
|
process.chdir(targetDir)
|
|
152
156
|
const { materialize } = await import('./materialize.js')
|
|
153
157
|
materialize(targetDir)
|
|
154
|
-
console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block,
|
|
158
|
+
console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block, harness shims, Codex trust)')
|
|
155
159
|
} catch (e) {
|
|
156
160
|
console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
|
|
157
161
|
} finally {
|
|
158
162
|
process.chdir(prevCwd)
|
|
159
163
|
}
|
|
160
164
|
|
|
161
|
-
// 3. next steps — what the human must do to bring the instance to life.
|
|
165
|
+
// 3. next steps — what the human must do to bring the instance to life. The governedRoots line reads the
|
|
166
|
+
// LIVE value (the planted starter's, or a pre-existing config's) so it can never drift from what's on disk.
|
|
167
|
+
const rootsNow = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
|
|
162
168
|
console.log(`
|
|
163
169
|
Next steps:
|
|
164
170
|
1. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
|
|
165
|
-
2.
|
|
166
|
-
|
|
171
|
+
2. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex lint\` governs —
|
|
172
|
+
["."] governs the whole git-tracked tree (tests excluded); narrow it to explicit source roots
|
|
173
|
+
when you want a curated graph.
|
|
167
174
|
3. Start the backend and open the board:
|
|
168
175
|
spex serve # http://localhost:8787
|
|
169
176
|
4. \`spex lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
|
package/spec-cli/src/issues.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (createIssue/createComment/closeIssue — the driver stays the only network toucher; the tracer stays read-only).
|
|
10
10
|
import type { ForgeIssue, ForgePR } from '../../spec-forge/src/port.js'
|
|
11
11
|
import { resolveLinks } from '../../spec-forge/src/links.js'
|
|
12
|
-
import {
|
|
12
|
+
import { FORGE_DRIVERS, forgeDriverFor, forgeIssueStores, resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
13
13
|
import { closeLocalIssue, loadLocalIssues, loadOne, postLocalIssue, reply, issuesEnabled, replyLocalIssue, runIssueWrite, ISSUE_WRITE_SUBS } from './localIssues.js'
|
|
14
14
|
import { dispatchMentions, parseMentions, type DispatchOutcome, type LoopIn } from './mentions.js'
|
|
15
15
|
import { envSessionId } from './layout.js'
|
|
@@ -191,8 +191,9 @@ export async function promote(id: string, opts: { author?: string } = {}): Promi
|
|
|
191
191
|
const author = opts.author || envSessionId() || 'unknown'
|
|
192
192
|
const t = loadOne(id)
|
|
193
193
|
if (t.status !== 'open') throw new Error(`'${id}' is ${t.status} — only an open local issue promotes`)
|
|
194
|
-
const
|
|
195
|
-
|
|
194
|
+
const host = resolveForgeHost()
|
|
195
|
+
const driver = forgeDriverFor(host)
|
|
196
|
+
if (!driver) throw new Error(`no driver for this repo's forge host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')}) — promotion needs one`)
|
|
196
197
|
const body = [
|
|
197
198
|
t.body,
|
|
198
199
|
t.nodes.length ? `\nSpec: ${t.nodes.join(', ')}` : '',
|
|
@@ -300,8 +301,9 @@ export async function runIssues(args: string[]): Promise<number> {
|
|
|
300
301
|
const nodeIds = loadSpecsLite().map((s) => s.id)
|
|
301
302
|
let forge: ForgeSlice | null = null
|
|
302
303
|
try {
|
|
303
|
-
const
|
|
304
|
-
|
|
304
|
+
const host = resolveForgeHost()
|
|
305
|
+
const driver = forgeDriverFor(host)
|
|
306
|
+
if (!driver) throw new Error(`no driver for this repo's forge host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
|
|
305
307
|
const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
|
|
306
308
|
forge = { host: driver.host, state: { issues, prs } }
|
|
307
309
|
} catch (e) {
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -10,12 +10,15 @@ type Config = {
|
|
|
10
10
|
mainBranch?: string // source-of-truth BRANCH worktrees fork from (default: auto-detected — see mainBranch())
|
|
11
11
|
branchPrefix?: string // how a branch names its node (default: "node/")
|
|
12
12
|
preset?: string // the SELECTED init preset — which cumulative .config tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
13
|
+
// RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
|
|
14
|
+
// tracked now (one residence behavior: the per-clone exclude, plus the content filter for a mixed
|
|
15
|
+
// contract file), so the field is IGNORED with a loud non-fatal notice (materialize's retiredAxisNotice);
|
|
16
|
+
// it stays in the type only so the notice can read it. The schema deliberately has NO knob for the spec
|
|
17
|
+
// DATA: `.spec` + spexcode.json are ALWAYS tracked ("git is the database") — the vocabulary itself makes
|
|
18
|
+
// "untrack the spec" unsayable.
|
|
19
|
+
render?: string
|
|
20
|
+
// RETIRED (residence compat): the old private-overlay toggle — ignored with the same loud notice;
|
|
21
|
+
// its data-untrack semantics are long gone. See `spex guide footprint` MIGRATIONS.
|
|
19
22
|
private?: boolean
|
|
20
23
|
// which harness targets `spex materialize` delivers into — native ids ('claude'|'codex') or a {plugin:"<folder>"}
|
|
21
24
|
// bundle; resolved + validated by [[harness-select]] (harness-select.ts). Default (omitted): all native harnesses.
|
|
@@ -46,12 +49,15 @@ type Config = {
|
|
|
46
49
|
issues?: {
|
|
47
50
|
enabled?: boolean // the [[local-issues]] issues-workflow on/off switch (default ON). OFF silences the post-merge nudge + hides the dashboard view; flip with `spex issues on|off`. (Pre-rename `proposals.enabled` still reads — localIssues.ts issuesEnabled.)
|
|
48
51
|
}
|
|
52
|
+
forge?: {
|
|
53
|
+
host?: string // explicit forge host id ('github'|'gitlab'|…) overriding the origin-remote derivation ([[forge-host]] — read by spec-forge drivers.ts resolveForgeHost, not here). A project fact → committed spexcode.json.
|
|
54
|
+
}
|
|
49
55
|
}
|
|
50
56
|
// the resolved LAYOUT convention — main/mainBranch/branchPrefix filled to defaults. `dashboard`, `sessions`,
|
|
51
|
-
// `serve`, `harnesses`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
|
|
57
|
+
// `serve`, `harnesses`, `render`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
|
|
52
58
|
// preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
|
|
53
59
|
// gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
|
|
54
|
-
type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'private'>>
|
|
60
|
+
type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
|
|
55
61
|
|
|
56
62
|
export type Worktree = {
|
|
57
63
|
path: string; branch: string | null; node: string | null
|
|
@@ -133,17 +139,29 @@ export function spexcodeHome(): string {
|
|
|
133
139
|
export function encodeProject(root: string): string {
|
|
134
140
|
return root.replace(/[/.]/g, '-')
|
|
135
141
|
}
|
|
136
|
-
// this project's per-PROJECT runtime tier — the
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
142
|
+
// this project's per-PROJECT runtime tier — the sessions/ records AND the per-TREE materialize slots (below) —
|
|
143
|
+
// living under the SAME global per-project dir, so NOTHING SpexCode materializes stays in the worktree (the
|
|
144
|
+
// worktree holds only the harness-discovered CLAUDE.md/AGENTS.md + shims, which must sit in-tree).
|
|
145
|
+
// proj-aware for `spex init <dir>` / materialize(proj); cwd-based default for the hooks/board. The shell
|
|
146
|
+
// hooks mirror this as hp_runtime_dir.
|
|
141
147
|
export function runtimeRoot(proj?: string): string {
|
|
142
148
|
const gcd = proj
|
|
143
149
|
? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
144
150
|
: gitCommonDir()
|
|
145
151
|
return join(spexcodeHome(), 'projects', encodeProject(dirname(gcd)))
|
|
146
152
|
}
|
|
153
|
+
// the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
|
|
154
|
+
// products that are a pure function of ONE tree's .config (hooks-manifest, content-hash, plugin-folders).
|
|
155
|
+
// Slotted per tree exactly like sessions/<id> is slotted per session: the old single global file made the
|
|
156
|
+
// last-materialized tree win, so dispatch ran tree A's compiled hook set inside tree B's sessions
|
|
157
|
+
// ([[hook-dispatch]]). Key = the sessions encodeProject transform over `rev-parse --show-toplevel`, the
|
|
158
|
+
// SAME derivation dispatch.sh's shell mirror (hp_tree_dir) runs from its own cwd — so writer and reader
|
|
159
|
+
// land on the same slot from the same tree, and only from the same tree. Throws when `wt` is not a live
|
|
160
|
+
// git tree (fail loud); a best-effort caller (the close-time GC) wraps it.
|
|
161
|
+
export function treeSlotDir(wt: string): string {
|
|
162
|
+
const top = git(['-C', wt, 'rev-parse', '--show-toplevel']).trim()
|
|
163
|
+
return join(runtimeRoot(wt), 'trees', encodeProject(top || wt))
|
|
164
|
+
}
|
|
147
165
|
// this project's per-session records dir, one session's dir, its structured record, and a sibling artifact —
|
|
148
166
|
// all keyed by session_id under <home>/projects/<enc>/sessions/.
|
|
149
167
|
export function sessionsRoot(): string { return join(runtimeRoot(), 'sessions') }
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -140,6 +140,11 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
140
140
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
|
|
141
141
|
owners.set(f, [...(owners.get(f) ?? []), s.id])
|
|
142
142
|
}
|
|
143
|
+
// one-govern: a node is source of truth for at most ONE file, so drift/yatsu/ack have a single
|
|
144
|
+
// unambiguous subject (see [[governed-related]]). >1 is a defect — pick the true subject, demote the
|
|
145
|
+
// rest to related. ERROR (the node-side twin of too-many-owners' file-side bound). 0 is fine.
|
|
146
|
+
if (s.code.length > 1)
|
|
147
|
+
out.push({ level: 'error', rule: 'one-govern', spec: s.id, msg: `'${s.id}' governs ${s.code.length} files [${s.code.join(', ')}] — a node is source of truth for at most ONE. Keep the true subject in code:, move the rest to related:` })
|
|
143
148
|
}
|
|
144
149
|
// a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
|
|
145
150
|
// `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
|
|
@@ -217,6 +222,20 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
217
222
|
out.push({ level: 'warn', rule: 'drift', spec: s.id, file: d.file, msg: `${d.file} is ${d.behind} commit(s) ahead of spec '${s.id}' (v${s.version}) — may be stale` })
|
|
218
223
|
}
|
|
219
224
|
|
|
225
|
+
// related drift: the SOFT tier ([[governed-related]]). A referenced file moved ahead of the node's
|
|
226
|
+
// version — a nudge that a dependency shifted. Same ancestry basis as govern drift, but WARN-only,
|
|
227
|
+
// never reaching the commit gate (driftGate reads govern) or yatsu. It is COMMON (shared substrate and
|
|
228
|
+
// faces change often without re-versioning every referrer), so per-file it is a wall; like
|
|
229
|
+
// too-many-owners it collapses to ONE summary line, with the per-file detail riding the board
|
|
230
|
+
// (relatedDriftFiles). It stays a soft edge, never a per-file interruption.
|
|
231
|
+
const rd = specs.flatMap((s) => s.relatedDriftFiles.map((d) => ({ id: s.id, behind: d.behind })))
|
|
232
|
+
if (rd.length) {
|
|
233
|
+
const byNode = new Map<string, number>()
|
|
234
|
+
for (const d of rd) byNode.set(d.id, (byNode.get(d.id) ?? 0) + 1)
|
|
235
|
+
const worst = [...byNode].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}(${n})`).join(', ')
|
|
236
|
+
out.push({ level: 'warn', rule: 'related-drift', msg: `${rd.length} related file(s) across ${byNode.size} node(s) drifted ahead of their spec (SOFT — a dependency shifted, worth a glance; never blocks, no ack, no yatsu). Most: ${worst}` })
|
|
237
|
+
}
|
|
238
|
+
|
|
220
239
|
return out
|
|
221
240
|
}
|
|
222
241
|
|
|
@@ -362,16 +362,28 @@ export async function replyLocalIssue(id: string, body: string, author: string,
|
|
|
362
362
|
// The FALLBACK CHAIN of candidates a reply loops in ([[mentions]] loop-in / [[remark-substrate]] R3's dispatch
|
|
363
363
|
// clause), tried in order until one is online. A plain thread's only candidate is its author (`by`). An
|
|
364
364
|
// EVAL-COMMENT thread (concern `eval: <node> · <scenario>`, the eval-remark track) chains: the agent who FILED
|
|
365
|
-
// the reading the remark judges FIRST
|
|
366
|
-
//
|
|
367
|
-
//
|
|
365
|
+
// the reading the remark judges FIRST — resolved from the TRUNK sidecar, then from each LIVE session's
|
|
366
|
+
// WORKTREE (an in-flight reading, filed on an unmerged branch, is invisible to the trunk — exactly the
|
|
367
|
+
// review-time case, when the filer sits online awaiting review and the remark must reach them) — then, when
|
|
368
|
+
// every filer is offline/absent, the NODE's governing session, so an unresolved remark still REACHES an agent
|
|
369
|
+
// who can act on it. A broken/absent worktree sidecar falls through silently — one bad worktree never fails
|
|
370
|
+
// the remark write. This is notification only; it resolves nothing (R3: resolve is a deliberate
|
|
371
|
+
// `spex resolve`). Non-eval threads pay nothing (no yatsu/specs/sessions import).
|
|
368
372
|
const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/ // node first (never contains ' · '), then the scenario (may)
|
|
369
373
|
async function threadOriginators(thread: Issue): Promise<(string | null)[]> {
|
|
370
374
|
const m = EVAL_CONCERN_RE.exec(thread.concern)
|
|
371
375
|
if (!m) return [thread.by]
|
|
372
376
|
const node = m[1].trim(), scenario = m[2].trim()
|
|
373
377
|
const { evalReadingFiler } = await import('../../spec-yatsu/src/filing.js')
|
|
374
|
-
|
|
378
|
+
const chain: (string | null)[] = [evalReadingFiler(node, scenario)]
|
|
379
|
+
try {
|
|
380
|
+
const { listSessions } = await import('./sessions.js')
|
|
381
|
+
for (const s of await listSessions()) {
|
|
382
|
+
try { if (s.path) chain.push(evalReadingFiler(node, scenario, s.path)) } catch { /* one unreadable worktree → next link */ }
|
|
383
|
+
}
|
|
384
|
+
} catch { /* sessions unavailable (bare store, no tmux) → trunk-only chain, as before */ }
|
|
385
|
+
chain.push(await nodeGoverningSession(node))
|
|
386
|
+
return chain
|
|
375
387
|
}
|
|
376
388
|
|
|
377
389
|
// A node's governing session — the `session` its spec resolves to (the Session: trailer of its latest version,
|