spexcode 0.5.7 → 0.5.9
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/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +199 -60
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +42 -30
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
package/spec-cli/src/layout.ts
CHANGED
|
@@ -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
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText } from './git.js'
|
|
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,
|
|
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 }
|
|
@@ -321,25 +321,28 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
321
321
|
// silent for either relation: a dead or ambiguous selector, a selector on a directory, and an
|
|
322
322
|
// unparseable working-tree file ERROR. An extension with no designated extractor, or a designated
|
|
323
323
|
// extractor that cannot run here, also ERRORS but skips those anchors so the remaining checks continue.
|
|
324
|
+
type AnchorLintQuery = { id: string; version: number; relation: 'code' | 'related'; path: string; symbols: string[]; win: DriftPathEvent[] }
|
|
325
|
+
type AnchorLintStep = { finding: Finding } | { query: AnchorLintQuery }
|
|
324
326
|
const readyWarned = new Set<string>()
|
|
327
|
+
const anchorSteps: AnchorLintStep[] = []
|
|
325
328
|
for (const s of specs) {
|
|
326
329
|
for (const { relation, entries } of [{ relation: 'code' as const, entries: s.codeScoped }, { relation: 'related' as const, entries: s.relatedScoped }]) {
|
|
327
330
|
for (const { path, selectors } of entries) {
|
|
328
331
|
if (pending && !changed.some((file) => relationClaimsPath(path, file))) continue
|
|
329
332
|
const x = extractorFor(regs, extOf(path))
|
|
330
333
|
if (!x) {
|
|
331
|
-
|
|
334
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — anchor validation was skipped and remains unverified; add a LangSpec row (anchors.ts) or drop the selector(s)` } })
|
|
332
335
|
continue
|
|
333
336
|
}
|
|
334
337
|
const ready = x.ready()
|
|
335
338
|
if (ready !== true) {
|
|
336
339
|
// once per (extractor, reason), even across several anchored nodes — one repair, one message.
|
|
337
|
-
if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready);
|
|
340
|
+
if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); anchorSteps.push({ finding: { level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` } }) }
|
|
338
341
|
continue
|
|
339
342
|
}
|
|
340
343
|
if (!existsAtTip(path)) continue // the missing FILE already errored above
|
|
341
344
|
if (isDirectoryAtTip(path)) {
|
|
342
|
-
|
|
345
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` } })
|
|
343
346
|
continue
|
|
344
347
|
}
|
|
345
348
|
let units
|
|
@@ -348,51 +351,60 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
348
351
|
if (source === null) throw new Error(`candidate tree has no file '${path}'`)
|
|
349
352
|
units = x.extract(source, path)
|
|
350
353
|
} catch (e: any) {
|
|
351
|
-
|
|
354
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` } })
|
|
352
355
|
continue
|
|
353
356
|
}
|
|
354
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.
|
|
355
360
|
const live: string[] = []
|
|
356
|
-
for (const
|
|
357
|
-
const
|
|
361
|
+
for (const res of resolveSelectors(units, selectors)) {
|
|
362
|
+
const sym = res.selector
|
|
358
363
|
if ('dead' in res) {
|
|
359
|
-
|
|
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` } })
|
|
360
365
|
continue
|
|
361
366
|
}
|
|
362
367
|
if ('ambiguous' in res) {
|
|
363
|
-
|
|
368
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `ambiguous anchor: ${path}#${sym} ('${s.id}') names ${res.ambiguous} same-named units in one file — an anchor must be unique; rename one unit` } })
|
|
364
369
|
continue
|
|
365
370
|
}
|
|
366
371
|
if (res.ok.typeOnly)
|
|
367
|
-
|
|
372
|
+
anchorSteps.push({ finding: { level: 'warn', rule: 'anchor', spec: s.id, file: path, msg: `${path}#${sym} anchors a ${res.ok.kind} — anchoring a type is usually wrong (types reshape with every refactor); anchor the behaviour-bearing unit instead` } })
|
|
368
373
|
live.push(sym)
|
|
369
374
|
}
|
|
370
375
|
if (!live.length) continue
|
|
371
376
|
const since = rowsFor(hidx, s.path)[0]?.hash || ''
|
|
372
377
|
const win = windowEvents(didx, since, path, s.id)
|
|
373
378
|
if (!win.length) continue
|
|
374
|
-
|
|
375
|
-
if (!hits.length) continue
|
|
376
|
-
const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
|
|
377
|
-
const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
|
|
378
|
-
const unparseable = hits.filter((h) => h.unparseable)
|
|
379
|
-
const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
|
|
380
|
-
if (relation === 'code') {
|
|
381
|
-
const current = pending && hits.some((hit) => hit.commit === tip)
|
|
382
|
-
const older = pending && hits.some((hit) => hit.commit !== tip)
|
|
383
|
-
const remedy = !pending
|
|
384
|
-
? `update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds`
|
|
385
|
-
: current && older
|
|
386
|
-
? `update the spec in this commit; its own hit can be declared by retrying with 'git commit --trailer "Spec-OK: ${s.id}" …', but the listed older debt must be cleared first (an in-commit declaration never pardons ancestors)`
|
|
387
|
-
: current
|
|
388
|
-
? `update the spec in this commit, or retry with 'git commit --trailer "Spec-OK: ${s.id}" …'; a later empty ack cannot pre-author this candidate`
|
|
389
|
-
: `update the spec in this commit, or clear this older debt with 'spex spec ack ${s.id} --reason "…"' before retrying the candidate`
|
|
390
|
-
out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: ${remedy}` })
|
|
391
|
-
} else
|
|
392
|
-
out.push({ level: 'warn', rule: 'related-drift', spec: s.id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${s.id}') was changed by ${hits.length} commit(s) since v${s.version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
|
|
379
|
+
anchorSteps.push({ query: { id: s.id, version: s.version, relation, path, symbols: live, win } })
|
|
393
380
|
}
|
|
394
381
|
}
|
|
395
382
|
}
|
|
383
|
+
const anchorHits = await anchorHitQueries(root, anchorSteps.flatMap((step) => 'query' in step ? [{ win: step.query.win, symbols: step.query.symbols }] : []), regs)
|
|
384
|
+
let hitIndex = 0
|
|
385
|
+
for (const step of anchorSteps) {
|
|
386
|
+
if ('finding' in step) { out.push(step.finding); continue }
|
|
387
|
+
const { id, version, relation, path } = step.query
|
|
388
|
+
const hits = anchorHits[hitIndex++]
|
|
389
|
+
if (!hits.length) continue
|
|
390
|
+
const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
|
|
391
|
+
const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
|
|
392
|
+
const unparseable = hits.filter((h) => h.unparseable)
|
|
393
|
+
const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
|
|
394
|
+
if (relation === 'code') {
|
|
395
|
+
const current = pending && hits.some((hit) => hit.commit === tip)
|
|
396
|
+
const older = pending && hits.some((hit) => hit.commit !== tip)
|
|
397
|
+
const remedy = !pending
|
|
398
|
+
? `update the spec, or 'spex spec ack ${id} --reason "…"' if the contract still holds`
|
|
399
|
+
: current && older
|
|
400
|
+
? `update the spec in this commit; its own hit can be declared by retrying with 'git commit --trailer "Spec-OK: ${id}" …', but the listed older debt must be cleared first (an in-commit declaration never pardons ancestors)`
|
|
401
|
+
: current
|
|
402
|
+
? `update the spec in this commit, or retry with 'git commit --trailer "Spec-OK: ${id}" …'; a later empty ack cannot pre-author this candidate`
|
|
403
|
+
: `update the spec in this commit, or clear this older debt with 'spex spec ack ${id} --reason "…"' before retrying the candidate`
|
|
404
|
+
out.push({ level: 'error', rule: 'anchor-drift', spec: id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${id}' v${version} [${shas}]${parseNote} — the anchored contract's code moved: ${remedy}` })
|
|
405
|
+
} else
|
|
406
|
+
out.push({ level: 'warn', rule: 'related-drift', spec: id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${id}') was changed by ${hits.length} commit(s) since v${version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
|
|
407
|
+
}
|
|
396
408
|
|
|
397
409
|
// drift: a governed file has commits NOT yet reflected in its spec. Judged by true git ancestry —
|
|
398
410
|
// loadSpecs computes `driftFiles` via driftFor() over the one cached driftIndex walk (git.ts): a
|
|
@@ -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
|
|
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)
|
|
360
|
-
//
|
|
361
|
-
|
|
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
|
-
|
|
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
|
|
371
|
+
): Promise<{ thread: Issue }> {
|
|
414
372
|
const thread = openIssue(concern, { nodes: opts.nodes, body: opts.body, evidence: opts.evidence, author: opts.author })
|
|
415
|
-
|
|
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 @-
|
|
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;
|
|
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
|
|
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,
|
|
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' && !
|
|
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
|
+
}
|