@ai-sdlc/orchestrator 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `ai-sdlc init` interactive wizard + feature dispatcher (AISDLC-143).
3
+ *
4
+ * Per Q4(b) of the operator-ratified quality-gate redesign, `ai-sdlc init`
5
+ * is a wizard by default with `--yes` for non-interactive (CI/scripts) and
6
+ * `--with-X` flags for explicit opt-in (`--with-dor`, `--with-attestation`,
7
+ * `--with-classifier`, `--with-branch-protection`). This module owns:
8
+ *
9
+ * 1. The ordered prompt list (resolveFeatureSelection).
10
+ * 2. The feature-toggle → file-write dispatcher (applyFeatureSelection).
11
+ * 3. The branch-protection helper (applyBranchProtection) including the
12
+ * `--dry-run` JSON-print path required by AC #6.
13
+ * 4. The "next steps" summary printed at the end of init (AC #5).
14
+ *
15
+ * Test surface: every public function takes a small options bag with
16
+ * injectable side-effect adapters (prompter, writeFile, runCommand) so the
17
+ * test suite can drive every wizard branch hermetically without spinning
18
+ * up a TTY or shelling out to `gh`. Production callers in `init.ts` pass
19
+ * the real adapters.
20
+ */
21
+ /** Per-feature on/off bits derived from prompts + flags. */
22
+ export interface FeatureSelection {
23
+ dor: boolean;
24
+ attestation: boolean;
25
+ classifier: boolean;
26
+ branchProtection: boolean;
27
+ }
28
+ /** All feature flags off — used as the initial state before flags + prompts. */
29
+ export declare const NO_FEATURES: FeatureSelection;
30
+ /** All features on — the answer used by `--yes` (accept all defaults). */
31
+ export declare const ALL_FEATURES: FeatureSelection;
32
+ /** Flag-bag controlling wizard behavior (already parsed from argv). */
33
+ export interface WizardFlags {
34
+ /** `--yes` short-circuits the wizard; treats every prompt as "yes". */
35
+ yes: boolean;
36
+ /** `--with-dor` forces the DoR feature on without prompting. */
37
+ withDor: boolean;
38
+ /** `--with-attestation` forces attestation infra on without prompting. */
39
+ withAttestation: boolean;
40
+ /** `--with-classifier` forces the classifier on without prompting. */
41
+ withClassifier: boolean;
42
+ /** `--with-branch-protection` forces branch-protection on without prompting. */
43
+ withBranchProtection: boolean;
44
+ /**
45
+ * `--add <feature>` extends an already-initialized repo with a single
46
+ * feature without re-prompting. AC #7 (idempotent extension). When set,
47
+ * the wizard short-circuits to scaffold ONLY this feature.
48
+ */
49
+ add?: 'dor' | 'attestation' | 'classifier' | 'branch-protection';
50
+ /** `--dry-run` — print what would be done, don't write. */
51
+ dryRun: boolean;
52
+ }
53
+ /**
54
+ * Single-question prompter contract — accepts a question + default and
55
+ * returns the user's answer. The production adapter wraps `@inquirer/prompts`
56
+ * so the user gets a real readline TTY; tests inject a stub that returns
57
+ * scripted answers without touching stdin.
58
+ *
59
+ * Why a single-question primitive instead of "ask all questions at once":
60
+ * the prompts are conditional in some cases (e.g. branch-protection only
61
+ * makes sense after the user has chosen which CI gates exist). Keeping
62
+ * the primitive small lets `resolveFeatureSelection` decide ordering +
63
+ * skip questions whose answer is already determined by a `--with-X` flag.
64
+ */
65
+ export type Prompter = (question: string, defaultYes: boolean) => Promise<boolean>;
66
+ /**
67
+ * Side-effect adapter bag — every part of the dispatcher that touches
68
+ * disk or shells out goes through this so tests can assert on intents
69
+ * without mocking `node:fs` globally.
70
+ */
71
+ export interface FeatureAdapters {
72
+ /** Resolve to an interactive prompt answer. */
73
+ prompt: Prompter;
74
+ /** Write a file. Production = `node:fs.writeFileSync`. */
75
+ writeFile: (path: string, contents: string) => void;
76
+ /**
77
+ * Append `contents` to `path` exactly once: if `sentinel` is already
78
+ * present in the file, no-op. If the file doesn't exist, behaves like
79
+ * a write. Used for the husky pre-push sign block + CLAUDE.md pointer
80
+ * (both of which need to coexist with user-edited content).
81
+ */
82
+ appendOnce: (path: string, contents: string, sentinel: string) => 'appended' | 'skipped';
83
+ /** mkdir -p. Production = `node:fs.mkdirSync({ recursive: true })`. */
84
+ mkdirp: (path: string) => void;
85
+ /** Test for path existence. Production = `node:fs.existsSync`. */
86
+ exists: (path: string) => boolean;
87
+ /** Run a shell command (used for `gh api`). Production = `execSync`. */
88
+ runCommand: (cmd: string, args: string[]) => {
89
+ stdout: string;
90
+ exitCode: number;
91
+ };
92
+ /** Sink for operator-visible output (defaults to console.log). */
93
+ log: (line: string) => void;
94
+ }
95
+ /**
96
+ * Build the production adapter bag. Pulled into a factory so tests can
97
+ * compose a partial override bag (e.g. only override `prompt`) and let
98
+ * the rest fall through to real disk writes.
99
+ *
100
+ * The `prompt` adapter is a lazy import of `@inquirer/prompts.confirm`
101
+ * so that:
102
+ * 1. Tests don't pay the import cost when they inject their own stub.
103
+ * 2. `--yes` runs (which never call `prompt`) don't pay it either.
104
+ * 3. The orchestrator's runtime `dist/` is smaller for the common case.
105
+ */
106
+ export declare function buildProductionAdapters(): FeatureAdapters;
107
+ /**
108
+ * Resolve the per-feature on/off vector by combining (in priority order):
109
+ * 1. `--add <feature>` — if set, ONLY that feature is on; everything
110
+ * else is suppressed (idempotent extension, AC #7).
111
+ * 2. `--yes` — accept every default (every feature on).
112
+ * 3. `--with-X` flags — opt-in without prompting.
113
+ * 4. Interactive prompts for any feature still undetermined.
114
+ *
115
+ * Returns a fully-determined FeatureSelection. The dispatcher then writes
116
+ * exactly the union of features marked true.
117
+ */
118
+ export declare function resolveFeatureSelection(flags: WizardFlags, adapters: Pick<FeatureAdapters, 'prompt' | 'log'>): Promise<FeatureSelection>;
119
+ /** Return value of `applyFeatureSelection` — what was actually written. */
120
+ export interface ApplyResult {
121
+ /** Files that were newly created on this run. */
122
+ created: string[];
123
+ /** Files that already existed and were left untouched (idempotent). */
124
+ skipped: string[];
125
+ /** Files that would have been created if not for `--dry-run`. */
126
+ wouldCreate: string[];
127
+ /** Branch-protection result, if attempted. */
128
+ branchProtection?: BranchProtectionResult;
129
+ }
130
+ /**
131
+ * Write the union of feature templates into the project dir. AC #4 says
132
+ * the BASELINE workflow templates (gate workflow) are always written; the
133
+ * per-feature template sets are written only when their toggle is on.
134
+ *
135
+ * Idempotent: any file that already exists at the target path is skipped
136
+ * with a "skip" log line. This is what makes `--add <feature>` safe to
137
+ * run on an already-initialized repo (AC #7).
138
+ */
139
+ export declare function applyFeatureSelection(projectDir: string, selection: FeatureSelection, flags: WizardFlags, adapters: FeatureAdapters): Promise<ApplyResult>;
140
+ export interface BranchProtectionResult {
141
+ /** Whether the rule was actually applied. False in dry-run. */
142
+ applied: boolean;
143
+ /** The PUT body as a JSON string (always populated for visibility). */
144
+ bodyJson: string;
145
+ /** Error message from `gh api`, if non-zero exit. */
146
+ error?: string;
147
+ }
148
+ /**
149
+ * Recommended branch-protection ruleset for AI-SDLC adopters. AC #1 #4:
150
+ * the required checks are `ai-sdlc/pr-ready` (the gate aggregator) and
151
+ * `codecov/patch` (the de facto coverage signal). Other AI-SDLC apps
152
+ * post their own statuses but they're all rolled into pr-ready.
153
+ *
154
+ * The body conforms to the GitHub REST API
155
+ * `PUT /repos/{owner}/{repo}/branches/{branch}/protection` schema.
156
+ */
157
+ export declare const RECOMMENDED_BRANCH_PROTECTION_BODY: {
158
+ required_status_checks: {
159
+ strict: boolean;
160
+ contexts: string[];
161
+ };
162
+ enforce_admins: boolean;
163
+ required_pull_request_reviews: {
164
+ dismiss_stale_reviews: boolean;
165
+ require_code_owner_reviews: boolean;
166
+ required_approving_review_count: number;
167
+ };
168
+ restrictions: null;
169
+ allow_force_pushes: boolean;
170
+ allow_deletions: boolean;
171
+ };
172
+ /**
173
+ * Apply (or print, in dry-run) the recommended branch protection rule
174
+ * to the `main` branch of the repo at `projectDir`. AC #6 explicitly
175
+ * requires that `--dry-run` print the JSON without applying.
176
+ *
177
+ * The repo identity (`owner/repo`) is resolved by shelling out to
178
+ * `gh repo view --json nameWithOwner -q .nameWithOwner`. We could parse
179
+ * the git remote ourselves (see git-remote.ts) but `gh` already resolves
180
+ * forks + renames + custom default branches consistently, and the user
181
+ * needs `gh` on PATH for the PUT to work anyway.
182
+ */
183
+ export declare function applyBranchProtection(projectDir: string, flags: WizardFlags, adapters: Pick<FeatureAdapters, 'runCommand' | 'log'>): Promise<BranchProtectionResult>;
184
+ /**
185
+ * Print the structured "next steps" summary at the end of init. AC #5:
186
+ * the summary must include operator action items conditional on which
187
+ * features were chosen (e.g. `gh secret set` commands when attestation
188
+ * was opted in).
189
+ *
190
+ * Returns the rendered summary as a string in addition to logging it,
191
+ * so tests can assert on it without re-stringifying console output.
192
+ */
193
+ export declare function renderNextSteps(selection: FeatureSelection, result: ApplyResult, adapters: Pick<FeatureAdapters, 'log'>): string;
194
+ /**
195
+ * The pointer block we append to CLAUDE.md so a freshly-initialized repo's
196
+ * Claude Code sessions know where to find the AI-SDLC quality-gate docs.
197
+ * Idempotent — guarded by a sentinel so re-running init doesn't duplicate
198
+ * the block.
199
+ */
200
+ export declare const CLAUDE_MD_POINTER = "\n<!-- ai-sdlc:recommendation-pointer -->\n## AI-SDLC quality gate\n\nThis repo is bootstrapped with the AI-SDLC framework. The single PR-ready\nmerge gate is `ai-sdlc/pr-ready` (see `.github/workflows/ai-sdlc-gate.yml`).\nRun `ai-sdlc health` to verify your local config; see\n`docs/operations/init.md` for the adopter guide.\n<!-- end ai-sdlc:recommendation-pointer -->\n";
201
+ /** Sentinel marker used by the CLAUDE.md pointer for idempotency. */
202
+ export declare const CLAUDE_MD_SENTINEL = "<!-- ai-sdlc:recommendation-pointer -->";
203
+ /**
204
+ * Append the recommendation pointer to CLAUDE.md (or create the file if
205
+ * missing). Idempotent: if the sentinel is already present we no-op.
206
+ */
207
+ export declare function ensureClaudeMdPointer(projectDir: string, adapters: Pick<FeatureAdapters, 'exists' | 'writeFile' | 'appendOnce' | 'log'>, dryRun: boolean): void;
208
+ //# sourceMappingURL=init-features.d.ts.map
@@ -0,0 +1,473 @@
1
+ /**
2
+ * `ai-sdlc init` interactive wizard + feature dispatcher (AISDLC-143).
3
+ *
4
+ * Per Q4(b) of the operator-ratified quality-gate redesign, `ai-sdlc init`
5
+ * is a wizard by default with `--yes` for non-interactive (CI/scripts) and
6
+ * `--with-X` flags for explicit opt-in (`--with-dor`, `--with-attestation`,
7
+ * `--with-classifier`, `--with-branch-protection`). This module owns:
8
+ *
9
+ * 1. The ordered prompt list (resolveFeatureSelection).
10
+ * 2. The feature-toggle → file-write dispatcher (applyFeatureSelection).
11
+ * 3. The branch-protection helper (applyBranchProtection) including the
12
+ * `--dry-run` JSON-print path required by AC #6.
13
+ * 4. The "next steps" summary printed at the end of init (AC #5).
14
+ *
15
+ * Test surface: every public function takes a small options bag with
16
+ * injectable side-effect adapters (prompter, writeFile, runCommand) so the
17
+ * test suite can drive every wizard branch hermetically without spinning
18
+ * up a TTY or shelling out to `gh`. Production callers in `init.ts` pass
19
+ * the real adapters.
20
+ */
21
+ 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';
25
+ /** All feature flags off — used as the initial state before flags + prompts. */
26
+ export const NO_FEATURES = {
27
+ dor: false,
28
+ attestation: false,
29
+ classifier: false,
30
+ branchProtection: false,
31
+ };
32
+ /** All features on — the answer used by `--yes` (accept all defaults). */
33
+ export const ALL_FEATURES = {
34
+ dor: true,
35
+ attestation: true,
36
+ classifier: true,
37
+ branchProtection: true,
38
+ };
39
+ // ── Production adapter factory ───────────────────────────────────────────
40
+ /**
41
+ * Build the production adapter bag. Pulled into a factory so tests can
42
+ * compose a partial override bag (e.g. only override `prompt`) and let
43
+ * the rest fall through to real disk writes.
44
+ *
45
+ * The `prompt` adapter is a lazy import of `@inquirer/prompts.confirm`
46
+ * so that:
47
+ * 1. Tests don't pay the import cost when they inject their own stub.
48
+ * 2. `--yes` runs (which never call `prompt`) don't pay it either.
49
+ * 3. The orchestrator's runtime `dist/` is smaller for the common case.
50
+ */
51
+ export function buildProductionAdapters() {
52
+ return {
53
+ prompt: async (question, defaultYes) => {
54
+ // Lazy import — see docblock above for why.
55
+ const { confirm } = await import('@inquirer/prompts');
56
+ return confirm({ message: question, default: defaultYes });
57
+ },
58
+ writeFile: (path, contents) => writeFileSync(path, contents, 'utf-8'),
59
+ appendOnce: (path, contents, sentinel) => {
60
+ // Make sure the parent dir exists (appendFileSync errors with ENOENT
61
+ // otherwise; we may be writing into a freshly-created `.husky/`).
62
+ mkdirSync(dirname(path), { recursive: true });
63
+ const existing = existsSync(path) ? readFileSync(path, 'utf-8') : '';
64
+ if (existing.includes(sentinel))
65
+ return 'skipped';
66
+ const sep = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
67
+ writeFileSync(path, existing + sep + contents, 'utf-8');
68
+ return 'appended';
69
+ },
70
+ mkdirp: (path) => mkdirSync(path, { recursive: true }),
71
+ exists: (path) => existsSync(path),
72
+ runCommand: (cmd, args) => {
73
+ try {
74
+ // Use `execFileSync` (no shell) so args are passed as a true
75
+ // argv array — never word-split or shell-interpreted. The prior
76
+ // `execSync(\`${cmd} ${args.join(' ')}\`)` form ran the command
77
+ // through `/bin/sh -c` and silently broke whenever any argument
78
+ // contained whitespace (e.g. macOS users with `~/Documents/My
79
+ // Project/` in their projectDir, where the `--input <tmpPath>`
80
+ // arg to `gh api` would word-split and `gh` would see two
81
+ // unrelated tokens — branch protection would silently fail or
82
+ // apply wrong content). Switching to `execFileSync` eliminates
83
+ // both word-splitting AND any shell-injection surface in one
84
+ // change. Stderr stays muted so users still get the clean
85
+ // single-line error rendered by `applyBranchProtection`.
86
+ const stdout = execFileSync(cmd, args, {
87
+ encoding: 'utf-8',
88
+ stdio: ['ignore', 'pipe', 'ignore'],
89
+ });
90
+ return { stdout, exitCode: 0 };
91
+ }
92
+ catch (err) {
93
+ const e = err;
94
+ return {
95
+ stdout: typeof e.stdout === 'string' ? e.stdout : (e.stdout?.toString() ?? ''),
96
+ exitCode: e.status ?? 1,
97
+ };
98
+ }
99
+ },
100
+ log: (line) => console.log(line),
101
+ };
102
+ }
103
+ // ── Wizard ───────────────────────────────────────────────────────────────
104
+ /**
105
+ * Resolve the per-feature on/off vector by combining (in priority order):
106
+ * 1. `--add <feature>` — if set, ONLY that feature is on; everything
107
+ * else is suppressed (idempotent extension, AC #7).
108
+ * 2. `--yes` — accept every default (every feature on).
109
+ * 3. `--with-X` flags — opt-in without prompting.
110
+ * 4. Interactive prompts for any feature still undetermined.
111
+ *
112
+ * Returns a fully-determined FeatureSelection. The dispatcher then writes
113
+ * exactly the union of features marked true.
114
+ */
115
+ export async function resolveFeatureSelection(flags, adapters) {
116
+ // ── Path 1: `--add` — single-feature extension ────────────────────────
117
+ if (flags.add) {
118
+ const sel = { ...NO_FEATURES };
119
+ switch (flags.add) {
120
+ case 'dor':
121
+ sel.dor = true;
122
+ break;
123
+ case 'attestation':
124
+ sel.attestation = true;
125
+ break;
126
+ case 'classifier':
127
+ sel.classifier = true;
128
+ break;
129
+ case 'branch-protection':
130
+ sel.branchProtection = true;
131
+ break;
132
+ }
133
+ return sel;
134
+ }
135
+ // ── Path 2: `--yes` — all defaults on, no prompts ─────────────────────
136
+ if (flags.yes) {
137
+ return { ...ALL_FEATURES };
138
+ }
139
+ // ── Path 3+4: `--with-X` overrides + prompts for the rest ─────────────
140
+ const sel = { ...NO_FEATURES };
141
+ // DoR
142
+ if (flags.withDor) {
143
+ sel.dor = true;
144
+ }
145
+ else {
146
+ sel.dor = await adapters.prompt('Will this repo use Definition-of-Ready gates?', true);
147
+ }
148
+ // Attestation
149
+ if (flags.withAttestation) {
150
+ sel.attestation = true;
151
+ }
152
+ else {
153
+ sel.attestation = await adapters.prompt('Do you want attestation infrastructure (audit-only)?', true);
154
+ }
155
+ // Classifier
156
+ if (flags.withClassifier) {
157
+ sel.classifier = true;
158
+ }
159
+ else {
160
+ sel.classifier = await adapters.prompt('Add review classifier for cost-optimized reviews?', true);
161
+ }
162
+ // Branch protection
163
+ if (flags.withBranchProtection) {
164
+ sel.branchProtection = true;
165
+ }
166
+ else {
167
+ sel.branchProtection = await adapters.prompt('Apply recommended branch protection? (required: ai-sdlc/pr-ready + codecov/patch)', true);
168
+ }
169
+ return sel;
170
+ }
171
+ /**
172
+ * Write the union of feature templates into the project dir. AC #4 says
173
+ * the BASELINE workflow templates (gate workflow) are always written; the
174
+ * per-feature template sets are written only when their toggle is on.
175
+ *
176
+ * Idempotent: any file that already exists at the target path is skipped
177
+ * with a "skip" log line. This is what makes `--add <feature>` safe to
178
+ * run on an already-initialized repo (AC #7).
179
+ */
180
+ export async function applyFeatureSelection(projectDir, selection, flags, adapters) {
181
+ const result = { created: [], skipped: [], wouldCreate: [] };
182
+ // Build the union of templates to write.
183
+ const templateSets = [];
184
+ // `--add` mode: skip the baseline (we're EXTENDING an existing init).
185
+ if (!flags.add) {
186
+ templateSets.push(BASELINE_WORKFLOW_TEMPLATES);
187
+ }
188
+ if (selection.dor)
189
+ templateSets.push(DOR_TEMPLATES);
190
+ if (selection.attestation)
191
+ templateSets.push(ATTESTATION_TEMPLATES);
192
+ if (selection.classifier)
193
+ templateSets.push(CLASSIFIER_TEMPLATES);
194
+ for (const set of templateSets) {
195
+ for (const [relPath, contents] of Object.entries(set.files)) {
196
+ const absPath = join(projectDir, relPath);
197
+ if (flags.dryRun) {
198
+ result.wouldCreate.push(relPath);
199
+ adapters.log(` would create ${relPath}`);
200
+ continue;
201
+ }
202
+ if (adapters.exists(absPath)) {
203
+ result.skipped.push(relPath);
204
+ adapters.log(` skip ${relPath} (already exists)`);
205
+ continue;
206
+ }
207
+ // mkdir -p the parent
208
+ adapters.mkdirp(dirname(absPath));
209
+ adapters.writeFile(absPath, contents);
210
+ result.created.push(relPath);
211
+ adapters.log(` created ${relPath}`);
212
+ }
213
+ }
214
+ // Husky pre-push sign hook is a separate concern from the
215
+ // FeatureTemplateSet because it's an APPEND (not a write-from-empty)
216
+ // — adopters often already have a .husky/pre-push from their existing
217
+ // tooling and we don't want to clobber it. Only fired when attestation
218
+ // is on.
219
+ if (selection.attestation && !flags.dryRun) {
220
+ const hookPath = join(projectDir, '.husky', 'pre-push');
221
+ if (!adapters.exists(hookPath)) {
222
+ // No existing hook — write a minimal one with the sign block.
223
+ adapters.mkdirp(dirname(hookPath));
224
+ adapters.writeFile(hookPath, `#!/usr/bin/env bash\nset -euo pipefail\n\n${HUSKY_PREPUSH_SIGN_SNIPPET}`);
225
+ result.created.push('.husky/pre-push');
226
+ adapters.log(` created .husky/pre-push`);
227
+ }
228
+ else {
229
+ const status = adapters.appendOnce(hookPath, HUSKY_PREPUSH_SIGN_SNIPPET, '# ai-sdlc:attestation-sign-block');
230
+ if (status === 'appended') {
231
+ adapters.log(` updated .husky/pre-push (appended sign block)`);
232
+ }
233
+ else {
234
+ result.skipped.push('.husky/pre-push');
235
+ adapters.log(` skip .husky/pre-push (sign block already present)`);
236
+ }
237
+ }
238
+ }
239
+ else if (selection.attestation && flags.dryRun) {
240
+ result.wouldCreate.push('.husky/pre-push');
241
+ adapters.log(' would update .husky/pre-push (sign block)');
242
+ }
243
+ // Branch protection (always last — depends on the gate workflow being
244
+ // present so the required check exists when the rule is applied).
245
+ if (selection.branchProtection) {
246
+ result.branchProtection = await applyBranchProtection(projectDir, flags, adapters);
247
+ }
248
+ return result;
249
+ }
250
+ /**
251
+ * Recommended branch-protection ruleset for AI-SDLC adopters. AC #1 #4:
252
+ * the required checks are `ai-sdlc/pr-ready` (the gate aggregator) and
253
+ * `codecov/patch` (the de facto coverage signal). Other AI-SDLC apps
254
+ * post their own statuses but they're all rolled into pr-ready.
255
+ *
256
+ * The body conforms to the GitHub REST API
257
+ * `PUT /repos/{owner}/{repo}/branches/{branch}/protection` schema.
258
+ */
259
+ export const RECOMMENDED_BRANCH_PROTECTION_BODY = {
260
+ required_status_checks: {
261
+ strict: true,
262
+ contexts: ['ai-sdlc/pr-ready', 'codecov/patch'],
263
+ },
264
+ enforce_admins: false,
265
+ required_pull_request_reviews: {
266
+ dismiss_stale_reviews: true,
267
+ require_code_owner_reviews: false,
268
+ required_approving_review_count: 1,
269
+ },
270
+ restrictions: null,
271
+ allow_force_pushes: false,
272
+ allow_deletions: false,
273
+ };
274
+ /**
275
+ * Apply (or print, in dry-run) the recommended branch protection rule
276
+ * to the `main` branch of the repo at `projectDir`. AC #6 explicitly
277
+ * requires that `--dry-run` print the JSON without applying.
278
+ *
279
+ * The repo identity (`owner/repo`) is resolved by shelling out to
280
+ * `gh repo view --json nameWithOwner -q .nameWithOwner`. We could parse
281
+ * the git remote ourselves (see git-remote.ts) but `gh` already resolves
282
+ * forks + renames + custom default branches consistently, and the user
283
+ * needs `gh` on PATH for the PUT to work anyway.
284
+ */
285
+ export async function applyBranchProtection(projectDir, flags, adapters) {
286
+ const bodyJson = JSON.stringify(RECOMMENDED_BRANCH_PROTECTION_BODY, null, 2);
287
+ if (flags.dryRun) {
288
+ adapters.log('');
289
+ adapters.log('Branch-protection dry-run — would PUT the following body:');
290
+ adapters.log(bodyJson);
291
+ adapters.log('');
292
+ adapters.log(' endpoint: PUT /repos/{owner}/{repo}/branches/main/protection');
293
+ adapters.log(' apply with: gh api -X PUT repos/{owner}/{repo}/branches/main/protection ...');
294
+ return { applied: false, bodyJson };
295
+ }
296
+ // Resolve owner/repo via gh.
297
+ const ownerRepo = adapters.runCommand('gh', [
298
+ 'repo',
299
+ 'view',
300
+ '--json',
301
+ 'nameWithOwner',
302
+ '-q',
303
+ '.nameWithOwner',
304
+ ]);
305
+ if (ownerRepo.exitCode !== 0) {
306
+ return {
307
+ applied: false,
308
+ bodyJson,
309
+ error: `gh repo view failed: ${ownerRepo.stdout.trim() || 'unknown error'}`,
310
+ };
311
+ }
312
+ const slug = ownerRepo.stdout.trim();
313
+ if (!slug) {
314
+ return {
315
+ applied: false,
316
+ bodyJson,
317
+ error: 'gh repo view returned empty owner/repo',
318
+ };
319
+ }
320
+ // Use the file-based input form so we don't have to thread quoted JSON
321
+ // through a shell. We write to a tmpfile, point gh at it, and let the
322
+ // adapter's runCommand spawn `gh` directly.
323
+ const tmpPath = join(projectDir, '.ai-sdlc', 'branch-protection-body.json');
324
+ // adapters.runCommand can't write files, so we use the underlying
325
+ // primitives directly here — branch protection is a one-shot operation
326
+ // and doesn't need to be hermetic in the same way as the file writes.
327
+ // Tests inject a stub that intercepts the runCommand call and never
328
+ // touches this path. See test for the contract.
329
+ try {
330
+ mkdirSync(dirname(tmpPath), { recursive: true });
331
+ writeFileSync(tmpPath, bodyJson, 'utf-8');
332
+ }
333
+ catch (err) {
334
+ return {
335
+ applied: false,
336
+ bodyJson,
337
+ error: `failed to stage branch-protection body: ${err.message}`,
338
+ };
339
+ }
340
+ const apply = adapters.runCommand('gh', [
341
+ 'api',
342
+ '-X',
343
+ 'PUT',
344
+ `repos/${slug}/branches/main/protection`,
345
+ '--input',
346
+ tmpPath,
347
+ ]);
348
+ if (apply.exitCode !== 0) {
349
+ return {
350
+ applied: false,
351
+ bodyJson,
352
+ error: `gh api PUT failed: ${apply.stdout.trim() || 'unknown error'}`,
353
+ };
354
+ }
355
+ adapters.log(` applied branch protection to ${slug}:main`);
356
+ return { applied: true, bodyJson };
357
+ }
358
+ // ── Next-steps summary ───────────────────────────────────────────────────
359
+ /**
360
+ * Print the structured "next steps" summary at the end of init. AC #5:
361
+ * the summary must include operator action items conditional on which
362
+ * features were chosen (e.g. `gh secret set` commands when attestation
363
+ * was opted in).
364
+ *
365
+ * Returns the rendered summary as a string in addition to logging it,
366
+ * so tests can assert on it without re-stringifying console output.
367
+ */
368
+ export function renderNextSteps(selection, result, adapters) {
369
+ const lines = [];
370
+ lines.push('');
371
+ lines.push('━━━ Next steps ━━━');
372
+ lines.push('');
373
+ // Always present (baseline gate)
374
+ lines.push('1. Commit the scaffolded files:');
375
+ lines.push(' git add .ai-sdlc .github/workflows package.json');
376
+ lines.push(' git commit -m "chore: bootstrap AI-SDLC config"');
377
+ lines.push('');
378
+ let stepN = 2;
379
+ if (selection.dor) {
380
+ lines.push(`${stepN}. DoR (Definition-of-Ready) is in WARN-ONLY mode by default.`);
381
+ lines.push(' Tune .ai-sdlc/dor-config.yaml then flip evaluationMode: enforce');
382
+ lines.push(' after a soak window confirms the false-positive rate is low.');
383
+ lines.push('');
384
+ stepN++;
385
+ }
386
+ if (selection.attestation) {
387
+ lines.push(`${stepN}. Attestation infrastructure was scaffolded in AUDIT-ONLY mode.`);
388
+ lines.push(' a) Bootstrap your signing key: /ai-sdlc init-signing-key');
389
+ lines.push(' b) Open a PR adding the printed YAML block to');
390
+ lines.push(' .ai-sdlc/trusted-reviewers.yaml');
391
+ // AISDLC-152: removed the optional CI-side signer step (the AISDLC-87
392
+ // CI-attestor was retired in AISDLC-140 sub-4 alongside attestation
393
+ // becoming audit-only). New adopters no longer need to provision the
394
+ // AI_SDLC_CI_ATTESTOR_PRIVATE_KEY secret.
395
+ lines.push('');
396
+ stepN++;
397
+ }
398
+ if (selection.classifier) {
399
+ lines.push(`${stepN}. Review classifier config was scaffolded.`);
400
+ lines.push(' The classifier RUNTIME ships in AISDLC-141 (follow-up). Until');
401
+ lines.push(' that lands, .ai-sdlc/review-classifier.yaml is advisory only.');
402
+ lines.push('');
403
+ stepN++;
404
+ }
405
+ if (selection.branchProtection) {
406
+ if (result.branchProtection?.applied) {
407
+ lines.push(`${stepN}. Branch protection on \`main\` was updated.`);
408
+ lines.push(' Required checks: ai-sdlc/pr-ready, codecov/patch');
409
+ }
410
+ else if (result.branchProtection?.error) {
411
+ lines.push(`${stepN}. Branch protection was NOT applied:`);
412
+ lines.push(` ${result.branchProtection.error}`);
413
+ lines.push(' After resolving (gh auth login, repo permissions, etc.) re-run:');
414
+ lines.push(' ai-sdlc init --add branch-protection');
415
+ }
416
+ else {
417
+ lines.push(`${stepN}. Branch protection (dry-run) — see JSON above; apply with:`);
418
+ lines.push(' ai-sdlc init --add branch-protection');
419
+ }
420
+ lines.push('');
421
+ stepN++;
422
+ }
423
+ lines.push(`${stepN}. Verify your configuration: ai-sdlc health`);
424
+ lines.push('');
425
+ lines.push('Adopter docs: https://github.com/ai-sdlc-framework/ai-sdlc/blob/main/docs/operations/init.md');
426
+ const out = lines.join('\n');
427
+ for (const line of lines)
428
+ adapters.log(line);
429
+ return out;
430
+ }
431
+ // ── CLAUDE.md recommendation pointer (AC #4) ─────────────────────────────
432
+ /**
433
+ * The pointer block we append to CLAUDE.md so a freshly-initialized repo's
434
+ * Claude Code sessions know where to find the AI-SDLC quality-gate docs.
435
+ * Idempotent — guarded by a sentinel so re-running init doesn't duplicate
436
+ * the block.
437
+ */
438
+ export const CLAUDE_MD_POINTER = `
439
+ <!-- ai-sdlc:recommendation-pointer -->
440
+ ## AI-SDLC quality gate
441
+
442
+ This repo is bootstrapped with the AI-SDLC framework. The single PR-ready
443
+ merge gate is \`ai-sdlc/pr-ready\` (see \`.github/workflows/ai-sdlc-gate.yml\`).
444
+ Run \`ai-sdlc health\` to verify your local config; see
445
+ \`docs/operations/init.md\` for the adopter guide.
446
+ <!-- end ai-sdlc:recommendation-pointer -->
447
+ `;
448
+ /** Sentinel marker used by the CLAUDE.md pointer for idempotency. */
449
+ export const CLAUDE_MD_SENTINEL = '<!-- ai-sdlc:recommendation-pointer -->';
450
+ /**
451
+ * Append the recommendation pointer to CLAUDE.md (or create the file if
452
+ * missing). Idempotent: if the sentinel is already present we no-op.
453
+ */
454
+ export function ensureClaudeMdPointer(projectDir, adapters, dryRun) {
455
+ const path = join(projectDir, 'CLAUDE.md');
456
+ if (dryRun) {
457
+ adapters.log(' would update CLAUDE.md (recommendation pointer)');
458
+ return;
459
+ }
460
+ if (!adapters.exists(path)) {
461
+ adapters.writeFile(path, `# Project instructions\n${CLAUDE_MD_POINTER}`);
462
+ adapters.log(' created CLAUDE.md');
463
+ return;
464
+ }
465
+ const status = adapters.appendOnce(path, CLAUDE_MD_POINTER, CLAUDE_MD_SENTINEL);
466
+ if (status === 'appended') {
467
+ adapters.log(' updated CLAUDE.md (recommendation pointer)');
468
+ }
469
+ else {
470
+ adapters.log(' skip CLAUDE.md (recommendation pointer already present)');
471
+ }
472
+ }
473
+ //# sourceMappingURL=init-features.js.map