@ai-sdlc/orchestrator 0.10.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.
Files changed (168) hide show
  1. package/dist/adapters.d.ts +42 -3
  2. package/dist/adapters.js +133 -3
  3. package/dist/admission-composite.d.ts +112 -1
  4. package/dist/admission-composite.js +85 -4
  5. package/dist/admission-enrichment.d.ts +12 -3
  6. package/dist/admission-enrichment.js +64 -13
  7. package/dist/admission-score.d.ts +30 -0
  8. package/dist/admission-score.js +4 -1
  9. package/dist/analysis/file-walker.js +5 -0
  10. package/dist/backlog-adapter.d.ts +45 -0
  11. package/dist/backlog-adapter.js +145 -1
  12. package/dist/calibration.d.ts +81 -0
  13. package/dist/calibration.js +76 -0
  14. package/dist/cli/commands/git-remote.js +1 -1
  15. package/dist/cli/commands/init-features.d.ts +301 -2
  16. package/dist/cli/commands/init-features.js +647 -7
  17. package/dist/cli/commands/init-templates.d.ts +198 -1
  18. package/dist/cli/commands/init-templates.js +943 -1
  19. package/dist/cli/commands/init.d.ts +45 -0
  20. package/dist/cli/commands/init.js +147 -5
  21. package/dist/cli/commands/run.js +9 -1
  22. package/dist/cli/index.d.ts +11 -0
  23. package/dist/cli/index.js +42 -8
  24. package/dist/compliance/composer.d.ts +79 -0
  25. package/dist/compliance/composer.js +258 -0
  26. package/dist/compliance/errors.d.ts +64 -0
  27. package/dist/compliance/errors.js +85 -0
  28. package/dist/compliance/loader.d.ts +52 -0
  29. package/dist/compliance/loader.js +124 -0
  30. package/dist/compliance/types.d.ts +184 -0
  31. package/dist/compliance/types.js +41 -0
  32. package/dist/compliance-clearance.d.ts +269 -0
  33. package/dist/compliance-clearance.js +269 -0
  34. package/dist/config.js +17 -0
  35. package/dist/cost-tracker.d.ts +22 -0
  36. package/dist/cost-tracker.js +41 -0
  37. package/dist/cycle-utils.js +14 -1
  38. package/dist/database/adapters/external.js +5 -1
  39. package/dist/embedding/adapters/openai-text-embedding-3-small.d.ts +71 -0
  40. package/dist/embedding/adapters/openai-text-embedding-3-small.js +190 -0
  41. package/dist/embedding/consumers/tessellation-drift.d.ts +74 -0
  42. package/dist/embedding/consumers/tessellation-drift.js +76 -0
  43. package/dist/embedding/cross-provider.d.ts +78 -0
  44. package/dist/embedding/cross-provider.js +75 -0
  45. package/dist/embedding/deprecation.d.ts +151 -0
  46. package/dist/embedding/deprecation.js +229 -0
  47. package/dist/embedding/errors.d.ts +90 -0
  48. package/dist/embedding/errors.js +150 -0
  49. package/dist/embedding/index.d.ts +29 -0
  50. package/dist/embedding/index.js +24 -0
  51. package/dist/embedding/pipeline-load.d.ts +146 -0
  52. package/dist/embedding/pipeline-load.js +178 -0
  53. package/dist/embedding/registry.d.ts +45 -0
  54. package/dist/embedding/registry.js +61 -0
  55. package/dist/embedding/stale-vector.d.ts +110 -0
  56. package/dist/embedding/stale-vector.js +92 -0
  57. package/dist/embedding/storage/index.d.ts +51 -0
  58. package/dist/embedding/storage/index.js +43 -0
  59. package/dist/embedding/storage/jsonl-backend.d.ts +150 -0
  60. package/dist/embedding/storage/jsonl-backend.js +332 -0
  61. package/dist/embedding/storage/types.d.ts +135 -0
  62. package/dist/embedding/storage/types.js +13 -0
  63. package/dist/embedding/types.d.ts +180 -0
  64. package/dist/embedding/types.js +10 -0
  65. package/dist/execute.d.ts +29 -2
  66. package/dist/execute.js +200 -38
  67. package/dist/fix-review.js +1 -1
  68. package/dist/index.d.ts +14 -2
  69. package/dist/index.js +19 -2
  70. package/dist/journey/inheritance-validator.d.ts +396 -0
  71. package/dist/journey/inheritance-validator.js +370 -0
  72. package/dist/journey/metric-snapshot.d.ts +403 -0
  73. package/dist/journey/metric-snapshot.js +370 -0
  74. package/dist/journey/state-id-drift-rule.d.ts +137 -0
  75. package/dist/journey/state-id-drift-rule.js +245 -0
  76. package/dist/journey-sa2-router.d.ts +395 -0
  77. package/dist/journey-sa2-router.js +308 -0
  78. package/dist/otel-exporter.js +0 -3
  79. package/dist/runners/claude-code.js +3 -3
  80. package/dist/runners/review-agent.js +6 -2
  81. package/dist/runners/runner-registry.d.ts +36 -0
  82. package/dist/runners/runner-registry.js +90 -0
  83. package/dist/runtime/attestations.d.ts +173 -13
  84. package/dist/runtime/attestations.js +265 -43
  85. package/dist/runtime/index.d.ts +1 -1
  86. package/dist/runtime/index.js +1 -1
  87. package/dist/sa-scoring/exemplar-bank.js +3 -1
  88. package/dist/sa-scoring/layer3-llm.js +6 -1
  89. package/dist/sa-scoring/revision-proposal-config.d.ts +178 -0
  90. package/dist/sa-scoring/revision-proposal-config.js +198 -0
  91. package/dist/sa-scoring/revision-proposal.d.ts +285 -0
  92. package/dist/sa-scoring/revision-proposal.js +417 -0
  93. package/dist/shared.d.ts +28 -0
  94. package/dist/shared.js +37 -0
  95. package/dist/signal-ingestion/adapters/community-thread.d.ts +43 -0
  96. package/dist/signal-ingestion/adapters/community-thread.js +55 -0
  97. package/dist/signal-ingestion/adapters/in-app-feedback.d.ts +67 -0
  98. package/dist/signal-ingestion/adapters/in-app-feedback.js +51 -0
  99. package/dist/signal-ingestion/adapters/manual.d.ts +78 -0
  100. package/dist/signal-ingestion/adapters/manual.js +112 -0
  101. package/dist/signal-ingestion/adapters/support-ticket.d.ts +47 -0
  102. package/dist/signal-ingestion/adapters/support-ticket.js +51 -0
  103. package/dist/signal-ingestion/classifier.d.ts +205 -0
  104. package/dist/signal-ingestion/classifier.js +494 -0
  105. package/dist/signal-ingestion/clustering-types.d.ts +36 -0
  106. package/dist/signal-ingestion/clustering-types.js +14 -0
  107. package/dist/signal-ingestion/clustering.d.ts +200 -0
  108. package/dist/signal-ingestion/clustering.js +413 -0
  109. package/dist/signal-ingestion/config.d.ts +351 -0
  110. package/dist/signal-ingestion/config.js +587 -0
  111. package/dist/signal-ingestion/d1.d.ts +252 -0
  112. package/dist/signal-ingestion/d1.js +235 -0
  113. package/dist/signal-ingestion/errors.d.ts +73 -0
  114. package/dist/signal-ingestion/errors.js +108 -0
  115. package/dist/signal-ingestion/governance-events.d.ts +181 -0
  116. package/dist/signal-ingestion/governance-events.js +189 -0
  117. package/dist/signal-ingestion/index.d.ts +35 -0
  118. package/dist/signal-ingestion/index.js +53 -0
  119. package/dist/signal-ingestion/manual-share-metric.d.ts +93 -0
  120. package/dist/signal-ingestion/manual-share-metric.js +106 -0
  121. package/dist/signal-ingestion/registry.d.ts +40 -0
  122. package/dist/signal-ingestion/registry.js +137 -0
  123. package/dist/signal-ingestion/residency.d.ts +227 -0
  124. package/dist/signal-ingestion/residency.js +238 -0
  125. package/dist/signal-ingestion/significance.d.ts +554 -0
  126. package/dist/signal-ingestion/significance.js +555 -0
  127. package/dist/signal-ingestion/types.d.ts +191 -0
  128. package/dist/signal-ingestion/types.js +8 -0
  129. package/dist/substrate/drift-composition.d.ts +270 -0
  130. package/dist/substrate/drift-composition.js +306 -0
  131. package/dist/substrate/drift-tui-surface.d.ts +61 -0
  132. package/dist/substrate/drift-tui-surface.js +102 -0
  133. package/dist/substrate/identity-class.d.ts +176 -0
  134. package/dist/substrate/identity-class.js +201 -0
  135. package/dist/tessellation/cross-soul-provenance-rule.d.ts +133 -0
  136. package/dist/tessellation/cross-soul-provenance-rule.js +171 -0
  137. package/dist/tessellation/inter-soul-embedding-distance-rule.d.ts +61 -0
  138. package/dist/tessellation/inter-soul-embedding-distance-rule.js +67 -0
  139. package/dist/tessellation/rule-registry.d.ts +269 -0
  140. package/dist/tessellation/rule-registry.js +92 -0
  141. package/dist/tessellation/soul-slug-ast-scan-rule.d.ts +90 -0
  142. package/dist/tessellation/soul-slug-ast-scan-rule.js +158 -0
  143. package/dist/tessellation-admission.d.ts +162 -0
  144. package/dist/tessellation-admission.js +146 -0
  145. package/dist/tessellation-drift.d.ts +246 -0
  146. package/dist/tessellation-drift.js +250 -0
  147. package/dist/validate-config.js +13 -0
  148. package/dist/validate-issue.js +2 -2
  149. package/dist/variant/cardinality-activation.d.ts +126 -0
  150. package/dist/variant/cardinality-activation.js +101 -0
  151. package/dist/variant/deprecation-lifecycle.d.ts +184 -0
  152. package/dist/variant/deprecation-lifecycle.js +208 -0
  153. package/dist/variant/drift-extension.d.ts +136 -0
  154. package/dist/variant/drift-extension.js +164 -0
  155. package/dist/variant/engineering-review.d.ts +185 -0
  156. package/dist/variant/engineering-review.js +142 -0
  157. package/dist/variant/index.d.ts +32 -0
  158. package/dist/variant/index.js +32 -0
  159. package/dist/variant/inheritance-validator.d.ts +165 -0
  160. package/dist/variant/inheritance-validator.js +139 -0
  161. package/dist/variant/internal-adopter/index.d.ts +11 -0
  162. package/dist/variant/internal-adopter/index.js +10 -0
  163. package/dist/variant/internal-adopter/products.d.ts +156 -0
  164. package/dist/variant/internal-adopter/products.js +366 -0
  165. package/dist/variant-admission.d.ts +316 -0
  166. package/dist/variant-admission.js +247 -0
  167. package/dist/webhook-manager.js +7 -1
  168. package/package.json +15 -12
@@ -11,6 +11,7 @@
11
11
  * 3. The branch-protection helper (applyBranchProtection) including the
12
12
  * `--dry-run` JSON-print path required by AC #6.
13
13
  * 4. The "next steps" summary printed at the end of init (AC #5).
14
+ * 5. The compliance-posture wizard step (RFC-0022 §7 / AISDLC-324).
14
15
  *
15
16
  * Test surface: every public function takes a small options bag with
16
17
  * injectable side-effect adapters (prompter, writeFile, runCommand) so the
@@ -19,23 +20,551 @@
19
20
  * the real adapters.
20
21
  */
21
22
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
- import { join, dirname } from 'node:path';
23
- import { execFileSync } from 'node:child_process';
24
- import { ATTESTATION_TEMPLATES, BASELINE_WORKFLOW_TEMPLATES, CLASSIFIER_TEMPLATES, DOR_TEMPLATES, HUSKY_PREPUSH_SIGN_SNIPPET, } from './init-templates.js';
23
+ import { join, dirname, basename } from 'node:path';
24
+ import { execFileSync, execSync } from 'node:child_process';
25
+ import { BASELINE_DERIVED_GATES } from '../../compliance/types.js';
26
+ import { ATTESTATION_TEMPLATES, BASELINE_WORKFLOW_TEMPLATES, CLASSIFIER_TEMPLATES, DOR_TEMPLATES, HUSKY_PREPUSH_SIGN_SNIPPET, SIGNAL_INGESTION_TEMPLATES, WORKFLOWS_TEMPLATES, } from './init-templates.js';
27
+ /**
28
+ * Canonical list of regulatory regimes presented in the compliance wizard.
29
+ * Mirrors the entries in spec/compliance/regime-mappings.yaml.
30
+ */
31
+ export const COMPLIANCE_REGIME_CHOICES = [
32
+ { value: 'SOC2-T2', label: 'SOC2 Type 2 (Service Organization Control)' },
33
+ { value: 'HIPAA', label: 'HIPAA (Health Insurance Portability and Accountability Act)' },
34
+ { value: 'PCI-DSS-L1', label: 'PCI-DSS Level 1 (Payment Card Industry Data Security Standard)' },
35
+ { value: 'GDPR', label: 'GDPR (General Data Protection Regulation; EU)' },
36
+ { value: 'FedRAMP-Moderate', label: 'FedRAMP Moderate (US federal)' },
37
+ { value: 'ISO-27001:2022', label: 'ISO 27001:2022' },
38
+ ];
39
+ /**
40
+ * Inline regime → DerivedGates mapping for the init wizard.
41
+ * Mirrors spec/compliance/regime-mappings.yaml so the wizard works in
42
+ * installed-package contexts (where the YAML file is not bundled).
43
+ *
44
+ * NOTE: This data is intentionally duplicated from regime-mappings.yaml to
45
+ * avoid a runtime file-read dependency in the init wizard. When regime-mappings.yaml
46
+ * is updated, this table MUST be updated in sync. The RFC-0022 §13 Q7 PR-template
47
+ * compliance-impact checkbox enforces this via reviewer check.
48
+ */
49
+ const INIT_WIZARD_REGIME_GATES = {
50
+ 'SOC2-T2': {
51
+ databaseBranchPool: 'per-shard',
52
+ secretScanStrictness: 'strict',
53
+ attestationRequired: true,
54
+ auditRetentionDays: 2555,
55
+ reviewerAuthorityModel: 'allowlist+role',
56
+ },
57
+ HIPAA: {
58
+ databaseBranchPool: 'per-shard',
59
+ secretScanStrictness: 'strict',
60
+ attestationRequired: true,
61
+ auditRetentionDays: 2190,
62
+ reviewerAuthorityModel: 'allowlist+role',
63
+ },
64
+ 'PCI-DSS-L1': {
65
+ databaseBranchPool: 'per-shard',
66
+ secretScanStrictness: 'strict',
67
+ attestationRequired: true,
68
+ auditRetentionDays: 365,
69
+ reviewerAuthorityModel: 'allowlist+role',
70
+ },
71
+ GDPR: {
72
+ databaseBranchPool: 'per-shard',
73
+ secretScanStrictness: 'standard',
74
+ attestationRequired: true,
75
+ auditRetentionDays: 365,
76
+ reviewerAuthorityModel: 'allowlist',
77
+ },
78
+ 'FedRAMP-Moderate': {
79
+ databaseBranchPool: 'per-shard',
80
+ secretScanStrictness: 'strict',
81
+ attestationRequired: true,
82
+ auditRetentionDays: 1095,
83
+ reviewerAuthorityModel: 'allowlist+role',
84
+ },
85
+ 'ISO-27001:2022': {
86
+ databaseBranchPool: 'per-shard',
87
+ secretScanStrictness: 'strict',
88
+ attestationRequired: true,
89
+ auditRetentionDays: 365,
90
+ reviewerAuthorityModel: 'allowlist+role',
91
+ },
92
+ };
93
+ /**
94
+ * Ordinal scales for tightest-wins composition.
95
+ */
96
+ const SECRET_SCAN_ORDINAL = {
97
+ minimal: 0,
98
+ standard: 1,
99
+ strict: 2,
100
+ };
101
+ const REVIEWER_AUTHORITY_ORDINAL = {
102
+ open: 0,
103
+ allowlist: 1,
104
+ 'allowlist+role': 2,
105
+ };
106
+ /**
107
+ * Compute DerivedGates from a list of regime IDs using tightest-wins semantics.
108
+ * Unknown regime IDs are skipped (the wizard's informational display degrades
109
+ * gracefully rather than throwing).
110
+ *
111
+ * This is a lightweight inline computation used exclusively by the init wizard.
112
+ * The canonical composer (orchestrator/src/compliance/composer.ts) is used for
113
+ * all post-init runtime gate resolution.
114
+ */
115
+ export function computeInitWizardDerivedGates(regimes) {
116
+ if (regimes.length === 0)
117
+ return { ...BASELINE_DERIVED_GATES };
118
+ let accumulated = { ...BASELINE_DERIVED_GATES };
119
+ for (const regimeId of regimes) {
120
+ const entry = INIT_WIZARD_REGIME_GATES[regimeId];
121
+ if (!entry)
122
+ continue; // Unknown regime — skip gracefully
123
+ // databaseBranchPool: per-shard beats shared-with-rls
124
+ if (entry.databaseBranchPool === 'per-shard') {
125
+ accumulated = { ...accumulated, databaseBranchPool: 'per-shard' };
126
+ }
127
+ // secretScanStrictness: ordinal max
128
+ if (SECRET_SCAN_ORDINAL[entry.secretScanStrictness] >
129
+ SECRET_SCAN_ORDINAL[accumulated.secretScanStrictness]) {
130
+ accumulated = { ...accumulated, secretScanStrictness: entry.secretScanStrictness };
131
+ }
132
+ // attestationRequired: boolean OR
133
+ if (entry.attestationRequired) {
134
+ accumulated = { ...accumulated, attestationRequired: true };
135
+ }
136
+ // auditRetentionDays: max
137
+ if (entry.auditRetentionDays > accumulated.auditRetentionDays) {
138
+ accumulated = { ...accumulated, auditRetentionDays: entry.auditRetentionDays };
139
+ }
140
+ // reviewerAuthorityModel: ordinal max
141
+ if (REVIEWER_AUTHORITY_ORDINAL[entry.reviewerAuthorityModel] >
142
+ REVIEWER_AUTHORITY_ORDINAL[accumulated.reviewerAuthorityModel]) {
143
+ accumulated = { ...accumulated, reviewerAuthorityModel: entry.reviewerAuthorityModel };
144
+ }
145
+ }
146
+ return accumulated;
147
+ }
148
+ /**
149
+ * Apply the RFC-0009 §8.7 / OQ-11 trigger checklist on top of regime-derived
150
+ * `DerivedGates`. Upgrades `databaseBranchPool` from `shared-with-rls` to
151
+ * `per-shard` when ANY of the three triggers fires:
152
+ *
153
+ * - Regulatory: already reflected in `inputGates.databaseBranchPool === 'per-shard'`
154
+ * (set by `computeInitWizardDerivedGates` when a per-shard-forcing regime was
155
+ * declared). When this is the case, 'regulatory' is recorded in `triggers`.
156
+ * - Customer contract: `answers.customerContract === true`.
157
+ * - Operator security review: `answers.operatorSecurityReview === true`.
158
+ *
159
+ * The function is monotonic — once `databaseBranchPool` is `per-shard`, it stays
160
+ * `per-shard`. Other DerivedGates fields are passed through unchanged.
161
+ *
162
+ * Pure function — no I/O, no side-effects. The wizard wires it into
163
+ * `runComplianceStep`; tests pin behavior directly.
164
+ */
165
+ export function applyOq11TriggerChecklistUpgrade(inputGates, answers) {
166
+ const triggers = [];
167
+ // Trigger 1 (regulatory) — surfaces only when the gate was already
168
+ // upgraded upstream by a per-shard-forcing regime declaration.
169
+ if (inputGates.databaseBranchPool === 'per-shard') {
170
+ triggers.push('regulatory');
171
+ }
172
+ if (answers.customerContract) {
173
+ triggers.push('customer-contract');
174
+ }
175
+ if (answers.operatorSecurityReview) {
176
+ triggers.push('operator-security-review');
177
+ }
178
+ const upgraded = triggers.length > 0;
179
+ return {
180
+ derivedGates: upgraded ? { ...inputGates, databaseBranchPool: 'per-shard' } : { ...inputGates },
181
+ triggers,
182
+ };
183
+ }
184
+ /**
185
+ * Human-readable line for each trigger kind, shown in the wizard summary so
186
+ * the operator can see WHY the framework selected `per-shard`.
187
+ */
188
+ export function describeOq11Trigger(kind) {
189
+ switch (kind) {
190
+ case 'regulatory':
191
+ return 'regulatory regime declared in .ai-sdlc/compliance.yaml (RFC-0022 derivedGates)';
192
+ case 'customer-contract':
193
+ return 'customer contract requires tenant physical isolation (operator-declared)';
194
+ case 'operator-security-review':
195
+ return 'operator security review identified a risk RLS cannot mitigate (operator-declared)';
196
+ }
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
+ }
209
+ /**
210
+ * Build the .ai-sdlc/compliance.yaml content for a given compliance declaration.
211
+ *
212
+ * The written file contains the declared `spec.regimes` with attestation metadata.
213
+ * The computed `derivedGates` are added as YAML comments (read-only reference)
214
+ * so the loader continues to compute them from regimes (not from spec.derivedGates,
215
+ * which is the operator-override field requiring _notes for each entry).
216
+ *
217
+ * Pure function — no filesystem side-effects, fully testable.
218
+ */
219
+ export function buildComplianceYaml(opts) {
220
+ const { projectName, regimes, attestedBy, attestedAt, attestedNotes, derivedGates } = opts;
221
+ // AISDLC-324 review fix: quote attestedBy/id/attestedAt so an operator
222
+ // git config user.email containing ": " or other YAML-significant chars
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)}"`;
227
+ const regimeItems = regimes
228
+ .map((id) => {
229
+ // id is from hardcoded COMPLIANCE_REGIME_CHOICES (validated upstream)
230
+ // so it's safe to emit unquoted; YAML-significant chars never reach here.
231
+ const lines = [
232
+ ` - id: ${id}`,
233
+ ` attestedBy: ${quotedAttestedBy}`,
234
+ ` attestedAt: "${escapeYamlDoubleQuoted(attestedAt)}"`,
235
+ ];
236
+ if (attestedNotes) {
237
+ lines.push(` attestedNotes: "${escapeYamlDoubleQuoted(attestedNotes)}"`);
238
+ }
239
+ return lines.join('\n');
240
+ })
241
+ .join('\n');
242
+ const derivedGatesComment = [
243
+ '# --- Derived gates (computed from declared regimes; read-only) ---',
244
+ `# databaseBranchPool: ${derivedGates.databaseBranchPool}`,
245
+ `# secretScanStrictness: ${derivedGates.secretScanStrictness}`,
246
+ `# attestationRequired: ${derivedGates.attestationRequired}`,
247
+ `# auditRetentionDays: ${derivedGates.auditRetentionDays}`,
248
+ `# reviewerAuthorityModel: ${derivedGates.reviewerAuthorityModel}`,
249
+ '#',
250
+ '# To override a gate, add spec.derivedGates.<field> with a sibling _notes entry.',
251
+ '# See docs/operations/compliance-posture.md for override patterns.',
252
+ ].join('\n');
253
+ // AISDLC-324 review fix: avoid duplicate `regimes:` key when empty.
254
+ // Inline `[]` for empty case; emit block under `regimes:` for non-empty.
255
+ const regimesSection = regimes.length === 0 ? ` regimes: []` : ` regimes:\n${regimeItems}`;
256
+ return [
257
+ `apiVersion: ai-sdlc.io/v1alpha1`,
258
+ `kind: CompliancePosture`,
259
+ `metadata:`,
260
+ ` name: "${escapeYamlDoubleQuoted(projectName)}"`,
261
+ `spec:`,
262
+ regimesSection,
263
+ ` auditExports: []`,
264
+ '',
265
+ derivedGatesComment,
266
+ '',
267
+ ].join('\n');
268
+ }
269
+ /**
270
+ * Format the derived gates for console display (the "✓ Wrote ... with derived gates:" block).
271
+ */
272
+ export function formatDerivedGatesDisplay(derivedGates) {
273
+ return [
274
+ ` databaseBranchPool: ${derivedGates.databaseBranchPool}`,
275
+ ` secretScanStrictness: ${derivedGates.secretScanStrictness}`,
276
+ ` attestationRequired: ${derivedGates.attestationRequired}`,
277
+ ` auditRetentionDays: ${derivedGates.auditRetentionDays}`,
278
+ ` reviewerAuthorityModel: ${derivedGates.reviewerAuthorityModel}`,
279
+ ].join('\n');
280
+ }
281
+ /**
282
+ * DB-pool rationale displayed when a compliance regime forces `per-shard`.
283
+ * Returns null if the DB-pool is `shared-with-rls` (no rationale needed).
284
+ */
285
+ export function getDbPoolRationale(regimes, derivedGates) {
286
+ if (derivedGates.databaseBranchPool !== 'per-shard')
287
+ return null;
288
+ if (regimes.length === 0)
289
+ return null;
290
+ const forcingRegimes = regimes.filter((id) => INIT_WIZARD_REGIME_GATES[id]?.databaseBranchPool === 'per-shard');
291
+ if (forcingRegimes.length === 0)
292
+ return null;
293
+ return ` (${forcingRegimes.join(', ')} declared at .ai-sdlc/compliance.yaml requires per-shard isolation)`;
294
+ }
295
+ /**
296
+ * Run the compliance posture wizard step (RFC-0022 §7 / AISDLC-324).
297
+ *
298
+ * Inserted into the init wizard BEFORE the gate-config feature prompts.
299
+ * Always runs — even for unregulated projects (declaring "(none)" is the
300
+ * explicit choice; the resulting compliance.yaml carries that decision).
301
+ *
302
+ * Flow:
303
+ * 1. Auto-detect git config user.email for attestedBy default.
304
+ * 2. If --yes or non-TTY: use baseline (no regimes), auto-fill attestedBy.
305
+ * 3. Otherwise: multi-select regimes, text-input attestedBy + notes.
306
+ * 4. Compute derivedGates via inline tightest-wins composition.
307
+ * 5. Write .ai-sdlc/compliance.yaml (skips if file already exists and not
308
+ * in --add compliance mode).
309
+ * 6. Log derived gates + DB-pool rationale.
310
+ */
311
+ export async function runComplianceStep(projectDir, flags, adapters) {
312
+ const compliancePath = join(projectDir, '.ai-sdlc', 'compliance.yaml');
313
+ // ── Auto-detect git email for attestedBy default ──────────────────────
314
+ let gitEmail = '';
315
+ try {
316
+ const result = adapters.runCommand('git', ['config', 'user.email']);
317
+ if (result.exitCode === 0) {
318
+ gitEmail = result.stdout.trim();
319
+ }
320
+ }
321
+ catch {
322
+ // Ignore — git config may not be set; attestedBy will be empty default
323
+ }
324
+ // Resolve project name from git remote or directory basename.
325
+ const projectName = (() => {
326
+ try {
327
+ const r = adapters.runCommand('git', ['remote', 'get-url', 'origin']);
328
+ if (r.exitCode === 0) {
329
+ // Extract repo name from remote URL (ssh: git@github.com:org/repo.git, https: .../org/repo.git)
330
+ const m = r.stdout.trim().match(/\/([^/]+?)(\.git)?$/);
331
+ if (m)
332
+ return m[1];
333
+ }
334
+ }
335
+ catch {
336
+ // Ignore
337
+ }
338
+ return basename(projectDir);
339
+ })();
340
+ // ── --yes / non-TTY path: baseline (no regimes, no triggers) ──────────
341
+ if (flags.yes || !process.stdin.isTTY) {
342
+ const derivedGates = computeInitWizardDerivedGates([]);
343
+ const attestedAt = new Date().toISOString();
344
+ const written = writeComplianceYaml({
345
+ projectDir,
346
+ compliancePath,
347
+ projectName,
348
+ regimes: [],
349
+ attestedBy: gitEmail,
350
+ attestedAt,
351
+ attestedNotes: undefined,
352
+ derivedGates,
353
+ flags,
354
+ adapters,
355
+ });
356
+ adapters.log('');
357
+ adapters.log(` Compliance posture: no regimes declared (unregulated baseline)`);
358
+ adapters.log(` derivedGates: ${JSON.stringify(derivedGates)}`);
359
+ return {
360
+ regimes: [],
361
+ attestedBy: gitEmail,
362
+ attestedAt,
363
+ attestedNotes: undefined,
364
+ derivedGates,
365
+ oq11Triggers: [],
366
+ yamlPath: compliancePath,
367
+ written,
368
+ };
369
+ }
370
+ // ── Interactive path ──────────────────────────────────────────────────
371
+ adapters.log('> Compliance posture');
372
+ adapters.log('');
373
+ const selectedRegimes = await adapters.multiSelect('Which regulatory regimes apply to this project? (space to toggle, enter to confirm)', COMPLIANCE_REGIME_CHOICES);
374
+ const attestedBy = await adapters.textInput('Who is attesting these regimes apply?', gitEmail || undefined);
375
+ const attestedNotesRaw = await adapters.textInput('Notes on the attestation (optional, audit-visible):', undefined);
376
+ const attestedNotes = attestedNotesRaw.trim() || undefined;
377
+ const attestedAt = new Date().toISOString();
378
+ const regimeDerivedGates = computeInitWizardDerivedGates(selectedRegimes);
379
+ // ── RFC-0009 §8.7 / OQ-11 trigger checklist (AISDLC-319) ──────────────
380
+ // Trigger 1 (regulatory) is already reflected in `regimeDerivedGates`;
381
+ // collect triggers 2 + 3 from the operator. The framework cannot auto-detect
382
+ // these (per RFC-0009 §8.7) — operator declares.
383
+ adapters.log('');
384
+ adapters.log('> DatabaseBranchPool trigger checklist (RFC-0009 §8.7)');
385
+ adapters.log(' Default is shared pool with row-level-security isolation. Answer Yes only');
386
+ adapters.log(' if the trigger actually applies — per-soul pools add operational complexity.');
387
+ const customerContract = await adapters.prompt('Does any customer contract require tenant physical isolation (independent of regulatory baseline)?', false);
388
+ const operatorSecurityReview = await adapters.prompt('Has operator security review identified a risk that RLS cannot mitigate?', false);
389
+ const triggerResult = applyOq11TriggerChecklistUpgrade(regimeDerivedGates, {
390
+ customerContract,
391
+ operatorSecurityReview,
392
+ });
393
+ const derivedGates = triggerResult.derivedGates;
394
+ const oq11Triggers = triggerResult.triggers;
395
+ const written = writeComplianceYaml({
396
+ projectDir,
397
+ compliancePath,
398
+ projectName,
399
+ regimes: selectedRegimes,
400
+ attestedBy,
401
+ attestedAt,
402
+ attestedNotes,
403
+ derivedGates,
404
+ flags,
405
+ adapters,
406
+ });
407
+ adapters.log('');
408
+ if (written) {
409
+ adapters.log(`✓ Wrote .ai-sdlc/compliance.yaml with derived gates:`);
410
+ }
411
+ else {
412
+ adapters.log(` .ai-sdlc/compliance.yaml already exists — skipped (run --add compliance to update)`);
413
+ adapters.log(` Derived gates (from existing compliance.yaml, if unchanged):`);
414
+ }
415
+ adapters.log(formatDerivedGatesDisplay(derivedGates));
416
+ // DB-pool rationale: surface BOTH the regime-derived rationale (AC #6) and
417
+ // the OQ-11 trigger checklist rationale (AISDLC-319 AC #2/#4).
418
+ const dbRationale = getDbPoolRationale(selectedRegimes, derivedGates);
419
+ if (dbRationale) {
420
+ adapters.log('');
421
+ adapters.log(` DatabaseBranchPool pre-selection: per-shard`);
422
+ adapters.log(dbRationale);
423
+ }
424
+ else if (oq11Triggers.length > 0) {
425
+ // Triggers 2 / 3 fired but no regulatory trigger — explain the upgrade.
426
+ adapters.log('');
427
+ adapters.log(` DatabaseBranchPool pre-selection: per-shard`);
428
+ adapters.log(` (RFC-0009 §8.7 trigger fired:`);
429
+ for (const t of oq11Triggers) {
430
+ if (t === 'regulatory')
431
+ continue; // already covered by dbRationale path
432
+ adapters.log(` - ${describeOq11Trigger(t)}`);
433
+ }
434
+ adapters.log(` )`);
435
+ }
436
+ adapters.log('');
437
+ adapters.log(` Review with: cat .ai-sdlc/compliance.yaml`);
438
+ adapters.log(` Override any field with the attestedNotes pattern (see docs/operations/compliance-posture.md).`);
439
+ return {
440
+ regimes: selectedRegimes,
441
+ attestedBy,
442
+ attestedAt,
443
+ attestedNotes,
444
+ derivedGates,
445
+ oq11Triggers,
446
+ yamlPath: compliancePath,
447
+ written,
448
+ };
449
+ }
450
+ /** Helper: write .ai-sdlc/compliance.yaml (or skip if exists). Returns true if written. */
451
+ function writeComplianceYaml(opts) {
452
+ const { compliancePath, flags, adapters } = opts;
453
+ if (flags.dryRun) {
454
+ adapters.log(` would write .ai-sdlc/compliance.yaml`);
455
+ return false;
456
+ }
457
+ if (adapters.exists(compliancePath)) {
458
+ return false;
459
+ }
460
+ const yamlContent = buildComplianceYaml({
461
+ projectName: opts.projectName,
462
+ regimes: opts.regimes,
463
+ attestedBy: opts.attestedBy,
464
+ attestedAt: opts.attestedAt,
465
+ attestedNotes: opts.attestedNotes,
466
+ derivedGates: opts.derivedGates,
467
+ });
468
+ adapters.mkdirp(join(opts.projectDir, '.ai-sdlc'));
469
+ adapters.writeFile(compliancePath, yamlContent);
470
+ return true;
471
+ }
25
472
  /** All feature flags off — used as the initial state before flags + prompts. */
26
473
  export const NO_FEATURES = {
27
474
  dor: false,
28
475
  attestation: false,
29
476
  classifier: false,
30
477
  branchProtection: false,
478
+ workflows: false,
479
+ signalIngestion: false,
31
480
  };
32
- /** All features on — the answer used by `--yes` (accept all defaults). */
481
+ /**
482
+ * All features on — the answer used by `--yes` (accept all defaults).
483
+ *
484
+ * Note: `signalIngestion` is deliberately FALSE in this set even though
485
+ * `--yes` accepts all *defaults*. RFC-0030's pipeline is gated by the
486
+ * `AI_SDLC_SIGNAL_INGESTION` env flag during its soak window, and the
487
+ * shipped default for the flag is OFF. Scaffolding the config stub on a
488
+ * fresh adopter who hasn't opted in would be noise; the file is only
489
+ * meaningful when the operator has explicit interest. Adopters opt in
490
+ * via `--with-signal-ingestion` or `--add signal-ingestion`.
491
+ */
33
492
  export const ALL_FEATURES = {
34
493
  dor: true,
35
494
  attestation: true,
36
495
  classifier: true,
37
496
  branchProtection: true,
497
+ workflows: true,
498
+ signalIngestion: false,
38
499
  };
500
+ /**
501
+ * Resolve the AI-SDLC install target directory for the current invocation
502
+ * (AISDLC-262).
503
+ *
504
+ * ## Default behavior (no `--workspace` flag)
505
+ *
506
+ * 1. Shell out to `git rev-parse --show-toplevel` to find the repo root.
507
+ * - If the cwd is not inside a git repo, install at cwd (plain-dir
508
+ * fallback — same as the pre-AISDLC-262 behavior).
509
+ * 2. If `<git-root>/.ai-sdlc/` **already exists**, refuse with a clear
510
+ * "already installed at <root>; pass --workspace <name>" message so
511
+ * the operator knows exactly what to do next.
512
+ * 3. Otherwise install at the git root.
513
+ *
514
+ * ## `--workspace <name>` mode
515
+ *
516
+ * The operator explicitly wants a per-workspace install at
517
+ * `packages/<name>/.ai-sdlc/` (or `<name>/.ai-sdlc/` if `packages/` does
518
+ * not exist under the git root). No nesting check is performed — the
519
+ * operator has opted in.
520
+ *
521
+ * ## Dry-run output
522
+ *
523
+ * The resolved `installDir` is logged by the caller on the FIRST output
524
+ * line so adopters can sanity-check the target before any files are written.
525
+ */
526
+ export function resolveInstallTarget(opts = {}) {
527
+ const cwd = opts.cwd ?? process.cwd();
528
+ const configDirName = opts.configDirName ?? '.ai-sdlc';
529
+ const exists = opts.exists ?? existsSync;
530
+ // Resolve git root (or fall back to cwd when not in a git repo).
531
+ let gitRoot;
532
+ try {
533
+ const getToplevel = opts.gitShowToplevel ??
534
+ ((dir) => execSync(`git rev-parse --show-toplevel`, {
535
+ cwd: dir,
536
+ encoding: 'utf-8',
537
+ stdio: ['ignore', 'pipe', 'ignore'],
538
+ }).trim());
539
+ gitRoot = getToplevel(cwd);
540
+ }
541
+ catch {
542
+ // Not inside a git repo — install at cwd (plain-dir fallback).
543
+ gitRoot = cwd;
544
+ }
545
+ if (opts.workspace) {
546
+ // Per-workspace install: `<git-root>/packages/<name>` if packages/ exists,
547
+ // otherwise `<git-root>/<name>`.
548
+ const packagesDir = join(gitRoot, 'packages');
549
+ const workspaceBase = exists(packagesDir) ? packagesDir : gitRoot;
550
+ const installDir = join(workspaceBase, opts.workspace);
551
+ return { installDir, resolved: installDir !== cwd };
552
+ }
553
+ // Default: install at git root. If `.ai-sdlc/` already exists there,
554
+ // refuse with a helpful message — UNLESS `skipExistingCheck` is set
555
+ // (used by `--add <feature>`, which intentionally extends an existing
556
+ // install and thus expects the directory to already be present).
557
+ const rootConfigDir = join(gitRoot, configDirName);
558
+ if (exists(rootConfigDir) && !opts.skipExistingCheck) {
559
+ return {
560
+ installDir: gitRoot,
561
+ resolved: gitRoot !== cwd,
562
+ error: `AI-SDLC is already installed at ${gitRoot}; ` +
563
+ `pass --workspace <name> to add a child install at packages/<name>/.ai-sdlc/`,
564
+ };
565
+ }
566
+ return { installDir: gitRoot, resolved: gitRoot !== cwd };
567
+ }
39
568
  // ── Production adapter factory ───────────────────────────────────────────
40
569
  /**
41
570
  * Build the production adapter bag. Pulled into a factory so tests can
@@ -55,6 +584,17 @@ export function buildProductionAdapters() {
55
584
  const { confirm } = await import('@inquirer/prompts');
56
585
  return confirm({ message: question, default: defaultYes });
57
586
  },
587
+ multiSelect: async (question, choices) => {
588
+ const { checkbox } = await import('@inquirer/prompts');
589
+ return checkbox({
590
+ message: question,
591
+ choices: choices.map((c) => ({ name: c.label, value: c.value })),
592
+ });
593
+ },
594
+ textInput: async (question, defaultValue) => {
595
+ const { input } = await import('@inquirer/prompts');
596
+ return input({ message: question, default: defaultValue });
597
+ },
58
598
  writeFile: (path, contents) => writeFileSync(path, contents, 'utf-8'),
59
599
  appendOnce: (path, contents, sentinel) => {
60
600
  // Make sure the parent dir exists (appendFileSync errors with ENOENT
@@ -129,6 +669,12 @@ export async function resolveFeatureSelection(flags, adapters) {
129
669
  case 'branch-protection':
130
670
  sel.branchProtection = true;
131
671
  break;
672
+ case 'workflows':
673
+ sel.workflows = true;
674
+ break;
675
+ case 'signal-ingestion':
676
+ sel.signalIngestion = true;
677
+ break;
132
678
  }
133
679
  return sel;
134
680
  }
@@ -136,6 +682,18 @@ export async function resolveFeatureSelection(flags, adapters) {
136
682
  if (flags.yes) {
137
683
  return { ...ALL_FEATURES };
138
684
  }
685
+ // ── Path 2b: non-TTY auto-fall-through ────────────────────────────────
686
+ // When stdin is not a TTY (CI runners, agent bash, piped input, Docker
687
+ // containers without `-it`), interactive prompts hang then throw an
688
+ // unhandled ExitPromptError. Auto-fall-through to ALL_FEATURES (same
689
+ // as --yes) so adopters can run `ai-sdlc init` in CI without any
690
+ // additional flags. This matches the documented behavior of --yes, which
691
+ // is defined as "accept all defaults non-interactively".
692
+ if (!process.stdin.isTTY) {
693
+ adapters.log('Non-TTY stdin detected — auto-accepting all feature defaults (equivalent to --yes).');
694
+ adapters.log('Pass --yes explicitly to suppress this message.');
695
+ return { ...ALL_FEATURES };
696
+ }
139
697
  // ── Path 3+4: `--with-X` overrides + prompts for the rest ─────────────
140
698
  const sel = { ...NO_FEATURES };
141
699
  // DoR
@@ -166,6 +724,24 @@ export async function resolveFeatureSelection(flags, adapters) {
166
724
  else {
167
725
  sel.branchProtection = await adapters.prompt('Apply recommended branch protection? (required: ai-sdlc/pr-ready + codecov/patch)', true);
168
726
  }
727
+ // GitHub Actions workflows bundle (AISDLC-261)
728
+ if (flags.withWorkflows) {
729
+ sel.workflows = true;
730
+ }
731
+ else {
732
+ sel.workflows = await adapters.prompt('Scaffold GitHub Actions workflows (gate, review, attestation, auto-merge)?', true);
733
+ }
734
+ // RFC-0030 Signal Ingestion Pipeline (AISDLC-348)
735
+ // Default to FALSE in the interactive prompt: the pipeline is in a soak
736
+ // window gated by AI_SDLC_SIGNAL_INGESTION; scaffolding the config on a
737
+ // fresh adopter who hasn't opted in is noise. Opt-in path: --with-signal-
738
+ // ingestion or --add signal-ingestion.
739
+ if (flags.withSignalIngestion) {
740
+ sel.signalIngestion = true;
741
+ }
742
+ else {
743
+ sel.signalIngestion = await adapters.prompt('Scaffold RFC-0030 signal-ingestion config (default OFF; opt in via AI_SDLC_SIGNAL_INGESTION soak)?', false);
744
+ }
169
745
  return sel;
170
746
  }
171
747
  /**
@@ -176,12 +752,18 @@ export async function resolveFeatureSelection(flags, adapters) {
176
752
  * Idempotent: any file that already exists at the target path is skipped
177
753
  * with a "skip" log line. This is what makes `--add <feature>` safe to
178
754
  * run on an already-initialized repo (AC #7).
755
+ *
756
+ * The `workflows` feature (AISDLC-261) supports `--force` to overwrite
757
+ * existing workflow files with the current template versions. Use this
758
+ * to upgrade a pre-261 repo to the full workflow bundle.
179
759
  */
180
760
  export async function applyFeatureSelection(projectDir, selection, flags, adapters) {
181
761
  const result = { created: [], skipped: [], wouldCreate: [] };
182
762
  // Build the union of templates to write.
183
763
  const templateSets = [];
184
764
  // `--add` mode: skip the baseline (we're EXTENDING an existing init).
765
+ // Exception: `--add workflows` must write the workflows bundle even in
766
+ // --add mode (the bundle IS the feature; it's not an extension of baseline).
185
767
  if (!flags.add) {
186
768
  templateSets.push(BASELINE_WORKFLOW_TEMPLATES);
187
769
  }
@@ -191,15 +773,36 @@ export async function applyFeatureSelection(projectDir, selection, flags, adapte
191
773
  templateSets.push(ATTESTATION_TEMPLATES);
192
774
  if (selection.classifier)
193
775
  templateSets.push(CLASSIFIER_TEMPLATES);
776
+ if (selection.workflows)
777
+ templateSets.push(WORKFLOWS_TEMPLATES);
778
+ if (selection.signalIngestion)
779
+ templateSets.push(SIGNAL_INGESTION_TEMPLATES);
780
+ // AISDLC-261 PR #480 review fix: dedupe across template sets so e.g.
781
+ // `ai-sdlc-gate.yml` (in BOTH BASELINE_WORKFLOW_TEMPLATES and
782
+ // WORKFLOWS_TEMPLATES) doesn't get written twice on
783
+ // `--with-workflows --force` (which would log "overwrite ${relPath}
784
+ // (--force)" against the duplicate, doubling result.created entries).
785
+ // First-set wins (BASELINE comes first, then feature bundles).
786
+ const seenRelPaths = new Set();
194
787
  for (const set of templateSets) {
195
788
  for (const [relPath, contents] of Object.entries(set.files)) {
789
+ if (seenRelPaths.has(relPath))
790
+ continue;
791
+ seenRelPaths.add(relPath);
196
792
  const absPath = join(projectDir, relPath);
197
793
  if (flags.dryRun) {
794
+ const isWorkflowFileDry = relPath.startsWith('.github/workflows/');
795
+ const wouldForce = isWorkflowFileDry && flags.force && adapters.exists(absPath);
198
796
  result.wouldCreate.push(relPath);
199
- adapters.log(` would create ${relPath}`);
797
+ adapters.log(wouldForce ? ` would overwrite ${relPath} (--force)` : ` would create ${relPath}`);
200
798
  continue;
201
799
  }
202
- if (adapters.exists(absPath)) {
800
+ // For the workflows feature: `--force` overwrites existing files.
801
+ // For all other features: existing files are always skipped (idempotent).
802
+ const isWorkflowFile = relPath.startsWith('.github/workflows/');
803
+ const shouldForce = isWorkflowFile && flags.force;
804
+ const alreadyExists = adapters.exists(absPath);
805
+ if (alreadyExists && !shouldForce) {
203
806
  result.skipped.push(relPath);
204
807
  adapters.log(` skip ${relPath} (already exists)`);
205
808
  continue;
@@ -208,7 +811,12 @@ export async function applyFeatureSelection(projectDir, selection, flags, adapte
208
811
  adapters.mkdirp(dirname(absPath));
209
812
  adapters.writeFile(absPath, contents);
210
813
  result.created.push(relPath);
211
- adapters.log(` created ${relPath}`);
814
+ if (alreadyExists && shouldForce) {
815
+ adapters.log(` overwrite ${relPath} (--force)`);
816
+ }
817
+ else {
818
+ adapters.log(` created ${relPath}`);
819
+ }
212
820
  }
213
821
  }
214
822
  // Husky pre-push sign hook is a separate concern from the
@@ -402,6 +1010,38 @@ export function renderNextSteps(selection, result, adapters) {
402
1010
  lines.push('');
403
1011
  stepN++;
404
1012
  }
1013
+ if (selection.workflows) {
1014
+ lines.push(`${stepN}. GitHub Actions workflows were scaffolded into .github/workflows/.`);
1015
+ lines.push(' Four files are now in place:');
1016
+ lines.push(' - ai-sdlc-gate.yml — ai-sdlc/pr-ready rollup check');
1017
+ lines.push(' - verify-attestation.yml — DSSE attestation verifier (audit-only)');
1018
+ lines.push(' - ai-sdlc-review.yml — PR review status (stub: wire your reviewers)');
1019
+ lines.push(' - auto-enable-auto-merge.yml — arms auto-merge on same-repo PRs');
1020
+ lines.push(' To activate auto-merge:');
1021
+ lines.push(' a) Enable "Allow auto-merge" in GitHub Settings → General.');
1022
+ lines.push(' b) Set the AI_SDLC_PAT secret with write access to the repo.');
1023
+ lines.push(' Re-run with --force to overwrite workflows on a pre-261 repo:');
1024
+ lines.push(' ai-sdlc init --add workflows --force');
1025
+ lines.push('');
1026
+ stepN++;
1027
+ }
1028
+ if (selection.signalIngestion) {
1029
+ lines.push(`${stepN}. RFC-0030 signal-ingestion config was scaffolded (DISABLED by default).`);
1030
+ lines.push(' .ai-sdlc/signal-ingestion.yaml ships every block commented-out under');
1031
+ lines.push(' enabled: false. The pipeline is gated by BOTH the YAML toggle AND the');
1032
+ lines.push(' AI_SDLC_SIGNAL_INGESTION env flag during the soak window.');
1033
+ lines.push(' To opt in:');
1034
+ lines.push(' a) Read docs/operations/signal-ingestion.md (adapter setup +');
1035
+ lines.push(' tier-multiplier + SA-threshold tuning + manual entry workflow).');
1036
+ lines.push(' b) Set AI_SDLC_SIGNAL_INGESTION=1 in your shell / CI env.');
1037
+ lines.push(' c) Edit .ai-sdlc/signal-ingestion.yaml, set spec.enabled: true,');
1038
+ lines.push(' uncomment the adapters block, and confirm tier multipliers.');
1039
+ lines.push(' Config edits emit SignalIngestionConfigChanged governance events to');
1040
+ lines.push(' <ARTIFACTS_DIR>/_orchestrator/events-YYYY-MM-DD.jsonl.');
1041
+ lines.push(' Promotion runbook: docs/operations/signal-ingestion-promotion.md');
1042
+ lines.push('');
1043
+ stepN++;
1044
+ }
405
1045
  if (selection.branchProtection) {
406
1046
  if (result.branchProtection?.applied) {
407
1047
  lines.push(`${stepN}. Branch protection on \`main\` was updated.`);