spexcode 0.5.8 → 0.6.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 (104) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +122 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/delivery-queue.ts +107 -0
  15. package/spec-cli/src/doctor.ts +0 -16
  16. package/spec-cli/src/gateway-auth.ts +0 -13
  17. package/spec-cli/src/gateway.ts +0 -22
  18. package/spec-cli/src/git.ts +151 -48
  19. package/spec-cli/src/graph.ts +28 -7
  20. package/spec-cli/src/graphCache.ts +253 -72
  21. package/spec-cli/src/graphDelta.ts +0 -13
  22. package/spec-cli/src/graphStream.ts +70 -37
  23. package/spec-cli/src/guide.ts +29 -6
  24. package/spec-cli/src/harness.ts +232 -248
  25. package/spec-cli/src/help.ts +27 -26
  26. package/spec-cli/src/host-resources.ts +11 -2
  27. package/spec-cli/src/index.ts +71 -95
  28. package/spec-cli/src/issues-cli.ts +276 -0
  29. package/spec-cli/src/issues.ts +15 -197
  30. package/spec-cli/src/layout.ts +78 -2
  31. package/spec-cli/src/lint.ts +5 -3
  32. package/spec-cli/src/localIssues.ts +22 -185
  33. package/spec-cli/src/loop-in.ts +78 -0
  34. package/spec-cli/src/materialize.ts +0 -42
  35. package/spec-cli/src/mentions.ts +49 -193
  36. package/spec-cli/src/opencode-headless.ts +7 -7
  37. package/spec-cli/src/opencode.ts +2 -5
  38. package/spec-cli/src/pi-headless.ts +5 -5
  39. package/spec-cli/src/pty-bridge.ts +4 -5
  40. package/spec-cli/src/reviewSnapshot.ts +1 -0
  41. package/spec-cli/src/reviews.ts +6 -1
  42. package/spec-cli/src/root-lru.ts +54 -0
  43. package/spec-cli/src/runtime-guard.ts +0 -9
  44. package/spec-cli/src/session-cursors.ts +91 -0
  45. package/spec-cli/src/session-follow.ts +182 -0
  46. package/spec-cli/src/session-timeline.ts +55 -127
  47. package/spec-cli/src/sessions.ts +235 -1021
  48. package/spec-cli/src/shim-runtime.ts +9 -16
  49. package/spec-cli/src/specs.ts +33 -10
  50. package/spec-cli/src/uninstall.ts +0 -9
  51. package/spec-cli/src/uploads.ts +240 -18
  52. package/spec-cli/src/worktree-sources.ts +0 -16
  53. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  54. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
  56. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  58. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  59. package/spec-cli/templates/spexcode.json +12 -0
  60. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  61. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  62. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  63. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  64. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  65. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  66. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  67. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  68. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  69. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  70. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  71. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  72. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  73. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  74. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  75. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  76. package/spec-dashboard/dist/index.html +2 -2
  77. package/spec-dashboard/src/session.js +1 -28
  78. package/spec-eval/src/cache.ts +0 -14
  79. package/spec-eval/src/cli.ts +26 -5
  80. package/spec-eval/src/evaltab.ts +67 -108
  81. package/spec-eval/src/filing.ts +0 -18
  82. package/spec-eval/src/freshness.ts +78 -32
  83. package/spec-eval/src/humanok.ts +0 -15
  84. package/spec-eval/src/scenariofresh.ts +8 -80
  85. package/spec-eval/src/scenarios.ts +16 -67
  86. package/spec-eval/src/sessioneval.ts +157 -78
  87. package/spec-eval/src/sidecar.ts +1 -70
  88. package/spec-eval/src/timeline.ts +0 -23
  89. package/spec-forge/src/cache.ts +34 -12
  90. package/spec-forge/src/cli.ts +0 -6
  91. package/spec-forge/src/drivers/github.ts +1 -12
  92. package/spec-forge/src/drivers/gitlab.ts +4 -19
  93. package/spec-forge/src/drivers.ts +0 -18
  94. package/spec-forge/src/links.ts +0 -4
  95. package/spec-forge/src/needs-eval.ts +0 -1
  96. package/spec-forge/src/port.ts +0 -12
  97. package/spec-forge/src/resident.ts +4 -20
  98. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  99. package/spec-cli/src/session-maintenance.ts +0 -751
  100. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  101. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  102. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  103. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  104. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
- import { join, dirname } from 'node:path'
2
+ import { join, dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
3
4
  import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
4
5
  import { guardWorktree } from './resilience.js'
5
6
  import { HARNESSES, type HarnessId } from './harness.js'
@@ -31,6 +32,20 @@ export type Config = {
31
32
  icon?: string // project identity icon: a picker preset id; existing emoji/Iconify/URL values remain supported ([[identity-config]])
32
33
  showHeadlessLaunchers?: boolean // include headless harness profiles in the dashboard New Session picker (default false; [[launcher-visibility]])
33
34
  }
35
+ uploads?: {
36
+ // One resumable attachment policy. Values default from templates/spexcode.json so a project may omit this
37
+ // section, while spexcode.local.json can tune the whole top-level section for one machine.
38
+ maxBytes?: number // maximum bytes in one attachment (default: templates/spexcode.json)
39
+ chunkBytes?: number // raw PATCH payload cap and client slice size (default: templates/spexcode.json)
40
+ concurrency?: number // simultaneous attachment streams in one dashboard batch (default: templates/spexcode.json)
41
+ requestTimeoutMs?: number // browser timeout for one chunk/complete request (default: templates/spexcode.json)
42
+ retryLimit?: number // automatic retries after the first failed transient chunk request (default: templates/spexcode.json)
43
+ retryDelayMs?: number // wait between automatic transient-request retries (default: templates/spexcode.json)
44
+ incompleteTtlMs?: number // idle staging lifetime before an unfinished transfer expires (default: templates/spexcode.json)
45
+ cleanupIntervalMs?: number // reaper interval for stale staging bytes (default: templates/spexcode.json)
46
+ minFreeBytes?: number // filesystem capacity retained while reserving attachments (default: templates/spexcode.json)
47
+ evidenceMaxBytes?: number // retained ceiling for eval-evidence POST bodies (default: templates/spexcode.json)
48
+ }
34
49
  sessions?: {
35
50
  maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
36
51
  // named launcher profiles: a session picks ONE by name at create time ([[launcher-select]]), fixing both
@@ -67,7 +82,7 @@ export type Config = {
67
82
  // `serve`, `harnesses`, `render`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
68
83
  // preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
69
84
  // gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
70
- type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'resources' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
85
+ type Convention = Required<Omit<Config, 'dashboard' | 'uploads' | 'sessions' | 'resources' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
71
86
 
72
87
  export type Worktree = {
73
88
  path: string; branch: string | null; node: string | null
@@ -105,6 +120,53 @@ export function readConfig(root: string): Config {
105
120
  return out
106
121
  }
107
122
 
123
+ export type UploadPolicy = Required<NonNullable<Config['uploads']>>
124
+
125
+ const TEMPLATE_CONFIG = fileURLToPath(new URL('../templates/spexcode.json', import.meta.url))
126
+ const MIN_POSITIVE_INTEGER = 1
127
+ const MIN_NONNEGATIVE_INTEGER = 0
128
+
129
+ function uploadConfigError(field: keyof UploadPolicy, rule: string): never {
130
+ const error = new Error(`uploads.${field} must be ${rule}`)
131
+ error.name = 'ConfigError'
132
+ throw error
133
+ }
134
+
135
+ function configuredInteger(value: unknown, field: keyof UploadPolicy, minimum: number): number {
136
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) {
137
+ uploadConfigError(field, minimum === MIN_POSITIVE_INTEGER ? 'a positive integer' : 'a non-negative integer')
138
+ }
139
+ return value
140
+ }
141
+
142
+ function resolveUploadPolicy(values: Record<keyof UploadPolicy, unknown>): UploadPolicy {
143
+ const policy: UploadPolicy = {
144
+ maxBytes: configuredInteger(values.maxBytes, 'maxBytes', MIN_POSITIVE_INTEGER),
145
+ chunkBytes: configuredInteger(values.chunkBytes, 'chunkBytes', MIN_POSITIVE_INTEGER),
146
+ concurrency: configuredInteger(values.concurrency, 'concurrency', MIN_POSITIVE_INTEGER),
147
+ requestTimeoutMs: configuredInteger(values.requestTimeoutMs, 'requestTimeoutMs', MIN_POSITIVE_INTEGER),
148
+ retryLimit: configuredInteger(values.retryLimit, 'retryLimit', MIN_NONNEGATIVE_INTEGER),
149
+ retryDelayMs: configuredInteger(values.retryDelayMs, 'retryDelayMs', MIN_NONNEGATIVE_INTEGER),
150
+ incompleteTtlMs: configuredInteger(values.incompleteTtlMs, 'incompleteTtlMs', MIN_POSITIVE_INTEGER),
151
+ cleanupIntervalMs: configuredInteger(values.cleanupIntervalMs, 'cleanupIntervalMs', MIN_POSITIVE_INTEGER),
152
+ minFreeBytes: configuredInteger(values.minFreeBytes, 'minFreeBytes', MIN_NONNEGATIVE_INTEGER),
153
+ evidenceMaxBytes: configuredInteger(values.evidenceMaxBytes, 'evidenceMaxBytes', MIN_POSITIVE_INTEGER),
154
+ }
155
+ if (policy.chunkBytes > policy.maxBytes) uploadConfigError('chunkBytes', 'no greater than uploads.maxBytes')
156
+ return policy
157
+ }
158
+
159
+ export function uploadPolicyDefaults(): UploadPolicy {
160
+ return resolveUploadPolicy(readJsonConfig(TEMPLATE_CONFIG).uploads as Record<keyof UploadPolicy, unknown>)
161
+ }
162
+
163
+ // The seed template is the sole default-value source. Existing projects may omit `uploads`; they receive this
164
+ // policy, while a committed/local value overrides it through readConfig's existing one-level merge.
165
+ export function readUploadPolicy(root: string): UploadPolicy {
166
+ const configured = readConfig(root).uploads
167
+ return resolveUploadPolicy({ ...uploadPolicyDefaults(), ...configured } as Record<keyof UploadPolicy, unknown>)
168
+ }
169
+
108
170
  // the shared git common dir (env-stripped git() so a hook's exported GIT_DIR can't misdirect it). Memoized:
109
171
  // it's a process constant, but mainBranch()/mainRoot() resolve it per call (~60 git rev-parse forks per board build without the cache).
110
172
  let commonDirCache: string | null = null
@@ -130,6 +192,20 @@ export function mainCheckout(proj?: string): string {
130
192
  return dirname(gcd)
131
193
  }
132
194
 
195
+ // @@@ main root - identity checks need creation's configured-main answer without the record/overlay work in resolveLayout.
196
+ export function mainRoot(proj?: string): string {
197
+ if (proj) {
198
+ const checkout = mainCheckout(proj)
199
+ const configured = readConfig(checkout).main?.trim()
200
+ return configured ? resolve(checkout, configured) : checkout
201
+ }
202
+ try {
203
+ const checkout = mainCheckout()
204
+ const configured = readConfig(checkout).main?.trim()
205
+ return configured ? resolve(checkout, configured) : checkout
206
+ } catch { return repoRoot() }
207
+ }
208
+
133
209
  // @@@ global per-session store - Fork A: NO SpexCode files live in the worktree any more, so the worktree's
134
210
  // spec/code tree is pristine (zero per-session pollution). Every per-session runtime artifact — the
135
211
  // structured record (session.json) AND the launcher products (prompt, launch, launch.sh) AND the recorded comms AND
@@ -3,7 +3,7 @@ import { join } from 'node:path'
3
3
  import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, type DriftPathEvent } from './git.js'
4
4
  import { loadSpecs, parseFrontmatter } from './specs.js'
5
5
  import { readJsonConfig } from './layout.js'
6
- import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, windowEvents, anchorHitQueries } from './anchors.js'
6
+ import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, resolveSelectors, windowEvents, anchorHitQueries } from './anchors.js'
7
7
  import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
8
8
 
9
9
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
@@ -355,9 +355,11 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
355
355
  continue
356
356
  }
357
357
  // each selector resolves (or errors) on its own; only the live ones feed the window engine.
358
+ // The dead/ambiguous verdict itself comes from the ONE shared classifier ([[code-anchor]]); only the
359
+ // wording of the gate's findings lives here.
358
360
  const live: string[] = []
359
- for (const sym of selectors) {
360
- const res = resolveAnchor(units, sym)
361
+ for (const res of resolveSelectors(units, selectors)) {
362
+ const sym = res.selector
361
363
  if ('dead' in res) {
362
364
  anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `dead anchor: ${path}#${sym} ('${s.id}') names no unit on the current tree — the unit was deleted or renamed; update the spec's ${relation}: entry to follow it` } })
363
365
  continue
@@ -17,7 +17,7 @@ import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync, rmdirS
17
17
  import { join, dirname, resolve as resolvePath } from 'node:path'
18
18
  import { git, headSha, repoRoot } from './git.js'
19
19
  import { mainCheckout, envSessionId, readConfig } from './layout.js'
20
- import { parseMentions, dispatchMentions, notifyOriginator, deliveredIds, summarize, type DispatchOutcome, type LoopIn } from './mentions.js'
20
+ import { parseMentions } from './mentions.js'
21
21
  import type { Issue, Reply } from './issues.js'
22
22
 
23
23
  const LOCAL_STORE_REL = '.spec/.issues'
@@ -72,7 +72,7 @@ function requirePrimaryStore(action: string): void {
72
72
  // .plugins system which nests under the named root node), OR the disposable override. Every read and write goes here.
73
73
  const localStoreDir = (): string => overrideStoreDir() ?? join(mainCheckout(), LOCAL_STORE_REL)
74
74
  // the author's signature: the effective governed session id (envSessionId handles the claude/codex split).
75
- const currentSession = (): string => envSessionId() || 'unknown'
75
+ export const currentSession = (): string => envSessionId() || 'unknown'
76
76
  // a synchronous sleep for the commit-retry backoff (Date/timers-free, safe in any runtime).
77
77
  const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
78
78
 
@@ -356,64 +356,21 @@ export function reply(id: string, body: string, author?: string, evidence?: stri
356
356
 
357
357
  // @@@ the PROGRAMMATIC store write surface — the dashboard's human write path calls these (author `'human'`).
358
358
  // The store is git-native data, so a human's write goes through the SAME open/reply the CLI uses (committed
359
- // straight to the trunk), and because the store is the programmatic surface a human's @-mention DOES
360
- // dispatch (a human summons an agent from the issues page, per [[mentions]]). Each returns the written thread
361
- // plus the @-dispatch outcomes so a caller can echo who was notified.
362
- // The two reply deliveries are orthogonal: `evidence?` (f15b) carries a video annotation's frame blobs onto
363
- // the thread; the originator loop-in ([[mentions]]) notifies who raised the thread. Both apply on every reply.
364
- export async function replyLocalIssue(id: string, body: string, author: string, evidence?: string[], remark?: { targetCodeSha: string }): Promise<{ thread: Issue; outcomes: DispatchOutcome[]; loopIn: LoopIn | null }> {
359
+ // straight to the trunk). @session remains text in that committed discussion; it never summons an agent.
360
+ // Originator courtesy is composed above this store because it needs eval-aware candidate resolution.
361
+ export async function replyLocalIssue(id: string, body: string, author: string, evidence?: string[], remark?: { targetCodeSha: string }): Promise<{ thread: Issue }> {
365
362
  const thread = reply(id, body, author, evidence, remark)
366
- const node = thread.nodes[0] || null
367
- const outcomes = await dispatchMentions(body, { threadId: id, node, author, status: thread.status })
368
- // implicit originator loop-in ([[mentions]] / [[remark-substrate]] R3): a courtesy copy down the fallback
369
- // chain — the reading's filer, then the node's governing session — delivered to the first online link.
370
- const loopIn = await notifyOriginator(await threadOriginators(thread), author, body,
371
- { threadId: id, node, alreadyDelivered: deliveredIds(outcomes) })
372
- return { thread, outcomes, loopIn }
363
+ return { thread }
373
364
  }
374
365
 
375
- // The FALLBACK CHAIN of candidates a reply loops in ([[mentions]] loop-in / [[remark-substrate]] R3's dispatch
376
- // clause), tried in order until one is online. A plain thread's only candidate is its author (`by`). An
377
- // EVAL-COMMENT thread (concern `eval: <node> · <scenario>`, the eval-remark track) chains: the agent who FILED
378
- // the reading the remark judges FIRST — resolved from the TRUNK sidecar, then from each LIVE session's
379
- // WORKTREE (an in-flight reading, filed on an unmerged branch, is invisible to the trunk — exactly the
380
- // review-time case, when the filer sits online awaiting review and the remark must reach them) — then, when
381
- // every filer is offline/absent, the NODE's governing session, so an unresolved remark still REACHES an agent
382
- // who can act on it. A broken/absent worktree sidecar falls through silently — one bad worktree never fails
383
- // the remark write. This is notification only; it resolves nothing (R3: resolve is a deliberate
384
- // `spex remark resolve`). Non-eval threads pay nothing (no eval/specs/sessions import).
385
- const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/ // node first (never contains ' · '), then the scenario (may)
386
- async function threadOriginators(thread: Issue): Promise<(string | null)[]> {
387
- const m = EVAL_CONCERN_RE.exec(thread.concern)
388
- if (!m) return [thread.by]
389
- const node = m[1].trim(), scenario = m[2].trim()
390
- const { evalReadingFiler } = await import('../../spec-eval/src/filing.js')
391
- const chain: (string | null)[] = [evalReadingFiler(node, scenario)]
392
- try {
393
- const { listSessions } = await import('./sessions.js')
394
- for (const s of await listSessions()) {
395
- try { if (s.path) chain.push(evalReadingFiler(node, scenario, s.path)) } catch { /* one unreadable worktree → next link */ }
396
- }
397
- } catch { /* sessions unavailable (bare store, no tmux) → trunk-only chain, as before */ }
398
- chain.push(await nodeGoverningSession(node))
399
- return chain
400
- }
401
366
 
402
- // A node's governing session — the `session` its spec resolves to (the Session: trailer of its latest version,
403
- // else the frontmatter `session:` fallback; specs.ts owns that derivation). The fallback link when a reading's
404
- // filer is unreachable. null when the node is unknown or has no governing session.
405
- async function nodeGoverningSession(nodeId: string): Promise<string | null> {
406
- const { loadSpecs } = await import('./specs.js')
407
- return (await loadSpecs()).find((s) => s.id === nodeId)?.session ?? null
408
- }
409
367
 
410
368
  export async function postLocalIssue(
411
369
  concern: string,
412
370
  opts: { nodes?: string[]; body?: string; evidence?: string[]; author: string },
413
- ): Promise<{ thread: Issue; outcomes: DispatchOutcome[] }> {
371
+ ): Promise<{ thread: Issue }> {
414
372
  const thread = openIssue(concern, { nodes: opts.nodes, body: opts.body, evidence: opts.evidence, author: opts.author })
415
- const outcomes = await dispatchMentions(opts.body || concern, { threadId: thread.id, node: thread.nodes[0] || null, author: opts.author, status: thread.status })
416
- return { thread, outcomes }
373
+ return { thread }
417
374
  }
418
375
 
419
376
  export function closeLocalIssue(id: string): { status: 'landed'; already: boolean } {
@@ -430,7 +387,16 @@ export function closeLocalIssue(id: string): { status: 'landed'; already: boolea
430
387
  // (node, scenario). The scenario track is NOT a new store — it is the annotator's lazy eval thread, keyed by
431
388
  // its `eval: <node> · <scenario>` concern; a remark reuses it, creating it on first remark as a stub
432
389
  // container (every remark is a reply, never the thread body, so the resolved bit always lives in one place).
390
+ // @@@ the eval-concern format, once - composer and parser side by side. The parser existed in two copies while
391
+ // a CLI surface down here had to recognise the format without being able to import the module that composes it;
392
+ // [[issues-cli]] removed that constraint, so the format is one pair now. `node` is matched non-greedily because
393
+ // it can never contain ' · ' while a scenario name may.
433
394
  const evalConcernKey = (node: string, scenario: string): string => `eval: ${node} · ${scenario}`
395
+ const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/
396
+ export const parseEvalConcern = (concern: string): { node: string; scenario: string } | null => {
397
+ const m = EVAL_CONCERN_RE.exec(concern)
398
+ return m ? { node: m[1].trim(), scenario: m[2].trim() } : null
399
+ }
434
400
 
435
401
  // find-or-create the ONE scenario thread for (node, scenario), keyed by its eval concern, ATOMICALLY under
436
402
  // the store lock. R4 says a scenario's remark track lives ONCE — but a concurrent first-remark burst is a
@@ -438,7 +404,7 @@ const evalConcernKey = (node: string, scenario: string): string => `eval: ${node
438
404
  // two racers could both read "absent" and both create, minting a second thread whose remarks are invisible
439
405
  // to the concern key (a silent teeth blind spot). Holding one lock across both the find AND the create closes
440
406
  // that window: a racer either sees the thread the first created, or is the first. The stub is a pure
441
- // container (its body carries a [[wiki-link]], never an @-mention), so it needs no async dispatch — a
407
+ // container (its body carries a [[wiki-link]], never an @-reference), so it needs no async notification — a
442
408
  // synchronous create suffices, and staying sync is exactly what lets it share the lock hold.
443
409
  function findOrCreateEvalThread(node: string, scenario: string, author: string): Issue {
444
410
  ensureStoreMigrated() // migrate before the lock (ensure takes it itself; never nest a store-lock hold)
@@ -472,13 +438,13 @@ export async function remarkOnHost(
472
438
  host: { issue?: string; node?: string; scenario?: string },
473
439
  body: string,
474
440
  opts: { codeSha?: string; author?: string; evidence?: string[] } = {},
475
- ): Promise<{ ref: string; rid: string; codeSha: string; thread: Issue; outcomes: DispatchOutcome[]; loopIn: LoopIn | null }> {
441
+ ): Promise<{ ref: string; rid: string; codeSha: string; thread: Issue; author: string }> {
476
442
  const author = opts.author || currentSession()
477
443
  const codeSha = opts.codeSha || headSha(repoRoot())
478
444
  const id = resolveRemarkHost(host, author)
479
- const { thread, outcomes, loopIn } = await replyLocalIssue(id, body, author, opts.evidence, { targetCodeSha: codeSha })
445
+ const { thread } = await replyLocalIssue(id, body, author, opts.evidence, { targetCodeSha: codeSha })
480
446
  const rid = thread.replies[thread.replies.length - 1].rid!
481
- return { ref: `${id}#${rid}`, rid, codeSha, thread, outcomes, loopIn }
447
+ return { ref: `${id}#${rid}`, rid, codeSha, thread, author }
482
448
  }
483
449
 
484
450
  // a remark ref is `<thread-id>#<rid>`; the thread id (a store slug) never contains '#', so split on the last.
@@ -563,139 +529,10 @@ export function nudge(node: string): string {
563
529
  export function closeoutNudge(sessionId: string | null | undefined): string {
564
530
  if (!sessionId || sessionId === 'unknown' || !issuesEnabled()) return ''
565
531
  const mine = loadLocalIssues().filter((t) =>
566
- t.status === 'open' && !EVAL_CONCERN_RE.test(t.concern) &&
532
+ t.status === 'open' && !parseEvalConcern(t.concern) &&
567
533
  (t.by === sessionId || t.replies.some((r) => r.by === sessionId)))
568
534
  if (!mine.length) return ''
569
535
  return `\n\nIssue closeout — ${mine.length} still-open local issue(s) you touched (opened or replied): ${mine.map((t) => t.id).join(', ')}. For each, close it now if its work is finished (\`spex issue close <id>\`), or reply why it should stay open past this session (\`spex issue reply <id> --body "<why>"\`). Some issues rightly outlive their session — this is a reminder to sweep, not a gate.`
570
536
  }
571
537
 
572
538
  // ───────────────────────── CLI ─────────────────────────
573
- const fl = (args: string[], name: string): string | undefined => {
574
- const i = args.indexOf(`--${name}`)
575
- return i >= 0 ? args[i + 1] : undefined
576
- }
577
- const VALUE_FLAGS = new Set(['--node', '--body', '--evidence', '--scenario', '--code-sha', '--store'])
578
- // bare positionals, skipping flags + their values.
579
- function bare(args: string[]): string[] {
580
- const out: string[] = []
581
- for (let i = 0; i < args.length; i++) {
582
- const t = args[i]
583
- if (t.startsWith('--')) { if (VALUE_FLAGS.has(t)) i++; continue }
584
- out.push(t)
585
- }
586
- return out
587
- }
588
- // `--body -` reads stdin; `--body "text"` is literal; absent → undefined.
589
- function readBody(args: string[]): string | undefined {
590
- const v = fl(args, 'body')
591
- if (v === undefined) return undefined
592
- return v === '-' ? readFileSync(0, 'utf8') : v
593
- }
594
- // a repeatable value flag: every `--<name> <value>` pair, in order.
595
- const repeated = (args: string[], name: string): string[] =>
596
- args.flatMap((a, i) => (a === `--${name}` ? [args[i + 1]] : [])).filter(Boolean) as string[]
597
-
598
- // the local-issue WRITE verbs of the issue drawer (`spex issue <verb>`): open "<concern>" [--store local|<host>] [--node id…]
599
- // [--evidence hash…] [--body -|text], and the id-based reply. Store is a property of the issue,
600
- // never a second command — open and reply route by it (issues.ts createIssue/replyIssue).
601
- export async function runIssueWrite(args: string[]): Promise<number> {
602
- const sub = args[0]
603
- try {
604
- if (sub === 'reply') {
605
- const id = bare(args.slice(1))[0]
606
- const body = readBody(args)
607
- if (!id || !body) { console.error('usage: spex issue reply <issue-id> --body -|<text> [--evidence <hash>…]'); return 2 }
608
- // the ONE store-routed reply verb ([[issues]]): a forge id posts a real comment through the driver,
609
- // a local id commits to the store — the same command either way (dynamic import: no static cycle).
610
- const r = await (await import('./issues.js')).replyIssue(id, body, { evidence: repeated(args, 'evidence') })
611
- console.log(r.store === 'local'
612
- ? `replied to '${id}' — ${r.replies?.length} post(s) in thread`
613
- : `commented on '${id}' — ${r.url}`)
614
- const s = summarize(r.outcomes, r.loopIn)
615
- if (s) console.log(` ${s}`)
616
- return 0
617
- }
618
- // `open`: start a new issue — STORE-ROUTED through the one creation port ([[issues]] createIssue, the
619
- // same routine POST /api/issues runs): default local commits to the trunk store; `--store <host>`
620
- // creates the real forge issue through that store's driver (no promote round-trip when the concern is
621
- // born forge-visible). The concern is the bare positional(s) after the sub.
622
- const concern = sub === 'open' ? bare(args.slice(1)).join(' ').trim() : ''
623
- if (!concern) {
624
- console.error('usage: spex issue open "<concern>" [--store local|<host>] [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issue reply|close|promote <issue-id> …')
625
- return 2
626
- }
627
- const r = await (await import('./issues.js')).createIssue(concern, {
628
- store: fl(args, 'store'),
629
- nodes: repeated(args, 'node'),
630
- body: readBody(args),
631
- evidence: repeated(args, 'evidence'),
632
- })
633
- const re = r.nodes.length ? ` (re: ${r.nodes.join(', ')})` : ''
634
- console.log(r.store === 'local'
635
- ? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issue ls\``
636
- : `opened '${r.id}' on ${r.store}${re} — ${r.url}`)
637
- const s = summarize(r.outcomes)
638
- if (s) console.log(` ${s}`)
639
- return 0
640
- } catch (e) {
641
- console.error(`spex issue: ${e instanceof Error ? e.message : e}`)
642
- return 1
643
- }
644
- }
645
-
646
- // the first positionals runIssueWrite handles — the issue drawer routes these to it. Exported so the
647
- // router and the runner can never drift. (`nudge` is not here: it is machine plumbing, called only by the
648
- // post-merge hook as `spex internal nudge`; the on|off|status toggle verbs died in v0.3.0 — the switch is
649
- // the `issues.enabled` settings key.)
650
- export const ISSUE_WRITE_SUBS = new Set(['open', 'reply'])
651
-
652
- // ── remark CLI ([[remark-substrate]]) — CLI-first: the whole author→resolve→retract loop, no server needed ──
653
- // `spex remark add <issue-id | <node> --scenario <name>> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]`
654
- // host = a local issue id, OR a <node> with --scenario <name>. Records targetCodeSha (default: worktree HEAD).
655
- export async function runRemark(args: string[]): Promise<number> {
656
- try {
657
- const scenario = fl(args, 'scenario')
658
- const positional = bare(args)[0]
659
- const body = readBody(args)
660
- if (!positional || !body) {
661
- console.error('usage: spex remark add <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
662
- return 2
663
- }
664
- // THE FLAG DECIDES THE PARSE ([[cli-surface]] §1): `--scenario` present ⇒ the positional is a NODE id
665
- // (the remark pins to that node's scenario track); absent ⇒ it is an ISSUE id. Never type-sniffed —
666
- // a node id and an issue id are both bare slugs, so any "looks like" guess would misroute; the flag
667
- // is the one unambiguous discriminator, and a wrong host fails loud downstream (unknown issue/node).
668
- const host = scenario ? { node: positional, scenario } : { issue: positional }
669
- const r = await remarkOnHost(host, body, { codeSha: fl(args, 'code-sha'), evidence: repeated(args, 'evidence') })
670
- console.log(`remark ${r.ref} (against ${r.codeSha.slice(0, 7) || 'HEAD'}) — read it with \`spex issue ls --all\``)
671
- const s = summarize(r.outcomes, r.loopIn)
672
- if (s) console.log(` ${s}`)
673
- return 0
674
- } catch (e) {
675
- console.error(`spex remark add: ${e instanceof Error ? e.message : e}`)
676
- return 1
677
- }
678
- }
679
-
680
- // `spex remark resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
681
- export async function runResolve(args: string[]): Promise<number> {
682
- const ref = bare(args)[0]
683
- if (!ref) { console.error('usage: spex remark resolve <remark-ref> (the <thread-id>#<rid> `spex remark add` printed)'); return 2 }
684
- try {
685
- const by = currentSession()
686
- resolveRemark(ref, by)
687
- console.log(`resolved remark ${ref} — by ${by}`)
688
- return 0
689
- } catch (e) { console.error(`spex remark resolve: ${e instanceof Error ? e.message : e}`); return 1 }
690
- }
691
-
692
- // `spex remark retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
693
- export async function runRetract(args: string[]): Promise<number> {
694
- const ref = bare(args)[0]
695
- if (!ref) { console.error('usage: spex remark retract <remark-ref>'); return 2 }
696
- try {
697
- retractRemark(ref, currentSession())
698
- console.log(`retracted remark ${ref}`)
699
- return 0
700
- } catch (e) { console.error(`spex remark retract: ${e instanceof Error ? e.message : e}`); return 1 }
701
- }
@@ -0,0 +1,78 @@
1
+ import { notifyOriginator, type LoopIn } from './mentions.js'
2
+ import { replyIssue, type Issue } from './issues.js'
3
+ import { parseEvalConcern, remarkOnHost } from './localIssues.js'
4
+
5
+ // @@@ loop-in - the originator loop-in's eval-aware half, at the one altitude that can hold it
6
+ // ([[mentions]] / [[remark-substrate]] R3). The MECHANISM never moved: `notifyOriginator`, `summarizeLoopIn` and
7
+ // `LoopIn` live in `mentions.ts`, genuine substrate, and they stay there. What lived at the wrong height was a
8
+ // single INPUT — resolving WHICH candidates to try, which for an eval-remark thread means asking the eval
9
+ // package who filed the reading under judgement. That resolution sat in `localIssues.ts`, a module the eval
10
+ // package imports, so it could only reach eval through a deferred `await import()` whose own comment explained
11
+ // why it had to be wrong. A correct mechanism with one mis-layered input is the hardest shape to see: every
12
+ // piece you read is where it belongs.
13
+ //
14
+ // This module sits above both the store modules and the eval layer, so the import is an ordinary static one.
15
+
16
+ // @@@ ONE composer per path - the loop-in is reachable from four call sites: the CLI's `issue reply` and
17
+ // `remark add`, and the HTTP routes for each. If each composed its own chain, the same verb would report
18
+ // different candidates depending on which door it came through, and no gate we have would notice the drift.
19
+ // So the four sites call these two functions, and these two are the only places a chain is built.
20
+
21
+ // A node's governing session — the `session` its spec resolves to (the Session: trailer of its latest version,
22
+ // else the frontmatter `session:` fallback; specs.ts owns that derivation). The fallback link when a reading's
23
+ // filer is unreachable. null when the node is unknown or has no governing session.
24
+ async function nodeGoverningSession(nodeId: string): Promise<string | null> {
25
+ const { loadSpecs } = await import('./specs.js')
26
+ return (await loadSpecs()).find((s) => s.id === nodeId)?.session ?? null
27
+ }
28
+
29
+ // The FALLBACK CHAIN of candidates a reply loops in, tried in order until one is online.
30
+ // A plain thread's only candidate is its author. An EVAL-COMMENT thread (concern `eval: <node> · <scenario>`)
31
+ // chains: the agent who FILED the reading the remark judges FIRST — resolved from the TRUNK sidecar, then from
32
+ // each LIVE session's worktree sidecar (the review-time case, when the filer sits online awaiting review) —
33
+ // then the NODE's governing session, so an unresolved remark still reaches an agent who can act on it. A
34
+ // broken/absent worktree sidecar falls through silently: one bad worktree never fails the remark write.
35
+ // Non-eval threads pay nothing — no eval, specs or sessions module is touched for them.
36
+ async function threadOriginators(thread: Issue): Promise<(string | null)[]> {
37
+ const parsed = parseEvalConcern(thread.concern)
38
+ if (!parsed) return [thread.by]
39
+ const { node, scenario } = parsed
40
+ const { evalReadingFiler } = await import('../../spec-eval/src/filing.js')
41
+ const chain: (string | null)[] = [evalReadingFiler(node, scenario)]
42
+ try {
43
+ const { listSessions } = await import('./sessions.js')
44
+ for (const s of await listSessions()) {
45
+ try { if (s.path) chain.push(evalReadingFiler(node, scenario, s.path)) } catch { /* one unreadable worktree → next link */ }
46
+ }
47
+ } catch { /* sessions unavailable (bare store, no tmux) → trunk-only chain, as before */ }
48
+ chain.push(await nodeGoverningSession(node))
49
+ return chain
50
+ }
51
+
52
+ // the implicit courtesy copy every reply carries: a copy down the fallback chain, delivered to the first online
53
+ // link, notification only — it resolves nothing (R3 keeps resolve a deliberate second-party act).
54
+ const loopInFor = async (thread: Issue, author: string, body: string, threadId: string) =>
55
+ notifyOriginator(await threadOriginators(thread), author, body, { threadId, node: thread.nodes[0] || null })
56
+
57
+ /** `issue reply` for every store, with the originator loop-in composed on top. */
58
+ export async function replyIssueWithLoopIn(
59
+ id: string,
60
+ body: string,
61
+ opts: { author?: string; node?: string | null; evidence?: string[] } = {},
62
+ ): Promise<Awaited<ReturnType<typeof replyIssue>> & { loopIn: LoopIn | null }> {
63
+ const r = await replyIssue(id, body, opts)
64
+ // a forge thread's author is a host login, not a live session, so there is no reachable originator and no
65
+ // local thread to read one from — silent by design, exactly as before.
66
+ const loopIn = r.thread ? await loopInFor(r.thread, r.author, body, id) : null
67
+ return { ...r, loopIn }
68
+ }
69
+
70
+ /** `remark add` on a host, with the same loop-in composed by the same code. */
71
+ export async function remarkWithLoopIn(
72
+ host: { issue?: string; node?: string; scenario?: string },
73
+ body: string,
74
+ opts: { codeSha?: string; author?: string; evidence?: string[] } = {},
75
+ ): Promise<Awaited<ReturnType<typeof remarkOnHost>> & { loopIn: LoopIn | null }> {
76
+ const r = await remarkOnHost(host, body, opts)
77
+ return { ...r, loopIn: await loopInFor(r.thread, r.author, body, r.thread.id) }
78
+ }
@@ -17,27 +17,6 @@ export type MaterializedArtifact = {
17
17
  }
18
18
  export type MaterializeResult = { contentHash: string; planted: MaterializedArtifact[] }
19
19
 
20
- // @@@ materialize - the materialize step (≈0.85s), anchored on GIT-NATIVE events only ([[commit-surgery]]):
21
- // spex verbs (init/materialize), session-worktree creation, and the planted git hooks (pre-commit,
22
- // post-checkout, post-merge) — never a harness event; the harness is a READER of the materialized files, not
23
- // a trigger. It turns the spec tree's surface nodes into the flat artifacts each consumer reads
24
- // cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
25
- // harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
26
- // surface:system plugin bodies (in name order) — written WHOLE into each
27
- // harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
28
- // (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
29
- // [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
30
- //
31
- // THE FORGETTING LAW ([[harness-delivery]]): materialize(P₂) ∘ materialize(P₁) = materialize(P₂) — one pass
32
- // under the current policy fully forgets every prior policy's artifacts; idempotence is the special case
33
- // P₂ = P₁, and dematerialize (= materialize(∅), what `spex uninstall` builds on) is the empty policy. The
34
- // implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
35
- // unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
36
- // mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
37
- // policy (possibly to nothing). There are no policy-pair branches. The one cross-tree migration receipt below
38
- // preserves old common ignore entries until every registered tree owns its local projection; it never reads or
39
- // reconstructs a sibling policy.
40
-
41
20
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
42
21
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
43
22
  // the ONE spex entry: the launcher (bin/spex.mjs), never a raw `tsx cli.ts` pair — the launcher owns tsx
@@ -62,14 +41,6 @@ export function contentHash(proj: string): string {
62
41
  } catch { return '' }
63
42
  }
64
43
 
65
- // @@@ footprint kinds ([[residence]]) - the vote axis is RETIRED: materialized artifacts carry no facts, so
66
- // they are NEVER tracked — there is exactly ONE residence behavior, not three. `.spec` + `spexcode.json` are ALWAYS
67
- // tracked (git is the database — no knob can untrack them); machine facts (shims, spexcode.local.json),
68
- // run residue (.worktrees/) stays in the common exclude; tree-selected artifacts are hidden by a managed
69
- // working .gitignore block whose tracked bytes stay pristine through the content filter. A contract file the host TRACKS — or one the user has begun
70
- // writing THEIR OWN prose into — is covered by the clean/smudge content filter ([[content-filter]]). An
71
- // environment without the generator (a teammate's clone, CI, a cloud agent) runs `spex materialize` in its
72
- // setup step — there is no committed-artifact delivery mode.
73
44
  export function retiredAxisNotice(cfg: { render?: string; private?: boolean }): void {
74
45
  if (!cfg.render?.trim() && !cfg.private) return
75
46
  const field = cfg.render?.trim() ? `"render": "${cfg.render.trim()}"` : '"private": true'
@@ -123,12 +94,6 @@ function publishSelection(path: string, body: string): void {
123
94
  renameSync(prepared, path)
124
95
  }
125
96
 
126
- // @@@ contract kind detection ([[residence]]) - a contract file's residence is a LIVE CONTENT FACT, not
127
- // an install-time choice, re-judged on every materialize: TRACKED → filter domain; untracked + wholly ours
128
- // (nothing left after stripping our sentinel block) → exclude domain; untracked + HOST CONTENT present (the
129
- // user began writing their own prose into it) → neither hidden nor tracked-for-them: the exclude entry is
130
- // withheld (hiding user content would make their prose invisible to git — data-loss shaped) and the clean
131
- // filter is pre-armed so their eventual, entirely-their-own `git add` strips our block automatically.
132
97
  const SENTINEL_RE = /\n*<!-- spexcode:start -->[\s\S]*?<!-- spexcode:end -->\n*/
133
98
  export function stripSpexcodeBlock(text: string): string {
134
99
  const m = SENTINEL_RE.exec(text)
@@ -170,13 +135,6 @@ function sweepGeneratedAgents(dir: string | null): void {
170
135
  }
171
136
  }
172
137
 
173
- // @@@ dematerialize - materialize(∅): the ERASE phase, also the whole of a backout ([[spex-uninstall]] adds
174
- // only the global store + plugin sweep + optional git hooks on top). Every removal is gated on an identity
175
- // stamp, so it deletes ONLY what a materialize wrote — never the user's prose, settings, or any .spec data. Order
176
- // matters once: the managed blocks leave the WORKING contract files before the content filter's config goes
177
- // (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
178
- // `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
179
- // covers everything materialized since, including products of renamed/deleted nodes.
180
138
  function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
181
139
  for (const h of HARNESSES) {
182
140
  // h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-