@spexcode/spec-cli 0.6.6 → 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.
Files changed (88) hide show
  1. package/bin/spex.mjs +56 -19
  2. package/dist/cli.js +102 -59
  3. package/dist/client.d.ts +1 -3
  4. package/dist/client.js +49 -30
  5. package/dist/codex-runtime-generations.d.ts +11 -0
  6. package/dist/codex-runtime-generations.js +46 -9
  7. package/dist/delivery-lock.d.ts +2 -0
  8. package/dist/delivery-lock.js +58 -0
  9. package/dist/doctor.js +53 -11
  10. package/dist/execution-trace.d.ts +1 -0
  11. package/dist/execution-trace.js +2 -2
  12. package/dist/gateway-hub.js +2 -1
  13. package/dist/gateway.js +6 -3
  14. package/dist/graphCache.js +32 -2
  15. package/dist/graphSnapshot.js +57 -2
  16. package/dist/graphStream.d.ts +2 -0
  17. package/dist/graphStream.js +83 -3
  18. package/dist/guide.js +20 -7
  19. package/dist/harness-select.js +16 -3
  20. package/dist/harness.d.ts +15 -3
  21. package/dist/harness.js +331 -50
  22. package/dist/help.js +11 -8
  23. package/dist/hook-prompts.js +8 -0
  24. package/dist/host-resources.js +29 -8
  25. package/dist/host.d.ts +7 -0
  26. package/dist/host.js +93 -0
  27. package/dist/index.js +324 -22
  28. package/dist/init.js +1 -1
  29. package/dist/lint.js +70 -35
  30. package/dist/listen.d.ts +3 -2
  31. package/dist/listen.js +14 -2
  32. package/dist/machine-peer.js +1 -1
  33. package/dist/materialize.d.ts +2 -2
  34. package/dist/materialize.js +176 -35
  35. package/dist/pty-bridge.js +14 -14
  36. package/dist/reviews.js +12 -7
  37. package/dist/runtime-ownership.d.ts +11 -0
  38. package/dist/runtime-ownership.js +79 -1
  39. package/dist/session-application.d.ts +23 -0
  40. package/dist/session-application.js +189 -0
  41. package/dist/session-declarations.js +13 -1
  42. package/dist/session-files.d.ts +6 -0
  43. package/dist/session-files.js +13 -1
  44. package/dist/session-follow.js +39 -22
  45. package/dist/session-record-lock.d.ts +3 -0
  46. package/dist/session-record-lock.js +94 -0
  47. package/dist/session-runtime-adapter.d.ts +44 -0
  48. package/dist/session-runtime-adapter.js +37 -0
  49. package/dist/session-timeline.d.ts +25 -2
  50. package/dist/session-timeline.js +68 -11
  51. package/dist/session-web.js +4 -4
  52. package/dist/sessions.d.ts +108 -15
  53. package/dist/sessions.js +1465 -744
  54. package/dist/source-list.d.ts +13 -0
  55. package/dist/source-list.js +99 -0
  56. package/dist/source-read.d.ts +16 -0
  57. package/dist/source-read.js +84 -0
  58. package/dist/spec-attachments.d.ts +7 -0
  59. package/dist/spec-attachments.js +89 -0
  60. package/dist/spec-body-edit.d.ts +23 -0
  61. package/dist/spec-body-edit.js +138 -0
  62. package/dist/supervise.js +15 -6
  63. package/dist/transcript-reader.d.ts +36 -0
  64. package/dist/transcript-reader.js +251 -0
  65. package/hooks/dispatch.sh +19 -31
  66. package/hooks/harness.sh +6 -6
  67. package/package.json +6 -6
  68. package/templates/hooks/post-checkout +4 -2
  69. package/templates/hooks/post-merge +2 -1
  70. package/templates/hooks/pre-commit +5 -3
  71. package/templates/hooks/reference-transaction +5 -3
  72. package/templates/spec/project/.plugins/commands/spec.md +2 -7
  73. package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  74. package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  75. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  76. package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  77. package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  78. package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  79. package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  80. package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  81. package/templates/spec/project/.plugins/core/spec.md +2 -0
  82. package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  83. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  84. package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  85. package/templates/spec/project/.plugins/skills/spec.md +2 -6
  86. package/templates/spec/project/.plugins/spec.md +7 -0
  87. package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  88. package/hooks/compat/mark-active-sed-v0.fixture +0 -46
package/dist/help.js CHANGED
@@ -38,7 +38,7 @@ The successful receipt names what to read, monitor, and reply on. --ssh uses an
38
38
  communication tunnel: its full id anchors the remote project, creation stays parentless and remote, and its
39
39
  prompt carries a runnable reply path over that same tunnel.`, ['project-bound']],
40
40
  ls: [['spex session ls [SEL…] [--children[=<PARENT-SEL>]] [--status a,b] [--all] [--json]', 'spex session ls --ssh <address> <FULL-SESSION-ID> [--children=<PARENT-SEL>] [--status a,b] [--json]'],
41
- 'One-shot table of this project\'s live sessions, with each direct parent beside the row. --children scopes it to the caller\'s direct children; --children=<PARENT-SEL> names another parent without changing positional selector grammar. The heading summarizes the displayed scope by status. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it. An explicit id missing from the session list is diagnosed from terminal-close history: closed is a successful answer, while no live, archived, or closed history is a named miss. --ssh uses an existing gateway-to-gateway communication tunnel; its full id anchors one remote project rather than filtering the table, and archive projection stays unavailable on that peer route.', ['selector']],
41
+ 'One-shot table of this project\'s session records, with each direct parent beside the row. --children scopes it to the caller\'s direct children; --children=<PARENT-SEL> names another parent without changing positional selector grammar. The heading summarizes the displayed scope by status. Closed records are hidden from the working projection; --all includes them, and naming one explicitly always shows it. A missing id is a loud record miss. --ssh uses an existing gateway-to-gateway communication tunnel; its full id anchors one remote project rather than filtering the table.', ['selector']],
42
42
  resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
43
43
  files: [['spex session files add <path>', 'spex session files ls', 'spex session files retract <path>'],
44
44
  'Publish, list, or withdraw YOUR session’s live file paths. Posting stores an absolute path beside the session record without copying bytes; the dashboard downloads it only when the human clicks.'],
@@ -72,16 +72,14 @@ provably cannot land.`, ['selector', 'project-bound']],
72
72
  existing gateway-to-gateway communication tunnel and requires a full session id; live-pane capture stays local.`, ['selector']],
73
73
  resume: ['spex session resume <SEL> [--force]', 'Relaunch ONLY if confirmed offline; --force is for a wedged session.', ['selector', 'project-bound']],
74
74
  stop: ['spex session stop <SEL>', 'Soft stop: kill the exact agent and KEEP the worktree resumable.', ['selector', 'project-bound']],
75
- archive: ['spex session archive <SEL>', 'Cold-archive it: exact leaf/runtime stopped, worktree and conversation kept.', ['selector']],
76
- unarchive: ['spex session unarchive <SEL>', 'Deprecated compatibility spelling: same behavior as resume, relaunching the same conversation.', ['selector']],
77
- close: [['spex session close <SEL>', 'spex session close --ssh <address> <FULL-SESSION-ID>'], `Retire ANOTHER session — one you dispatched — deleting its worktree, branch and record.
75
+ close: [['spex session close <SEL>', 'spex session close --ssh <address> <FULL-SESSION-ID>'], `Close ANOTHER session — one you dispatched — after an exact cold stop. Dirty work is committed to refs/spex-archive/<id>; only the worktree is removed. The branch, record, transcript, and conversation remain available to resume.
78
76
  <SEL> names that session; it is never \`.\` and never your own id. Closing yourself deletes the worktree
79
- you are running in, mid-turn. Your own ending is a declaration: \`done --propose close\`. --ssh uses an existing
77
+ you are running in, mid-turn. A live turn is refused; stop it first. Your own ending is a declaration: \`done --propose close\`. --ssh uses an existing
80
78
  gateway-to-gateway communication tunnel and requires a full session id.`, ['selector', 'project-bound']],
81
79
  quarantine: ['spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <id> --worktree <absent-path> --branch <absent-branch> [--restore]',
82
80
  'Move only an unreadable record after the backend proves every named residue absent. --thread is an adapter-native conversation id, never the SpexCode session id; omit it for Claude. Quarantine and --restore both require the original exact id because corrupt rows are outside selectors.', ['project-bound']],
83
81
  done: ['spex session done --propose merge|nothing|close [--note T]',
84
- '`merge` declares review: committed work ready for human review, and it is the ONLY declaration that offers a clickable merge. `nothing` is an intended trap: it writes no state and sends the agent to the true merge, close, ask, or park destination. `close` declares close-pending only for settled work: discardable worktree and no outstanding human decision, follow-up, or inspection. The human closes it. This declaration is how a session ends itself; never run `session close` on your own id.'],
82
+ '`merge` declares review: committed work ready for human review and is the ONLY declaration that offers a clickable merge. `nothing` is an intended trap: it writes no state and sends the agent to the true merge, close, ask, or park destination. `close` declares close-pending only for settled work: discardable worktree and no outstanding human decision, follow-up, or inspection. The human closes it. This declaration is how a session ends itself; never run `session close` on your own id.'],
85
83
  park: ['spex session park --note <what-you-await>',
86
84
  'Declare parked only when a managed watch delivery or real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
87
85
  ask: ['spex session ask --note <what-you-await>',
@@ -92,7 +90,7 @@ LOCAL-only (fails loud on a remote backend); show --capture and send are non-int
92
90
  }
93
91
  const SESSION_HELP_GROUPS = [
94
92
  { title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge', 'reparent'] },
95
- { title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'quarantine'] },
93
+ { title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'close', 'quarantine'] },
96
94
  { title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask', 'files', 'web'] },
97
95
  { title: 'Human escape hatch', verbs: ['attach'] },
98
96
  ];
@@ -150,7 +148,11 @@ Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .plug
150
148
  spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
151
149
  shims). --harness is REQUIRED — the explicit choice of which harnesses materialize delivers into
152
150
  (stamped as spexcode.json "harnesses"; only their launchers are seeded); a pre-existing "harnesses"
153
- field satisfies it. Additive never overwrites your files. --preset picks the .plugins plugin tier (cumulative).
151
+ field satisfies it; --harness none delivers into NO harness (spec tree + lint + git hooks only, nothing
152
+ written into any agent's config). Additive: it never replaces a file you own — where a harness discovers its
153
+ hooks in your own config (.claude/settings.json, …) only SpexCode's entries are merged in and out, and a
154
+ skill/agent name you already use is skipped and reported rather than overwritten.
155
+ --preset picks the .plugins plugin tier (cumulative).
154
156
  Footprint is fixed: materialized artifacts are never tracked — hidden via the per-clone .git/info/exclude, with
155
157
  a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex guide footprint).`,
156
158
  see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex spec lint (adoption TODO)',
@@ -473,6 +475,7 @@ Machine plumbing — called by generated hooks and launch scripts, never typed b
473
475
  session-state <st> --session <id> a lifecycle hook authors the session's state
474
476
  session-fail --session <id> the StopFailure hook marks the session errored
475
477
  session-idle --session <id> the idle-prompt hook marks an active session idle
478
+ session-hook-state --session <id> hook-only canonical lifecycle read (tab-separated)
476
479
  commit-gate the Stop gate's deterministic commit check (exit 0 = ready to declare done)
477
480
  hook-prompt <name> render canonical model-facing text for a runtime hook
478
481
  nudge <node> the post-merge hook prints the issue nudge for a merged node
@@ -58,6 +58,14 @@ const DEFINITIONS = Object.freeze([
58
58
  content: NO_PROMPT('session-fail', 'It records a failed governed turn as error.'),
59
59
  render: () => '',
60
60
  },
61
+ {
62
+ name: 'session-listen',
63
+ title: 'session-listen',
64
+ description: 'Lifecycle delivery signal; runtime adopter messages are delivered at harness input boundaries.',
65
+ role: 'signal',
66
+ content: NO_PROMPT('session-listen', 'It delivers adopter-owned queued messages as runtime context when configured.'),
67
+ render: () => '',
68
+ },
61
69
  {
62
70
  name: 'spec-first',
63
71
  title: 'spec-first',
@@ -4,11 +4,12 @@ import { randomUUID } from 'node:crypto';
4
4
  import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { defaultHarness, HARNESSES, harnessById, harnessByIdOrNull, sessionIdentityEnvVars } from './harness.js';
7
- import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, readRawRecord, runtimeRoot } from '@spexcode/spec-core';
7
+ import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, runtimeRoot } from '@spexcode/spec-core';
8
8
  import { repoRoot } from '@spexcode/spec-core';
9
9
  import { endpointRecordPath } from './host.js';
10
10
  import { detachedRuntimeGenerationToken, parseProcStat, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
11
11
  import { readBackendInstanceRecords } from './runtime-ownership.js';
12
+ import { configuredSessionApplicationIfCutover } from './session-application.js';
12
13
  const DEFAULTS = {
13
14
  sessionRssMiB: 1024,
14
15
  backendRssMiB: 2048,
@@ -146,20 +147,40 @@ const descendants = (root, procs) => {
146
147
  };
147
148
  const publicRecordInventory = () => {
148
149
  const entries = listSessionIds().map(readPublicRecordEntry);
150
+ const application = configuredSessionApplicationIfCutover();
149
151
  const byId = new Map();
150
- for (const entry of entries) {
152
+ const projected = entries.map((entry) => {
153
+ if (entry.kind !== 'ok' || !application || !entry.raw.governed)
154
+ return entry;
155
+ const state = application.readState(entry.raw.session_id);
156
+ if (!state)
157
+ throw new ResourceConflict(`session ${entry.raw.session_id} has no canonical application state after JSON cutover`);
158
+ return {
159
+ ...entry,
160
+ raw: {
161
+ ...entry.raw,
162
+ status: state.status,
163
+ proposal: state.proposal,
164
+ note: state.note,
165
+ parent: state.parentSessionId,
166
+ },
167
+ };
168
+ });
169
+ for (const entry of projected) {
151
170
  if (entry.kind === 'ok')
152
171
  byId.set(entry.raw.session_id, entry);
153
172
  else if (entry.kind === 'corrupt')
154
173
  byId.set(entry.sessionId, entry);
155
174
  }
156
175
  return {
157
- entries,
158
- records: entries.flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : []),
176
+ entries: projected,
177
+ records: projected.flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : []),
159
178
  byId,
160
179
  };
161
180
  };
162
- const rawRecords = () => listSessionIds().map(readRawRecord).filter((r) => !!r);
181
+ // Resource ownership must use the same lifecycle projection as the board. The raw reader remains for
182
+ // ungoverned/runtime identity discovery, but governed rows come from the canonical inventory above.
183
+ const rawRecords = () => publicRecordInventory().records;
163
184
  const runtimePid = (file) => {
164
185
  try {
165
186
  const pid = Number(readFileSync(file, 'utf8').trim());
@@ -374,9 +395,9 @@ const ownerTotals = (processes) => ({
374
395
  cpuPercent: Math.round(processes.reduce((n, p) => n + p.cpuPercent, 0) * 10) / 10,
375
396
  });
376
397
  const terminal = (rec) => !!rec && rec.status === 'awaiting' && (rec.proposal === 'nothing' || rec.proposal === 'close');
377
- const probeRuntime = async (descriptor) => {
398
+ const probeRuntime = async (descriptor, referenceIds) => {
378
399
  try {
379
- return await descriptor.probe();
400
+ return await descriptor.probe(referenceIds);
380
401
  }
381
402
  catch (error) {
382
403
  return { healthy: false, references: [], error: error.message };
@@ -473,7 +494,7 @@ const sessionStopBlocker = async (id, harnessId, recs = rawRecords(), knownProbe
473
494
  }
474
495
  return null;
475
496
  };
476
- const probeShared = async (shared) => new Map(await Promise.all([...shared].map(async ([key, entry]) => [key, await probeRuntime(entry.descriptor)])));
497
+ const probeShared = async (shared) => new Map(await Promise.all([...shared].map(async ([key, entry]) => [key, await probeRuntime(entry.descriptor, entry.recs.map((rec) => rec.harness_session_id).filter((id) => !!id))])));
477
498
  const sharedFindings = (references, probe) => {
478
499
  const findings = [];
479
500
  if (!probe.healthy)
package/dist/host.d.ts CHANGED
@@ -36,6 +36,13 @@ export type ProjectDirectoryListing = {
36
36
  };
37
37
  export declare function browseProjectDirectories(dir?: string): ProjectDirectoryListing;
38
38
  export declare function addKnownProject(dir: string): string;
39
+ export type RemoveProjectResult = {
40
+ root: string;
41
+ projectId: string;
42
+ sessions: number;
43
+ runtimeRecordRemoved: boolean;
44
+ };
45
+ export declare function removeKnownProject(root: string, confirmation: string): RemoveProjectResult;
39
46
  export type ProjectEntry = {
40
47
  projectId: string;
41
48
  root: string;
package/dist/host.js CHANGED
@@ -13,6 +13,7 @@ import { MachinePeerGateway } from './machine-peer.js';
13
13
  import { DEFAULT_PROJECT_ICON, requireIdentityChoice } from '@spexcode/spec-core/identity';
14
14
  import { resolveProjectIdentity, writeGatewayIcon, } from '@spexcode/spec-core';
15
15
  import { cliEntrypointArgs } from './tsx-bin.js';
16
+ import { clearProjectPassword } from './gateway-auth.js';
16
17
  const here = dirname(fileURLToPath(import.meta.url));
17
18
  export const endpointRecordPath = (root) => join(spexcodeHome(), 'projects', encodeProject(root), 'backend.json');
18
19
  // atomic publish: tmp + rename, so a reader never sees a torn record (the old write-in-place could be
@@ -174,6 +175,70 @@ function catalogAdd(root) {
174
175
  return;
175
176
  writeCatalog([...entries, { root, addedAt: new Date().toISOString() }]);
176
177
  }
178
+ function activeProjectSessions(root) {
179
+ const dir = join(spexcodeHome(), 'projects', encodeProject(root), 'sessions');
180
+ let entries;
181
+ try {
182
+ entries = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
183
+ }
184
+ catch {
185
+ return 0;
186
+ }
187
+ let active = 0;
188
+ for (const id of entries) {
189
+ try {
190
+ const record = JSON.parse(readFileSync(join(dir, id, 'runtime.json'), 'utf8'));
191
+ if (record?.archived !== true && record?.stopped !== true && record?.closedAt == null)
192
+ active++;
193
+ }
194
+ catch {
195
+ // An unreadable record is not safe to classify as inactive. It stays a loud blocker for removal.
196
+ active++;
197
+ }
198
+ }
199
+ return active;
200
+ }
201
+ export function removeKnownProject(root, confirmation) {
202
+ const entries = readCatalog();
203
+ const entry = entries.find((e) => e.root === root);
204
+ const projectId = encodeProject(root);
205
+ if (!entry)
206
+ throw Object.assign(new Error(`project '${projectId}' is not in the catalog`), { status: 404 });
207
+ const sessions = activeProjectSessions(root);
208
+ if (sessions)
209
+ throw Object.assign(new Error(`project has ${sessions} active session record(s); stop or close them before removing the registration`), { status: 409 });
210
+ const snapshotEntry = snapshot.find((p) => p.root === root);
211
+ if (snapshotEntry?.online)
212
+ throw Object.assign(new Error('project backend is online; stop it before removing the registration'), { status: 409 });
213
+ const expected = `REMOVE ${snapshotEntry?.identity.title || basename(root)}`;
214
+ if (confirmation !== expected)
215
+ throw Object.assign(new Error(`confirmation must exactly equal '${expected}'`), { status: 400 });
216
+ let runtimeRecordRemoved = false;
217
+ const recordFile = endpointRecordPath(root);
218
+ const record = readEndpointRecord(recordFile);
219
+ if (record) {
220
+ let alive = false;
221
+ try {
222
+ process.kill(record.pid, 0);
223
+ alive = true;
224
+ }
225
+ catch { /* no process at that pid */ }
226
+ if (alive) {
227
+ throw Object.assign(new Error('backend runtime could not be proven stopped; registration was preserved'), { status: 409 });
228
+ }
229
+ }
230
+ writeCatalog(entries.filter((e) => e.root !== root));
231
+ clearProjectPassword(projectId);
232
+ if (record) {
233
+ try {
234
+ rmSync(recordFile);
235
+ runtimeRecordRemoved = true;
236
+ }
237
+ catch { /* already gone */ }
238
+ }
239
+ snapshot = snapshot.filter((p) => p.root !== root);
240
+ return { root, projectId, sessions, runtimeRecordRemoved };
241
+ }
177
242
  async function fetchInstance(url) {
178
243
  const ctrl = new AbortController();
179
244
  const t = setTimeout(() => ctrl.abort(), 900);
@@ -515,6 +580,34 @@ export function startHostDashboard(opts) {
515
580
  }
516
581
  return true;
517
582
  }
583
+ const remove = path.match(/^\/projects\/([^/]+)$/);
584
+ if (remove && req.method === 'DELETE') {
585
+ const projectId = decodeURIComponent(remove[1]);
586
+ if (!projectId) {
587
+ json(res, 404, { error: 'unknown project' });
588
+ return true;
589
+ }
590
+ let body = {};
591
+ try {
592
+ body = JSON.parse(await readBody(req) || '{}');
593
+ }
594
+ catch { /* validation below is the answer */ }
595
+ try {
596
+ const list = await reconcileNow();
597
+ const entry = list.find((p) => p.projectId === projectId);
598
+ if (!entry) {
599
+ json(res, 404, { error: `unknown project '${projectId}'` });
600
+ return true;
601
+ }
602
+ const removed = removeKnownProject(entry.root, typeof body?.confirmation === 'string' ? body.confirmation : '');
603
+ json(res, 200, { ok: true, ...removed });
604
+ }
605
+ catch (e) {
606
+ const error = e;
607
+ json(res, error.status ?? 400, { error: error.message });
608
+ }
609
+ return true;
610
+ }
518
611
  if (path === '/projects/icon' && req.method === 'PUT') {
519
612
  let body;
520
613
  try {