mandrel 1.68.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 (75) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/docs/agentrc-reference.json +1 -2
  3. package/.agents/docs/configuration.md +2 -4
  4. package/.agents/docs/workflows.md +1 -1
  5. package/.agents/schemas/agentrc.schema.json +1 -5
  6. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  7. package/.agents/scripts/agents-update-preflight.js +235 -0
  8. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  9. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  10. package/.agents/scripts/audit-to-stories.js +54 -0
  11. package/.agents/scripts/bootstrap.js +13 -3
  12. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  13. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  14. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  15. package/.agents/scripts/generate-config-docs.js +189 -94
  16. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  17. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  18. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  19. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  20. package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
  21. package/.agents/scripts/lib/config/baselines.js +0 -20
  22. package/.agents/scripts/lib/config/explain.js +0 -2
  23. package/.agents/scripts/lib/config/limits.js +19 -8
  24. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  25. package/.agents/scripts/lib/config-settings-schema.js +1 -2
  26. package/.agents/scripts/lib/crap-utils.js +281 -0
  27. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  28. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  29. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  30. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  31. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  32. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  33. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
  34. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  35. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  36. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  37. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  38. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  39. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  40. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  41. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  42. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  43. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  44. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  45. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  46. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  47. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  48. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  49. package/.agents/scripts/lib/story-adjacency.js +14 -10
  50. package/.agents/scripts/lib/story-body/story-body.js +142 -65
  51. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  52. package/.agents/scripts/lib/test-tiers.js +13 -7
  53. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  54. package/.agents/scripts/lib/wave-runner/tick.js +446 -216
  55. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  56. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  57. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  58. package/.agents/scripts/providers/github/issues.js +48 -0
  59. package/.agents/scripts/providers/github.js +1 -0
  60. package/.agents/scripts/stories-wave-tick.js +262 -161
  61. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  62. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  63. package/.agents/skills/skills.index.json +2 -2
  64. package/.agents/workflows/agents-update.md +205 -28
  65. package/.agents/workflows/deliver.md +12 -9
  66. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  67. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  68. package/.agents/workflows/helpers/plan-epic.md +13 -10
  69. package/.agents/workflows/plan.md +1 -1
  70. package/README.md +20 -0
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +49 -6
  73. package/lib/cli/update.js +335 -332
  74. package/package.json +16 -11
  75. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
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
 
@@ -59,7 +59,6 @@
59
59
  }
60
60
  },
61
61
  "planning": {
62
- "maxTickets": 60,
63
62
  "context": {
64
63
  "maxBytes": 50000,
65
64
  "summaryMode": "auto"
@@ -91,7 +90,7 @@
91
90
  "Schema migrations that rewrite existing rows or drop columns without a backfill or rollback plan."
92
91
  ],
93
92
  "taskSizing": {
94
- "softFiles": 8,
93
+ "softFiles": 15,
95
94
  "hardFiles": 30,
96
95
  "maxAcceptance": 14,
97
96
  "softAcceptanceCount": 10
@@ -26,7 +26,7 @@ with the runtime validators.
26
26
  "$schema": "./.agents/schemas/agentrc.schema.json",
27
27
  "project": { /* paths, commands, baseBranch, docsContextFiles */ },
28
28
  "github": { /* owner, repo, branchProtection, mergeMethods, notifications */ },
29
- "planning": { /* riskHeuristics, maxTickets, codebaseSnapshot, context */ },
29
+ "planning": { /* riskHeuristics, codebaseSnapshot, context */ },
30
30
  "delivery": { /* execution, quality, worktreeIsolation, deliverRunner, ... */ }
31
31
  }
32
32
  ```
@@ -97,7 +97,6 @@ top-level keys are validation errors.
97
97
  | Key | Required | Type | Default | Description |
98
98
  | --- | --- | --- | --- | --- |
99
99
  | `riskHeuristics` | No | `string[]` or `{ append?, prepend? }` | — | — |
100
- | `maxTickets` | No | `integer` | — | — |
101
100
  | `context` | No | `object` | — | Nested configuration block. |
102
101
  | `context.maxBytes` | No | `integer` | — | — |
103
102
  | `context.summaryMode` | No | `"auto"` \| `"always"` \| `"never"` | — | — |
@@ -107,7 +106,7 @@ top-level keys are validation errors.
107
106
  | `codebaseSnapshot.exclude` | No | `array<string>` | — | — |
108
107
  | `codebaseSnapshot.recentCommitWindow` | No | `integer` | — | — |
109
108
  | `taskSizing` | No | `object` | — | Story-sizing thresholds consumed by ticket-validator-sizing.js. Operator overrides shallow-merge with DEFAULT_TASK_SIZING defaults. Story #3760 collapsed the per-profile matrix and the parallel testSurface axis into a flat set of knobs; the sizingProfile enum was replaced by an optional body-level `wide` declaration that lifts the hardFiles rejection. Story #3874 cut over to one uniform relaxed profile sized for capability slices a frontier model delivers and self-verifies in one pass. |
110
- | `taskSizing.softFiles` | No | `integer` | — | File-count soft-warn threshold above which a typical-Story width finding fires (default 8). |
109
+ | `taskSizing.softFiles` | No | `integer` | — | File-count soft-warn threshold above which a typical-Story width finding fires (default 15). |
111
110
  | `taskSizing.hardFiles` | No | `integer` | — | File-count hard ceiling: a Story exceeding it is rejected unless it declares `wide` with a reason (default 30). |
112
111
  | `taskSizing.maxAcceptance` | No | `integer` | — | Hard ceiling on acceptance[] item count (default 14). |
113
112
  | `taskSizing.softAcceptanceCount` | No | `integer` | — | Soft-warn threshold on acceptance[] item count (default 10). |
@@ -430,7 +429,6 @@ suppress a channel entirely, set its array to `[]`.
430
429
  | Field | Required | Default | Purpose |
431
430
  | ------------------------------ | -------- | ---------- | ------------------------------------------------------------------------------------------------ |
432
431
  | `riskHeuristics` | No | `[]` | Free-form rubric for `risk::high` decisions (informational only — `risk::high` does not gate runtime). Accepts a plain array or the `{ append/prepend }` extender form. |
433
- | `maxTickets` | No | (none) | Soft cap on tickets a single Epic may decompose. |
434
432
  | `failOnSharedEditors` | No | (none) | Hard-fail Phase 7 when two Stories declare the same editor. |
435
433
  | `requireExplicitCrossStoryDeps`| No | (none) | Require explicit cross-Story `blocked by` declarations rather than inferring from shared paths. |
436
434
 
@@ -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. |
@@ -332,7 +332,7 @@
332
332
  "softFiles": {
333
333
  "type": "integer",
334
334
  "minimum": 1,
335
- "description": "File-count soft-warn threshold above which a typical-Story width finding fires (default 8)."
335
+ "description": "File-count soft-warn threshold above which a typical-Story width finding fires (default 15)."
336
336
  },
337
337
  "hardFiles": {
338
338
  "type": "integer",
@@ -358,10 +358,6 @@
358
358
  "riskHeuristics": {
359
359
  "$ref": "#/$defs/listOrExtenderOfStrings"
360
360
  },
361
- "maxTickets": {
362
- "type": "integer",
363
- "minimum": 1
364
- },
365
361
  "context": {
366
362
  "$ref": "#/$defs/planningContext"
367
363
  },
@@ -8,7 +8,8 @@
8
8
  "properties": {
9
9
  "prUrl": { "type": "string", "format": "uri", "minLength": 1 },
10
10
  "merged": { "type": "boolean" },
11
- "reason": { "type": "string" }
11
+ "reason": { "type": "string" },
12
+ "epicId": { "type": "integer", "minimum": 1 }
12
13
  },
13
14
  "additionalProperties": false
14
15
  }
@@ -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() {