spexcode 0.3.0 → 0.4.1

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 (59) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/anchors.ts +341 -0
  3. package/spec-cli/src/cli.ts +20 -20
  4. package/spec-cli/src/gateway.ts +8 -1
  5. package/spec-cli/src/git.ts +21 -13
  6. package/spec-cli/src/graph.ts +13 -7
  7. package/spec-cli/src/guide.ts +63 -15
  8. package/spec-cli/src/harness.ts +85 -34
  9. package/spec-cli/src/help.ts +33 -17
  10. package/spec-cli/src/index.ts +39 -7
  11. package/spec-cli/src/init.ts +5 -4
  12. package/spec-cli/src/lint.ts +103 -28
  13. package/spec-cli/src/localIssues.ts +19 -0
  14. package/spec-cli/src/migrate-table.ts +27 -16
  15. package/spec-cli/src/search.bench.mjs +2 -2
  16. package/spec-cli/src/session-timeline.ts +148 -0
  17. package/spec-cli/src/sessions.ts +29 -4
  18. package/spec-cli/src/specs.ts +44 -21
  19. package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
  20. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +1 -1
  21. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
  22. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  23. package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
  24. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  25. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
  26. package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
  27. package/spec-cli/templates/spec/project/spec.md +1 -1
  28. package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-C_fGmOKK.js} +3 -3
  29. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
  30. package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-x9gtO1OQ.js} +1 -1
  31. package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-5f_vL-JV.js} +1 -1
  32. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +1 -0
  33. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +66 -0
  34. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +13 -0
  35. package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-DKb5Ji_X.js} +1 -1
  36. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +41 -0
  37. package/spec-dashboard/dist/assets/index-BbMkwuix.css +1 -0
  38. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +6 -0
  39. package/spec-dashboard/dist/index.html +2 -2
  40. package/spec-eval/src/cli.ts +82 -21
  41. package/spec-eval/src/evaltab.ts +15 -6
  42. package/spec-eval/src/humanok.ts +43 -0
  43. package/spec-eval/src/scenarios.ts +116 -4
  44. package/spec-eval/src/sidecar.ts +35 -9
  45. package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
  46. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  47. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
  48. package/spec-dashboard/dist/assets/MobileApp-DwuTKgdP.js +0 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-CBS5_cmK.js +0 -71
  50. package/spec-dashboard/dist/assets/SessionWindow-CqAnjWfI.js +0 -9
  51. package/spec-dashboard/dist/assets/index-Cc26X4ce.css +0 -1
  52. package/spec-dashboard/dist/assets/index-Ce0wDyQS.js +0 -41
  53. /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +0 -0
  54. /package/spec-cli/templates/spec/project/.plugins/{tidy → commands/tidy}/spec.md +0 -0
  55. /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
  56. /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
  57. /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/spec.md +0 -0
  58. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
  59. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/spec.md +0 -0
@@ -62,9 +62,22 @@ FRONTMATTER (YAML between the opening and closing --- lines; every field optiona
62
62
  Drives drift + eval freshness. Many nodes MAY govern the same file (ordinary
63
63
  composition); a file governed by > maxOwners nodes warns (the \`owners\` rule — split it). Omit
64
64
  for a pure-prose node: a cross-cutting contract no file owns.
65
+ The entry may pin named units — ANCHORS: one or more \`path#symbol\` rows, ALL on the same
66
+ base file (\`#Class.method\` for a class method; top-level functions, arrow/const declarations,
67
+ classes, enums anchor cleanly; a type/interface anchor warns). One-govern counts DISTINCT
68
+ base paths, so selectors never widen govern past one file. Several selectors are OR: a
69
+ commit hitting ANY upgrades drift to the blocking \`anchor-drift\` error (one error per
70
+ entry, naming the hit selectors — a commit counts once). Without an anchor, drift stays
71
+ advisory forever. A base path is either whole-file (bare) or selector-scoped, never both;
72
+ duplicates, globs/directories with a selector, and dead/ambiguous units all error loud. A
73
+ selector-scoped governor claims units, not the file, so it stays out of the \`owners\` bound
74
+ (spex spec owner still displays it, marked "(scoped)"). Anchors are optional.
65
75
  related: files this node REFERENCES but does not own — a YAML list, same path forms. Carries coverage
66
76
  (never drift, never eval freshness, nothing to ack); it is the many-to-many net that claims the files
67
- govern doesn't. Every listed path must exist (lint integrity error otherwise).
77
+ govern doesn't. Every listed path must exist (lint integrity error otherwise). A related row
78
+ may also pin \`path#symbol\`: the node then hears about a commit ONLY when it moves that
79
+ unit — a hit is a soft \`related-drift\` warn naming the selector, a miss is SILENT (a scoped
80
+ related file's ordinary file-level nudge is off). Still never blocks, no ack, no eval freshness.
68
81
  surface plugin-system/.plugins nodes only: one or MORE of system (folded into every agent's prompt) |
69
82
  command (a /command) | skill (an on-demand SKILL.md the harness loads when a task matches the
70
83
  node's desc) | agent (a spawnable sub-agent definition; its \`tools:\` list is the spawned
@@ -84,7 +97,18 @@ Bodies without those headings are read whole. Link sibling nodes with [[node-id]
84
97
  a REAL node (lint's mention rule; backtick a placeholder like \`[[node]]\` so it reads as sample text).
85
98
 
86
99
  WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
87
- integrity (error) every code:/related: path exists.
100
+ integrity (error) every code:/related: path exists — and every anchor RESOLVES: a dead anchor (unit
101
+ deleted/renamed), an ambiguous one (two same-named units in one file), a file that
102
+ no longer parses, a language with no designated extractor, or an extractor that
103
+ can't run here (e.g. no host typescript — 'npm i -D typescript' or drop the anchor)
104
+ all error, never silently pass. So do a relation's STRUCTURAL defects: a duplicate
105
+ entry, a base path both bare and selector-scoped, or a selector on a
106
+ glob/directory.
107
+ anchor-drift (error) a commit since the node's version intersected an ANCHORED unit's lines (judged
108
+ from the file as it existed AT each commit) and no Spec-OK ack covers it — the
109
+ blocking tier of drift. Selectors on one file are OR'd: one error per entry, the
110
+ hit selectors named, each commit counted once. Remedy: update the spec, or
111
+ \`spex spec ack\` with a real reason (recorded in the ack commit body).
88
112
  one-govern (error) a node governs (code:) at most ONE file — keep the true subject, move the rest
89
113
  to related:.
90
114
  living (error) no "## vN" changelog headings — the body is current-state.
@@ -98,11 +122,21 @@ WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
98
122
  twin; is an intermediate grouping layer missing?
99
123
  coverage (warn) every source file is claimed by ≥1 node — via code: OR related: (related is the net).
100
124
  drift (warn) a governed file has commits newer than the node's spec version — it may be stale.
101
- Remedy: edit the spec to the new intent (re-versions the node), OR \`spex spec ack <node>
102
- --reason "…"\` when only mechanics changed and the contract still holds.
103
- related-drift (warn) a related: file moved ahead of the node — a soft nudge, one summary line, never blocks.
104
- owners (warn) a file governed by > maxOwners nodes (default 3) does too much SPLIT it so each
105
- governor owns its own module (or merge the nodes, or give it one foundation owner).
125
+ ALWAYS advisory: unanchored drift never blocks a commit (the blocking tier is
126
+ anchor-drift above). On a selector-SCOPED code file whose window has NO hit (a
127
+ miss), this advisory stays by default; the committed \`lint.scopedCodeMiss:
128
+ "ignore"\` silences ONLY it (hit blocks, bare drift, integrity, acks, related,
129
+ eval freshness all untouched). Remedy: edit the spec to the new intent
130
+ (re-versions the node), OR \`spex spec ack <node> --reason "…"\` when only
131
+ mechanics changed and the contract still holds.
132
+ anchor (warn) an anchor pins a type/interface — types reshape with every refactor; anchor the
133
+ behaviour-bearing unit instead.
134
+ related-drift (warn) a related: file moved ahead of the node — a soft nudge, one summary line, never
135
+ blocks. A selector-scoped related row instead warns per HIT (selector named);
136
+ its file-level misses are silent.
137
+ owners (warn) a file governed WHOLE-FILE by > maxOwners nodes (default 3) does too much — SPLIT
138
+ it so each governor owns its own module (or merge the nodes, or give it one
139
+ foundation owner). Selector-scoped governors don't count toward the bound.
106
140
  confusable-id (warn) two leaf ids one edit apart read as the same word — rename one to read apart.
107
141
 
108
142
  LIFECYCLE: author each node on a node/<id> branch, one node per commit; \`spex spec lint\` must reach 0 errors
@@ -125,8 +159,13 @@ FRONTMATTER: a \`scenarios:\` list (a YAML block sequence of mappings). Each sce
125
159
  \`frontend-e2e, backend-api, cli, desktop, mobile\`). A tag outside the library is rejected —
126
160
  use an existing one, or add it to the library to mint it. Tags classify a scenario (surface,
127
161
  device) so it can be filtered and, later, routed to the right driver.
128
- test optional. A repo path to a co-located runnable file (a playwright.spec.ts, a script)
129
- the agent MAY run by hand. Not a driver — eval never executes it.
162
+ test optional. Either a repo-path scalar (the backward-compatible shorthand) or a strict object:
163
+ test:
164
+ path: tests/auth.spec.ts
165
+ name: rejects an expired session
166
+ Both forms normalize in JSON to \`{ "path": "..." }\` with optional \`"name"\`. The object
167
+ requires exactly \`path\` + \`name\`; its case name is opaque text preserved for the measuring
168
+ hand. The path must exist. SpexCode does not parse WDIO/Playwright or execute anything.
130
169
  code optional. The file THIS scenario GOVERNS, ideally one (a comma list / flow list \`[a, b]\` is
131
170
  allowed) — its own slice of the code freshness axis, so scenarios on one node go stale
132
171
  independently. Absent → it inherits the node's \`code:\` list. A file governed by > maxOwners
@@ -338,9 +377,16 @@ the guard (the flag is the declaration of intent). Reads point anywhere.
338
377
  lint.altitude body budgets: { lineBudget, charBudget, sizeable, dense, steps }
339
378
  (defaults 50 / 4200 / 35 / 1.3 / 3).
340
379
  lint.maxChildren breadth budget: warn at >= this many direct children (default 8).
341
- lint.driftErrorThreshold commit-local gate HARD-BLOCKS a commit touching a node >= this many commits
342
- behind (default 3).
343
- lint.maxOwners warn when a file is governed by > this many nodes (default 3).
380
+ lint.maxOwners warn when a file is governed WHOLE-FILE by > this many nodes (default 3).
381
+ Selector-scoped governors (code: path#symbol) don't count toward the bound.
382
+ (lint.driftErrorThreshold is RETIRED: the count-based commit gate is replaced
383
+ by code anchors — \`code: path#symbol\` — whose hits error unconditionally; a
384
+ leftover key is ignored.)
385
+ lint.scopedCodeMiss "warn" (default) | "ignore" — the file-level drift ADVISORY on a selector-
386
+ scoped code: file whose window commits hit no selector (a miss). "ignore"
387
+ silences ONLY that advisory; it never touches hit blocks (anchor-drift),
388
+ bare-path drift, integrity, Spec-OK acks, related semantics, or eval
389
+ freshness. A project policy → committed spexcode.json.
344
390
  lint.scenarioTags the closed vocabulary an eval scenario's tags: must draw from (default
345
391
  ["frontend-e2e","backend-api","cli","desktop","mobile"]); extend to mint a tag.
346
392
  Example — govern your own source dir and loosen the altitude budget:
@@ -359,9 +405,11 @@ const FOOTPRINT = `spex guide footprint — what SpexCode plants in a repo, and
359
405
 
360
406
  SpexCode claims software engineering's HEAD (the recording of intent) and TAIL (the storage of
361
407
  measurement) and leaves the MIDDLE — construction — to the harness/agent/test framework; freshness
362
- stitches the two ends into a closed loop. The footprint follows: the head+tail (.spec, spexcode.json,
363
- evals) is the ASSET and lives in git like source; everything else is derived wiring or a machine fact.
364
- Materialized artifacts carry no facts, so they are NEVER tracked there is exactly one residence
408
+ stitches the two ends into a closed loop. Materialize is the base operation of harness ADAPTATION:
409
+ one pass renders the spec tree into whatever artifacts the selected harness auto-discovers, so that
410
+ is how SpexCode reaches an agent never a launch-time flag. The footprint follows: the head+tail
411
+ (.spec, spexcode.json, evals) is the ASSET and lives in git like source; everything else is derived
412
+ wiring or a machine fact. Materialized artifacts carry no facts, so they are NEVER tracked — there is exactly one residence
365
413
  behavior, decided per KIND (and, for a contract file, by its live CONTENT).
366
414
 
367
415
  ── THE FOUR KINDS (all fixed) ──
@@ -133,13 +133,21 @@ export interface Harness {
133
133
  // grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
134
134
  liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
135
135
  // deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
136
- // control socket — OPTIMISTIC-after-liveness (loud failure when the socket is missing/dead or the write can't
137
- // flush; once the reply line flushes to a live socket it returns ok, without waiting for an application ack —
138
- // see replyViaSocket). codex: JSON-RPC on the same app-server WebSocket the
136
+ // control socket — an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
137
+ // before it proves a concurrent connect kicked the chunk (the daemon is single-connection) resend; a wall
138
+ // expiry on a still-open connection is a busy-not-lost agent → optimistic ok (see replyViaSocket). codex:
139
+ // JSON-RPC on the same app-server WebSocket the
139
140
  // visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
140
141
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
141
142
  // Returns ok=false with a reason that propagates to the API.
142
143
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
144
+ // the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
145
+ // socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
146
+ // the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
147
+ // BEFORE delivering; absent on harnesses with no such state (codex delivery ignores the pane). claude: the
148
+ // TUI's sessions panel ("← for agents") enqueues an injected reply to the panel context and never drains it
149
+ // — verified live: parsed + enqueued, no dequeue, no turn, daemon silent.
150
+ deliveryBlockedBy?(paneText: string): string | null
143
151
  // --- materialize: clean (the inverse of write — [[harness-select]] prunes a deselected harness) ---
144
152
  // clean is the EXACT inverse of materialize's per-harness write: SURGICALLY remove ONLY SpexCode's own
145
153
  // artifacts — the managed contract block (sentinels), the generated shim file, the trust block, and the
@@ -161,11 +169,11 @@ export interface Harness {
161
169
  }
162
170
 
163
171
  // a prompt-dispatch outcome. ok=true means delivery is confirmed at the layer that harness proves it: claude at
164
- // the TRANSPORT/liveness layer (the reply line flushed to a live rendezvous socket see replyViaSocket for why
165
- // it is OPTIMISTIC-after-liveness, not a round-trip ack); codex at the application layer (the app-server accepted
166
- // `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route (non-2xx)
167
- // and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts re-exports it for
168
- // its existing importers.
172
+ // the DAEMON-PARSE layer (the atomic reply+repaint chunk answered `repaint-done`, or the wall expired on a
173
+ // still-open connection — busy, not lost; see replyViaSocket); codex at the application layer (the app-server
174
+ // accepted `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
175
+ // (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
176
+ // re-exports it for its existing importers.
169
177
  export type DispatchResult = { ok: boolean; error?: string }
170
178
  export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
171
179
  // the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
@@ -252,50 +260,84 @@ function shQuote(s: string): string {
252
260
  const PKG = fileURLToPath(new URL('..', import.meta.url))
253
261
  const SPEX = join(PKG, 'bin', 'spex.mjs')
254
262
 
255
- // @@@ replyViaSocket - OPTIMISTIC-after-liveness delivery: connect to the LIVE rendezvous socket and WRITE the
256
- // `{type:reply}\n` line; once that line FLUSHES to the socket with no immediate transport error, the reply is on
257
- // the wire and claude submits it the moment it yields the event loop so we return ok:true right there. We do
258
- // NOT wait for an application-level acceptance ack. The old code wrote a `{type:repaint}` probe after the reply
259
- // and waited up to 2500ms for a `{type:repaint-done}` to CONFIRM the reply was processed; the ordering barrier is
260
- // sound one-way (repaint-done delivered) but its ABSENCE is NOT proof of non-delivery a mid-turn/BUSY claude
261
- // can't answer the probe within the wall, so a message that actually landed reported a COMMON, misleading false
262
- // failure. The delivery confirmation therefore lives at the TRANSPORT/liveness layer, not the application layer:
263
- // deliverViaRendezvous already gates on the socket's existence (liveness), and here the FREE, INSTANT transport
264
- // signals a connect that throws, a socket 'error' (ECONNREFUSED / EPIPE), or a 'close' BEFORE the write flushes —
265
- // are still reported as real failures (they cost no waiting). What we knowingly give up is detection of the narrow
266
- // alive-but-wedged / reply-rejected / shutting-down cases: a rare silent drop, recoverable because the supervisor
267
- // sees the worker never transition. That tradeoff is the deliberate contract now see [[harness-adapter]].
268
- // Never throws.
269
- function replyViaSocket(sock: string, text: string): Promise<DispatchResult> {
263
+ // @@@ replyViaSocket - ATOMIC parse-confirmed delivery. The daemon is SINGLE-CONNECTION: a new connect
264
+ // `destroy()`s the previous socket, discarding any received-but-not-yet-parsed line with it and our own
265
+ // `rendezvousListening` liveness probe IS such a connect, fired for every session on every board snapshot. So
266
+ // the previous optimistic write (return ok once the reply line flushed) LOST prompts whenever a probe landed in
267
+ // the write→parse window, a window that widens exactly when claude is busy mid-turn (field: dashboard messages
268
+ // recorded `sent` with no trace in the claude transcript; measured 2/10 lost under a 20ms probe hammer). The
269
+ // daemon parses a chunk's complete lines in ONE synchronous loop, so writing `{type:reply}` + `{type:repaint}`
270
+ // as ONE chunk makes the pair indivisible a kick loses BOTH or NEITHER — and the outcome decidable from this
271
+ // connection alone:
272
+ // `repaint-done` arrives → the reply line before it was parsed (in-order barrier) ok, CONFIRMED.
273
+ // 'close' before it → the chunk was never parsed (kicked by a concurrent connect) resolve kicked:true
274
+ // so deliverViaRendezvous RESENDS a proven loss, so the retry cannot duplicate.
275
+ // wall expires, conn open a busy event loop is DELAYING, not losing (the 2500ms-wall lesson: ack absence is
276
+ // NOT non-delivery) → ok, OPTIMISTIC — never a false failure on a busy worker.
277
+ // `reply-rejected`/`auth-rejected`/`shutting-down` loud failure, not retried.
278
+ // Other daemon lines (heartbeat, state patches) are ignored. Never throws.
279
+ type ReplyOutcome = DispatchResult & { kicked?: boolean }
280
+ function replyViaSocket(sock: string, text: string, wallMs = 10_000): Promise<ReplyOutcome> {
270
281
  return new Promise((resolve) => {
271
282
  let settled = false
272
283
  let c: ReturnType<typeof createConnection>
273
- const done = (r: DispatchResult) => {
284
+ const done = (r: ReplyOutcome) => {
274
285
  if (settled) return
275
286
  settled = true
287
+ clearTimeout(wall)
276
288
  try { c?.destroy() } catch { /* */ }
277
289
  resolve(r)
278
290
  }
291
+ const wall = setTimeout(() => done({ ok: true }), wallMs)
279
292
  try {
280
293
  c = createConnection({ path: sock })
281
294
  } catch (e) {
282
295
  done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` })
283
296
  return
284
297
  }
285
- c.on('error', (e: NodeJS.ErrnoException) => done({ ok: false, error: `rendezvous socket connect failed: ${e?.code || String(e)}` }))
286
- c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the prompt was written' }))
287
- // write the reply line; the write callback fires once it flushes to the live socket optimistic ok. An
288
- // immediate transport 'error'/'close' beats the callback and reports the real failure (done is idempotent).
289
- c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n', () => done({ ok: true })))
298
+ // ECONNRESET/EPIPE are the KICK surfacing as an error: the daemon destroy()s the previous connection the
299
+ // moment a new one connects, and destroying a socket with OUR chunk still unread raises RST — whereas a
300
+ // parsed chunk answers repaint-done (readable even after a later close) before any clean FIN. So both codes
301
+ // PROVE the chunk was never parsed retryable, same as the clean pre-parse close. ECONNREFUSED/ENOENT
302
+ // (daemon gone) stay loud.
303
+ c.on('error', (e: NodeJS.ErrnoException) => {
304
+ const code = e?.code || String(e)
305
+ const kicked = code === 'ECONNRESET' || code === 'EPIPE'
306
+ done({ ok: false, ...(kicked ? { kicked } : {}), error: `rendezvous socket error: ${code} — prompt NOT delivered` })
307
+ })
308
+ c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection was closed before the daemon parsed the prompt (kicked by a concurrent connect)' }))
309
+ c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n' + JSON.stringify({ type: 'repaint' }) + '\n'))
310
+ let buf = ''
311
+ c.on('data', (d) => {
312
+ buf += d.toString('utf8')
313
+ let nl
314
+ while ((nl = buf.indexOf('\n')) >= 0) {
315
+ const line = buf.slice(0, nl)
316
+ buf = buf.slice(nl + 1)
317
+ let type = ''
318
+ try { type = (JSON.parse(line) as { type?: string })?.type ?? '' } catch { continue }
319
+ if (type === 'repaint-done') return done({ ok: true })
320
+ if (type === 'reply-rejected' || type === 'auth-rejected') return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` })
321
+ if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' })
322
+ }
323
+ })
290
324
  })
291
325
  }
292
326
  // claude's deliver: the pre-write LIVENESS gate — fail loud BEFORE attempting the socket if it isn't there (a
293
- // clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent alive).
294
- // Then inject the reply optimistically via replyViaSocket (no round-trip ack).
295
- function deliverViaRendezvous(id: string, text: string): Promise<DispatchResult> {
327
+ // clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent
328
+ // alive). Then the atomic parse-confirmed write; a KICKED outcome is a proven whole-chunk loss, so it resends
329
+ // (bounded attempts + jitter so re-collision with the probe cadence is unlikely); exhausted retries fail loud.
330
+ const DELIVER_ATTEMPTS = 3
331
+ export async function deliverViaRendezvous(id: string, text: string, wallMs?: number): Promise<DispatchResult> {
296
332
  const sock = rvSock(id)
297
- if (!existsSync(sock)) return Promise.resolve({ ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` })
298
- return replyViaSocket(sock, text)
333
+ if (!existsSync(sock)) return { ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` }
334
+ let last: ReplyOutcome = { ok: false, error: 'not attempted' }
335
+ for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt++) {
336
+ last = await replyViaSocket(sock, text, wallMs)
337
+ if (last.ok || !last.kicked) return { ok: last.ok, ...(last.error ? { error: last.error } : {}) }
338
+ await new Promise((r) => setTimeout(r, 60 + Math.random() * 140))
339
+ }
340
+ return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× — prompt NOT delivered, retry the send` }
299
341
  }
300
342
 
301
343
  type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
@@ -997,6 +1039,15 @@ export const claudeHarness: Harness = {
997
1039
  // dead-pane-reads-working bug). See rendezvousListening.
998
1040
  liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
999
1041
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1042
+ // the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
1043
+ // and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
1044
+ // the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
1045
+ // strings — the new-session composer placeholder, or its footer key hints together (either alone could drift
1046
+ // across claude versions; requiring the footer PAIR keeps a prose false-positive unlikely).
1047
+ deliveryBlockedBy: (paneText) =>
1048
+ paneText.includes('describe a task for a new session') || (paneText.includes('enter to return') && paneText.includes('space to reply'))
1049
+ ? 'the claude TUI is focused on its sessions panel ("← for agents"), which silently swallows injected prompts — press Enter in the session terminal to return to the composer, then resend'
1050
+ : null,
1000
1051
  resumeArg: (rec) => `--resume ${rec.session}`,
1001
1052
  }
1002
1053
 
@@ -45,7 +45,7 @@ derived status, title, and attention badges (drift:N · stale:N · issues:N · g
45
45
  },
46
46
  init: {
47
47
  line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name]',
48
- body: `Usage: spex init [dir=cwd] [--preset default|careful]
48
+ body: `Usage: spex init [dir=cwd] [--preset default]
49
49
 
50
50
  Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .plugins plugins), plants
51
51
  spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
@@ -55,15 +55,17 @@ a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex
55
55
  see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex spec lint (adoption TODO)',
56
56
  },
57
57
  materialize: {
58
- line: 'materialize re-materialize the harness artifacts (contract block · shims) for cwd’s project',
58
+ line: 'materialize the base pass of harness adaptation: render .spec/.plugins into your harness’s artifacts',
59
59
  body: `Usage: spex materialize
60
60
 
61
- Materializes the surface:system plugin nodes into the managed <!-- spexcode --> block of
62
- CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash. The materialize anchors on
63
- git-native events only (init · this verb · session-worktree creation · the pre-commit/post-checkout/
64
- post-merge hooks) run it by hand after a toolchain update, or in the setup step of any clone that
65
- has no spex-planted hooks yet (CI, a cloud agent): the artifacts are generated and excluded, so they
66
- never arrive via git.`,
61
+ The base operation of HARNESS ADAPTATION: one pass renders the spec tree's surface nodes into the
62
+ artifacts each selected harness auto-discovers the managed <!-- spexcode --> block of
63
+ CLAUDE.md/AGENTS.md, the .claude/.codex shims, the skills/agents and prints the content hash.
64
+ The outputs are derived and never tracked: to change one, edit its source (.plugins, spexcode.json)
65
+ and re-materialize never the artifact. Not a one-time setup: it anchors on git-native events
66
+ (init · this verb · session-worktree creation · the pre-commit/post-checkout/post-merge hooks) —
67
+ run it by hand after a toolchain update, or in the setup step of any clone that has no spex-planted
68
+ hooks yet (CI, a cloud agent): generated and excluded, the artifacts never arrive via git.`,
67
69
  see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
68
70
  },
69
71
  doctor: {
@@ -121,16 +123,24 @@ owner — the reverse edge: a file's GOVERNORS (code: — drives drift + eval fr
121
123
  — coverage only), with the verdict spelled out (uncovered / related-only / sanely governed /
122
124
  over-owned → split the file). --actionable prints NOTHING unless action is needed (hook use).
123
125
 
124
- lint — checks the whole spec↔code graph and exits non-zero on errors (or a blocked commit-local
125
- drift gate). Errors: integrity (a code:/related: file does not exist) · one-govern (a node governs >1
126
- file) · living (a "## vN" changelog heading) · id-format (an id char outside the whitelist — ascii
127
- [a-z0-9-] or a non-ascii unicode letter/number, CJK ok or a leaf id
128
- reused) · mention (a [[id]] naming no node). Warns: altitude · breadth · coverage · drift ·
129
- related-drift · owners · confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the pre-commit shim; bypass SPEXCODE_SKIP_LINT=1);
126
+ lint — checks the whole spec↔code graph and exits non-zero on errors. Errors: integrity (a
127
+ code:/related: file does not exist; a dead/ambiguous/unverifiable \`path#symbol\` selector; a selector
128
+ whose language has no designated extractor or whose extractor can't run here; a duplicate entry, a
129
+ base path both bare and scoped, or a selector on a glob/directory) · anchor-drift (a
130
+ commit since the spec's version touched an ANCHORED unit's lines, unacked the blocking tier of
131
+ drift; same-file selectors OR'd, one error naming the hit selectors) · one-govern (a node
132
+ governs >1 DISTINCT file) · living (a "## vN" changelog heading) · id-format (an
133
+ id char outside the whitelist — ascii [a-z0-9-] or a non-ascii unicode letter/number, CJK ok — or a
134
+ leaf id reused) · mention (a [[id]] naming no node). Warns: altitude · breadth · coverage · drift
135
+ (UNANCHORED drift — always advisory, never blocks; on a scoped file's MISS, \`lint.scopedCodeMiss:
136
+ "ignore"\` may silence it) · anchor (anchoring a type) · related-drift (a scoped related row warns
137
+ per selector HIT, misses silent) · owners (whole-file governors only; scoped don't count) ·
138
+ confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the pre-commit shim; bypass SPEXCODE_SKIP_LINT=1);
130
139
  contrast \`spex eval lint\`, which is pure advisory and never blocks anyone.
131
140
 
132
141
  ack — stamp Spec-OK on HEAD (an empty stamp commit): the drift remedy when only MECHANICS changed
133
- and the spec's contract still holds. --reason is required but NOT stored. If the intent DID change,
142
+ and the spec's contract still holds. --reason is required and recorded in the ack commit's body
143
+ (quieting an anchor hit is a strong claim — the why must be durable). If the intent DID change,
134
144
  edit the spec instead — same commit as the code.`,
135
145
  see: 'spex guide spec (the file format + every lint rule) · spex graph (browse the whole tree)',
136
146
  },
@@ -192,7 +202,7 @@ ${MENTION_NOTE}`,
192
202
  see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
193
203
  },
194
204
  eval: {
195
- line: 'eval <verb> the measurement system: add · ls · scenario ls · lint · retract · clean',
205
+ line: 'eval <verb> the measurement system: add · ls · scenario ls · lint · ok · retract · clean',
196
206
  body: `Usage: spex eval add [<node>|.] [--scenario <name>] (--pass|--fail) [--note <text>]
197
207
  [--image <png> …repeatable] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
198
208
  spex eval ls [<node>|.] [--json] a node's eval timeline, newest first
@@ -200,6 +210,7 @@ ${MENTION_NOTE}`,
200
210
  spex eval ls --session <SEL> --export [--open | --out <path>]
201
211
  spex eval scenario ls [<node>|.] [--unmeasured] [--json] declared scenarios; bare = every node
202
212
  spex eval lint [--changed] measurement-layer findings (advisory, always exit 0)
213
+ spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest reading
203
214
  spex eval retract [<node>|.] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
204
215
  spex eval clean [--keep-latest | --all] GC the content-addressed evidence cache
205
216
 
@@ -213,7 +224,7 @@ ls — node-scoped bare (its per-scenario eval history); session-scoped with an
213
224
  ✦-marked ahead of the inherited baseline. --export writes that evaluation as ONE self-contained
214
225
  HTML artifact (diff · evidence inlined · gates) for CI/sharing.
215
226
 
216
- scenario ls — the DECLARED contracts (name · tags · latest verdict), no evals: bare lists every
227
+ scenario ls — the DECLARED contracts (name · tags · normalized test reference · latest verdict), no evals: bare lists every
217
228
  measurable node's scenarios; --unmeasured keeps only the never-measured — the blind-spot worklist.
218
229
 
219
230
  lint — the measurement layer's findings: malformed eval.md (eval-schema) · unmeasured (eval-missing) ·
@@ -222,6 +233,11 @@ stale (eval-drift) · orphaned remark tracks (eval-dangling) · governed source
222
233
  files (eval-owners). --changed scopes to the nodes THIS branch touched. spec lint's errors block
223
234
  commits; eval lint is PURE ADVISORY, always exit 0 — a measurement gap never blocks anyone.
224
235
 
236
+ ok — the human's reviewed-and-agreed mark on the scenario's LATEST reading: an appended, monotonic
237
+ sign-off bound to that one immutable reading (a newer reading or staleness releases it on its own —
238
+ no un-ok exists). The evals feed default-hides a fresh, ok'd scenario; a governed session is refused
239
+ (an agent's judgment on a reading is a remark, never a self-blessing).
240
+
225
241
  retract — the sanctioned undo for a botched filing: APPENDS a retraction event (traceable, never
226
242
  deletes a line); the previous eval becomes latest again, or the scenario honestly returns to
227
243
  unmeasured.
@@ -5,7 +5,7 @@ import { Hono } from 'hono'
5
5
  import { cors } from 'hono/cors'
6
6
  import { etag } from 'hono/etag'
7
7
  import { createNodeWebSocket } from '@hono/node-ws'
8
- import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig } from './specs.js'
8
+ import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig } from './specs.js'
9
9
  import { issuesEnabled, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
10
10
  import { closeIssue, createIssue, findIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
11
11
  import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
@@ -16,11 +16,13 @@ import { getBoardJson } from './graphCache.js'
16
16
  import { boardStream, notifyBoardChanged } from './graphStream.js'
17
17
  import { gitA, gitTry, repoRoot } from './git.js'
18
18
  import { newSession, listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
19
+ import { superviseTimeline, readTimeline } from './session-timeline.js'
19
20
  import { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
20
21
  import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
21
22
  import { putBlob } from '../../spec-eval/src/cache.js'
22
23
  import { evalNodes } from '../../spec-eval/src/scenarios.js'
23
24
  import { fileHumanReading } from '../../spec-eval/src/filing.js'
25
+ import { fileHumanOk } from '../../spec-eval/src/humanok.js'
24
26
  import { buildExportModel, renderExportHtml, buildSessionEvals } from '../../spec-eval/src/sessioneval.js'
25
27
  import { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
26
28
  import { attachViewer, detachViewer, resizeBridge, forwardWheel, superviseBridges, type Viewer } from './pty-bridge.js'
@@ -63,15 +65,19 @@ app.get('/api/specs', async (c) => c.json(await loadSpecs()))
63
65
  // the search corpus ([[graph-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
64
66
  // board omits `body` to stay lean, so the search palette fetches this ONCE when it opens (cached client-side)
65
67
  // to rank nodes over their prose — off the board's hot poll. A literal segment, before the `:id` routes.
66
- // Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags}), so a measurable
67
- // node's row here carries its declared scenarios' description/expected (+ per-scenario code) — one fetch
68
+ // Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags, test}), so a measurable
69
+ // node's row here carries its declared scenarios' description/expected (+ test reference and per-scenario code) — one fetch
68
70
  // serves both the palette's scenario plane and the focus-panel preview.
69
71
  app.get('/api/specs/lite', (c) => {
70
72
  const scByNode = new Map(evalNodes(repoRoot()).map((y) => [y.id, y.scenarios]))
71
73
  return c.json(loadSpecsLite().map((row) => {
72
74
  const sc = scByNode.get(row.id)
73
75
  return sc?.length
74
- ? { ...row, scenarios: sc.map((s) => ({ name: s.name, description: s.description, expected: s.expected, ...(s.code?.length ? { code: s.code } : {}) })) }
76
+ ? { ...row, scenarios: sc.map((s) => ({
77
+ name: s.name, description: s.description, expected: s.expected,
78
+ ...(s.test ? { test: s.test } : {}),
79
+ ...(s.code?.length ? { code: s.code } : {}),
80
+ })) }
75
81
  : row
76
82
  }))
77
83
  })
@@ -113,6 +119,19 @@ app.post('/api/specs/:id/evals', async (c) => {
113
119
  const r = fileHumanReading(c.req.param('id'), b)
114
120
  return r.ok ? c.json({ ok: true, reading: r.reading }) : c.json({ error: r.error }, 400)
115
121
  })
122
+ // the HUMAN SIGN-OFF write ([[human-ok]]) — the dashboard's ok affordance and `spex eval ok` share this ONE
123
+ // write (LAW L: no dashboard-only path). Identity is SERVER-DERIVED 'human', never the request body (the
124
+ // same rule as /api/remarks). The write appends a monotonic human-ok event bound to the scenario's latest
125
+ // reading and — on the trunk checkout — commits it straight to trunk; the board cache is invalidated
126
+ // atomically with persistence so the writer's own refetch never races a stale cache.
127
+ app.post('/api/specs/:id/evals/ok', async (c) => {
128
+ const b = await c.req.json().catch(() => null)
129
+ if (!b || typeof b.scenario !== 'string') return c.json({ error: 'body needs { scenario }' }, 400)
130
+ const r = fileHumanOk(c.req.param('id'), b.scenario, 'human')
131
+ if (!r.ok) return c.json({ error: r.error }, 400)
132
+ notifyBoardChanged('full')
133
+ return c.json({ ok: true, already: r.already, humanOk: r.humanOk })
134
+ })
116
135
  // serve a reading's evidence blob by content hash (bytes never enter git): bad hash → 400, missing → 404,
117
136
  // else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
118
137
  // HTTP Range is honored — a <video> can only SEEK when the server answers byte ranges (a browser clamps
@@ -157,7 +176,9 @@ app.get('/api/settings', async (c) => c.json({
157
176
  // the `surface: command` plugin-root nodes (built/active only) for the new-session `/` dropdown — each with
158
177
  // its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
159
178
  // frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
160
- app.get('/api/plugins', (c) => c.json(loadConfig()))
179
+ // `?surface=review` lists the review-track presets instead ([[review-commands]] the eval detail's
180
+ // remark-composer `/` dropdown); the exposed surfaces stay this explicit whitelist, never a passthrough.
181
+ app.get('/api/plugins', (c) => c.json(c.req.query('surface') === 'review' ? loadReviewConfig() : loadConfig()))
161
182
  // the ISSUES read surface ([[issues]]) for the dashboard's issues page — the merged list over every store
162
183
  // (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
163
184
  // (the dashboard computes nothing over it: no re-sort, no salience ranking). The `enabled` flag mirrors
@@ -390,6 +411,14 @@ app.get('/api/sessions/:id/capture', async (c) => {
390
411
  if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
391
412
  return c.text('capture failed', 502)
392
413
  })
414
+ // the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
415
+ // FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
416
+ // the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
417
+ app.get('/api/sessions/:id/timeline', (c) => {
418
+ const limit = Number(c.req.query('limit'))
419
+ const r = readTimeline(c.req.param('id'), Number.isFinite(limit) && limit > 0 ? limit : undefined)
420
+ return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
421
+ })
393
422
  // the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
394
423
  // plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
395
424
  // the CLI's show; 404 for an unknown id.
@@ -461,8 +490,10 @@ app.post('/api/sessions/:id/input', async (c) => {
461
490
  const body = await c.req.json().catch(() => ({}))
462
491
  if (body?.kind === 'text') {
463
492
  // `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
464
- // edge ([[comms-edge]]); a raw human dispatch omits it and is not logged.
465
- const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined)
493
+ // edge ([[comms-edge]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
494
+ // terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
495
+ const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined,
496
+ body?.replyVia === 'note' ? { replyVia: 'note' } : {})
466
497
  return c.json(r, r.ok ? 200 : 502)
467
498
  }
468
499
  if (body?.kind === 'keys') {
@@ -517,6 +548,7 @@ installConnectionReaper(server as unknown as HttpServer)
517
548
  injectWebSocket(server)
518
549
  superviseBridges() // keep a warm tmux client per live session, so opening a tab is instant
519
550
  superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
551
+ superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
520
552
  console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
521
553
 
522
554
  // graceful drain (the other half of zero-downtime reload, supervise.ts): on SIGTERM stop accepting new
@@ -11,11 +11,12 @@ const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
11
11
  const TEMPLATES = join(pkgRoot, 'templates')
12
12
 
13
13
  // the cumulative preset chain, lean → cautious (see [[init-preset]]). `default` is the live `.plugins`
14
- // instance set (planted from templates/spec); every higher tier is a SEPARATE package under
15
- // templates/presets/<tier>/ that seeding stacks ON TOP — a superset, so selecting `careful` seeds the
16
- // default set PLUS the careful package. Selection matters ONLY here at seed time; the running repo just
14
+ // instance set (planted from templates/spec); a higher tier would be a SEPARATE package under
15
+ // templates/presets/<tier>/ that seeding stacks ON TOP — a superset. No non-default tier ships today
16
+ // (the `careful` package was retired); the chain mechanism stays for when one earns its keep.
17
+ // Selection matters ONLY here at seed time; the running repo just
17
18
  // walks whatever `.plugins` ended up planted, so there is no launcher-side preset gate.
18
- const PRESET_TIERS = ['default', 'careful'] as const
19
+ const PRESET_TIERS = ['default'] as const
19
20
  const presetRank = (name: string): number => (PRESET_TIERS as readonly string[]).indexOf(name)
20
21
 
21
22
  // recursively copy srcDir -> destDir, NEVER overwriting an existing file. Returns the repo-relative