mandrel 1.69.0 → 1.70.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 (35) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/docs/workflows.md +1 -1
  3. package/.agents/scripts/agents-update-preflight.js +235 -0
  4. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  5. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  6. package/.agents/scripts/audit-to-stories.js +54 -0
  7. package/.agents/scripts/bootstrap.js +13 -3
  8. package/.agents/scripts/generate-config-docs.js +189 -94
  9. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  10. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  11. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  12. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  13. package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
  14. package/.agents/scripts/lib/config/baselines.js +0 -20
  15. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  16. package/.agents/scripts/lib/crap-utils.js +281 -0
  17. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  18. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
  19. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  20. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  21. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  22. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  23. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  24. package/.agents/scripts/lib/story-body/story-body.js +110 -65
  25. package/.agents/scripts/lib/test-tiers.js +13 -7
  26. package/.agents/scripts/lib/wave-runner/tick.js +177 -53
  27. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  28. package/.agents/scripts/providers/github/issues.js +48 -0
  29. package/.agents/scripts/providers/github.js +1 -0
  30. package/.agents/workflows/agents-update.md +205 -28
  31. package/README.md +20 -0
  32. package/docs/CHANGELOG.md +32 -0
  33. package/lib/cli/registry.js +49 -6
  34. package/lib/cli/update.js +335 -332
  35. package/package.json +16 -11
package/.agents/README.md CHANGED
@@ -466,7 +466,7 @@ ticketing provider is GitHub, resolved by `provider-factory.js` from the
466
466
  `orchestration.provider` config key. CLI scripts receive provider
467
467
  instances from the SDK surface rather than importing provider
468
468
  implementations directly. Execution is Claude-Code-in-session — there is
469
- no separate adapter abstraction; `wave-dispatcher.js` synthesizes the
469
+ no separate adapter abstraction; `manifest-builder.js` synthesizes the
470
470
  dispatch record inline and the dispatch manifest (md + structured
471
471
  comment) is the cross-runtime contract.
472
472
 
@@ -29,7 +29,7 @@ description, edit the workflow file’s front-matter and regenerate.
29
29
 
30
30
  | Command | Description |
31
31
  | --- | --- |
32
- | `/agents-update` | npm-era upgrade wraparound for a Mandrel consumer. Runs `mandrel update` (resolve newest published version → install → re-materialize `.agents/` → migrate → doctor → surface changelog) as the single mechanical step, then walks the operator through the judgment wraparound the CLI deliberately leaves unowned: reconcile `.agentrc.json`, install the Epic #1386 quality-gate surface, refresh the harness permission allowlist, reconcile the consumer's `AGENTS.md` / runbooks against the surfaced changelog, and stage + commit the staged lockfile bump. |
32
+ | `/agents-update` | npm-era upgrade wraparound for a Mandrel consumer. Runs `npx mandrel update` (resolve newest published version → install → re-materialize `.agents/` → migrate → doctor → surface changelog) as the single mechanical step, then walks the operator through the judgment wraparound the CLI deliberately leaves unowned: reconcile `.agentrc.json`, install the Epic #1386 quality-gate surface, refresh the harness permission allowlist, reconcile the consumer's `AGENTS.md` / runbooks against the surfaced changelog, and stage + commit the staged lockfile bump. |
33
33
  | `/audit-architecture` | Audit architectural boundaries, module coupling, and layering violations; emit a structured findings report keyed to High/Medium/Low severity. |
34
34
  | `/audit-clean-code` | Audit code smells, dead code, complexity hotspots, and maintainability-index outliers; emit a structured findings report. |
35
35
  | `/audit-dependencies` | Audit `package.json` for unused, outdated, and major-version-stale dependencies; surface Node-engine drift and propose upgrade batches. |
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * agents-update-preflight.js — Story #4170
5
+ * (feat(agents-update): add a first-run preflight before the updater)
6
+ *
7
+ * A first-run preflight for the `/agents-update` workflow. The workflow
8
+ * otherwise jumps straight to `npx mandrel update` with no guard rails;
9
+ * this preflight catches three day-0 failure modes *before* the version
10
+ * bump:
11
+ *
12
+ * 1. **Wrong project (hard stop).** Running the updater in the framework
13
+ * repo itself or in a non-consumer (no `mandrel` dependency in
14
+ * `package.json`, or no materialized `.agents/` directory) silently
15
+ * does the wrong thing. The consumer-shape check is a BLOCKER: it
16
+ * exits non-zero so the workflow halts before bumping anything.
17
+ * 2. **Dirty git index (warn).** `mandrel update` deliberately leaves the
18
+ * lockfile *staged* (see `lib/cli/update.js`); the workflow's commit
19
+ * step then `git add`s and commits. If the index already holds
20
+ * unrelated staged changes, that step would sweep them into the
21
+ * `chore: update mandrel` commit. Warn so the operator can unstage
22
+ * first.
23
+ * 3. **Offline (warn).** The CLI throws a usable error on a failed
24
+ * `npm view`, but a one-line up-front reachability check gives a
25
+ * friendlier "you're offline" signal before any version probe.
26
+ *
27
+ * Severity is consistent with framework preflight conventions
28
+ * (cf. `story-close.js` runStoryClosePreflight, `epic-deliver-preflight.js`):
29
+ * the consumer-shape check is a hard stop (blocker), dirty-index and offline
30
+ * are warn-only and never block the run.
31
+ *
32
+ * Out of scope (per the Story): folding these checks into
33
+ * `lib/cli/update.js` itself — the CLI stays git-free and
34
+ * side-effect-scoped; this is a workflow-layer concern.
35
+ *
36
+ * The detection logic is a pure function (`runAgentsUpdatePreflight`) that
37
+ * takes injectable probes so it is unit-testable without touching the real
38
+ * filesystem, git index, or network. The CLI wrapper wires the real probes
39
+ * and maps a blocker finding to a non-zero exit code.
40
+ */
41
+
42
+ import { execFileSync } from 'node:child_process';
43
+ import { existsSync, readFileSync } from 'node:fs';
44
+ import path from 'node:path';
45
+ import { runAsCli } from './lib/cli-utils.js';
46
+ import { Logger } from './lib/Logger.js';
47
+
48
+ /**
49
+ * @typedef {object} PreflightFinding
50
+ * @property {string} id Stable check id.
51
+ * @property {'blocker'|'warning'} severity
52
+ * @property {string} summary One-line human-readable description.
53
+ * @property {string} [fix] Copy-pasteable remediation hint.
54
+ */
55
+
56
+ /**
57
+ * Real-world probes. Each is overridable in tests via the `probes` option.
58
+ *
59
+ * @param {string} projectRoot Absolute consumer repo root.
60
+ */
61
+ export function makeProbes(projectRoot) {
62
+ return {
63
+ /**
64
+ * Read + parse `package.json`. Returns the parsed object, or `null`
65
+ * when the file is missing or unparseable.
66
+ */
67
+ readPackageJson() {
68
+ const pkgPath = path.join(projectRoot, 'package.json');
69
+ if (!existsSync(pkgPath)) return null;
70
+ try {
71
+ return JSON.parse(readFileSync(pkgPath, 'utf8'));
72
+ } catch {
73
+ return null;
74
+ }
75
+ },
76
+ /** Does the materialized `.agents/` directory exist? */
77
+ agentsDirExists() {
78
+ return existsSync(path.join(projectRoot, '.agents'));
79
+ },
80
+ /**
81
+ * Are there staged (index) changes? Returns true when
82
+ * `git diff --cached --name-only` is non-empty. Returns false on any
83
+ * git error (not a repo, git missing) — a missing index is not a
84
+ * dirty index.
85
+ */
86
+ hasStagedChanges() {
87
+ try {
88
+ const out = execFileSync('git', ['diff', '--cached', '--name-only'], {
89
+ cwd: projectRoot,
90
+ encoding: 'utf8',
91
+ stdio: ['ignore', 'pipe', 'ignore'],
92
+ });
93
+ return out.trim().length > 0;
94
+ } catch {
95
+ return false;
96
+ }
97
+ },
98
+ /**
99
+ * Is the npm registry reachable? Probes `npm ping` (a PM-agnostic
100
+ * reachability query that does not mutate anything). Returns true when
101
+ * the ping succeeds, false on any failure (offline / registry down).
102
+ */
103
+ registryReachable() {
104
+ try {
105
+ execFileSync('npm', ['ping'], {
106
+ cwd: projectRoot,
107
+ encoding: 'utf8',
108
+ stdio: ['ignore', 'ignore', 'ignore'],
109
+ timeout: 10_000,
110
+ });
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Pure preflight evaluator. Runs the three checks against the supplied
121
+ * probes and returns a structured result. Never throws; never performs
122
+ * I/O directly (all I/O is behind `probes`).
123
+ *
124
+ * @param {object} options
125
+ * @param {ReturnType<typeof makeProbes>} options.probes
126
+ * @returns {{ ok: boolean, blocked: boolean, findings: PreflightFinding[] }}
127
+ * `blocked` is true when any blocker-severity finding fired (the
128
+ * consumer-shape hard stop). `ok` is true when there are no findings of
129
+ * any severity.
130
+ */
131
+ export function runAgentsUpdatePreflight({ probes }) {
132
+ /** @type {PreflightFinding[]} */
133
+ const findings = [];
134
+
135
+ // 1. Consumer-shape check — BLOCKER (hard stop).
136
+ const pkg = probes.readPackageJson();
137
+ const deps = pkg
138
+ ? {
139
+ ...(pkg.dependencies ?? {}),
140
+ ...(pkg.devDependencies ?? {}),
141
+ ...(pkg.optionalDependencies ?? {}),
142
+ }
143
+ : {};
144
+ const hasMandrelDep = Object.hasOwn(deps, 'mandrel');
145
+ const hasAgentsDir = probes.agentsDirExists();
146
+
147
+ if (!pkg || !hasMandrelDep || !hasAgentsDir) {
148
+ const missing = [];
149
+ if (!pkg) missing.push('no readable package.json');
150
+ else if (!hasMandrelDep)
151
+ missing.push('package.json does not list "mandrel" as a dependency');
152
+ if (!hasAgentsDir) missing.push('no .agents/ directory');
153
+ findings.push({
154
+ id: 'consumer-shape',
155
+ severity: 'blocker',
156
+ summary: `Not a Mandrel consumer project (${missing.join('; ')}). Run /agents-update from a consumer repo that depends on "mandrel" and has a materialized .agents/ tree — not the framework repo itself or an unrelated project.`,
157
+ fix: 'cd into the consumer project root, or run `npm install -D mandrel && npx mandrel sync` to bootstrap one.',
158
+ });
159
+ }
160
+
161
+ // 2. Dirty-index check — WARN only.
162
+ if (probes.hasStagedChanges()) {
163
+ findings.push({
164
+ id: 'dirty-index',
165
+ severity: 'warning',
166
+ summary:
167
+ "The git index already has staged changes. `mandrel update` leaves the lockfile staged, and the workflow's commit step (Step 5) would sweep these unrelated staged files into the `chore: update mandrel` commit.",
168
+ fix: 'Unstage unrelated changes first: `git restore --staged <path>` (or `git reset` to clear the whole index), then re-run the preflight.',
169
+ });
170
+ }
171
+
172
+ // 3. Offline check — WARN only.
173
+ if (!probes.registryReachable()) {
174
+ findings.push({
175
+ id: 'offline',
176
+ severity: 'warning',
177
+ summary:
178
+ 'The npm registry is not reachable. `npx mandrel update` resolves the newest published version via the registry and will fail its version probe while offline.',
179
+ fix: 'Check your network connection (or registry auth/proxy config) before running `npx mandrel update`.',
180
+ });
181
+ }
182
+
183
+ const blocked = findings.some((f) => f.severity === 'blocker');
184
+ return { ok: findings.length === 0, blocked, findings };
185
+ }
186
+
187
+ /**
188
+ * Render the findings to a logger. Blockers go to `error`, warnings to
189
+ * `warn`. A clean result logs a single `info` line.
190
+ *
191
+ * @param {{ ok: boolean, blocked: boolean, findings: PreflightFinding[] }} result
192
+ * @param {{ info: Function, warn: Function, error: Function }} logger
193
+ */
194
+ export function reportPreflight(result, logger) {
195
+ if (result.ok) {
196
+ logger.info(
197
+ '✅ [agents-update-preflight] All checks passed — safe to run `npx mandrel update`.',
198
+ );
199
+ return;
200
+ }
201
+ for (const f of result.findings) {
202
+ const line = `[${f.id}] ${f.summary}${f.fix ? `\n ↳ Fix: ${f.fix}` : ''}`;
203
+ if (f.severity === 'blocker') {
204
+ logger.error(`❌ ${line}`);
205
+ } else {
206
+ logger.warn(`⚠️ ${line}`);
207
+ }
208
+ }
209
+ if (result.blocked) {
210
+ logger.error(
211
+ '[agents-update-preflight] Hard stop: do not run `npx mandrel update` until the blocker above is resolved (exit 2).',
212
+ );
213
+ } else {
214
+ logger.warn(
215
+ '[agents-update-preflight] Warnings only — review them, then proceed if intentional.',
216
+ );
217
+ }
218
+ }
219
+
220
+ async function main() {
221
+ const projectRoot = process.cwd();
222
+ const probes = makeProbes(projectRoot);
223
+ const result = runAgentsUpdatePreflight({ probes });
224
+ reportPreflight(result, Logger);
225
+ // Machine-parsable JSON envelope on stdout for tooling / the workflow to
226
+ // read. Use process.stdout.write (not console.log) per the no-console
227
+ // enforcement boundary: human-facing output goes through Logger above.
228
+ process.stdout.write(`${JSON.stringify(result)}\n`);
229
+ return result.blocked ? 2 : 0;
230
+ }
231
+
232
+ runAsCli(import.meta.url, main, {
233
+ source: 'agents-update-preflight',
234
+ propagateExitCode: true,
235
+ });
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * apply-quality-bootstrap.js — Story #4171
5
+ * (refactor(agents-update): extract the quality-bootstrap heredoc into a
6
+ * tested script)
7
+ *
8
+ * Replaces the inline `node -e "Promise.all([...])"` heredoc that Step 3.5 of
9
+ * the `/agents-update` workflow used to carry. That shape was fragile in three
10
+ * ways: it broke across shells (PowerShell vs bash quoting / backticks), it
11
+ * had no test so it silently drifted when the two helper signatures moved, and
12
+ * it could not be invoked or dry-run independently.
13
+ *
14
+ * This script runs the two Epic #1386 quality-gate installs in order against
15
+ * the consumer repo root:
16
+ *
17
+ * 1. `applyQualityBootstrap` — copies the code-quality-guardrails helper,
18
+ * installs the `.husky/pre-commit` quality:preview line, backfills the
19
+ * `quality:preview` / `quality:watch` npm scripts, and seeds the
20
+ * `delivery.quality.{codingGuardrails,autoRefresh}` defaults.
21
+ * 2. `migrateBaselinesLayout` — relocates per-Epic baseline snapshots into
22
+ * the `temp/epic/<id>/baselines/` namespace.
23
+ *
24
+ * Both helpers are idempotent by contract — a second run reports `no-change`
25
+ * on every install path — so this wrapper is safe to re-run. It prints the
26
+ * **same JSON result shape** the heredoc did: `{ quality, baselines }` to
27
+ * stdout, so any tooling that parsed the old output keeps working.
28
+ *
29
+ * The effectful work is a thin pure function (`applyBootstrapAndMigration`)
30
+ * that takes the two helpers and the project root, so the test suite can
31
+ * drive it against a tmp directory without spawning a child process. The CLI
32
+ * wrapper wires the real helpers and `process.cwd()`.
33
+ */
34
+
35
+ import path from 'node:path';
36
+ import { migrateBaselinesLayout } from './lib/bootstrap/baselines-layout-migration.js';
37
+ import { applyQualityBootstrap } from './lib/bootstrap/quality-bootstrap.js';
38
+ import { runAsCli } from './lib/cli-utils.js';
39
+
40
+ /**
41
+ * Run the quality-bootstrap install and the baselines-layout migration
42
+ * against `projectRoot`, returning the combined `{ quality, baselines }`
43
+ * envelope. Pure relative to its injected helpers: the default helpers touch
44
+ * the filesystem under `projectRoot`, but tests can pass stubs to exercise
45
+ * the composition in isolation.
46
+ *
47
+ * @param {object} options
48
+ * @param {string} options.projectRoot Absolute consumer repo root.
49
+ * @param {typeof applyQualityBootstrap} [options.applyQualityBootstrap]
50
+ * @param {typeof migrateBaselinesLayout} [options.migrateBaselinesLayout]
51
+ * @returns {{ quality: object, baselines: object }}
52
+ */
53
+ export function applyBootstrapAndMigration({
54
+ projectRoot,
55
+ applyQualityBootstrap: applyQuality = applyQualityBootstrap,
56
+ migrateBaselinesLayout: migrateBaselines = migrateBaselinesLayout,
57
+ }) {
58
+ const quality = applyQuality({ projectRoot });
59
+ const baselines = migrateBaselines({
60
+ baselinesDir: path.join(projectRoot, 'baselines'),
61
+ repoRoot: projectRoot,
62
+ });
63
+ return { quality, baselines };
64
+ }
65
+
66
+ async function main() {
67
+ const projectRoot = process.cwd();
68
+ const result = applyBootstrapAndMigration({ projectRoot });
69
+ // Mirror the retired heredoc's output: pretty-printed `{ quality, baselines }`
70
+ // to stdout. Use process.stdout.write (not console.log) per the no-console
71
+ // enforcement boundary.
72
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
73
+ return 0;
74
+ }
75
+
76
+ runAsCli(import.meta.url, main, {
77
+ source: 'apply-quality-bootstrap',
78
+ propagateExitCode: true,
79
+ });
@@ -1,14 +1,21 @@
1
1
  /**
2
- * audit-labels-bootstrap.js — Idempotently create the `audit::<dimension>`
2
+ * audit-labels-bootstrap.js — Idempotently create the `audit::<lens>`
3
3
  * label taxonomy in the configured GitHub repo.
4
4
  *
5
5
  * Run this once per repo before `/audit-to-stories` opens its first
6
6
  * Story. Re-runs are safe — existing labels are skipped, only missing
7
7
  * ones are created. Story #2583 acceptance criterion #6.
8
8
  *
9
- * The dimension list mirrors the 12 audit-* workflows in
10
- * `.agents/workflows/`. Adding a new audit-* workflow should also add a
11
- * corresponding entry below.
9
+ * The lens list is the shared SSOT `AUDIT_LENSES`
10
+ * (`lib/audit-to-stories/audit-lenses.js`), one per `/audit-<lens>` workflow
11
+ * under `.agents/workflows/`. Sourcing the list from the same module that
12
+ * `build-story-body.js` derives `audit::<lens>` labels from guarantees the
13
+ * label producer (this bootstrap) and the label deriver (story-body) cannot
14
+ * drift — a finding from `audit-documentation-results.md` derives
15
+ * `audit::documentation`, and this bootstrap creates exactly that label
16
+ * (Story #4195). The per-lens colour/description metadata lives in
17
+ * `LENS_META` below; adding a new `audit-*` workflow means adding its lens to
18
+ * `AUDIT_LENSES` and (optionally) a `LENS_META` entry.
12
19
  *
13
20
  * Delegates to `gh label create` so the script works without any
14
21
  * provider plumbing — `gh auth status` is the only prerequisite. Per
@@ -19,72 +26,87 @@
19
26
  import process from 'node:process';
20
27
  import { parseArgs } from 'node:util';
21
28
 
29
+ import { AUDIT_LENSES } from './lib/audit-to-stories/audit-lenses.js';
22
30
  import { runAsCli } from './lib/cli-utils.js';
23
31
  import { resolveConfig } from './lib/config-resolver.js';
24
32
  import { gh as defaultGh, GhExecError } from './lib/gh-exec.js';
25
33
 
26
- const DIMENSIONS = Object.freeze([
27
- {
28
- name: 'architecture',
34
+ /**
35
+ * Per-lens label presentation. Keyed by canonical lens name. A lens absent
36
+ * from this map falls back to {@link DEFAULT_LENS_META} so a newly-added
37
+ * `AUDIT_LENSES` entry still gets a label without a hard requirement to
38
+ * register colour/description here first.
39
+ */
40
+ const LENS_META = Object.freeze({
41
+ architecture: {
29
42
  color: '6f42c1',
30
43
  description: 'Audit-sourced finding: architectural concerns',
31
44
  },
32
- {
33
- name: 'clean-code',
45
+ 'clean-code': {
34
46
  color: '0e8a16',
35
47
  description: 'Audit-sourced finding: clean-code / maintainability',
36
48
  },
37
- {
38
- name: 'dependencies',
49
+ dependencies: {
39
50
  color: 'd4c5f9',
40
51
  description: 'Audit-sourced finding: dependencies / supply chain',
41
52
  },
42
- {
43
- name: 'devops',
53
+ devops: {
44
54
  color: 'fbca04',
45
55
  description: 'Audit-sourced finding: DevOps / CI / CD',
46
56
  },
47
- {
48
- name: 'lighthouse',
57
+ documentation: {
58
+ color: '1d76db',
59
+ description: 'Audit-sourced finding: documentation staleness / gaps',
60
+ },
61
+ lighthouse: {
49
62
  color: 'c5def5',
50
63
  description: 'Audit-sourced finding: Lighthouse score regressions',
51
64
  },
52
- {
53
- name: 'performance',
65
+ navigability: {
66
+ color: 'bfdadc',
67
+ description: 'Audit-sourced finding: route / nav reachability',
68
+ },
69
+ performance: {
54
70
  color: 'b60205',
55
71
  description: 'Audit-sourced finding: performance / latency',
56
72
  },
57
- {
58
- name: 'privacy',
73
+ privacy: {
59
74
  color: 'fef2c0',
60
75
  description: 'Audit-sourced finding: privacy / data handling',
61
76
  },
62
- {
63
- name: 'quality',
77
+ quality: {
64
78
  color: '0052cc',
65
79
  description: 'Audit-sourced finding: test quality / coverage gaps',
66
80
  },
67
- {
68
- name: 'security',
81
+ security: {
69
82
  color: 'b60205',
70
83
  description: 'Audit-sourced finding: security / OWASP',
71
84
  },
72
- {
73
- name: 'seo',
85
+ seo: {
74
86
  color: 'fbca04',
75
87
  description: 'Audit-sourced finding: SEO / discoverability',
76
88
  },
77
- {
78
- name: 'sre',
89
+ sre: {
79
90
  color: '0052cc',
80
91
  description: 'Audit-sourced finding: SRE / observability / reliability',
81
92
  },
82
- {
83
- name: 'ux-ui',
93
+ 'ux-ui': {
84
94
  color: 'd4c5f9',
85
95
  description: 'Audit-sourced finding: UX / UI concerns',
86
96
  },
87
- ]);
97
+ });
98
+
99
+ const DEFAULT_LENS_META = Object.freeze({
100
+ color: 'ededed',
101
+ description: 'Audit-sourced finding',
102
+ });
103
+
104
+ const DIMENSIONS = Object.freeze(
105
+ AUDIT_LENSES.map((name) => ({
106
+ name,
107
+ ...(LENS_META[name] ?? DEFAULT_LENS_META),
108
+ })),
109
+ );
88
110
 
89
111
  async function labelExists(gh, owner, repo, name) {
90
112
  try {
@@ -39,6 +39,7 @@ import { groupFindings } from './lib/audit-to-stories/group-findings.js';
39
39
  import { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
40
40
  import { buildEpicSeedMarkdown } from './lib/audit-to-stories/seed-epic-from-findings.js';
41
41
  import { runAsCli } from './lib/cli-utils.js';
42
+ import { Logger } from './lib/Logger.js';
42
43
 
43
44
  const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
44
45
  const DEFAULT_GLOB = 'temp/audits/audit-*-results.md';
@@ -109,6 +110,44 @@ async function loadProvider() {
109
110
  }
110
111
  }
111
112
 
113
+ /**
114
+ * Render the loud, operator-visible warning emitted when the Phase 6 dedup
115
+ * does NOT run against real GitHub issues. Two distinct reasons:
116
+ *
117
+ * - `'no-provider-port'` — the configured provider resolved but exposes no
118
+ * `searchIssues` port (or `loadProvider()` threw). This is the
119
+ * silent-no-op the workflow's "Never open a duplicate Issue" contract
120
+ * was failing on: every group classifies `create` and the operator gets
121
+ * zero automated dedup signal. Surfacing it loudly is the whole point.
122
+ * - `'disabled'` — the operator passed `--no-provider`, intentionally
123
+ * skipping dedup. Still warned (so a re-run that opens duplicates is
124
+ * never a surprise), but framed as a deliberate choice.
125
+ *
126
+ * Pure: returns the message string so `buildPlan` owns the single
127
+ * `Logger.warn` write site and the text stays unit-testable.
128
+ *
129
+ * @param {'no-provider-port'|'disabled'} reason
130
+ * @returns {string}
131
+ */
132
+ function dedupSkippedWarning(reason) {
133
+ if (reason === 'disabled') {
134
+ return (
135
+ 'dedup skipped (--no-provider): every group is classified "create" ' +
136
+ 'without checking GitHub for existing issues. A re-run may open ' +
137
+ 'duplicates of already-tracked or already-closed findings. Drop ' +
138
+ '--no-provider to enable fingerprint dedup against real issues.'
139
+ );
140
+ }
141
+ return (
142
+ 'dedup skipped (no provider port): the configured provider exposes no ' +
143
+ 'searchIssues() port, so Phase 6 dedup did NOT run. Every group is ' +
144
+ 'classified "create" and existing/closed issues are NOT checked — a run ' +
145
+ 'that creates Stories from this plan WILL open duplicates of ' +
146
+ 'already-tracked work. Verify `gh auth status` and the github.{owner,repo} ' +
147
+ 'config so a real provider resolves.'
148
+ );
149
+ }
150
+
112
151
  async function buildPlan({ glob: pattern, severity, useProvider }) {
113
152
  const reportPaths = await collectReportPaths(pattern ?? DEFAULT_GLOB);
114
153
  if (reportPaths.length === 0) {
@@ -143,6 +182,7 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
143
182
  matchedFingerprints: [],
144
183
  }));
145
184
  let summary = { create: groups.length, skipOpen: 0, skipReoccurring: 0 };
185
+ let dedupApplied = false;
146
186
 
147
187
  if (useProvider) {
148
188
  const provider = await loadProvider();
@@ -150,7 +190,18 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
150
190
  const result = await classifyGroupsAgainstGitHub({ groups, provider });
151
191
  classifications = result.classifications;
152
192
  summary = result.summary;
193
+ dedupApplied = true;
194
+ } else {
195
+ // The provider could not resolve a searchIssues port — the dedup gate
196
+ // is silently a no-op without this. Surface it loudly (stderr, so the
197
+ // --scan JSON on stdout stays clean) so the operator does not read a
198
+ // create-only plan as "no duplicates found".
199
+ Logger.warn(dedupSkippedWarning('no-provider-port'));
153
200
  }
201
+ } else {
202
+ // Operator explicitly opted out via --no-provider. Still warn so a
203
+ // duplicate-opening re-run is never a surprise.
204
+ Logger.warn(dedupSkippedWarning('disabled'));
154
205
  }
155
206
 
156
207
  return {
@@ -165,6 +216,7 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
165
216
  totalFindings: allFindings.length,
166
217
  filtered: filtered.length,
167
218
  tally: tallyBySeverity(filtered),
219
+ dedupApplied,
168
220
  ...summary,
169
221
  },
170
222
  };
@@ -188,6 +240,8 @@ export const __testing = {
188
240
  meetsSeverity,
189
241
  collectReportPaths,
190
242
  buildPlan,
243
+ loadProvider,
244
+ dedupSkippedWarning,
191
245
  };
192
246
 
193
247
  async function main() {
@@ -534,6 +534,13 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
534
534
  const reposList = lists.reposList;
535
535
  const projectsList = lists.projectsList;
536
536
  const pickerOwner = (answers) => answers?.owner || owner;
537
+ // `owner` / `repo` are GitHub-side answers. When `--skip-github` suppresses
538
+ // the entire GitHub bootstrap, they are not required — this lets a
539
+ // non-interactive `--assume-yes --skip-github` run materialize and configure
540
+ // a fresh non-git directory (no inferable remote) without hard-failing on
541
+ // `missing required answers: owner, repo`. With GitHub bootstrap active they
542
+ // remain required (the target repo must be resolvable).
543
+ const skipGithub = Boolean(flags?.['skip-github']);
537
544
  return [
538
545
  {
539
546
  key: 'owner',
@@ -541,7 +548,7 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
541
548
  env: 'GH_OWNER',
542
549
  message: '\n\nGitHub repo owner',
543
550
  default: defaults.owner,
544
- required: true,
551
+ required: !skipGithub,
545
552
  validate: (v) =>
546
553
  /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(v) ? null : 'Invalid GitHub owner',
547
554
  },
@@ -567,7 +574,7 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
567
574
  pickerMessage:
568
575
  'GitHub repo name - Select existing or press ENTER to create',
569
576
  default: defaults.repo,
570
- required: true,
577
+ required: !skipGithub,
571
578
  picker: {
572
579
  list: (answers) => {
573
580
  if (Array.isArray(reposList) && reposList.length > 0)
@@ -957,7 +964,10 @@ export async function collectAndConfirm(state) {
957
964
  });
958
965
  if (missing.length > 0) {
959
966
  Logger.error(
960
- `[Bootstrap] missing required answers: ${missing.join(', ')}`,
967
+ `[Bootstrap] missing required answers: ${missing.join(', ')}. ` +
968
+ 'Pass them as flags (e.g. `--owner <name> --repo <name>`), or run ' +
969
+ 'with `--skip-github` to configure the files/local setup only and ' +
970
+ 'wire GitHub later.',
961
971
  );
962
972
  return { ok: false, exit: 1 };
963
973
  }