@ai-sdlc/orchestrator 0.13.0 → 0.15.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.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * RFC-0018 Phase 4 — MetricSnapshot resource read API (OQ-5 resolution).
3
+ *
4
+ * Implements:
5
+ * AC #1: MetricSnapshot schema (spec/schemas/metric-snapshot.v1.schema.json)
6
+ * AC #2: MetricSnapshot read API — `getLatestMetricSnapshot(journey, metricId)`
7
+ * AC #3: Stale-metric detection (default 30d; per-Soul configurable) with
8
+ * `Decision: journey-metric-stale` + warn-and-unknown Cκ behavior
9
+ * AC #4: Graduated Eρ₅ degradation (0-30/30-60/60-90/90+ thresholds + Decisions)
10
+ * AC #5: Per-Soul `accessibility.auditOverdueGracePolicy` modes
11
+ * AC #6: RFC-0022 multi-posture composition (strictest cadence applies)
12
+ *
13
+ * ### OQ-5 design summary
14
+ *
15
+ * Operators supply MetricSnapshot resources from their analytics pipeline
16
+ * (Mixpanel, Amplitude, Heap, internal-pipeline). The framework reads
17
+ * `completion-rate` and other journey-success values; it does NOT compute
18
+ * them from an analytics backend.
19
+ *
20
+ * Staleness: when `recordedAt` is older than `thresholdDays` (default 30),
21
+ * the scorer treats the metric as an unknown input (warn-and-unknown, NOT
22
+ * fail-closed). A `Decision: journey-metric-stale` is emitted for operator
23
+ * batch review.
24
+ *
25
+ * ### OQ-6 design summary
26
+ *
27
+ * When a journey's accessibility audit is overdue, Eρ₅ degrades on this
28
+ * graduated schedule (per-Soul policy `auditOverdueGracePolicy`):
29
+ *
30
+ * Policy `graduated` (default):
31
+ * 0–30d past cadence → warn only (`journey-audit-overdue-warn`)
32
+ * 30–60d → Eρ₅ -25% (`journey-audit-overdue-graduated`)
33
+ * 60–90d → Eρ₅ -50% (`journey-audit-overdue-graduated`)
34
+ * 90d+ → effective block (`journey-audit-overdue-blocking`)
35
+ *
36
+ * Policy `binary-30d`:
37
+ * 0–30d → no impact (SOC2/HIPAA early-warning model)
38
+ * 30d+ → immediate Eρ₅ fail
39
+ *
40
+ * Policy `hard-block`:
41
+ * Immediate Eρ₅ fail at cadence+0d (strictest, no grace)
42
+ *
43
+ * ### RFC-0022 multi-posture composition (AC #6)
44
+ *
45
+ * When the RFC-0022 compliance posture declares a stricter cadence than
46
+ * the soul-default, the strictest constraint wins. This mirrors the
47
+ * RFC-0030 OQ-13.3 UNION precedent for multi-posture composition.
48
+ *
49
+ * ### Decision-routing must-consume contract
50
+ *
51
+ * `getLatestMetricSnapshot` emits `decision: 'journey-metric-stale'` when a
52
+ * snapshot is present but older than `thresholdDays`. Callers MUST inspect
53
+ * `result.decision` and route it — typically to the RFC-0035 G0 batch-review
54
+ * queue — before using `result.snapshot.spec.value` for Cκ scoring. Silently
55
+ * dropping `result.decision` defeats the operator-visibility guarantee that
56
+ * makes warn-and-unknown safe (non-fail-closed). A typed must-consume pattern:
57
+ *
58
+ * ```ts
59
+ * const result = getLatestMetricSnapshot(journey, metricId, opts);
60
+ * if (result.decision) emitDecision(result.decision); // required
61
+ * if (result.freshness === 'fresh') useValue(result.snapshot!.spec.value);
62
+ * ```
63
+ *
64
+ * @see spec/rfcs/RFC-0018-in-soul-journey-pattern.md §10.1 OQ-5 + OQ-6
65
+ * @see spec/schemas/metric-snapshot.v1.schema.json
66
+ */
67
+ /** Default staleness threshold per OQ-5 resolution (30 days). */
68
+ export const DEFAULT_STALENESS_THRESHOLD_DAYS = 30;
69
+ /**
70
+ * Retrieve the **latest** MetricSnapshot for the given journey + metricId pair
71
+ * and classify it as fresh, stale, or missing.
72
+ *
73
+ * Selection: when multiple snapshots match, the one with the most recent
74
+ * `spec.recordedAt` is returned (latest-wins). This covers the case where
75
+ * the operator's pipeline emits snapshots on a periodic schedule.
76
+ *
77
+ * Staleness: `ageInDays = (now - recordedAt) / (1000 * 60 * 60 * 24)`.
78
+ * When `ageInDays > thresholdDays`, the result carries:
79
+ * - `freshness: 'stale'`
80
+ * - `decision: 'journey-metric-stale'`
81
+ *
82
+ * This Decision routes through RFC-0035 G0 (non-blocking batch review):
83
+ * the Cκ scorer treats a stale metric as an unknown input (same behavior as
84
+ * `freshness: 'missing'`), NOT as a hard fail. The pipeline continues.
85
+ *
86
+ * @param journey Path-style journey URI (e.g. 'spry-engage/onboarding')
87
+ * @param metricId Metric identifier (e.g. 'completion-rate')
88
+ * @param options Snapshot collection + optional per-Soul config
89
+ */
90
+ export function getLatestMetricSnapshot(journey, metricId, options) {
91
+ const { snapshots, stalenessConfig, now } = options;
92
+ const thresholdDays = stalenessConfig?.thresholdDays ?? DEFAULT_STALENESS_THRESHOLD_DAYS;
93
+ const nowMs = now ? new Date(now).getTime() : Date.now();
94
+ // Filter to snapshots matching this journey + metricId pair.
95
+ const matching = snapshots.filter((s) => s.metadata.journey === journey && s.metadata.metricId === metricId);
96
+ if (matching.length === 0) {
97
+ return {
98
+ journey,
99
+ metricId,
100
+ freshness: 'missing',
101
+ thresholdDays,
102
+ };
103
+ }
104
+ // Select the most recent by recordedAt (latest-wins).
105
+ const latest = matching.reduce((best, candidate) => {
106
+ const bestMs = new Date(best.spec.recordedAt).getTime();
107
+ const candidateMs = new Date(candidate.spec.recordedAt).getTime();
108
+ return candidateMs > bestMs ? candidate : best;
109
+ });
110
+ const recordedAtMs = new Date(latest.spec.recordedAt).getTime();
111
+ // Guard: a future-dated recordedAt (recordedAt > now) would yield a negative
112
+ // ageInDays, making the metric appear perpetually fresh and suppressing the
113
+ // journey-metric-stale Decision. Clamp negative ages to stale so a
114
+ // misconfigured analytics pipeline cannot silently defeat staleness checks.
115
+ const rawAgeInDays = (nowMs - recordedAtMs) / (1000 * 60 * 60 * 24);
116
+ const ageInDays = rawAgeInDays < 0 ? thresholdDays + 1 : rawAgeInDays;
117
+ if (ageInDays > thresholdDays) {
118
+ return {
119
+ journey,
120
+ metricId,
121
+ freshness: 'stale',
122
+ snapshot: latest,
123
+ decision: 'journey-metric-stale',
124
+ ageInDays,
125
+ thresholdDays,
126
+ };
127
+ }
128
+ return {
129
+ journey,
130
+ metricId,
131
+ freshness: 'fresh',
132
+ snapshot: latest,
133
+ ageInDays,
134
+ thresholdDays,
135
+ };
136
+ }
137
+ /** Eρ₅ multiplier for each impact tier. */
138
+ export const ERHO5_MULTIPLIERS = {
139
+ warn: 1.0,
140
+ 'reduced-25': 0.75,
141
+ 'reduced-50': 0.5,
142
+ 'effective-block': 0.0,
143
+ };
144
+ /** Default graduated thresholds per OQ-6 resolution. */
145
+ export const DEFAULT_GRADUATED_THRESHOLDS = {
146
+ warnAt: 0,
147
+ reduced25At: 30,
148
+ reduced50At: 60,
149
+ effectiveBlockAt: 90,
150
+ };
151
+ /**
152
+ * Compute the Eρ₅ impact and Decision for an overdue accessibility audit.
153
+ *
154
+ * Implements RFC-0018 §10.1 OQ-6 graduated Eρ₅ degradation:
155
+ *
156
+ * Policy `graduated` (default):
157
+ * - 0 ≤ daysOverdue < 30 → `warn` (multiplier 1.0)
158
+ * - 30 ≤ daysOverdue < 60 → `reduced-25` (multiplier 0.75)
159
+ * - 60 ≤ daysOverdue < 90 → `reduced-50` (multiplier 0.50)
160
+ * - daysOverdue ≥ 90 → `effective-block` (multiplier 0.0)
161
+ *
162
+ * Policy `binary-30d` (SOC2/HIPAA strict):
163
+ * - daysOverdue < 30 → no impact (multiplier 1.0, no Decision)
164
+ * - daysOverdue ≥ 30 → `effective-block` (multiplier 0.0)
165
+ *
166
+ * Policy `hard-block` (HIPAA/PCI-DSS ultra-strict):
167
+ * - daysOverdue < 0 → no impact (multiplier 1.0, no Decision; not yet due)
168
+ * - daysOverdue ≥ 0 → `effective-block` (multiplier 0.0) — cadence+0d, no grace
169
+ *
170
+ * When `daysOverdue < 0`, returns multiplier 1.0 and `decision: null`
171
+ * regardless of policy (audit is not yet due). At daysOverdue ≥ 0 each policy
172
+ * emits its Decision (no implicit grace day).
173
+ */
174
+ export function computeAuditOverdueErho5(options) {
175
+ const { soulId, journeyId, daysOverdue, policy = 'graduated', graduatedThresholds } = options;
176
+ // Strictly negative daysOverdue means the audit is not yet due — no impact
177
+ // regardless of policy. Note: daysOverdue === 0 means "exactly at cadence
178
+ // boundary (cadence+0d)" and is intentionally NOT caught here so the
179
+ // policy-specific logic below can apply. In particular, `hard-block`
180
+ // specifies "no grace at cadence+0d", meaning it must fire at daysOverdue=0.
181
+ if (daysOverdue < 0) {
182
+ return {
183
+ soulId,
184
+ journeyId,
185
+ daysOverdue,
186
+ policy,
187
+ impact: 'warn',
188
+ erho5Multiplier: 1.0,
189
+ decision: null,
190
+ };
191
+ }
192
+ if (policy === 'hard-block') {
193
+ return {
194
+ soulId,
195
+ journeyId,
196
+ daysOverdue,
197
+ policy,
198
+ impact: 'effective-block',
199
+ erho5Multiplier: ERHO5_MULTIPLIERS['effective-block'],
200
+ decision: 'journey-audit-overdue-blocking',
201
+ };
202
+ }
203
+ if (policy === 'binary-30d') {
204
+ const threshold = 30;
205
+ if (daysOverdue < threshold) {
206
+ // SOC2/HIPAA grace window: warn only, no Eρ₅ impact.
207
+ return {
208
+ soulId,
209
+ journeyId,
210
+ daysOverdue,
211
+ policy,
212
+ impact: 'warn',
213
+ erho5Multiplier: 1.0,
214
+ decision: 'journey-audit-overdue-warn',
215
+ };
216
+ }
217
+ return {
218
+ soulId,
219
+ journeyId,
220
+ daysOverdue,
221
+ policy,
222
+ impact: 'effective-block',
223
+ erho5Multiplier: ERHO5_MULTIPLIERS['effective-block'],
224
+ decision: 'journey-audit-overdue-blocking',
225
+ };
226
+ }
227
+ // policy === 'graduated' (default)
228
+ // Guard: NaN daysOverdue (e.g. from a division by zero or bad caller) must
229
+ // not fall through to the warn/1.0 return at the bottom of the graduated
230
+ // path, producing a fail-open result. Treat non-finite values as
231
+ // effective-block (conservative) so the pipeline aborts rather than silently
232
+ // continuing with an unknown overdue duration.
233
+ if (!Number.isFinite(daysOverdue)) {
234
+ return {
235
+ soulId,
236
+ journeyId,
237
+ daysOverdue,
238
+ policy,
239
+ impact: 'effective-block',
240
+ erho5Multiplier: ERHO5_MULTIPLIERS['effective-block'],
241
+ decision: 'journey-audit-overdue-blocking',
242
+ };
243
+ }
244
+ const thresholds = {
245
+ warnAt: graduatedThresholds?.warnAt ?? DEFAULT_GRADUATED_THRESHOLDS.warnAt,
246
+ reduced25At: graduatedThresholds?.reduced25At ?? DEFAULT_GRADUATED_THRESHOLDS.reduced25At,
247
+ reduced50At: graduatedThresholds?.reduced50At ?? DEFAULT_GRADUATED_THRESHOLDS.reduced50At,
248
+ effectiveBlockAt: graduatedThresholds?.effectiveBlockAt ?? DEFAULT_GRADUATED_THRESHOLDS.effectiveBlockAt,
249
+ };
250
+ if (daysOverdue >= thresholds.effectiveBlockAt) {
251
+ return {
252
+ soulId,
253
+ journeyId,
254
+ daysOverdue,
255
+ policy,
256
+ impact: 'effective-block',
257
+ erho5Multiplier: ERHO5_MULTIPLIERS['effective-block'],
258
+ decision: 'journey-audit-overdue-blocking',
259
+ };
260
+ }
261
+ if (daysOverdue >= thresholds.reduced50At) {
262
+ return {
263
+ soulId,
264
+ journeyId,
265
+ daysOverdue,
266
+ policy,
267
+ impact: 'reduced-50',
268
+ erho5Multiplier: ERHO5_MULTIPLIERS['reduced-50'],
269
+ decision: 'journey-audit-overdue-graduated',
270
+ };
271
+ }
272
+ if (daysOverdue >= thresholds.reduced25At) {
273
+ return {
274
+ soulId,
275
+ journeyId,
276
+ daysOverdue,
277
+ policy,
278
+ impact: 'reduced-25',
279
+ erho5Multiplier: ERHO5_MULTIPLIERS['reduced-25'],
280
+ decision: 'journey-audit-overdue-graduated',
281
+ };
282
+ }
283
+ // daysOverdue >= warnAt (default 0) but below reduced25At
284
+ return {
285
+ soulId,
286
+ journeyId,
287
+ daysOverdue,
288
+ policy,
289
+ impact: 'warn',
290
+ erho5Multiplier: 1.0,
291
+ decision: 'journey-audit-overdue-warn',
292
+ };
293
+ }
294
+ /**
295
+ * Numeric strictness order for cadence values.
296
+ * Higher = stricter (shorter audit interval).
297
+ * Used by `resolveStrictestCadence` to pick the UNION result.
298
+ */
299
+ export const AUDIT_CADENCE_STRICTNESS = {
300
+ continuous: 4,
301
+ 'release-gated': 3,
302
+ quarterly: 2,
303
+ annually: 1,
304
+ };
305
+ /**
306
+ * Resolve the effective audit cadence by applying the strictest constraint
307
+ * from the journey declaration and all active RFC-0022 compliance postures.
308
+ *
309
+ * This implements RFC-0018 AC #6 + RFC-0030 OQ-13.3 UNION precedent:
310
+ * the strictest constraint among all active postures and the journey's own
311
+ * declaration wins.
312
+ *
313
+ * @example
314
+ * // Journey declares 'annually', but SOC2 posture requires 'quarterly'
315
+ * resolveStrictestCadence({
316
+ * journeyCadence: 'annually',
317
+ * postureCadences: ['quarterly'],
318
+ * })
319
+ * // → 'quarterly' (posture wins — stricter)
320
+ *
321
+ * @example
322
+ * // Journey declares 'continuous' (strictest possible)
323
+ * resolveStrictestCadence({
324
+ * journeyCadence: 'continuous',
325
+ * postureCadences: ['quarterly', 'annually'],
326
+ * })
327
+ * // → 'continuous' (journey wins — already strictest)
328
+ */
329
+ export function resolveStrictestCadence(options) {
330
+ const { journeyCadence, postureCadences } = options;
331
+ const all = [journeyCadence, ...postureCadences];
332
+ // UNION = strictest (highest strictness number wins).
333
+ return all.reduce((strictest, candidate) => {
334
+ const currentOrder = AUDIT_CADENCE_STRICTNESS[strictest] ?? 0;
335
+ const candidateOrder = AUDIT_CADENCE_STRICTNESS[candidate] ?? 0;
336
+ return candidateOrder > currentOrder ? candidate : strictest;
337
+ });
338
+ }
339
+ /**
340
+ * Policy strictness order (higher = stricter).
341
+ */
342
+ export const GRACE_POLICY_STRICTNESS = {
343
+ graduated: 1,
344
+ 'binary-30d': 2,
345
+ 'hard-block': 3,
346
+ };
347
+ /**
348
+ * Resolve the effective grace policy by picking the STRICTEST among the
349
+ * soul-level policy and all active RFC-0022 compliance posture policies.
350
+ *
351
+ * RFC-0022 + RFC-0018 AC #6: multi-posture UNION → strictest applies.
352
+ *
353
+ * @example
354
+ * // Soul defaults to 'graduated'; SOC2 posture requires 'binary-30d'
355
+ * resolveStrictestGracePolicy({
356
+ * soulPolicy: 'graduated',
357
+ * posturesPolicies: ['binary-30d'],
358
+ * })
359
+ * // → 'binary-30d' (posture wins — stricter)
360
+ */
361
+ export function resolveStrictestGracePolicy(options) {
362
+ const { soulPolicy = 'graduated', posturesPolicies } = options;
363
+ const all = [soulPolicy, ...posturesPolicies];
364
+ return all.reduce((strictest, candidate) => {
365
+ const currentOrder = GRACE_POLICY_STRICTNESS[strictest] ?? 0;
366
+ const candidateOrder = GRACE_POLICY_STRICTNESS[candidate] ?? 0;
367
+ return candidateOrder > currentOrder ? candidate : strictest;
368
+ });
369
+ }
370
+ //# sourceMappingURL=metric-snapshot.js.map
@@ -87,13 +87,10 @@ export function createOTelBridge(metricStore, options) {
87
87
  [ATTRIBUTE_KEYS.RUN_ID]: runId,
88
88
  [ATTRIBUTE_KEYS.PIPELINE]: pipelineType,
89
89
  };
90
- // Use withSpan to create a span (fire-and-forget style)
91
- let endFn;
92
90
  // Since withSpan is async, we track spans manually
93
91
  const handle = {
94
92
  end(_status) {
95
93
  activeSpans.delete(runId);
96
- endFn?.();
97
94
  },
98
95
  setAttribute(key, value) {
99
96
  attributes[key] = value;
@@ -47,7 +47,7 @@ export function buildPrompt(ctx) {
47
47
  const ciVerify = buildVerificationSteps(step, lintCmd, fmtCmd, typecheckCmd);
48
48
  lines.push(...ciVerify.lines);
49
49
  step = ciVerify.nextStep;
50
- lines.push(`${++step}. Write or update tests if needed to cover your fix.`, `${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${++step}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
50
+ lines.push(`${++step}. Write or update tests if needed to cover your fix.`, `${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${step + 1}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
51
51
  }
52
52
  else if (ctx.reviewFindings) {
53
53
  let step = 0;
@@ -55,7 +55,7 @@ export function buildPrompt(ctx) {
55
55
  const reviewVerify = buildVerificationSteps(step, lintCmd, fmtCmd, typecheckCmd);
56
56
  lines.push(...reviewVerify.lines);
57
57
  step = reviewVerify.nextStep;
58
- lines.push(`${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${++step}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
58
+ lines.push(`${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${step + 1}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
59
59
  }
60
60
  else {
61
61
  let step = 0;
@@ -63,7 +63,7 @@ export function buildPrompt(ctx) {
63
63
  const defaultVerify = buildVerificationSteps(step, lintCmd, fmtCmd, typecheckCmd);
64
64
  lines.push(...defaultVerify.lines);
65
65
  step = defaultVerify.nextStep;
66
- lines.push(`${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${++step}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
66
+ lines.push(`${++step}. NEVER modify files matching the blocked paths below — violations will be automatically detected and the change will be rejected.`, `${step + 1}. Keep your changes to at most ${ctx.constraints.maxFilesPerChange} files.`);
67
67
  }
68
68
  lines.push('', '## Constraints (enforced — violations will be automatically rejected)', `- Maximum files to change: ${ctx.constraints.maxFilesPerChange}`, `- Tests required: ${ctx.constraints.requireTests}`, `- Blocked paths (NEVER modify — changes will be rejected): ${ctx.constraints.blockedPaths.join(', ') || 'none'}`);
69
69
  // Append relevant episodic memory if available
@@ -75,7 +75,7 @@ export class RunnerRegistry {
75
75
  }
76
76
  catch (err) {
77
77
  throw new Error(`AI_SDLC_RUNNER_PLUGIN: failed to import plugin module "${pluginPath}": ${err instanceof Error ? err.message : String(err)}.\n` +
78
- `Ensure the path is correct and the module is a valid ESM/CJS module.`);
78
+ `Ensure the path is correct and the module is a valid ESM/CJS module.`, { cause: err });
79
79
  }
80
80
  // Accept default export or named 'runner' export
81
81
  const exported = mod.default ?? mod.runner;
@@ -554,7 +554,9 @@ export function collectChangedFileEntriesForV5(repoRoot, baseRef = 'origin/main'
554
554
  }
555
555
  catch (err) {
556
556
  const msg = err instanceof Error ? err.message : String(err);
557
- throw new Error(`collectChangedFileEntriesForV5: git merge-base failed: ${msg}`);
557
+ throw new Error(`collectChangedFileEntriesForV5: git merge-base failed: ${msg}`, {
558
+ cause: err,
559
+ });
558
560
  }
559
561
  if (!/^[0-9a-f]{40}$/i.test(signedMergeBase)) {
560
562
  throw new Error(`collectChangedFileEntriesForV5: git merge-base returned non-SHA output: ${JSON.stringify(signedMergeBase)}`);
@@ -577,7 +579,9 @@ export function collectChangedFileEntriesForV5(repoRoot, baseRef = 'origin/main'
577
579
  }
578
580
  catch (err) {
579
581
  const msg = err instanceof Error ? err.message : String(err);
580
- throw new Error(`collectChangedFileEntriesForV5: git diff --name-only failed: ${msg}`);
582
+ throw new Error(`collectChangedFileEntriesForV5: git diff --name-only failed: ${msg}`, {
583
+ cause: err,
584
+ });
581
585
  }
582
586
  const paths = nameOnly.split('\n').filter((p) => p.length > 0);
583
587
  const entries = [];
@@ -722,7 +726,9 @@ export function collectChangedFileEntries(baseRef, headRef, repoRoot, options =
722
726
  }
723
727
  catch (err) {
724
728
  const msg = err instanceof Error ? err.message : String(err);
725
- throw new Error(`collectChangedFileEntries: git diff --name-only failed: ${msg}`);
729
+ throw new Error(`collectChangedFileEntries: git diff --name-only failed: ${msg}`, {
730
+ cause: err,
731
+ });
726
732
  }
727
733
  const paths = nameOnly.split('\n').filter((p) => p.length > 0);
728
734
  const entries = [];
@@ -929,7 +935,9 @@ export function collectChangedFileDeltaEntries(baseRef, headRef, repoRoot, optio
929
935
  }
930
936
  catch (err) {
931
937
  const msg = err instanceof Error ? err.message : String(err);
932
- throw new Error(`collectChangedFileDeltaEntries: git merge-base failed: ${msg}`);
938
+ throw new Error(`collectChangedFileDeltaEntries: git merge-base failed: ${msg}`, {
939
+ cause: err,
940
+ });
933
941
  }
934
942
  if (!/^[0-9a-f]{40}$/.test(mergeBase)) {
935
943
  throw new Error(`collectChangedFileDeltaEntries: git merge-base returned non-SHA output: ${JSON.stringify(mergeBase)}`);
@@ -947,7 +955,9 @@ export function collectChangedFileDeltaEntries(baseRef, headRef, repoRoot, optio
947
955
  }
948
956
  catch (err) {
949
957
  const msg = err instanceof Error ? err.message : String(err);
950
- throw new Error(`collectChangedFileDeltaEntries: git diff --name-only failed: ${msg}`);
958
+ throw new Error(`collectChangedFileDeltaEntries: git diff --name-only failed: ${msg}`, {
959
+ cause: err,
960
+ });
951
961
  }
952
962
  const paths = nameOnly.split('\n').filter((p) => p.length > 0);
953
963
  const entries = [];
@@ -29,7 +29,9 @@ export function loadExemplarBank(filePath) {
29
29
  doc = parseYaml(raw);
30
30
  }
31
31
  catch (err) {
32
- throw new Error(`Failed to parse SA exemplar bank at ${filePath}: ${err.message}`);
32
+ throw new Error(`Failed to parse SA exemplar bank at ${filePath}: ${err.message}`, {
33
+ cause: err,
34
+ });
33
35
  }
34
36
  if (!doc || typeof doc !== 'object' || !('exemplars' in doc)) {
35
37
  throw new Error(`SA exemplar bank ${filePath} must contain a top-level "exemplars" array`);
package/dist/shared.d.ts CHANGED
@@ -28,8 +28,36 @@ export declare function slugify(input: string, maxLen?: number): string;
28
28
  /**
29
29
  * Interpolate a branch name pattern by replacing `{key}` placeholders.
30
30
  * Falls back to `ai-sdlc/issue-{issueNumber}` when no pattern is provided.
31
+ *
32
+ * **Security advisory — `{issueTitle}` is unsafe in custom branch patterns.**
33
+ * Issue titles are user-supplied and may contain characters outside the safe
34
+ * git ref charset `[A-Za-z0-9/_.-]` (e.g. spaces, colons, parentheses, Unicode).
35
+ * When those characters are interpolated into a custom `branchPattern`, the
36
+ * resulting branch name fails `validateBranchName()` and the pipeline aborts.
37
+ * Use `{slug}` instead — it is the output of `slugify(issueTitle)` and is
38
+ * guaranteed to consist only of lowercase alphanumerics and hyphens.
39
+ *
40
+ * @example
41
+ * // SAFE:
42
+ * 'ai-sdlc/{issueIdLower}-{slug}' // slug is pre-sanitized
43
+ *
44
+ * // UNSAFE (may throw validateBranchName):
45
+ * 'ai-sdlc/{issueIdLower}-{issueTitle}' // issueTitle is raw user input
31
46
  */
32
47
  export declare function interpolateBranchPattern(pattern: string | undefined, vars: Record<string, string>): string;
48
+ /**
49
+ * Validate that a computed branch name is safe to pass to `git` as a
50
+ * positional argument (defense against second-order command injection,
51
+ * CodeQL js/second-order-command-line-injection, alert #167).
52
+ *
53
+ * Git ref names MUST:
54
+ * - Not start with `-` (would be parsed as a flag, e.g. `--upload-pack=cmd`)
55
+ * - Contain only safe characters: alphanumerics, `/`, `-`, `_`, `.`
56
+ *
57
+ * Throws `Error` when the name fails validation so the pipeline aborts
58
+ * before any `git fetch/checkout/push` call uses the tainted value.
59
+ */
60
+ export declare function validateBranchName(name: string): void;
33
61
  /**
34
62
  * Interpolate a PR title template by replacing `{key}` placeholders.
35
63
  * Falls back to `fix: {issueTitle} (#{issueNumber})` when no template is provided.
package/dist/shared.js CHANGED
@@ -59,10 +59,47 @@ export function slugify(input, maxLen = 40) {
59
59
  /**
60
60
  * Interpolate a branch name pattern by replacing `{key}` placeholders.
61
61
  * Falls back to `ai-sdlc/issue-{issueNumber}` when no pattern is provided.
62
+ *
63
+ * **Security advisory — `{issueTitle}` is unsafe in custom branch patterns.**
64
+ * Issue titles are user-supplied and may contain characters outside the safe
65
+ * git ref charset `[A-Za-z0-9/_.-]` (e.g. spaces, colons, parentheses, Unicode).
66
+ * When those characters are interpolated into a custom `branchPattern`, the
67
+ * resulting branch name fails `validateBranchName()` and the pipeline aborts.
68
+ * Use `{slug}` instead — it is the output of `slugify(issueTitle)` and is
69
+ * guaranteed to consist only of lowercase alphanumerics and hyphens.
70
+ *
71
+ * @example
72
+ * // SAFE:
73
+ * 'ai-sdlc/{issueIdLower}-{slug}' // slug is pre-sanitized
74
+ *
75
+ * // UNSAFE (may throw validateBranchName):
76
+ * 'ai-sdlc/{issueIdLower}-{issueTitle}' // issueTitle is raw user input
62
77
  */
63
78
  export function interpolateBranchPattern(pattern, vars) {
64
79
  return interpolate(pattern ?? DEFAULT_BRANCH_TEMPLATE, vars);
65
80
  }
81
+ /**
82
+ * Validate that a computed branch name is safe to pass to `git` as a
83
+ * positional argument (defense against second-order command injection,
84
+ * CodeQL js/second-order-command-line-injection, alert #167).
85
+ *
86
+ * Git ref names MUST:
87
+ * - Not start with `-` (would be parsed as a flag, e.g. `--upload-pack=cmd`)
88
+ * - Contain only safe characters: alphanumerics, `/`, `-`, `_`, `.`
89
+ *
90
+ * Throws `Error` when the name fails validation so the pipeline aborts
91
+ * before any `git fetch/checkout/push` call uses the tainted value.
92
+ */
93
+ export function validateBranchName(name) {
94
+ if (name.startsWith('-')) {
95
+ throw new Error(`[security] Computed branch name starts with '-' and would be interpreted as a git flag: ${JSON.stringify(name)}`);
96
+ }
97
+ // Allow the chars that appear in all supported branch name templates:
98
+ // alphanumerics, forward-slash (namespace separator), hyphen, underscore, dot.
99
+ if (!/^[A-Za-z0-9/_.-]+$/.test(name)) {
100
+ throw new Error(`[security] Computed branch name contains characters outside the safe ref charset [A-Za-z0-9/_.-]: ${JSON.stringify(name)}`);
101
+ }
102
+ }
66
103
  /**
67
104
  * Interpolate a PR title template by replacing `{key}` placeholders.
68
105
  * Falls back to `fix: {issueTitle} (#{issueNumber})` when no template is provided.
@@ -6,7 +6,13 @@ import { createWebhookServer, createWebhookBridge, createGitHubWebhookProvider,
6
6
  // ── Implementation ───────────────────────────────────────────────────
7
7
  export function createWebhookManager(config) {
8
8
  const server = createWebhookServer({ port: config.port, host: config.host });
9
- // Create unified bridges
9
+ // Create unified bridges.
10
+ // The `: unknown` annotations below are explicit (matching the
11
+ // `WebhookTransformer<T> = (payload: unknown) => T | null` contract) to guard
12
+ // against a fresh-worktree implicit-any (TS7006): when `@ai-sdlc/reference`
13
+ // dist is absent, TS resolves its types as `any` and the inferred callback
14
+ // param becomes implicitly `any`. Do not remove these as "redundant" — that
15
+ // re-introduces the build-order-sensitive typecheck failure (AISDLC-517).
10
16
  const issueBridge = createWebhookBridge((payload) => {
11
17
  // Try each transformer in order
12
18
  return (transformIssueEvent(payload) ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdlc/orchestrator",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "AI-SDLC Orchestrator — long-running runtime that drives issues through the complete SDLC with AI agents",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -37,26 +37,31 @@
37
37
  "types": "./dist/state/index.d.ts",
38
38
  "import": "./dist/state/index.js"
39
39
  },
40
+ "./runtime": {
41
+ "types": "./dist/runtime/index.d.ts",
42
+ "import": "./dist/runtime/index.js"
43
+ },
40
44
  "./cli": {
41
45
  "types": "./dist/cli/index.d.ts",
42
46
  "import": "./dist/cli/index.js"
43
47
  }
44
48
  },
45
49
  "dependencies": {
46
- "@inquirer/prompts": "^7.0.0",
47
- "better-sqlite3": "^11.0.0",
50
+ "@inquirer/prompts": "^8.5.2",
51
+ "better-sqlite3": "^12.11.1",
48
52
  "commander": "^15.0.0",
49
53
  "franc": "^6.2.0",
50
54
  "yaml": "^2.9.0",
51
- "@ai-sdlc/reference": "0.13.0"
55
+ "@ai-sdlc/reference": "0.15.0"
52
56
  },
53
57
  "devDependencies": {
54
58
  "@types/better-sqlite3": "^7.6.0",
55
- "@types/node": "^25.9.2",
56
- "@vitest/coverage-v8": "^3.2.4",
59
+ "@types/node": "^25.9.3",
60
+ "@vitest/coverage-v8": "^4.1.9",
57
61
  "tsx": "^4.22.4",
58
62
  "typescript": "^6.0.3",
59
- "vitest": "^3.0.0"
63
+ "vite": "^6.0.0",
64
+ "vitest": "^4.1.9"
60
65
  },
61
66
  "scripts": {
62
67
  "build": "tsc",