spexcode 0.2.3 → 0.2.5
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 +2 -2
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/harness.sh +32 -6
- 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 +82 -19
- package/spec-cli/src/contract-filter.ts +154 -0
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +91 -38
- package/spec-cli/src/harness.ts +18 -4
- package/spec-cli/src/help.ts +16 -7
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +52 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +20 -8
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +218 -124
- 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 +81 -12
- package/spec-cli/src/specs.ts +29 -14
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +15 -17
- package/spec-cli/src/worktree-sources.ts +56 -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-dashboard/dist/assets/Dashboard-CMRJGfYI.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-Dj2mxcfW.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-BfNpeyRQ.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-DfY315kt.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-BGdC0A0P.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-BOBCAR0t.js} +5 -5
- package/spec-dashboard/dist/assets/SessionWindow-D7YmjV0i.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-BlSNmpH_.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BFdzpd_O.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/guide.ts
CHANGED
|
@@ -36,10 +36,12 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
|
|
|
36
36
|
\`spex lint\` must report 0 errors; coverage warnings are your adoption TODO (files no node claims yet).
|
|
37
37
|
|
|
38
38
|
Look these up on demand — the formats an agent authors, and the settings it configures:
|
|
39
|
-
spex guide spec
|
|
40
|
-
spex guide yatsu
|
|
41
|
-
spex guide config
|
|
42
|
-
|
|
39
|
+
spex guide spec the spec.md format (frontmatter + body + the rules lint enforces)
|
|
40
|
+
spex guide yatsu the yatsu.md format (scenario schema + how loss is measured and filed)
|
|
41
|
+
spex guide config the spexcode.json / spexcode.local.json settings (launchers, dashboard icon, lint
|
|
42
|
+
budgets, layout) — every field, and which of the two files it belongs in
|
|
43
|
+
spex guide footprint the share-axis model — what SpexCode plants in a repo, the render policy
|
|
44
|
+
(committed | ignored | hidden), and every migration recipe`
|
|
43
45
|
|
|
44
46
|
const SPEC = `spex guide spec — the spec.md file format
|
|
45
47
|
|
|
@@ -191,12 +193,12 @@ PORTABILITY, and picking the right one is the whole discipline:
|
|
|
191
193
|
identity, lint budgets, launcher NAMES. "Git is the database": tracked so the
|
|
192
194
|
team shares ONE configuration.
|
|
193
195
|
spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
|
|
194
|
-
paths,
|
|
195
|
-
targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
|
|
196
|
+
paths, a personal "render": "hidden" vote. Layered OVER spexcode.json (see MERGE
|
|
197
|
+
below); a targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
|
|
196
198
|
|
|
197
199
|
Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
|
|
198
200
|
icon, a lint budget, a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
|
|
199
|
-
PATH of a launcher wrapper, a TLS cert path,
|
|
201
|
+
PATH of a launcher wrapper, a TLS cert path, a personal render:hidden are machine facts → gitignored spexcode.local.json.
|
|
200
202
|
Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
|
|
201
203
|
\`spex new\` or the dashboard without an explicit launcher choice.
|
|
202
204
|
|
|
@@ -292,6 +294,13 @@ the guard (the flag is the proof of intent). Reads point anywhere.
|
|
|
292
294
|
issues.enabled the issues-workflow on/off switch (default ON). OFF silences the post-merge nudge and
|
|
293
295
|
hides the dashboard view; the CLI toggle is \`spex issues on|off\`.
|
|
294
296
|
|
|
297
|
+
── FORGE (spexcode.json — which forge this repo's remote is; a project fact, so committed) ──
|
|
298
|
+
forge.host explicit forge host id ('github' | 'gitlab' | …) overriding the automatic derivation.
|
|
299
|
+
Normally OMIT it: spec-forge resolves the host from the origin remote's hostname —
|
|
300
|
+
github.com → github, a gitlab/self-hosted remote → gitlab — and only an ambiguous
|
|
301
|
+
self-hosted domain the heuristic misreads needs the override. A resolved host with no
|
|
302
|
+
registered driver degrades to an EMPTY forge slice (local issues still work, no error).
|
|
303
|
+
|
|
295
304
|
── LINT (spexcode.json — a top-level "lint" key; budgets are portable, so committed only) ──
|
|
296
305
|
lint.governedRoots dirs whose source files must each be governed by a spec (coverage).
|
|
297
306
|
'.' = the whole project (only git-TRACKED files). Default
|
|
@@ -314,37 +323,81 @@ Example — govern your own source dir and loosen the altitude budget:
|
|
|
314
323
|
preset the SELECTED init preset — which cumulative .config tier \`spex init\` seeds (default
|
|
315
324
|
'default'; seed-time only, read by init.ts).
|
|
316
325
|
harnesses which harness targets \`spex materialize\` delivers into — native ids ("claude"|"codex") or a
|
|
317
|
-
{ "plugin": "<folder>" } bundle. Default (omitted): all native harnesses.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
326
|
+
{ "plugin": "<folder>" } bundle. Default (omitted): all native harnesses. PERSISTENT and
|
|
327
|
+
self-healing: an edit moves the hook gate's key, so the next harness event re-renders under
|
|
328
|
+
the new set (a deselected harness's artifacts are pruned) — no manual materialize needed.
|
|
329
|
+
render the footprint vote: where the machine-independent RENDERS (CLAUDE.md/AGENTS.md contract
|
|
330
|
+
blocks, .claude/.codex skills + agents) sit relative to the shared repo. One of
|
|
331
|
+
"committed" | "ignored" (default) | "hidden". "committed" is a PROJECT fact → spexcode.json;
|
|
332
|
+
"hidden" is a HOST/person fact → spexcode.local.json. The spec DATA (.spec, spexcode.json)
|
|
333
|
+
has NO knob — always tracked. Full model + migration recipes: \`spex guide footprint\`.
|
|
334
|
+
private RETIRED (old private-overlay toggle) — read as "render": "hidden" with a loud migration
|
|
335
|
+
notice; its data-untrack semantics are gone. See \`spex guide footprint\` MIGRATIONS.`
|
|
336
|
+
|
|
337
|
+
const FOOTPRINT = `spex guide footprint — the share-axis model (what SpexCode plants in a repo, and who sees it)
|
|
338
|
+
|
|
339
|
+
SpexCode claims software engineering's HEAD (the recording of intent) and TAIL (the storage of
|
|
340
|
+
measurement) and leaves the MIDDLE — construction — to the harness/agent/test framework; freshness
|
|
341
|
+
stitches the two ends into a closed loop. The footprint follows: the head+tail (.spec, spexcode.json,
|
|
342
|
+
readings) is the ASSET and lives in git like source; everything else is derived wiring or a machine fact.
|
|
343
|
+
ONE axis — "who sees this artifact in the shared repo?" — is answered per KIND, never per usage mode.
|
|
344
|
+
|
|
345
|
+
── THE FOUR KINDS (three are fixed; one votes) ──
|
|
346
|
+
spec data .spec/ (incl .config/) + spexcode.json — ALWAYS tracked. Git is the database; there is
|
|
347
|
+
deliberately NO way to say "untrack the spec" in this schema.
|
|
348
|
+
machine facts spexcode.local.json, the hook shims (.claude/settings.json, .codex/hooks.json), plugin
|
|
349
|
+
bundles — NEVER tracked; always in the ignore rules, whatever those rules' home.
|
|
350
|
+
renders the CLAUDE.md/AGENTS.md contract blocks + rendered skills/agents — machine-independent,
|
|
351
|
+
derived from the spec tree. THE one voted class: the \`render\` field (see below).
|
|
352
|
+
run residue .worktrees/, the global store (~/.spexcode), .git/spexcode blobs — never tracked;
|
|
353
|
+
out-of-tree, or ignore-ruled where in-tree.
|
|
354
|
+
|
|
355
|
+
── THE VOTE: render = "committed" | "ignored" | "hidden" ──
|
|
356
|
+
committed renders are ordinary committed files; their entries LEAVE the ignore block (machine facts
|
|
357
|
+
stay ignored). The contract reaches teammates/CI who never installed SpexCode — the harness
|
|
358
|
+
discovers the committed file natively. Pick for a fully-adopted team. → spexcode.json.
|
|
359
|
+
ignored (default) renders are generated + gitignored via the managed block in the TRACKED
|
|
360
|
+
.gitignore — the team sees the RULE, never the products. NOTE: a contract file the host
|
|
361
|
+
ALREADY TRACKS cannot be ignored — it stays visibly dirty, your prompt to pick committed
|
|
362
|
+
or hidden.
|
|
363
|
+
hidden zero repo footprint: the same managed block lives in the per-clone .git/info/exclude, and a
|
|
364
|
+
HOST-TRACKED CLAUDE.md/AGENTS.md is covered by a per-clone git clean/smudge content filter —
|
|
365
|
+
the repo keeps the pristine host prose, your working tree carries prose + contract block,
|
|
366
|
+
and git status stays clean. Pick to dogfood on a repo you don't own. → spexcode.local.json.
|
|
367
|
+
The ignore rules are themselves a render artifact, so their HOME follows the same vote — one axis, no
|
|
368
|
+
second mechanism. TRACK ≠ PUSH: the vote never touches remotes; where commits GO is branch/remote policy.
|
|
369
|
+
Vote at adoption in one step: \`spex init --render <word>\` (committed/ignored → spexcode.json, hidden →
|
|
370
|
+
spexcode.local.json). Until an explicit vote is set, init/materialize print a one-time decision hint
|
|
371
|
+
whenever a host-TRACKED contract file carries the block — the "mystery M" explained at first sight.
|
|
372
|
+
|
|
373
|
+
── GUARANTEES (the forgetting law) ──
|
|
374
|
+
materialize(P₂) ∘ materialize(P₁) = materialize(P₂): every render first ERASES all landing points by
|
|
375
|
+
SpexCode's own identity stamps, then re-asserts the current policy — so switching render values (or
|
|
376
|
+
harness sets) in ANY order, any number of times, converges; running one policy twice changes nothing.
|
|
377
|
+
\`spex uninstall\` is the empty policy plus the global store: a total backout that never touches your
|
|
378
|
+
.spec/.config or prose. Fresh clones and session worktrees are self-sufficient: data by checkout, renders
|
|
379
|
+
by re-render (init/materialize/the hook gate), the machine snapshot (spexcode.local.json) by copy.
|
|
380
|
+
|
|
381
|
+
── THE CONTENT FILTER (hidden + a host-tracked contract file) ──
|
|
382
|
+
Per-clone only — git config filter.spexcode.* + .git/info/attributes + a shim under .git/spexcode/ — and
|
|
383
|
+
planted ONLY when the contract file is actually tracked. clean strips the sentinel block (history never
|
|
384
|
+
sees it); smudge re-injects it on checkout. A missing shim degrades to identity (never a git fatal). Your
|
|
385
|
+
own edits to the file's prose still show as real modifications; only the block is invisible to git.
|
|
386
|
+
|
|
387
|
+
── MIGRATIONS ──
|
|
388
|
+
private: true (retired) → replace with { "render": "hidden" } in spexcode.local.json. materialize
|
|
389
|
+
reads the old flag as hidden and prints this recipe until you migrate.
|
|
390
|
+
legacy untracked spec the retired mode also untracked .spec + spexcode.json. Track them once:
|
|
391
|
+
(untrack-private) git add .spec spexcode.json # then commit on your branch
|
|
392
|
+
WARNING: tracking is not retroactive secrecy — history already pushed
|
|
393
|
+
elsewhere cannot be recalled.
|
|
394
|
+
hidden → committed/ignored just edit \`render\` — the hook gate re-renders on the very next harness
|
|
395
|
+
event (the gate key covers spexcode.json/.local), or run \`spex materialize\`
|
|
396
|
+
to apply immediately; the erase phase unplants the filter/exclude block in
|
|
397
|
+
the same pass. Narrowing \`harnesses\` self-heals the same way.
|
|
398
|
+
back out entirely \`spex uninstall\` (add --hooks to also remove the spexcode git hooks).`
|
|
399
|
+
|
|
400
|
+
const TOPICS: Record<string, string> = { spec: SPEC, yatsu: YATSU, config: CONFIG, footprint: FOOTPRINT }
|
|
348
401
|
|
|
349
402
|
// every guide page ends by naming the OTHER help layer, so a reader never dead-ends here: guide is
|
|
350
403
|
// the skill layer (workflows · formats · settings); command usage lives in help.ts's two layers.
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
|
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
|
|
10
10
|
import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
|
|
11
|
+
import { git } from './git.js'
|
|
11
12
|
|
|
12
13
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
13
14
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -770,9 +771,14 @@ export function removeManagedBlock(file: string, comment: readonly [string, stri
|
|
|
770
771
|
// remove ONLY our block plus the blank lines writeManagedBlock inserted around it; do NOT normalize the
|
|
771
772
|
// user's OWN whitespace elsewhere — this must leave every other byte intact so it is a faithful INVERSE of
|
|
772
773
|
// writeManagedBlock's append. A global `\n{3,}→\n\n` collapse used to sit here and mutated pre-existing
|
|
773
|
-
// blank-line runs in the user's file, which broke the
|
|
774
|
-
//
|
|
775
|
-
|
|
774
|
+
// blank-line runs in the user's file, which broke the policy round-trip ([[render-policy]]): a mode flip
|
|
775
|
+
// and back left a spurious one-line diff on a .gitignore that had internal blank lines. The leading-newline
|
|
776
|
+
// strip is GUARDED the same way: it exists only for a block sitting at the TOP of the file (whose '\n'
|
|
777
|
+
// replacement would otherwise become a leading blank) — a host file that BEGINS with its own blank lines
|
|
778
|
+
// keeps them ([[content-filter]]'s invariant, same bug class as the shim's old unconditional strip).
|
|
779
|
+
const atTop = (re.exec(cur)?.index ?? -1) === 0
|
|
780
|
+
const replaced = cur.replace(re, '\n')
|
|
781
|
+
const out = atTop ? replaced.replace(/^\n+/, '') : replaced
|
|
776
782
|
if (deleteIfEmpty && !out.trim()) { rmSync(file, { force: true }); return }
|
|
777
783
|
writeFileSync(file, out)
|
|
778
784
|
}
|
|
@@ -874,6 +880,11 @@ function removeCodexTrust(proj: string): void {
|
|
|
874
880
|
writeFileSync(file, cleaned ? `${cleaned}\n` : '')
|
|
875
881
|
}
|
|
876
882
|
|
|
883
|
+
// is this file git-tracked in proj? (guards cleanHarness's deleteIfEmpty; env-stripped git, never throws)
|
|
884
|
+
function isTrackedFile(proj: string, f: string): boolean {
|
|
885
|
+
try { git(['-C', proj, 'ls-files', '--error-unmatch', f]); return true } catch { return false }
|
|
886
|
+
}
|
|
887
|
+
|
|
877
888
|
// @@@ cleanHarness - the shared clean: the inverse of materialize's per-harness write, expressed PURELY
|
|
878
889
|
// through the adapter's own path methods so it can never drift from what write put there. Each step is
|
|
879
890
|
// surgical, gated on a SpexCode identity stamp: the contract files carry the managed-block sentinels; the shim
|
|
@@ -882,7 +893,10 @@ function removeCodexTrust(proj: string): void {
|
|
|
882
893
|
// blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
|
|
883
894
|
// a sibling skill/agent the user added, and NEVER any .spec data.
|
|
884
895
|
function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
|
|
885
|
-
for
|
|
896
|
+
// deleteIfEmpty ONLY for an UNTRACKED contract file: a wholly-ours generated file goes; a HOST-TRACKED file
|
|
897
|
+
// that carried nothing but our block (an empty committed CLAUDE.md we folded into) is stripped back to its
|
|
898
|
+
// pristine emptiness but never deleted — deleting a tracked file would surface as a `D` in the host's status.
|
|
899
|
+
for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f))
|
|
886
900
|
const shim = h.shimFile(proj)
|
|
887
901
|
if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
|
|
888
902
|
const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
|
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 share-axis model: the render vote (committed|ignored|hidden) + migrations
|
|
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]',
|
|
@@ -307,12 +313,15 @@ ${ROUTING_NOTE}`,
|
|
|
307
313
|
|
|
308
314
|
// ── install & serve (operator) ────────────────────────────────────────────
|
|
309
315
|
init: {
|
|
310
|
-
line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name]',
|
|
311
|
-
body: `Usage: spex init [dir=cwd] [--preset default|careful]
|
|
316
|
+
line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name] [--render word]',
|
|
317
|
+
body: `Usage: spex init [dir=cwd] [--preset default|careful] [--render committed|ignored|hidden]
|
|
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
|
+
--render casts the one-time render-policy vote at adoption (committed → spexcode.json, hidden →
|
|
323
|
+
spexcode.local.json; an unknown word fails loud) — without it, a host-tracked CLAUDE.md/AGENTS.md
|
|
324
|
+
prints a one-time decision hint instead (see spex guide footprint).`,
|
|
316
325
|
see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex lint (adoption TODO)',
|
|
317
326
|
},
|
|
318
327
|
uninstall: {
|
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
|
|
@@ -53,9 +53,27 @@ function resolveHooksDir(dir: string): string | null {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export async function specInit(targetArg: string | undefined, presetArg?: string): Promise<void> {
|
|
56
|
+
export async function specInit(targetArg: string | undefined, presetArg?: string, renderArg?: string): Promise<void> {
|
|
57
57
|
const targetDir = resolve(targetArg ?? process.cwd())
|
|
58
58
|
|
|
59
|
+
// the one-step render vote (`--render <committed|ignored|hidden>`, see [[render-policy]]): validated up
|
|
60
|
+
// front against the same vocabulary materialize enforces (resolveRenderPolicy — an unknown word fails loud
|
|
61
|
+
// BEFORE anything is written), applied to the config files below, and honored by this run's materialize.
|
|
62
|
+
const render = renderArg?.trim()
|
|
63
|
+
if (renderArg !== undefined) {
|
|
64
|
+
if (!render) {
|
|
65
|
+
console.error('spex init: --render needs a value — the render axis has three words: committed | ignored | hidden (see `spex guide footprint`)')
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const { resolveRenderPolicy } = await import('./materialize.js')
|
|
70
|
+
resolveRenderPolicy({ render })
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error(`spex init: ${(e as Error).message}`)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
// the preset the NEW adopter gets — `--preset <name>` wins, else an existing target spexcode.json's
|
|
60
78
|
// `preset` field, else the lean `default`. Validated loudly against the chain (an unknown name would
|
|
61
79
|
// otherwise seed silently). A non-default tier stacks its template package on top of the default set below.
|
|
@@ -99,13 +117,29 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
99
117
|
}
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
// 1b. plant a starter spexcode.json (the lint/layout knob)
|
|
120
|
+
// 1b. plant a starter spexcode.json (the lint/layout knob). The success message reports the value the
|
|
121
|
+
// template ACTUALLY ships (read from the planted file, never restated as a string literal here — the two
|
|
122
|
+
// once drifted: the message claimed ["src"] while the template seeded ["."]).
|
|
103
123
|
const cfgDest = join(targetDir, 'spexcode.json')
|
|
104
124
|
if (existsSync(cfgDest)) {
|
|
105
125
|
console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched.`)
|
|
106
126
|
} else {
|
|
107
127
|
copyFileSync(join(TEMPLATES, 'spexcode.json'), cfgDest)
|
|
108
|
-
|
|
128
|
+
const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
|
|
129
|
+
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`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 1c. apply the --render vote where the axis says each word lives ([[render-policy]]): committed/ignored
|
|
133
|
+
// are project facts → the committed spexcode.json; hidden is a host/person fact → the gitignored
|
|
134
|
+
// spexcode.local.json. An explicit flag is an explicit instruction, so it sets the field even in a
|
|
135
|
+
// pre-existing config file (the ONE deliberate exception to "existing files are left untouched").
|
|
136
|
+
if (render) {
|
|
137
|
+
const home = render === 'hidden' ? 'spexcode.local.json' : 'spexcode.json'
|
|
138
|
+
const p = join(targetDir, home)
|
|
139
|
+
const cur = readJsonConfig(p) // fails loud on a malformed existing file, {} when absent
|
|
140
|
+
cur.render = render
|
|
141
|
+
writeFileSync(p, JSON.stringify(cur, null, 2) + '\n')
|
|
142
|
+
console.log(`✓ render policy voted: "${render}" → ${home}`)
|
|
109
143
|
}
|
|
110
144
|
|
|
111
145
|
// validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
|
|
@@ -149,21 +183,29 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
149
183
|
const prevCwd = process.cwd()
|
|
150
184
|
try {
|
|
151
185
|
process.chdir(targetDir)
|
|
152
|
-
const { materialize } = await import('./materialize.js')
|
|
186
|
+
const { materialize, renderVoteHint } = await import('./materialize.js')
|
|
153
187
|
materialize(targetDir)
|
|
154
|
-
console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block,
|
|
188
|
+
console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block, harness shims, Codex trust)')
|
|
189
|
+
// the adoption vote hint ([[render-policy]]): a host-TRACKED contract file now carries the generated
|
|
190
|
+
// block and shows honestly dirty under the default policy — print the one-time decision guidance while
|
|
191
|
+
// the vote is open (an explicit `render`, including --render on this very run, retires it).
|
|
192
|
+
const hint = renderVoteHint(targetDir)
|
|
193
|
+
if (hint) console.log(`\n${hint}`)
|
|
155
194
|
} catch (e) {
|
|
156
195
|
console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
|
|
157
196
|
} finally {
|
|
158
197
|
process.chdir(prevCwd)
|
|
159
198
|
}
|
|
160
199
|
|
|
161
|
-
// 3. next steps — what the human must do to bring the instance to life.
|
|
200
|
+
// 3. next steps — what the human must do to bring the instance to life. The governedRoots line reads the
|
|
201
|
+
// LIVE value (the planted starter's, or a pre-existing config's) so it can never drift from what's on disk.
|
|
202
|
+
const rootsNow = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
|
|
162
203
|
console.log(`
|
|
163
204
|
Next steps:
|
|
164
205
|
1. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
|
|
165
|
-
2.
|
|
166
|
-
|
|
206
|
+
2. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex lint\` governs —
|
|
207
|
+
["."] governs the whole git-tracked tree (tests excluded); narrow it to explicit source roots
|
|
208
|
+
when you want a curated graph.
|
|
167
209
|
3. Start the backend and open the board:
|
|
168
210
|
spex serve # http://localhost:8787
|
|
169
211
|
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,21 @@ 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
|
-
// shared
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
13
|
+
// the RENDER POLICY ([[render-policy]]) — the ONE voted footprint knob: where the machine-independent
|
|
14
|
+
// RENDERS (the CLAUDE.md/AGENTS.md contract blocks, the .claude/.codex skills + agents) sit relative to the
|
|
15
|
+
// shared repo. Three words, validated fail-loud by materialize's resolveRenderPolicy:
|
|
16
|
+
// 'committed' the renders are ordinary committed files (their entries leave the ignore block) — the
|
|
17
|
+
// contract reaches un-adopted teammates/CI through the harness's native discovery, for free;
|
|
18
|
+
// 'ignored' (default) the renders are generated + gitignored via the managed block in the TRACKED
|
|
19
|
+
// .gitignore — the team sees the rule, never the products;
|
|
20
|
+
// 'hidden' zero repo footprint — the ignore rules live in the per-clone .git/info/exclude, and a
|
|
21
|
+
// HOST-TRACKED contract file is handled by the clean/smudge content filter ([[content-filter]]).
|
|
22
|
+
// `committed` is a project fact → spexcode.json; `hidden` is a host/person fact → spexcode.local.json.
|
|
23
|
+
// The schema deliberately has NO knob for the spec DATA: `.spec` + spexcode.json are ALWAYS tracked
|
|
24
|
+
// ("git is the database") — the vocabulary itself makes "untrack the spec" unsayable.
|
|
25
|
+
render?: 'committed' | 'ignored' | 'hidden'
|
|
26
|
+
// DEPRECATED ([[render-policy]] compat): the retired private-overlay toggle. Read as render:'hidden' (with a
|
|
27
|
+
// loud, non-fatal migration notice); its old data-untrack semantics are gone — see `spex guide footprint`.
|
|
19
28
|
private?: boolean
|
|
20
29
|
// which harness targets `spex materialize` delivers into — native ids ('claude'|'codex') or a {plugin:"<folder>"}
|
|
21
30
|
// bundle; resolved + validated by [[harness-select]] (harness-select.ts). Default (omitted): all native harnesses.
|
|
@@ -46,12 +55,15 @@ type Config = {
|
|
|
46
55
|
issues?: {
|
|
47
56
|
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
57
|
}
|
|
58
|
+
forge?: {
|
|
59
|
+
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.
|
|
60
|
+
}
|
|
49
61
|
}
|
|
50
62
|
// 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 —
|
|
63
|
+
// `serve`, `harnesses`, `render`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
|
|
52
64
|
// preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
|
|
53
65
|
// 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'>>
|
|
66
|
+
type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
|
|
55
67
|
|
|
56
68
|
export type Worktree = {
|
|
57
69
|
path: string; branch: string | null; node: string | null
|
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,
|