mandrel 2.64.0 → 2.66.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 (105) hide show
  1. package/.agents/agents/acceptance-critic.md +8 -7
  2. package/.agents/agents/auditor.md +20 -20
  3. package/.agents/agents/plan-critic.md +8 -7
  4. package/.agents/agents/story-worker.md +7 -7
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +1 -9
  7. package/.agents/docs/configuration.md +8 -7
  8. package/.agents/docs/execution-reference.md +27 -5
  9. package/.agents/instructions.md +10 -12
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +12 -3
  13. package/.agents/rules/git-conventions.md +9 -7
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -13
  17. package/.agents/schemas/audit-rules.schema.json +1 -1
  18. package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
  19. package/.agents/scripts/bootstrap.js +102 -91
  20. package/.agents/scripts/check-context-budget.js +1 -1
  21. package/.agents/scripts/lib/ITicketingProvider.js +1 -3
  22. package/.agents/scripts/lib/audit-suite/findings.js +1 -17
  23. package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
  24. package/.agents/scripts/lib/audit-suite/index.js +0 -6
  25. package/.agents/scripts/lib/audit-suite/selector.js +0 -31
  26. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  27. package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
  28. package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
  29. package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
  30. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +91 -107
  31. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  32. package/.agents/scripts/lib/cli-args.js +26 -0
  33. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  34. package/.agents/scripts/lib/config/review-chain-default.js +13 -0
  35. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  36. package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
  37. package/.agents/scripts/lib/doc-tiers.js +25 -6
  38. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  39. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  40. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  43. package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
  44. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  45. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  46. package/.agents/scripts/lib/orchestration/code-review.js +33 -6
  47. package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
  48. package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
  49. package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
  50. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  51. package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
  52. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
  53. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +29 -4
  54. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
  55. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
  57. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -12
  58. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +370 -268
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +393 -313
  63. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
  64. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
  65. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  66. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  67. package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
  68. package/.agents/scripts/lib/transpile.js +28 -3
  69. package/.agents/scripts/providers/github/issues.js +14 -23
  70. package/.agents/scripts/single-story-close.js +10 -2
  71. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  72. package/.agents/scripts/sync-claude-agents.js +1 -1
  73. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  74. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  75. package/.agents/workflows/audit-architecture.md +5 -4
  76. package/.agents/workflows/audit-documentation.md +5 -5
  77. package/.agents/workflows/audit-performance.md +10 -10
  78. package/.agents/workflows/audit-quality.md +42 -7
  79. package/.agents/workflows/helpers/acceptance-self-eval.md +9 -9
  80. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  81. package/.agents/workflows/helpers/code-review.md +15 -38
  82. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  83. package/.agents/workflows/helpers/deliver-reference.md +7 -3
  84. package/.agents/workflows/helpers/deliver-story.md +9 -1
  85. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  86. package/.agents/workflows/helpers/plan-reference.md +9 -8
  87. package/.agents/workflows/mandrel-deliver.md +3 -2
  88. package/.agents/workflows/mandrel-plan.md +11 -7
  89. package/.agents/workflows/mandrel-update.md +5 -3
  90. package/docs/CHANGELOG.md +57 -0
  91. package/lib/cli/claude-code-version.js +73 -0
  92. package/lib/cli/doctor.js +2 -2
  93. package/lib/cli/guarded-sync.js +87 -0
  94. package/lib/cli/registry.js +9 -0
  95. package/lib/cli/sync-agents.js +9 -92
  96. package/lib/cli/sync-commands.js +9 -101
  97. package/lib/cli/uninstall.js +37 -9
  98. package/lib/migrations/index.js +2 -0
  99. package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
  100. package/package.json +3 -2
  101. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
  102. package/.agents/scripts/lib/audit-suite/runner.js +0 -205
  103. package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
  104. package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
  105. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
@@ -1,99 +0,0 @@
1
- /**
2
- * Close-scope lens diff-floor: a diff under the changed-line floor that
3
- * touches no sensitive path skips lens materialization. Fail-open — every
4
- * degraded input (unknown count, unreadable manifest, floor `0`) means "do
5
- * not skip". Sensitive classes come from the same selector as review depth,
6
- * so the two never disagree. Total; no I/O beyond the injected git spawn.
7
- */
8
-
9
- import { gitSpawn } from '../git-utils.js';
10
- import { readNumstatRows } from '../orchestration/diff-magnitude.js';
11
- import { selectSensitivePathClasses } from './selector.js';
12
-
13
- /** Measured: zero-yield closes clustered well under this size. */
14
- export const DEFAULT_LENS_DIFF_FLOOR = 40;
15
-
16
- /**
17
- * Whole-diff additions + deletions (no companion exemption: the question is
18
- * "is this diff small"). Shares the numstat parse with the light path's
19
- * backstop. `null` on any git failure; binary rows count 0.
20
- *
21
- * @param {{
22
- * baseRef: string,
23
- * headRef: string,
24
- * cwd?: string,
25
- * gitSpawnFn?: typeof gitSpawn,
26
- * }} args
27
- * @returns {number|null}
28
- */
29
- export function countChangedLines({
30
- baseRef,
31
- headRef,
32
- cwd = process.cwd(),
33
- gitSpawnFn = gitSpawn,
34
- } = {}) {
35
- const rows = readNumstatRows({ baseRef, headRef, cwd, gitSpawnFn });
36
- if (rows === null) return null;
37
- return rows.reduce((total, row) => total + row.additions + row.deletions, 0);
38
- }
39
-
40
- /**
41
- * Skip only when the floor is enabled, the count is known and below it, and
42
- * no sensitive-path class matches; every other state names its reason.
43
- *
44
- * @param {{
45
- * changedFiles?: string[]|null,
46
- * changedLineCount?: number|null,
47
- * floor?: number,
48
- * injectedRules?: object,
49
- * selectSensitivePathClassesFn?: typeof selectSensitivePathClasses,
50
- * }} [input]
51
- * @returns {{
52
- * skip: boolean,
53
- * reason: 'floor-disabled'|'line-count-unknown'|'at-or-above-floor'|'sensitive-classes-unknown'|'sensitive-path-hit'|'below-floor',
54
- * floor: number,
55
- * changedLineCount: number|null,
56
- * sensitiveClasses: string[],
57
- * }}
58
- */
59
- export function evaluateLensDiffFloor(input = {}) {
60
- const floorRaw = input.floor;
61
- const floor =
62
- typeof floorRaw === 'number' && Number.isInteger(floorRaw) && floorRaw >= 0
63
- ? floorRaw
64
- : DEFAULT_LENS_DIFF_FLOOR;
65
- const count =
66
- typeof input.changedLineCount === 'number' &&
67
- Number.isFinite(input.changedLineCount) &&
68
- input.changedLineCount >= 0
69
- ? Math.floor(input.changedLineCount)
70
- : null;
71
- const verdict = (skip, reason, sensitiveClasses = []) => ({
72
- skip,
73
- reason,
74
- floor,
75
- changedLineCount: count,
76
- sensitiveClasses,
77
- });
78
-
79
- if (floor <= 0) return verdict(false, 'floor-disabled');
80
- if (count === null) return verdict(false, 'line-count-unknown');
81
- if (count >= floor) return verdict(false, 'at-or-above-floor');
82
-
83
- const select =
84
- input.selectSensitivePathClassesFn ?? selectSensitivePathClasses;
85
- let classes;
86
- try {
87
- classes = select({
88
- changedFiles: Array.isArray(input.changedFiles) ? input.changedFiles : [],
89
- injectedRules: input.injectedRules,
90
- });
91
- } catch {
92
- return verdict(false, 'sensitive-classes-unknown');
93
- }
94
- const matched = Array.isArray(classes) ? classes : [];
95
- if (matched.length > 0) {
96
- return verdict(false, 'sensitive-path-hit', matched);
97
- }
98
- return verdict(true, 'below-floor');
99
- }
@@ -1,205 +0,0 @@
1
- /**
2
- * `runAuditSuite`: owns the audit envelope shape (`metadata`, `findings`,
3
- * `workflows`) and the per-audit fan-out; invoked via the barrel.
4
- */
5
-
6
- import fs from 'node:fs/promises';
7
- import path from 'node:path';
8
- import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
9
- import { ValidationError } from '../errors/index.js';
10
- import { aggregateSummary } from './findings.js';
11
- import { summarizeWorkflow } from './frontmatter.js';
12
- import { applySubstitutions, computeAllowedKeys } from './substitutions.js';
13
- import { defaultWriteArtifact, loadWorkflow } from './workflow-loader.js';
14
-
15
- async function loadRules(paths) {
16
- const rulesPath = path.join(
17
- PROJECT_ROOT,
18
- paths.schemasRoot,
19
- 'audit-rules.json',
20
- );
21
- const rulesContent = await fs.readFile(rulesPath, 'utf8');
22
- return JSON.parse(rulesContent);
23
- }
24
-
25
- function rejectUnknownKeys(allowedKeys, callerSubstitutions) {
26
- const unknownKeys = Object.keys(callerSubstitutions).filter(
27
- (k) => !allowedKeys.has(k),
28
- );
29
- if (unknownKeys.length === 0) return;
30
- const allowedList = [...allowedKeys].sort().join(', ');
31
- throw new ValidationError(
32
- `Unknown substitution key(s): ${unknownKeys.join(', ')}. Allowed for this call: ${allowedList}.`,
33
- { unknownKeys, allowedKeys: [...allowedKeys] },
34
- );
35
- }
36
-
37
- function emptyEnvelope(auditWorkflows) {
38
- return {
39
- metadata: {
40
- timestamp: new Date().toISOString(),
41
- auditsRequested: auditWorkflows,
42
- auditsRun: [],
43
- summary: { critical: 0, high: 0, medium: 0, low: 0 },
44
- },
45
- findings: [],
46
- workflows: [],
47
- };
48
- }
49
-
50
- function notDefinedFinding(auditName) {
51
- return {
52
- error: true,
53
- finding: {
54
- audit: auditName,
55
- severity: 'low',
56
- message: `Requested audit workflow '${auditName}' is not defined in audit-rules.json.`,
57
- },
58
- };
59
- }
60
-
61
- function notFoundFinding(auditName) {
62
- return {
63
- error: true,
64
- finding: {
65
- audit: auditName,
66
- severity: 'low',
67
- message: `Audit workflow '${auditName}.md' not found in workflows directory.`,
68
- },
69
- };
70
- }
71
-
72
- async function processAudit({
73
- auditName,
74
- validAudits,
75
- loader,
76
- workflowsDir,
77
- effectiveSubstitutions,
78
- }) {
79
- if (!validAudits.includes(auditName)) {
80
- return notDefinedFinding(auditName);
81
- }
82
-
83
- const workflow = await loader(auditName, workflowsDir);
84
- if (!workflow) {
85
- return notFoundFinding(auditName);
86
- }
87
-
88
- const substituted = applySubstitutions(
89
- workflow.content,
90
- effectiveSubstitutions,
91
- );
92
-
93
- return {
94
- success: true,
95
- auditName,
96
- workflowPath: workflow.path ?? null,
97
- workflowContent: substituted,
98
- summary: summarizeWorkflow(workflow.content),
99
- byteSize: Buffer.byteLength(substituted, 'utf8'),
100
- };
101
- }
102
-
103
- async function reduceResults({
104
- results,
105
- envelope,
106
- artifactPrefix,
107
- effectiveArtifactsDir,
108
- writeArtifact,
109
- }) {
110
- for (const result of results) {
111
- if (result.error) {
112
- envelope.findings.push(result.finding);
113
- continue;
114
- }
115
- if (!result.success) continue;
116
-
117
- envelope.metadata.auditsRun.push(result.auditName);
118
- let artifactPath = null;
119
- if (artifactPrefix) {
120
- const fileName = `audit-${artifactPrefix}-${result.auditName}.md`;
121
- artifactPath = await writeArtifact(
122
- effectiveArtifactsDir,
123
- fileName,
124
- result.workflowContent,
125
- );
126
- }
127
- envelope.workflows.push({
128
- audit: result.auditName,
129
- path: result.workflowPath,
130
- summary: result.summary,
131
- byteSize: result.byteSize,
132
- artifactPath,
133
- });
134
- }
135
- }
136
-
137
- /**
138
- * Resolve registered audit workflows into slim descriptors; with
139
- * `artifactPrefix`, full bodies are written to disk instead of travelling in
140
- * the envelope. Substitution keys beyond the built-ins must be declared as
141
- * `substitutionKeys` in audit-rules.json, else ValidationError.
142
- *
143
- * @param {object} opts
144
- * @param {string[]} opts.auditWorkflows
145
- * @param {Record<string,string>} [opts.substitutions]
146
- * @param {string} [opts.artifactPrefix] - writes `audit-<prefix>-<audit>.md`.
147
- * @param {string} [opts.artifactsDir] - defaults to `<auditOutputDir>`.
148
- * @param {Function} [opts.injectedLoadWorkflow]
149
- * @param {object} [opts.injectedRules]
150
- * @param {Function} [opts.injectedWriteArtifact]
151
- * @returns {Promise<object>} Aggregated audit results.
152
- */
153
- export async function runAuditSuite({
154
- auditWorkflows,
155
- substitutions,
156
- artifactPrefix,
157
- artifactsDir,
158
- injectedLoadWorkflow,
159
- injectedRules,
160
- injectedWriteArtifact,
161
- }) {
162
- const config = resolveConfig();
163
- const paths = getPaths(config);
164
- const callerSubstitutions = substitutions ?? {};
165
- const rules = injectedRules ?? (await loadRules(paths));
166
-
167
- const allowedKeys = computeAllowedKeys(rules, auditWorkflows);
168
- rejectUnknownKeys(allowedKeys, callerSubstitutions);
169
-
170
- const effectiveSubstitutions = {
171
- auditOutputDir: paths.auditOutputDir,
172
- ...callerSubstitutions,
173
- };
174
-
175
- const validAudits = Object.keys(rules.audits || {});
176
- const envelope = emptyEnvelope(auditWorkflows);
177
- const workflowsDir = path.join(PROJECT_ROOT, paths.workflowsRoot);
178
- const effectiveArtifactsDir =
179
- artifactsDir ?? path.join(PROJECT_ROOT, paths.auditOutputDir);
180
- const writeArtifact = injectedWriteArtifact ?? defaultWriteArtifact;
181
- const loader = injectedLoadWorkflow ?? loadWorkflow;
182
-
183
- const results = await Promise.all(
184
- auditWorkflows.map((auditName) =>
185
- processAudit({
186
- auditName,
187
- validAudits,
188
- loader,
189
- workflowsDir,
190
- effectiveSubstitutions,
191
- }),
192
- ),
193
- );
194
-
195
- await reduceResults({
196
- results,
197
- envelope,
198
- artifactPrefix,
199
- effectiveArtifactsDir,
200
- writeArtifact,
201
- });
202
-
203
- envelope.metadata.summary = aggregateSummary(envelope.findings);
204
- return envelope;
205
- }
@@ -1,96 +0,0 @@
1
- /**
2
- * `{{key}}` substitution for audit workflow bodies, plus the CLI glue and the
3
- * per-run allowed-key set.
4
- */
5
-
6
- import { ValidationError } from '../errors/index.js';
7
-
8
- export const BUILT_IN_SUBSTITUTION_KEYS = Object.freeze([
9
- 'auditOutputDir',
10
- 'ticketId',
11
- 'baseBranch',
12
- // Newline-joined change set; when unsubstituted, lens templates read the
13
- // literal as "no scope filter".
14
- 'changedFiles',
15
- ]);
16
-
17
- function escapeRegExp(str) {
18
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
- }
20
-
21
- /**
22
- * Unrecognised placeholders are left intact; keys are validated upstream.
23
- *
24
- * @param {string} content
25
- * @param {Record<string, string>} substitutions
26
- * @returns {string}
27
- */
28
- export function applySubstitutions(content, substitutions) {
29
- let out = content;
30
- for (const [key, value] of Object.entries(substitutions)) {
31
- out = out.replace(
32
- new RegExp(`\\{\\{${escapeRegExp(key)}\\}\\}`, 'g'),
33
- value,
34
- );
35
- }
36
- return out;
37
- }
38
-
39
- /**
40
- * Throws {@link ValidationError} on a missing `=` or empty key.
41
- *
42
- * @param {string[]} [pairs]
43
- * @returns {Record<string, string>}
44
- */
45
- export function parseSubstitutionPairs(pairs = []) {
46
- const out = {};
47
- for (const entry of pairs) {
48
- const eq = entry.indexOf('=');
49
- if (eq <= 0) {
50
- throw new ValidationError(
51
- `Invalid --substitution "${entry}"; expected key=value.`,
52
- { entry },
53
- );
54
- }
55
- const key = entry.slice(0, eq);
56
- const value = entry.slice(eq + 1);
57
- out[key] = value;
58
- }
59
- return out;
60
- }
61
-
62
- /**
63
- * Fill `ticketId`/`baseBranch` from flags in place, unless an explicit
64
- * `--substitution` already set them.
65
- *
66
- * @param {Record<string, unknown>} values
67
- * @param {Record<string, string|undefined>} substitutions
68
- */
69
- export function applyImplicitSubstitutions(values, substitutions) {
70
- if (values.ticket && substitutions.ticketId === undefined) {
71
- substitutions.ticketId = String(values.ticket);
72
- }
73
- if (values['base-branch'] && substitutions.baseBranch === undefined) {
74
- substitutions.baseBranch = values['base-branch'];
75
- }
76
- }
77
-
78
- /**
79
- * Built-ins plus each requested audit's declared keys; unregistered audits
80
- * are rejected elsewhere.
81
- *
82
- * @param {{ audits?: Record<string, { substitutionKeys?: string[] }> }} rules
83
- * @param {string[]} auditWorkflows
84
- * @returns {Set<string>}
85
- */
86
- export function computeAllowedKeys(rules, auditWorkflows) {
87
- const allowed = new Set(BUILT_IN_SUBSTITUTION_KEYS);
88
- for (const auditName of auditWorkflows) {
89
- const entry = rules.audits?.[auditName];
90
- if (!entry) continue;
91
- for (const k of entry.substitutionKeys ?? []) {
92
- allowed.add(k);
93
- }
94
- }
95
- return allowed;
96
- }
@@ -1,37 +0,0 @@
1
- /**
2
- * Filesystem IO for audit workflows, kept out of the runner so tests can
3
- * inject stubs.
4
- */
5
-
6
- import fs from 'node:fs/promises';
7
- import path from 'node:path';
8
-
9
- /**
10
- * `null` when missing; the runner records a finding rather than failing.
11
- *
12
- * @param {string} auditName
13
- * @param {string} workflowsDir absolute path to the workflows root
14
- * @returns {Promise<{ path: string, content: string } | null>}
15
- */
16
- export async function loadWorkflow(auditName, workflowsDir) {
17
- const workflowPath = path.join(workflowsDir, `${auditName}.md`);
18
- try {
19
- const content = await fs.readFile(workflowPath, 'utf8');
20
- return { path: workflowPath, content };
21
- } catch {
22
- return null;
23
- }
24
- }
25
-
26
- /**
27
- * @param {string} artifactsDir
28
- * @param {string} fileName
29
- * @param {string} content
30
- * @returns {Promise<string>}
31
- */
32
- export async function defaultWriteArtifact(artifactsDir, fileName, content) {
33
- await fs.mkdir(artifactsDir, { recursive: true });
34
- const fullPath = path.join(artifactsDir, fileName);
35
- await fs.writeFile(fullPath, content, 'utf8');
36
- return fullPath;
37
- }
@@ -1,234 +0,0 @@
1
- /**
2
- * phases/local-lens-review.js — the Story-scope local-lens pass: selects the
3
- * local lenses matching the Story diff and materializes their prompts. The
4
- * review spine (`runStoryReviewCore`) owns diff enumeration and code review.
5
- */
6
-
7
- import {
8
- evaluateLensDiffFloor,
9
- runAuditSuite,
10
- selectLocalLenses,
11
- } from '../../../audit-suite/index.js';
12
- import { gitSpawn } from '../../../git-utils.js';
13
- import {
14
- emitRuntimeFriction,
15
- RUNTIME_FRICTION_CATEGORIES,
16
- } from '../../../observability/runtime-friction.js';
17
- import { computeChangeSet } from '../../change-set.js';
18
-
19
- /** Fixed for this tier; not risk-scaled. */
20
- const STORY_SCOPE_LENS_DEPTH = 'light';
21
-
22
- /**
23
- * Roster of materialized lens-prompt artifacts the host MUST walk — the
24
- * default review provider never reads them, so without this they are inert.
25
- *
26
- * @param {object|null} materialized the `runAuditSuite` result envelope.
27
- * @returns {string|null} the roster block, or `null` when there is nothing to walk.
28
- */
29
- function renderLensArtifactRoster(materialized) {
30
- const paths = (materialized?.workflows ?? [])
31
- .map((w) => w?.artifactPath)
32
- .filter((p) => typeof p === 'string' && p.length > 0);
33
- if (paths.length === 0) return null;
34
- return [
35
- `Lens prompts materialized (host MUST read/walk each against the Story diff):`,
36
- ...paths.map((p) => ` - ${p}`),
37
- ].join('\n');
38
- }
39
-
40
- /**
41
- * Built-in substitution keys, so no per-lens declaration is needed.
42
- *
43
- * @param {{ changedFiles: string[], storyId?: number|string|null }} args
44
- * @returns {Record<string, string>}
45
- */
46
- function buildLensSubstitutions({ changedFiles, storyId }) {
47
- const substitutions = { changedFiles: changedFiles.join('\n') };
48
- if (storyId != null && `${storyId}`.length > 0) {
49
- substitutions.ticketId = String(storyId);
50
- }
51
- return substitutions;
52
- }
53
-
54
- /**
55
- * Never throws: an unknown diff returns `[]`, which matches no lens, same as
56
- * an empty one.
57
- *
58
- * @param {{
59
- * baseRef: string,
60
- * headRef: string,
61
- * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
62
- * }} args
63
- * @returns {string[]} Changed file paths, or `[]` on any failure.
64
- */
65
- function enumerateChangedFiles({ baseRef, headRef, gitSpawnFn = gitSpawn }) {
66
- return computeChangeSet({ baseRef, headRef, gitSpawnFn }).files ?? [];
67
- }
68
-
69
- /**
70
- * Three-state injection: an array is used verbatim; `null` means the spine
71
- * already failed to enumerate, so don't re-spawn git; `undefined` means
72
- * nobody enumerated, so self-enumerate.
73
- *
74
- * @param {{
75
- * changedFiles: string[]|null|undefined,
76
- * baseRef: string,
77
- * headRef: string,
78
- * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
79
- * }} args
80
- * @returns {string[]}
81
- */
82
- function resolveLensChangeSet({
83
- changedFiles,
84
- baseRef,
85
- headRef,
86
- gitSpawnFn = gitSpawn,
87
- }) {
88
- if (changedFiles === undefined) {
89
- return enumerateChangedFiles({ baseRef, headRef, gitSpawnFn });
90
- }
91
- return changedFiles ?? [];
92
- }
93
-
94
- /**
95
- * Run the maker-blind Story-scope lens pass (called only from
96
- * `runStoryReviewCore`). No matching lens → no lens work. A diff below the
97
- * lens diff-floor with no sensitive-path hits records the roster but skips
98
- * materialization; an unknown line count fails open. Advisory: any failure
99
- * degrades to a skipped envelope plus a friction signal.
100
- *
101
- * @param {{
102
- * baseRef: string,
103
- * headRef: string,
104
- * changedFiles?: string[]|null,
105
- * changedLineCount?: number|null,
106
- * lensDiffFloor?: number,
107
- * storyId?: number|string|null,
108
- * artifactPrefix?: string,
109
- * progress: (tag: string, msg: string) => void,
110
- * progressTag?: string,
111
- * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
112
- * selectLocalLensesFn?: typeof selectLocalLenses,
113
- * runAuditSuiteFn?: typeof runAuditSuite,
114
- * evaluateLensDiffFloorFn?: typeof evaluateLensDiffFloor,
115
- * emitToolDegradationFn?: typeof emitRuntimeFriction,
116
- * }} args
117
- * @returns {Promise<{
118
- * depth: 'light',
119
- * lenses: string[],
120
- * skipped: boolean,
121
- * floorSkip: object|null,
122
- * materialized: object|null,
123
- * artifactPaths: string[],
124
- * }>}
125
- */
126
- export async function runLocalLensReview({
127
- baseRef,
128
- headRef,
129
- changedFiles: injectedChangedFiles,
130
- changedLineCount = null,
131
- lensDiffFloor,
132
- storyId,
133
- artifactPrefix,
134
- progress,
135
- progressTag = 'CODE-REVIEW',
136
- gitSpawnFn = gitSpawn,
137
- selectLocalLensesFn = selectLocalLenses,
138
- runAuditSuiteFn = runAuditSuite,
139
- evaluateLensDiffFloorFn = evaluateLensDiffFloor,
140
- emitToolDegradationFn = emitRuntimeFriction,
141
- }) {
142
- const empty = {
143
- depth: STORY_SCOPE_LENS_DEPTH,
144
- lenses: [],
145
- skipped: true,
146
- floorSkip: null,
147
- materialized: null,
148
- artifactPaths: [],
149
- };
150
- try {
151
- const changedFiles = resolveLensChangeSet({
152
- changedFiles: injectedChangedFiles,
153
- baseRef,
154
- headRef,
155
- gitSpawnFn,
156
- });
157
- const lenses = selectLocalLensesFn({ changedFiles });
158
- if (lenses.length === 0) {
159
- progress(
160
- progressTag,
161
- 'No local lens matched the Story diff — skipping the lens pass.',
162
- );
163
- return empty;
164
- }
165
-
166
- // After selection so a floor-skip still records which lenses it skipped.
167
- const floorVerdict = evaluateLensDiffFloorFn({
168
- changedFiles,
169
- changedLineCount,
170
- floor: lensDiffFloor,
171
- });
172
- if (floorVerdict.skip) {
173
- progress(
174
- progressTag,
175
- `Lens diff-floor: ${floorVerdict.changedLineCount} changed line(s) < ` +
176
- `floor ${floorVerdict.floor} with zero sensitive-path hits — ` +
177
- `skipping materialization of ${lenses.join(', ')}.`,
178
- );
179
- return {
180
- depth: STORY_SCOPE_LENS_DEPTH,
181
- lenses,
182
- skipped: true,
183
- floorSkip: floorVerdict,
184
- materialized: null,
185
- artifactPaths: [],
186
- };
187
- }
188
- // Story-scoped names so concurrent closes can't clobber each other.
189
- const effectivePrefix =
190
- artifactPrefix ?? (storyId != null ? `story-${storyId}` : 'story-scope');
191
- const materialized = await runAuditSuiteFn({
192
- auditWorkflows: lenses,
193
- substitutions: buildLensSubstitutions({ changedFiles, storyId }),
194
- artifactPrefix: effectivePrefix,
195
- });
196
- progress(
197
- progressTag,
198
- `Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
199
- );
200
- const roster = renderLensArtifactRoster(materialized);
201
- if (roster) progress(progressTag, roster);
202
- const artifactPaths = (materialized?.workflows ?? [])
203
- .map((w) => w?.artifactPath)
204
- .filter((p) => typeof p === 'string' && p.length > 0);
205
- return {
206
- depth: STORY_SCOPE_LENS_DEPTH,
207
- lenses,
208
- skipped: false,
209
- floorSkip: floorVerdict,
210
- materialized,
211
- artifactPaths,
212
- };
213
- } catch (err) {
214
- // Degradations are operational signals (friction), not findings.
215
- progress(
216
- progressTag,
217
- `⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
218
- );
219
- try {
220
- await emitToolDegradationFn({
221
- storyId,
222
- category: RUNTIME_FRICTION_CATEGORIES.TOOL_DEGRADED,
223
- tool: 'local-lens-review',
224
- details: {
225
- surface: 'lens-materialization',
226
- reason: String(err?.message ?? err).slice(0, 500),
227
- },
228
- });
229
- } catch {
230
- // Observability must never fail the close.
231
- }
232
- return empty;
233
- }
234
- }