specrails-core 6.6.5 → 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.
Files changed (118) hide show
  1. package/dist/agent-runtime/capabilities.d.ts +5 -2
  2. package/dist/agent-runtime/capabilities.js +8 -2
  3. package/dist/agent-runtime/change-scope.d.ts +2 -0
  4. package/dist/agent-runtime/change-scope.js +5 -0
  5. package/dist/agent-runtime/cli-executor.js +7 -0
  6. package/dist/agent-runtime/cli.d.ts +6 -1
  7. package/dist/agent-runtime/cli.js +15 -4
  8. package/dist/agent-runtime/compact/reviewer.js +3 -1
  9. package/dist/agent-runtime/config.js +16 -5
  10. package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
  11. package/dist/agent-runtime/efficiency-summary.js +7 -1
  12. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
  13. package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
  14. package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
  15. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
  16. package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
  17. package/dist/agent-runtime/engine/cli.d.ts +12 -0
  18. package/dist/agent-runtime/engine/cli.js +15 -4
  19. package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
  20. package/dist/agent-runtime/engine/definition-schema.js +1 -0
  21. package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
  22. package/dist/agent-runtime/engine/definition-types.js +1 -1
  23. package/dist/agent-runtime/engine/definition-validator.js +8 -0
  24. package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
  25. package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
  26. package/dist/agent-runtime/engine/invocation-context.js +2 -0
  27. package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
  28. package/dist/agent-runtime/engine/piece-registry.js +1 -1
  29. package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
  30. package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
  31. package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
  32. package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
  33. package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
  34. package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
  35. package/dist/agent-runtime/engine/pieces/index.js +2 -1
  36. package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
  37. package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
  38. package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
  39. package/dist/agent-runtime/engine/preflight.d.ts +2 -1
  40. package/dist/agent-runtime/engine/preflight.js +3 -1
  41. package/dist/agent-runtime/engine/regex.d.ts +12 -2
  42. package/dist/agent-runtime/engine/regex.js +25 -11
  43. package/dist/agent-runtime/engine/run-status.d.ts +34 -0
  44. package/dist/agent-runtime/engine/run-status.js +7 -1
  45. package/dist/agent-runtime/engine/runs.d.ts +56 -0
  46. package/dist/agent-runtime/engine/runs.js +88 -5
  47. package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
  48. package/dist/agent-runtime/engine/steering/inbox.js +21 -1
  49. package/dist/agent-runtime/engine/storage/private-path.js +7 -1
  50. package/dist/agent-runtime/evaluation.js +1 -1
  51. package/dist/agent-runtime/executor-types.d.ts +19 -0
  52. package/dist/agent-runtime/executor-types.js +10 -0
  53. package/dist/agent-runtime/failure-classification.d.ts +51 -0
  54. package/dist/agent-runtime/failure-classification.js +110 -0
  55. package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
  56. package/dist/agent-runtime/graph/artifacts.js +5 -1
  57. package/dist/agent-runtime/graph/baseline.d.ts +50 -0
  58. package/dist/agent-runtime/graph/baseline.js +145 -0
  59. package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
  60. package/dist/agent-runtime/graph/capability-map.js +65 -0
  61. package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
  62. package/dist/agent-runtime/graph/clarifications.js +158 -0
  63. package/dist/agent-runtime/graph/nodes.js +178 -19
  64. package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
  65. package/dist/agent-runtime/graph/review-brief-record.js +26 -0
  66. package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
  67. package/dist/agent-runtime/graph/review-brief.js +127 -0
  68. package/dist/agent-runtime/graph/roles.js +5 -3
  69. package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
  70. package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
  71. package/dist/agent-runtime/graph/state.d.ts +8 -0
  72. package/dist/agent-runtime/index.d.ts +7 -0
  73. package/dist/agent-runtime/index.js +7 -0
  74. package/dist/agent-runtime/openspec.d.ts +1 -0
  75. package/dist/agent-runtime/openspec.js +1 -1
  76. package/dist/agent-runtime/project-rules.d.ts +139 -0
  77. package/dist/agent-runtime/project-rules.js +683 -0
  78. package/dist/agent-runtime/prompts.d.ts +32 -1
  79. package/dist/agent-runtime/prompts.js +180 -7
  80. package/dist/agent-runtime/role-routing.d.ts +2 -1
  81. package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
  82. package/dist/agent-runtime/session/application/active-session.js +163 -7
  83. package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
  84. package/dist/agent-runtime/session/domain/descriptor.js +5 -0
  85. package/dist/agent-runtime/session/domain/interruption.js +1 -0
  86. package/dist/agent-runtime/session/domain/policy.js +2 -0
  87. package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
  88. package/dist/agent-runtime/session/domain/redaction.js +71 -0
  89. package/dist/agent-runtime/session/domain/types.d.ts +17 -1
  90. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
  91. package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
  92. package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
  93. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
  94. package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
  95. package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
  96. package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
  97. package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
  98. package/dist/agent-runtime/session/host/protocol.js +10 -2
  99. package/dist/agent-runtime/session/host/server.js +6 -5
  100. package/dist/agent-runtime/session/index.d.ts +1 -1
  101. package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
  102. package/dist/agent-runtime/session/ports.d.ts +6 -2
  103. package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
  104. package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
  105. package/dist/installer/cli.js +10 -1
  106. package/dist/installer/commands/init.js +14 -3
  107. package/dist/installer/phases/scaffold.js +56 -5
  108. package/dist/pipeline/pipeline-state.d.ts +15 -0
  109. package/dist/pipeline/pipeline-state.js +36 -3
  110. package/dist/pipeline/spec-trace.d.ts +134 -0
  111. package/dist/pipeline/spec-trace.js +392 -0
  112. package/dist/shared/secret-redaction.d.ts +42 -0
  113. package/dist/shared/secret-redaction.js +184 -0
  114. package/integration-contract.json +177 -7
  115. package/package.json +7 -7
  116. package/schemas/agent-runtime.schema.json +3 -1
  117. package/schemas/workflow-definition.schema.json +1 -0
  118. package/templates/settings/codex-config.toml +3 -2
@@ -28,6 +28,21 @@ export declare const BUILTIN_ROLE_POLICY: {
28
28
  readonly openspecSkill: 'openspec-verify-change';
29
29
  };
30
30
  };
31
+ /**
32
+ * Engine-only built-in roles. Core owns their policy and their instructions
33
+ * (prompts.ts); they take the architect's provider assignment, are not legacy
34
+ * pipeline phases and cannot be redefined as custom roles.
35
+ */
36
+ export declare const ENGINE_ROLES: readonly ['deriver'];
37
+ export type EngineRole = typeof ENGINE_ROLES[number];
38
+ export declare function isEngineRole(role: string): role is EngineRole;
39
+ export declare const ENGINE_ROLE_POLICY: {
40
+ readonly deriver: {
41
+ readonly access: 'read';
42
+ readonly artifacts: 'all';
43
+ readonly openspecSkill: 'openspec-ff-change';
44
+ };
45
+ };
31
46
  /** Who an observer sees acting: a pipeline role, or the FIXER stance the developer step takes on a correction round. */
32
47
  export type AgentEventRole = AgentRole | 'fixer';
33
48
  export type CliProvider = 'claude' | 'codex' | 'gemini' | 'kimi';
@@ -112,6 +127,8 @@ export interface RuntimeConfig {
112
127
  /** Idempotent commands the `verify` piece runs sequentially before each verification plan (same shape as `verification`; `policy` is ignored). */
113
128
  setup?: VerificationCommand[];
114
129
  approvalBeforeArchive?: boolean;
130
+ /** Characterize uncovered modules before changing them: a Baseline task group whose tests must pass on the base code (default false). */
131
+ baselineOnTouch?: boolean;
115
132
  /** Review gate thresholds (0–100); unset fields keep Core's defaults. */
116
133
  review?: {
117
134
  minScore?: number;
@@ -120,6 +137,8 @@ export interface RuntimeConfig {
120
137
  /** What to do when the architect still reports low confidence after investigating: ask the requester (default) or proceed on stated assumptions. */
121
138
  architect?: {
122
139
  onLowConfidence?: 'ask' | 'proceed';
140
+ /** `ask-on-high` (default): a high-impact clarification without a safe default pauses the run; `always-proceed`: plan on stated assumptions. */
141
+ clarifications?: 'ask-on-high' | 'always-proceed';
123
142
  };
124
143
  /** Compact-runtime guardrails switched OFF by the project (`{ id: false }`); unset = every guardrail on. */
125
144
  guardrails?: GuardrailSettings;
@@ -8,6 +8,16 @@ export const BUILTIN_ROLE_POLICY = {
8
8
  developer: { access: 'write', artifacts: 'tasks-checkboxes', openspecSkill: 'openspec-apply-change' },
9
9
  reviewer: { access: 'read', artifacts: 'none', openspecSkill: 'openspec-verify-change' },
10
10
  };
11
+ /**
12
+ * Engine-only built-in roles. Core owns their policy and their instructions
13
+ * (prompts.ts); they take the architect's provider assignment, are not legacy
14
+ * pipeline phases and cannot be redefined as custom roles.
15
+ */
16
+ export const ENGINE_ROLES = ['deriver'];
17
+ export function isEngineRole(role) { return ENGINE_ROLES.includes(role); }
18
+ export const ENGINE_ROLE_POLICY = {
19
+ deriver: { access: 'read', artifacts: 'all', openspecSkill: 'openspec-ff-change' },
20
+ };
11
21
  export class AgentExecutionError extends Error {
12
22
  code;
13
23
  usage;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Structured failure classification (contract 5.8, capability `failureClassification`).
3
+ *
4
+ * A terminal `failed` or `blocked` run reports WHY it stopped in one typed record so a host
5
+ * can offer a remedy without parsing prose. The classifier is a pure function of signals the
6
+ * run already produced (status, the failing step, error codes and messages); it never invents
7
+ * a cause. Anything it cannot tie to a documented signal is `unknown` with `confidence: 'low'`.
8
+ */
9
+ export declare const FAILURE_KINDS: readonly ['tests_failed', 'provider_limit', 'environment', 'blocked', 'timeout', 'tool_inactivity', 'crash', 'unknown'];
10
+ export type FailureKind = (typeof FAILURE_KINDS)[number];
11
+ export declare const FAILURE_CONFIDENCE: readonly ['high', 'low'];
12
+ export interface FailureClassification {
13
+ kind: FailureKind;
14
+ /** The step (engine 1) or node path (engine 2) that stopped the run. */
15
+ phase?: string;
16
+ /** Correction rounds the run spent before it stopped (visits of the correction step). */
17
+ rounds?: number;
18
+ /** `provider_limit` only: the provider's own wording of when it resets ("3am (Europe/Madrid)"), never reformatted. */
19
+ resetAt?: string;
20
+ /** One short plain sentence taken from the run's own error; bounded, redacted upstream. */
21
+ detail?: string;
22
+ /** The step a `runtime resume` continues from, when resuming can help. */
23
+ resumablePhase?: string;
24
+ confidence: (typeof FAILURE_CONFIDENCE)[number];
25
+ }
26
+ export interface FailureSignals {
27
+ status: string;
28
+ /** Run-level error: engine 1 sends a string, engine 2 `{code, message}`. */
29
+ error?: unknown;
30
+ /** Engine 1 step records or engine 2 compact step map, keyed by step id / node path. */
31
+ steps?: Record<string, {
32
+ status?: unknown;
33
+ visits?: unknown;
34
+ error?: unknown;
35
+ kind?: unknown;
36
+ } | undefined>;
37
+ /** Engine 2 `recentFailures`, newest first. */
38
+ recentFailures?: Array<{
39
+ nodePath?: unknown;
40
+ status?: unknown;
41
+ visit?: unknown;
42
+ error?: unknown;
43
+ } | undefined>;
44
+ nextStep?: unknown;
45
+ }
46
+ export declare function providerLimitReset(message: string): string | undefined;
47
+ /**
48
+ * Classify a terminal result. Returns `undefined` for any status other than `failed` or
49
+ * `blocked`: a succeeded, paused or cancelled run has nothing to diagnose.
50
+ */
51
+ export declare function classifyFailure(signals: FailureSignals): FailureClassification | undefined;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Structured failure classification (contract 5.8, capability `failureClassification`).
3
+ *
4
+ * A terminal `failed` or `blocked` run reports WHY it stopped in one typed record so a host
5
+ * can offer a remedy without parsing prose. The classifier is a pure function of signals the
6
+ * run already produced (status, the failing step, error codes and messages); it never invents
7
+ * a cause. Anything it cannot tie to a documented signal is `unknown` with `confidence: 'low'`.
8
+ */
9
+ export const FAILURE_KINDS = ['tests_failed', 'provider_limit', 'environment', 'blocked', 'timeout', 'tool_inactivity', 'crash', 'unknown'];
10
+ export const FAILURE_CONFIDENCE = ['high', 'low'];
11
+ const DETAIL_LIMIT = 280;
12
+ const text = (value) => typeof value === 'string' ? value : '';
13
+ function messageOf(error) {
14
+ if (typeof error === 'string')
15
+ return { code: '', message: error };
16
+ if (error && typeof error === 'object') {
17
+ const record = error;
18
+ return { code: text(record.code), message: text(record.message) };
19
+ }
20
+ return { code: '', message: '' };
21
+ }
22
+ /** The provider's own "resets 3am (Europe/Madrid)" hint, when the message carries one. */
23
+ const RESETS = /\bresets?\s+(?:at\s+|in\s+)?([^\n·(]*?\d[^\n·(]*?)(\s*\([^)]*\))?(?=\s*(?:\(error|[.,;]\s|\n|$))/i;
24
+ export function providerLimitReset(message) {
25
+ const match = RESETS.exec(message);
26
+ if (!match)
27
+ return undefined;
28
+ const value = `${match[1].trim()}${match[2] ? ` ${match[2].trim()}` : ''}`.trim().replace(/[.,;:]+$/, '');
29
+ return value && value.length <= 80 ? value : undefined;
30
+ }
31
+ const PROVIDER_LIMIT = /\byou(?:'|’)?ve (?:hit|reached) your (?:session|usage|weekly|daily|monthly|plan)?\s*limit\b|\berror type rate_limit\b|\bHTTP 429\b|\b429 Too Many Requests\b|\brate[ _-]?limit(?:ed|s)? (?:exceeded|reached|hit)\b|\brate_limit_error\b|\binsufficient_quota\b|\busage limit (?:reached|exceeded)\b|\bRESOURCE_EXHAUSTED\b|\bquota (?:exceeded|exhausted)\b/i;
32
+ const ENVIRONMENT_CODES = new Set(['verification_host_precondition', 'verification_nondeterministic_output', 'verification_execution_error']);
33
+ const ENVIRONMENT_TEXT = /Verification cannot run in this environment|Verification output is not deterministic|\(verification_nondeterministic_output\)|setup command .* failed|could not undo them|outside the repository scope/i;
34
+ const IDLE_CODES = new Set(['idle_timeout']);
35
+ const IDLE_TEXT = /\bidle timeout\b|no tool call, reply or usage for|no output for \d/i;
36
+ const TIMEOUT_CODES = new Set(['timeout']);
37
+ const TIMEOUT_TEXT = /total execution limit|active-duration budget exhausted|execution timed out|reached its time budget/i;
38
+ const CRASH_CODES = new Set(['lease_lost', 'storage_corrupt', 'provider_request_error', 'incomplete_response', 'terminal_conflict']);
39
+ const CRASH_TEXT = /execution failed \(exit \d+\)|exited without a successful final result|lost its endpoint|process exited|\bcrashed\b|\bSIGKILL\b|\bSIGSEGV\b/i;
40
+ const TESTS_TEXT = /Correction loop stopped: .*(?:verif|check|test|fail)|verification (?:failed|did not pass)|failing (?:tests|checks)|(?:tests?|checks?) (?:are |is )?failing|(?:tests?|checks?) failed/i;
41
+ const BLOCKED_CODES = new Set(['baseline_characterization_failed', 'budget_exhausted', 'retry_exhausted', 'recover_required', 'recovery_ambiguous']);
42
+ const BLOCKED_TEXT = /correction limit reached|continuation limit reached|reviewer rejected the same change|could not make progress|Run budget is exhausted|\(baseline_characterization_failed\)/i;
43
+ const CORRECTION_STEP = /(?:^|[/.:])(?:fixer|fix|correct)/i;
44
+ function detailOf(message) {
45
+ const flat = message.replace(/\s+/g, ' ').trim();
46
+ if (!flat)
47
+ return undefined;
48
+ return flat.length > DETAIL_LIMIT ? `${flat.slice(0, DETAIL_LIMIT - 1)}…` : flat;
49
+ }
50
+ function failingPhase(signals) {
51
+ const recent = (signals.recentFailures ?? []).find(item => item && typeof item.nodePath === 'string' && ['failed', 'blocked', 'interrupted'].includes(text(item.status)));
52
+ if (recent) {
53
+ const { code, message } = messageOf(recent.error);
54
+ return { phase: recent.nodePath, message, code };
55
+ }
56
+ const entries = Object.entries(signals.steps ?? {}).filter(([, step]) => step && ['failed', 'blocked', 'interrupted'].includes(text(step.status)));
57
+ const [id, step] = entries[0] ?? [];
58
+ if (id && step)
59
+ return { phase: id, ...(() => { const { code, message } = messageOf(step.error); return { code, message }; })() };
60
+ return { message: '', code: '' };
61
+ }
62
+ function correctionRounds(steps) {
63
+ let visits = 0;
64
+ for (const [id, step] of Object.entries(steps ?? {})) {
65
+ if (!step || !CORRECTION_STEP.test(id))
66
+ continue;
67
+ const count = typeof step.visits === 'number' && Number.isFinite(step.visits) ? step.visits : 0;
68
+ visits += count;
69
+ }
70
+ return visits > 0 ? visits : undefined;
71
+ }
72
+ /**
73
+ * Classify a terminal result. Returns `undefined` for any status other than `failed` or
74
+ * `blocked`: a succeeded, paused or cancelled run has nothing to diagnose.
75
+ */
76
+ export function classifyFailure(signals) {
77
+ if (signals.status !== 'failed' && signals.status !== 'blocked')
78
+ return undefined;
79
+ const top = messageOf(signals.error);
80
+ const step = failingPhase(signals);
81
+ // The run-level error names the cause; the failing attempt's own error is the fallback.
82
+ const code = top.code || step.code;
83
+ const message = top.message || step.message;
84
+ const haystack = `${top.message}\n${step.message}`;
85
+ const phase = step.phase ?? (typeof signals.nextStep === 'string' && signals.nextStep ? signals.nextStep : undefined);
86
+ const rounds = correctionRounds(signals.steps);
87
+ const base = { ...(phase ? { phase } : {}), ...(rounds ? { rounds } : {}) };
88
+ const detail = detailOf(message);
89
+ const resumable = phase ? { resumablePhase: typeof signals.nextStep === 'string' && signals.nextStep ? signals.nextStep : phase } : {};
90
+ const done = (kind, extra = {}, resume = false) => ({ kind, ...base, ...(detail ? { detail } : {}), ...(resume ? resumable : {}), ...extra, confidence: kind === 'unknown' ? 'low' : 'high' });
91
+ if (PROVIDER_LIMIT.test(haystack)) {
92
+ const resetAt = providerLimitReset(haystack);
93
+ return done('provider_limit', resetAt ? { resetAt } : {}, true);
94
+ }
95
+ if (IDLE_CODES.has(code) || IDLE_TEXT.test(haystack))
96
+ return done('tool_inactivity', {}, true);
97
+ if (TIMEOUT_CODES.has(code) || TIMEOUT_TEXT.test(haystack))
98
+ return done('timeout', {}, true);
99
+ if (code === 'baseline_characterization_failed' || /\(baseline_characterization_failed\)/.test(haystack))
100
+ return done('blocked');
101
+ if (ENVIRONMENT_CODES.has(code) || ENVIRONMENT_TEXT.test(haystack))
102
+ return done('environment', {}, true);
103
+ if (BLOCKED_CODES.has(code) || BLOCKED_TEXT.test(haystack))
104
+ return done('blocked', {}, true);
105
+ if (TESTS_TEXT.test(haystack) || (code === 'verification_failed'))
106
+ return done('tests_failed', {}, signals.status === 'blocked');
107
+ if (CRASH_CODES.has(code) || CRASH_TEXT.test(haystack))
108
+ return done('crash', {}, true);
109
+ return done('unknown', {}, false);
110
+ }
@@ -30,4 +30,6 @@ export declare function archiveOpenSpecChange(context: PipelineContext, change:
30
30
  directory: string;
31
31
  beforePublish(): void;
32
32
  }, signal?: AbortSignal): Promise<void>;
33
- export declare function archive(context: PipelineContext, change: string, signal?: AbortSignal): Promise<void>;
33
+ export declare function archive(context: PipelineContext, change: string, signal?: AbortSignal, options?: {
34
+ beforeDone?: (archived: string) => void;
35
+ }): Promise<void>;
@@ -263,7 +263,7 @@ export async function archiveOpenSpecChange(context, change, adapter, signal) {
263
263
  throw new Error('OpenSpec active change does not exist');
264
264
  await archiveWithOpenSpec(context, change, active, signal, adapter);
265
265
  }
266
- export async function archive(context, change, signal) {
266
+ export async function archive(context, change, signal, options = {}) {
267
267
  const state = journal(context);
268
268
  if (state.phases.archive.status !== 'done') {
269
269
  const active = artifactPath(context.artifactRoot, 'openspec/changes/' + change);
@@ -272,6 +272,10 @@ export async function archive(context, change, signal) {
272
272
  transitionPipeline(context, 'archive', 'running');
273
273
  await archiveWithOpenSpec(context, change, active, signal);
274
274
  }
275
+ // Idempotent post-archive metadata (the capability map); repeated after a crash.
276
+ const archived = archivedOpenSpecChange(context, change);
277
+ if (archived && options.beforeDone)
278
+ options.beforeDone(artifactPath(context.artifactRoot, archived));
275
279
  // If the process died after rename, Core rechecks the saved archive approval.
276
280
  transitionPipeline(context, 'archive', 'done');
277
281
  }
@@ -0,0 +1,50 @@
1
+ import { type PipelineContext } from '../../pipeline/pipeline-state.js';
2
+ /**
3
+ * Baseline on touch: when the blast radius of a change reaches code no
4
+ * capability covers, the plan gains a Baseline task group (characterization
5
+ * tests of today's behaviour plus a minimal as-built spec) that must pass
6
+ * against the unchanged code before the developer touches the module.
7
+ */
8
+ export declare const BASELINE_SCHEMA_VERSION = 1;
9
+ export declare const BASELINE_FILE = "baseline.json";
10
+ /** Error code of the host blocker when characterization tests fail on the base code. */
11
+ export declare const BASELINE_BLOCKER = "baseline_characterization_failed";
12
+ export interface BlastRadiusFile {
13
+ repositoryId: string;
14
+ path: string;
15
+ }
16
+ export interface BaselineDetection {
17
+ blastRadius: string[];
18
+ covered: string[];
19
+ uncovered: string[];
20
+ skipped: string[];
21
+ }
22
+ export interface BaselineRecord {
23
+ schemaVersion: typeof BASELINE_SCHEMA_VERSION;
24
+ change: string;
25
+ /** `not-needed`: every touched module is covered; `planned`: the architect added the Baseline group; `passed`/`failed`: characterization on the base code; `violated`: the baseline stage edited non-test files. */
26
+ status: 'not-needed' | 'planned' | 'passed' | 'failed' | 'violated';
27
+ recordedAt: string;
28
+ detection: BaselineDetection;
29
+ /** Characterization test files the developer wrote in the baseline stage. */
30
+ tests: string[];
31
+ receiptId?: string;
32
+ reason?: string;
33
+ }
34
+ /** Files the design names under its Blast radius heading (backticked paths, or the first path-like token of a bullet). */
35
+ export declare function blastRadiusFiles(context: Pick<PipelineContext, 'repositories' | 'artifactRepositoryId'>, design: string): BlastRadiusFile[];
36
+ /**
37
+ * Intersects the design's blast radius with the capability map and the path
38
+ * mentions in the canonical specs. Only existing code files can need a
39
+ * baseline: new files, tests, documentation and lockfiles are skipped.
40
+ */
41
+ export declare function detectBaseline(context: PipelineContext, change: string): BaselineDetection;
42
+ /** Throws a repairable error unless tasks.md has a Baseline group with a task and a delta spec adds at least one requirement. */
43
+ export declare function validateBaselinePlan(context: PipelineContext, change: string): void;
44
+ /** Test files among changed files (the characterization tests). */
45
+ export declare function testEdits(files: readonly string[]): string[];
46
+ /** Files of the baseline stage that are not tests: the module must stay untouched until characterization passes. */
47
+ export declare function nonTestEdits(files: readonly string[]): string[];
48
+ export declare function baselinePath(context: Pick<PipelineContext, 'backlogRoot' | 'runId'>): string;
49
+ export declare function writeBaselineRecord(context: PipelineContext, entry: Omit<BaselineRecord, 'schemaVersion' | 'recordedAt'>): BaselineRecord;
50
+ export declare function readBaselineRecord(context: Pick<PipelineContext, 'backlogRoot' | 'runId'>): BaselineRecord | null;
@@ -0,0 +1,145 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pipelineStateDirectory } from '../../pipeline/pipeline-state.js';
4
+ import { TEST_FILE, readChangeDelta } from '../../pipeline/spec-trace.js';
5
+ import { TEST_FILE_PATTERN } from '../compact/test-reachability.js';
6
+ import { child, write } from './artifacts.js';
7
+ import { readCapabilityMap, qualifiedPath } from './capability-map.js';
8
+ import { designSection } from './clarifications.js';
9
+ /**
10
+ * Baseline on touch: when the blast radius of a change reaches code no
11
+ * capability covers, the plan gains a Baseline task group (characterization
12
+ * tests of today's behaviour plus a minimal as-built spec) that must pass
13
+ * against the unchanged code before the developer touches the module.
14
+ */
15
+ export const BASELINE_SCHEMA_VERSION = 1;
16
+ export const BASELINE_FILE = 'baseline.json';
17
+ /** Error code of the host blocker when characterization tests fail on the base code. */
18
+ export const BASELINE_BLOCKER = 'baseline_characterization_failed';
19
+ const GENERIC_DIRECTORIES = new Set(['src', 'lib', 'app', 'apps', 'packages', 'server', 'client', 'test', 'tests', 'spec', 'specs', 'source', 'internal', 'pkg', 'cmd']);
20
+ const NOT_CODE = /\.(?:md|mdx|txt|rst|adoc|lock|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|eot|pdf|snap)$|(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|Cargo\.lock|go\.sum|poetry\.lock)$/i;
21
+ const posix = (file) => file.replace(/\\/g, '/').replace(/^\.\//, '');
22
+ /** Files the design names under its Blast radius heading (backticked paths, or the first path-like token of a bullet). */
23
+ export function blastRadiusFiles(context, design) {
24
+ const lines = designSection(design, /\bblast\s+radius\b/i) ?? [];
25
+ const files = [];
26
+ for (const line of lines) {
27
+ const ticks = [...line.matchAll(/`([^`\s]+)`/g)].map(match => match[1]);
28
+ const bare = /^\s*(?:[-*+]|\d+\.)\s+([\w@.~/-][^\s:—–(]*[\w)])/.exec(line)?.[1];
29
+ for (const token of ticks.length ? ticks : bare ? [bare] : []) {
30
+ const qualified = /^([A-Za-z0-9][\w.-]*):(.+)$/.exec(token);
31
+ const repository = qualified && context.repositories.some(item => item.id === qualified[1]) ? qualified[1] : context.artifactRepositoryId;
32
+ const file = posix(qualified && repository === qualified[1] ? qualified[2] : token);
33
+ if (!/[/.]/.test(file) || file.startsWith('..') || path.isAbsolute(file))
34
+ continue;
35
+ if (!files.some(item => item.repositoryId === repository && item.path === file))
36
+ files.push({ repositoryId: repository, path: file });
37
+ }
38
+ }
39
+ return files;
40
+ }
41
+ function specText(context) {
42
+ const root = path.join(context.artifactRoot, 'openspec', 'specs');
43
+ try {
44
+ return readdirSync(root, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => {
45
+ try {
46
+ return readFileSync(path.join(root, entry.name, 'spec.md'), 'utf8');
47
+ }
48
+ catch {
49
+ return '';
50
+ }
51
+ }).join('\n');
52
+ }
53
+ catch {
54
+ return '';
55
+ }
56
+ }
57
+ /** A file is covered when the capability map lists it or a spec mentions it or one of its specific directories. */
58
+ function covered(file, mapped, specs) {
59
+ if (mapped.has(file))
60
+ return true;
61
+ const plain = file.includes(':') ? file.slice(file.indexOf(':') + 1) : file;
62
+ if (specs.includes(plain))
63
+ return true;
64
+ const parts = plain.split('/').slice(0, -1);
65
+ for (let depth = parts.length; depth >= 1; depth--) {
66
+ const directory = parts.slice(0, depth).join('/');
67
+ if (depth === 1 && GENERIC_DIRECTORIES.has(directory))
68
+ continue;
69
+ if (specs.includes(directory + '/'))
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+ /**
75
+ * Intersects the design's blast radius with the capability map and the path
76
+ * mentions in the canonical specs. Only existing code files can need a
77
+ * baseline: new files, tests, documentation and lockfiles are skipped.
78
+ */
79
+ export function detectBaseline(context, change) {
80
+ const designFile = child(context.artifactRoot, 'openspec/changes/' + change + '/design.md');
81
+ const design = existsSync(designFile) ? readFileSync(designFile, 'utf8') : '';
82
+ const files = blastRadiusFiles(context, design);
83
+ const map = readCapabilityMap(context);
84
+ const mapped = new Set(Object.values(map.capabilities).flatMap(entry => entry.files));
85
+ const specs = specText(context);
86
+ const detection = { blastRadius: [], covered: [], uncovered: [], skipped: [] };
87
+ for (const file of files) {
88
+ const repository = context.repositories.find(item => item.id === file.repositoryId);
89
+ const name = qualifiedPath(context, file.repositoryId, file.path);
90
+ detection.blastRadius.push(name);
91
+ const absolute = repository ? path.resolve(repository.path, file.path) : undefined;
92
+ if (!absolute || !existsSync(absolute) || NOT_CODE.test(file.path) || TEST_FILE.test(file.path) || TEST_FILE_PATTERN.test(file.path) || /^(?:openspec|\.specrails)\//.test(file.path)) {
93
+ detection.skipped.push(name);
94
+ continue;
95
+ }
96
+ ;
97
+ (covered(name, mapped, specs) ? detection.covered : detection.uncovered).push(name);
98
+ }
99
+ return detection;
100
+ }
101
+ /** Throws a repairable error unless tasks.md has a Baseline group with a task and a delta spec adds at least one requirement. */
102
+ export function validateBaselinePlan(context, change) {
103
+ const tasksFile = child(context.artifactRoot, 'openspec/changes/' + change + '/tasks.md');
104
+ const tasks = existsSync(tasksFile) ? readFileSync(tasksFile, 'utf8') : '';
105
+ const lines = tasks.split(/\r?\n/);
106
+ const heading = lines.findIndex(line => /^#{1,6}\s.*\bbaseline\b/i.test(line));
107
+ const next = heading < 0 ? -1 : lines.findIndex((line, index) => index > heading && /^#{1,6}\s/.test(line));
108
+ const group = heading < 0 ? [] : lines.slice(heading + 1, next < 0 ? undefined : next);
109
+ const problems = [];
110
+ if (!group.some(line => /^\s*-\s+\[[ x]\]/.test(line)))
111
+ problems.push('tasks.md needs a `## Baseline` task group (before the change tasks) with at least one task');
112
+ if (!readChangeDelta(context.artifactRoot, change).added.length)
113
+ problems.push('a delta spec needs the as-built requirements under `## ADDED Requirements`');
114
+ if (problems.length)
115
+ throw new Error(`Invalid baseline plan: ${problems.join(' and ')}. Update the artifacts through the workflow, then resend the same JSON reply.`);
116
+ }
117
+ /** Test files among changed files (the characterization tests). */
118
+ export function testEdits(files) {
119
+ return files.filter(file => (TEST_FILE.test(file) || TEST_FILE_PATTERN.test(file)) && !/^(?:openspec|\.specrails)\//.test(file));
120
+ }
121
+ /** Files of the baseline stage that are not tests: the module must stay untouched until characterization passes. */
122
+ export function nonTestEdits(files) {
123
+ // OpenSpec artifacts (the Baseline tasks and as-built spec, OpenSpec's own config) are not the module.
124
+ return files.filter(file => !(TEST_FILE.test(file) || TEST_FILE_PATTERN.test(file) || /^(?:openspec|\.specrails)\//.test(file)));
125
+ }
126
+ export function baselinePath(context) {
127
+ return path.join(pipelineStateDirectory(context), BASELINE_FILE);
128
+ }
129
+ export function writeBaselineRecord(context, entry) {
130
+ const record = { schemaVersion: BASELINE_SCHEMA_VERSION, recordedAt: new Date().toISOString(), ...entry };
131
+ write(baselinePath(context), JSON.stringify(record, null, 2) + '\n');
132
+ return record;
133
+ }
134
+ export function readBaselineRecord(context) {
135
+ try {
136
+ const file = baselinePath(context);
137
+ if (!existsSync(file))
138
+ return null;
139
+ const value = JSON.parse(readFileSync(file, 'utf8'));
140
+ return value && typeof value === 'object' && value.schemaVersion === BASELINE_SCHEMA_VERSION ? value : null;
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
@@ -0,0 +1,29 @@
1
+ import type { PipelineContext } from '../../pipeline/pipeline-state.js';
2
+ /**
3
+ * `openspec/capability-map.json`: for every capability, the files archived
4
+ * changes touched. The archive node maintains it; baseline-on-touch reads it
5
+ * to decide whether the blast radius of a new change is already covered by a
6
+ * capability. Paths are repository-relative, prefixed `<repositoryId>:` for
7
+ * repositories other than the artifact repository.
8
+ */
9
+ export declare const CAPABILITY_MAP_PATH = "openspec/capability-map.json";
10
+ export declare const CAPABILITY_MAP_SCHEMA_VERSION = 1;
11
+ export interface CapabilityEntry {
12
+ files: string[];
13
+ changes: string[];
14
+ }
15
+ export interface CapabilityMap {
16
+ schemaVersion: typeof CAPABILITY_MAP_SCHEMA_VERSION;
17
+ capabilities: Record<string, CapabilityEntry>;
18
+ }
19
+ export declare function readCapabilityMap(context: Pick<PipelineContext, 'artifactRoot'>): CapabilityMap;
20
+ /** `<repositoryId>:<path>` outside the artifact repository, the bare path inside it. */
21
+ export declare function qualifiedPath(context: Pick<PipelineContext, 'artifactRepositoryId'>, repositoryId: string, file: string): string;
22
+ /** Capabilities a change's delta specs touch (active or archived change directory). */
23
+ export declare function changeCapabilities(changeDirectory: string): string[];
24
+ /**
25
+ * Merges an archived change into the map: its capabilities gain the files the
26
+ * run changed (OpenSpec artifacts and Core metadata excluded). Idempotent, so
27
+ * a repeated archive step after a crash records nothing twice.
28
+ */
29
+ export declare function recordArchivedChange(context: PipelineContext, change: string, archiveDirectory: string): CapabilityMap;
@@ -0,0 +1,65 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { changeSet } from '../change-scope.js';
4
+ import { child, write } from './artifacts.js';
5
+ /**
6
+ * `openspec/capability-map.json`: for every capability, the files archived
7
+ * changes touched. The archive node maintains it; baseline-on-touch reads it
8
+ * to decide whether the blast radius of a new change is already covered by a
9
+ * capability. Paths are repository-relative, prefixed `<repositoryId>:` for
10
+ * repositories other than the artifact repository.
11
+ */
12
+ export const CAPABILITY_MAP_PATH = 'openspec/capability-map.json';
13
+ export const CAPABILITY_MAP_SCHEMA_VERSION = 1;
14
+ const FILE_LIMIT = 2000;
15
+ const CHANGE_LIMIT = 500;
16
+ export function readCapabilityMap(context) {
17
+ try {
18
+ const file = child(context.artifactRoot, CAPABILITY_MAP_PATH);
19
+ if (!existsSync(file))
20
+ return { schemaVersion: CAPABILITY_MAP_SCHEMA_VERSION, capabilities: {} };
21
+ const value = JSON.parse(readFileSync(file, 'utf8'));
22
+ if (value?.schemaVersion !== CAPABILITY_MAP_SCHEMA_VERSION || !value.capabilities || typeof value.capabilities !== 'object')
23
+ return { schemaVersion: CAPABILITY_MAP_SCHEMA_VERSION, capabilities: {} };
24
+ return value;
25
+ }
26
+ catch {
27
+ return { schemaVersion: CAPABILITY_MAP_SCHEMA_VERSION, capabilities: {} };
28
+ }
29
+ }
30
+ /** `<repositoryId>:<path>` outside the artifact repository, the bare path inside it. */
31
+ export function qualifiedPath(context, repositoryId, file) {
32
+ return repositoryId === context.artifactRepositoryId ? file : repositoryId + ':' + file;
33
+ }
34
+ /** Capabilities a change's delta specs touch (active or archived change directory). */
35
+ export function changeCapabilities(changeDirectory) {
36
+ const specs = path.join(changeDirectory, 'specs');
37
+ try {
38
+ return readdirSync(specs, { withFileTypes: true }).filter(entry => entry.isDirectory() && existsSync(path.join(specs, entry.name, 'spec.md'))).map(entry => entry.name).sort();
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ }
44
+ /**
45
+ * Merges an archived change into the map: its capabilities gain the files the
46
+ * run changed (OpenSpec artifacts and Core metadata excluded). Idempotent, so
47
+ * a repeated archive step after a crash records nothing twice.
48
+ */
49
+ export function recordArchivedChange(context, change, archiveDirectory) {
50
+ const capabilities = changeCapabilities(archiveDirectory);
51
+ const map = readCapabilityMap(context);
52
+ if (!capabilities.length)
53
+ return map;
54
+ const files = changeSet(context, change).flatMap(set => set.files.filter(file => file.status !== 'deleted' && !/^(?:openspec|\.specrails)\//.test(file.path)).map(file => qualifiedPath(context, set.repositoryId, file.path)));
55
+ for (const capability of capabilities) {
56
+ const entry = map.capabilities[capability] ?? { files: [], changes: [] };
57
+ map.capabilities[capability] = {
58
+ files: [...new Set([...entry.files, ...files])].sort().slice(0, FILE_LIMIT),
59
+ changes: [...new Set([...entry.changes, change])].slice(-CHANGE_LIMIT),
60
+ };
61
+ }
62
+ map.capabilities = Object.fromEntries(Object.entries(map.capabilities).sort(([left], [right]) => left.localeCompare(right, 'en')));
63
+ write(child(context.artifactRoot, CAPABILITY_MAP_PATH), JSON.stringify(map, null, 2) + '\n');
64
+ return map;
65
+ }
@@ -0,0 +1,75 @@
1
+ import { type PipelineContext } from '../../pipeline/pipeline-state.js';
2
+ /**
3
+ * The architect's clarifications: the short list of ambiguities it found
4
+ * before planning, each with its impact and the default it would choose.
5
+ * A high-impact question without a safe default pauses the run (policy
6
+ * `ask-on-high`); everything else proceeds and is written down in design.md
7
+ * under Assumptions. Core also enforces a non-empty Non-Goals section.
8
+ */
9
+ export declare const CLARIFICATIONS_SCHEMA_VERSION = 1;
10
+ export declare const CLARIFICATION_LIMIT = 5;
11
+ export declare const CLARIFICATION_IMPACTS: readonly ['high', 'medium', 'low'];
12
+ export declare const CLARIFICATION_POLICIES: readonly ['ask-on-high', 'always-proceed'];
13
+ export type ClarificationImpact = typeof CLARIFICATION_IMPACTS[number];
14
+ export type ClarificationPolicy = typeof CLARIFICATION_POLICIES[number];
15
+ export declare const DEFAULT_CLARIFICATION_POLICY: ClarificationPolicy;
16
+ export interface Clarification {
17
+ question: string;
18
+ impact: ClarificationImpact;
19
+ /** The answer the architect would assume; null when no safe default exists. */
20
+ default: string | null;
21
+ }
22
+ /** The optional `clarifications` property of ARCHITECT_OUTPUT_SCHEMA. */
23
+ export declare const CLARIFICATIONS_SCHEMA: Record<string, unknown>;
24
+ /** Throws a repairable `Invalid architect clarifications` error for a malformed list. */
25
+ export declare function parseClarifications(value: unknown): Clarification[];
26
+ /** The clarifications that pause the run under the policy: high impact without a safe default. */
27
+ export declare function blockingClarifications(clarifications: readonly Clarification[], policy?: ClarificationPolicy): Clarification[];
28
+ /** True when a raw architect reply carries a high-impact clarification without a default: it may stop before authoring artifacts. */
29
+ export declare function asksBeforePlanning(value: unknown): boolean;
30
+ /** The single question the paused run carries (`pendingQuestion.question`). */
31
+ export declare function clarificationQuestion(blocking: readonly Clarification[], lowConfidenceQuestion?: string): string;
32
+ /**
33
+ * The content lines of a design.md section, or undefined when the section is
34
+ * absent. A section is a heading that contains the name (`## Non-Goals`,
35
+ * `## Goals / Non-Goals` without labels) or a bold label (`**Non-Goals:**`),
36
+ * which takes precedence; it ends at the next heading or label. HTML comments
37
+ * (template placeholders) and empty bullets do not count as content.
38
+ */
39
+ export declare function designSection(markdown: string, name: RegExp): string[] | undefined;
40
+ export declare const NON_GOALS: RegExp;
41
+ export declare const ASSUMPTIONS: RegExp;
42
+ /**
43
+ * Validates the active change's design.md once the architect proceeds:
44
+ * a non-empty Non-Goals section always, and a non-empty Assumptions section
45
+ * when it reported clarifications. Missing design.md is left to the design
46
+ * gate. Throws a repairable `Invalid design.md` error.
47
+ */
48
+ export declare function validateDesignSections(context: PipelineContext, change: string, options: {
49
+ clarifications: readonly Clarification[];
50
+ policy?: ClarificationPolicy;
51
+ }): void;
52
+ export interface ClarificationsRecord {
53
+ schemaVersion: typeof CLARIFICATIONS_SCHEMA_VERSION;
54
+ change: string;
55
+ policy: ClarificationPolicy;
56
+ /** `awaiting-answers`: the run paused on `blocking`; `proceeded`: the architect planned on its defaults and the answers. */
57
+ status: 'awaiting-answers' | 'proceeded';
58
+ recordedAt: string;
59
+ clarifications: Clarification[];
60
+ /** The high-impact questions without a safe default the run paused on (empty once it proceeds). */
61
+ blocking: Clarification[];
62
+ /** Answers the requester gave so far, oldest first; authoritative for the design. */
63
+ answers: string[];
64
+ /** The Assumptions lines design.md records once the architect proceeds (empty when none). */
65
+ assumptions: string[];
66
+ }
67
+ export declare const CLARIFICATIONS_FILE = "clarifications.json";
68
+ export declare function clarificationsPath(context: Pick<PipelineContext, 'backlogRoot' | 'runId'>): string;
69
+ export declare function writeClarificationsRecord(context: PipelineContext, entry: Omit<ClarificationsRecord, 'schemaVersion' | 'recordedAt' | 'assumptions'> & {
70
+ assumptions?: string[];
71
+ }): ClarificationsRecord;
72
+ /** The Assumptions lines of the active change's design.md, for the host record. */
73
+ export declare function designAssumptions(context: PipelineContext, change: string): string[];
74
+ /** The latest clarifications record of a run, or null when the architect has not recorded one (or it is unreadable). */
75
+ export declare function readClarificationsRecord(context: Pick<PipelineContext, 'backlogRoot' | 'runId'>): ClarificationsRecord | null;