spexcode 0.2.8 → 0.4.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.
- package/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +691 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +23 -15
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +142 -98
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +300 -385
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +144 -103
- package/spec-cli/src/init.ts +14 -13
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +128 -30
- package/spec-cli/src/localIssues.ts +61 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +398 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +12 -12
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +40 -25
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/spec-eval/src/humanok.ts +43 -0
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/spec.md +0 -15
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// never written into it. One thread = one PLAIN markdown file at <main>/.spec/.issues/<id>.md; there is
|
|
4
4
|
// deliberately no content-kind taxonomy (a change suggestion, an annotation, a Q&A are the same mechanism —
|
|
5
5
|
// the prose says what it is). Others reply/discuss like an async chatroom; a supervisor drains it via
|
|
6
|
-
// `spex
|
|
6
|
+
// `spex issue ls` (reading is the port's job, issues.ts — this module owns only the store + its write verbs).
|
|
7
7
|
// Because a thread file is NOT named spec.md, the spec walk never nodes it and isSpecMd ignores it —
|
|
8
8
|
// invisible to lint / drift / deriveStatus / board with ZERO exemption. The store lives on the TRUNK, not
|
|
9
9
|
// per-branch: reads and writes target the main checkout and commit STRAIGHT to it (--no-verify, provably
|
|
@@ -28,22 +28,12 @@ const LEGACY_STORE_REL = '.spec/.forum'
|
|
|
28
28
|
// truth is `spexcode.json`'s `issues.enabled` (the same settings file that carries every other toggle),
|
|
29
29
|
// read via readConfig so a machine-local `spexcode.local.json` can override it. OFF silences the post-merge
|
|
30
30
|
// nudge (and, in the dashboard, hides the issues view); the raw write verbs stay usable, since running one
|
|
31
|
-
// is explicit consent.
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
export const issuesEnabled = (): boolean =>
|
|
36
|
-
|
|
37
|
-
return cfg.issues?.enabled ?? (cfg as { proposals?: { enabled?: boolean } }).proposals?.enabled ?? true
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function setEnabled(on: boolean): void {
|
|
41
|
-
const f = join(mainCheckout(), 'spexcode.json')
|
|
42
|
-
const cfg = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : {}
|
|
43
|
-
cfg.issues = { ...(cfg.issues || {}), enabled: on }
|
|
44
|
-
delete cfg.proposals // the pre-rename key, superseded by `issues` on this write
|
|
45
|
-
writeFileSync(f, JSON.stringify(cfg, null, 2) + '\n')
|
|
46
|
-
}
|
|
31
|
+
// is explicit consent. There is deliberately NO CLI toggle verb (v0.3.0 — "no `spex config set`" is this
|
|
32
|
+
// project's standing rule): flip the key by editing the JSON, effective immediately because readConfig
|
|
33
|
+
// reads the working tree. A legacy pre-rename `proposals.enabled` key is NOT read (no fallback — fail
|
|
34
|
+
// toward the default); `spex doctor` reports it so an old settings file gets repaired, not silently obeyed.
|
|
35
|
+
export const issuesEnabled = (): boolean =>
|
|
36
|
+
readConfig(mainCheckout()).issues?.enabled ?? true
|
|
47
37
|
|
|
48
38
|
const list = (v: string | undefined): string[] =>
|
|
49
39
|
v ? v.split(',').map((s) => s.trim()).filter(Boolean) : []
|
|
@@ -79,7 +69,7 @@ function requirePrimaryStore(action: string): void {
|
|
|
79
69
|
}
|
|
80
70
|
|
|
81
71
|
// the local issue store dir — a fixed path directly under the trunk's .spec (name-independent, unlike the
|
|
82
|
-
// .
|
|
72
|
+
// .plugins system which nests under the named root node), OR the disposable override. Every read and write goes here.
|
|
83
73
|
const localStoreDir = (): string => overrideStoreDir() ?? join(mainCheckout(), LOCAL_STORE_REL)
|
|
84
74
|
// the author's signature: the effective governed session id (envSessionId handles the claude/codex split).
|
|
85
75
|
const currentSession = (): string => envSessionId() || 'unknown'
|
|
@@ -183,7 +173,7 @@ export function loadLocalIssues(): Issue[] {
|
|
|
183
173
|
export function loadOne(id: string): Issue {
|
|
184
174
|
ensureStoreMigrated()
|
|
185
175
|
const f = join(localStoreDir(), `${id}.md`)
|
|
186
|
-
if (!existsSync(f)) throw new Error(`no local issue '${id}' (see \`spex
|
|
176
|
+
if (!existsSync(f)) throw new Error(`no local issue '${id}' (see \`spex issue ls --all --store local\`)`)
|
|
187
177
|
return parse(id, readFileSync(f, 'utf8'))
|
|
188
178
|
}
|
|
189
179
|
|
|
@@ -282,6 +272,25 @@ function writeStoreFile(p: Issue, message: string): boolean {
|
|
|
282
272
|
return true
|
|
283
273
|
}
|
|
284
274
|
|
|
275
|
+
// @@@ the shared trunk-data commit — the committing half of the store discipline, exported for OTHER
|
|
276
|
+
// git-tracked data writes that need a durable landing with no worktree ritual behind them (the eval
|
|
277
|
+
// sidecar's human-ok, [[human-ok]]). Same rules as the issue store's own write: only the trunk checkout
|
|
278
|
+
// itself commits (a linked-worktree caller gets 'not-primary' back and leaves its append for its own
|
|
279
|
+
// session's ritual commit — the sidecar, unlike the issue store, legitimately lives per-branch and
|
|
280
|
+
// merges); the commit is `--no-verify` and provably scoped to the one data path; the shared store lock
|
|
281
|
+
// serializes it against every other trunk data write so racing writers never fight over the git index.
|
|
282
|
+
export function commitTrunkData(relPath: string, message: string): 'committed' | 'no-op' | 'not-primary' {
|
|
283
|
+
if (overrideStoreDir()) return 'not-primary' // a disposable-store test rig commits nothing, ever
|
|
284
|
+
if (!isPrimaryCheckout()) return 'not-primary'
|
|
285
|
+
return withStoreLock(() => {
|
|
286
|
+
const root = mainCheckout()
|
|
287
|
+
git(['-C', root, 'add', '--', relPath])
|
|
288
|
+
if (!git(['-C', root, 'status', '--porcelain', '--', relPath]).trim()) return 'no-op'
|
|
289
|
+
git(['-C', root, 'commit', '--no-verify', '-m', message, '--', relPath])
|
|
290
|
+
return 'committed'
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
285
294
|
// prepare (a FRESH read-modify or a new thread) + write + commit a single store file, all under the store
|
|
286
295
|
// lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/close reads the
|
|
287
296
|
// current thread, never a stale copy. A pre-rename store migrates first (before the lock — ensure takes it).
|
|
@@ -368,13 +377,13 @@ export async function replyLocalIssue(id: string, body: string, author: string,
|
|
|
368
377
|
// every filer is offline/absent, the NODE's governing session, so an unresolved remark still REACHES an agent
|
|
369
378
|
// who can act on it. A broken/absent worktree sidecar falls through silently — one bad worktree never fails
|
|
370
379
|
// the remark write. This is notification only; it resolves nothing (R3: resolve is a deliberate
|
|
371
|
-
// `spex resolve`). Non-eval threads pay nothing (no
|
|
380
|
+
// `spex remark resolve`). Non-eval threads pay nothing (no eval/specs/sessions import).
|
|
372
381
|
const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/ // node first (never contains ' · '), then the scenario (may)
|
|
373
382
|
async function threadOriginators(thread: Issue): Promise<(string | null)[]> {
|
|
374
383
|
const m = EVAL_CONCERN_RE.exec(thread.concern)
|
|
375
384
|
if (!m) return [thread.by]
|
|
376
385
|
const node = m[1].trim(), scenario = m[2].trim()
|
|
377
|
-
const { evalReadingFiler } = await import('../../spec-
|
|
386
|
+
const { evalReadingFiler } = await import('../../spec-eval/src/filing.js')
|
|
378
387
|
const chain: (string | null)[] = [evalReadingFiler(node, scenario)]
|
|
379
388
|
try {
|
|
380
389
|
const { listSessions } = await import('./sessions.js')
|
|
@@ -453,7 +462,7 @@ function resolveRemarkHost(host: { issue?: string; node?: string; scenario?: str
|
|
|
453
462
|
return loadOne(host.issue).id // throws loudly if the issue doesn't exist
|
|
454
463
|
}
|
|
455
464
|
|
|
456
|
-
// author a remark on a host — the ONE write both the CLI (`spex remark`) and the server call. Stamps the
|
|
465
|
+
// author a remark on a host — the ONE write both the CLI (`spex remark add`) and the server call. Stamps the
|
|
457
466
|
// codeSha it was authored against (the worktree HEAD by default — R2). Returns the `<thread-id>#<rid>` ref.
|
|
458
467
|
export async function remarkOnHost(
|
|
459
468
|
host: { issue?: string; node?: string; scenario?: string },
|
|
@@ -471,7 +480,7 @@ export async function remarkOnHost(
|
|
|
471
480
|
// a remark ref is `<thread-id>#<rid>`; the thread id (a store slug) never contains '#', so split on the last.
|
|
472
481
|
function parseRemarkRef(ref: string): { id: string; rid: string } {
|
|
473
482
|
const i = ref.lastIndexOf('#')
|
|
474
|
-
if (i <= 0 || i === ref.length - 1) throw new Error(`bad remark ref '${ref}' — expected <thread-id>#<rid> (the id \`spex remark\` printed)`)
|
|
483
|
+
if (i <= 0 || i === ref.length - 1) throw new Error(`bad remark ref '${ref}' — expected <thread-id>#<rid> (the id \`spex remark add\` printed)`)
|
|
475
484
|
return { id: ref.slice(0, i), rid: ref.slice(i + 1) }
|
|
476
485
|
}
|
|
477
486
|
|
|
@@ -525,15 +534,15 @@ export function nudge(node: string): string {
|
|
|
525
534
|
'',
|
|
526
535
|
'1. CLOSE what you finished. An issue whose work just landed is closed, not',
|
|
527
536
|
' left open — the open set is the OUTSTANDING work, so a stale open reads as a',
|
|
528
|
-
' lie: spex
|
|
537
|
+
' lie: spex issue ls --store local then spex issue close <id>',
|
|
529
538
|
'',
|
|
530
539
|
'2. RECORD only what OUTLIVES this task — a concern you are NOT acting on now:',
|
|
531
540
|
' an off-mainline smell / awkward boundary / wish, or a trivial-but-must-not-',
|
|
532
541
|
" forget to-do that doesn't earn a spec node. NOT a bug tracker (that is the",
|
|
533
542
|
' spec graph + the forge), NOT your assigned task or a fix you are about to',
|
|
534
543
|
' make — those need no issue. Only the taste that would otherwise evaporate:',
|
|
535
|
-
' spex
|
|
536
|
-
' spex
|
|
544
|
+
' spex issue ls # read first — reply if already raised',
|
|
545
|
+
' spex issue open "<concern>" [--node <id>] # else open one',
|
|
537
546
|
'A supervisor drains the store later. (Advisory — skip if nothing is owed.)',
|
|
538
547
|
'───────────────────────────────────────────────────────────────────',
|
|
539
548
|
].join('\n')
|
|
@@ -553,7 +562,7 @@ export function closeoutNudge(sessionId: string | null | undefined): string {
|
|
|
553
562
|
t.status === 'open' && !EVAL_CONCERN_RE.test(t.concern) &&
|
|
554
563
|
(t.by === sessionId || t.replies.some((r) => r.by === sessionId)))
|
|
555
564
|
if (!mine.length) return ''
|
|
556
|
-
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
|
|
565
|
+
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.`
|
|
557
566
|
}
|
|
558
567
|
|
|
559
568
|
// ───────────────────────── CLI ─────────────────────────
|
|
@@ -582,24 +591,16 @@ function readBody(args: string[]): string | undefined {
|
|
|
582
591
|
const repeated = (args: string[], name: string): string[] =>
|
|
583
592
|
args.flatMap((a, i) => (a === `--${name}` ? [args[i + 1]] : [])).filter(Boolean) as string[]
|
|
584
593
|
|
|
585
|
-
// the local-issue WRITE verbs
|
|
586
|
-
//
|
|
587
|
-
// [--evidence hash…] [--body -|text], the id-based reply, and the feature toggle
|
|
588
|
-
// on | off | status. `nudge` is internal (the post-merge hook's caller). Store is a property of the issue,
|
|
594
|
+
// the local-issue WRITE verbs of the issue drawer (`spex issue <verb>`): open "<concern>" [--store local|<host>] [--node id…]
|
|
595
|
+
// [--evidence hash…] [--body -|text], and the id-based reply. Store is a property of the issue,
|
|
589
596
|
// never a second command — open and reply route by it (issues.ts createIssue/replyIssue).
|
|
590
597
|
export async function runIssueWrite(args: string[]): Promise<number> {
|
|
591
598
|
const sub = args[0]
|
|
592
599
|
try {
|
|
593
|
-
if (sub === 'on' || sub === 'off') {
|
|
594
|
-
setEnabled(sub === 'on')
|
|
595
|
-
console.log(`issues workflow ${sub.toUpperCase()} — spexcode.json issues.enabled = ${sub === 'on'}${sub === 'on' ? '' : ' (post-merge nudge silenced; dashboard issues view hidden)'}`)
|
|
596
|
-
return 0
|
|
597
|
-
}
|
|
598
|
-
if (sub === 'status') { console.log(`issues workflow is ${issuesEnabled() ? 'ON' : 'OFF'}`); return 0 }
|
|
599
600
|
if (sub === 'reply') {
|
|
600
601
|
const id = bare(args.slice(1))[0]
|
|
601
602
|
const body = readBody(args)
|
|
602
|
-
if (!id || !body) { console.error('usage: spex
|
|
603
|
+
if (!id || !body) { console.error('usage: spex issue reply <issue-id> --body -|<text> [--evidence <hash>…]'); return 2 }
|
|
603
604
|
// the ONE store-routed reply verb ([[issues]]): a forge id posts a real comment through the driver,
|
|
604
605
|
// a local id commits to the store — the same command either way (dynamic import: no static cycle).
|
|
605
606
|
const r = await (await import('./issues.js')).replyIssue(id, body, { evidence: repeated(args, 'evidence') })
|
|
@@ -610,19 +611,13 @@ export async function runIssueWrite(args: string[]): Promise<number> {
|
|
|
610
611
|
if (s) console.log(` ${s}`)
|
|
611
612
|
return 0
|
|
612
613
|
}
|
|
613
|
-
if (sub === 'nudge') {
|
|
614
|
-
// internal: the post-merge hook calls this to print the (toggle-aware) nudge for a merged node.
|
|
615
|
-
const text = nudge(bare(args.slice(1))[0] || '')
|
|
616
|
-
if (text) console.log(text)
|
|
617
|
-
return 0
|
|
618
|
-
}
|
|
619
614
|
// `open`: start a new issue — STORE-ROUTED through the one creation port ([[issues]] createIssue, the
|
|
620
615
|
// same routine POST /api/issues runs): default local commits to the trunk store; `--store <host>`
|
|
621
616
|
// creates the real forge issue through that store's driver (no promote round-trip when the concern is
|
|
622
617
|
// born forge-visible). The concern is the bare positional(s) after the sub.
|
|
623
618
|
const concern = sub === 'open' ? bare(args.slice(1)).join(' ').trim() : ''
|
|
624
619
|
if (!concern) {
|
|
625
|
-
console.error('usage: spex
|
|
620
|
+
console.error('usage: spex issue open "<concern>" [--store local|<host>] [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issue reply|close|promote <issue-id> …')
|
|
626
621
|
return 2
|
|
627
622
|
}
|
|
628
623
|
const r = await (await import('./issues.js')).createIssue(concern, {
|
|
@@ -633,23 +628,25 @@ export async function runIssueWrite(args: string[]): Promise<number> {
|
|
|
633
628
|
})
|
|
634
629
|
const re = r.nodes.length ? ` (re: ${r.nodes.join(', ')})` : ''
|
|
635
630
|
console.log(r.store === 'local'
|
|
636
|
-
? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex
|
|
631
|
+
? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issue ls\``
|
|
637
632
|
: `opened '${r.id}' on ${r.store}${re} — ${r.url}`)
|
|
638
633
|
const s = summarize(r.outcomes)
|
|
639
634
|
if (s) console.log(` ${s}`)
|
|
640
635
|
return 0
|
|
641
636
|
} catch (e) {
|
|
642
|
-
console.error(`spex
|
|
637
|
+
console.error(`spex issue: ${e instanceof Error ? e.message : e}`)
|
|
643
638
|
return 1
|
|
644
639
|
}
|
|
645
640
|
}
|
|
646
641
|
|
|
647
|
-
// the first positionals runIssueWrite handles — the
|
|
648
|
-
//
|
|
649
|
-
|
|
642
|
+
// the first positionals runIssueWrite handles — the issue drawer routes these to it. Exported so the
|
|
643
|
+
// router and the runner can never drift. (`nudge` is not here: it is machine plumbing, called only by the
|
|
644
|
+
// post-merge hook as `spex internal nudge`; the on|off|status toggle verbs died in v0.3.0 — the switch is
|
|
645
|
+
// the `issues.enabled` settings key.)
|
|
646
|
+
export const ISSUE_WRITE_SUBS = new Set(['open', 'reply'])
|
|
650
647
|
|
|
651
648
|
// ── remark CLI ([[remark-substrate]]) — CLI-first: the whole author→resolve→retract loop, no server needed ──
|
|
652
|
-
// `spex remark <
|
|
649
|
+
// `spex remark add <issue-id | <node> --scenario <name>> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]`
|
|
653
650
|
// host = a local issue id, OR a <node> with --scenario <name>. Records targetCodeSha (default: worktree HEAD).
|
|
654
651
|
export async function runRemark(args: string[]): Promise<number> {
|
|
655
652
|
try {
|
|
@@ -657,40 +654,44 @@ export async function runRemark(args: string[]): Promise<number> {
|
|
|
657
654
|
const positional = bare(args)[0]
|
|
658
655
|
const body = readBody(args)
|
|
659
656
|
if (!positional || !body) {
|
|
660
|
-
console.error('usage: spex remark <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
|
|
657
|
+
console.error('usage: spex remark add <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
|
|
661
658
|
return 2
|
|
662
659
|
}
|
|
660
|
+
// THE FLAG DECIDES THE PARSE ([[cli-surface]] §1): `--scenario` present ⇒ the positional is a NODE id
|
|
661
|
+
// (the remark pins to that node's scenario track); absent ⇒ it is an ISSUE id. Never type-sniffed —
|
|
662
|
+
// a node id and an issue id are both bare slugs, so any "looks like" guess would misroute; the flag
|
|
663
|
+
// is the one unambiguous discriminator, and a wrong host fails loud downstream (unknown issue/node).
|
|
663
664
|
const host = scenario ? { node: positional, scenario } : { issue: positional }
|
|
664
665
|
const r = await remarkOnHost(host, body, { codeSha: fl(args, 'code-sha'), evidence: repeated(args, 'evidence') })
|
|
665
|
-
console.log(`remark ${r.ref} (against ${r.codeSha.slice(0, 7) || 'HEAD'}) — read it with \`spex
|
|
666
|
+
console.log(`remark ${r.ref} (against ${r.codeSha.slice(0, 7) || 'HEAD'}) — read it with \`spex issue ls --all\``)
|
|
666
667
|
const s = summarize(r.outcomes, r.loopIn)
|
|
667
668
|
if (s) console.log(` ${s}`)
|
|
668
669
|
return 0
|
|
669
670
|
} catch (e) {
|
|
670
|
-
console.error(`spex remark: ${e instanceof Error ? e.message : e}`)
|
|
671
|
+
console.error(`spex remark add: ${e instanceof Error ? e.message : e}`)
|
|
671
672
|
return 1
|
|
672
673
|
}
|
|
673
674
|
}
|
|
674
675
|
|
|
675
|
-
// `spex resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
|
|
676
|
+
// `spex remark resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
|
|
676
677
|
export async function runResolve(args: string[]): Promise<number> {
|
|
677
678
|
const ref = bare(args)[0]
|
|
678
|
-
if (!ref) { console.error('usage: spex resolve <remark-ref> (the <thread-id>#<rid> `spex remark` printed)'); return 2 }
|
|
679
|
+
if (!ref) { console.error('usage: spex remark resolve <remark-ref> (the <thread-id>#<rid> `spex remark add` printed)'); return 2 }
|
|
679
680
|
try {
|
|
680
681
|
const by = currentSession()
|
|
681
682
|
resolveRemark(ref, by)
|
|
682
683
|
console.log(`resolved remark ${ref} — by ${by}`)
|
|
683
684
|
return 0
|
|
684
|
-
} catch (e) { console.error(`spex resolve: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
685
|
+
} catch (e) { console.error(`spex remark resolve: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
685
686
|
}
|
|
686
687
|
|
|
687
|
-
// `spex retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
|
|
688
|
+
// `spex remark retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
|
|
688
689
|
export async function runRetract(args: string[]): Promise<number> {
|
|
689
690
|
const ref = bare(args)[0]
|
|
690
|
-
if (!ref) { console.error('usage: spex retract <remark-ref>'); return 2 }
|
|
691
|
+
if (!ref) { console.error('usage: spex remark retract <remark-ref>'); return 2 }
|
|
691
692
|
try {
|
|
692
693
|
retractRemark(ref, currentSession())
|
|
693
694
|
console.log(`retracted remark ${ref}`)
|
|
694
695
|
return 0
|
|
695
|
-
} catch (e) { console.error(`spex retract: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
696
|
+
} catch (e) { console.error(`spex remark retract: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
696
697
|
}
|
|
@@ -39,7 +39,7 @@ const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
|
|
|
39
39
|
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
40
40
|
// the manifest + content-hash marker + plugin-folder ledger land in the materialized TREE's own slot of the
|
|
41
41
|
// GLOBAL per-project store (layout.treeSlotDir — trees/<enc-worktree>), NOT the worktree and NOT one shared
|
|
42
|
-
// per-project file: each is a pure function of ONE tree's .
|
|
42
|
+
// per-project file: each is a pure function of ONE tree's .plugins, and the old single slot let the last-
|
|
43
43
|
// materialized tree's hook set reach every other tree's dispatch ([[hook-dispatch]]). The worktree keeps
|
|
44
44
|
// zero SpexCode-materialized runtime; only the harness-discovered contract files + shims (which the harness
|
|
45
45
|
// must find in-tree) are written under proj below.
|
package/spec-cli/src/mentions.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// node) and `@session` (an ACTOR — a live session, or `@new` for a fresh worker). The same parser resolves
|
|
3
3
|
// them in ANY input box; the resolve+dispatch live HERE (CLI-first) so the issues page, the composer, and an agent's
|
|
4
4
|
// own prompt share one implementation. An `@` "just auto-sends a prompt": resolve it against the live board
|
|
5
|
-
// sessions and dispatch via [[dispatch]]'s
|
|
5
|
+
// sessions and dispatch via [[dispatch]]'s sendText / [[launch]]'s newSession — storage and delivery stay
|
|
6
6
|
// separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
|
|
7
7
|
|
|
8
8
|
// ── parse (pure) ──────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -17,8 +17,8 @@ const uniq = (xs: string[]): string[] => [...new Set(xs)]
|
|
|
17
17
|
|
|
18
18
|
// ── CLI sigil tolerance ───────────────────────────────────────────────────────────────────────────────
|
|
19
19
|
// In FREE TEXT the sigils are required — they are what marks a reference apart from prose. In a CLI
|
|
20
|
-
// ARGUMENT the whole token IS the reference, so the sigil is optional: `spex review @graph` ≡
|
|
21
|
-
// `spex review graph`, `spex
|
|
20
|
+
// ARGUMENT the whole token IS the reference, so the sigil is optional: `spex session review @graph` ≡
|
|
21
|
+
// `spex session review graph`, `spex eval add [[cli-surface]]` ≡ `spex eval add cli-surface`. One shared strip,
|
|
22
22
|
// applied by the session-selector matcher and every node-arg read site, so the habit a user learns in the
|
|
23
23
|
// dashboard's input boxes works verbatim on the CLI — never a second grammar to learn.
|
|
24
24
|
export function stripRefSigil(token: string): string {
|
|
@@ -78,8 +78,8 @@ export type DispatchOutcome = { token: string; result: 'sent' | 'spawned' | 'off
|
|
|
78
78
|
function mentionPrompt(threadId: string, node: string | null, author: string, text: string): string {
|
|
79
79
|
const re = node ? ` (re: ${node})` : ''
|
|
80
80
|
return `You were @-mentioned in issue thread "${threadId}"${re} by ${author}:\n\n ${text.trim()}\n\n` +
|
|
81
|
-
`Read the thread and act as the comment asks (often just a look): \`spex
|
|
82
|
-
`reply with \`spex
|
|
81
|
+
`Read the thread and act as the comment asks (often just a look): \`spex issue ls --all\` lists them; ` +
|
|
82
|
+
`reply with \`spex issue reply ${threadId} --body -\`.`
|
|
83
83
|
}
|
|
84
84
|
// A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
|
|
85
85
|
// landed, so the prompt leads with the status and a verify-on-main-first instruction.
|
|
@@ -92,7 +92,7 @@ export function newWorkerPrompt(threadId: string, node: string | null, author: s
|
|
|
92
92
|
: ''
|
|
93
93
|
return `Issue thread "${threadId}"${on} @-mentioned @new (by ${author}) for a fresh look:\n\n ${text.trim()}\n\n` +
|
|
94
94
|
settled +
|
|
95
|
-
`Read the thread (\`spex
|
|
95
|
+
`Read the thread (\`spex issue ls --all\`, find ${threadId}) and act on it${node ? `; the relevant node is ${node}` : ''}.`
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// Parse a committed issue post's text for `@` actors and deliver to each. Best-effort and LOUD: the thread is
|
|
@@ -104,7 +104,7 @@ export async function dispatchMentions(
|
|
|
104
104
|
): Promise<DispatchOutcome[]> {
|
|
105
105
|
const { actors } = parseMentions(text)
|
|
106
106
|
if (!actors.length) return []
|
|
107
|
-
const {
|
|
107
|
+
const { sendText, listSessions, newSession } = await import('./sessions.js')
|
|
108
108
|
const sessions = await listSessions()
|
|
109
109
|
const resolved = resolveActors(actors, sessions as unknown as ActorSession[])
|
|
110
110
|
const out: DispatchOutcome[] = []
|
|
@@ -120,7 +120,7 @@ export async function dispatchMentions(
|
|
|
120
120
|
} catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
|
|
121
121
|
continue
|
|
122
122
|
}
|
|
123
|
-
const res = await
|
|
123
|
+
const res = await sendText(r.session.id, mentionPrompt(ctx.threadId, ctx.node, ctx.author, text), 'issues')
|
|
124
124
|
out.push(res.ok ? { token: r.token, result: 'sent', detail: r.session.id }
|
|
125
125
|
: { token: r.token, result: 'offline', detail: res.error })
|
|
126
126
|
}
|
|
@@ -131,9 +131,9 @@ export async function dispatchMentions(
|
|
|
131
131
|
// A committed reply is ALSO auto-delivered as a COURTESY — never an assignment — to a FALLBACK CHAIN of
|
|
132
132
|
// candidates, in order, stopping at the FIRST one that can be reached: for a remark this is the reading's
|
|
133
133
|
// filer session, then the node's governing session, then nobody (it still surfaces on the board via the
|
|
134
|
-
// teeth). This is a NOTIFICATION chain only — it resolves NOTHING (resolve stays a deliberate `spex resolve`,
|
|
134
|
+
// teeth). This is a NOTIFICATION chain only — it resolves NOTHING (resolve stays a deliberate `spex remark resolve`,
|
|
135
135
|
// R3); it just reaches an agent who can act. It is the same delivery pipe as dispatchMentions (one
|
|
136
|
-
// online-resolution + one
|
|
136
|
+
// online-resolution + one sendText), with the same cuts that keep courtesy ≠ assignment: deliver ONLY to an
|
|
137
137
|
// ONLINE session (an unreachable link is skipped for the next, NEVER spawns a worker, NEVER drains — only an
|
|
138
138
|
// explicit @new spawns); SKIP a candidate that is the replier (no self-notify); a candidate already reached by
|
|
139
139
|
// an explicit @-target of this same text counts as delivered, so the chain STOPS (no double-delivery, no
|
|
@@ -146,14 +146,14 @@ function originatorPrompt(threadId: string, node: string | null, replier: string
|
|
|
146
146
|
const re = node ? ` (re: ${node})` : ''
|
|
147
147
|
return `A new reply landed on a thread you originated — "${threadId}"${re}, from ${replier}:\n\n ${text.trim()}\n\n` +
|
|
148
148
|
`This is a courtesy heads-up (you started this thread), not an assignment. Look if it concerns you; ` +
|
|
149
|
-
`\`spex
|
|
149
|
+
`\`spex issue ls --all\` lists them, reply with \`spex issue reply ${threadId} --body -\`.`
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// The pure fallback decision (testable without sessions.ts): walk the ordered chain (nulls/dupes/the-replier
|
|
153
153
|
// pruned) and return the FIRST link that resolves to an online session — that is who the courtesy goes to. A
|
|
154
154
|
// link already reached by an explicit @-target of this same text short-circuits to `reached` (stop, no
|
|
155
155
|
// double-delivery — the actor already has it); an offline/absent link falls through to the next. `none` means
|
|
156
|
-
// the chain ran dry (nobody online). This is the whole fallback logic; delivery is a thin
|
|
156
|
+
// the chain ran dry (nobody online). This is the whole fallback logic; delivery is a thin sendText around it.
|
|
157
157
|
export type LoopInPick =
|
|
158
158
|
| { kind: 'deliver'; originator: string; session: ActorSession }
|
|
159
159
|
| { kind: 'reached' }
|
|
@@ -177,7 +177,7 @@ export function pickLoopIn(
|
|
|
177
177
|
|
|
178
178
|
// `chain` is the ordered fallback list. We deliver the courtesy to the first online link and STOP; an
|
|
179
179
|
// offline/failed link falls through to the next. NOTIFICATION ONLY — this never touches a `resolved` bit
|
|
180
|
-
// (resolve is a deliberate `spex resolve`), never spawns (only `@new` spawns).
|
|
180
|
+
// (resolve is a deliberate `spex remark resolve`), never spawns (only `@new` spawns).
|
|
181
181
|
export async function notifyOriginator(
|
|
182
182
|
chain: (string | null)[],
|
|
183
183
|
replier: string,
|
|
@@ -186,10 +186,10 @@ export async function notifyOriginator(
|
|
|
186
186
|
): Promise<LoopIn | null> {
|
|
187
187
|
const seen = new Set<string>()
|
|
188
188
|
if (!chain.some((c) => c && c !== replier && !seen.has(c) && (seen.add(c), true))) return null // nothing to do → no session load
|
|
189
|
-
const {
|
|
189
|
+
const { sendText, listSessions } = await import('./sessions.js')
|
|
190
190
|
const pick = pickLoopIn(chain, replier, await listSessions() as unknown as ActorSession[], ctx.alreadyDelivered)
|
|
191
191
|
if (pick.kind !== 'deliver') return null // reached via @ / nobody online → silent
|
|
192
|
-
const res = await
|
|
192
|
+
const res = await sendText(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
|
|
193
193
|
return res.ok ? { originator: pick.originator } : null // a failed send behaves like offline: silent
|
|
194
194
|
}
|
|
195
195
|
|