spexcode 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +110 -18
  7. package/spec-cli/src/claude-headless.ts +271 -0
  8. package/spec-cli/src/cli.ts +112 -38
  9. package/spec-cli/src/client.ts +42 -11
  10. package/spec-cli/src/doctor.ts +174 -36
  11. package/spec-cli/src/gateway-auth.ts +194 -0
  12. package/spec-cli/src/gateway-hub.ts +360 -0
  13. package/spec-cli/src/gateway.ts +27 -19
  14. package/spec-cli/src/git.ts +12 -5
  15. package/spec-cli/src/graph.ts +54 -43
  16. package/spec-cli/src/graphStream.ts +265 -34
  17. package/spec-cli/src/guide.ts +115 -56
  18. package/spec-cli/src/harness-select.ts +20 -6
  19. package/spec-cli/src/harness.ts +295 -39
  20. package/spec-cli/src/help.ts +68 -28
  21. package/spec-cli/src/host.ts +610 -0
  22. package/spec-cli/src/identity-presets.d.ts +13 -0
  23. package/spec-cli/src/identity-presets.js +128 -0
  24. package/spec-cli/src/index.ts +158 -87
  25. package/spec-cli/src/init.ts +53 -25
  26. package/spec-cli/src/layout.ts +6 -5
  27. package/spec-cli/src/lint.ts +25 -121
  28. package/spec-cli/src/login-page.ts +12 -5
  29. package/spec-cli/src/materialize.ts +39 -13
  30. package/spec-cli/src/mentions.ts +16 -11
  31. package/spec-cli/src/message-stream.ts +147 -0
  32. package/spec-cli/src/opencode-headless.ts +95 -0
  33. package/spec-cli/src/opencode.ts +145 -0
  34. package/spec-cli/src/pi-harness.ts +124 -0
  35. package/spec-cli/src/pi-headless.ts +195 -0
  36. package/spec-cli/src/project-identity.ts +73 -0
  37. package/spec-cli/src/pty-bridge.ts +321 -521
  38. package/spec-cli/src/pty-helper.mjs +86 -0
  39. package/spec-cli/src/reaper.ts +16 -8
  40. package/spec-cli/src/reviewSnapshot.ts +23 -0
  41. package/spec-cli/src/reviews.ts +347 -0
  42. package/spec-cli/src/session-timeline.ts +14 -0
  43. package/spec-cli/src/sessions.ts +211 -75
  44. package/spec-cli/src/shim-runtime.ts +169 -0
  45. package/spec-cli/src/slash-commands.ts +76 -0
  46. package/spec-cli/src/source-files.ts +79 -0
  47. package/spec-cli/src/specs.ts +12 -7
  48. package/spec-cli/src/supervise.ts +37 -15
  49. package/spec-cli/src/tree.ts +8 -8
  50. package/spec-cli/src/uninstall.ts +56 -23
  51. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  52. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  53. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  54. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  55. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  56. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  58. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  59. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  60. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  62. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  63. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  64. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  65. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  66. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  67. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  68. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  69. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  70. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  71. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  72. package/spec-cli/templates/spec/project/spec.md +6 -5
  73. package/spec-cli/templates/spexcode.json +10 -2
  74. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  75. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
  76. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  77. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  78. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  79. package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
  80. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  81. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  82. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  83. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
  84. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
  85. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  86. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  87. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  88. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  89. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  90. package/spec-dashboard/dist/index.html +10 -10
  91. package/spec-dashboard/src/reviewFilters.js +279 -0
  92. package/spec-dashboard/src/reviewQuery.js +174 -0
  93. package/spec-dashboard/src/session.js +167 -0
  94. package/spec-eval/src/cli.ts +49 -28
  95. package/spec-eval/src/humanok.ts +1 -1
  96. package/spec-eval/src/matrix.ts +693 -0
  97. package/spec-eval/src/sessioneval.ts +726 -66
  98. package/spec-cli/src/migrate-table.ts +0 -408
  99. package/spec-cli/src/migrate.ts +0 -386
  100. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  108. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  109. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  110. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  111. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -3,7 +3,7 @@ import { join, resolve, relative } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { execFileSync } from 'node:child_process'
5
5
  import { readConfig, readJsonConfig } from './layout.js'
6
- import { resolveHarnessTargets } from './harness-select.js'
6
+ import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } 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
9
9
  // launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
@@ -54,7 +54,7 @@ function resolveHooksDir(dir: string): string | null {
54
54
  }
55
55
  }
56
56
 
57
- export async function specInit(targetArg: string | undefined, presetArg?: string): Promise<void> {
57
+ export async function specInit(targetArg: string | undefined, presetArg?: string, harnessArg?: string): Promise<void> {
58
58
  const targetDir = resolve(targetArg ?? process.cwd())
59
59
 
60
60
  // the preset the NEW adopter gets — `--preset <name>` wins, else an existing target spexcode.json's
@@ -78,6 +78,24 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
78
78
  process.exit(1)
79
79
  }
80
80
 
81
+ // the harness DELIVERY TARGET set ([[harness-select]]) is a REQUIRED, explicit choice — `--harness <ids>`
82
+ // stamps it into spexcode.json; absent the flag, a pre-existing explicit `harnesses` field IS the choice.
83
+ // Neither → abort BEFORE writing anything: with many harnesses, a silent "deliver to all" would litter the
84
+ // adopter's tree (and global tool configs) with artifacts for harnesses they never installed. Legality
85
+ // (unknown ids, plugin exclusivity, empty set) fails loud here too, not as a soft materialize warning.
86
+ const flagRaw = (harnessArg ?? '').trim() ? parseHarnessFlag(harnessArg!.trim()) : null
87
+ const chosenHarnesses = flagRaw ?? readConfig(targetDir).harnesses ?? null
88
+ if (chosenHarnesses === null) {
89
+ console.error(`spex init: --harness is required — name the harness(es) this repo delivers into, e.g. \`spex init --harness claude\`. Known native ids: ${NATIVE_HARNESS_IDS.join(', ')} (comma-separate several; a plugin bundle: --harness plugin:<folder>). A pre-existing spexcode.json "harnesses" field also satisfies this.`)
90
+ process.exit(1)
91
+ }
92
+ try {
93
+ resolveHarnessTargets(chosenHarnesses)
94
+ } catch (e) {
95
+ console.error(`spex init: ${(e as Error).message}`)
96
+ process.exit(1)
97
+ }
98
+
81
99
  if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true })
82
100
  console.log(`spex init → ${targetDir}`)
83
101
 
@@ -100,27 +118,35 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
100
118
  }
101
119
  }
102
120
 
103
- // 1b. plant a starter spexcode.json (the lint/layout knob). The success message reports the value the
104
- // template ACTUALLY ships (read from the planted file, never restated as a string literal here the two
105
- // once drifted: the message claimed ["src"] while the template seeded ["."]).
121
+ // 1b. plant a starter spexcode.json (the lint/layout knob) carrying the CHOSEN `harnesses` set. The
122
+ // template ships launchers for every native harness; seeding keeps only the SELECTED ones (a launcher for
123
+ // a tool the adopter didn't pick is exactly the litter --harness exists to prevent) — a plugin-only
124
+ // selection keeps them all, since the bundle serves the HOST agent while dispatched sessions still need a
125
+ // launcher. The success message reports values read back from the planted file, never restated literals.
106
126
  const cfgDest = join(targetDir, 'spexcode.json')
127
+ const nativeChosen = (chosenHarnesses as unknown[]).filter((m): m is string => typeof m === 'string')
107
128
  if (existsSync(cfgDest)) {
108
- console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched.`)
129
+ if (flagRaw) {
130
+ // an explicit --harness on a re-init is a deliberate command: restamp THAT field, touch nothing else.
131
+ const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
132
+ cfg.harnesses = flagRaw
133
+ writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
134
+ console.log(`✓ stamped "harnesses": ${JSON.stringify(flagRaw)} into the existing spexcode.json (other fields untouched)`)
135
+ } else {
136
+ console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched (harnesses: ${JSON.stringify(chosenHarnesses)}).`)
137
+ }
109
138
  } else {
110
- copyFileSync(join(TEMPLATES, 'spexcode.json'), cfgDest)
139
+ const cfg = (readJsonConfig(join(TEMPLATES, 'spexcode.json')) ?? {}) as Record<string, any>
140
+ cfg.harnesses = chosenHarnesses
141
+ if (nativeChosen.length && cfg.sessions?.launchers) {
142
+ cfg.sessions.launchers = Object.fromEntries(
143
+ Object.entries(cfg.sessions.launchers as Record<string, { harness?: string }>).filter(([, l]) => nativeChosen.includes(l.harness ?? 'claude')))
144
+ const names = Object.keys(cfg.sessions.launchers)
145
+ if (names.length) cfg.sessions.defaultLauncher = names[0]
146
+ }
147
+ writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
111
148
  const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
112
- 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`)
113
- }
114
-
115
- // validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
116
- // native, or a plugin with no folder) must fail LOUD here, not be silently swallowed by the materialize
117
- // try/catch below. A fresh starter spexcode.json omits the field (defaults to all natives), so this only
118
- // bites a hand-edited or re-init'd config — exactly where a clear error belongs.
119
- try {
120
- resolveHarnessTargets(readConfig(targetDir).harnesses)
121
- } catch (e) {
122
- console.error(`spex init: ${(e as Error).message}`)
123
- process.exit(1)
149
+ console.log(`✓ planted spexcode.json — harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
124
150
  }
125
151
 
126
152
  // 2. install the git hooks: templates/hooks/* -> <repo>/<common-git-dir>/hooks/* (skip any that exist).
@@ -145,18 +171,20 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
145
171
  if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
146
172
  }
147
173
 
148
- // 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched claude/codex works with zero further
149
- // steps: the hook manifest (in the GLOBAL per-project store, not the worktree), the AGENTS.md/CLAUDE.md
150
- // <spexcode> contract block (user content preserved), the .claude/.codex shims, and the Codex trust (global,
151
- // scoped) so codex self-launch is prompt-free. Runs with cwd = the target so the loaders read the just-seeded
174
+ // 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched harness works with zero further
175
+ // steps. Runs with cwd = the target so the loaders read the just-seeded
152
176
  // .plugins. Idempotent — the planted git hooks (pre-commit/post-checkout/post-merge) keep it fresh
153
177
  // thereafter on the git-native anchors ([[commit-surgery]]); no harness event ever triggers a materialize.
154
178
  const prevCwd = process.cwd()
155
179
  try {
156
180
  process.chdir(targetDir)
157
181
  const { materialize } = await import('./materialize.js')
158
- materialize(targetDir)
159
- console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block, harness shims, Codex trust)')
182
+ const result = materialize(targetDir)
183
+ const display = (path: string) => {
184
+ const local = relative(targetDir, path)
185
+ return local && local !== '..' && !local.startsWith('../') && !local.startsWith('..\\') ? local : path
186
+ }
187
+ console.log(`✓ materialized harness artifacts (${result.planted.map((a) => `${a.kind}: ${display(a.path)}`).join(', ')})`)
160
188
  } catch (e) {
161
189
  console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
162
190
  } finally {
@@ -3,7 +3,7 @@ import { join, dirname } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
5
5
  import { guardWorktree } from './resilience.js'
6
- import { HARNESSES } from './harness.js'
6
+ import { HARNESSES, type HarnessId } from './harness.js'
7
7
 
8
8
  type Config = {
9
9
  main?: string // path to the source-of-truth checkout (default: the `main` worktree)
@@ -20,13 +20,14 @@ type Config = {
20
20
  // RETIRED (residence compat): the old private-overlay toggle — ignored with the same loud notice;
21
21
  // its data-untrack semantics are long gone. See `spex guide footprint` MIGRATIONS.
22
22
  private?: boolean
23
- // which harness targets `spex materialize` delivers into — native ids ('claude'|'codex') or a {plugin:"<folder>"}
24
- // bundle; resolved + validated by [[harness-select]] (harness-select.ts). Default (omitted): all native harnesses.
23
+ // which harness targets `spex materialize` delivers into — a native HarnessId or a {plugin:"<folder>"}
24
+ // bundle; resolved + validated by [[harness-select]] (harness-select.ts). REQUIRED no default set; `spex init --harness` stamps it.
25
25
  harnesses?: (string | { plugin?: string })[]
26
26
  dashboard?: {
27
27
  apiUrl?: string // the per-project backend the board proxies to (read frontend-side; see api-endpoint)
28
28
  title?: string // override for the browser-tab name (default: the repo-root basename; see tab-title)
29
- icon?: string // the browser-tab favicon: an emoji ("🔭") or an Iconify name ("mdi:rocket-launch"); see tab-icon
29
+ icon?: string // project identity icon: a picker preset id; existing emoji/Iconify/URL values remain supported ([[identity-config]])
30
+ showHeadlessLaunchers?: boolean // include headless harness profiles in the dashboard New Session picker (default false; [[launcher-visibility]])
30
31
  }
31
32
  sessions?: {
32
33
  maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
@@ -34,7 +35,7 @@ type Config = {
34
35
  // its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
35
36
  // same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
36
37
  // gitignored spexcode.local.json — the name is portable, the cmd is a machine fact.
37
- launchers?: { [name: string]: { harness?: 'claude' | 'codex'; cmd: string } }
38
+ launchers?: { [name: string]: { harness?: HarnessId; cmd: string } }
38
39
  defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
39
40
  }
40
41
  serve?: {
@@ -1,19 +1,19 @@
1
- import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'
1
+ import { readFileSync, existsSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { repoRoot, git, driftIndex, historyIndex, rowsFor } from './git.js'
4
4
  import { loadSpecs } from './specs.js'
5
5
  import { readJsonConfig } from './layout.js'
6
6
  import { extractors, extractorFor, extOf, resolveAnchor, windowCommits, anchorHitCommits } from './anchors.js'
7
+ import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
7
8
 
8
9
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
9
10
 
10
11
  export type LintConfig = {
11
- governedRoots: string[] // dirs whose source files must each be governed by a spec (coverage). '.' = whole project (safe: only git-TRACKED files, so node_modules/build/nested worktrees never count).
12
- sourceExtensions: string[] // extensions coverage treats as source files
13
- testGlobs: string[] // globs EXCLUDED from coverage — tests aren't governed product (default ['**/*.test.*']; set [] to govern tests too)
14
- identifierExtensions: string[]// extensions the altitude bare-filename signal recognises (see IDENT below)
15
- altitude: { lineBudget: number; charBudget: number; sizeable: number; dense: number; steps: number }
16
- maxChildren: number // breadth budget: warn at >= this many direct children
12
+ governedRoots: string[] // dirs whose tracked source files must each be governed by a spec. '.' = whole project.
13
+ sourceIncludeGlobs: string[] | null // null includes every tracked regular text file; [] intentionally includes none
14
+ sourceExcludeGlobs: string[] // explicit source-policy subtraction
15
+ sourceExtensions: string[] | null // compatibility shorthand compiled into sourceIncludeGlobs
16
+ testGlobs: string[] // globs EXCLUDED from coverage; set [] to govern tests too
17
17
  maxOwners: number // warn when a file is governed (code:) by > this many nodes
18
18
  scenarioTags: string[] // the closed vocabulary an eval scenario's `tags:` must draw from; extend it to mint a new tag
19
19
  scopedCodeMiss: 'warn' | 'ignore' // the file-level drift ADVISORY on a selector-scoped code: file whose window has no
@@ -23,11 +23,10 @@ export type LintConfig = {
23
23
  }
24
24
  const DEFAULT_CONFIG: LintConfig = {
25
25
  governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
26
- sourceExtensions: ['ts', 'tsx', 'js', 'jsx'],
27
- testGlobs: ['**/*.test.*'],
28
- identifierExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'],
29
- altitude: { lineBudget: 50, charBudget: 4200, sizeable: 35, dense: 1.3, steps: 3 },
30
- maxChildren: 8,
26
+ sourceIncludeGlobs: null,
27
+ sourceExcludeGlobs: [],
28
+ sourceExtensions: null,
29
+ testGlobs: DEFAULT_TEST_GLOBS,
31
30
  maxOwners: 3,
32
31
  scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
33
32
  scopedCodeMiss: 'warn',
@@ -36,107 +35,36 @@ export function loadConfig(root: string): LintConfig {
36
35
  // Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
37
36
  // silently reverting the author's budgets to defaults and green-washing the very warnings they tuned.
38
37
  const c = readJsonConfig(join(root, 'spexcode.json'))?.lint ?? {}
39
- const merged = { ...DEFAULT_CONFIG, ...c, altitude: { ...DEFAULT_CONFIG.altitude, ...(c.altitude ?? {}) } }
38
+ const merged = { ...DEFAULT_CONFIG, ...c }
40
39
  return normalizeConfig(merged)
41
40
  }
42
41
 
43
- // canonicalize two adopter-input footguns that would otherwise SILENTLY match ZERO files (the same failure
44
- // class as an unset governedRoots a green board that governs nothing). Both are natural mistakes a non-web
45
- // adopter makes reading the prose, so we accept-what-they-meant rather than reject:
46
- // - a LEADING DOT on an extension: the matcher is `\.(ext)$`, so a literal ".ts" becomes `\..ts$` and never
47
- // matches. Strip leading dots → ["ts"] and [".ts"] both work (prose historically showed ".ts").
48
- // - a testGlob with NO "/": globs anchor to the full repo-relative path, so a bare "*.test.ts" matches only
49
- // ROOT-level files and leaks every nested test into coverage. A slash-less glob is a basename intent →
50
- // prepend "**/" so it matches that basename at any depth (the default "**/*.test.*" already does).
42
+ // Compile every author-facing source selector into one include-minus-exclude/test policy. In particular,
43
+ // sourceExtensions is compatibility syntax only: it contributes include globs and never reaches discovery.
51
44
  export function normalizeConfig(cfg: LintConfig): LintConfig {
52
45
  // a mistyped enum silently reverting to the default would green-wash (or over-warn) exactly the
53
46
  // advisory the author meant to tune — same fail-loud rule as a malformed spexcode.json.
54
47
  if (cfg.scopedCodeMiss !== 'warn' && cfg.scopedCodeMiss !== 'ignore')
55
48
  throw new Error(`spexcode.json lint.scopedCodeMiss must be "warn" or "ignore", got ${JSON.stringify(cfg.scopedCodeMiss)}`)
56
49
  const dedot = (xs: string[]) => xs.map((x) => x.replace(/^\.+/, ''))
50
+ const anyDepth = (xs: string[]) => xs.map((g) => (g.includes('/') ? g : `**/${g}`))
51
+ const extensions = cfg.sourceExtensions === null ? null : dedot(cfg.sourceExtensions)
52
+ const includes = cfg.sourceIncludeGlobs === null && extensions === null
53
+ ? null
54
+ : [...new Set([...anyDepth(cfg.sourceIncludeGlobs ?? []), ...(extensions ?? []).map((ext) => `**/*.${ext}`)])]
57
55
  return {
58
56
  ...cfg,
59
- sourceExtensions: dedot(cfg.sourceExtensions),
60
- identifierExtensions: dedot(cfg.identifierExtensions),
61
- testGlobs: cfg.testGlobs.map((g) => (g.includes('/') ? g : `**/${g}`)),
57
+ sourceIncludeGlobs: includes,
58
+ sourceExcludeGlobs: anyDepth(cfg.sourceExcludeGlobs),
59
+ sourceExtensions: extensions,
60
+ testGlobs: anyDepth(cfg.testGlobs),
62
61
  }
63
62
  }
64
63
 
65
- // the source-file matcher, built from the configurable `sourceExtensions` knob. Coverage uses it to decide
66
- // which tracked files must be governed; eval lint's `eval-coverage` reuses THE SAME knob so ONE setting
67
- // defines "source" for both coverage axes — a non-web project (Rust/Go/Python .rs/.go/.py) sets it once and
68
- // both the coverage warning and the loss-signal blind-spot check follow, with no second web-only allowlist.
69
- export const sourceExtRe = (extensions: string[]) => new RegExp(`\\.(${extensions.join('|')})$`)
70
-
71
- // a minimal glob → RegExp anchored to the full repo-relative path: `**` = any dirs, `*` = within a segment.
72
- function globToRe(glob: string): RegExp {
73
- const body = glob.split(/(\*\*\/|\*\*|\*|\?)/).map((seg) => {
74
- if (seg === '**/') return '(?:.*/)?'
75
- if (seg === '**') return '.*'
76
- if (seg === '*') return '[^/]*'
77
- if (seg === '?') return '[^/]'
78
- return seg.replace(/[.+^${}()|[\]\\]/g, '\\$&')
79
- }).join('')
80
- return new RegExp(`^${body}$`)
81
- }
82
-
83
- // coverage enumerates source via GIT-TRACKED files (`git ls-files`, through git() which strips the hook's
84
- // GIT_DIR), NOT a raw fs walk. Tracked-only auto-excludes node_modules + build output (gitignored), nested
85
- // or linked worktrees + submodules (a separate index), `.git`, and anything untracked — so governedRoots
86
- // '.' means "all tracked source" with no fs explosion and no hand-maintained skip list (git IS the database).
87
- // Test files drop per cfg.testGlobs (default *.test.*; set [] to govern tests too).
88
- function trackedSourceFiles(root: string, roots: string[], src: RegExp, testGlobs: string[]): string[] {
89
- const testRes = testGlobs.map(globToRe)
90
- const out = new Set<string>()
91
- for (const r of roots) {
92
- let listed = ''
93
- try { listed = git(['-C', root, 'ls-files', '-z', '--', r]) } catch { continue }
94
- for (const f of listed.split('\0')) {
95
- if (!f || !src.test(f) || testRes.some((re) => re.test(f))) continue
96
- out.add(f)
97
- }
98
- }
99
- return [...out]
100
- }
101
-
102
- // code-identifier signals: camelCase | snake_case | foo( | `backticked` | /a/path.ext | bare file.ext. Only
103
- // the bare-filename branch needs the extension allowlist (config, so a non-TS project recognises its own
104
- // sources) — without it a bare `word.word` would match ordinary prose like "e.g".
105
- function identRe(extensions: string[]): RegExp {
106
- const ext = extensions.join('|')
107
- return new RegExp(`[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*|\\b[a-z]+_[a-z0-9_]+\\b|\\b\\w+\\(|\`[^\`]+\`|\\/[\\w./-]+\\.\\w+|\\b[\\w-]+\\.(${ext})\\b`, 'g')
108
- }
109
- // step-by-step how-to phrasing: numbered steps, or sequencing connectives that walk through mechanics.
110
- const STEP_LINE = /^\s*(\d+[.)]\s|[-*]\s*(first|then|next|finally)\b)|(^|[,;]\s*)(first|then|next|finally),/i
111
- // returns a one-line reason naming whichever low-altitude proxy(ies) tripped (length / identifier density /
112
- // step-by-step), or null when the body is at altitude.
113
- function altitude(body: string, cfg: LintConfig, ident: RegExp): string | null {
114
- const a = cfg.altitude
115
- const lines = body.split('\n')
116
- const nb = lines.filter((l) => l.trim()).length
117
- const chars = body.length
118
- // identifiers and step phrasing are read from PROSE only — a fenced code sample is acknowledged code,
119
- // not low-altitude narration, so it inflates length but not density.
120
- let inFence = false, signals = 0, steps = 0
121
- for (const l of lines) {
122
- if (/^\s*```/.test(l)) { inFence = !inFence; continue }
123
- if (inFence || !l.trim()) continue
124
- signals += l.match(ident)?.length ?? 0
125
- if (STEP_LINE.test(l)) steps++
126
- }
127
- const density = signals / Math.max(1, nb)
128
- const why: string[] = []
129
- if (nb > a.lineBudget || chars > a.charBudget) why.push(`${nb} non-blank lines / ${chars} chars over budget (${a.lineBudget}/${a.charBudget})`)
130
- if (nb > a.sizeable && density > a.dense) why.push(`code-identifier density ${density.toFixed(2)}/line over ${a.dense}`)
131
- if (nb > a.sizeable && steps >= a.steps) why.push(`${steps} step-by-step how-to lines`)
132
- return why.length ? why.join('; ') : null
133
- }
134
-
135
64
  export async function specLint(): Promise<Finding[]> {
136
65
  const root = repoRoot()
137
66
  const cfg = loadConfig(root)
138
- const ident = identRe(cfg.identifierExtensions)
139
- const srcRe = sourceExtRe(cfg.sourceExtensions)
67
+ const governed = trackedSourceFiles(root, cfg.governedRoots, cfg)
140
68
  const specs = await loadSpecs()
141
69
  const out: Finding[] = []
142
70
 
@@ -252,33 +180,9 @@ export async function specLint(): Promise<Finding[]> {
252
180
  }
253
181
  }
254
182
 
255
- // altitude: a body that re-narrates mechanics instead of stating contract/intent (WARN — soft budget).
256
- for (const s of specs) {
257
- const why = altitude(s.body, cfg, ident)
258
- if (why) out.push({ level: 'warn', rule: 'altitude', spec: s.id, msg: `'${s.id}' body reads low-altitude (mechanics, not contract): ${why}` })
259
- }
260
-
261
- // breadth: a node with too many DIRECT children is altitude's structural twin — splitting a node to pass
262
- // altitude shouldn't just relocate the sprawl into a wide flat fan-out (WARN — soft, advisory). Children
263
- // are derived from the parent links loadSpecs already computes; no explicit child array to keep in sync.
264
- const childCount = new Map<string, number>()
265
- for (const s of specs) if (s.parent) childCount.set(s.parent, (childCount.get(s.parent) ?? 0) + 1)
266
- for (const s of specs) {
267
- const n = childCount.get(s.id) ?? 0
268
- if (n >= cfg.maxChildren)
269
- out.push({ level: 'warn', rule: 'breadth', spec: s.id, msg: `'${s.id}' has ${n} direct child nodes (>= ${cfg.maxChildren}) — is an intermediate grouping layer missing? (a flat list of genuine peers is sometimes right — ignore if so)` })
270
- }
271
-
272
183
  // coverage: every governed source file must be claimed by at least one spec.
273
- const governed = trackedSourceFiles(root, cfg.governedRoots, srcRe, cfg.testGlobs)
274
- // no governed source found at all → make it a SELF-EXPLANATORY repair entrypoint, not a dead end. The two
275
- // knobs governing this are BOTH web-tuned by default (extensions ts/tsx/js/jsx; roots this repo's own dirs),
276
- // so a non-web adopter (Rust/Go/Python) hits zero source two ways: right dir but wrong extension, or an
277
- // unset root. Naming BOTH knobs, echoing their CURRENT values (so the mismatch is visible — "searching .ts
278
- // in a .py tree"), and stating the `lint`-key nesting (a top-level key silently no-ops) turns the warning
279
- // into the fix. Concrete non-web extension examples so the repair is copy-pasteable, not a schema hunt.
280
184
  if (governed.length === 0)
281
- out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — 0 source files matched extensions [${cfg.sourceExtensions.join(', ')}] under governedRoots [${cfg.governedRoots.join(', ')}]. Both knobs live under the "lint" key in spexcode.json (a top-level key is ignored): set governedRoots to your source dir(s) (e.g. ["src"]) AND sourceExtensions to your language (e.g. ["rs"] / ["go"] / ["py"]).` })
185
+ out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — 0 source candidates under governedRoots [${cfg.governedRoots.join(', ')}]; ${sourcePolicyDescription(cfg)}. Repair these knobs under the "lint" key in spexcode.json (top-level keys are ignored): governedRoots, sourceIncludeGlobs, sourceExcludeGlobs, testGlobs; sourceExtensions remains compatibility shorthand for include globs.` })
282
186
  for (const f of governed)
283
187
  if (!claimed.has(f)) out.push({ level: 'warn', rule: 'coverage', file: f, msg: `no spec governs: ${f}` })
284
188
 
@@ -1,8 +1,15 @@
1
1
  // @@@ login page - the gateway's gate is a DESIGNED page, not the browser's Basic-auth dialog (which can't
2
2
  // be styled and feels like a 1998 intranet). Self-contained: inline CSS + SVG, zero external assets, so it
3
3
  // renders before anything is authorised. Dark, calm, a single password field; an error state when the
4
- // password is wrong. The form POSTs to /login (same-origin), which mints the auth cookie and redirects.
5
- export function loginPage(error = false): string {
4
+ // password is wrong. The form POSTs to `action` (same-origin), which mints the auth cookie and redirects.
5
+ // One page serves every gate: the single-project gateway's /login (the defaults) and the multi-project
6
+ // hub's admin + per-project logins ([[gateway-hub]] passes action/heading/sub). Every option is
7
+ // HTML-escaped at this sink — projectIds are path-derived, attacker-influencable strings.
8
+ export function loginPage(error = false, opts: { action?: string; heading?: string; sub?: string } = {}): string {
9
+ const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!))
10
+ const action = esc(opts.action ?? '/login')
11
+ const heading = esc(opts.heading ?? 'Restricted access')
12
+ const sub = esc(opts.sub ?? 'This is a private agent workspace. Enter the access password to continue.')
6
13
  return `<!doctype html>
7
14
  <html lang="en">
8
15
  <head>
@@ -58,7 +65,7 @@ export function loginPage(error = false): string {
58
65
  </style>
59
66
  </head>
60
67
  <body>
61
- <form class="card" method="POST" action="/login" autocomplete="off">
68
+ <form class="card" method="POST" action="${action}" autocomplete="off">
62
69
  <div class="mark">
63
70
  <svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
64
71
  <rect x="2" y="2" width="28" height="28" rx="8" fill="#0e1626" stroke="#3f6fe0" stroke-opacity="0.5"/>
@@ -66,8 +73,8 @@ export function loginPage(error = false): string {
66
73
  </svg>
67
74
  <b>Spex<span>Code</span></b>
68
75
  </div>
69
- <h1>Restricted access</h1>
70
- <p class="sub">This is a private agent workspace. Enter the access password to continue.</p>
76
+ <h1>${heading}</h1>
77
+ <p class="sub">${sub}</p>
71
78
  ${error ? '<div class="err">Incorrect password — try again.</div>' : ''}
72
79
  <label for="password">Password</label>
73
80
  <input id="password" name="password" type="password" autofocus required placeholder="••••••••••">
@@ -11,6 +11,12 @@ import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
11
11
  import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
12
12
  import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
13
13
 
14
+ export type MaterializedArtifact = {
15
+ kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
16
+ path: string
17
+ }
18
+ export type MaterializeResult = { contentHash: string; planted: MaterializedArtifact[] }
19
+
14
20
  // @@@ materialize - the materialize step (≈0.85s), anchored on GIT-NATIVE events only ([[commit-surgery]]):
15
21
  // spex verbs (init/materialize), session-worktree creation, and the planted git hooks (pre-commit,
16
22
  // post-checkout, post-merge) — never a harness event; the harness is a READER of the materialized files, not
@@ -156,22 +162,36 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
156
162
  // and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
157
163
  // unstaged edit is never staged ([[content-filter]] edge 2).
158
164
  try { settleIndexStat(proj, HARNESSES.flatMap((h) => h.contractFiles(proj))) } catch { /* not a git repo */ }
159
- // leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex and their
160
- // skills/agents subdirs children listed before parents). rmdirSync is NON-recursive, so a dir holding
165
+ // leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex/.opencode/.pi
166
+ // and their skills/agents/plugins/extensions subdirs). Each dir AND its parent are swept deepest-first,
167
+ // because a harness may nest its shim a level below its home (opencode's .opencode/plugins/, pi's
168
+ // .pi/extensions/) — but never the checkout roots themselves. rmdirSync is NON-recursive, so a dir holding
161
169
  // any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
162
170
  for (const h of HARNESSES) {
163
171
  const anchor = h.worktreeHookAnchor(proj)
164
- for (const d of [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null])
165
- if (d) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
172
+ const dirs = [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
173
+ .filter((d): d is string => !!d)
174
+ const roots = new Set([proj, mainCheckout(proj)])
175
+ const sweep = [...new Set([...dirs, ...dirs.map((d) => dirname(d))])]
176
+ .filter((d) => !roots.has(d))
177
+ .sort((a, b) => b.length - a.length)
178
+ for (const d of sweep) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
166
179
  }
167
180
  }
168
181
 
169
- // the whole pay-per-change materialize. proj defaults to cwd. Returns the new content-hash it stamped.
170
- export function materialize(proj = process.cwd()): string {
182
+ // the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
183
+ // write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
184
+ export function materialize(proj = process.cwd()): MaterializeResult {
171
185
  const rt = treeSlotDir(proj) // this tree's slot in the global store, not the worktree
172
186
  mkdirSync(rt, { recursive: true })
187
+ const planted: MaterializedArtifact[] = []
188
+ const record = (kind: MaterializedArtifact['kind'], path: string) => {
189
+ if (!planted.some((a) => a.kind === kind && a.path === path)) planted.push({ kind, path })
190
+ }
173
191
  // (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
174
- writeFileSync(join(rt, 'hooks-manifest'), compileManifest())
192
+ const manifest = join(rt, 'hooks-manifest')
193
+ writeFileSync(manifest, compileManifest())
194
+ record('hook manifest', manifest)
175
195
  // (2) the contract = the tracked docs guide (the hand-written agent/contributor notes — the ONE piece of
176
196
  // in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
177
197
  // contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
@@ -216,17 +236,18 @@ export function materialize(proj = process.cwd()): string {
216
236
  const machinePaths: string[] = []
217
237
  const contractPaths: string[] = []
218
238
  for (const h of selected) {
219
- if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f) }
239
+ if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
220
240
  const shimFile = h.shimFile(proj)
221
241
  mkdirSync(dirname(shimFile), { recursive: true })
222
242
  const shim = h.shim(DISPATCH, SPEX)
223
- writeFileSync(shimFile, shim.json)
224
- h.writeTrust(proj, shim.cmd)
243
+ writeFileSync(shimFile, shim.content)
244
+ record('shim', shimFile)
245
+ for (const f of h.writeTrust(proj, shim.cmd)) record('trust', f)
225
246
  machinePaths.push(shimFile)
226
247
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
227
248
  // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
228
249
  const anchor = h.worktreeHookAnchor(proj)
229
- if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); machinePaths.push(anchor) }
250
+ if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
230
251
  }
231
252
  // (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
232
253
  // harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
@@ -237,6 +258,7 @@ export function materialize(proj = process.cwd()): string {
237
258
  mkdirSync(dirname(f), { recursive: true })
238
259
  writeFileSync(f, skillArtifact(sk))
239
260
  artifactPaths.push(f)
261
+ record('skill', f)
240
262
  }
241
263
  }
242
264
  for (const ag of agentNodes) {
@@ -246,6 +268,7 @@ export function materialize(proj = process.cwd()): string {
246
268
  mkdirSync(dirname(f), { recursive: true })
247
269
  writeFileSync(f, agentArtifact(ag))
248
270
  artifactPaths.push(f)
271
+ record('agent', f)
249
272
  }
250
273
  }
251
274
  // (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
@@ -271,7 +294,10 @@ export function materialize(proj = process.cwd()): string {
271
294
  spex: SPEX,
272
295
  version: pluginVersion(),
273
296
  }
274
- for (const p of plugins) emitPlugin(proj, p.folder, bundle)
297
+ for (const p of plugins) {
298
+ emitPlugin(proj, p.folder, bundle)
299
+ record('plugin bundle', pluginBundleDir(proj, p.folder))
300
+ }
275
301
  }
276
302
  writeFileSync(ledger, curFolders.join('\n'))
277
303
  // (9) the ignore rules — ALWAYS the per-clone .git/info/exclude ([[residence]]): the exclude is not a
@@ -313,5 +339,5 @@ export function materialize(proj = process.cwd()): string {
313
339
  // (5) stamp the content-hash marker LAST (a diagnostic freshness record; a crash mid-materialize leaves it stale).
314
340
  const h = contentHash(proj)
315
341
  writeFileSync(join(rt, 'content-hash'), h)
316
- return h
342
+ return { contentHash: h, planted }
317
343
  }
@@ -9,8 +9,9 @@
9
9
  // `@<token>` at a word boundary is an actor; `[[<id>]]` is a topic. Token chars are any unicode
10
10
  // letter/number plus [_-] (a CJK session handle or node id is first-class — same charset the launch-side
11
11
  // MENTION and the dashboard's MENTION_RE use); a session id, a short label/prefix, or the literal `new`.
12
- // Both forms are deduped in first-seen order.
13
- const ACTOR_RE = /(?:^|\s)@([\p{L}\p{N}_-]+)/gu
12
+ // Only `new` may carry `:<launcher>` ([[launcher-select]]); keeping that choice in the token makes the same
13
+ // prose work from the CLI and dashboard, with no composer-only field. Both forms are deduped first-seen.
14
+ const ACTOR_RE = /(?:^|\s)@([\p{L}\p{N}_-]+)(?::([\p{L}\p{N}_.-]+))?/gu
14
15
  const NODE_RE = /\[\[([^\]\s]+)\]\]/g
15
16
 
16
17
  const uniq = (xs: string[]): string[] => [...new Set(xs)]
@@ -30,7 +31,7 @@ export function stripRefSigil(token: string): string {
30
31
  export function parseMentions(text: string): { actors: string[]; nodes: string[] } {
31
32
  const actors: string[] = []
32
33
  const nodes: string[] = []
33
- for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1])
34
+ for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1] === 'new' && m[2] ? `new:${m[2]}` : m[1])
34
35
  for (const m of text.matchAll(NODE_RE)) nodes.push(m[1])
35
36
  return { actors: uniq(actors), nodes: uniq(nodes) }
36
37
  }
@@ -39,17 +40,19 @@ export function parseMentions(text: string): { actors: string[]; nodes: string[]
39
40
  // A minimal shape of what dispatchMentions needs from a Session (so this stays testable without sessions.ts).
40
41
  export type ActorSession = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
41
42
  export type Resolved =
42
- | { token: string; kind: 'new' }
43
+ | { token: string; kind: 'new'; launcher?: string }
43
44
  | { token: string; kind: 'session'; session: ActorSession }
44
45
  | { token: string; kind: 'unresolved' }
45
46
 
46
- // Resolve each actor token to a `new` sentinel, an ONLINE session (never a dead one you don't summon a
47
- // closed agent; `@new` acts on its behalf), or unresolved. Match order: literal `new` → exact id → id prefix
48
- // → name/title (case-insensitive) → name/title prefix. First online match wins.
47
+ // Resolve each actor token to a `new` sentinel (optionally carrying its explicit launcher), an ONLINE
48
+ // session (never a dead one — you don't summon a closed agent; `@new` acts on its behalf), or unresolved.
49
+ // Match order: new/new:<launcher> exact id → id prefix → name/title (case-insensitive) → name/title prefix.
50
+ // First online match wins.
49
51
  export function resolveActors(tokens: string[], sessions: ActorSession[]): Resolved[] {
50
52
  const online = sessions.filter((s) => s.liveness === 'online')
51
53
  return tokens.map((token): Resolved => {
52
- if (token === 'new') return { token, kind: 'new' }
54
+ const fresh = /^new(?::(.+))?$/.exec(token)
55
+ if (fresh) return { token, kind: 'new', ...(fresh[1] ? { launcher: fresh[1] } : {}) }
53
56
  const t = token.toLowerCase()
54
57
  const label = (s: ActorSession) => (s.name || s.title || '').toLowerCase()
55
58
  const hit =
@@ -84,7 +87,9 @@ function mentionPrompt(threadId: string, node: string | null, author: string, te
84
87
  // A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
85
88
  // landed, so the prompt leads with the status and a verify-on-main-first instruction.
86
89
  export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
87
- const on = node ? ` on node ${node}` : ''
90
+ // Keep inherited scope inside the text the worker receives: newSession derives its node only from the
91
+ // raw prompt's first [[id]] mention, so issue dispatch gets no private node-binding argument.
92
+ const on = node ? ` on node [[${node}]]` : ''
88
93
  const settled = status && status !== 'open'
89
94
  ? `NOTE: this thread is already resolved (status: ${status}) — the work it describes has likely LANDED. ` +
90
95
  `Verify the current state on main FIRST; if main already satisfies the thread, reply with that finding ` +
@@ -115,7 +120,7 @@ export async function dispatchMentions(
115
120
  // deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
116
121
  const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
117
122
  try {
118
- const s = await newSession(ctx.node, newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status), spawnParent(ctx.author, sessions))
123
+ const s = await newSession(newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status), spawnParent(ctx.author, sessions), r.launcher)
119
124
  out.push({ token: r.token, result: 'spawned', detail: s.id, ...(settled ? { note: `thread ${settled}` } : {}) })
120
125
  } catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
121
126
  continue
@@ -205,7 +210,7 @@ export function summarize(outcomes: DispatchOutcome[], loopIn?: LoopIn | null):
205
210
  const parts: string[] = []
206
211
  if (outcomes.length) parts.push('@ ' + outcomes.map((o) => {
207
212
  if (o.result === 'sent') return `${o.token}→sent`
208
- if (o.result === 'spawned') return `new→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
213
+ if (o.result === 'spawned') return `${o.token}→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
209
214
  if (o.result === 'offline') return `${o.token}→offline (stored)`
210
215
  if (o.result === 'unresolved') return `${o.token}→? (no live session; stored)`
211
216
  return `${o.token}→failed (${o.detail})`