specrails-core 5.0.0 → 5.1.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 (48) hide show
  1. package/README.md +103 -310
  2. package/bin/specrails-core.mjs +3 -1
  3. package/dist/installer/cli.js +4 -0
  4. package/dist/installer/cli.js.map +1 -1
  5. package/dist/installer/commands/framework.js +64 -49
  6. package/dist/installer/commands/framework.js.map +1 -1
  7. package/dist/installer/commands/init.js +102 -66
  8. package/dist/installer/commands/init.js.map +1 -1
  9. package/dist/installer/commands/update.js +80 -74
  10. package/dist/installer/commands/update.js.map +1 -1
  11. package/dist/installer/commands/v5-migration.js +14 -0
  12. package/dist/installer/commands/v5-migration.js.map +1 -1
  13. package/dist/installer/phases/framework-lifecycle.js +2 -0
  14. package/dist/installer/phases/framework-lifecycle.js.map +1 -1
  15. package/dist/installer/phases/scaffold.js +191 -258
  16. package/dist/installer/phases/scaffold.js.map +1 -1
  17. package/dist/installer/runtime/pipeline-state.js +801 -0
  18. package/dist/installer/runtime/pipeline-state.js.map +1 -0
  19. package/dist/installer/util/exec.js +6 -1
  20. package/dist/installer/util/exec.js.map +1 -1
  21. package/dist/installer/util/fs.js +11 -2
  22. package/dist/installer/util/fs.js.map +1 -1
  23. package/dist/installer/util/install-transaction.js +246 -0
  24. package/dist/installer/util/install-transaction.js.map +1 -0
  25. package/dist/installer/util/registry.js +20 -0
  26. package/dist/installer/util/registry.js.map +1 -1
  27. package/docs/ci-cd.md +57 -0
  28. package/docs/user-docs/codex-vs-claude-code.md +23 -151
  29. package/docs/user-docs/core-updates.md +70 -0
  30. package/docs/user-docs/provider-pipelines.md +53 -0
  31. package/integration-contract.json +179 -66
  32. package/package.json +5 -2
  33. package/templates/agents/sr-developer.md +9 -11
  34. package/templates/agents/sr-reviewer.md +26 -33
  35. package/templates/codex-skills/batch-implement/SKILL.md +58 -244
  36. package/templates/codex-skills/implement/SKILL.md +136 -338
  37. package/templates/codex-skills/rails/sr-architect/SKILL.md +7 -0
  38. package/templates/codex-skills/rails/sr-developer/SKILL.md +13 -0
  39. package/templates/codex-skills/rails/sr-reviewer/SKILL.md +39 -5
  40. package/templates/codex-skills/retry/SKILL.md +37 -117
  41. package/templates/commands/specrails/batch-implement.md +16 -288
  42. package/templates/commands/specrails/implement.md +62 -1057
  43. package/templates/commands/specrails/retry.md +22 -314
  44. package/templates/gemini-commands/batch-implement.toml +28 -40
  45. package/templates/gemini-commands/implement.toml +55 -114
  46. package/templates/gemini-commands/retry.toml +21 -0
  47. package/templates/kimi/specrails/run-skill.mjs +51 -2
  48. package/templates/runtime/provider-pipeline.md +55 -0
@@ -0,0 +1,801 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ const PHASES = ['architect', 'developer', 'reviewer', 'archive', 'ship', 'ci'];
6
+ const TRANSPORT_ENV_KEYS = new Set(['_', 'PWD', 'OLDPWD', 'SHLVL']);
7
+ function verificationEnvironmentKeys(env, overrideKeys = []) {
8
+ return Object.keys(env).filter((key) => env[key] !== undefined && !TRANSPORT_ENV_KEYS.has(key) && !overrideKeys.includes(key)).sort();
9
+ }
10
+ const ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
11
+ const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
12
+ function fail(message) { throw new Error(message); }
13
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
14
+ function canonical(value) {
15
+ if (Array.isArray(value))
16
+ return '[' + value.map(canonical).join(',') + ']';
17
+ if (value && typeof value === 'object')
18
+ return '{' + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => JSON.stringify(k) + ':' + canonical(v)).join(',') + '}';
19
+ return JSON.stringify(value) ?? 'null';
20
+ }
21
+ function readJson(file) { return JSON.parse(readFileSync(file, 'utf8')); }
22
+ function object(value) {
23
+ if (!value || typeof value !== 'object' || Array.isArray(value))
24
+ fail('Expected a JSON object');
25
+ return value;
26
+ }
27
+ function directory(value) {
28
+ if (typeof value !== 'string' || !path.isAbsolute(value))
29
+ fail('Execution roots must be absolute paths');
30
+ const resolved = realpathSync(value);
31
+ if (!lstatSync(resolved).isDirectory())
32
+ fail('Execution root is not a directory: ' + value);
33
+ return resolved;
34
+ }
35
+ function within(root, target) {
36
+ const relative = path.relative(root, target);
37
+ return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
38
+ }
39
+ function safeChild(root, relative) {
40
+ if (!relative || path.isAbsolute(relative) || relative.includes('\0'))
41
+ fail('Expected a repository-relative path');
42
+ const target = path.resolve(root, relative);
43
+ if (!within(root, target) || target === root)
44
+ fail('Path escapes its repository: ' + relative);
45
+ let current = root;
46
+ for (const part of path.relative(root, target).split(path.sep)) {
47
+ current = path.join(current, part);
48
+ try {
49
+ if (lstatSync(current).isSymbolicLink())
50
+ fail('Refusing a symlink write path: ' + relative);
51
+ }
52
+ catch (error) {
53
+ if (error.code !== 'ENOENT')
54
+ throw error;
55
+ }
56
+ }
57
+ return target;
58
+ }
59
+ function atomicJson(file, value) {
60
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
61
+ const temp = file + '.' + randomUUID() + '.tmp';
62
+ try {
63
+ writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
64
+ renameSync(temp, file);
65
+ }
66
+ finally {
67
+ rmSync(temp, { force: true });
68
+ }
69
+ }
70
+ export function validatePipelineContext(input) {
71
+ const data = object(input);
72
+ if (data.schemaVersion !== 1 || typeof data.runId !== 'string' || !ID.test(data.runId))
73
+ fail('Invalid execution context version or runId');
74
+ if (!Array.isArray(data.repositories) || data.repositories.length === 0)
75
+ fail('Execution context needs repositories');
76
+ const repositories = data.repositories.map((entry) => {
77
+ const repo = object(entry);
78
+ if (typeof repo.id !== 'string' || !ID.test(repo.id) || typeof repo.name !== 'string')
79
+ fail('Invalid repository identity');
80
+ if (repo.baseSha !== undefined && (typeof repo.baseSha !== 'string' || !/^[a-f0-9]{40,64}$/.test(repo.baseSha)))
81
+ fail('Invalid repository base SHA');
82
+ return { id: repo.id, name: repo.name, path: directory(repo.path), ...(repo.baseSha ? { baseSha: repo.baseSha } : {}) };
83
+ });
84
+ if (new Set(repositories.map((repo) => repo.id)).size !== repositories.length || new Set(repositories.map((repo) => repo.path)).size !== repositories.length)
85
+ fail('Duplicate repository identity or path');
86
+ const artifactRoot = directory(data.artifactRoot);
87
+ if (!repositories.some((repo) => repo.id === data.artifactRepositoryId && repo.path === artifactRoot))
88
+ fail('artifactRoot must match artifactRepositoryId');
89
+ const ownership = object(data.ownership);
90
+ for (const key of ['git', 'backlog', 'worktrees'])
91
+ if (!['host', 'core'].includes(String(ownership[key])))
92
+ fail('Invalid ownership: ' + key);
93
+ if (!Array.isArray(data.specs))
94
+ fail('Frozen specs must be an array');
95
+ const specs = data.specs.map((entry) => {
96
+ const spec = object(entry);
97
+ if (!['string', 'number'].includes(typeof spec.id) || typeof spec.title !== 'string' || typeof spec.description !== 'string')
98
+ fail('Invalid frozen spec');
99
+ if (spec.repositoryIds !== undefined && (!Array.isArray(spec.repositoryIds) || !spec.repositoryIds.every((id) => repositories.some((repo) => repo.id === id))))
100
+ fail('Spec selects an unknown repository');
101
+ if (spec.acceptanceCriteria !== undefined && (!Array.isArray(spec.acceptanceCriteria) || !spec.acceptanceCriteria.every((x) => typeof x === 'string')))
102
+ fail('Invalid acceptance criteria');
103
+ return { id: spec.id, title: spec.title, description: spec.description, ...(spec.repositoryIds ? { repositoryIds: spec.repositoryIds } : {}), ...(spec.acceptanceCriteria ? { acceptanceCriteria: spec.acceptanceCriteria } : {}) };
104
+ });
105
+ const backlogRoot = directory(data.backlogRoot);
106
+ const backlogPath = data.backlogPath === undefined ? path.join(backlogRoot, '.specrails', 'local-tickets.json') : String(data.backlogPath);
107
+ if (!path.isAbsolute(backlogPath) || !within(backlogRoot, path.resolve(backlogPath)))
108
+ fail('Backlog path escapes backlogRoot');
109
+ return { schemaVersion: 1, runId: data.runId, backlogRoot, backlogPath, artifactRoot, artifactRepositoryId: String(data.artifactRepositoryId), repositories, ownership: ownership, specs };
110
+ }
111
+ export function pipelineStateDirectory(context) {
112
+ return safeChild(context.backlogRoot, '.specrails/pipeline/' + context.runId);
113
+ }
114
+ function locked(context, operation) {
115
+ const dir = pipelineStateDirectory(context);
116
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
117
+ const file = safeChild(dir, 'journal.lock');
118
+ const owner = { pid: process.pid, token: randomUUID() };
119
+ const release = (target, token) => {
120
+ try {
121
+ if (object(readJson(target)).token === token)
122
+ rmSync(target);
123
+ }
124
+ catch { /* never remove a replacement or uncertain lease */ }
125
+ };
126
+ let fd;
127
+ try {
128
+ fd = openSync(file, 'wx', 0o600);
129
+ }
130
+ catch (error) {
131
+ if (error.code !== 'EEXIST')
132
+ throw error;
133
+ // A stable exclusive guard serializes stale-owner recovery. Without it,
134
+ // two readers of a dead PID could unlink the new winner's live lease.
135
+ const guard = safeChild(dir, 'journal-reclaim.lock');
136
+ const recovery = { pid: process.pid, token: randomUUID() };
137
+ let guardFd;
138
+ try {
139
+ guardFd = openSync(guard, 'wx', 0o600);
140
+ }
141
+ catch {
142
+ fail('Pipeline journal recovery is locked; inspect a stale recovery guard before retrying');
143
+ }
144
+ try {
145
+ writeFileSync(guardFd, JSON.stringify(recovery));
146
+ if (existsSync(file)) {
147
+ const previousText = readFileSync(file, 'utf8');
148
+ const previous = object(JSON.parse(previousText));
149
+ let stale = false;
150
+ if (typeof previous.pid === 'number' && previous.pid > 0) {
151
+ try {
152
+ process.kill(previous.pid, 0);
153
+ }
154
+ catch (error) {
155
+ stale = error.code === 'ESRCH';
156
+ }
157
+ }
158
+ if (!stale || readFileSync(file, 'utf8') !== previousText)
159
+ fail('Pipeline journal is locked by another operation');
160
+ rmSync(file);
161
+ }
162
+ // A fast-path contender can win after unlink; exclusive create then
163
+ // fails instead of deleting that contender's lease or running unlocked.
164
+ fd = openSync(file, 'wx', 0o600);
165
+ }
166
+ finally {
167
+ closeSync(guardFd);
168
+ release(guard, recovery.token);
169
+ }
170
+ }
171
+ try {
172
+ writeFileSync(fd, JSON.stringify(owner));
173
+ return operation();
174
+ }
175
+ finally {
176
+ closeSync(fd);
177
+ release(file, owner.token);
178
+ }
179
+ }
180
+ function stateFile(context) { return path.join(pipelineStateDirectory(context), 'state.json'); }
181
+ function readState(context) {
182
+ const state = object(readJson(stateFile(context)));
183
+ if (state.schemaVersion !== 1 || state.runId !== context.runId || state.scopeHash !== digest(canonical(context)))
184
+ fail('Execution context differs from the frozen journal scope');
185
+ return state;
186
+ }
187
+ function saveState(state) {
188
+ state.revision += 1;
189
+ state.updatedAt = new Date().toISOString();
190
+ atomicJson(stateFile(state.context), state);
191
+ }
192
+ function relativeUnix(value) { return value.split(path.sep).join('/'); }
193
+ function excluded(state, repo, relative) {
194
+ const absolute = path.join(repo.path, relative);
195
+ if (within(pipelineStateDirectory(state.context), absolute))
196
+ return true;
197
+ if (['.specrails/kimi-role-wave.json', '.specrails/kimi-role-request.json', '.specrails/kimi-role-merge.json', '.specrails/kimi-role-worktrees/' + state.runId + '.json'].includes(relative))
198
+ return true;
199
+ if (relative === '.specrails/runtime' || relative.startsWith('.specrails/runtime/'))
200
+ return true;
201
+ if (repo.id !== state.context.artifactRepositoryId)
202
+ return false;
203
+ return state.artifactExclusions.some((item) => relative === item || relative.startsWith(item + '/'));
204
+ }
205
+ function fileFingerprint(file, ancestors = new Set(), budget = { entries: 0, bytes: 0 }, linkedTree = false) {
206
+ const stat = lstatSync(file, { throwIfNoEntry: false });
207
+ if (!stat) {
208
+ if (linkedTree)
209
+ fail('Candidate has a missing symlink target: ' + file);
210
+ return 'deleted';
211
+ }
212
+ if (++budget.entries > 50_000)
213
+ fail('Linked candidate tree exceeds fingerprint entry limit: ' + file);
214
+ if (stat.isSymbolicLink()) {
215
+ const link = readlinkSync(file);
216
+ let target;
217
+ try {
218
+ target = realpathSync(file);
219
+ }
220
+ catch {
221
+ fail('Candidate has a dangling or cyclic symlink: ' + file);
222
+ }
223
+ if (ancestors.has(target))
224
+ fail('Candidate has a cyclic linked directory: ' + file);
225
+ const next = new Set(ancestors);
226
+ next.add(target);
227
+ return 'link:' + link + ':' + fileFingerprint(target, next, budget, true);
228
+ }
229
+ if (stat.isDirectory() && linkedTree) {
230
+ // Framework directory links are normal. Hash their actual inputs, not only
231
+ // link text, with deterministic traversal and bounds. Git administration is
232
+ // not a source input; dependency/output directories are not blindly hidden.
233
+ const children = readdirSync(file, { withFileTypes: true }).filter((entry) => entry.name !== '.git').sort((a, b) => a.name.localeCompare(b.name));
234
+ return 'directory:' + digest(canonical(children.map((entry) => [entry.name, fileFingerprint(path.join(file, entry.name), ancestors, budget, true)])));
235
+ }
236
+ if (!stat.isFile())
237
+ fail('Candidate contains a directory entry or unsupported file: ' + file);
238
+ budget.bytes += stat.size;
239
+ if (linkedTree && budget.bytes > 256 * 1024 * 1024)
240
+ fail('Linked candidate inputs exceed fingerprint byte limit: ' + file);
241
+ return (stat.mode & 0o111 ? 'executable:' : 'file:') + digest(readFileSync(file));
242
+ }
243
+ function trackedFiles(repo) {
244
+ // A provider's temporary GIT_CONFIG_COUNT/excludesFile must not hide
245
+ // candidate files from verification or change receipt validity at handoff.
246
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
247
+ env.GIT_CONFIG_NOSYSTEM = '1';
248
+ env.GIT_CONFIG_GLOBAL = process.platform === 'win32' ? 'NUL' : '/dev/null';
249
+ 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 });
250
+ if (result.error || result.status !== 0)
251
+ fail('Cannot fingerprint repository ' + repo.name + ': ' + (result.error?.message ?? result.stderr));
252
+ return [...new Set(result.stdout.split('\0').filter(Boolean))].sort();
253
+ }
254
+ export function fingerprintCandidate(state) {
255
+ const entries = state.context.repositories.map((repo) => ({
256
+ id: repo.id, path: repo.path,
257
+ files: trackedFiles(repo).filter((file) => !excluded(state, repo, relativeUnix(file))).map((file) => [relativeUnix(file), fileFingerprint(path.join(repo.path, file))]),
258
+ }));
259
+ return digest(canonical(entries));
260
+ }
261
+ function activeArtifactPath(state) { return state.archivePath ?? path.join(state.context.artifactRoot, 'openspec', 'changes', state.change); }
262
+ function artifactFingerprint(state) {
263
+ const root = activeArtifactPath(state);
264
+ if (!existsSync(root))
265
+ return 'missing';
266
+ const result = [];
267
+ const walk = (dir) => {
268
+ for (const item of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
269
+ const file = path.join(dir, item.name);
270
+ if (item.name === 'confidence-score.json')
271
+ continue;
272
+ if (item.isDirectory())
273
+ walk(file);
274
+ else {
275
+ const relative = relativeUnix(path.relative(root, file));
276
+ const fingerprint = relative === 'tasks.md' ? digest(readFileSync(file, 'utf8').replace(/^(\s*-\s+)\[[ x]\]/gm, '$1[ ]')) : fileFingerprint(file);
277
+ result.push([relative, fingerprint]);
278
+ }
279
+ }
280
+ };
281
+ walk(root);
282
+ return digest(canonical(result));
283
+ }
284
+ export function initializePipeline(contextInput, change) {
285
+ const context = validatePipelineContext(contextInput);
286
+ if (!slug.test(change))
287
+ fail('Invalid OpenSpec change name');
288
+ return locked(context, () => {
289
+ if (existsSync(stateFile(context))) {
290
+ const existing = readState(context);
291
+ if (existing.change !== change)
292
+ fail('A runId cannot be reused for another change');
293
+ return existing;
294
+ }
295
+ const now = new Date().toISOString();
296
+ const state = {
297
+ schemaVersion: 1, runId: context.runId, change, context, scopeHash: digest(canonical(context)), revision: 0,
298
+ createdAt: now, updatedAt: now,
299
+ phases: Object.fromEntries(PHASES.map((phase) => [phase, { status: 'pending' }])),
300
+ artifactExclusions: ['openspec/changes/' + change],
301
+ };
302
+ atomicJson(path.join(pipelineStateDirectory(context), 'context.json'), context);
303
+ saveState(state);
304
+ return state;
305
+ });
306
+ }
307
+ function environmentHash(keys, overrides = {}, env = process.env) {
308
+ return digest(canonical(Object.fromEntries(keys.map((key) => [key, overrides[key] ?? env[key] ?? null]))));
309
+ }
310
+ function inspectReceipt(state, env = process.env) {
311
+ const receipt = state.verification;
312
+ const reasons = [];
313
+ if (!receipt)
314
+ return { valid: false, reasons: ['No verification receipt'] };
315
+ if (!receipt.valid || receipt.kind !== 'full')
316
+ reasons.push(receipt.reason ?? 'No successful full verification');
317
+ if (receipt.scopeHash !== state.scopeHash)
318
+ reasons.push('Spec scope changed');
319
+ if (receipt.candidateHash !== fingerprintCandidate(state))
320
+ reasons.push('Candidate files changed');
321
+ for (const command of receipt.commands) {
322
+ if (command.exitCode !== 0)
323
+ reasons.push('Command failed: ' + command.command);
324
+ const currentKeys = verificationEnvironmentKeys(env, command.environmentOverrideKeys ?? []);
325
+ if (canonical(currentKeys) !== canonical(command.environmentKeys) || command.environmentHash !== environmentHash(currentKeys, {}, env))
326
+ reasons.push('Verification environment changed: ' + command.command);
327
+ }
328
+ return { valid: reasons.length === 0, reasons: [...new Set(reasons)], receipt };
329
+ }
330
+ function designGate(state) {
331
+ const root = activeArtifactPath(state);
332
+ for (const file of ['proposal.md', 'design.md', 'tasks.md'])
333
+ if (!existsSync(path.join(root, file)))
334
+ fail('Missing architecture artifact: ' + file);
335
+ const specs = path.join(root, 'specs');
336
+ if (!existsSync(specs) || !readdirSync(specs, { withFileTypes: true }).some((entry) => entry.isDirectory() && existsSync(path.join(specs, entry.name, 'spec.md'))))
337
+ fail('Missing architecture delta specs');
338
+ const design = object(readJson(path.join(root, 'design-confidence.json')));
339
+ if (!['high', 'medium'].includes(String(design.confidence)))
340
+ fail('Design confidence blocks implementation');
341
+ }
342
+ function confidenceGate(state) {
343
+ const file = path.join(activeArtifactPath(state), 'confidence-score.json');
344
+ if (!existsSync(file))
345
+ fail('Required confidence-score.json is missing');
346
+ const score = object(readJson(file));
347
+ const aspects = object(score.aspects);
348
+ if (score.change !== state.change || typeof score.overall !== 'number' || score.overall < 70)
349
+ fail('Confidence score does not pass');
350
+ for (const [name, threshold] of Object.entries({ type_correctness: 60, pattern_adherence: 60, test_coverage: 60, security: 75, architectural_alignment: 60 })) {
351
+ if (typeof aspects[name] !== 'number' || Number(aspects[name]) < threshold || Number(aspects[name]) > 100)
352
+ fail('Confidence aspect does not pass: ' + name);
353
+ }
354
+ if (score.overall > 100)
355
+ fail('Invalid confidence score');
356
+ }
357
+ function taskGate(state) {
358
+ const tasks = readFileSync(path.join(activeArtifactPath(state), 'tasks.md'), 'utf8');
359
+ if (!/^\s*-\s+\[x\]/m.test(tasks) || /^\s*-\s+\[ \]/m.test(tasks))
360
+ fail('Required implementation tasks remain incomplete');
361
+ }
362
+ export function checkArchive(contextInput) {
363
+ const context = validatePipelineContext(contextInput);
364
+ return locked(context, () => {
365
+ const state = readState(context);
366
+ if (state.phases.reviewer.status !== 'done')
367
+ fail('Review must complete before archive');
368
+ const verification = inspectReceipt(state);
369
+ if (!verification.valid)
370
+ fail('Archive blocked: ' + verification.reasons.join('; '));
371
+ if (state.phases.reviewer.candidateHash !== fingerprintCandidate(state))
372
+ fail('Review does not describe the current candidate');
373
+ if (state.phases.reviewer.artifactHash !== artifactFingerprint(state))
374
+ fail('Review artifacts changed after review');
375
+ designGate(state);
376
+ if (state.phases.architect.artifactHash !== artifactFingerprint(state))
377
+ fail('Architecture artifacts changed after design approval');
378
+ taskGate(state);
379
+ confidenceGate(state);
380
+ state.archiveApproval = { candidateHash: fingerprintCandidate(state), artifactHash: artifactFingerprint(state), confidenceHash: digest(readFileSync(path.join(activeArtifactPath(state), 'confidence-score.json'))) };
381
+ saveState(state);
382
+ return state;
383
+ });
384
+ }
385
+ export function transitionPipeline(contextInput, phase, status, reason) {
386
+ const context = validatePipelineContext(contextInput);
387
+ if (!PHASES.includes(phase) || !['running', 'done', 'blocked', 'failed', 'skipped'].includes(status))
388
+ fail('Invalid phase transition');
389
+ return locked(context, () => {
390
+ const state = readState(context);
391
+ if ((status === 'blocked' || status === 'failed') && !reason?.trim())
392
+ fail('Blocked and failed phases require a reason');
393
+ if (status === 'skipped' && !((phase === 'ship' || phase === 'ci') && context.ownership.git === 'host'))
394
+ fail('Only host-owned shipping/CI can be skipped');
395
+ if ((phase === 'ship' || phase === 'ci') && context.ownership.git === 'host' && status !== 'skipped')
396
+ fail('Host owns delivery; Core cannot ship or monitor its CI');
397
+ if (status === 'running' || status === 'done') {
398
+ const before = PHASES.slice(0, PHASES.indexOf(phase));
399
+ if (before.some((p) => !['done', 'skipped'].includes(state.phases[p].status)))
400
+ fail('A required earlier phase is incomplete');
401
+ }
402
+ if (status === 'done') {
403
+ if (phase === 'architect') {
404
+ const root = activeArtifactPath(state);
405
+ designGate(state);
406
+ const specsDir = path.join(root, 'specs');
407
+ if (existsSync(specsDir)) {
408
+ for (const item of readdirSync(specsDir, { withFileTypes: true })) {
409
+ if (item.isDirectory() && slug.test(item.name))
410
+ state.artifactExclusions.push('openspec/specs/' + item.name);
411
+ }
412
+ }
413
+ }
414
+ if (phase === 'developer' || phase === 'reviewer') {
415
+ designGate(state);
416
+ if (state.phases.architect.artifactHash !== artifactFingerprint(state))
417
+ fail('Architecture artifacts changed after design approval');
418
+ taskGate(state);
419
+ const verification = inspectReceipt(state);
420
+ if (!verification.valid)
421
+ fail('Fresh full verification required: ' + verification.reasons.join('; '));
422
+ }
423
+ if (phase === 'reviewer')
424
+ confidenceGate(state);
425
+ if (phase === 'archive') {
426
+ const archiveRoot = path.join(context.artifactRoot, 'openspec', 'changes', 'archive');
427
+ const candidates = existsSync(archiveRoot) ? readdirSync(archiveRoot).filter((name) => name.endsWith('-' + state.change)) : [];
428
+ if (existsSync(activeArtifactPath(state)) || candidates.length !== 1)
429
+ fail('Archive location must be unambiguous and active change moved');
430
+ state.archivePath = path.join(archiveRoot, candidates[0]);
431
+ state.artifactExclusions.push(relativeUnix(path.relative(context.artifactRoot, state.archivePath)));
432
+ taskGate(state);
433
+ confidenceGate(state);
434
+ const approval = state.archiveApproval;
435
+ if (!approval || approval.candidateHash !== fingerprintCandidate(state) || approval.artifactHash !== artifactFingerprint(state) || approval.confidenceHash !== digest(readFileSync(path.join(activeArtifactPath(state), 'confidence-score.json'))))
436
+ fail('Archive was not authorized for this exact reviewed candidate');
437
+ }
438
+ state.phases[phase] = { status, candidateHash: fingerprintCandidate(state), artifactHash: artifactFingerprint(state), completedAt: new Date().toISOString() };
439
+ }
440
+ else {
441
+ state.phases[phase] = { status, ...(reason ? { reason } : {}) };
442
+ if (status === 'running')
443
+ for (const later of PHASES.slice(PHASES.indexOf(phase) + 1))
444
+ state.phases[later] = { status: 'pending' };
445
+ }
446
+ saveState(state);
447
+ return state;
448
+ });
449
+ }
450
+ export function inspectPipeline(contextInput) {
451
+ const context = validatePipelineContext(contextInput);
452
+ const state = readState(context);
453
+ const candidate = fingerprintCandidate(state);
454
+ const verification = inspectReceipt(state);
455
+ let resumePhase = null;
456
+ for (const phase of PHASES) {
457
+ const record = state.phases[phase];
458
+ if (record.status === 'skipped')
459
+ continue;
460
+ if (record.status !== 'done') {
461
+ resumePhase = phase;
462
+ break;
463
+ }
464
+ if (phase === 'architect' && record.artifactHash !== artifactFingerprint(state)) {
465
+ resumePhase = phase;
466
+ break;
467
+ }
468
+ if ((phase === 'developer' || phase === 'reviewer') && record.candidateHash !== candidate) {
469
+ if (phase === 'developer' && state.phases.reviewer.status === 'done' && state.phases.reviewer.candidateHash === candidate)
470
+ continue;
471
+ resumePhase = phase === 'developer' ? 'reviewer' : phase;
472
+ break;
473
+ }
474
+ }
475
+ if (!verification.valid && state.phases.developer.status === 'done' && (resumePhase === null || ['archive', 'ship', 'ci'].includes(resumePhase)))
476
+ resumePhase = 'reviewer';
477
+ return { schemaVersion: 1, runId: context.runId, change: state.change, context, stateDir: pipelineStateDirectory(context), resumePhase, phases: state.phases, verification };
478
+ }
479
+ function validateCommand(context, raw) {
480
+ const command = object(raw);
481
+ const repo = context.repositories.find((item) => item.id === command.repositoryId);
482
+ if (!repo || typeof command.command !== 'string' || !command.command || command.command.includes('\0') || !Array.isArray(command.args) || !command.args.every((arg) => typeof arg === 'string' && !arg.includes('\0')))
483
+ fail('Invalid verification command');
484
+ const cwd = command.cwd === undefined ? repo.path : directory(path.resolve(repo.path, String(command.cwd)));
485
+ if (!within(repo.path, cwd))
486
+ fail('Verification cwd escapes selected repository');
487
+ const env = command.env === undefined ? undefined : object(command.env);
488
+ if (env && Object.values(env).some((value) => typeof value !== 'string' || value.includes('\0')))
489
+ fail('Invalid verification environment');
490
+ const timeoutMs = command.timeoutMs === undefined ? 15 * 60_000 : Number(command.timeoutMs);
491
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2 * 60 * 60_000)
492
+ fail('Invalid verification timeout');
493
+ return { repositoryId: repo.id, command: command.command, args: command.args, cwd, ...(env ? { env: env } : {}), timeoutMs };
494
+ }
495
+ /**
496
+ * Native executables retain structured argv; Windows script shims need cmd.
497
+ * This runtime is copied as one standalone module into .specrails/runtime,
498
+ * without installer util/exec. Keep this builtins-only equivalent local rather
499
+ * than importing a helper absent from installed projects. Like runCommand, it
500
+ * handles Windows shims; unlike a shell string it refuses ambiguous arguments.
501
+ */
502
+ export function verificationInvocation(command, args, cwd, platform = process.platform, env = process.env) {
503
+ if (platform !== 'win32')
504
+ return { command, args };
505
+ let resolved = command;
506
+ if (!/\.(cmd|bat|exe|com)$/i.test(command)) {
507
+ const pathValue = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? '';
508
+ const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';');
509
+ const bases = /[\\/]/.test(command) ? [path.win32.resolve(cwd, command)] : [path.win32.join(cwd, command), ...pathValue.split(';').map((dir) => path.win32.join(dir, command))];
510
+ for (const base of bases) {
511
+ const candidate = extensions.map((extension) => base + extension.toLowerCase()).find((file) => existsSync(file));
512
+ if (candidate) {
513
+ resolved = candidate;
514
+ break;
515
+ }
516
+ }
517
+ }
518
+ if (!/\.(cmd|bat)$/i.test(resolved))
519
+ return { command: resolved, args };
520
+ // cmd performs a second parse, including environment expansion. Rather than
521
+ // silently reinterpret a structured argument, reject ambiguous script input.
522
+ // Call node/python/the native tool executable directly for these arguments.
523
+ if ([resolved, ...args].some((value) => /[\r\n"%!^&|<>]/.test(value)))
524
+ fail('Windows script-shim arguments contain cmd syntax; invoke the underlying executable with structured argv instead');
525
+ const quote = (value) => '"' + value + '"';
526
+ return { command: env.ComSpec ?? env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', '"' + [resolved, ...args].map(quote).join(' ') + '"'], windowsVerbatimArguments: true };
527
+ }
528
+ async function executeCheck(command, log) {
529
+ const started = Date.now();
530
+ const overrideKeys = Object.keys(command.env ?? {}).sort();
531
+ const keys = verificationEnvironmentKeys(process.env, overrideKeys);
532
+ const hash = environmentHash(keys);
533
+ const overridesHash = digest(canonical(command.env ?? {}));
534
+ let output = '';
535
+ let exitCode = -1;
536
+ await new Promise((resolve) => {
537
+ let child;
538
+ let timer;
539
+ let done = false;
540
+ const finish = (code) => { if (done)
541
+ return; done = true; exitCode = code; if (timer)
542
+ clearTimeout(timer); resolve(); };
543
+ try {
544
+ const invocation = verificationInvocation(command.command, command.args, command.cwd);
545
+ 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 });
546
+ }
547
+ catch (error) {
548
+ output = String(error);
549
+ finish(-1);
550
+ return;
551
+ }
552
+ const receive = (chunk) => { const text = chunk.toString('utf8'); output = (output + text).slice(-32_000); log(text); };
553
+ child.stdout?.on('data', receive);
554
+ child.stderr?.on('data', receive);
555
+ child.on('error', (error) => { output += error.message; finish(-1); });
556
+ child.on('close', (code) => finish(code ?? -1));
557
+ timer = setTimeout(() => {
558
+ output += '\nVerification command timed out';
559
+ if (child.pid) {
560
+ if (process.platform === 'win32')
561
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
562
+ else {
563
+ try {
564
+ process.kill(-child.pid, 'SIGKILL');
565
+ }
566
+ catch { /* already exited */ }
567
+ }
568
+ }
569
+ finish(-1);
570
+ }, command.timeoutMs);
571
+ });
572
+ 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 };
573
+ }
574
+ export async function verifyPipeline(contextInput, raw, log = () => { }) {
575
+ const context = validatePipelineContext(contextInput);
576
+ const { request, commands } = verificationPlan(context, raw);
577
+ const state = readState(context);
578
+ const candidateHash = fingerprintCandidate(state);
579
+ const results = [];
580
+ for (const command of commands) {
581
+ results.push(await executeCheck(command, log));
582
+ if (results[results.length - 1].exitCode !== 0)
583
+ break;
584
+ }
585
+ return locked(context, () => {
586
+ const current = readState(context);
587
+ const changed = fingerprintCandidate(current) !== candidateHash || current.revision !== state.revision;
588
+ const receipt = {
589
+ id: randomUUID(), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results,
590
+ completedAt: new Date().toISOString(), valid: !changed && results.length === commands.length && results.every((result) => result.exitCode === 0),
591
+ ...(changed ? { reason: 'Candidate changed during verification' } : results.some((result) => result.exitCode !== 0) ? { reason: 'A verification command failed' } : {}),
592
+ };
593
+ atomicJson(safeChild(pipelineStateDirectory(context), 'receipts/' + receipt.id + '.json'), receipt);
594
+ if (receipt.kind === 'full' || !receipt.valid || !current.verification)
595
+ current.verification = receipt;
596
+ saveState(current);
597
+ return receipt;
598
+ });
599
+ }
600
+ function verificationPlan(context, raw) {
601
+ const request = object(raw);
602
+ if (!['full', 'scoped'].includes(String(request.kind)) || !Array.isArray(request.commands) || request.commands.length === 0 || request.commands.length > 100)
603
+ fail('Verification requires bounded structured commands');
604
+ const commands = request.commands.map((command) => validateCommand(context, command));
605
+ if (request.kind === 'full' && context.repositories.some((repo) => !commands.some((command) => command.repositoryId === repo.id)))
606
+ fail('Full verification must cover every selected repository');
607
+ return { request, commands };
608
+ }
609
+ export function preparePreview(contextInput, raw) {
610
+ const context = validatePipelineContext(contextInput);
611
+ const request = object(raw);
612
+ if (!Array.isArray(request.files) || request.files.length === 0)
613
+ fail('Preview needs explicit files');
614
+ const inputFiles = request.files;
615
+ return locked(context, () => {
616
+ const state = readState(context);
617
+ const files = inputFiles.map((item, index) => {
618
+ const input = object(item);
619
+ const repo = context.repositories.find((entry) => entry.id === input.repositoryId);
620
+ if (!repo || typeof input.path !== 'string' || !['write', 'delete'].includes(String(input.operation)))
621
+ fail('Invalid preview file');
622
+ const target = safeChild(repo.path, input.path);
623
+ if (excluded(state, repo, relativeUnix(path.relative(repo.path, target))))
624
+ fail('Preview cannot overwrite runtime or lifecycle artifacts');
625
+ if (input.operation === 'delete')
626
+ return { repositoryId: repo.id, path: relativeUnix(path.relative(repo.path, target)), operation: 'delete' };
627
+ if (typeof input.sourcePath !== 'string' || !path.isAbsolute(input.sourcePath))
628
+ fail('Preview sourcePath must be absolute');
629
+ const source = realpathSync(input.sourcePath);
630
+ if (![context.backlogRoot, ...context.repositories.map((entry) => entry.path)].some((root) => within(root, source)))
631
+ fail('Preview source is outside execution scope');
632
+ const bytes = readFileSync(source);
633
+ const cached = safeChild(pipelineStateDirectory(context), 'preview/' + String(index));
634
+ mkdirSync(path.dirname(cached), { recursive: true, mode: 0o700 });
635
+ writeFileSync(cached, bytes, { mode: 0o600 });
636
+ return { repositoryId: repo.id, path: relativeUnix(path.relative(repo.path, target)), operation: 'write', sourcePath: cached, contentHash: digest(bytes) };
637
+ });
638
+ if (new Set(files.map((file) => file.repositoryId + ':' + file.path)).size !== files.length)
639
+ fail('Duplicate preview target');
640
+ state.preview = { baseHash: fingerprintCandidate(state), files, createdAt: new Date().toISOString() };
641
+ saveState(state);
642
+ return state;
643
+ });
644
+ }
645
+ export async function applyPreview(contextInput, verificationRequest, log = () => { }) {
646
+ const context = validatePipelineContext(contextInput);
647
+ verificationPlan(context, verificationRequest);
648
+ locked(context, () => {
649
+ const state = readState(context);
650
+ if (!state.preview || fingerprintCandidate(state) !== state.preview.baseHash)
651
+ fail('Preview base changed; create a new preview instead of overwriting work');
652
+ const operations = state.preview.files.map((file) => {
653
+ const repo = context.repositories.find((entry) => entry.id === file.repositoryId);
654
+ const target = safeChild(repo.path, file.path);
655
+ if (file.operation === 'write' && (!file.sourcePath || !within(path.join(pipelineStateDirectory(context), 'preview'), realpathSync(file.sourcePath))))
656
+ fail('Preview cache path escapes its owned directory');
657
+ const content = file.operation === 'write' ? readFileSync(file.sourcePath) : null;
658
+ if (content && digest(content) !== file.contentHash)
659
+ fail('Preview content changed');
660
+ return { target, content, before: existsSync(target) ? readFileSync(target) : null };
661
+ });
662
+ const applied = [];
663
+ try {
664
+ for (const operation of operations) {
665
+ mkdirSync(path.dirname(operation.target), { recursive: true });
666
+ if (operation.content)
667
+ writeFileSync(operation.target, operation.content);
668
+ else
669
+ rmSync(operation.target);
670
+ applied.push(operation);
671
+ }
672
+ }
673
+ catch (error) {
674
+ for (const operation of applied.reverse()) {
675
+ if (operation.before)
676
+ writeFileSync(operation.target, operation.before);
677
+ else
678
+ rmSync(operation.target, { force: true });
679
+ }
680
+ throw error;
681
+ }
682
+ state.verification = undefined;
683
+ for (const phase of PHASES.slice(1))
684
+ state.phases[phase] = { status: 'pending' };
685
+ saveState(state);
686
+ });
687
+ // Applied files remain reviewable on failure; never ship based on preview's
688
+ // unchanged baseline. The actual applied candidate owns this fresh receipt.
689
+ return verifyPipeline(context, verificationRequest, log);
690
+ }
691
+ function parseArguments(argv) {
692
+ const operation = argv[0] ?? 'status';
693
+ const flags = {};
694
+ for (let i = 1; i < argv.length; i++) {
695
+ const arg = argv[i];
696
+ if (!arg.startsWith('--'))
697
+ fail('Unexpected positional argument: ' + arg);
698
+ const next = argv[i + 1];
699
+ if (next && !next.startsWith('--')) {
700
+ flags[arg.slice(2)] = next;
701
+ i++;
702
+ }
703
+ else
704
+ flags[arg.slice(2)] = true;
705
+ }
706
+ return { operation, flags };
707
+ }
708
+ function resolveContext(flags, operation) {
709
+ const file = typeof flags.context === 'string' ? flags.context : process.env.SPECRAILS_EXECUTION_CONTEXT;
710
+ if (file)
711
+ return validatePipelineContext(readJson(file));
712
+ const cwd = realpathSync(process.cwd());
713
+ const legacy = safeChild(cwd, '.specrails/pipeline-context.json');
714
+ if (existsSync(legacy)) {
715
+ const previous = validatePipelineContext(readJson(legacy));
716
+ if (operation !== 'init')
717
+ return previous;
718
+ if (existsSync(stateFile(previous)) && readState(previous).change === flags.change)
719
+ return previous;
720
+ }
721
+ if (operation !== 'init')
722
+ fail('Initialize the pipeline or supply SPECRAILS_EXECUTION_CONTEXT');
723
+ const repo = realpathSync(process.env.SPECRAILS_REPO_DIR ?? cwd);
724
+ let scope = {};
725
+ if (typeof flags['scope-request'] === 'string')
726
+ scope = object(readJson(flags['scope-request']));
727
+ const backlogPath = typeof flags['backlog-path'] === 'string' ? flags['backlog-path'] : path.join(cwd, '.specrails', 'local-tickets.json');
728
+ let specs = scope.specs ?? [];
729
+ if (typeof flags.tickets === 'string') {
730
+ if (scope.specs !== undefined)
731
+ fail('Choose --tickets or --scope-request specs, not both');
732
+ const ids = flags.tickets.split(',').map((id) => id.trim().replace(/^#/, '')).filter(Boolean);
733
+ if (!ids.length || new Set(ids).size !== ids.length || ids.some((id) => !/^[a-zA-Z0-9._-]+$/.test(id)))
734
+ fail('Invalid or duplicate ticket IDs');
735
+ const tickets = object(object(readJson(backlogPath)).tickets);
736
+ specs = ids.map((id) => {
737
+ if (!tickets[id])
738
+ fail('Ticket not found in backlog: ' + id);
739
+ const ticket = object(tickets[id]);
740
+ if (typeof ticket.title !== 'string')
741
+ fail('Ticket title is missing: ' + id);
742
+ return { id: ticket.id ?? id, title: ticket.title, description: ticket.description ?? '',
743
+ ...(ticket.acceptanceCriteria ? { acceptanceCriteria: ticket.acceptanceCriteria } : {}),
744
+ ...(ticket.repositoryIds ? { repositoryIds: ticket.repositoryIds } : {}) };
745
+ });
746
+ }
747
+ const context = validatePipelineContext({
748
+ schemaVersion: 1, runId: randomUUID(), backlogRoot: cwd, backlogPath, artifactRoot: repo, artifactRepositoryId: 'primary',
749
+ repositories: [{ id: 'primary', name: path.basename(repo), path: repo }], specs,
750
+ ownership: scope.ownership ?? { git: 'host', backlog: 'host', worktrees: 'host' },
751
+ });
752
+ atomicJson(legacy, context);
753
+ return context;
754
+ }
755
+ export async function runPipelineCommand(flags, positionals) {
756
+ const operation = positionals[0] ?? 'status';
757
+ const context = resolveContext(flags, operation);
758
+ let result;
759
+ switch (operation) {
760
+ case 'init':
761
+ result = initializePipeline(context, String(flags.change ?? ''));
762
+ break;
763
+ case 'status':
764
+ result = inspectPipeline(context);
765
+ break;
766
+ case 'phase':
767
+ result = transitionPipeline(context, String(flags.phase), String(flags.status), typeof flags.reason === 'string' ? flags.reason : undefined);
768
+ break;
769
+ case 'archive-check':
770
+ result = checkArchive(context);
771
+ break;
772
+ case 'verify':
773
+ case 'apply-preview': {
774
+ if (typeof flags.request !== 'string')
775
+ fail('Provide --request with structured verification JSON');
776
+ const request = readJson(flags.request);
777
+ const receipt = operation === 'verify' ? await verifyPipeline(context, request, (text) => process.stderr.write(text)) : await applyPreview(context, request, (text) => process.stderr.write(text));
778
+ console.log(JSON.stringify(receipt));
779
+ return receipt.valid ? 0 : 1;
780
+ }
781
+ case 'preview':
782
+ if (typeof flags.request !== 'string')
783
+ fail('Provide --request with preview file JSON');
784
+ result = preparePreview(context, readJson(flags.request));
785
+ break;
786
+ default: fail('Unknown pipeline operation: ' + operation);
787
+ }
788
+ console.log(JSON.stringify(result));
789
+ return 0;
790
+ }
791
+ export async function runPipelineCli(argv) {
792
+ try {
793
+ const { operation, flags } = parseArguments(argv);
794
+ return await runPipelineCommand(flags, [operation]);
795
+ }
796
+ catch (error) {
797
+ console.error('Pipeline: ' + (error instanceof Error ? error.message : String(error)));
798
+ return 1;
799
+ }
800
+ }
801
+ //# sourceMappingURL=pipeline-state.js.map