@spexcode/spec-cli 0.6.7 → 0.6.8
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/bin/spex.mjs +56 -19
- package/dist/cli.js +102 -59
- package/dist/client.d.ts +1 -3
- package/dist/client.js +49 -30
- package/dist/codex-runtime-generations.d.ts +11 -0
- package/dist/codex-runtime-generations.js +46 -9
- package/dist/delivery-lock.d.ts +2 -0
- package/dist/delivery-lock.js +58 -0
- package/dist/doctor.js +53 -11
- package/dist/execution-trace.d.ts +1 -0
- package/dist/execution-trace.js +2 -2
- package/dist/gateway-hub.js +2 -1
- package/dist/gateway.js +6 -3
- package/dist/graphCache.js +32 -2
- package/dist/graphSnapshot.js +57 -2
- package/dist/graphStream.d.ts +2 -0
- package/dist/graphStream.js +83 -3
- package/dist/guide.js +20 -7
- package/dist/harness-select.js +16 -3
- package/dist/harness.d.ts +15 -3
- package/dist/harness.js +331 -50
- package/dist/help.js +11 -8
- package/dist/hook-prompts.js +8 -0
- package/dist/host-resources.js +29 -8
- package/dist/host.d.ts +7 -0
- package/dist/host.js +93 -0
- package/dist/index.js +324 -22
- package/dist/init.js +1 -1
- package/dist/lint.js +70 -35
- package/dist/listen.d.ts +3 -2
- package/dist/listen.js +14 -2
- package/dist/machine-peer.js +1 -1
- package/dist/materialize.d.ts +2 -2
- package/dist/materialize.js +176 -35
- package/dist/pty-bridge.js +14 -14
- package/dist/reviews.js +12 -7
- package/dist/runtime-ownership.d.ts +11 -0
- package/dist/runtime-ownership.js +79 -1
- package/dist/session-application.d.ts +23 -0
- package/dist/session-application.js +189 -0
- package/dist/session-declarations.js +13 -1
- package/dist/session-files.d.ts +6 -0
- package/dist/session-files.js +13 -1
- package/dist/session-follow.js +39 -22
- package/dist/session-record-lock.d.ts +3 -0
- package/dist/session-record-lock.js +94 -0
- package/dist/session-runtime-adapter.d.ts +44 -0
- package/dist/session-runtime-adapter.js +37 -0
- package/dist/session-timeline.d.ts +25 -2
- package/dist/session-timeline.js +68 -11
- package/dist/session-web.js +4 -4
- package/dist/sessions.d.ts +108 -15
- package/dist/sessions.js +1465 -744
- package/dist/source-list.d.ts +13 -0
- package/dist/source-list.js +99 -0
- package/dist/source-read.d.ts +16 -0
- package/dist/source-read.js +84 -0
- package/dist/spec-attachments.d.ts +7 -0
- package/dist/spec-attachments.js +89 -0
- package/dist/spec-body-edit.d.ts +23 -0
- package/dist/spec-body-edit.js +138 -0
- package/dist/supervise.js +15 -6
- package/dist/transcript-reader.d.ts +36 -0
- package/dist/transcript-reader.js +251 -0
- package/hooks/dispatch.sh +19 -31
- package/hooks/harness.sh +6 -6
- package/package.json +6 -6
- package/templates/hooks/post-checkout +4 -2
- package/templates/hooks/post-merge +2 -1
- package/templates/hooks/pre-commit +5 -3
- package/templates/hooks/reference-transaction +5 -3
- package/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/templates/spec/project/.plugins/core/spec.md +2 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/templates/spec/project/.plugins/spec.md +7 -0
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/hooks/compat/mark-active-sed-v0.fixture +0 -46
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, openSync, readFileSync, unlinkSync, writeSync, closeSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runtimeRoot } from '@spexcode/spec-core';
|
|
4
|
+
const lockRoot = () => join(runtimeRoot(), '.delivery-locks');
|
|
5
|
+
const lockPath = (id) => join(lockRoot(), `${id}.lock`);
|
|
6
|
+
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
7
|
+
async function acquire(id, timeoutMs) {
|
|
8
|
+
mkdirSync(lockRoot(), { recursive: true });
|
|
9
|
+
const path = lockPath(id), deadline = Date.now() + timeoutMs;
|
|
10
|
+
for (;;) {
|
|
11
|
+
try {
|
|
12
|
+
const fd = openSync(path, 'wx');
|
|
13
|
+
writeSync(fd, String(process.pid));
|
|
14
|
+
closeSync(fd);
|
|
15
|
+
return () => { try {
|
|
16
|
+
unlinkSync(path);
|
|
17
|
+
}
|
|
18
|
+
catch { /* a dead owner may have reclaimed it */ } };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code !== 'EEXIST')
|
|
22
|
+
throw error;
|
|
23
|
+
let owner = 0;
|
|
24
|
+
try {
|
|
25
|
+
owner = Number(readFileSync(path, 'utf8').trim()) || 0;
|
|
26
|
+
}
|
|
27
|
+
catch { /* creator/releaser race */ }
|
|
28
|
+
if (owner && owner !== process.pid) {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(owner, 0);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
try {
|
|
34
|
+
unlinkSync(path);
|
|
35
|
+
}
|
|
36
|
+
catch { /* race */ }
|
|
37
|
+
;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (Date.now() >= deadline)
|
|
42
|
+
throw new Error(`delivery queue ${id}: timed out waiting for transaction lock`);
|
|
43
|
+
await pause(25);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Serialize delivery claims without making the transport itself part of SQLite. */
|
|
48
|
+
export async function withDeliveryLocks(rawIds, body, index = 0, ids = [...new Set(rawIds)].sort()) {
|
|
49
|
+
if (index >= ids.length)
|
|
50
|
+
return body();
|
|
51
|
+
const release = await acquire(ids[index], 30_000);
|
|
52
|
+
try {
|
|
53
|
+
return await withDeliveryLocks(ids, body, index + 1, ids);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
release();
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -3,9 +3,10 @@ import { join, dirname, basename } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { loadSystemConfig, loadSkillConfig, loadSpecs } from '@spexcode/spec-core';
|
|
7
|
-
import {
|
|
6
|
+
import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadSpecs } from '@spexcode/spec-core';
|
|
7
|
+
import { treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from '@spexcode/spec-core';
|
|
8
8
|
import { loadConfig } from './lint.js';
|
|
9
|
+
import { GENERATED_MARK } from './harness.js';
|
|
9
10
|
import { trackedSourceFiles } from './source-files.js';
|
|
10
11
|
import { gitBinary } from '@spexcode/spec-core';
|
|
11
12
|
// this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
|
|
@@ -211,6 +212,25 @@ function manifestScripts(text) {
|
|
|
211
212
|
}
|
|
212
213
|
return [...out];
|
|
213
214
|
}
|
|
215
|
+
// which EVENTS the manifest actually binds. A tree can carry handlers and still be ungoverned in the way
|
|
216
|
+
// that matters, because a handler answers an event and the lifecycle rides on specific ones.
|
|
217
|
+
function manifestEvents(text) {
|
|
218
|
+
const out = new Set();
|
|
219
|
+
for (const line of text.split('\n')) {
|
|
220
|
+
const f = line.split('\t');
|
|
221
|
+
if (f.length >= 4 && f[0])
|
|
222
|
+
out.add(f[0]);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
// THE LIFECYCLE EVENTS, and why they are named here rather than derived from an adapter's full event list.
|
|
227
|
+
// A tree that binds no turn-end event has a shim, a manifest, and readable handlers — every check above it
|
|
228
|
+
// passes — and still cannot report that its agent stopped: the record keeps whatever state it last held and
|
|
229
|
+
// a dead turn still shows as running. That is not hypothetical; a deployment whose `.config` predated
|
|
230
|
+
// the stop-gate node hit exactly this, and nothing in the diagnosis said so. Stop is the gate; StopFailure
|
|
231
|
+
// is the failed-turn writer; PostToolUse is the freshness mark. Missing any of them is a distinct break
|
|
232
|
+
// from "no manifest", and it is the one a passing installation can hide.
|
|
233
|
+
const LIFECYCLE_EVENTS = ['Stop', 'StopFailure', 'PostToolUse'];
|
|
214
234
|
// the bundle's declared name, from plugin.json (root or the .claude-plugin/ convention), or null when none.
|
|
215
235
|
function pluginName(dir) {
|
|
216
236
|
for (const p of [join(dir, 'plugin.json'), join(dir, '.claude-plugin', 'plugin.json')]) {
|
|
@@ -263,6 +283,12 @@ async function doubleDeliveryReport(base) {
|
|
|
263
283
|
catch {
|
|
264
284
|
return [];
|
|
265
285
|
} })();
|
|
286
|
+
const ourAgents = (() => { try {
|
|
287
|
+
return loadAgentConfig().map((c) => c.name);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return [];
|
|
291
|
+
} })();
|
|
266
292
|
const L = [];
|
|
267
293
|
const line = (k, v) => L.push(` ${k.padEnd(16)}: ${v}`);
|
|
268
294
|
let conflict = false;
|
|
@@ -282,6 +308,13 @@ async function doubleDeliveryReport(base) {
|
|
|
282
308
|
...(looseDispatch ? [`loose ${rel(shimFile)}`] : []),
|
|
283
309
|
...bundles.filter((b) => b.hooksToDispatch).map((b) => `plugin "${b.name}" (${b.scope})`),
|
|
284
310
|
];
|
|
311
|
+
// a DIFFERENT collision from the double-delivery channels below: a skill/agent path a live spec node
|
|
312
|
+
// names, already occupied by a file the user wrote (no GENERATED_MARK). materialize skips those, so the
|
|
313
|
+
// node silently never reaches this harness — worth naming here, where somebody asks "do we clash?".
|
|
314
|
+
const userOwned = [
|
|
315
|
+
...(looseSkillDir ? ourSkills.map((s) => join(looseSkillDir, s, 'SKILL.md')) : []),
|
|
316
|
+
...((dir) => dir ? ourAgents.map((a) => join(dir, `${a}.md`)) : [])(h.agentDir(base)),
|
|
317
|
+
].filter((f) => existsSync(f) && !read(f).includes(GENERATED_MARK));
|
|
285
318
|
// channel 2 — same-named skill in loose skillDir AND a bundle's skills dir
|
|
286
319
|
const skillHits = [];
|
|
287
320
|
for (const s of ourSkills) {
|
|
@@ -302,6 +335,7 @@ async function doubleDeliveryReport(base) {
|
|
|
302
335
|
line(' plugin', `"${b.name}" (${b.scope}) — ${b.dir}`);
|
|
303
336
|
line('hooks→dispatch', `${hookSrc.length}${hookSrc.length > 1 ? ' (>1 → CONFLICT): ' + hookSrc.join(', ') : hookSrc.length === 1 ? ' (single — ok)' : ' (none wired)'}`);
|
|
304
337
|
line('skill shadowing', skillHits.length ? `CONFLICT: ${skillHits.join(', ')}` : 'none');
|
|
338
|
+
line('your files kept', userOwned.length ? `${userOwned.map(rel).join(', ')} — yours (no spexcode stamp), so the same-named spec node is NOT delivered here` : 'none');
|
|
305
339
|
L.push('');
|
|
306
340
|
}
|
|
307
341
|
if (conflict) {
|
|
@@ -423,31 +457,39 @@ async function doctor() {
|
|
|
423
457
|
const shim = read(h.shimFile(base));
|
|
424
458
|
line(`${h.id} shim`, /dispatch\.sh/.test(shim) ? `wired (${h.shimFile(base).replace(base + '/', '')})` : 'NOT wired (no dispatch shim)');
|
|
425
459
|
}
|
|
426
|
-
//
|
|
427
|
-
// (a pre-slot tree's migration-window fallback) — so the doctor reads exactly what a dispatch would.
|
|
460
|
+
// Manifest resolution mirrors dispatch.sh: only the current tree slot is executable.
|
|
428
461
|
let manifestText = '';
|
|
429
462
|
let manifestHome = 'tree slot';
|
|
430
463
|
try {
|
|
431
464
|
manifestText = read(join(treeSlotDir(base), 'hooks-manifest'));
|
|
432
465
|
}
|
|
433
466
|
catch { /* non-git / no store */ }
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
manifestText = read(join(runtimeRoot(base), 'hooks-manifest'));
|
|
437
|
-
manifestHome = 'legacy global file (pre-slot materialize — re-run `spex materialize`)';
|
|
438
|
-
}
|
|
439
|
-
catch { /* neither */ }
|
|
467
|
+
const manifestFile = (() => { try {
|
|
468
|
+
return join(treeSlotDir(base), 'hooks-manifest');
|
|
440
469
|
}
|
|
470
|
+
catch {
|
|
471
|
+
return null;
|
|
472
|
+
} })();
|
|
473
|
+
const manifestPresent = !!manifestFile && existsSync(manifestFile);
|
|
441
474
|
if (!manifestText) {
|
|
442
|
-
|
|
475
|
+
// ABSENT and EMPTY are different breaks and want different repairs, and the dispatcher cannot tell them
|
|
476
|
+
// apart at all: a missing file is a loud exit 78, an empty one dispatches nothing and exits 0 in silence.
|
|
477
|
+
line('manifest', manifestPresent
|
|
478
|
+
? 'EMPTY in the current tree slot — materialize ran and bound ZERO handlers: this tree has no hook nodes, so every event dispatches nothing and exits 0 in silence'
|
|
479
|
+
: 'MISSING from the current tree slot — materialize must run before hooks can execute');
|
|
443
480
|
}
|
|
444
481
|
else {
|
|
445
482
|
const scripts = manifestScripts(manifestText);
|
|
446
483
|
const missing = scripts.filter((s) => !existsSync(join(base, s)));
|
|
484
|
+
const events = manifestEvents(manifestText);
|
|
447
485
|
line('manifest', `${scripts.length} handler(s) in the ${manifestHome}`);
|
|
448
486
|
line('handlers', missing.length === 0 ? 'all readable in the worktree' : `${missing.length} MISSING in the worktree → those hooks SILENTLY NO-OP:`);
|
|
449
487
|
for (const m of missing)
|
|
450
488
|
L.push(` ✗ ${m}`);
|
|
489
|
+
const unbound = LIFECYCLE_EVENTS.filter((event) => !events.has(event));
|
|
490
|
+
line('lifecycle events', unbound.length === 0
|
|
491
|
+
? `${LIFECYCLE_EVENTS.join(', ')} all bound`
|
|
492
|
+
: `${unbound.join(', ')} NOT bound → this tree cannot report its own turn ending; the record keeps its last state and a stopped agent still shows as running`);
|
|
451
493
|
}
|
|
452
494
|
// codex trust
|
|
453
495
|
const trustPresent = codexCfg.includes(`# spexcode:trust:${base}`);
|
|
@@ -19,6 +19,7 @@ export type ExecutionTrace = Readonly<{
|
|
|
19
19
|
type ExportLoader = (threadId: string) => string;
|
|
20
20
|
export declare function codexRolloutPath(threadId: string, root?: string): string | null;
|
|
21
21
|
export declare function readCodexExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
|
|
22
|
+
export declare function claudeTranscriptPath(threadId: string, root?: string): string | null;
|
|
22
23
|
export declare function readProjectJsonlExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
|
|
23
24
|
export declare function readSessionJsonlExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
|
|
24
25
|
export declare function readLocalStoreExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string, load?: ExportLoader): ExecutionTrace;
|
package/dist/execution-trace.js
CHANGED
|
@@ -359,7 +359,7 @@ export function readCodexExecutionTrace(threadId, turn, root = codexSessionsDir(
|
|
|
359
359
|
return readIncremental(codexRolloutPath(threadId, root), applyRolloutEvent, turn);
|
|
360
360
|
}
|
|
361
361
|
const projectTranscriptRoot = () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
|
|
362
|
-
function
|
|
362
|
+
export function claudeTranscriptPath(threadId, root = projectTranscriptRoot()) {
|
|
363
363
|
for (const project of children(root)) {
|
|
364
364
|
const path = join(root, project, `${threadId}.jsonl`);
|
|
365
365
|
try {
|
|
@@ -371,7 +371,7 @@ function projectJsonlPath(threadId, root = projectTranscriptRoot()) {
|
|
|
371
371
|
return null;
|
|
372
372
|
}
|
|
373
373
|
export function readProjectJsonlExecutionTrace(threadId, turn, root = projectTranscriptRoot()) {
|
|
374
|
-
return readIncremental(
|
|
374
|
+
return readIncremental(claudeTranscriptPath(threadId, root), applyProjectJsonlEvent, turn);
|
|
375
375
|
}
|
|
376
376
|
const sessionJsonlRoot = () => join(process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent'), 'sessions');
|
|
377
377
|
function sessionJsonlPath(threadId, root = sessionJsonlRoot()) {
|
package/dist/gateway-hub.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// mounted, an explicit text/html GET gets the Projects UI instead
|
|
12
12
|
// PUT|DELETE /projects/admin-password admin: set/clear the admin password
|
|
13
13
|
// PUT|DELETE /projects/:id/password admin: set/clear one project's password
|
|
14
|
+
// DELETE /projects/:id host extension: high-friction catalog registration removal
|
|
14
15
|
// /p/:projectId/login|logout project session for that project
|
|
15
16
|
// ANY /p/:projectId/* (+ WS upgrade) authorized → proxied, prefix-stripped, to that project's backend
|
|
16
17
|
// Authorization never trusts the cookie's name or Path — the token's projectId claim is validated against
|
|
@@ -332,7 +333,7 @@ export function startHubGateway(opts) {
|
|
|
332
333
|
host: opts.host,
|
|
333
334
|
label: opts.label ?? 'hub gateway',
|
|
334
335
|
cleanup: opts.onBindFail,
|
|
335
|
-
ready: `[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${
|
|
336
|
+
ready: (actualPort) => `[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${actualPort} — /projects + /p/:projectId/*`,
|
|
336
337
|
});
|
|
337
338
|
return server;
|
|
338
339
|
}
|
package/dist/gateway.js
CHANGED
|
@@ -181,9 +181,12 @@ export function startGateway(opts) {
|
|
|
181
181
|
const scheme = secure ? 'https' : 'http';
|
|
182
182
|
const label = opts.label ?? 'public mode';
|
|
183
183
|
const gate = isLoopback ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}`;
|
|
184
|
-
const ready =
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
const ready = (port) => {
|
|
185
|
+
const lines = [...(opts.readyLines ?? []), `[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${port}${gate}, proxying /api to :${opts.upstreamPort}`];
|
|
186
|
+
if (!secure && !isLoopback && !opts.host)
|
|
187
|
+
lines.push('[gateway] (TLS off — --http)');
|
|
188
|
+
return lines;
|
|
189
|
+
};
|
|
187
190
|
// a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
|
|
188
191
|
// (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
|
|
189
192
|
listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, ready });
|
package/dist/graphCache.js
CHANGED
|
@@ -11,6 +11,7 @@ import { residentForgeState } from '@spexcode/spec-forge/resident';
|
|
|
11
11
|
import { resolveProjectIdentity } from '@spexcode/spec-core';
|
|
12
12
|
import { readReviewSnapshot } from '@spexcode/spec-core';
|
|
13
13
|
import { sessionEvalProjection } from '@spexcode/spec-eval/sessioneval';
|
|
14
|
+
import { resolveDatabasePath } from '@spexcode/session-selflaunch';
|
|
14
15
|
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1';
|
|
15
16
|
function textOrNull(path) {
|
|
16
17
|
try {
|
|
@@ -128,6 +129,28 @@ function strictSpecTreeRevision(wtPath) {
|
|
|
128
129
|
}
|
|
129
130
|
return parts.sort().join('\n');
|
|
130
131
|
}
|
|
132
|
+
// The canonical session database ([[production-cutin]]) is a board input exactly like the runtime envelope:
|
|
133
|
+
// lifecycle status lives there, and ANY process may commit to it — a hook's `spex internal session-state`, a CLI
|
|
134
|
+
// declaration, the backend's own routes. Its file identity folds into the session revision, so a commit no leaf
|
|
135
|
+
// pushed (graph-stream's `session-db` leaf held or disabled) is still a moved revision that the patrol answers
|
|
136
|
+
// with the sessions splice. journal_mode=delete rewrites the file in place on every commit, so this is the same
|
|
137
|
+
// strict stat contract as the `.spec` walk: ctime catches a same-size rewrite, ENOENT is a legitimate fresh
|
|
138
|
+
// store, and every other read failure is loud.
|
|
139
|
+
function strictFileRevision(path) {
|
|
140
|
+
try {
|
|
141
|
+
const stat = statSync(path);
|
|
142
|
+
return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code === 'ENOENT')
|
|
146
|
+
return '';
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function sessionDatabaseRevision() {
|
|
151
|
+
const databasePath = resolveDatabasePath();
|
|
152
|
+
return [databasePath, strictFileRevision(databasePath)];
|
|
153
|
+
}
|
|
131
154
|
function worktreeRevision(root) {
|
|
132
155
|
return {
|
|
133
156
|
root,
|
|
@@ -153,12 +176,17 @@ function sessionInputRevision() {
|
|
|
153
176
|
textOrNull(sessionRecordPath(id)),
|
|
154
177
|
textOrNull(sessionArtifactPath(id, 'prompt')),
|
|
155
178
|
]);
|
|
179
|
+
// The canonical database carries the lifecycle those envelopes no longer do, and a commit from ANY process
|
|
180
|
+
// moves it. It is an input only while a record exists to read it through: with no session records the board
|
|
181
|
+
// has no row that derives from the store, and the store's own birth — the first canonical access inside a
|
|
182
|
+
// build initializes it — must not read as an input that moved during that build.
|
|
183
|
+
const canonical = ids.length ? sessionDatabaseRevision() : null;
|
|
156
184
|
const projections = digest(ids.map((id) => [id, sessionEvalProjection(id)]));
|
|
157
185
|
const activeRoots = [...new Set(ids.flatMap((id) => {
|
|
158
186
|
const entry = readPublicRecordEntry(id);
|
|
159
187
|
return entry.kind === 'ok' && entry.raw.governed && !entry.raw.archived ? [entry.raw.worktree_path] : [];
|
|
160
188
|
}))].sort();
|
|
161
|
-
return { sessions: digest(sessionInputs), projections, projectionIds: ids, activeRoots };
|
|
189
|
+
return { sessions: digest([canonical, sessionInputs]), projections, projectionIds: ids, activeRoots };
|
|
162
190
|
}
|
|
163
191
|
function boardInputRevision(board) {
|
|
164
192
|
const root = repoRoot();
|
|
@@ -444,6 +472,8 @@ function startBuild(mode = 'dirty') {
|
|
|
444
472
|
});
|
|
445
473
|
// Give a stale HTTP response a turn to flush before the producer's synchronous setup occupies the event
|
|
446
474
|
// loop. Fresh callers simply absorb this small scheduling window while waiting on the same flight.
|
|
475
|
+
// A direct cold reader may be the only live work in a short-lived CLI process. Keep this one-shot
|
|
476
|
+
// scheduler referenced until it starts the producer, otherwise Node can exit with its fresh read pending.
|
|
447
477
|
setTimeout(() => {
|
|
448
478
|
if (controller.signal.aborted) {
|
|
449
479
|
rejectBuild(Object.assign(new Error('graph build aborted before start'), { name: 'AbortError' }));
|
|
@@ -547,7 +577,7 @@ function startBuild(mode = 'dirty') {
|
|
|
547
577
|
catch (error) {
|
|
548
578
|
rejectBuild(error);
|
|
549
579
|
}
|
|
550
|
-
}, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS)
|
|
580
|
+
}, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS);
|
|
551
581
|
const timeoutError = () => new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`);
|
|
552
582
|
// `settle` owns the real builder. The watchdog only rejects `wait`; the slot remains occupied until this
|
|
553
583
|
// promise settles, so a next read can never overlap an abandoned git/fs build.
|
package/dist/graphSnapshot.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { loadSpecs, requireGitWorkspace, headSha } from '@spexcode/spec-core';
|
|
2
4
|
import { resolveLayout } from '@spexcode/spec-core';
|
|
3
5
|
import { listSessions } from './sessions.js';
|
|
4
6
|
import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
|
|
@@ -9,6 +11,46 @@ import { buildBoard as assembleBoard, spliceSessions as spliceBoardSessions } fr
|
|
|
9
11
|
import { evalContext, evalTimelines } from '@spexcode/spec-eval/evaltab';
|
|
10
12
|
import { evalNodesAsync } from '@spexcode/spec-eval/scenarios';
|
|
11
13
|
import { sessionEvalProjections } from '@spexcode/spec-eval/sessioneval';
|
|
14
|
+
import { evalRemarkSourceFingerprint } from '@spexcode/spec-eval/host';
|
|
15
|
+
import { listBlobs } from '@spexcode/spec-eval/cache';
|
|
16
|
+
let timelineCache = null;
|
|
17
|
+
function fileFingerprint(path) {
|
|
18
|
+
try {
|
|
19
|
+
return readFileSync(path).toString('base64');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return '<missing>';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function timelineCacheKey(root, nodeIds, specs, evalNodes) {
|
|
26
|
+
const hash = createHash('sha256');
|
|
27
|
+
hash.update(root);
|
|
28
|
+
hash.update('\0');
|
|
29
|
+
hash.update(headSha(root) || '<no-head>');
|
|
30
|
+
hash.update('\0');
|
|
31
|
+
hash.update(JSON.stringify(nodeIds));
|
|
32
|
+
for (const spec of specs) {
|
|
33
|
+
hash.update('\0spec\0');
|
|
34
|
+
hash.update(spec.path);
|
|
35
|
+
hash.update('\0');
|
|
36
|
+
hash.update(spec.body);
|
|
37
|
+
}
|
|
38
|
+
for (const node of evalNodes) {
|
|
39
|
+
hash.update('\0eval\0');
|
|
40
|
+
hash.update(node.id);
|
|
41
|
+
hash.update('\0');
|
|
42
|
+
hash.update(node.evalSource ?? '');
|
|
43
|
+
hash.update('\0');
|
|
44
|
+
hash.update(fileFingerprint(node.sidecarPath));
|
|
45
|
+
}
|
|
46
|
+
// Issue remarks are an eval input even though they live outside the spec tree.
|
|
47
|
+
hash.update('\0remarks\0');
|
|
48
|
+
hash.update(evalRemarkSourceFingerprint());
|
|
49
|
+
// Evidence presence is part of each published timeline row (`present` vs `miss`).
|
|
50
|
+
hash.update('\0blobs\0');
|
|
51
|
+
hash.update(listBlobs().join('\0'));
|
|
52
|
+
return hash.digest('hex');
|
|
53
|
+
}
|
|
12
54
|
// The application adapter is the sole reader of runtime/forge state. graph.ts only receives this result.
|
|
13
55
|
export async function boardSnapshot() {
|
|
14
56
|
const root = repoRoot();
|
|
@@ -22,7 +64,20 @@ export async function boardSnapshot() {
|
|
|
22
64
|
const { issues, stamp: issuesStamp } = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds);
|
|
23
65
|
const [idx, hidx, evalNodes] = await Promise.all([driftIndex(root), historyIndex(root), evalNodesAsync(root)]);
|
|
24
66
|
const context = await evalContext(root, specs, idx, hidx, undefined, evalNodes);
|
|
25
|
-
const
|
|
67
|
+
const key = timelineCacheKey(root, nodeIds, specs, evalNodes);
|
|
68
|
+
let timelines;
|
|
69
|
+
if (timelineCache?.key === key) {
|
|
70
|
+
timelines = timelineCache.timelines;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// The board carries only latest-per-scenario counts, so the cold build reads latest-only: the retained
|
|
74
|
+
// sidecar history is served by the detail endpoints and probing it here scales graph latency with the
|
|
75
|
+
// reading count instead of the live verdict population. Freshness itself is NOT deferred — the board
|
|
76
|
+
// publishes verdicts, and a verdict whose freshness was never computed is not a stale verdict, it is no
|
|
77
|
+
// verdict at all. An order-only board would report every measured row as stale and none as fresh.
|
|
78
|
+
timelines = await evalTimelines(nodeIds, context, { latestOnly: true });
|
|
79
|
+
timelineCache = { key, timelines };
|
|
80
|
+
}
|
|
26
81
|
return {
|
|
27
82
|
root, specs, layout, sessions, issues, issuesStamp, forgeRevision: residentForgeRevision(),
|
|
28
83
|
evalTimelines: new Map(nodeIds.map((nodeId, index) => [nodeId, timelines[index]])),
|
package/dist/graphStream.d.ts
CHANGED
|
@@ -58,6 +58,8 @@ export type PendingGraphChanges = {
|
|
|
58
58
|
export declare const addPendingGraphChange: (pending: PendingGraphChanges, scope: Scope) => PendingGraphChanges;
|
|
59
59
|
export declare function isSessionCreateCandidateRegistryEvent(relativePath: string, candidatePaths: Iterable<string>): boolean;
|
|
60
60
|
export declare const notifyBoardChanged: (scope?: Scope) => void;
|
|
61
|
+
export declare const sessionDatabaseWatchIgnore: (databasePath: string) => ((relativePath: string) => boolean);
|
|
62
|
+
export declare function watchSessionDatabase(databasePath: string, onInput: () => void, onFailure: (error: Error) => void, watchFactory?: WatchFactory): TreeWatcherRegistry;
|
|
61
63
|
type RegistryGroup = {
|
|
62
64
|
root: string;
|
|
63
65
|
close(): void;
|
package/dist/graphStream.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { streamSSE } from 'hono/streaming';
|
|
2
2
|
import { watch, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join, dirname, relative, resolve, basename } from 'node:path';
|
|
4
|
-
import { sessionsRoot, gitCommonDir, repoRoot, sessionBranchIndex, mainBranch } from '@spexcode/spec-core';
|
|
4
|
+
import { sessionsRoot, gitCommonDir, repoRoot, sessionBranchIndex, mainBranch, isTrashWorktreePath } from '@spexcode/spec-core';
|
|
5
|
+
import { resolveDatabasePath } from '@spexcode/session-selflaunch';
|
|
5
6
|
import { hotSignature, warmSignature, listSessions, pendingSessionCreateWorktreePaths } from './sessions.js';
|
|
6
7
|
import { getBoard, getBoardForSessionRefresh, invalidateBoard, patrolBoard } from './graphCache.js';
|
|
7
8
|
import { unitize, tagOf, diffUnits } from '@spexcode/spec-core';
|
|
@@ -439,7 +440,7 @@ function fireChanged(scope = 'full', evalTarget) {
|
|
|
439
440
|
}
|
|
440
441
|
// ---- event source 0: an EXPLICIT server-side nudge ----
|
|
441
442
|
// for a server-side mutation that must show instantly regardless of watcher health: /rename writes the
|
|
442
|
-
// session's global
|
|
443
|
+
// session's global runtime envelope (`runtime.json` — [[session-rename]]), which lives INSIDE the watched store, so
|
|
443
444
|
// source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
|
|
444
445
|
// (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
|
|
445
446
|
// debounced funnel as every other source; defaults to 'full' but the rename route passes 'sessions'.
|
|
@@ -535,6 +536,80 @@ function ensureWatcher(root) {
|
|
|
535
536
|
}
|
|
536
537
|
noteSourceHealthy('store');
|
|
537
538
|
}
|
|
539
|
+
// ---- event source 1b: the canonical session database (lifecycle commits from ANY process) → 'sessions' ----
|
|
540
|
+
// Since the JSON cutover ([[production-cutin]]) a lifecycle transition is a SQLite commit, not a write inside the
|
|
541
|
+
// store above: that watch still sees the runtime envelope and the prompt, but the state a HOOK authors — mark-active
|
|
542
|
+
// on every prompt and tool call, the stop-gate's declarations, idle — is committed by the hook's OWN process through
|
|
543
|
+
// `spex internal session-state`. The backend's in-process commit observer (index.ts) bridges only its own commits,
|
|
544
|
+
// so without this leaf a hook-authored flip reached the board only when some unrelated signal happened to re-splice:
|
|
545
|
+
// a message sent from the dashboard left the row idle for minutes (measured on the dogfood board: 150s of nothing
|
|
546
|
+
// but pings after the commit). One NON-recursive watch on the database's directory, delivering only the database's
|
|
547
|
+
// own names — the file and its `-journal` (journal_mode=delete writes both on every commit); every other file in
|
|
548
|
+
// that directory is filtered out at delivery. Attach failure is held and repaired like every other source, and
|
|
549
|
+
// [[graph-cache]] folds the same file into its session revision so the patrol covers a held or disabled leaf.
|
|
550
|
+
let sessionDatabaseWatcher = null;
|
|
551
|
+
let activeDatabasePath = null;
|
|
552
|
+
const SESSION_DB_SOURCE = 'session-db';
|
|
553
|
+
export const sessionDatabaseWatchIgnore = (databasePath) => {
|
|
554
|
+
const name = basename(databasePath);
|
|
555
|
+
return (relativePath) => relativePath !== name && !relativePath.startsWith(`${name}-`);
|
|
556
|
+
};
|
|
557
|
+
export function watchSessionDatabase(databasePath, onInput, onFailure, watchFactory) {
|
|
558
|
+
return new TreeWatcherRegistry({
|
|
559
|
+
root: dirname(databasePath),
|
|
560
|
+
source: SESSION_DB_SOURCE,
|
|
561
|
+
scope: 'sessions',
|
|
562
|
+
recursive: false,
|
|
563
|
+
ignore: sessionDatabaseWatchIgnore(databasePath),
|
|
564
|
+
watchFactory,
|
|
565
|
+
onInput: () => onInput(),
|
|
566
|
+
onFailure,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
function closeSessionDatabaseWatcher() {
|
|
570
|
+
sessionDatabaseWatcher?.close();
|
|
571
|
+
sessionDatabaseWatcher = null;
|
|
572
|
+
activeDatabasePath = null;
|
|
573
|
+
}
|
|
574
|
+
function ensureSessionDatabaseWatcher() {
|
|
575
|
+
if (isDisabled(SESSION_DB_SOURCE)) {
|
|
576
|
+
closeSessionDatabaseWatcher();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
let databasePath;
|
|
580
|
+
try {
|
|
581
|
+
databasePath = resolveDatabasePath();
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
noteSourceFailure(SESSION_DB_SOURCE, error);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (sessionDatabaseWatcher && activeDatabasePath === databasePath)
|
|
588
|
+
return;
|
|
589
|
+
closeSessionDatabaseWatcher();
|
|
590
|
+
activeDatabasePath = databasePath;
|
|
591
|
+
if (!mayAttach(SESSION_DB_SOURCE))
|
|
592
|
+
return;
|
|
593
|
+
try {
|
|
594
|
+
mkdirSync(dirname(databasePath), { recursive: true });
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
console.error(`spec-cli: graph watcher '${SESSION_DB_SOURCE}' could not create ${dirname(databasePath)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
598
|
+
}
|
|
599
|
+
const registry = watchSessionDatabase(databasePath, () => fireChanged('sessions'), (error) => {
|
|
600
|
+
if (sessionDatabaseWatcher === registry)
|
|
601
|
+
sessionDatabaseWatcher = null;
|
|
602
|
+
noteSourceFailure(SESSION_DB_SOURCE, error);
|
|
603
|
+
fireChanged('sessions');
|
|
604
|
+
});
|
|
605
|
+
sessionDatabaseWatcher = registry;
|
|
606
|
+
if (!registry.refresh()) {
|
|
607
|
+
if (sessionDatabaseWatcher === registry)
|
|
608
|
+
sessionDatabaseWatcher = null;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
noteSourceHealthy(SESSION_DB_SOURCE);
|
|
612
|
+
}
|
|
538
613
|
let refsWatchers = null;
|
|
539
614
|
const REFS_OBSERVER = 'graph:refs';
|
|
540
615
|
// @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
|
|
@@ -657,13 +732,14 @@ const worktreeSource = (name) => `worktree:${name}`;
|
|
|
657
732
|
const PROJECT_ROOT_SOURCE = 'project-root';
|
|
658
733
|
let projectRootWatcher = null;
|
|
659
734
|
const ignoredWorktreePath = (file) => file.split(/[\\/]/).some((segment) => segment === '.git' || segment === 'node_modules');
|
|
735
|
+
const ignoredGeneratedBuildPath = (file) => file.split(/[\\/]/).some((segment) => segment === 'dist' || segment.startsWith('.dist-next-') || segment.startsWith('.dist-previous-'));
|
|
660
736
|
// @@@ linked worktrees are not graph input for THIS backend - the board's node statuses derive from the
|
|
661
737
|
// served checkout's own HEAD, so a file under `.worktrees/<node>` belongs to a different branch's tree and
|
|
662
738
|
// cannot move any status here until it lands and this HEAD advances. Watching them registers one inotify
|
|
663
739
|
// watch per directory (Linux takes the exact-directory transport) and buys nothing: measured on this repo,
|
|
664
740
|
// 20,124 of 20,473 watched directories were linked worktrees against 843 in the served tree.
|
|
665
741
|
// The per-worktree registries above keep their own roots; only the project-root sweep skips them.
|
|
666
|
-
export const ignoredProjectRootPath = (file) => ignoredWorktreePath(file) || file.split(/[\\/]/).some((segment) => segment === '.worktrees');
|
|
742
|
+
export const ignoredProjectRootPath = (file) => ignoredWorktreePath(file) || ignoredGeneratedBuildPath(file) || file.split(/[\\/]/).some((segment) => segment === '.worktrees');
|
|
667
743
|
// The directory whose tree this backend serves is graph input even before it has a `.spec` tree or any live
|
|
668
744
|
// session worktree. Keeping it in the same root registry as linked worktrees means a first `spex init` or
|
|
669
745
|
// agent-created spec invalidates a warmed empty board instead of leaving a confidently stale cache until a
|
|
@@ -807,6 +883,8 @@ async function reconcileWorktreePass(forcedSessions, era, common) {
|
|
|
807
883
|
catch {
|
|
808
884
|
continue;
|
|
809
885
|
}
|
|
886
|
+
if (isTrashWorktreePath(wtPath))
|
|
887
|
+
continue;
|
|
810
888
|
const normalizedPath = resolve(wtPath);
|
|
811
889
|
if (!wantedPaths.has(normalizedPath)) {
|
|
812
890
|
if (dropWorktreeWatcher(e.name))
|
|
@@ -1033,6 +1111,7 @@ export async function ensureBoardFileWatchers(forceSessionId) {
|
|
|
1033
1111
|
activeStoreRoot = storeRoot;
|
|
1034
1112
|
activeCommonRoot = commonRoot;
|
|
1035
1113
|
ensureWatcher(storeRoot);
|
|
1114
|
+
ensureSessionDatabaseWatcher();
|
|
1036
1115
|
ensureRefsWatcher(commonRoot);
|
|
1037
1116
|
await ensureWorktreeRegistry(forceSessionId);
|
|
1038
1117
|
ensureProjectRootWatcher();
|
|
@@ -1055,6 +1134,7 @@ export function closeBoardFileWatchers() {
|
|
|
1055
1134
|
worktreeReconcileFlight = null;
|
|
1056
1135
|
storeWatcher?.close();
|
|
1057
1136
|
storeWatcher = null;
|
|
1137
|
+
closeSessionDatabaseWatcher();
|
|
1058
1138
|
refsWatchers?.close();
|
|
1059
1139
|
refsWatchers = null;
|
|
1060
1140
|
registryWatcher?.close();
|
package/dist/guide.js
CHANGED
|
@@ -71,8 +71,9 @@ FRONTMATTER (YAML between the opening and closing --- lines; every field optiona
|
|
|
71
71
|
duplicates, globs/directories with a selector, and dead/ambiguous units all error loud. A
|
|
72
72
|
selector-scoped governor claims units, not the file, so it stays out of the \`owners\` bound
|
|
73
73
|
(spex spec owner still displays it, marked "(scoped)"). Anchors are optional.
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
Tree-sitter-backed \`.ts\`/\`.tsx\`/\`.py\`/\`.go\`/\`.rs\`/\`.java\`/\`.rb\` anchors recognize
|
|
75
|
+
structural function, method, class, interface, enum, and type declarations. Methods and nested
|
|
76
|
+
declarations use lexical qualified names such as
|
|
76
77
|
\`Class.method\`, \`outer.inner\`, or \`Outer.Inner.method\`; attached decorators belong to
|
|
77
78
|
the declaration's range. Runtime-created/assigned callables, imported aliases, and generated
|
|
78
79
|
names are outside this declaration extractor and therefore resolve as dead anchors.
|
|
@@ -546,8 +547,15 @@ behavior, decided per KIND (and, for a contract file, by its live CONTENT).
|
|
|
546
547
|
── THE FOUR KINDS (all fixed) ──
|
|
547
548
|
spec data .spec/ (incl .plugins/) + spexcode.json — ALWAYS tracked. Git is the database; there is
|
|
548
549
|
deliberately NO way to say "untrack the spec" in this schema.
|
|
549
|
-
|
|
550
|
-
|
|
550
|
+
(no delivery) \`spex init --harness none\` ("harnesses": []) adopts the spec tree, the lint and the git
|
|
551
|
+
hooks and writes NOTHING into any agent's config — the L0-only footprint.
|
|
552
|
+
machine facts spexcode.local.json, the hook shims, plugin bundles — NEVER tracked; always in the
|
|
553
|
+
per-clone exclude. A shim is a machine fact only while it is WHOLLY OURS: where the harness
|
|
554
|
+
discovers its hooks in a file that is ALSO your project config (.claude/settings.json,
|
|
555
|
+
.codex/hooks.json, .zcode/settings.json), SpexCode co-owns only its own hook entries —
|
|
556
|
+
your permissions/env/statusLine/hooks are merged around, never replaced, and uninstall
|
|
557
|
+
takes back exactly those entries. Such a file stays visible to git (hiding yours would be
|
|
558
|
+
data-loss shaped), so keep our absolute toolchain paths out of your commits.
|
|
551
559
|
artifacts the CLAUDE.md/AGENTS.md contract blocks + materialized skills/agents — derived, NEVER
|
|
552
560
|
tracked; hidden via .git/info/exclude. The host's tracked .gitignore is never touched.
|
|
553
561
|
run residue .worktrees/, the global store (~/.spexcode), .git/spexcode evidence — never tracked;
|
|
@@ -621,15 +629,20 @@ Use the session's file list when an artifact belongs in the human's hands:
|
|
|
621
629
|
spex session files retract <path> withdraw one path
|
|
622
630
|
|
|
623
631
|
Posting resolves a relative path from your current directory and records its absolute path beside the global
|
|
624
|
-
session record. It copies, moves, stages, and uploads NOTHING. The path is live: editing the file after
|
|
632
|
+
session record only after confirming it is a readable regular file. It copies, moves, stages, and uploads NOTHING. The path is live: editing the file after
|
|
625
633
|
posting changes what the human downloads. The reference is host-local; opening the session elsewhere cannot
|
|
626
634
|
make its path point at another machine's file.
|
|
627
635
|
|
|
636
|
+
Put raw run artifacts in a persistent directory OUTSIDE the product repository by default. A worktree artifact
|
|
637
|
+
makes merge readiness report a dirty tree and pressures generated evidence into the product commit. Before review,
|
|
638
|
+
run \`spex session files ls\`: a target that disappeared or became unreadable is printed as \`INVALID\` and must be
|
|
639
|
+
recreated or retracted; a valid path prints normally.
|
|
640
|
+
|
|
628
641
|
The session page's top-right files icon is grey while the list is empty. Once live, it opens the posted list;
|
|
629
642
|
choosing a path previews its current text or raster-image bytes in a pop-out, while the adjacent download tool
|
|
630
643
|
downloads it through the backend at that moment. Previews are limited to 2 MiB, text and PNG/JPEG/GIF/WebP;
|
|
631
|
-
other types and larger files say to download instead. A missing, moved, or unreadable target stays listed
|
|
632
|
-
reports that it no longer exists. The backend refuses a preview or download for any path not on that session's
|
|
644
|
+
other types and larger files say to download instead. A missing, moved, or unreadable target stays listed and is
|
|
645
|
+
marked invalid by the CLI; preview/download reports that it no longer exists. The backend refuses a preview or download for any path not on that session's
|
|
633
646
|
list.
|
|
634
647
|
|
|
635
648
|
This is the reverse of a dashboard attachment: [[file-attach]] sends human bytes to an agent. Files publishes
|