specrails-core 6.6.4 → 6.7.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.
- package/dist/agent-runtime/capabilities.d.ts +5 -2
- package/dist/agent-runtime/capabilities.js +8 -2
- package/dist/agent-runtime/change-scope.d.ts +2 -0
- package/dist/agent-runtime/change-scope.js +5 -0
- package/dist/agent-runtime/cli-executor.js +7 -0
- package/dist/agent-runtime/cli.d.ts +6 -1
- package/dist/agent-runtime/cli.js +15 -4
- package/dist/agent-runtime/compact/reviewer.js +3 -1
- package/dist/agent-runtime/config.js +16 -5
- package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
- package/dist/agent-runtime/efficiency-summary.js +7 -1
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
- package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
- package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
- package/dist/agent-runtime/engine/cli.d.ts +12 -0
- package/dist/agent-runtime/engine/cli.js +15 -4
- package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-schema.js +1 -0
- package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-types.js +1 -1
- package/dist/agent-runtime/engine/definition-validator.js +8 -0
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
- package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
- package/dist/agent-runtime/engine/invocation-context.js +2 -0
- package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
- package/dist/agent-runtime/engine/piece-registry.js +1 -1
- package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
- package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
- package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
- package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
- package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
- package/dist/agent-runtime/engine/pieces/index.js +2 -1
- package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
- package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
- package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
- package/dist/agent-runtime/engine/preflight.d.ts +2 -1
- package/dist/agent-runtime/engine/preflight.js +3 -1
- package/dist/agent-runtime/engine/regex.d.ts +12 -2
- package/dist/agent-runtime/engine/regex.js +25 -11
- package/dist/agent-runtime/engine/run-status.d.ts +34 -0
- package/dist/agent-runtime/engine/run-status.js +7 -1
- package/dist/agent-runtime/engine/runs.d.ts +56 -0
- package/dist/agent-runtime/engine/runs.js +88 -5
- package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
- package/dist/agent-runtime/engine/steering/inbox.js +21 -1
- package/dist/agent-runtime/engine/storage/private-path.js +7 -1
- package/dist/agent-runtime/evaluation.js +1 -1
- package/dist/agent-runtime/executor-types.d.ts +19 -0
- package/dist/agent-runtime/executor-types.js +10 -0
- package/dist/agent-runtime/failure-classification.d.ts +51 -0
- package/dist/agent-runtime/failure-classification.js +110 -0
- package/dist/agent-runtime/graph/archive-preflight.d.ts +3 -18
- package/dist/agent-runtime/graph/archive-preflight.js +6 -51
- package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
- package/dist/agent-runtime/graph/artifacts.js +6 -2
- package/dist/agent-runtime/graph/baseline.d.ts +50 -0
- package/dist/agent-runtime/graph/baseline.js +145 -0
- package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
- package/dist/agent-runtime/graph/capability-map.js +65 -0
- package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
- package/dist/agent-runtime/graph/clarifications.js +158 -0
- package/dist/agent-runtime/graph/nodes.js +178 -19
- package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
- package/dist/agent-runtime/graph/review-brief-record.js +26 -0
- package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
- package/dist/agent-runtime/graph/review-brief.js +127 -0
- package/dist/agent-runtime/graph/roles.js +5 -3
- package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
- package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
- package/dist/agent-runtime/graph/state.d.ts +8 -0
- package/dist/agent-runtime/index.d.ts +7 -0
- package/dist/agent-runtime/index.js +7 -0
- package/dist/agent-runtime/openspec.d.ts +2 -1
- package/dist/agent-runtime/openspec.js +11 -5
- package/dist/agent-runtime/project-rules.d.ts +139 -0
- package/dist/agent-runtime/project-rules.js +683 -0
- package/dist/agent-runtime/prompts.d.ts +32 -1
- package/dist/agent-runtime/prompts.js +180 -7
- package/dist/agent-runtime/role-routing.d.ts +2 -1
- package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
- package/dist/agent-runtime/session/application/active-session.js +163 -7
- package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
- package/dist/agent-runtime/session/domain/descriptor.js +5 -0
- package/dist/agent-runtime/session/domain/interruption.js +1 -0
- package/dist/agent-runtime/session/domain/policy.js +2 -0
- package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
- package/dist/agent-runtime/session/domain/redaction.js +71 -0
- package/dist/agent-runtime/session/domain/types.d.ts +17 -1
- package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
- package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
- package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
- package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
- package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
- package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
- package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
- package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
- package/dist/agent-runtime/session/host/protocol.js +10 -2
- package/dist/agent-runtime/session/host/server.js +6 -5
- package/dist/agent-runtime/session/index.d.ts +1 -1
- package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
- package/dist/agent-runtime/session/ports.d.ts +6 -2
- package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
- package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
- package/dist/installer/cli.js +10 -1
- package/dist/installer/commands/init.js +15 -4
- package/dist/installer/phases/prereqs.d.ts +1 -1
- package/dist/installer/phases/prereqs.js +2 -2
- package/dist/installer/phases/scaffold.js +59 -11
- package/dist/pipeline/pipeline-state.d.ts +15 -0
- package/dist/pipeline/pipeline-state.js +36 -3
- package/dist/pipeline/spec-trace.d.ts +134 -0
- package/dist/pipeline/spec-trace.js +392 -0
- package/dist/shared/secret-redaction.d.ts +42 -0
- package/dist/shared/secret-redaction.js +184 -0
- package/integration-contract.json +177 -7
- package/package.json +8 -8
- package/pinned-versions.json +1 -1
- package/schemas/agent-runtime.schema.json +3 -1
- package/schemas/workflow-definition.schema.json +1 -0
- 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: [
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
27
|
-
|
|
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)
|
package/dist/installer/cli.js
CHANGED
|
@@ -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');
|
|
@@ -340,7 +351,7 @@ function readPinnedVersion(scriptDir, key, fallback) {
|
|
|
340
351
|
* 3. neither set → `runCommand('npx', ['--yes', '-p', '@fission-ai/openspec@<pinned>', '--', 'openspec', 'init', …])`
|
|
341
352
|
* (default online path — users never need a global install).
|
|
342
353
|
*/
|
|
343
|
-
export function buildOpenSpecInvocation(repoRoot, provider, env = process.env, pinnedVersion = readPinnedVersion(resolveScriptDir(), 'openspec', '1.
|
|
354
|
+
export function buildOpenSpecInvocation(repoRoot, provider, env = process.env, pinnedVersion = readPinnedVersion(resolveScriptDir(), 'openspec', '1.14.3')) {
|
|
344
355
|
const bin = env.SPECRAILS_OPENSPEC_BIN;
|
|
345
356
|
const node = env.SPECRAILS_OPENSPEC_NODE;
|
|
346
357
|
const initArgs = ['init', '--tools', provider, '--profile', 'custom', repoRoot];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Provider, type ProviderAvailability } from './provider-detect.js';
|
|
2
2
|
/**
|
|
3
|
-
* OpenSpec 1.
|
|
3
|
+
* OpenSpec 1.14.3, which the installer invokes during the default init/update
|
|
4
4
|
* flow, requires Node >=20.19.0. Keep this floor independent from Kimi's npm
|
|
5
5
|
* package requirement: SpecRails launches an externally installed Kimi CLI and
|
|
6
6
|
* does not require the Kimi npm distribution.
|
|
@@ -6,7 +6,7 @@ import { info, ok, warn } from '../util/logger.js';
|
|
|
6
6
|
import { gitInstalled, initRepo, isGitRepo, repoRoot } from '../util/git.js';
|
|
7
7
|
import { assertClaudeAuthenticated, assertKimiAuthenticated, claudeVersion, detectAvailability, isSupportedKimiVersion, kimiVersion, MIN_KIMI_VERSION, resolveProvider, } from './provider-detect.js';
|
|
8
8
|
/**
|
|
9
|
-
* OpenSpec 1.
|
|
9
|
+
* OpenSpec 1.14.3, which the installer invokes during the default init/update
|
|
10
10
|
* flow, requires Node >=20.19.0. Keep this floor independent from Kimi's npm
|
|
11
11
|
* package requirement: SpecRails launches an externally installed Kimi CLI and
|
|
12
12
|
* does not require the Kimi npm distribution.
|
|
@@ -34,7 +34,7 @@ export async function checkPrerequisites(options) {
|
|
|
34
34
|
const nodeVersion = process.versions.node;
|
|
35
35
|
if (!isSupportedNodeVersion(nodeVersion)) {
|
|
36
36
|
throw new PrerequisiteError(`Node.js ${nodeVersion} is unsupported. SpecRails requires Node.js ` +
|
|
37
|
-
`${MIN_NODE_VERSION} or newer because OpenSpec 1.
|
|
37
|
+
`${MIN_NODE_VERSION} or newer because OpenSpec 1.14.3 uses that runtime floor. ` +
|
|
38
38
|
'Install a supported Node.js release from https://nodejs.org/');
|
|
39
39
|
}
|
|
40
40
|
ok(`Node.js: ${nodeVersion}`);
|
|
@@ -125,9 +125,8 @@ export function scaffoldInstallation(input) {
|
|
|
125
125
|
// --- Directory skeleton ---
|
|
126
126
|
mk(path.join(input.artifactRoot, input.providerDir));
|
|
127
127
|
if (input.provider === 'codex') {
|
|
128
|
-
// Codex skills live under <providerDir>/skills/ (e.g. .codex/skills/).
|
|
129
|
-
//
|
|
130
|
-
// that was a placeholder name from the gated state.
|
|
128
|
+
// Specrails' Codex skills live under <providerDir>/skills/ (e.g. .codex/skills/).
|
|
129
|
+
// OpenSpec's own Codex skills live under `.agents/skills/`.
|
|
131
130
|
mk(path.join(input.artifactRoot, input.providerDir, 'skills'));
|
|
132
131
|
}
|
|
133
132
|
else if (input.provider === 'gemini') {
|
|
@@ -203,8 +202,15 @@ export function scaffoldInstallation(input) {
|
|
|
203
202
|
if (skills.placed > 0)
|
|
204
203
|
info(`Placed ${skills.placed} skill(s) into ${input.providerDir}/skills/`);
|
|
205
204
|
}
|
|
206
|
-
// ---
|
|
207
|
-
if (input.provider === '
|
|
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') {
|
|
208
214
|
const written = applyCodexSettings(input);
|
|
209
215
|
copiedFiles += written;
|
|
210
216
|
if (written > 0) {
|
|
@@ -545,7 +551,10 @@ function seedProjectLayer(input) {
|
|
|
545
551
|
// Project-named instruction file (codex AGENTS.md / gemini GEMINI.md). Reuse
|
|
546
552
|
// the same sentinel-upsert helpers via the settings appliers, scoped so they
|
|
547
553
|
// ONLY emit the instruction file (the settings file is already linked above).
|
|
548
|
-
if (input.provider === '
|
|
554
|
+
if (input.provider === 'claude') {
|
|
555
|
+
seedInstructionFile(path.join(input.workspace, 'CLAUDE.md'), renderInitialClaudeMd(input.codeRoot));
|
|
556
|
+
}
|
|
557
|
+
else if (input.provider === 'codex') {
|
|
549
558
|
seedInstructionFile(path.join(input.workspace, 'AGENTS.md'), renderInitialAgentsMd(input.codeRoot));
|
|
550
559
|
}
|
|
551
560
|
else if (input.provider === 'gemini') {
|
|
@@ -992,6 +1001,39 @@ function renderInitialKimiAgentsMd(repoRoot) {
|
|
|
992
1001
|
'',
|
|
993
1002
|
].join('\n');
|
|
994
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
|
+
}
|
|
995
1037
|
function renderInitialGeminiMd(repoRoot) {
|
|
996
1038
|
return renderInitialAgentsMd(repoRoot).replace(AGENTS_MD_END, 'Read provider rules from `.gemini/`.\n\n' + AGENTS_MD_END);
|
|
997
1039
|
}
|
|
@@ -1011,6 +1053,14 @@ function placePipelineRuntime(input) {
|
|
|
1011
1053
|
return 0;
|
|
1012
1054
|
const dest = path.join(input.artifactRoot, '.specrails', 'runtime');
|
|
1013
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
|
+
}
|
|
1014
1064
|
writeFileLf(path.join(dest, 'pipeline.mjs'), "import { runPipelineCli } from './pipeline-state.mjs'\n" +
|
|
1015
1065
|
"process.exitCode = await runPipelineCli(process.argv.slice(2))\n");
|
|
1016
1066
|
const runtime = path.join(input.scriptDir, 'dist', 'agent-runtime', 'cli.js');
|
|
@@ -1027,9 +1077,9 @@ function placePipelineRuntime(input) {
|
|
|
1027
1077
|
agents: Object.fromEntries(['architect', 'developer', 'reviewer'].map(role => [role, { provider, maxTurns: 100 }])), verification: [],
|
|
1028
1078
|
}, null, 2) + '\n');
|
|
1029
1079
|
}
|
|
1030
|
-
return 3;
|
|
1080
|
+
return 3 + traced;
|
|
1031
1081
|
}
|
|
1032
|
-
return 2;
|
|
1082
|
+
return 2 + traced;
|
|
1033
1083
|
}
|
|
1034
1084
|
function pruneLegacyArtifacts(input) {
|
|
1035
1085
|
const legacyPaths = [
|
|
@@ -1039,9 +1089,7 @@ function pruneLegacyArtifacts(input) {
|
|
|
1039
1089
|
path.join(input.artifactRoot, '.specrails-version'),
|
|
1040
1090
|
];
|
|
1041
1091
|
if (input.provider === 'codex') {
|
|
1042
|
-
//
|
|
1043
|
-
// legacy install before settling on the canonical `.codex/skills/`.
|
|
1044
|
-
legacyPaths.push(path.join(input.artifactRoot, '.agents'));
|
|
1092
|
+
// `.agents/skills/` is not pruned: OpenSpec 1.14+ writes its Codex skills there.
|
|
1045
1093
|
legacyPaths.push(path.join(input.artifactRoot, input.providerDir, 'skills', 'setup'));
|
|
1046
1094
|
}
|
|
1047
1095
|
else if (input.provider === 'gemini') {
|
|
@@ -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;
|
|
@@ -276,6 +285,12 @@ declare function inspectReceipt(state: PipelineState, env?: NodeJS.ProcessEnv):
|
|
|
276
285
|
export declare function frozenAcceptanceCriteria(contextInput: unknown): Array<Pick<AcceptanceCriterion, 'specId' | 'criterionIndex' | 'requirement'>>;
|
|
277
286
|
/** Validates a report against the frozen scope without recording it; throws the same errors `recordAcceptance` would. */
|
|
278
287
|
export declare function validateAcceptanceReport(contextInput: unknown, input: unknown): AcceptanceReport;
|
|
288
|
+
/**
|
|
289
|
+
* Keeps only well-formed anchors for the findings that exist. A malformed
|
|
290
|
+
* anchor is dropped, never fatal: a location is advisory and must not fail an
|
|
291
|
+
* otherwise valid review.
|
|
292
|
+
*/
|
|
293
|
+
export declare function sanitizeFindingLocations(value: unknown, findingCount: number): FindingLocation[];
|
|
279
294
|
declare function inspectAcceptance(state: PipelineState): {
|
|
280
295
|
valid: boolean;
|
|
281
296
|
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./-]
|
|
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
|
-
|
|
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);
|
|
@@ -615,6 +616,36 @@ export function frozenAcceptanceCriteria(contextInput) {
|
|
|
615
616
|
export function validateAcceptanceReport(contextInput, input) {
|
|
616
617
|
return acceptanceReport(validatePipelineContext(contextInput), input);
|
|
617
618
|
}
|
|
619
|
+
const MAX_FINDING_LOCATIONS = 50;
|
|
620
|
+
/**
|
|
621
|
+
* Keeps only well-formed anchors for the findings that exist. A malformed
|
|
622
|
+
* anchor is dropped, never fatal: a location is advisory and must not fail an
|
|
623
|
+
* otherwise valid review.
|
|
624
|
+
*/
|
|
625
|
+
export function sanitizeFindingLocations(value, findingCount) {
|
|
626
|
+
if (!Array.isArray(value))
|
|
627
|
+
return [];
|
|
628
|
+
const out = [];
|
|
629
|
+
const seen = new Set();
|
|
630
|
+
for (const raw of value) {
|
|
631
|
+
if (out.length >= MAX_FINDING_LOCATIONS)
|
|
632
|
+
break;
|
|
633
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
634
|
+
continue;
|
|
635
|
+
const row = raw;
|
|
636
|
+
const finding = row.finding;
|
|
637
|
+
if (typeof finding !== 'number' || !Number.isInteger(finding) || finding < 0 || finding >= findingCount || seen.has(finding))
|
|
638
|
+
continue;
|
|
639
|
+
const filePath = typeof row.path === 'string' ? row.path.trim().replace(/\\/g, '/').replace(/^\.\//, '') : '';
|
|
640
|
+
if (!filePath || filePath.length > 500 || filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath) || filePath.includes('\0') || filePath.split('/').includes('..'))
|
|
641
|
+
continue;
|
|
642
|
+
const line = typeof row.line === 'number' && Number.isInteger(row.line) && row.line >= 1 && row.line <= 10_000_000 ? row.line : undefined;
|
|
643
|
+
const endLine = line !== undefined && typeof row.endLine === 'number' && Number.isInteger(row.endLine) && row.endLine > line && row.endLine <= 10_000_000 ? row.endLine : undefined;
|
|
644
|
+
seen.add(finding);
|
|
645
|
+
out.push({ finding, path: filePath, ...(line !== undefined ? { line } : {}), ...(endLine !== undefined ? { endLine } : {}) });
|
|
646
|
+
}
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
618
649
|
function nonempty(value) { return typeof value === 'string' && value.trim().length > 0; }
|
|
619
650
|
function evidence(value) { return Array.isArray(value) && value.length > 0 && value.every(nonempty); }
|
|
620
651
|
function acceptanceReport(context, input) {
|
|
@@ -655,7 +686,9 @@ function acceptanceReport(context, input) {
|
|
|
655
686
|
fail('Check needs status, evidence, measurement scope and limitations');
|
|
656
687
|
return { name: check.name, status: check.status, required: check.required, evidence: check.evidence, scope: check.scope, limitations: check.limitations };
|
|
657
688
|
});
|
|
658
|
-
|
|
689
|
+
const findings = report.findings;
|
|
690
|
+
const findingLocations = sanitizeFindingLocations(report.findingLocations, findings.length);
|
|
691
|
+
return { criteria, checks, findings, ...(findingLocations.length ? { findingLocations } : {}) };
|
|
659
692
|
}
|
|
660
693
|
function inspectAcceptance(state) {
|
|
661
694
|
const receipt = state.acceptance;
|