specrails-core 6.4.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/agent-runtime/capabilities.js +22 -5
  2. package/dist/agent-runtime/cli-executor.js +8 -3
  3. package/dist/agent-runtime/cli.d.ts +1 -1
  4. package/dist/agent-runtime/cli.js +50 -3
  5. package/dist/agent-runtime/compact/environment.d.ts +30 -2
  6. package/dist/agent-runtime/compact/environment.js +120 -2
  7. package/dist/agent-runtime/engine/pieces/role-turn.d.ts +5 -0
  8. package/dist/agent-runtime/engine/pieces/role-turn.js +54 -21
  9. package/dist/agent-runtime/engine/pieces/verify.js +3 -5
  10. package/dist/agent-runtime/session/application/active-session.d.ts +176 -0
  11. package/dist/agent-runtime/session/application/active-session.js +701 -0
  12. package/dist/agent-runtime/session/application/delegation.d.ts +73 -0
  13. package/dist/agent-runtime/session/application/delegation.js +171 -0
  14. package/dist/agent-runtime/session/application/output-buffer.d.ts +43 -0
  15. package/dist/agent-runtime/session/application/output-buffer.js +65 -0
  16. package/dist/agent-runtime/session/application/session-service.d.ts +121 -0
  17. package/dist/agent-runtime/session/application/session-service.js +256 -0
  18. package/dist/agent-runtime/session/application/timers.d.ts +15 -0
  19. package/dist/agent-runtime/session/application/timers.js +35 -0
  20. package/dist/agent-runtime/session/domain/descriptor.d.ts +6 -0
  21. package/dist/agent-runtime/session/domain/descriptor.js +32 -0
  22. package/dist/agent-runtime/session/domain/errors.d.ts +9 -0
  23. package/dist/agent-runtime/session/domain/errors.js +16 -0
  24. package/dist/agent-runtime/session/domain/events.d.ts +147 -0
  25. package/dist/agent-runtime/session/domain/events.js +24 -0
  26. package/dist/agent-runtime/session/domain/fingerprint.d.ts +2 -0
  27. package/dist/agent-runtime/session/domain/fingerprint.js +10 -0
  28. package/dist/agent-runtime/session/domain/interruption.d.ts +13 -0
  29. package/dist/agent-runtime/session/domain/interruption.js +39 -0
  30. package/dist/agent-runtime/session/domain/policy.d.ts +42 -0
  31. package/dist/agent-runtime/session/domain/policy.js +104 -0
  32. package/dist/agent-runtime/session/domain/snapshot.d.ts +100 -0
  33. package/dist/agent-runtime/session/domain/snapshot.js +234 -0
  34. package/dist/agent-runtime/session/domain/state-machines.d.ts +21 -0
  35. package/dist/agent-runtime/session/domain/state-machines.js +41 -0
  36. package/dist/agent-runtime/session/domain/types.d.ts +130 -0
  37. package/dist/agent-runtime/session/domain/types.js +15 -0
  38. package/dist/agent-runtime/session/domain/usage.d.ts +54 -0
  39. package/dist/agent-runtime/session/domain/usage.js +86 -0
  40. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +19 -0
  41. package/dist/agent-runtime/session/drivers/claude/argv.js +67 -0
  42. package/dist/agent-runtime/session/drivers/claude/driver.d.ts +19 -0
  43. package/dist/agent-runtime/session/drivers/claude/driver.js +134 -0
  44. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +40 -0
  45. package/dist/agent-runtime/session/drivers/claude/translator.js +247 -0
  46. package/dist/agent-runtime/session/drivers/codex/argv.d.ts +12 -0
  47. package/dist/agent-runtime/session/drivers/codex/argv.js +76 -0
  48. package/dist/agent-runtime/session/drivers/codex/driver.d.ts +19 -0
  49. package/dist/agent-runtime/session/drivers/codex/driver.js +193 -0
  50. package/dist/agent-runtime/session/drivers/codex/rpc.d.ts +28 -0
  51. package/dist/agent-runtime/session/drivers/codex/rpc.js +81 -0
  52. package/dist/agent-runtime/session/drivers/codex/translator.d.ts +53 -0
  53. package/dist/agent-runtime/session/drivers/codex/translator.js +267 -0
  54. package/dist/agent-runtime/session/drivers/common/process.d.ts +37 -0
  55. package/dist/agent-runtime/session/drivers/common/process.js +116 -0
  56. package/dist/agent-runtime/session/drivers/executor/driver.d.ts +22 -0
  57. package/dist/agent-runtime/session/drivers/executor/driver.js +163 -0
  58. package/dist/agent-runtime/session/drivers/registry.d.ts +32 -0
  59. package/dist/agent-runtime/session/drivers/registry.js +38 -0
  60. package/dist/agent-runtime/session/host/protocol.d.ts +18 -0
  61. package/dist/agent-runtime/session/host/protocol.js +88 -0
  62. package/dist/agent-runtime/session/host/server.d.ts +57 -0
  63. package/dist/agent-runtime/session/host/server.js +229 -0
  64. package/dist/agent-runtime/session/index.d.ts +45 -0
  65. package/dist/agent-runtime/session/index.js +74 -0
  66. package/dist/agent-runtime/session/journal/schema.d.ts +7 -0
  67. package/dist/agent-runtime/session/journal/schema.js +44 -0
  68. package/dist/agent-runtime/session/journal/sqlite-journal.d.ts +70 -0
  69. package/dist/agent-runtime/session/journal/sqlite-journal.js +279 -0
  70. package/dist/agent-runtime/session/ports.d.ts +192 -0
  71. package/dist/agent-runtime/session/ports.js +1 -0
  72. package/dist/agent-runtime/session/testing/driver-conformance.d.ts +21 -0
  73. package/dist/agent-runtime/session/testing/driver-conformance.js +97 -0
  74. package/dist/agent-runtime/session/testing/fake-clock.d.ts +20 -0
  75. package/dist/agent-runtime/session/testing/fake-clock.js +34 -0
  76. package/dist/agent-runtime/session/testing/fixture-replayer.d.ts +41 -0
  77. package/dist/agent-runtime/session/testing/fixture-replayer.js +172 -0
  78. package/dist/agent-runtime/session/testing/index.d.ts +9 -0
  79. package/dist/agent-runtime/session/testing/index.js +9 -0
  80. package/dist/agent-runtime/session/testing/memory-journal.d.ts +27 -0
  81. package/dist/agent-runtime/session/testing/memory-journal.js +70 -0
  82. package/dist/agent-runtime/session/testing/scripted-driver.d.ts +30 -0
  83. package/dist/agent-runtime/session/testing/scripted-driver.js +77 -0
  84. package/dist/agent-runtime/verification-proposals.d.ts +9 -0
  85. package/dist/agent-runtime/verification-proposals.js +69 -0
  86. package/dist/installer/util/registry.js +3 -3
  87. package/dist/shared/specrails-home.d.ts +21 -0
  88. package/dist/shared/specrails-home.js +36 -0
  89. package/integration-contract.json +42 -1
  90. package/package.json +11 -1
@@ -0,0 +1,41 @@
1
+ import type { ProcessSpawner, ProcessSpec } from '../drivers/common/process.js';
2
+ /** One recorded transcript row (see the spike harness in the OpenSpec change). */
3
+ export interface FixtureRow {
4
+ t: number;
5
+ dir: 'in' | 'out' | 'ctl';
6
+ json?: Record<string, unknown> | null;
7
+ raw?: string;
8
+ }
9
+ export declare function loadFixture(file: string): FixtureRow[];
10
+ export interface ReplaySession {
11
+ readonly spec: ProcessSpec;
12
+ /** Lines the driver wrote to stdin, parsed. */
13
+ readonly written: Array<Record<string, unknown>>;
14
+ readonly inputEnded: boolean;
15
+ readonly terminated: boolean;
16
+ /** Resolves when the replay is blocked waiting for the driver, or finished. */
17
+ idle(): Promise<void>;
18
+ }
19
+ /**
20
+ * Replays a recorded provider transcript as a ProcessSpawner:
21
+ * - `out` rows are emitted in order (time compressed);
22
+ * - before passing an `in` row, replay waits until the driver wrote a line,
23
+ * then maps recorded ids/uuids to the ones the driver used;
24
+ * - a `ctl kill` row waits for `terminate()` (output after it is what the
25
+ * provider printed while stopping); `closeStdin` waits for `endInput()`;
26
+ * - after the last row the process stays alive until terminated, like a
27
+ * resident provider, unless the transcript recorded a clean exit.
28
+ */
29
+ export declare class FixtureReplayer {
30
+ private readonly rows;
31
+ private readonly options;
32
+ readonly sessions: ReplaySession[];
33
+ constructor(rows: FixtureRow[], options?: {
34
+ exitAtEnd?: boolean;
35
+ });
36
+ static fromFile(file: string, options?: {
37
+ exitAtEnd?: boolean;
38
+ }): FixtureReplayer;
39
+ get last(): ReplaySession;
40
+ readonly spawner: ProcessSpawner;
41
+ }
@@ -0,0 +1,172 @@
1
+ import { readFileSync } from 'node:fs';
2
+ export function loadFixture(file) {
3
+ return readFileSync(file, 'utf8').split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line));
4
+ }
5
+ /** Fields whose recorded values are replaced by the values the driver actually wrote. */
6
+ const REMAPPED = ['id', 'uuid', 'command_uuid'];
7
+ /**
8
+ * Replays a recorded provider transcript as a ProcessSpawner:
9
+ * - `out` rows are emitted in order (time compressed);
10
+ * - before passing an `in` row, replay waits until the driver wrote a line,
11
+ * then maps recorded ids/uuids to the ones the driver used;
12
+ * - a `ctl kill` row waits for `terminate()` (output after it is what the
13
+ * provider printed while stopping); `closeStdin` waits for `endInput()`;
14
+ * - after the last row the process stays alive until terminated, like a
15
+ * resident provider, unless the transcript recorded a clean exit.
16
+ */
17
+ export class FixtureReplayer {
18
+ rows;
19
+ options;
20
+ sessions = [];
21
+ constructor(rows, options = {}) {
22
+ this.rows = rows;
23
+ this.options = options;
24
+ }
25
+ static fromFile(file, options) {
26
+ return new FixtureReplayer(loadFixture(file), options);
27
+ }
28
+ get last() {
29
+ const session = this.sessions.at(-1);
30
+ if (!session)
31
+ throw new Error('fixture replayer: nothing spawned');
32
+ return session;
33
+ }
34
+ spawner = (spec, handlers) => {
35
+ const rows = this.rows;
36
+ const ids = new Map();
37
+ const written = [];
38
+ let inputsSeen = 0;
39
+ let index = 0;
40
+ let inputOpen = true;
41
+ let terminated = false;
42
+ let exit = null;
43
+ let resolveExit;
44
+ const exited = new Promise((resolve) => { resolveExit = resolve; });
45
+ let idleWaiters = [];
46
+ let blocked = false;
47
+ const exitAtEnd = this.options.exitAtEnd ?? false;
48
+ const remap = (value) => {
49
+ if (Array.isArray(value))
50
+ return value.map(remap);
51
+ if (value && typeof value === 'object') {
52
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key,
53
+ REMAPPED.includes(key) && ids.has(JSON.stringify(inner)) ? ids.get(JSON.stringify(inner)) : remap(inner)]));
54
+ }
55
+ return value;
56
+ };
57
+ const finish = (result) => {
58
+ if (exit)
59
+ return;
60
+ exit = result;
61
+ blocked = true;
62
+ resolveExit(result);
63
+ flushIdle();
64
+ };
65
+ const flushIdle = () => { const waiters = idleWaiters; idleWaiters = []; for (const waiter of waiters)
66
+ waiter(); };
67
+ const step = () => {
68
+ if (exit)
69
+ return;
70
+ blocked = false;
71
+ while (index < rows.length) {
72
+ const row = rows[index];
73
+ if (row.dir === 'in') {
74
+ if (written.length <= inputsSeen) {
75
+ blocked = true;
76
+ flushIdle();
77
+ return;
78
+ }
79
+ const actual = written[inputsSeen];
80
+ for (const key of REMAPPED) {
81
+ const recorded = row.json?.[key];
82
+ if (recorded !== undefined && actual[key] !== undefined)
83
+ ids.set(JSON.stringify(recorded), actual[key]);
84
+ }
85
+ inputsSeen += 1;
86
+ index += 1;
87
+ continue;
88
+ }
89
+ if (row.dir === 'ctl') {
90
+ const control = row.json ?? {};
91
+ if ('kill' in control) {
92
+ if (!terminated) {
93
+ blocked = true;
94
+ flushIdle();
95
+ return;
96
+ }
97
+ index += 1;
98
+ continue;
99
+ }
100
+ if ('closeStdin' in control) {
101
+ if (inputOpen) {
102
+ blocked = true;
103
+ flushIdle();
104
+ return;
105
+ }
106
+ index += 1;
107
+ continue;
108
+ }
109
+ if ('end' in control) {
110
+ const match = /exit code=(\S+) sig=(\S+)/.exec(String(control.end));
111
+ const code = match && match[1] !== 'null' ? Number(match[1]) : null;
112
+ const signal = match && match[2] !== 'null' ? match[2] : null;
113
+ // A harness timeout means the real process was still alive: stay resident.
114
+ if (terminated || exitAtEnd || (signal !== 'SIGKILL' && signal !== 'SIGTERM')) {
115
+ finish({ exitCode: code, signal });
116
+ return;
117
+ }
118
+ index += 1;
119
+ continue;
120
+ }
121
+ index += 1;
122
+ continue;
123
+ }
124
+ index += 1;
125
+ if (row.json)
126
+ handlers.onLine(JSON.stringify(remap(row.json)));
127
+ else if (row.raw)
128
+ handlers.onLine(row.raw);
129
+ setImmediate(step);
130
+ return;
131
+ }
132
+ if (terminated || exitAtEnd)
133
+ finish({ exitCode: terminated ? null : 0, signal: terminated ? 'SIGTERM' : null });
134
+ else {
135
+ blocked = true;
136
+ flushIdle();
137
+ }
138
+ };
139
+ const session = {
140
+ spec,
141
+ written,
142
+ get inputEnded() { return !inputOpen; },
143
+ get terminated() { return terminated; },
144
+ idle: () => (blocked || exit ? Promise.resolve() : new Promise((resolve) => { idleWaiters.push(resolve); })),
145
+ };
146
+ this.sessions.push(session);
147
+ setImmediate(step);
148
+ return {
149
+ pid: 4242,
150
+ exited,
151
+ write(line) {
152
+ if (!inputOpen || exit)
153
+ return false;
154
+ written.push(JSON.parse(line));
155
+ if (blocked)
156
+ setImmediate(step);
157
+ return true;
158
+ },
159
+ endInput() {
160
+ inputOpen = false;
161
+ if (blocked)
162
+ setImmediate(step);
163
+ },
164
+ terminate() {
165
+ terminated = true;
166
+ if (blocked)
167
+ setImmediate(step);
168
+ return exited;
169
+ },
170
+ };
171
+ };
172
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Test kit for hosts and driver authors (package export `./agent-runtime/session/testing`).
3
+ * Framework-free: usable from any test runner.
4
+ */
5
+ export { DRIVER_CONFORMANCE, type ConformanceCheck, type ConformanceHarness } from './driver-conformance.js';
6
+ export { FixtureReplayer, loadFixture, type FixtureRow, type ReplaySession } from './fixture-replayer.js';
7
+ export { MemoryJournal } from './memory-journal.js';
8
+ export { FakeClock, SequentialIds } from './fake-clock.js';
9
+ export { ScriptedDriverFactory, ScriptedDriverSession, catalogOf, descriptor } from './scripted-driver.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Test kit for hosts and driver authors (package export `./agent-runtime/session/testing`).
3
+ * Framework-free: usable from any test runner.
4
+ */
5
+ export { DRIVER_CONFORMANCE } from './driver-conformance.js';
6
+ export { FixtureReplayer, loadFixture } from './fixture-replayer.js';
7
+ export { MemoryJournal } from './memory-journal.js';
8
+ export { FakeClock, SequentialIds } from './fake-clock.js';
9
+ export { ScriptedDriverFactory, ScriptedDriverSession, catalogOf, descriptor } from './scripted-driver.js';
@@ -0,0 +1,27 @@
1
+ import type { SessionEvent, SessionEventEnvelope } from '../domain/events.js';
2
+ import { type SessionSnapshot } from '../domain/snapshot.js';
3
+ import type { UsageBaseline } from '../domain/usage.js';
4
+ import type { AppendOptions, SessionJournal, SessionRecord, SessionSummary } from '../ports.js';
5
+ /**
6
+ * In-memory SessionJournal with the same contract as the SQLite adapter:
7
+ * gap-free sequences, atomic appends, baselines stored with their events.
8
+ * `failNextAppend` simulates a failed transaction (nothing becomes visible).
9
+ */
10
+ export declare class MemoryJournal implements SessionJournal {
11
+ private readonly sessions;
12
+ private readonly baselines;
13
+ failNextAppend: Error | null;
14
+ createSession(record: SessionRecord): void;
15
+ getSession(sessionId: string): SessionRecord | null;
16
+ append(sessionId: string, events: SessionEvent[], options?: AppendOptions): SessionEventEnvelope[];
17
+ read(sessionId: string, afterSeq: number, limit: number): {
18
+ events: SessionEventEnvelope[];
19
+ hasMore: boolean;
20
+ };
21
+ snapshot(sessionId: string): SessionSnapshot | null;
22
+ list(filter: 'open' | 'closed' | 'all'): SessionSummary[];
23
+ baseline(providerRef: string): UsageBaseline | null;
24
+ /** Test helper: every committed event of a session. */
25
+ events(sessionId: string): SessionEventEnvelope[];
26
+ private require;
27
+ }
@@ -0,0 +1,70 @@
1
+ import { emptySnapshot, foldEvents } from '../domain/snapshot.js';
2
+ /**
3
+ * In-memory SessionJournal with the same contract as the SQLite adapter:
4
+ * gap-free sequences, atomic appends, baselines stored with their events.
5
+ * `failNextAppend` simulates a failed transaction (nothing becomes visible).
6
+ */
7
+ export class MemoryJournal {
8
+ sessions = new Map();
9
+ baselines = new Map();
10
+ failNextAppend = null;
11
+ createSession(record) {
12
+ if (this.sessions.has(record.sessionId))
13
+ throw new Error(`Session ${record.sessionId} exists`);
14
+ this.sessions.set(record.sessionId, { record: { ...record }, events: [], snapshot: emptySnapshot(record.sessionId) });
15
+ }
16
+ getSession(sessionId) {
17
+ return this.sessions.get(sessionId)?.record ?? null;
18
+ }
19
+ append(sessionId, events, options) {
20
+ const entry = this.require(sessionId);
21
+ if (this.failNextAppend) {
22
+ const error = this.failNextAppend;
23
+ this.failNextAppend = null;
24
+ throw error;
25
+ }
26
+ const envelopes = events.map((event, index) => ({ sessionId, seq: entry.snapshot.lastSeq + index + 1, event: structuredClone(event) }));
27
+ const snapshot = foldEvents(entry.snapshot, envelopes);
28
+ entry.events.push(...envelopes);
29
+ entry.snapshot = snapshot;
30
+ if (options?.baseline)
31
+ this.baselines.set(options.baseline.providerRef, { ...options.baseline.value });
32
+ return envelopes.map((envelope) => structuredClone(envelope));
33
+ }
34
+ read(sessionId, afterSeq, limit) {
35
+ const all = this.require(sessionId).events.filter((envelope) => envelope.seq > afterSeq);
36
+ return { events: structuredClone(all.slice(0, limit)), hasMore: all.length > limit };
37
+ }
38
+ snapshot(sessionId) {
39
+ const entry = this.sessions.get(sessionId);
40
+ return entry ? structuredClone(entry.snapshot) : null;
41
+ }
42
+ list(filter) {
43
+ return [...this.sessions.values()]
44
+ .filter(({ snapshot }) => filter === 'all' || snapshot.status === filter)
45
+ .map(({ record, snapshot }) => ({
46
+ sessionId: record.sessionId,
47
+ driver: record.driver,
48
+ model: snapshot.model,
49
+ status: snapshot.status,
50
+ phase: snapshot.phase,
51
+ lastSeq: snapshot.lastSeq,
52
+ createdAt: record.createdAt,
53
+ metadata: { ...record.metadata },
54
+ }));
55
+ }
56
+ baseline(providerRef) {
57
+ const value = this.baselines.get(providerRef);
58
+ return value ? { ...value } : null;
59
+ }
60
+ /** Test helper: every committed event of a session. */
61
+ events(sessionId) {
62
+ return structuredClone(this.require(sessionId).events);
63
+ }
64
+ require(sessionId) {
65
+ const entry = this.sessions.get(sessionId);
66
+ if (!entry)
67
+ throw new Error(`Unknown session ${sessionId}`);
68
+ return entry;
69
+ }
70
+ }
@@ -0,0 +1,30 @@
1
+ import type { DriverCapabilities, DriverDescriptor } from '../domain/types.js';
2
+ import type { CloseReason, DriverCatalog, DriverEvent, DriverEventSink, DriverFactory, DriverInput, DriverOpenSpec, DriverSession } from '../ports.js';
3
+ export declare function descriptor(id: string, capabilities?: Partial<DriverCapabilities>): DriverDescriptor;
4
+ /** One opened provider process, driven by the test through `emit`. */
5
+ export declare class ScriptedDriverSession implements DriverSession {
6
+ readonly spec: DriverOpenSpec;
7
+ private readonly sink;
8
+ readonly sent: DriverInput[];
9
+ readonly calls: string[];
10
+ closed: CloseReason | null;
11
+ /** What `stopSubagents` resolves with. */
12
+ stopResult: string[] | 'process';
13
+ /** Events emitted while closing (e.g. Claude's stopped/killed notifications on SIGTERM). */
14
+ onClose: DriverEvent[];
15
+ constructor(spec: DriverOpenSpec, sink: DriverEventSink);
16
+ emit(...events: DriverEvent[]): void;
17
+ send(input: DriverInput): Promise<void>;
18
+ interrupt(): Promise<void>;
19
+ stopSubagents(ids?: string[]): Promise<string[] | 'process'>;
20
+ close(reason: CloseReason): Promise<void>;
21
+ }
22
+ export declare class ScriptedDriverFactory implements DriverFactory {
23
+ readonly descriptor: DriverDescriptor;
24
+ readonly sessions: ScriptedDriverSession[];
25
+ openError: Error | null;
26
+ constructor(descriptor: DriverDescriptor);
27
+ open(spec: DriverOpenSpec, sink: DriverEventSink): Promise<DriverSession>;
28
+ get last(): ScriptedDriverSession;
29
+ }
30
+ export declare function catalogOf(...factories: DriverFactory[]): DriverCatalog;
@@ -0,0 +1,77 @@
1
+ export function descriptor(id, capabilities = {}) {
2
+ return {
3
+ id,
4
+ displayName: id,
5
+ capabilities: {
6
+ resident: true,
7
+ nativeInputQueue: true,
8
+ subagents: 'supported',
9
+ subagentDisable: true,
10
+ subagentModel: true,
11
+ subagentEffort: true,
12
+ autonomousContinuation: true,
13
+ steer: true,
14
+ toolFiltering: true,
15
+ usage: { costUsd: 'session-cumulative', tokens: 'per-turn' },
16
+ ...capabilities,
17
+ },
18
+ };
19
+ }
20
+ /** One opened provider process, driven by the test through `emit`. */
21
+ export class ScriptedDriverSession {
22
+ spec;
23
+ sink;
24
+ sent = [];
25
+ calls = [];
26
+ closed = null;
27
+ /** What `stopSubagents` resolves with. */
28
+ stopResult = 'process';
29
+ /** Events emitted while closing (e.g. Claude's stopped/killed notifications on SIGTERM). */
30
+ onClose = [];
31
+ constructor(spec, sink) {
32
+ this.spec = spec;
33
+ this.sink = sink;
34
+ }
35
+ emit(...events) {
36
+ if (this.closed)
37
+ throw new Error('scripted driver: emit after close');
38
+ for (const event of events)
39
+ this.sink(event);
40
+ }
41
+ async send(input) { this.calls.push(`send:${input.inputId}`); this.sent.push(input); }
42
+ async interrupt() { this.calls.push('interrupt'); }
43
+ async stopSubagents(ids) { this.calls.push(`stopSubagents:${ids?.join(',') ?? '*'}`); return this.stopResult; }
44
+ async close(reason) {
45
+ if (this.closed)
46
+ return;
47
+ this.calls.push(`close:${reason}`);
48
+ for (const event of this.onClose)
49
+ this.sink(event);
50
+ this.closed = reason;
51
+ }
52
+ }
53
+ export class ScriptedDriverFactory {
54
+ descriptor;
55
+ sessions = [];
56
+ openError = null;
57
+ constructor(descriptor) {
58
+ this.descriptor = descriptor;
59
+ }
60
+ async open(spec, sink) {
61
+ if (this.openError)
62
+ throw this.openError;
63
+ const session = new ScriptedDriverSession(spec, sink);
64
+ this.sessions.push(session);
65
+ return session;
66
+ }
67
+ get last() {
68
+ const session = this.sessions.at(-1);
69
+ if (!session)
70
+ throw new Error('scripted driver: no session opened');
71
+ return session;
72
+ }
73
+ }
74
+ export function catalogOf(...factories) {
75
+ const map = new Map(factories.map((factory) => [factory.descriptor.id, factory]));
76
+ return { get: (id) => map.get(id), descriptors: () => factories.map((factory) => factory.descriptor) };
77
+ }
@@ -0,0 +1,9 @@
1
+ import type { PipelineContext, VerificationCommand } from '../pipeline/pipeline-state.js';
2
+ /** The package script a command runs (`npm test`, `npm run x`, `pnpm [run] x`, `yarn [run] x`), when it runs exactly one with no extra arguments. */
3
+ export declare function packageScriptOf(command: Pick<VerificationCommand, 'command' | 'args'>): string | undefined;
4
+ /**
5
+ * The proposals to append to `planned` (already scope-defaulted). `normalize`
6
+ * applies the caller's scope default; proposals are expected to be
7
+ * structurally valid.
8
+ */
9
+ export declare function admitVerificationProposals(context: PipelineContext, planned: readonly VerificationCommand[], proposals: readonly VerificationCommand[], normalize: (command: VerificationCommand) => VerificationCommand): VerificationCommand[];
@@ -0,0 +1,69 @@
1
+ // Which agent-proposed verification commands the host adds to its plan.
2
+ //
3
+ // A repository without configured checks takes the proposals as its checks
4
+ // (validated like any configured command). A repository with configured
5
+ // checks keeps them and may gain only runs of a package script the
6
+ // repository itself declares, such as `npm run test:e2e`: a change whose
7
+ // acceptance needs browser or integration tests then gets host evidence for
8
+ // them, without the agent introducing arbitrary commands next to the
9
+ // configured ones.
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ /** The package script a command runs (`npm test`, `npm run x`, `pnpm [run] x`, `yarn [run] x`), when it runs exactly one with no extra arguments. */
13
+ export function packageScriptOf(command) {
14
+ const [first, second, ...rest] = command.args;
15
+ if (command.command === 'npm') {
16
+ if (first === 'test' && second === undefined)
17
+ return 'test';
18
+ if ((first === 'run' || first === 'run-script') && second && !rest.length)
19
+ return second;
20
+ return undefined;
21
+ }
22
+ if (command.command === 'pnpm' || command.command === 'yarn') {
23
+ if (first === 'run' && second && !rest.length)
24
+ return second;
25
+ if (first && second === undefined && !first.startsWith('-'))
26
+ return first;
27
+ }
28
+ return undefined;
29
+ }
30
+ function declaresScript(directory, script) {
31
+ const file = path.join(directory, 'package.json');
32
+ if (!existsSync(file))
33
+ return false;
34
+ try {
35
+ const scripts = JSON.parse(readFileSync(file, 'utf8')).scripts;
36
+ return !!scripts && typeof scripts[script] === 'string';
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ const planKey = (command) => JSON.stringify([command.repositoryId, command.cwd ?? '', command.command, command.args]);
43
+ /**
44
+ * The proposals to append to `planned` (already scope-defaulted). `normalize`
45
+ * applies the caller's scope default; proposals are expected to be
46
+ * structurally valid.
47
+ */
48
+ export function admitVerificationProposals(context, planned, proposals, normalize) {
49
+ const configured = new Set(planned.map(command => command.repositoryId));
50
+ const seen = new Set(planned.map(planKey));
51
+ const admitted = [];
52
+ for (const raw of proposals) {
53
+ const repository = context.repositories.find(repo => repo.id === raw.repositoryId);
54
+ if (!repository)
55
+ continue;
56
+ const command = normalize(raw);
57
+ if (configured.has(raw.repositoryId)) {
58
+ const script = packageScriptOf(command);
59
+ if (!script || !declaresScript(path.resolve(repository.path, command.cwd ?? '.'), script))
60
+ continue;
61
+ }
62
+ const key = planKey(command);
63
+ if (seen.has(key))
64
+ continue;
65
+ seen.add(key);
66
+ admitted.push(command);
67
+ }
68
+ return admitted;
69
+ }
@@ -17,8 +17,8 @@
17
17
  * Everything in this module is synchronous to fit the installer's sync flow.
18
18
  */
19
19
  import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
20
- import os from 'node:os';
21
20
  import path from 'node:path';
21
+ import { userHome } from '../../shared/specrails-home.js';
22
22
  /** Registry schema version. A reader that sees a higher value MUST treat all
23
23
  * entries as absent (legacy fallback), never mis-parse. */
24
24
  export const REGISTRY_SCHEMA_VERSION = 1;
@@ -190,9 +190,9 @@ function isValidProjectEntry(key, value, home) {
190
190
  export function slugify(name) {
191
191
  return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
192
192
  }
193
- /** `$HOME` for the registry, overridable for tests. */
193
+ /** `$HOME` for the registry, overridable for tests (shared with session journals). */
194
194
  function resolveHome(home) {
195
- return home ?? process.env.SPECRAILS_REGISTRY_HOME ?? os.homedir();
195
+ return userHome(home);
196
196
  }
197
197
  /** Absolute path to `registry.json`. */
198
198
  export function registryPath(home) {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The user-level Specrails home. Registry, framework store, workspaces and
3
+ * agent session journals all live under `<home>/.specrails`, never inside a
4
+ * project repository.
5
+ *
6
+ * `SPECRAILS_REGISTRY_HOME` overrides the base directory so tests (and
7
+ * isolated installs) redirect every user-level artifact together.
8
+ */
9
+ export declare function userHome(home?: string): string;
10
+ /** Absolute path to `<home>/.specrails`. */
11
+ export declare function specrailsHome(home?: string): string;
12
+ /** Scope used by session hosts that are not bound to a project. */
13
+ export declare const GLOBAL_SESSION_SCOPE = "global";
14
+ /** True when `scope` is a valid session scope key (a project slug or `global`). */
15
+ export declare function isSessionScopeKey(scope: string): boolean;
16
+ /**
17
+ * Directory that holds every agent session journal of one scope:
18
+ * `<home>/.specrails/sessions/<scope>/`. Sessions are grouped per project so a
19
+ * project's history can be inspected, backed up or removed as one unit.
20
+ */
21
+ export declare function sessionsRoot(scope: string, home?: string): string;
@@ -0,0 +1,36 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /**
4
+ * The user-level Specrails home. Registry, framework store, workspaces and
5
+ * agent session journals all live under `<home>/.specrails`, never inside a
6
+ * project repository.
7
+ *
8
+ * `SPECRAILS_REGISTRY_HOME` overrides the base directory so tests (and
9
+ * isolated installs) redirect every user-level artifact together.
10
+ */
11
+ export function userHome(home) {
12
+ return home ?? process.env.SPECRAILS_REGISTRY_HOME ?? os.homedir();
13
+ }
14
+ /** Absolute path to `<home>/.specrails`. */
15
+ export function specrailsHome(home) {
16
+ return path.join(userHome(home), '.specrails');
17
+ }
18
+ /** Scope used by session hosts that are not bound to a project. */
19
+ export const GLOBAL_SESSION_SCOPE = 'global';
20
+ /** Same alphabet the registry uses for project slugs, bounded for file names. */
21
+ const SCOPE_KEY = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/;
22
+ /** True when `scope` is a valid session scope key (a project slug or `global`). */
23
+ export function isSessionScopeKey(scope) {
24
+ return SCOPE_KEY.test(scope);
25
+ }
26
+ /**
27
+ * Directory that holds every agent session journal of one scope:
28
+ * `<home>/.specrails/sessions/<scope>/`. Sessions are grouped per project so a
29
+ * project's history can be inspected, backed up or removed as one unit.
30
+ */
31
+ export function sessionsRoot(scope, home) {
32
+ if (!isSessionScopeKey(scope)) {
33
+ throw new Error(`Invalid session scope "${scope}": use a project slug (a-z, 0-9, "-") or "${GLOBAL_SESSION_SCOPE}"`);
34
+ }
35
+ return path.join(specrailsHome(home), 'sessions', scope);
36
+ }
@@ -241,7 +241,8 @@
241
241
  "workflows",
242
242
  "fork",
243
243
  "signal",
244
- "cancel"
244
+ "cancel",
245
+ "host"
245
246
  ],
246
247
  "cliPresentationOperations": [
247
248
  "help"
@@ -373,6 +374,46 @@
373
374
  "workflowAgentSteps": {
374
375
  "version": 1,
375
376
  "ownership": "Host-defined roles, schemas and gates; Core provides scoped artifact contracts and verified candidate guards"
377
+ },
378
+ "sessions": {
379
+ "version": 1,
380
+ "protocolVersions": [
381
+ 1
382
+ ],
383
+ "capability": "sessions",
384
+ "cliOperation": "host",
385
+ "cliCommand": "specrails-core runtime host --stdio --scope <projectKey|global>",
386
+ "transport": "JSON-RPC 2.0 over newline-delimited stdio; one host per scope",
387
+ "journalRoot": "~/.specrails/sessions/<scope>/sessions.sqlite",
388
+ "moduleExport": "./agent-runtime/session",
389
+ "documentation": "docs/agent-sessions/protocol.md",
390
+ "eventTypes": [
391
+ "session.opened",
392
+ "session.phase",
393
+ "session.process",
394
+ "session.provider-ref",
395
+ "session.updated",
396
+ "session.closed",
397
+ "input.accepted",
398
+ "input.state",
399
+ "turn.started",
400
+ "turn.output",
401
+ "turn.tool",
402
+ "turn.completed",
403
+ "subagent.started",
404
+ "subagent.phase",
405
+ "subagent.output",
406
+ "subagent.usage",
407
+ "subagent.result",
408
+ "subagents.settled",
409
+ "output.truncated",
410
+ "notice.interruption",
411
+ "provider.diagnostic"
412
+ ],
413
+ "capabilities": [
414
+ "sessions",
415
+ "delegation"
416
+ ]
376
417
  }
377
418
  },
378
419
  "execution": {