@ai-sdlc/orchestrator 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/file-walker.js +5 -0
- package/dist/cli/commands/git-remote.js +1 -1
- package/dist/cli/commands/init-features.js +18 -5
- package/dist/cycle-utils.js +14 -1
- package/dist/execute.js +29 -8
- package/dist/fix-review.js +1 -1
- package/dist/journey/metric-snapshot.d.ts +403 -0
- package/dist/journey/metric-snapshot.js +370 -0
- package/dist/otel-exporter.js +0 -3
- package/dist/runners/claude-code.js +3 -3
- package/dist/runners/runner-registry.js +1 -1
- package/dist/runtime/attestations.js +15 -5
- package/dist/sa-scoring/exemplar-bank.js +3 -1
- package/dist/shared.d.ts +28 -0
- package/dist/shared.js +37 -0
- package/dist/webhook-manager.js +7 -1
- package/package.json +8 -7
|
@@ -28,7 +28,12 @@ const MODULE_MARKERS = ['index.ts', 'index.js', 'index.mjs', 'package.json'];
|
|
|
28
28
|
function matchesGlob(filePath, patterns) {
|
|
29
29
|
for (const pattern of patterns) {
|
|
30
30
|
// Simple glob matching: supports ** and *
|
|
31
|
+
// Escape backslashes FIRST before any other replacement so that the
|
|
32
|
+
// subsequent `.replace(/\./g, '\\.')` doesn't produce `\\.` sequences
|
|
33
|
+
// that are themselves broken when the input contained a `\`
|
|
34
|
+
// (CodeQL js/incomplete-sanitization alert #67).
|
|
31
35
|
const regex = pattern
|
|
36
|
+
.replace(/\\/g, '\\\\')
|
|
32
37
|
.replace(/\./g, '\\.')
|
|
33
38
|
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
|
34
39
|
.replace(/\*/g, '[^/]*')
|
|
@@ -195,6 +195,17 @@ export function describeOq11Trigger(kind) {
|
|
|
195
195
|
return 'operator security review identified a risk RLS cannot mitigate (operator-declared)';
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Escape a string for safe embedding inside a YAML double-quoted scalar.
|
|
200
|
+
* YAML double-quoted strings treat `\` as an escape character, so backslashes
|
|
201
|
+
* must be escaped first, then double-quotes. Escaping only `"` (without
|
|
202
|
+
* first escaping `\`) is an incomplete sanitization — a value like `foo\bar`
|
|
203
|
+
* would produce `"foo\bar"` where `\b` is a YAML escape sequence.
|
|
204
|
+
* (CodeQL js/incomplete-sanitization alerts #68, #69, #70.)
|
|
205
|
+
*/
|
|
206
|
+
function escapeYamlDoubleQuoted(value) {
|
|
207
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
208
|
+
}
|
|
198
209
|
/**
|
|
199
210
|
* Build the .ai-sdlc/compliance.yaml content for a given compliance declaration.
|
|
200
211
|
*
|
|
@@ -209,8 +220,10 @@ export function buildComplianceYaml(opts) {
|
|
|
209
220
|
const { projectName, regimes, attestedBy, attestedAt, attestedNotes, derivedGates } = opts;
|
|
210
221
|
// AISDLC-324 review fix: quote attestedBy/id/attestedAt so an operator
|
|
211
222
|
// git config user.email containing ": " or other YAML-significant chars
|
|
212
|
-
// can't break the YAML structure.
|
|
213
|
-
|
|
223
|
+
// can't break the YAML structure. Use escapeYamlDoubleQuoted() which
|
|
224
|
+
// escapes backslashes before quotes (incomplete-sanitization fix for
|
|
225
|
+
// CodeQL alerts #68/#69/#70).
|
|
226
|
+
const quotedAttestedBy = `"${escapeYamlDoubleQuoted(attestedBy)}"`;
|
|
214
227
|
const regimeItems = regimes
|
|
215
228
|
.map((id) => {
|
|
216
229
|
// id is from hardcoded COMPLIANCE_REGIME_CHOICES (validated upstream)
|
|
@@ -218,10 +231,10 @@ export function buildComplianceYaml(opts) {
|
|
|
218
231
|
const lines = [
|
|
219
232
|
` - id: ${id}`,
|
|
220
233
|
` attestedBy: ${quotedAttestedBy}`,
|
|
221
|
-
` attestedAt: "${attestedAt}"`,
|
|
234
|
+
` attestedAt: "${escapeYamlDoubleQuoted(attestedAt)}"`,
|
|
222
235
|
];
|
|
223
236
|
if (attestedNotes) {
|
|
224
|
-
lines.push(` attestedNotes: "${attestedNotes
|
|
237
|
+
lines.push(` attestedNotes: "${escapeYamlDoubleQuoted(attestedNotes)}"`);
|
|
225
238
|
}
|
|
226
239
|
return lines.join('\n');
|
|
227
240
|
})
|
|
@@ -244,7 +257,7 @@ export function buildComplianceYaml(opts) {
|
|
|
244
257
|
`apiVersion: ai-sdlc.io/v1alpha1`,
|
|
245
258
|
`kind: CompliancePosture`,
|
|
246
259
|
`metadata:`,
|
|
247
|
-
` name: "${projectName
|
|
260
|
+
` name: "${escapeYamlDoubleQuoted(projectName)}"`,
|
|
248
261
|
`spec:`,
|
|
249
262
|
regimesSection,
|
|
250
263
|
` auditExports: []`,
|
package/dist/cycle-utils.js
CHANGED
|
@@ -6,9 +6,22 @@ import { NOTIFICATION_TITLES } from './defaults.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* Sanitize template content to prevent markdown/HTML injection.
|
|
8
8
|
* Strips HTML tags and limits length.
|
|
9
|
+
*
|
|
10
|
+
* A single-pass `<[^>]*>` strip is flagged by CodeQL as
|
|
11
|
+
* `js/incomplete-multi-character-sanitization` (alert #65) because a
|
|
12
|
+
* crafted string like `<scr<script>ipt>` leaves `<script>` after one pass.
|
|
13
|
+
* We iterate the replacement until stable so any re-introduced bad
|
|
14
|
+
* substring is eliminated on the next pass — making the sanitization
|
|
15
|
+
* demonstrably idempotent.
|
|
9
16
|
*/
|
|
10
17
|
function sanitizeTemplate(text) {
|
|
11
|
-
|
|
18
|
+
let prev;
|
|
19
|
+
let current = text;
|
|
20
|
+
do {
|
|
21
|
+
prev = current;
|
|
22
|
+
current = prev.replace(/<[^>]*>/g, '');
|
|
23
|
+
} while (current !== prev);
|
|
24
|
+
return current.slice(0, 2000);
|
|
12
25
|
}
|
|
13
26
|
/**
|
|
14
27
|
* Check for pipeline cycles and post notification if detected.
|
package/dist/execute.js
CHANGED
|
@@ -12,7 +12,7 @@ import { validateAgentOutput } from './validate-agent-output.js';
|
|
|
12
12
|
import { createLogger } from './logger.js';
|
|
13
13
|
import { createStructuredConsoleLogger, createStructuredBufferLogger, } from './structured-logger.js';
|
|
14
14
|
import { createRunnerRegistry, resolveRunner } from './runners/runner-registry.js';
|
|
15
|
-
import { execFileAsync, getGitHubConfig, resolveRepoRoot, createDefaultAuditLog, resolveAutonomyLevel, resolveConstraints, isAutonomousStrategy, recordMetric, evaluatePipelineCompliance, authorizeFilesChanged, interpolateBranchPattern, interpolatePRTitle, issueIdToNumber, formatIssueRef, buildIssueTemplateVars, } from './shared.js';
|
|
15
|
+
import { execFileAsync, getGitHubConfig, resolveRepoRoot, createDefaultAuditLog, resolveAutonomyLevel, resolveConstraints, isAutonomousStrategy, recordMetric, evaluatePipelineCompliance, authorizeFilesChanged, interpolateBranchPattern, interpolatePRTitle, issueIdToNumber, formatIssueRef, buildIssueTemplateVars, validateBranchName, } from './shared.js';
|
|
16
16
|
import { cleanGitEnv } from './runtime/git-env.js';
|
|
17
17
|
import { checkKillSwitch, issueAgentCredentials, revokeAgentCredentials, classifyAndSubmitApproval, createPipelineSecurity, } from './security.js';
|
|
18
18
|
import { createPipelineOrchestration, executePipelineOrchestration, validatePipelineHandoffs, } from './orchestration.js';
|
|
@@ -307,7 +307,7 @@ export async function pushBranchWithRebase(workDir, branchName, log) {
|
|
|
307
307
|
/* nothing to abort */
|
|
308
308
|
}
|
|
309
309
|
throw new Error(`Push rebase failed for branch ${branchName}: ${rebaseErr.message}. ` +
|
|
310
|
-
`Resolve conflicts manually and re-run the pipeline
|
|
310
|
+
`Resolve conflicts manually and re-run the pipeline.`, { cause: rebaseErr });
|
|
311
311
|
}
|
|
312
312
|
await execFileAsync('git', ['push', 'origin', branchName], { cwd: workDir, env });
|
|
313
313
|
return false;
|
|
@@ -453,6 +453,10 @@ async function executePipelineBody(issueId, issue, config, qualityGate, agentRol
|
|
|
453
453
|
// 7. Create branch and checkout locally (read pattern from pipeline config)
|
|
454
454
|
const branchVars = buildIssueTemplateVars(issueId, issue.title);
|
|
455
455
|
const branchName = interpolateBranchPattern(config.pipeline?.spec.branching?.pattern, branchVars);
|
|
456
|
+
// Defense against second-order command injection: reject branch names that
|
|
457
|
+
// start with '-' (git flag injection) or contain chars outside the safe ref
|
|
458
|
+
// charset. CodeQL alert #167 (js/second-order-command-line-injection).
|
|
459
|
+
validateBranchName(branchName);
|
|
456
460
|
await sc.createBranch({ name: branchName });
|
|
457
461
|
// cleanGitEnv() prevents leaked GIT_DIR from corrupting these calls (AISDLC-72).
|
|
458
462
|
// Guard: skip fetch when no 'origin' remote is configured (local-only repos).
|
|
@@ -1013,8 +1017,13 @@ async function executePipelineBody(issueId, issue, config, qualityGate, agentRol
|
|
|
1013
1017
|
details: { averageCoverage: avgCoverage, frameworks: complianceReports.length },
|
|
1014
1018
|
});
|
|
1015
1019
|
// 16b. Extended diagnostics (non-blocking)
|
|
1020
|
+
// Awaited so that every async operation started inside the diagnostics pass
|
|
1021
|
+
// completes before executePipeline returns. Without the await, fire-and-forget
|
|
1022
|
+
// promises (resolveAdapterFromGit, scanPipelineAdapters, loadAuditEntries) stay
|
|
1023
|
+
// pending past the function boundary and can fire into a closed vitest worker
|
|
1024
|
+
// MessagePort, producing ERR_IPC_CHANNEL_CLOSED (AISDLC-542).
|
|
1016
1025
|
try {
|
|
1017
|
-
runPipelineDiagnostics({
|
|
1026
|
+
await runPipelineDiagnostics({
|
|
1018
1027
|
config,
|
|
1019
1028
|
qualityGate,
|
|
1020
1029
|
agentRole,
|
|
@@ -1083,9 +1092,15 @@ async function executePipelineBody(issueId, issue, config, qualityGate, agentRol
|
|
|
1083
1092
|
/**
|
|
1084
1093
|
* Non-blocking diagnostics pass that exercises all previously unwired modules.
|
|
1085
1094
|
* Runs after the main pipeline succeeds — failures are silently ignored.
|
|
1095
|
+
*
|
|
1096
|
+
* Returns a Promise so the caller can await all async work, preventing dangling
|
|
1097
|
+
* promises from outliving the pipeline call and firing into a closed vitest
|
|
1098
|
+
* worker MessagePort (AISDLC-542: ERR_IPC_CHANNEL_CLOSED teardown race).
|
|
1086
1099
|
*/
|
|
1087
|
-
function runPipelineDiagnostics(input) {
|
|
1100
|
+
async function runPipelineDiagnostics(input) {
|
|
1088
1101
|
const { config, qualityGate, agentRole, autonomyPolicy, log } = input;
|
|
1102
|
+
// Collect async operations; await all at the end so no promise escapes the function.
|
|
1103
|
+
const asyncOps = [];
|
|
1089
1104
|
// Policy evaluators: Rego, CEL, ABAC, gate evaluation, complexity scoring
|
|
1090
1105
|
const _rego = createPipelineRegoEvaluator();
|
|
1091
1106
|
const _cel = createPipelineCELEvaluator();
|
|
@@ -1110,9 +1125,10 @@ function runPipelineDiagnostics(input) {
|
|
|
1110
1125
|
const adapterRegistry = createPipelineAdapterRegistry();
|
|
1111
1126
|
log.info(`Adapter registry: ${adapterRegistry.list().length} adapters registered`);
|
|
1112
1127
|
const _bridge = createPipelineWebhookBridge();
|
|
1113
|
-
//
|
|
1114
|
-
|
|
1115
|
-
|
|
1128
|
+
// Collect async ops instead of fire-and-forget — awaited below via Promise.allSettled
|
|
1129
|
+
// so no pending promise outlives this function (AISDLC-542).
|
|
1130
|
+
asyncOps.push(resolveAdapterFromGit('github:ai-sdlc-framework/ai-sdlc').catch(() => { }));
|
|
1131
|
+
asyncOps.push(scanPipelineAdapters({ basePath: `${input.workDir}/.ai-sdlc/adapters` }).catch(() => { }));
|
|
1116
1132
|
// Extended compliance: per-framework checks, control catalog, mappings
|
|
1117
1133
|
const controlIds = getControlCatalog();
|
|
1118
1134
|
const frameworks = listSupportedFrameworks();
|
|
@@ -1167,10 +1183,15 @@ function runPipelineDiagnostics(input) {
|
|
|
1167
1183
|
resource: 'pipeline',
|
|
1168
1184
|
decision: 'allowed',
|
|
1169
1185
|
});
|
|
1170
|
-
|
|
1186
|
+
// Collected into asyncOps — awaited below so the promise doesn't escape (AISDLC-542).
|
|
1187
|
+
asyncOps.push(loadAuditEntries(auditPath).catch(() => { }));
|
|
1171
1188
|
// Note: rotateAuditLog intentionally omitted — it truncates the file,
|
|
1172
1189
|
// which would empty it before artifact upload can capture the contents.
|
|
1173
1190
|
}
|
|
1191
|
+
// Await all async diagnostic operations so no promise outlives this function.
|
|
1192
|
+
// allSettled keeps the best-effort semantic: individual failures are silently swallowed,
|
|
1193
|
+
// but the caller can await the diagnostics without unhandled rejections leaking.
|
|
1194
|
+
await Promise.allSettled(asyncOps);
|
|
1174
1195
|
}
|
|
1175
1196
|
// ── Gitignore helper ─────────────────────────────────────────────────
|
|
1176
1197
|
const RUNTIME_GITIGNORE_PATHS = ['.ai-sdlc/state.db', '.ai-sdlc/state/', '.ai-sdlc/audit.jsonl'];
|
package/dist/fix-review.js
CHANGED
|
@@ -67,7 +67,7 @@ export async function fetchReviewFindings(prNumber, injectedFindings, _secretSto
|
|
|
67
67
|
reviews = JSON.parse(stdout);
|
|
68
68
|
}
|
|
69
69
|
catch (err) {
|
|
70
|
-
throw new Error(`Failed to parse review data from gh CLI: ${err instanceof Error ? err.message : String(err)}
|
|
70
|
+
throw new Error(`Failed to parse review data from gh CLI: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
71
71
|
}
|
|
72
72
|
const changesRequestedReviews = reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
73
73
|
if (changesRequestedReviews.length === 0) {
|
|
@@ -0,0 +1,403 @@
|
|
|
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
|
+
/**
|
|
68
|
+
* A single journey success-metric snapshot as supplied by the operator's
|
|
69
|
+
* analytics pipeline. Matches `spec/schemas/metric-snapshot.v1.schema.json`.
|
|
70
|
+
*/
|
|
71
|
+
export interface MetricSnapshot {
|
|
72
|
+
/** Always 'ai-sdlc.io/v1alpha1'. */
|
|
73
|
+
readonly apiVersion: 'ai-sdlc.io/v1alpha1';
|
|
74
|
+
/** Always 'MetricSnapshot'. */
|
|
75
|
+
readonly kind: 'MetricSnapshot';
|
|
76
|
+
readonly metadata: {
|
|
77
|
+
/**
|
|
78
|
+
* Path-style journey URI.
|
|
79
|
+
* Soul-scoped: `<soul-id>/<journey-id>`
|
|
80
|
+
* Variant-scoped: `<soul-id>/<variant-id>/<journey-id>`
|
|
81
|
+
*/
|
|
82
|
+
readonly journey: string;
|
|
83
|
+
/**
|
|
84
|
+
* Metric identifier (kebab-case). MUST match a `successMetrics[].id`
|
|
85
|
+
* on the parent journey declaration.
|
|
86
|
+
* Examples: 'completion-rate', 'median-time-to-first-task-done'
|
|
87
|
+
*/
|
|
88
|
+
readonly metricId: string;
|
|
89
|
+
readonly labels?: Record<string, string>;
|
|
90
|
+
readonly annotations?: Record<string, string>;
|
|
91
|
+
};
|
|
92
|
+
readonly spec: {
|
|
93
|
+
/** Metric value. Unit convention shared between operator and framework. */
|
|
94
|
+
readonly value: number;
|
|
95
|
+
/**
|
|
96
|
+
* ISO 8601 timestamp when this metric was recorded / sampled.
|
|
97
|
+
* Used to compute staleness relative to `thresholdDays`.
|
|
98
|
+
*/
|
|
99
|
+
readonly recordedAt: string;
|
|
100
|
+
/**
|
|
101
|
+
* Free-text analytics tool identifier.
|
|
102
|
+
* Examples: 'mixpanel', 'amplitude', 'heap', 'internal-pipeline'
|
|
103
|
+
*/
|
|
104
|
+
readonly sourceTool: string;
|
|
105
|
+
/** Optional ISO 8601 window start (informational). */
|
|
106
|
+
readonly windowStart?: string;
|
|
107
|
+
/** Optional ISO 8601 window end (informational). */
|
|
108
|
+
readonly windowEnd?: string;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Staleness configuration for `journey.successMetrics.staleness`.
|
|
113
|
+
* Matches the corresponding block in `journey-config.v1.schema.json`.
|
|
114
|
+
*/
|
|
115
|
+
export interface MetricStalenessConfig {
|
|
116
|
+
/** Days after last MetricSnapshot before the metric is stale. Default 30. */
|
|
117
|
+
readonly thresholdDays?: number;
|
|
118
|
+
}
|
|
119
|
+
/** Default staleness threshold per OQ-5 resolution (30 days). */
|
|
120
|
+
export declare const DEFAULT_STALENESS_THRESHOLD_DAYS = 30;
|
|
121
|
+
/**
|
|
122
|
+
* Possible states for a metric lookup result.
|
|
123
|
+
*
|
|
124
|
+
* - `'fresh'` — snapshot found AND within staleness threshold
|
|
125
|
+
* - `'stale'` — snapshot found BUT older than threshold (warn-and-unknown)
|
|
126
|
+
* - `'missing'` — no snapshot found for the journey/metricId pair
|
|
127
|
+
*/
|
|
128
|
+
export type MetricFreshness = 'fresh' | 'stale' | 'missing';
|
|
129
|
+
/**
|
|
130
|
+
* Result returned by `getLatestMetricSnapshot`.
|
|
131
|
+
*
|
|
132
|
+
* - When `freshness === 'fresh'`: `snapshot` is populated; use `snapshot.spec.value`
|
|
133
|
+
* - When `freshness === 'stale'`: `snapshot` is populated but `decision` is emitted
|
|
134
|
+
* (warn-and-unknown — Cκ treats metric as unknown input, pipeline continues)
|
|
135
|
+
* - When `freshness === 'missing'`: no snapshot; Cκ scores as unknown input
|
|
136
|
+
*/
|
|
137
|
+
export interface MetricSnapshotResult {
|
|
138
|
+
/** Journey path URI (e.g. 'spry-engage/onboarding'). */
|
|
139
|
+
readonly journey: string;
|
|
140
|
+
/** Metric ID (e.g. 'completion-rate'). */
|
|
141
|
+
readonly metricId: string;
|
|
142
|
+
/** Freshness state. */
|
|
143
|
+
readonly freshness: MetricFreshness;
|
|
144
|
+
/** The matched snapshot (populated when freshness is 'fresh' or 'stale'). */
|
|
145
|
+
readonly snapshot?: MetricSnapshot;
|
|
146
|
+
/**
|
|
147
|
+
* Decision emitted when `freshness === 'stale'`.
|
|
148
|
+
* Value: `'journey-metric-stale'`.
|
|
149
|
+
* Routing: RFC-0035 G0 (non-blocking batch review — warn-and-unknown, not fail-closed).
|
|
150
|
+
*/
|
|
151
|
+
readonly decision?: 'journey-metric-stale';
|
|
152
|
+
/** Days since `recordedAt` (populated when snapshot is present). */
|
|
153
|
+
readonly ageInDays?: number;
|
|
154
|
+
/** Staleness threshold in days that was applied. */
|
|
155
|
+
readonly thresholdDays: number;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Options for `getLatestMetricSnapshot`.
|
|
159
|
+
*/
|
|
160
|
+
export interface GetLatestMetricSnapshotOptions {
|
|
161
|
+
/**
|
|
162
|
+
* Collection of all MetricSnapshot records known to the framework.
|
|
163
|
+
* Callers are responsible for loading these from their persistence layer
|
|
164
|
+
* (filesystem, in-memory fixture, database) before calling.
|
|
165
|
+
*/
|
|
166
|
+
readonly snapshots: readonly MetricSnapshot[];
|
|
167
|
+
/**
|
|
168
|
+
* Per-Soul staleness config (from the soul's `spec.journeyConfig.successMetrics.staleness`
|
|
169
|
+
* or the org-wide `.ai-sdlc/journey-config.yaml` default).
|
|
170
|
+
* When omitted, the default 30d threshold applies.
|
|
171
|
+
*/
|
|
172
|
+
readonly stalenessConfig?: MetricStalenessConfig;
|
|
173
|
+
/**
|
|
174
|
+
* Reference "now" timestamp (ISO 8601). Defaults to `new Date().toISOString()`.
|
|
175
|
+
* Provided for deterministic testing.
|
|
176
|
+
*/
|
|
177
|
+
readonly now?: string;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Retrieve the **latest** MetricSnapshot for the given journey + metricId pair
|
|
181
|
+
* and classify it as fresh, stale, or missing.
|
|
182
|
+
*
|
|
183
|
+
* Selection: when multiple snapshots match, the one with the most recent
|
|
184
|
+
* `spec.recordedAt` is returned (latest-wins). This covers the case where
|
|
185
|
+
* the operator's pipeline emits snapshots on a periodic schedule.
|
|
186
|
+
*
|
|
187
|
+
* Staleness: `ageInDays = (now - recordedAt) / (1000 * 60 * 60 * 24)`.
|
|
188
|
+
* When `ageInDays > thresholdDays`, the result carries:
|
|
189
|
+
* - `freshness: 'stale'`
|
|
190
|
+
* - `decision: 'journey-metric-stale'`
|
|
191
|
+
*
|
|
192
|
+
* This Decision routes through RFC-0035 G0 (non-blocking batch review):
|
|
193
|
+
* the Cκ scorer treats a stale metric as an unknown input (same behavior as
|
|
194
|
+
* `freshness: 'missing'`), NOT as a hard fail. The pipeline continues.
|
|
195
|
+
*
|
|
196
|
+
* @param journey Path-style journey URI (e.g. 'spry-engage/onboarding')
|
|
197
|
+
* @param metricId Metric identifier (e.g. 'completion-rate')
|
|
198
|
+
* @param options Snapshot collection + optional per-Soul config
|
|
199
|
+
*/
|
|
200
|
+
export declare function getLatestMetricSnapshot(journey: string, metricId: string, options: GetLatestMetricSnapshotOptions): MetricSnapshotResult;
|
|
201
|
+
/**
|
|
202
|
+
* Per-Soul policy for Eρ₅ degradation when the accessibility audit is overdue.
|
|
203
|
+
* Matches `journey-config.v1.schema.json` `accessibility.auditOverdueGracePolicy`.
|
|
204
|
+
*
|
|
205
|
+
* - `'graduated'` — default; progressive reduction matching Vanta/Drata/Secureframe pattern
|
|
206
|
+
* - `'binary-30d'` — SOC2/HIPAA strict: no impact within 30d, then fail-closed
|
|
207
|
+
* - `'hard-block'` — immediate fail at cadence+0d (no grace)
|
|
208
|
+
*/
|
|
209
|
+
export type AuditOverdueGracePolicy = 'graduated' | 'binary-30d' | 'hard-block';
|
|
210
|
+
/**
|
|
211
|
+
* Eρ₅ impact tiers for graduated degradation.
|
|
212
|
+
*
|
|
213
|
+
* - `'warn'` — Eρ₅ unchanged; Decision emitted for operator visibility
|
|
214
|
+
* - `'reduced-25'` — Eρ₅ multiplied by 0.75 (−25%)
|
|
215
|
+
* - `'reduced-50'` — Eρ₅ multiplied by 0.50 (−50%)
|
|
216
|
+
* - `'effective-block'`— Eρ₅ set to 0 (admission blocked)
|
|
217
|
+
*/
|
|
218
|
+
export type Erho5Impact = 'warn' | 'reduced-25' | 'reduced-50' | 'effective-block';
|
|
219
|
+
/** Eρ₅ multiplier for each impact tier. */
|
|
220
|
+
export declare const ERHO5_MULTIPLIERS: Record<Erho5Impact, number>;
|
|
221
|
+
/**
|
|
222
|
+
* Graduated thresholds configuration (days past audit cadence).
|
|
223
|
+
* Matches `accessibility.graduatedThresholds` in `journey-config.v1.schema.json`.
|
|
224
|
+
*/
|
|
225
|
+
export interface GraduatedThresholds {
|
|
226
|
+
/** Days past cadence at which 'warn' Decision fires. Default 0. */
|
|
227
|
+
readonly warnAt?: number;
|
|
228
|
+
/** Days past cadence at which −25% reduction fires. Default 30. */
|
|
229
|
+
readonly reduced25At?: number;
|
|
230
|
+
/** Days past cadence at which −50% reduction fires. Default 60. */
|
|
231
|
+
readonly reduced50At?: number;
|
|
232
|
+
/** Days past cadence at which effective-block fires. Default 90. */
|
|
233
|
+
readonly effectiveBlockAt?: number;
|
|
234
|
+
}
|
|
235
|
+
/** Default graduated thresholds per OQ-6 resolution. */
|
|
236
|
+
export declare const DEFAULT_GRADUATED_THRESHOLDS: Required<GraduatedThresholds>;
|
|
237
|
+
/**
|
|
238
|
+
* Decision kinds emitted for accessibility audit overdue events.
|
|
239
|
+
* Routes through RFC-0035 G0 non-blocking pipeline contract.
|
|
240
|
+
*/
|
|
241
|
+
export type AuditOverdueDecision = 'journey-audit-overdue-warn' | 'journey-audit-overdue-graduated' | 'journey-audit-overdue-blocking';
|
|
242
|
+
/**
|
|
243
|
+
* Result of the Eρ₅ degradation calculation for an overdue accessibility audit.
|
|
244
|
+
*/
|
|
245
|
+
export interface AuditOverdueResult {
|
|
246
|
+
/** Soul identifier for which the result was computed. */
|
|
247
|
+
readonly soulId: string;
|
|
248
|
+
/** Journey identifier. */
|
|
249
|
+
readonly journeyId: string;
|
|
250
|
+
/** Days the audit is past cadence (0 means exactly at cadence). */
|
|
251
|
+
readonly daysOverdue: number;
|
|
252
|
+
/** The grace policy that was applied. */
|
|
253
|
+
readonly policy: AuditOverdueGracePolicy;
|
|
254
|
+
/** Eρ₅ impact tier. */
|
|
255
|
+
readonly impact: Erho5Impact;
|
|
256
|
+
/**
|
|
257
|
+
* Eρ₅ multiplier to apply to the base Eρ₅ score.
|
|
258
|
+
* 1.0 = no impact; 0.75 = -25%; 0.50 = -50%; 0.0 = effective block.
|
|
259
|
+
*/
|
|
260
|
+
readonly erho5Multiplier: number;
|
|
261
|
+
/**
|
|
262
|
+
* Decision to emit for this result.
|
|
263
|
+
* `null` only when daysOverdue < 0 (audit not yet due). At daysOverdue >= 0
|
|
264
|
+
* (cadence+0d — no grace) a Decision is emitted per the policy.
|
|
265
|
+
*/
|
|
266
|
+
readonly decision: AuditOverdueDecision | null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Options for `computeAuditOverdueErho5`.
|
|
270
|
+
*/
|
|
271
|
+
export interface ComputeAuditOverdueOptions {
|
|
272
|
+
/** Soul identifier for event attribution. */
|
|
273
|
+
readonly soulId: string;
|
|
274
|
+
/** Journey identifier for event attribution. */
|
|
275
|
+
readonly journeyId: string;
|
|
276
|
+
/**
|
|
277
|
+
* Days the journey's audit is past its declared cadence.
|
|
278
|
+
* 0 = exactly at cadence; positive = overdue; negative = not yet overdue.
|
|
279
|
+
*/
|
|
280
|
+
readonly daysOverdue: number;
|
|
281
|
+
/**
|
|
282
|
+
* Per-Soul grace policy.
|
|
283
|
+
* Defaults to `'graduated'`.
|
|
284
|
+
*/
|
|
285
|
+
readonly policy?: AuditOverdueGracePolicy;
|
|
286
|
+
/**
|
|
287
|
+
* Per-org graduated threshold configuration.
|
|
288
|
+
* Only used when `policy === 'graduated'`.
|
|
289
|
+
* Defaults to `DEFAULT_GRADUATED_THRESHOLDS`.
|
|
290
|
+
*/
|
|
291
|
+
readonly graduatedThresholds?: GraduatedThresholds;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Compute the Eρ₅ impact and Decision for an overdue accessibility audit.
|
|
295
|
+
*
|
|
296
|
+
* Implements RFC-0018 §10.1 OQ-6 graduated Eρ₅ degradation:
|
|
297
|
+
*
|
|
298
|
+
* Policy `graduated` (default):
|
|
299
|
+
* - 0 ≤ daysOverdue < 30 → `warn` (multiplier 1.0)
|
|
300
|
+
* - 30 ≤ daysOverdue < 60 → `reduced-25` (multiplier 0.75)
|
|
301
|
+
* - 60 ≤ daysOverdue < 90 → `reduced-50` (multiplier 0.50)
|
|
302
|
+
* - daysOverdue ≥ 90 → `effective-block` (multiplier 0.0)
|
|
303
|
+
*
|
|
304
|
+
* Policy `binary-30d` (SOC2/HIPAA strict):
|
|
305
|
+
* - daysOverdue < 30 → no impact (multiplier 1.0, no Decision)
|
|
306
|
+
* - daysOverdue ≥ 30 → `effective-block` (multiplier 0.0)
|
|
307
|
+
*
|
|
308
|
+
* Policy `hard-block` (HIPAA/PCI-DSS ultra-strict):
|
|
309
|
+
* - daysOverdue < 0 → no impact (multiplier 1.0, no Decision; not yet due)
|
|
310
|
+
* - daysOverdue ≥ 0 → `effective-block` (multiplier 0.0) — cadence+0d, no grace
|
|
311
|
+
*
|
|
312
|
+
* When `daysOverdue < 0`, returns multiplier 1.0 and `decision: null`
|
|
313
|
+
* regardless of policy (audit is not yet due). At daysOverdue ≥ 0 each policy
|
|
314
|
+
* emits its Decision (no implicit grace day).
|
|
315
|
+
*/
|
|
316
|
+
export declare function computeAuditOverdueErho5(options: ComputeAuditOverdueOptions): AuditOverdueResult;
|
|
317
|
+
/**
|
|
318
|
+
* Audit cadence values from the journey declaration (RFC-0018 §5.2).
|
|
319
|
+
* Ordered strictest → least strict for UNION selection.
|
|
320
|
+
*/
|
|
321
|
+
export type AuditCadence = 'continuous' | 'release-gated' | 'quarterly' | 'annually';
|
|
322
|
+
/**
|
|
323
|
+
* Numeric strictness order for cadence values.
|
|
324
|
+
* Higher = stricter (shorter audit interval).
|
|
325
|
+
* Used by `resolveStrictestCadence` to pick the UNION result.
|
|
326
|
+
*/
|
|
327
|
+
export declare const AUDIT_CADENCE_STRICTNESS: Record<AuditCadence, number>;
|
|
328
|
+
/**
|
|
329
|
+
* Options for `resolveStrictestCadence`.
|
|
330
|
+
*/
|
|
331
|
+
export interface ResolveStrictestCadenceOptions {
|
|
332
|
+
/**
|
|
333
|
+
* Journey-level cadence declared in `accessibility.auditCadence`.
|
|
334
|
+
*/
|
|
335
|
+
readonly journeyCadence: AuditCadence;
|
|
336
|
+
/**
|
|
337
|
+
* Cadences required by the active RFC-0022 compliance posture(s).
|
|
338
|
+
* An empty array means no posture constraint — journey cadence is used as-is.
|
|
339
|
+
* When multiple postures are active, all are included here.
|
|
340
|
+
*/
|
|
341
|
+
readonly postureCadences: readonly AuditCadence[];
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Resolve the effective audit cadence by applying the strictest constraint
|
|
345
|
+
* from the journey declaration and all active RFC-0022 compliance postures.
|
|
346
|
+
*
|
|
347
|
+
* This implements RFC-0018 AC #6 + RFC-0030 OQ-13.3 UNION precedent:
|
|
348
|
+
* the strictest constraint among all active postures and the journey's own
|
|
349
|
+
* declaration wins.
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* // Journey declares 'annually', but SOC2 posture requires 'quarterly'
|
|
353
|
+
* resolveStrictestCadence({
|
|
354
|
+
* journeyCadence: 'annually',
|
|
355
|
+
* postureCadences: ['quarterly'],
|
|
356
|
+
* })
|
|
357
|
+
* // → 'quarterly' (posture wins — stricter)
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* // Journey declares 'continuous' (strictest possible)
|
|
361
|
+
* resolveStrictestCadence({
|
|
362
|
+
* journeyCadence: 'continuous',
|
|
363
|
+
* postureCadences: ['quarterly', 'annually'],
|
|
364
|
+
* })
|
|
365
|
+
* // → 'continuous' (journey wins — already strictest)
|
|
366
|
+
*/
|
|
367
|
+
export declare function resolveStrictestCadence(options: ResolveStrictestCadenceOptions): AuditCadence;
|
|
368
|
+
/**
|
|
369
|
+
* Options for `resolveStrictestGracePolicy`.
|
|
370
|
+
*/
|
|
371
|
+
export interface ResolveStrictestGracePolicyOptions {
|
|
372
|
+
/**
|
|
373
|
+
* Per-Soul grace policy from `accessibility.auditOverdueGracePolicy`.
|
|
374
|
+
* Defaults to `'graduated'`.
|
|
375
|
+
*/
|
|
376
|
+
readonly soulPolicy?: AuditOverdueGracePolicy;
|
|
377
|
+
/**
|
|
378
|
+
* Grace policies required by the active RFC-0022 compliance postures.
|
|
379
|
+
* An empty array means no posture constraint — soul policy is used as-is.
|
|
380
|
+
* SOC2/HIPAA postures typically impose 'binary-30d' or 'hard-block'.
|
|
381
|
+
*/
|
|
382
|
+
readonly posturesPolicies: readonly AuditOverdueGracePolicy[];
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Policy strictness order (higher = stricter).
|
|
386
|
+
*/
|
|
387
|
+
export declare const GRACE_POLICY_STRICTNESS: Record<AuditOverdueGracePolicy, number>;
|
|
388
|
+
/**
|
|
389
|
+
* Resolve the effective grace policy by picking the STRICTEST among the
|
|
390
|
+
* soul-level policy and all active RFC-0022 compliance posture policies.
|
|
391
|
+
*
|
|
392
|
+
* RFC-0022 + RFC-0018 AC #6: multi-posture UNION → strictest applies.
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* // Soul defaults to 'graduated'; SOC2 posture requires 'binary-30d'
|
|
396
|
+
* resolveStrictestGracePolicy({
|
|
397
|
+
* soulPolicy: 'graduated',
|
|
398
|
+
* posturesPolicies: ['binary-30d'],
|
|
399
|
+
* })
|
|
400
|
+
* // → 'binary-30d' (posture wins — stricter)
|
|
401
|
+
*/
|
|
402
|
+
export declare function resolveStrictestGracePolicy(options: ResolveStrictestGracePolicyOptions): AuditOverdueGracePolicy;
|
|
403
|
+
//# sourceMappingURL=metric-snapshot.d.ts.map
|
|
@@ -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
|
package/dist/otel-exporter.js
CHANGED
|
@@ -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.`, `${
|
|
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.`, `${
|
|
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.`, `${
|
|
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.
|
package/dist/webhook-manager.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.14.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": {
|
|
@@ -43,20 +43,21 @@
|
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@inquirer/prompts": "^
|
|
47
|
-
"better-sqlite3": "^11.
|
|
46
|
+
"@inquirer/prompts": "^8.5.2",
|
|
47
|
+
"better-sqlite3": "^12.11.1",
|
|
48
48
|
"commander": "^15.0.0",
|
|
49
49
|
"franc": "^6.2.0",
|
|
50
50
|
"yaml": "^2.9.0",
|
|
51
|
-
"@ai-sdlc/reference": "0.
|
|
51
|
+
"@ai-sdlc/reference": "0.14.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/better-sqlite3": "^7.6.0",
|
|
55
|
-
"@types/node": "^25.9.
|
|
56
|
-
"@vitest/coverage-v8": "^
|
|
55
|
+
"@types/node": "^25.9.3",
|
|
56
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
57
57
|
"tsx": "^4.22.4",
|
|
58
58
|
"typescript": "^6.0.3",
|
|
59
|
-
"
|
|
59
|
+
"vite": "^6.0.0",
|
|
60
|
+
"vitest": "^4.1.9"
|
|
60
61
|
},
|
|
61
62
|
"scripts": {
|
|
62
63
|
"build": "tsc",
|