specrails-core 5.2.2 → 5.3.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 (102) hide show
  1. package/README.md +3 -0
  2. package/bin/specrails-core.mjs +5 -0
  3. package/dist/agent-runtime/cli-executor.d.ts +34 -0
  4. package/dist/agent-runtime/cli-executor.js +335 -0
  5. package/dist/agent-runtime/cli-executor.js.map +1 -0
  6. package/dist/agent-runtime/cli-process.d.ts +32 -0
  7. package/dist/agent-runtime/cli-process.js +159 -0
  8. package/dist/agent-runtime/cli-process.js.map +1 -0
  9. package/dist/agent-runtime/cli.d.ts +38 -0
  10. package/dist/agent-runtime/cli.js +168 -0
  11. package/dist/agent-runtime/cli.js.map +1 -0
  12. package/dist/agent-runtime/config.d.ts +7 -0
  13. package/dist/agent-runtime/config.js +162 -0
  14. package/dist/agent-runtime/config.js.map +1 -0
  15. package/dist/agent-runtime/core-host.d.ts +33 -0
  16. package/dist/agent-runtime/core-host.js +97 -0
  17. package/dist/agent-runtime/core-host.js.map +1 -0
  18. package/dist/agent-runtime/durable-store.d.ts +22 -0
  19. package/dist/agent-runtime/durable-store.js +205 -0
  20. package/dist/agent-runtime/durable-store.js.map +1 -0
  21. package/dist/agent-runtime/executor-types.d.ts +92 -0
  22. package/dist/agent-runtime/executor-types.js +29 -0
  23. package/dist/agent-runtime/executor-types.js.map +1 -0
  24. package/dist/agent-runtime/executors.d.ts +22 -0
  25. package/dist/agent-runtime/executors.js +44 -0
  26. package/dist/agent-runtime/executors.js.map +1 -0
  27. package/dist/agent-runtime/gemini-policy.d.ts +8 -0
  28. package/dist/agent-runtime/gemini-policy.js +36 -0
  29. package/dist/agent-runtime/gemini-policy.js.map +1 -0
  30. package/dist/agent-runtime/graph/artifacts.d.ts +40 -0
  31. package/dist/agent-runtime/graph/artifacts.js +179 -0
  32. package/dist/agent-runtime/graph/artifacts.js.map +1 -0
  33. package/dist/agent-runtime/graph/nodes.d.ts +36 -0
  34. package/dist/agent-runtime/graph/nodes.js +274 -0
  35. package/dist/agent-runtime/graph/nodes.js.map +1 -0
  36. package/dist/agent-runtime/graph/review-policy.d.ts +18 -0
  37. package/dist/agent-runtime/graph/review-policy.js +32 -0
  38. package/dist/agent-runtime/graph/review-policy.js.map +1 -0
  39. package/dist/agent-runtime/graph/roles.d.ts +44 -0
  40. package/dist/agent-runtime/graph/roles.js +126 -0
  41. package/dist/agent-runtime/graph/roles.js.map +1 -0
  42. package/dist/agent-runtime/graph/state.d.ts +75 -0
  43. package/dist/agent-runtime/graph/state.js +23 -0
  44. package/dist/agent-runtime/graph/state.js.map +1 -0
  45. package/dist/agent-runtime/graph-checkpointer.d.ts +61 -0
  46. package/dist/agent-runtime/graph-checkpointer.js +137 -0
  47. package/dist/agent-runtime/graph-checkpointer.js.map +1 -0
  48. package/dist/agent-runtime/index.d.ts +14 -0
  49. package/dist/agent-runtime/index.js +15 -0
  50. package/dist/agent-runtime/index.js.map +1 -0
  51. package/dist/agent-runtime/kimi-acp.d.ts +7 -0
  52. package/dist/agent-runtime/kimi-acp.js +179 -0
  53. package/dist/agent-runtime/kimi-acp.js.map +1 -0
  54. package/dist/agent-runtime/openai-executor.d.ts +18 -0
  55. package/dist/agent-runtime/openai-executor.js +174 -0
  56. package/dist/agent-runtime/openai-executor.js.map +1 -0
  57. package/dist/agent-runtime/prompts.d.ts +40 -0
  58. package/dist/agent-runtime/prompts.js +291 -0
  59. package/dist/agent-runtime/prompts.js.map +1 -0
  60. package/dist/agent-runtime/workflow-types.d.ts +210 -0
  61. package/dist/agent-runtime/workflow-types.js +2 -0
  62. package/dist/agent-runtime/workflow-types.js.map +1 -0
  63. package/dist/agent-runtime/workflow.d.ts +17 -0
  64. package/dist/agent-runtime/workflow.js +609 -0
  65. package/dist/agent-runtime/workflow.js.map +1 -0
  66. package/dist/agent-runtime/workspace-tools.d.ts +25 -0
  67. package/dist/agent-runtime/workspace-tools.js +120 -0
  68. package/dist/agent-runtime/workspace-tools.js.map +1 -0
  69. package/dist/installer/cli.d.ts +35 -0
  70. package/dist/installer/cli.js +3 -0
  71. package/dist/installer/cli.js.map +1 -1
  72. package/dist/installer/commands/doctor.d.ts +27 -0
  73. package/dist/installer/commands/framework.d.ts +85 -0
  74. package/dist/installer/commands/init.d.ts +147 -0
  75. package/dist/installer/commands/update.d.ts +56 -0
  76. package/dist/installer/commands/v5-migration.d.ts +32 -0
  77. package/dist/installer/phases/framework-lifecycle.d.ts +53 -0
  78. package/dist/installer/phases/install-config.d.ts +64 -0
  79. package/dist/installer/phases/manifest.d.ts +45 -0
  80. package/dist/installer/phases/prereqs.d.ts +51 -0
  81. package/dist/installer/phases/provider-detect.d.ts +89 -0
  82. package/dist/installer/phases/scaffold.d.ts +211 -0
  83. package/dist/installer/runtime/kimi.d.ts +84 -0
  84. package/dist/installer/runtime/pipeline-state.d.ts +210 -0
  85. package/dist/installer/runtime/pipeline-state.js +107 -38
  86. package/dist/installer/runtime/pipeline-state.js.map +1 -1
  87. package/dist/installer/util/errors.d.ts +46 -0
  88. package/dist/installer/util/exec.d.ts +41 -0
  89. package/dist/installer/util/fs.d.ts +153 -0
  90. package/dist/installer/util/git.d.ts +44 -0
  91. package/dist/installer/util/install-transaction.d.ts +29 -0
  92. package/dist/installer/util/logger.d.ts +31 -0
  93. package/dist/installer/util/paths.d.ts +34 -0
  94. package/dist/installer/util/prompts.d.ts +23 -0
  95. package/dist/installer/util/registry.d.ts +174 -0
  96. package/dist/installer/util/template.d.ts +23 -0
  97. package/docs/README.md +1 -0
  98. package/docs/agent-runtime.md +268 -0
  99. package/integration-contract.json +79 -7
  100. package/package.json +16 -1
  101. package/schemas/agent-runtime.schema.json +55 -0
  102. package/templates/runtime/provider-pipeline.md +16 -0
@@ -0,0 +1,210 @@
1
+ export type PipelinePhase = 'architect' | 'developer' | 'reviewer' | 'archive' | 'ship' | 'ci';
2
+ export type PhaseStatus = 'pending' | 'running' | 'done' | 'blocked' | 'failed' | 'skipped';
3
+ export interface PipelineSpec {
4
+ id: string | number;
5
+ title: string;
6
+ description: string;
7
+ repositoryIds?: string[];
8
+ acceptanceCriteria?: string[];
9
+ }
10
+ export interface PipelineRepository {
11
+ id: string;
12
+ name: string;
13
+ path: string;
14
+ baseSha?: string;
15
+ }
16
+ export interface PipelineContext {
17
+ schemaVersion: 1;
18
+ runId: string;
19
+ backlogRoot: string;
20
+ backlogPath?: string;
21
+ artifactRoot: string;
22
+ artifactRepositoryId: string;
23
+ repositories: PipelineRepository[];
24
+ ownership: {
25
+ git: 'host' | 'core';
26
+ backlog: 'host' | 'core';
27
+ worktrees: 'host' | 'core';
28
+ };
29
+ specs: PipelineSpec[];
30
+ }
31
+ export interface VerificationCommand {
32
+ repositoryId: string;
33
+ command: string;
34
+ args: string[];
35
+ cwd?: string;
36
+ env?: Record<string, string>;
37
+ timeoutMs?: number;
38
+ }
39
+ /** `unverified` admits a full request whose commands do not cover every repository
40
+ * (or cover none): the receipt then records which repositories ran no check. */
41
+ export interface VerificationRequest {
42
+ kind: 'full' | 'scoped';
43
+ commands: VerificationCommand[];
44
+ unverified?: boolean;
45
+ }
46
+ export interface CommandReceipt {
47
+ repositoryId: string;
48
+ command: string;
49
+ args: string[];
50
+ cwd: string;
51
+ environmentPolicy?: 'isolated-transport-v1';
52
+ environmentHash: string;
53
+ environmentKeys: string[];
54
+ environmentOverrideKeys: string[];
55
+ environmentOverridesHash: string;
56
+ exitCode: number;
57
+ durationMs: number;
58
+ output: string;
59
+ }
60
+ export interface VerificationReceipt {
61
+ id: string;
62
+ kind: 'full' | 'scoped';
63
+ scopeHash: string;
64
+ candidateHash: string;
65
+ commands: CommandReceipt[];
66
+ completedAt: string;
67
+ valid: boolean;
68
+ reason?: string;
69
+ /** Repositories in scope that this receipt ran no command for (explicitly admitted by the request). */
70
+ unverifiedRepositories?: string[];
71
+ }
72
+ export interface AcceptanceCriterion {
73
+ specId: string;
74
+ criterionIndex: number;
75
+ requirement: string;
76
+ status: 'met' | 'exception' | 'blocked' | 'pending';
77
+ evidence: string[];
78
+ exception?: {
79
+ reason: string;
80
+ impact: string;
81
+ material: boolean;
82
+ acceptedBy: 'reviewer' | 'user' | 'host';
83
+ approvalEvidence: string;
84
+ };
85
+ }
86
+ export interface AcceptanceCheck {
87
+ name: string;
88
+ status: 'passed' | 'failed' | 'unavailable';
89
+ required: boolean;
90
+ evidence: string[];
91
+ /** What was measured and what remains outside the measurement. */
92
+ scope: string;
93
+ limitations: string;
94
+ }
95
+ export interface AcceptanceReport {
96
+ criteria: AcceptanceCriterion[];
97
+ checks: AcceptanceCheck[];
98
+ findings: string[];
99
+ }
100
+ export interface AcceptanceReceipt extends AcceptanceReport {
101
+ scopeHash: string;
102
+ candidateHash: string;
103
+ artifactHash: string;
104
+ recordedAt: string;
105
+ }
106
+ export interface PipelineCompletion {
107
+ implementation: 'complete' | 'incomplete';
108
+ validation: 'verified' | 'with-exceptions' | 'pending' | 'blocked';
109
+ archive: PhaseStatus;
110
+ delivery: 'pending-host' | 'complete' | 'pending';
111
+ reasons: string[];
112
+ }
113
+ interface PhaseRecord {
114
+ startedAt?: string;
115
+ durationMs?: number;
116
+ attempts?: number;
117
+ status: PhaseStatus;
118
+ reason?: string;
119
+ candidateHash?: string;
120
+ artifactHash?: string;
121
+ completedAt?: string;
122
+ }
123
+ export interface PipelineState {
124
+ schemaVersion: 1;
125
+ runId: string;
126
+ change: string;
127
+ context: PipelineContext;
128
+ scopeHash: string;
129
+ revision: number;
130
+ createdAt: string;
131
+ updatedAt: string;
132
+ phases: Record<PipelinePhase, PhaseRecord>;
133
+ verification?: VerificationReceipt;
134
+ acceptance?: AcceptanceReceipt;
135
+ archivePath?: string;
136
+ archiveApproval?: {
137
+ candidateHash: string;
138
+ artifactHash: string;
139
+ confidenceHash: string;
140
+ acceptanceHash: string;
141
+ };
142
+ artifactExclusions: string[];
143
+ preview?: {
144
+ baseHash: string;
145
+ files: PreviewFile[];
146
+ createdAt: string;
147
+ };
148
+ }
149
+ export interface PreviewFile {
150
+ repositoryId: string;
151
+ path: string;
152
+ operation: 'write' | 'delete';
153
+ sourcePath?: string;
154
+ contentHash?: string;
155
+ }
156
+ export declare function verificationEnvironment(env: NodeJS.ProcessEnv, overrides?: Record<string, string>, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
157
+ export declare function validatePipelineContext(input: unknown): PipelineContext;
158
+ export declare function pipelineStateDirectory(context: PipelineContext): string;
159
+ export declare function fingerprintCandidate(state: PipelineState): string;
160
+ export declare function initializePipeline(contextInput: unknown, change: string): PipelineState;
161
+ declare function inspectReceipt(state: PipelineState, env?: NodeJS.ProcessEnv): {
162
+ valid: boolean;
163
+ reasons: string[];
164
+ receipt?: VerificationReceipt;
165
+ };
166
+ /** The frozen requirements an acceptance report must certify, in scope order. */
167
+ export declare function frozenAcceptanceCriteria(contextInput: unknown): Array<Pick<AcceptanceCriterion, 'specId' | 'criterionIndex' | 'requirement'>>;
168
+ /** Validates a report against the frozen scope without recording it; throws the same errors `recordAcceptance` would. */
169
+ export declare function validateAcceptanceReport(contextInput: unknown, input: unknown): AcceptanceReport;
170
+ declare function inspectAcceptance(state: PipelineState): {
171
+ valid: boolean;
172
+ status: PipelineCompletion['validation'];
173
+ reasons: string[];
174
+ receipt?: AcceptanceReceipt;
175
+ };
176
+ export declare function recordAcceptance(contextInput: unknown, input: unknown): PipelineState;
177
+ export declare function checkArchive(contextInput: unknown): PipelineState;
178
+ export declare function transitionPipeline(contextInput: unknown, phase: PipelinePhase, status: PhaseStatus, reason?: string): PipelineState;
179
+ export declare function inspectPipeline(contextInput: unknown): {
180
+ schemaVersion: 1;
181
+ runId: string;
182
+ change: string;
183
+ context: PipelineContext;
184
+ stateDir: string;
185
+ resumePhase: PipelinePhase | null;
186
+ phases: PipelineState['phases'];
187
+ verification: ReturnType<typeof inspectReceipt>;
188
+ acceptance: ReturnType<typeof inspectAcceptance>;
189
+ completion: PipelineCompletion;
190
+ };
191
+ /**
192
+ * Native executables retain structured argv; Windows script shims need cmd.
193
+ * This runtime is copied as one standalone module into .specrails/runtime,
194
+ * without installer util/exec. Keep this builtins-only equivalent local rather
195
+ * than importing a helper absent from installed projects. Like runCommand, it
196
+ * handles Windows shims; unlike a shell string it refuses ambiguous arguments.
197
+ */
198
+ export declare function verificationInvocation(command: string, args: string[], cwd: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): {
199
+ command: string;
200
+ args: string[];
201
+ windowsVerbatimArguments?: boolean;
202
+ };
203
+ export declare function verifyPipeline(contextInput: unknown, raw: unknown, log?: (text: string) => void, signal?: AbortSignal): Promise<VerificationReceipt>;
204
+ /** Validate a check plan without creating state or executing commands. */
205
+ export declare function validateVerificationRequest(contextInput: unknown, raw: unknown): void;
206
+ export declare function preparePreview(contextInput: unknown, raw: unknown): PipelineState;
207
+ export declare function applyPreview(contextInput: unknown, verificationRequest: unknown, log?: (text: string) => void): Promise<VerificationReceipt>;
208
+ export declare function runPipelineCommand(flags: Record<string, string | boolean>, positionals: string[]): Promise<number>;
209
+ export declare function runPipelineCli(argv: string[]): Promise<number>;
210
+ export {};
@@ -4,15 +4,34 @@ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, re
4
4
  import path from 'node:path';
5
5
  const PHASES = ['architect', 'developer', 'reviewer', 'archive', 'ship', 'ci'];
6
6
  // Session identity belongs to the agent transport, not the checked application.
7
- // Keep this list explicit: provider configuration, application inputs and host
8
- // scope/configuration (including SPECRAILS_*) must still invalidate receipts.
7
+ // Remove these from check subprocesses too, so ignored identity cannot become
8
+ // an unrecorded test input. Explicit command.env inputs remain bound overrides.
9
+ // Keep provider configuration, application inputs and host scope/configuration
10
+ // (including SPECRAILS_*) in the evidence; never exclude an entire prefix.
9
11
  const TRANSPORT_ENV_KEYS = new Set([
10
12
  '_', 'PWD', 'OLDPWD', 'SHLVL',
13
+ 'AI_AGENT', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_EFFORT',
11
14
  'CLAUDE_PID', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_MESSAGING_SOCKET',
12
15
  'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_CHILD_SESSION',
13
16
  ]);
14
17
  function verificationEnvironmentKeys(env, overrideKeys = []) {
15
- return Object.keys(env).filter((key) => env[key] !== undefined && !TRANSPORT_ENV_KEYS.has(key) && !overrideKeys.includes(key)).sort();
18
+ return Object.keys(normalizeVerificationEnvironment(env)).filter((key) => !TRANSPORT_ENV_KEYS.has(key) && !overrideKeys.includes(key)).sort();
19
+ }
20
+ function normalizeVerificationEnvironment(env, platform = process.platform) {
21
+ const result = Object.create(null);
22
+ for (const [raw, value] of Object.entries(env)) {
23
+ if (value === undefined)
24
+ continue;
25
+ const key = platform === 'win32' ? raw.toUpperCase() : raw;
26
+ if (Object.hasOwn(result, key) && result[key] !== value)
27
+ fail('Ambiguous verification environment key: ' + key);
28
+ result[key] = value;
29
+ }
30
+ return result;
31
+ }
32
+ export function verificationEnvironment(env, overrides = {}, platform = process.platform) {
33
+ const normalized = normalizeVerificationEnvironment(env, platform);
34
+ return { ...Object.fromEntries(Object.entries(normalized).filter(([key]) => !TRANSPORT_ENV_KEYS.has(key))), ...normalizeVerificationEnvironment(overrides, platform) };
16
35
  }
17
36
  const ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
18
37
  const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -255,15 +274,28 @@ function trackedFiles(repo) {
255
274
  const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
256
275
  env.GIT_CONFIG_NOSYSTEM = '1';
257
276
  env.GIT_CONFIG_GLOBAL = process.platform === 'win32' ? 'NUL' : '/dev/null';
258
- const result = spawnSync('git', ['-c', 'core.excludesFile=', '-C', repo.path, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { env, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, windowsHide: true });
259
- if (result.error || result.status !== 0)
260
- fail('Cannot fingerprint repository ' + repo.name + ': ' + (result.error?.message ?? result.stderr));
261
- return [...new Set(result.stdout.split('\0').filter(Boolean))].sort();
277
+ const list = (args) => {
278
+ const result = spawnSync('git', ['-c', 'core.excludesFile=', '-C', repo.path, 'ls-files', '-z', ...args], { env, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, windowsHide: true });
279
+ if (result.error || result.status !== 0)
280
+ fail('Cannot fingerprint repository ' + repo.name + ': ' + (result.error?.message ?? result.stderr));
281
+ return result.stdout.split('\0').filter(Boolean);
282
+ };
283
+ const tracked = new Set(list(['--cached']));
284
+ return [...new Set([...tracked, ...list(['--others', '--exclude-standard'])])].sort().map(file => ({ file, tracked: tracked.has(file) }));
285
+ }
286
+ function untrackedAgentMemory(relative) {
287
+ // Native project memory can be written automatically after a role finishes,
288
+ // even when the role keeps explicit notes under stateDir. Exclude only known
289
+ // generated memory roots; tracked files and provider settings/skills still
290
+ // belong to the candidate. Do not hide arbitrary provider directory content.
291
+ return ['.claude', '.codex', '.gemini', '.kimi-code', '.specrails'].some(provider => relative === provider + '/agent-memory' || relative.startsWith(provider + '/agent-memory/'));
262
292
  }
263
293
  export function fingerprintCandidate(state) {
264
294
  const entries = state.context.repositories.map((repo) => ({
265
295
  id: repo.id, path: repo.path,
266
- files: trackedFiles(repo).filter((file) => !excluded(state, repo, relativeUnix(file))).map((file) => [relativeUnix(file), fileFingerprint(path.join(repo.path, file))]),
296
+ files: trackedFiles(repo)
297
+ .filter(({ file, tracked }) => !excluded(state, repo, relativeUnix(file)) && (tracked || !untrackedAgentMemory(relativeUnix(file))))
298
+ .map(({ file }) => [relativeUnix(file), fileFingerprint(path.join(repo.path, file))]),
267
299
  }));
268
300
  return digest(canonical(entries));
269
301
  }
@@ -314,7 +346,8 @@ export function initializePipeline(contextInput, change) {
314
346
  });
315
347
  }
316
348
  function environmentHash(keys, overrides = {}, env = process.env) {
317
- return digest(canonical(Object.fromEntries(keys.map((key) => [key, overrides[key] ?? env[key] ?? null]))));
349
+ const normalized = normalizeVerificationEnvironment(env);
350
+ return digest(canonical(Object.fromEntries(keys.map((key) => [key, overrides[key] ?? normalized[key] ?? null]))));
318
351
  }
319
352
  function inspectReceipt(state, env = process.env) {
320
353
  const receipt = state.verification;
@@ -330,9 +363,19 @@ function inspectReceipt(state, env = process.env) {
330
363
  for (const command of receipt.commands) {
331
364
  if (command.exitCode !== 0)
332
365
  reasons.push('Command failed: ' + command.command);
366
+ if (command.environmentPolicy !== 'isolated-transport-v1')
367
+ reasons.push('Verification environment policy changed; run full verification again: ' + command.command);
333
368
  const currentKeys = verificationEnvironmentKeys(env, command.environmentOverrideKeys ?? []);
334
- if (canonical(currentKeys) !== canonical(command.environmentKeys) || command.environmentHash !== environmentHash(currentKeys, {}, env))
335
- reasons.push('Verification environment changed: ' + command.command);
369
+ if (canonical(currentKeys) !== canonical(command.environmentKeys) || command.environmentHash !== environmentHash(currentKeys, {}, env)) {
370
+ const previousKeys = command.environmentKeys ?? [];
371
+ const added = currentKeys.filter(key => !previousKeys.includes(key));
372
+ const removed = previousKeys.filter(key => !currentKeys.includes(key));
373
+ // Names explain process handoff drift without retaining or revealing
374
+ // values. A changed aggregate hash cannot identify which value changed.
375
+ const summarize = (keys) => keys.slice(0, 10).map(key => JSON.stringify(key)).join(', ') + (keys.length > 10 ? ` (+${keys.length - 10} more)` : '');
376
+ const details = [added.length ? 'added keys: ' + summarize(added) : '', removed.length ? 'removed keys: ' + summarize(removed) : ''].filter(Boolean);
377
+ reasons.push('Verification environment changed: ' + command.command + ' (' + (details.join('; ') || 'recorded environment values differ') + ')');
378
+ }
336
379
  }
337
380
  return { valid: reasons.length === 0, reasons: [...new Set(reasons)], receipt };
338
381
  }
@@ -374,6 +417,14 @@ function frozenCriteria(context) {
374
417
  return context.specs.flatMap(spec => (spec.acceptanceCriteria?.length ? spec.acceptanceCriteria : [spec.description || spec.title])
375
418
  .map((requirement, criterionIndex) => ({ specId: String(spec.id), criterionIndex, requirement })));
376
419
  }
420
+ /** The frozen requirements an acceptance report must certify, in scope order. */
421
+ export function frozenAcceptanceCriteria(contextInput) {
422
+ return frozenCriteria(validatePipelineContext(contextInput));
423
+ }
424
+ /** Validates a report against the frozen scope without recording it; throws the same errors `recordAcceptance` would. */
425
+ export function validateAcceptanceReport(contextInput, input) {
426
+ return acceptanceReport(validatePipelineContext(contextInput), input);
427
+ }
377
428
  function nonempty(value) { return typeof value === 'string' && value.trim().length > 0; }
378
429
  function evidence(value) { return Array.isArray(value) && value.length > 0 && value.every(nonempty); }
379
430
  function acceptanceReport(context, input) {
@@ -651,12 +702,14 @@ export function verificationInvocation(command, args, cwd, platform = process.pl
651
702
  const quote = (value) => '"' + value + '"';
652
703
  return { command: env.ComSpec ?? env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', '"' + [resolved, ...args].map(quote).join(' ') + '"'], windowsVerbatimArguments: true };
653
704
  }
654
- async function executeCheck(command, log) {
705
+ async function executeCheck(command, log, signal) {
655
706
  const started = Date.now();
656
- const overrideKeys = Object.keys(command.env ?? {}).sort();
707
+ const overrides = normalizeVerificationEnvironment(command.env ?? {});
708
+ const overrideKeys = Object.keys(overrides).sort();
657
709
  const keys = verificationEnvironmentKeys(process.env, overrideKeys);
658
710
  const hash = environmentHash(keys);
659
- const overridesHash = digest(canonical(command.env ?? {}));
711
+ const overridesHash = digest(canonical(overrides));
712
+ const env = verificationEnvironment(process.env, overrides);
660
713
  let output = '';
661
714
  let exitCode = -1;
662
715
  await new Promise((resolve) => {
@@ -665,10 +718,29 @@ async function executeCheck(command, log) {
665
718
  let done = false;
666
719
  const finish = (code) => { if (done)
667
720
  return; done = true; exitCode = code; if (timer)
668
- clearTimeout(timer); resolve(); };
721
+ clearTimeout(timer); signal?.removeEventListener('abort', abort); resolve(); };
722
+ const stop = (reason) => {
723
+ output += '\n' + reason;
724
+ if (child?.pid) {
725
+ if (process.platform === 'win32')
726
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
727
+ else {
728
+ try {
729
+ process.kill(-child.pid, 'SIGKILL');
730
+ }
731
+ catch { /* already exited */ }
732
+ }
733
+ }
734
+ finish(-1);
735
+ };
736
+ const abort = () => stop('Verification command cancelled');
737
+ if (signal?.aborted) {
738
+ abort();
739
+ return;
740
+ }
669
741
  try {
670
- const invocation = verificationInvocation(command.command, command.args, command.cwd);
671
- child = spawn(invocation.command, invocation.args, { windowsVerbatimArguments: invocation.windowsVerbatimArguments, cwd: command.cwd, env: { ...process.env, ...command.env }, shell: false, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
742
+ const invocation = verificationInvocation(command.command, command.args, command.cwd, process.platform, env);
743
+ child = spawn(invocation.command, invocation.args, { windowsVerbatimArguments: invocation.windowsVerbatimArguments, cwd: command.cwd, env, shell: false, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
672
744
  }
673
745
  catch (error) {
674
746
  output = String(error);
@@ -680,31 +752,19 @@ async function executeCheck(command, log) {
680
752
  child.stderr?.on('data', receive);
681
753
  child.on('error', (error) => { output += error.message; finish(-1); });
682
754
  child.on('close', (code) => finish(code ?? -1));
683
- timer = setTimeout(() => {
684
- output += '\nVerification command timed out';
685
- if (child.pid) {
686
- if (process.platform === 'win32')
687
- spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
688
- else {
689
- try {
690
- process.kill(-child.pid, 'SIGKILL');
691
- }
692
- catch { /* already exited */ }
693
- }
694
- }
695
- finish(-1);
696
- }, command.timeoutMs);
755
+ signal?.addEventListener('abort', abort, { once: true });
756
+ timer = setTimeout(() => stop('Verification command timed out'), command.timeoutMs);
697
757
  });
698
- return { repositoryId: command.repositoryId, command: command.command, args: command.args, cwd: command.cwd, environmentHash: hash, environmentKeys: keys, environmentOverrideKeys: overrideKeys, environmentOverridesHash: overridesHash, exitCode, durationMs: Date.now() - started, output };
758
+ return { repositoryId: command.repositoryId, command: command.command, args: command.args, cwd: command.cwd, environmentPolicy: 'isolated-transport-v1', environmentHash: hash, environmentKeys: keys, environmentOverrideKeys: overrideKeys, environmentOverridesHash: overridesHash, exitCode, durationMs: Date.now() - started, output };
699
759
  }
700
- export async function verifyPipeline(contextInput, raw, log = () => { }) {
760
+ export async function verifyPipeline(contextInput, raw, log = () => { }, signal) {
701
761
  const context = validatePipelineContext(contextInput);
702
- const { request, commands } = verificationPlan(context, raw);
762
+ const { request, commands, unverifiedRepositories } = verificationPlan(context, raw);
703
763
  const state = readState(context);
704
764
  const candidateHash = fingerprintCandidate(state);
705
765
  const results = [];
706
766
  for (const command of commands) {
707
- results.push(await executeCheck(command, log));
767
+ results.push(await executeCheck(command, log, signal));
708
768
  if (results[results.length - 1].exitCode !== 0)
709
769
  break;
710
770
  }
@@ -715,6 +775,7 @@ export async function verifyPipeline(contextInput, raw, log = () => { }) {
715
775
  id: randomUUID(), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results,
716
776
  completedAt: new Date().toISOString(), valid: !changed && results.length === commands.length && results.every((result) => result.exitCode === 0),
717
777
  ...(changed ? { reason: 'Candidate changed during verification' } : results.some((result) => result.exitCode !== 0) ? { reason: 'A verification command failed' } : {}),
778
+ ...(unverifiedRepositories.length ? { unverifiedRepositories } : {}),
718
779
  };
719
780
  atomicJson(safeChild(pipelineStateDirectory(context), 'receipts/' + receipt.id + '.json'), receipt);
720
781
  if (receipt.kind === 'full' || !receipt.valid || !current.verification)
@@ -725,12 +786,20 @@ export async function verifyPipeline(contextInput, raw, log = () => { }) {
725
786
  }
726
787
  function verificationPlan(context, raw) {
727
788
  const request = object(raw);
728
- if (!['full', 'scoped'].includes(String(request.kind)) || !Array.isArray(request.commands) || request.commands.length === 0 || request.commands.length > 100)
789
+ const unverified = request.unverified === true;
790
+ if (request.unverified !== undefined && typeof request.unverified !== 'boolean')
791
+ fail('Verification unverified flag must be boolean');
792
+ if (!['full', 'scoped'].includes(String(request.kind)) || !Array.isArray(request.commands) || (request.commands.length === 0 && !unverified) || request.commands.length > 100)
729
793
  fail('Verification requires bounded structured commands');
730
794
  const commands = request.commands.map((command) => validateCommand(context, command));
731
- if (request.kind === 'full' && context.repositories.some((repo) => !commands.some((command) => command.repositoryId === repo.id)))
795
+ const unverifiedRepositories = context.repositories.filter((repo) => !commands.some((command) => command.repositoryId === repo.id)).map((repo) => repo.id);
796
+ if (request.kind === 'full' && !unverified && unverifiedRepositories.length)
732
797
  fail('Full verification must cover every selected repository');
733
- return { request, commands };
798
+ return { request, commands, unverifiedRepositories: request.kind === 'full' && unverified ? unverifiedRepositories : [] };
799
+ }
800
+ /** Validate a check plan without creating state or executing commands. */
801
+ export function validateVerificationRequest(contextInput, raw) {
802
+ verificationPlan(validatePipelineContext(contextInput), raw);
734
803
  }
735
804
  export function preparePreview(contextInput, raw) {
736
805
  const context = validatePipelineContext(contextInput);