specrails-core 6.6.5 → 6.7.1

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 (118) hide show
  1. package/dist/agent-runtime/capabilities.d.ts +5 -2
  2. package/dist/agent-runtime/capabilities.js +8 -2
  3. package/dist/agent-runtime/change-scope.d.ts +2 -0
  4. package/dist/agent-runtime/change-scope.js +5 -0
  5. package/dist/agent-runtime/cli-executor.js +8 -1
  6. package/dist/agent-runtime/cli.d.ts +6 -1
  7. package/dist/agent-runtime/cli.js +15 -4
  8. package/dist/agent-runtime/compact/reviewer.js +3 -1
  9. package/dist/agent-runtime/config.js +16 -5
  10. package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
  11. package/dist/agent-runtime/efficiency-summary.js +7 -1
  12. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
  13. package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
  14. package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
  15. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
  16. package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
  17. package/dist/agent-runtime/engine/cli.d.ts +12 -0
  18. package/dist/agent-runtime/engine/cli.js +15 -4
  19. package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
  20. package/dist/agent-runtime/engine/definition-schema.js +1 -0
  21. package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
  22. package/dist/agent-runtime/engine/definition-types.js +1 -1
  23. package/dist/agent-runtime/engine/definition-validator.js +8 -0
  24. package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
  25. package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
  26. package/dist/agent-runtime/engine/invocation-context.js +2 -0
  27. package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
  28. package/dist/agent-runtime/engine/piece-registry.js +1 -1
  29. package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
  30. package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
  31. package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
  32. package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
  33. package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
  34. package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
  35. package/dist/agent-runtime/engine/pieces/index.js +2 -1
  36. package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
  37. package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
  38. package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
  39. package/dist/agent-runtime/engine/preflight.d.ts +2 -1
  40. package/dist/agent-runtime/engine/preflight.js +3 -1
  41. package/dist/agent-runtime/engine/regex.d.ts +12 -2
  42. package/dist/agent-runtime/engine/regex.js +25 -11
  43. package/dist/agent-runtime/engine/run-status.d.ts +34 -0
  44. package/dist/agent-runtime/engine/run-status.js +7 -1
  45. package/dist/agent-runtime/engine/runs.d.ts +56 -0
  46. package/dist/agent-runtime/engine/runs.js +88 -5
  47. package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
  48. package/dist/agent-runtime/engine/steering/inbox.js +21 -1
  49. package/dist/agent-runtime/engine/storage/private-path.js +7 -1
  50. package/dist/agent-runtime/evaluation.js +1 -1
  51. package/dist/agent-runtime/executor-types.d.ts +19 -0
  52. package/dist/agent-runtime/executor-types.js +10 -0
  53. package/dist/agent-runtime/failure-classification.d.ts +51 -0
  54. package/dist/agent-runtime/failure-classification.js +110 -0
  55. package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
  56. package/dist/agent-runtime/graph/artifacts.js +5 -1
  57. package/dist/agent-runtime/graph/baseline.d.ts +50 -0
  58. package/dist/agent-runtime/graph/baseline.js +145 -0
  59. package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
  60. package/dist/agent-runtime/graph/capability-map.js +65 -0
  61. package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
  62. package/dist/agent-runtime/graph/clarifications.js +158 -0
  63. package/dist/agent-runtime/graph/nodes.js +178 -19
  64. package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
  65. package/dist/agent-runtime/graph/review-brief-record.js +26 -0
  66. package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
  67. package/dist/agent-runtime/graph/review-brief.js +127 -0
  68. package/dist/agent-runtime/graph/roles.js +5 -3
  69. package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
  70. package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
  71. package/dist/agent-runtime/graph/state.d.ts +8 -0
  72. package/dist/agent-runtime/index.d.ts +7 -0
  73. package/dist/agent-runtime/index.js +7 -0
  74. package/dist/agent-runtime/openspec.d.ts +1 -0
  75. package/dist/agent-runtime/openspec.js +1 -1
  76. package/dist/agent-runtime/project-rules.d.ts +139 -0
  77. package/dist/agent-runtime/project-rules.js +683 -0
  78. package/dist/agent-runtime/prompts.d.ts +32 -1
  79. package/dist/agent-runtime/prompts.js +182 -7
  80. package/dist/agent-runtime/role-routing.d.ts +2 -1
  81. package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
  82. package/dist/agent-runtime/session/application/active-session.js +163 -7
  83. package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
  84. package/dist/agent-runtime/session/domain/descriptor.js +5 -0
  85. package/dist/agent-runtime/session/domain/interruption.js +1 -0
  86. package/dist/agent-runtime/session/domain/policy.js +2 -0
  87. package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
  88. package/dist/agent-runtime/session/domain/redaction.js +71 -0
  89. package/dist/agent-runtime/session/domain/types.d.ts +17 -1
  90. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
  91. package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
  92. package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
  93. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
  94. package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
  95. package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
  96. package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
  97. package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
  98. package/dist/agent-runtime/session/host/protocol.js +10 -2
  99. package/dist/agent-runtime/session/host/server.js +6 -5
  100. package/dist/agent-runtime/session/index.d.ts +1 -1
  101. package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
  102. package/dist/agent-runtime/session/ports.d.ts +6 -2
  103. package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
  104. package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
  105. package/dist/installer/cli.js +10 -1
  106. package/dist/installer/commands/init.js +14 -3
  107. package/dist/installer/phases/scaffold.js +56 -5
  108. package/dist/pipeline/pipeline-state.d.ts +22 -0
  109. package/dist/pipeline/pipeline-state.js +55 -4
  110. package/dist/pipeline/spec-trace.d.ts +134 -0
  111. package/dist/pipeline/spec-trace.js +392 -0
  112. package/dist/shared/secret-redaction.d.ts +42 -0
  113. package/dist/shared/secret-redaction.js +184 -0
  114. package/integration-contract.json +177 -7
  115. package/package.json +7 -7
  116. package/schemas/agent-runtime.schema.json +3 -1
  117. package/schemas/workflow-definition.schema.json +1 -0
  118. package/templates/settings/codex-config.toml +3 -2
@@ -19,6 +19,8 @@ export function executorDescriptor(id, displayName) {
19
19
  autonomousContinuation: false,
20
20
  steer: false,
21
21
  toolFiltering: false,
22
+ // No turn-level stop that keeps a session: abort the invocation, then run the input next.
23
+ interrupt: 'emulated',
22
24
  usage: Object.freeze({ costUsd: 'per-turn', tokens: 'per-turn' }),
23
25
  }),
24
26
  });
@@ -1,6 +1,18 @@
1
1
  import { type ValidateFunction } from 'ajv/dist/2020.js';
2
2
  /** Protocol versions this host speaks (docs/agent-sessions/protocol.md). */
3
3
  export declare const SESSION_PROTOCOL_VERSIONS: readonly number[];
4
+ /** `session.send` deliveries this host accepts (mirrored into integration-contract.json). */
5
+ export declare const SESSION_DELIVERIES: readonly ["queue", "steer", "interrupt"];
6
+ /**
7
+ * Capabilities `initialize` advertises (mirrored into integration-contract.json).
8
+ * `delegation`: session.delegate / session.waitSubagents and subagentRuntime.mode 'delegated'.
9
+ * `flowSessions`: session.send delivery 'interrupt' and per-driver capabilities.interrupt.
10
+ */
11
+ export declare const SESSION_HOST_CAPABILITIES: Readonly<{
12
+ readonly sessions: 1;
13
+ readonly delegation: 1;
14
+ readonly flowSessions: 1;
15
+ }>;
4
16
  /** Longest accepted request line. */
5
17
  export declare const MAX_FRAME_BYTES: number;
6
18
  type Schema = Record<string, unknown>;
@@ -1,6 +1,14 @@
1
1
  import { Ajv2020 } from 'ajv/dist/2020.js';
2
2
  /** Protocol versions this host speaks (docs/agent-sessions/protocol.md). */
3
3
  export const SESSION_PROTOCOL_VERSIONS = Object.freeze([1]);
4
+ /** `session.send` deliveries this host accepts (mirrored into integration-contract.json). */
5
+ export const SESSION_DELIVERIES = Object.freeze(['queue', 'steer', 'interrupt']);
6
+ /**
7
+ * Capabilities `initialize` advertises (mirrored into integration-contract.json).
8
+ * `delegation`: session.delegate / session.waitSubagents and subagentRuntime.mode 'delegated'.
9
+ * `flowSessions`: session.send delivery 'interrupt' and per-driver capabilities.interrupt.
10
+ */
11
+ export const SESSION_HOST_CAPABILITIES = Object.freeze({ sessions: 1, delegation: 1, flowSessions: 1 });
4
12
  /** Longest accepted request line. */
5
13
  export const MAX_FRAME_BYTES = 2 * 1024 * 1024;
6
14
  const id = { type: 'string', minLength: 1, maxLength: 200 };
@@ -20,7 +28,7 @@ const mcpServer = {
20
28
  };
21
29
  const limits = {
22
30
  type: 'object', additionalProperties: false,
23
- properties: Object.fromEntries(['idleMs', 'stallMs', 'backgroundMaxMs', 'turnInactivityMs', 'maxSettleHandoffs', 'settleDebounceMs'].map((key) => [key, { type: 'integer', minimum: 0 }])),
31
+ properties: Object.fromEntries(['idleMs', 'stallMs', 'backgroundMaxMs', 'turnInactivityMs', 'toolInactivityMs', 'maxSettleHandoffs', 'settleDebounceMs'].map((key) => [key, { type: 'integer', minimum: 0 }])),
24
32
  };
25
33
  const policy = {
26
34
  type: 'object', additionalProperties: false, required: ['subagents'],
@@ -58,7 +66,7 @@ export const METHOD_SCHEMAS = Object.freeze({
58
66
  },
59
67
  oneOf: [{ required: ['resume'] }, { required: ['driver', 'model', 'cwd', 'policy'] }],
60
68
  },
61
- 'session.send': { type: 'object', additionalProperties: false, required: ['sessionId', 'input'], properties: { sessionId: id, input: { type: 'object', additionalProperties: false, required: ['inputId', 'text', 'delivery'], properties: { inputId: id, text, attachments: { type: 'array', maxItems: 32, items: attachment }, delivery: { enum: ['queue', 'steer'] } } } } },
69
+ 'session.send': { type: 'object', additionalProperties: false, required: ['sessionId', 'input'], properties: { sessionId: id, input: { type: 'object', additionalProperties: false, required: ['inputId', 'text', 'delivery'], properties: { inputId: id, text, attachments: { type: 'array', maxItems: 32, items: attachment }, delivery: { enum: [...SESSION_DELIVERIES] } } } } },
62
70
  'session.interrupt': { type: 'object', additionalProperties: false, required: ['sessionId'], properties: { sessionId: id } },
63
71
  'session.stopSubagents': { type: 'object', additionalProperties: false, required: ['sessionId'], properties: { sessionId: id, subagentIds: { type: 'array', maxItems: 500, items: id } } },
64
72
  'session.delegate': { type: 'object', additionalProperties: false, required: ['sessionId', 'description', 'prompt'], properties: { sessionId: id, description: { type: 'string', minLength: 1, maxLength: 200 }, prompt: { type: 'string', minLength: 1, maxLength: 64 * 1024 }, agentType: { type: 'string', minLength: 1, maxLength: 100 }, contextTurns: { type: 'integer', minimum: 0, maximum: 10 } } },
@@ -1,6 +1,7 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
2
  import { SessionError, isSessionError } from '../domain/errors.js';
3
- import { MAX_FRAME_BYTES, RPC_APPLICATION_ERROR, RPC_INVALID_PARAMS, RPC_INVALID_REQUEST, RPC_METHOD_NOT_FOUND, RPC_PARSE_ERROR, SESSION_PROTOCOL_VERSIONS, validatorFor, } from './protocol.js';
3
+ import { redactSessionSnapshot } from '../domain/redaction.js';
4
+ import { MAX_FRAME_BYTES, RPC_APPLICATION_ERROR, RPC_INVALID_PARAMS, RPC_INVALID_REQUEST, RPC_METHOD_NOT_FOUND, RPC_PARSE_ERROR, SESSION_HOST_CAPABILITIES, SESSION_PROTOCOL_VERSIONS, validatorFor, } from './protocol.js';
4
5
  /**
5
6
  * JSON-RPC 2.0 server over NDJSON stdio (session protocol v1).
6
7
  * One handler per method (closed table); each validates its params with a
@@ -27,7 +28,8 @@ export class SessionHost {
27
28
  const service = runtime.service;
28
29
  this.handlers = Object.freeze({
29
30
  'initialize': async (params) => this.initialize(params),
30
- 'session.open': async (params) => service.open(params),
31
+ // Live snapshots come from the in-memory session: redact them like the journal does.
32
+ 'session.open': async (params) => { const opened = await service.open(params); return { ...opened, snapshot: redactSessionSnapshot(opened.snapshot) }; },
31
33
  'session.send': async (params) => service.send(String(params.sessionId), params.input),
32
34
  'session.interrupt': async (params) => service.interrupt(String(params.sessionId)),
33
35
  'session.delegate': async (params) => service.delegate(String(params.sessionId), {
@@ -40,7 +42,7 @@ export class SessionHost {
40
42
  'session.stopSubagents': async (params) => service.stopSubagents(String(params.sessionId), params.subagentIds),
41
43
  'session.update': async (params) => { const { sessionId, ...changes } = params; return service.update(String(sessionId), changes); },
42
44
  'session.close': async (params) => { await service.close(String(params.sessionId), String(params.reason)); return {}; },
43
- 'session.snapshot': async (params) => service.snapshot(String(params.sessionId)),
45
+ 'session.snapshot': async (params) => redactSessionSnapshot(service.snapshot(String(params.sessionId))),
44
46
  'session.events': async (params) => service.events(String(params.sessionId), Number(params.afterSeq), params.limit === undefined ? undefined : Number(params.limit)),
45
47
  'session.list': async (params) => ({ sessions: service.list(params.state ?? 'open') }),
46
48
  'host.ping': async () => ({ ...service.stats(), uptimeMs: Math.round(process.uptime() * 1000) }),
@@ -159,8 +161,7 @@ export class SessionHost {
159
161
  protocolVersion: Math.max(...requested),
160
162
  scope: this.runtime.scope,
161
163
  runtime: this.runtime.identity,
162
- // `delegation`: session.delegate / session.waitSubagents and subagentRuntime.mode 'delegated'.
163
- capabilities: { sessions: 1, delegation: 1 },
164
+ capabilities: { ...SESSION_HOST_CAPABILITIES },
164
165
  drivers: this.runtime.service.drivers(),
165
166
  };
166
167
  }
@@ -7,7 +7,7 @@ import type { Clock, DriverCatalog, Ids } from './ports.js';
7
7
  export { SessionError, isSessionError, type SessionErrorCode } from './domain/errors.js';
8
8
  export { SESSION_EVENT_TYPES, type SessionEvent, type SessionEventBody, type SessionEventEnvelope, type SessionEventType, type ToolActivity } from './domain/events.js';
9
9
  export type { SessionSnapshot, SubagentNode, TurnRecord, InputRecord, OpenTurn } from './domain/snapshot.js';
10
- export type { DriverCapabilities, DriverDescriptor, SessionLimits, SessionPolicy, Usage, McpServerSpec, Attachment } from './domain/types.js';
10
+ export type { DriverCapabilities, DriverDescriptor, InputDelivery, SessionLimits, SessionPolicy, Usage, McpServerSpec, Attachment } from './domain/types.js';
11
11
  export type { SessionPolicyInput } from './domain/policy.js';
12
12
  export { DEFAULT_LIMITS } from './domain/policy.js';
13
13
  export { SessionService, type OpenParams, type SendParams } from './application/session-service.js';
@@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
4
4
  import { sessionsRoot } from '../../../shared/specrails-home.js';
5
5
  import { PersistenceError, privateSqlitePath } from '../../engine/storage/private-path.js';
6
6
  import { SessionError } from '../domain/errors.js';
7
+ import { redactSessionEvent, redactSessionSnapshot } from '../domain/redaction.js';
7
8
  import { emptySnapshot, foldEvents } from '../domain/snapshot.js';
8
9
  import { JOURNAL_MIGRATIONS, JOURNAL_SCHEMA_VERSION } from './schema.js';
9
10
  export const JOURNAL_LEASE_MS = 60_000;
@@ -82,8 +83,11 @@ export class SqliteSessionJournal {
82
83
  const current = this.loadSnapshot(sessionId);
83
84
  if (!current)
84
85
  throw new SessionError('session_not_found', `Unknown session ${sessionId}`);
85
- const envelopes = events.map((event, index) => ({ sessionId, seq: current.lastSeq + index + 1, event }));
86
- const next = foldEvents(current, envelopes);
86
+ // Fold the events as produced (input fingerprints must match the live
87
+ // session), then persist and return only redacted copies: what is
88
+ // stored, published and replayed is the same redacted stream.
89
+ const next = redactSessionSnapshot(foldEvents(current, events.map((event, index) => ({ sessionId, seq: current.lastSeq + index + 1, event }))));
90
+ const envelopes = events.map((event, index) => ({ sessionId, seq: current.lastSeq + index + 1, event: redactSessionEvent(event) }));
87
91
  const insert = this.db.prepare('INSERT INTO events(session_id,seq,type,at,payload_json) VALUES (?,?,?,?,?)');
88
92
  for (const envelope of envelopes)
89
93
  insert.run(sessionId, envelope.seq, envelope.event.type, envelope.event.at, JSON.stringify(envelope.event));
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { SessionEvent, SessionEventEnvelope, ToolActivity } from './domain/events.js';
9
9
  import type { SessionSnapshot } from './domain/snapshot.js';
10
- import type { Attachment, DriverDescriptor, OutputChannel, SessionPolicy, SubagentKind, SubagentPhase } from './domain/types.js';
10
+ import type { Attachment, DriverDescriptor, InputDelivery, OutputChannel, SessionPolicy, SubagentKind, SubagentPhase } from './domain/types.js';
11
11
  import type { ReportedUsage, UsageBaseline } from './domain/usage.js';
12
12
  export interface DriverOpenSpec {
13
13
  sessionId: string;
@@ -25,7 +25,11 @@ export interface DriverInput {
25
25
  inputId: string;
26
26
  text: string;
27
27
  attachments?: Attachment[];
28
- delivery: 'queue' | 'steer';
28
+ /**
29
+ * Drivers receive `interrupt` only when they declare a native queue and a
30
+ * native interrupt: they stop the running turn and put this input first.
31
+ */
32
+ delivery: InputDelivery;
29
33
  /** Set by the application for policy-driven turns (e.g. collecting settled sub-agents). */
30
34
  origin?: 'user' | 'system';
31
35
  }
@@ -8,12 +8,12 @@ function assert(condition, message) {
8
8
  if (!condition)
9
9
  throw new Error(`Driver conformance: ${message}`);
10
10
  }
11
- async function oneTurn(harness) {
11
+ async function oneTurn(harness, delivery = 'queue') {
12
12
  const factory = harness.factory();
13
13
  const journal = new MemoryJournal();
14
14
  const service = new SessionService({ journal, drivers: catalogOf(factory), clock: new FakeClock(), ids: new SequentialIds() });
15
15
  const { sessionId } = await service.open({ driver: factory.descriptor.id, model: harness.model, cwd: process.cwd(), policy: { subagents: harness.subagents } });
16
- await service.send(sessionId, { inputId: 'conformance-1', text: 'Reply briefly.', delivery: 'queue' });
16
+ await service.send(sessionId, { inputId: 'conformance-1', text: 'Reply briefly.', delivery });
17
17
  await harness.settle(factory);
18
18
  const events = () => journal.events(sessionId).map((envelope) => envelope.event);
19
19
  return { factory, journal, service, sessionId, events };
@@ -67,6 +67,20 @@ export const DRIVER_CONFORMANCE = Object.freeze([
67
67
  assert(snapshot.openTurn === null && snapshot.phase === 'idle', 'the session is idle after a turn without sub-agents');
68
68
  },
69
69
  },
70
+ {
71
+ name: 'declares its interrupt mechanism and treats an interrupt on an idle session as a queued input',
72
+ async run(harness) {
73
+ const caps = harness.factory().descriptor.capabilities;
74
+ assert(caps.interrupt === 'native' || caps.interrupt === 'emulated', 'capabilities.interrupt is native or emulated');
75
+ const { events, service, sessionId } = await oneTurn(harness, 'interrupt');
76
+ const list = events();
77
+ const accepted = list.find((event) => event.type === 'input.accepted');
78
+ assert(accepted?.delivery === 'interrupt', 'the journal keeps the requested delivery');
79
+ const statuses = list.filter((event) => event.type === 'turn.completed').map((event) => event.status);
80
+ assert(statuses.length === 1 && statuses[0] === 'completed', 'nothing is interrupted on an idle session');
81
+ assert(service.snapshot(sessionId).inputs['conformance-1']?.state === 'completed', 'the input completes with its turn');
82
+ },
83
+ },
70
84
  {
71
85
  name: 'reports usage according to its declared semantics',
72
86
  async run(harness) {
@@ -1,3 +1,4 @@
1
+ import { redactSessionEvent, redactSessionSnapshot } from '../domain/redaction.js';
1
2
  import { emptySnapshot, foldEvents } from '../domain/snapshot.js';
2
3
  /**
3
4
  * In-memory SessionJournal with the same contract as the SQLite adapter:
@@ -23,8 +24,9 @@ export class MemoryJournal {
23
24
  this.failNextAppend = null;
24
25
  throw error;
25
26
  }
26
- const envelopes = events.map((event, index) => ({ sessionId, seq: entry.snapshot.lastSeq + index + 1, event: structuredClone(event) }));
27
- const snapshot = foldEvents(entry.snapshot, envelopes);
27
+ const snapshot = redactSessionSnapshot(foldEvents(entry.snapshot, events.map((event, index) => ({ sessionId, seq: entry.snapshot.lastSeq + index + 1, event }))));
28
+ // Same redaction boundary as the SQLite adapter (domain/redaction.ts).
29
+ const envelopes = events.map((event, index) => ({ sessionId, seq: entry.snapshot.lastSeq + index + 1, event: structuredClone(redactSessionEvent(event)) }));
28
30
  entry.events.push(...envelopes);
29
31
  entry.snapshot = snapshot;
30
32
  if (options?.baseline)
@@ -25,7 +25,8 @@ function usageText() {
25
25
  ' install-framework Materialize the versioned framework (offline)',
26
26
  ' swap-current Point framework/current at a version (offline)',
27
27
  ' assemble Link the framework into a project workspace (offline)',
28
- ' pipeline Inspect and verify durable implementation phases',
28
+ ' pipeline Inspect and verify durable implementation phases (pipeline trace: requirement ↔ test traceability)',
29
+ ' rules Inventory project gates and written rules (rules inventory → .specrails/rules.json)',
29
30
  ' runtime Run, inspect and resume programmatic agents',
30
31
  ' help Show this help message',
31
32
  ' version Print the installed version',
@@ -57,6 +58,8 @@ async function dispatch(subcommand, flags, positionals) {
57
58
  return runPipelineCommand(flags, positionals);
58
59
  case 'runtime':
59
60
  return (await import('../agent-runtime/cli.js')).runRuntimeCommand(flags, positionals);
61
+ case 'rules':
62
+ return (await import('../agent-runtime/project-rules.js')).runRulesCommand(flags, positionals);
60
63
  case 'install-framework':
61
64
  await runInstallFramework(flags);
62
65
  return 0;
@@ -79,6 +82,12 @@ async function dispatch(subcommand, flags, positionals) {
79
82
  }
80
83
  }
81
84
  export async function main(argv = process.argv.slice(2)) {
85
+ // `pipeline trace` keeps its own argv (repeatable, comma-separated lists) and
86
+ // judges execution with the runtime's full test-reachability check.
87
+ if (argv[0] === 'pipeline' && argv[1] === 'trace') {
88
+ const [{ runSpecTraceCli }, { commandReachesTest }] = await Promise.all([import('../pipeline/spec-trace.js'), import('../agent-runtime/compact/test-reachability.js')]);
89
+ return runSpecTraceCli(argv.slice(2), { reaches: commandReachesTest });
90
+ }
82
91
  const { subcommand, flags, positionals } = parseArgs(argv);
83
92
  // `--version` is global only before a command: `swap-current --version <v>`
84
93
  // names a framework version.
@@ -6,7 +6,7 @@ import { recordSuccessfulInstall } from '../util/registry.js';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { InstallerError } from '../util/errors.js';
8
8
  import { runCommand } from '../util/exec.js';
9
- import { info, ok, step } from '../util/logger.js';
9
+ import { info, ok, step, warn } from '../util/logger.js';
10
10
  import { isDir, isSymlink, listDir, pathExists, readTextFile, removePath, writeFileLf, } from '../util/fs.js';
11
11
  import { loadInstallConfig, resolveConfigPath, } from '../phases/install-config.js';
12
12
  import { checkPrerequisites } from '../phases/prereqs.js';
@@ -188,8 +188,8 @@ export async function runInit(flags) {
188
188
  assertNoCoreDowngrade(readTextFile(installedMarker).trim(), version);
189
189
  const previousSelections = snapshotWorkspaceProviderSelections(artifactRoot);
190
190
  const surfaces = [
191
- ...['.claude', '.codex', '.gemini', '.kimi-code', 'AGENTS.md', 'GEMINI.md', '.gitignore'].map((name) => path.join(artifactRoot, name)),
192
- ...['specrails-version', 'specrails-manifest.json', 'setup-templates', 'runtime'].map((name) => path.join(artifactRoot, '.specrails', name)),
191
+ ...['.claude', '.codex', '.gemini', '.kimi-code', 'AGENTS.md', 'GEMINI.md', 'CLAUDE.md', '.gitignore'].map((name) => path.join(artifactRoot, name)),
192
+ ...['specrails-version', 'specrails-manifest.json', 'setup-templates', 'runtime', 'rules.json'].map((name) => path.join(artifactRoot, '.specrails', name)),
193
193
  path.join(fwDir, 'current'),
194
194
  // Contents deliberately NOT snapshotted: `installFramework` rebuilds a
195
195
  // version tree whenever its content stamp does not match and stages
@@ -227,6 +227,17 @@ export async function runInit(flags) {
227
227
  }
228
228
  // openspec STAYS in the repo (codeRoot) — unchanged behaviour.
229
229
  await installOpenSpecProject(codeRoot, prereqs.provider, artifactRoot);
230
+ // Project rules inventory (gates + parsed rule statements). Offline here:
231
+ // init never spends on a provider; classifications from an earlier
232
+ // `specrails-core rules inventory` are kept for unchanged rules.
233
+ try {
234
+ const { runRulesInventory } = await import('../../agent-runtime/project-rules.js');
235
+ const { inventory } = await runRulesInventory({ root: codeRoot, artifactRoot, classify: false });
236
+ ok(`Project rules inventory: ${inventory.gates.length} gate(s), ${inventory.rules.length} rule(s) in .specrails/rules.json`);
237
+ }
238
+ catch (err) {
239
+ warn(`Project rules inventory skipped: ${err.message}`);
240
+ }
230
241
  if (artifactRoot !== codeRoot)
231
242
  recordSuccessfulInstall(repoRoot, { home: process.env.SPECRAILS_REGISTRY_HOME, providers: [prereqs.provider], coreVersion: version });
232
243
  step('Installation complete');
@@ -202,8 +202,15 @@ export function scaffoldInstallation(input) {
202
202
  if (skills.placed > 0)
203
203
  info(`Placed ${skills.placed} skill(s) into ${input.providerDir}/skills/`);
204
204
  }
205
- // --- Codex provider settings + AGENTS.md initial content ---
206
- if (input.provider === 'codex') {
205
+ // --- Provider settings + managed instruction block ---
206
+ if (input.provider === 'claude') {
207
+ const written = applyClaudeSettings(input);
208
+ copiedFiles += written;
209
+ if (written > 0) {
210
+ info(`Claude provider: wrote ${written} setting file(s) (CLAUDE.md)`);
211
+ }
212
+ }
213
+ else if (input.provider === 'codex') {
207
214
  const written = applyCodexSettings(input);
208
215
  copiedFiles += written;
209
216
  if (written > 0) {
@@ -544,7 +551,10 @@ function seedProjectLayer(input) {
544
551
  // Project-named instruction file (codex AGENTS.md / gemini GEMINI.md). Reuse
545
552
  // the same sentinel-upsert helpers via the settings appliers, scoped so they
546
553
  // ONLY emit the instruction file (the settings file is already linked above).
547
- if (input.provider === 'codex') {
554
+ if (input.provider === 'claude') {
555
+ seedInstructionFile(path.join(input.workspace, 'CLAUDE.md'), renderInitialClaudeMd(input.codeRoot));
556
+ }
557
+ else if (input.provider === 'codex') {
548
558
  seedInstructionFile(path.join(input.workspace, 'AGENTS.md'), renderInitialAgentsMd(input.codeRoot));
549
559
  }
550
560
  else if (input.provider === 'gemini') {
@@ -991,6 +1001,39 @@ function renderInitialKimiAgentsMd(repoRoot) {
991
1001
  '',
992
1002
  ].join('\n');
993
1003
  }
1004
+ /**
1005
+ * Claude Code provider settings: the sentinel-protected Specrails block in the
1006
+ * root `CLAUDE.md` (the instruction file Claude Code loads on startup), upserted
1007
+ * exactly like codex `AGENTS.md` and gemini `GEMINI.md` so user content outside
1008
+ * the block survives. Returns files written.
1009
+ */
1010
+ function applyClaudeSettings(input) {
1011
+ const claudeMdPath = path.join(input.artifactRoot, 'CLAUDE.md');
1012
+ const content = renderInitialClaudeMd(input.codeRoot);
1013
+ if (!pathExists(claudeMdPath)) {
1014
+ writeFileLf(claudeMdPath, content);
1015
+ return 1;
1016
+ }
1017
+ const existing = readTextFile(claudeMdPath);
1018
+ const next = upsertAgentsMdManagedBlock(existing, extractManagedBlock(content));
1019
+ if (next === existing)
1020
+ return 0;
1021
+ writeFileLf(claudeMdPath, next);
1022
+ return 1;
1023
+ }
1024
+ function renderInitialClaudeMd(repoRoot) {
1025
+ return renderInitialAgentsMd(repoRoot).replace(AGENTS_MD_END, [
1026
+ '## Project rules',
1027
+ '',
1028
+ '- `.specrails/rules.json` (refreshed by `specrails-core rules inventory`)',
1029
+ ' lists the gates this repository already enforces and how each written',
1030
+ ' rule is checked. Run the gates that cover your change; rules marked',
1031
+ ' `humanOnly` are for the human reviewer, never claim them verified.',
1032
+ '- Read provider rules from `.claude/rules/` when present.',
1033
+ '',
1034
+ AGENTS_MD_END,
1035
+ ].join('\n'));
1036
+ }
994
1037
  function renderInitialGeminiMd(repoRoot) {
995
1038
  return renderInitialAgentsMd(repoRoot).replace(AGENTS_MD_END, 'Read provider rules from `.gemini/`.\n\n' + AGENTS_MD_END);
996
1039
  }
@@ -1010,6 +1053,14 @@ function placePipelineRuntime(input) {
1010
1053
  return 0;
1011
1054
  const dest = path.join(input.artifactRoot, '.specrails', 'runtime');
1012
1055
  copyFile(source, path.join(dest, 'pipeline-state.mjs'));
1056
+ // Requirement ↔ test traceability, self-contained like pipeline-state: `node .specrails/runtime/trace.mjs --check`.
1057
+ const trace = path.join(input.scriptDir, 'dist', 'pipeline', 'spec-trace.js');
1058
+ const traced = pathExists(trace) ? 2 : 0;
1059
+ if (traced) {
1060
+ copyFile(trace, path.join(dest, 'spec-trace.mjs'));
1061
+ writeFileLf(path.join(dest, 'trace.mjs'), "import { runSpecTraceCli } from './spec-trace.mjs'\n" +
1062
+ "process.exitCode = await runSpecTraceCli(process.argv.slice(2))\n");
1063
+ }
1013
1064
  writeFileLf(path.join(dest, 'pipeline.mjs'), "import { runPipelineCli } from './pipeline-state.mjs'\n" +
1014
1065
  "process.exitCode = await runPipelineCli(process.argv.slice(2))\n");
1015
1066
  const runtime = path.join(input.scriptDir, 'dist', 'agent-runtime', 'cli.js');
@@ -1026,9 +1077,9 @@ function placePipelineRuntime(input) {
1026
1077
  agents: Object.fromEntries(['architect', 'developer', 'reviewer'].map(role => [role, { provider, maxTurns: 100 }])), verification: [],
1027
1078
  }, null, 2) + '\n');
1028
1079
  }
1029
- return 3;
1080
+ return 3 + traced;
1030
1081
  }
1031
- return 2;
1082
+ return 2 + traced;
1032
1083
  }
1033
1084
  function pruneLegacyArtifacts(input) {
1034
1085
  const legacyPaths = [
@@ -147,10 +147,19 @@ export interface AcceptanceCheck {
147
147
  scope: string;
148
148
  limitations: string;
149
149
  }
150
+ /** Where in the change one finding applies (contract 5.9). `finding` indexes `findings`; `path` is repository-relative. */
151
+ export interface FindingLocation {
152
+ finding: number;
153
+ path: string;
154
+ line?: number;
155
+ endLine?: number;
156
+ }
150
157
  export interface AcceptanceReport {
151
158
  criteria: AcceptanceCriterion[];
152
159
  checks: AcceptanceCheck[];
153
160
  findings: string[];
161
+ /** Optional anchors for findings (contract 5.9); absent when the reviewer named none. */
162
+ findingLocations?: FindingLocation[];
154
163
  }
155
164
  export interface AcceptanceReceipt extends AcceptanceReport {
156
165
  scopeHash: string;
@@ -272,10 +281,23 @@ declare function inspectReceipt(state: PipelineState, env?: NodeJS.ProcessEnv):
272
281
  reasons: string[];
273
282
  receipt?: VerificationReceipt;
274
283
  };
284
+ /**
285
+ * The requirement a spec without acceptance criteria freezes: its description
286
+ * minus the intent block. A hypothesis is validated with users after the
287
+ * change ships and its success signals are measured then; freezing them would
288
+ * make the reviewer demand evidence no code can give.
289
+ */
290
+ export declare function requirementFromDescription(description: string, title: string): string;
275
291
  /** The frozen requirements an acceptance report must certify, in scope order. */
276
292
  export declare function frozenAcceptanceCriteria(contextInput: unknown): Array<Pick<AcceptanceCriterion, 'specId' | 'criterionIndex' | 'requirement'>>;
277
293
  /** Validates a report against the frozen scope without recording it; throws the same errors `recordAcceptance` would. */
278
294
  export declare function validateAcceptanceReport(contextInput: unknown, input: unknown): AcceptanceReport;
295
+ /**
296
+ * Keeps only well-formed anchors for the findings that exist. A malformed
297
+ * anchor is dropped, never fatal: a location is advisory and must not fail an
298
+ * otherwise valid review.
299
+ */
300
+ export declare function sanitizeFindingLocations(value: unknown, findingCount: number): FindingLocation[];
279
301
  declare function inspectAcceptance(state: PipelineState): {
280
302
  valid: boolean;
281
303
  status: PipelineCompletion['validation'];
@@ -296,7 +296,7 @@ export function bindImplementationExclusions(contextInput, exclusions) {
296
296
  continue; // A scoped child receives the parent union, filtered to its frozen repositories.
297
297
  repositoryExclusions[id] = [...new Set(paths)].sort();
298
298
  for (const item of repositoryExclusions[id]) {
299
- if (!/^(?:[a-zA-Z0-9][a-zA-Z0-9._-]*\/)*openspec\/(?:changes|specs)\/[a-z0-9][a-z0-9./-]*$/.test(item))
299
+ if (!/^(?:[a-zA-Z0-9][a-zA-Z0-9._-]*\/)*openspec\/(?:(?:changes|specs)\/[a-z0-9][a-z0-9./-]*|capability-map\.json)$/.test(item))
300
300
  fail('Implementation artifact exclusion is outside declared OpenSpec artifacts');
301
301
  safeChild(repository.path, item);
302
302
  }
@@ -485,7 +485,8 @@ export function initializePipeline(contextInput, change) {
485
485
  schemaVersion: 1, runId: context.runId, change, context, scopeHash: digest(canonical(context)), revision: 0,
486
486
  createdAt: now, updatedAt: now,
487
487
  phases: Object.fromEntries(PHASES.map((phase) => [phase, { status: 'pending' }])),
488
- artifactExclusions: ['openspec/changes/' + change],
488
+ // The capability map is Core metadata the archive step rewrites (graph/capability-map.ts), never part of the reviewed candidate.
489
+ artifactExclusions: ['openspec/changes/' + change, 'openspec/capability-map.json'],
489
490
  };
490
491
  atomicJson(path.join(pipelineStateDirectory(context), 'context.json'), context);
491
492
  saveState(state);
@@ -603,8 +604,26 @@ function taskGate(state) {
603
604
  }
604
605
  /** Stable indices refer to the frozen scope, never to rewritten design text.
605
606
  * When no explicit AC list exists, the frozen description is the requirement. */
607
+ /** Heading of the intent block a host prepends to a spec's description (Desktop
608
+ * `renderIntentForAgents`): why and outcome, never a requirement to certify. */
609
+ const INTENT_BLOCK_HEADING = /^## Intent \(/m;
610
+ /**
611
+ * The requirement a spec without acceptance criteria freezes: its description
612
+ * minus the intent block. A hypothesis is validated with users after the
613
+ * change ships and its success signals are measured then; freezing them would
614
+ * make the reviewer demand evidence no code can give.
615
+ */
616
+ export function requirementFromDescription(description, title) {
617
+ const start = description.search(INTENT_BLOCK_HEADING);
618
+ if (start === -1)
619
+ return description || title;
620
+ const rest = description.slice(start + 1);
621
+ const next = rest.search(/^## /m);
622
+ const stripped = (description.slice(0, start) + (next === -1 ? '' : rest.slice(next))).trim();
623
+ return stripped || title;
624
+ }
606
625
  function frozenCriteria(context) {
607
- return context.specs.flatMap(spec => (spec.acceptanceCriteria?.length ? spec.acceptanceCriteria : [spec.description || spec.title])
626
+ return context.specs.flatMap(spec => (spec.acceptanceCriteria?.length ? spec.acceptanceCriteria : [requirementFromDescription(spec.description, spec.title)])
608
627
  .map((requirement, criterionIndex) => ({ specId: String(spec.id), criterionIndex, requirement })));
609
628
  }
610
629
  /** The frozen requirements an acceptance report must certify, in scope order. */
@@ -615,6 +634,36 @@ export function frozenAcceptanceCriteria(contextInput) {
615
634
  export function validateAcceptanceReport(contextInput, input) {
616
635
  return acceptanceReport(validatePipelineContext(contextInput), input);
617
636
  }
637
+ const MAX_FINDING_LOCATIONS = 50;
638
+ /**
639
+ * Keeps only well-formed anchors for the findings that exist. A malformed
640
+ * anchor is dropped, never fatal: a location is advisory and must not fail an
641
+ * otherwise valid review.
642
+ */
643
+ export function sanitizeFindingLocations(value, findingCount) {
644
+ if (!Array.isArray(value))
645
+ return [];
646
+ const out = [];
647
+ const seen = new Set();
648
+ for (const raw of value) {
649
+ if (out.length >= MAX_FINDING_LOCATIONS)
650
+ break;
651
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
652
+ continue;
653
+ const row = raw;
654
+ const finding = row.finding;
655
+ if (typeof finding !== 'number' || !Number.isInteger(finding) || finding < 0 || finding >= findingCount || seen.has(finding))
656
+ continue;
657
+ const filePath = typeof row.path === 'string' ? row.path.trim().replace(/\\/g, '/').replace(/^\.\//, '') : '';
658
+ if (!filePath || filePath.length > 500 || filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) || filePath.includes('\0') || filePath.split('/').includes('..'))
659
+ continue;
660
+ const line = typeof row.line === 'number' && Number.isInteger(row.line) && row.line >= 1 && row.line <= 10_000_000 ? row.line : undefined;
661
+ const endLine = line !== undefined && typeof row.endLine === 'number' && Number.isInteger(row.endLine) && row.endLine > line && row.endLine <= 10_000_000 ? row.endLine : undefined;
662
+ seen.add(finding);
663
+ out.push({ finding, path: filePath, ...(line !== undefined ? { line } : {}), ...(endLine !== undefined ? { endLine } : {}) });
664
+ }
665
+ return out;
666
+ }
618
667
  function nonempty(value) { return typeof value === 'string' && value.trim().length > 0; }
619
668
  function evidence(value) { return Array.isArray(value) && value.length > 0 && value.every(nonempty); }
620
669
  function acceptanceReport(context, input) {
@@ -655,7 +704,9 @@ function acceptanceReport(context, input) {
655
704
  fail('Check needs status, evidence, measurement scope and limitations');
656
705
  return { name: check.name, status: check.status, required: check.required, evidence: check.evidence, scope: check.scope, limitations: check.limitations };
657
706
  });
658
- return { criteria, checks, findings: report.findings };
707
+ const findings = report.findings;
708
+ const findingLocations = sanitizeFindingLocations(report.findingLocations, findings.length);
709
+ return { criteria, checks, findings, ...(findingLocations.length ? { findingLocations } : {}) };
659
710
  }
660
711
  function inspectAcceptance(state) {
661
712
  const receipt = state.acceptance;