mandrel 1.87.0 → 1.89.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.
- package/.agents/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +63 -16
- package/.agents/docs/configuration.md +5 -3
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doc-tier resolver (Story #4438, Epic #4430 — Context Economy).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that classify the repository's documentation into the four
|
|
5
|
+
* read-tiers Mandrel's context model recognizes, each entry carrying its
|
|
6
|
+
* on-disk byte size so a byte budget can ratchet against it:
|
|
7
|
+
*
|
|
8
|
+
* - `alwaysLoaded` — `CLAUDE.md` plus the transitive closure of its
|
|
9
|
+
* `@`-import references (`@AGENTS.md`,
|
|
10
|
+
* `@.agents/instructions.md`, the persona, the always-on
|
|
11
|
+
* rules, …). This is the context every session re-pays
|
|
12
|
+
* on every subagent spawn (instructions.md § 4), so it is
|
|
13
|
+
* the primary budget the context-budget ratchet gates.
|
|
14
|
+
* - `mandatoryRead` — the resolved `project.docsContextFiles` set (prefixed
|
|
15
|
+
* by `project.paths.docsRoot`), existing files only. This
|
|
16
|
+
* is the `docsContextFiles` half the Epic AC gates; it
|
|
17
|
+
* skips silently when the set is unconfigured or its
|
|
18
|
+
* files are absent.
|
|
19
|
+
* - `digestVisible` — the situational **Conditional Reads** docs from
|
|
20
|
+
* instructions.md § 3 (`docs/style-guide.md`,
|
|
21
|
+
* `docs/web-routes.md`) — surfaced only when a task
|
|
22
|
+
* touches UI/routing, i.e. visible through the docs
|
|
23
|
+
* digest rather than always read. Existing files only.
|
|
24
|
+
* - `onDemand` — the on-demand `.agents/rules/*.md` set (instructions.md
|
|
25
|
+
* § 1.F): every rule file that is **not** part of the
|
|
26
|
+
* always-on core already captured in `alwaysLoaded`.
|
|
27
|
+
*
|
|
28
|
+
* A file that could appear in more than one tier is kept in its **highest**
|
|
29
|
+
* tier only (alwaysLoaded > mandatoryRead > digestVisible > onDemand), so the
|
|
30
|
+
* four arrays partition the doc set with no double-counting.
|
|
31
|
+
*
|
|
32
|
+
* The closure is discovered by parsing `@`-import references and following
|
|
33
|
+
* them recursively (cycle-safe via a visited set). A candidate `@`-token only
|
|
34
|
+
* counts as an import when it resolves to an existing repo file, which
|
|
35
|
+
* naturally filters prose mentions (`@[USERNAME]`, `noreply@example.com`,
|
|
36
|
+
* backtick-wrapped `` `@`-imported `` phrasing).
|
|
37
|
+
*
|
|
38
|
+
* Security (security-baseline § 5 — Data Leakage & Logging): every function
|
|
39
|
+
* emits only repo-relative paths and byte counts — never file contents.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import nodeFs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Basename of the always-loaded entry document (the root of the closure).
|
|
47
|
+
* @type {string}
|
|
48
|
+
*/
|
|
49
|
+
const ENTRY_DOC = 'CLAUDE.md';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Always-on core rule files (instructions.md § 1.F). These live in the
|
|
53
|
+
* `alwaysLoaded` closure (imported by `CLAUDE.md`); every other
|
|
54
|
+
* `.agents/rules/*.md` file is `onDemand`.
|
|
55
|
+
* @type {string[]}
|
|
56
|
+
*/
|
|
57
|
+
const ALWAYS_ON_RULES = ['security-baseline.md', 'git-conventions.md'];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Conditional-read docs (instructions.md § 3 — "Conditional Reads"), resolved
|
|
61
|
+
* against `project.paths.docsRoot`. Present in the `digestVisible` tier when
|
|
62
|
+
* they exist.
|
|
63
|
+
* @type {string[]}
|
|
64
|
+
*/
|
|
65
|
+
const CONDITIONAL_DOCS = ['style-guide.md', 'web-routes.md'];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Match `@`-import tokens: an `@` at start-of-line or after whitespace,
|
|
69
|
+
* followed by a path token that stops at whitespace or common closing
|
|
70
|
+
* punctuation. The resolved-file existence check downstream is the real
|
|
71
|
+
* filter; this regex only harvests candidates.
|
|
72
|
+
*/
|
|
73
|
+
const IMPORT_RE = /(?:^|\s)@([^\s'"`)\]}>,]+)/gm;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default fs surface — a small subset of `node:fs` so callers can inject a
|
|
77
|
+
* fixture double in tests without touching the real filesystem.
|
|
78
|
+
* @typedef {{
|
|
79
|
+
* existsSync: (p: string) => boolean,
|
|
80
|
+
* readFileSync: (p: string, enc: string) => string,
|
|
81
|
+
* statSync: (p: string) => { size: number },
|
|
82
|
+
* }} FsLike
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse the raw `@`-import specifiers from a source document. A trailing `.`
|
|
87
|
+
* or `:` (sentence punctuation) is trimmed so `@AGENTS.md.` still resolves.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} source
|
|
90
|
+
* @returns {string[]} raw specifiers in first-seen order
|
|
91
|
+
*/
|
|
92
|
+
export function parseImportSpecifiers(source) {
|
|
93
|
+
const specs = [];
|
|
94
|
+
for (const m of String(source ?? '').matchAll(IMPORT_RE)) {
|
|
95
|
+
let spec = m[1];
|
|
96
|
+
// Strip trailing sentence punctuation that the greedy class allowed in.
|
|
97
|
+
while (spec.length > 0 && (spec.endsWith('.') || spec.endsWith(':'))) {
|
|
98
|
+
spec = spec.slice(0, -1);
|
|
99
|
+
}
|
|
100
|
+
if (spec.length > 0) specs.push(spec);
|
|
101
|
+
}
|
|
102
|
+
return specs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Convert an absolute path to a repo-relative, posix-separated id.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} root absolute repo root
|
|
109
|
+
* @param {string} abs absolute path
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function toRepoRel(root, abs) {
|
|
113
|
+
return path.relative(root, abs).split(path.sep).join('/');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a `{ path, bytes }` entry for a repo file, or `null` when it does not
|
|
118
|
+
* exist. `path` is repo-relative posix; `bytes` is the on-disk byte size.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} root absolute repo root
|
|
121
|
+
* @param {string} rel repo-relative path
|
|
122
|
+
* @param {FsLike} fs
|
|
123
|
+
* @returns {{ path: string, bytes: number } | null}
|
|
124
|
+
*/
|
|
125
|
+
function fileEntry(root, rel, fs) {
|
|
126
|
+
const abs = path.resolve(root, rel);
|
|
127
|
+
if (!fs.existsSync(abs)) return null;
|
|
128
|
+
let bytes = 0;
|
|
129
|
+
try {
|
|
130
|
+
bytes = fs.statSync(abs).size;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return { path: toRepoRel(root, abs), bytes };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the always-loaded closure: `CLAUDE.md` plus every file reachable by
|
|
139
|
+
* recursively parsing `@`-import references. Cycle-safe (a visited set keyed
|
|
140
|
+
* by repo-relative path). Nested imports resolve relative to the importing
|
|
141
|
+
* file's directory (Claude Code `@`-import semantics). Non-resolving `@`-tokens
|
|
142
|
+
* are ignored, so prose mentions never pollute the closure.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} root absolute repo root (where `CLAUDE.md` lives)
|
|
145
|
+
* @param {{ fs?: FsLike }} [opts]
|
|
146
|
+
* @returns {Array<{ path: string, bytes: number }>} sorted by path; empty when
|
|
147
|
+
* `CLAUDE.md` is absent
|
|
148
|
+
*/
|
|
149
|
+
export function resolveAlwaysLoadedClosure(root, { fs = nodeFs } = {}) {
|
|
150
|
+
const entryAbs = path.resolve(root, ENTRY_DOC);
|
|
151
|
+
if (!fs.existsSync(entryAbs)) return [];
|
|
152
|
+
|
|
153
|
+
const visited = new Set();
|
|
154
|
+
const entries = new Map();
|
|
155
|
+
const queue = [entryAbs];
|
|
156
|
+
|
|
157
|
+
while (queue.length > 0) {
|
|
158
|
+
const abs = queue.shift();
|
|
159
|
+
const rel = toRepoRel(root, abs);
|
|
160
|
+
if (visited.has(rel)) continue;
|
|
161
|
+
visited.add(rel);
|
|
162
|
+
|
|
163
|
+
if (!fs.existsSync(abs)) continue;
|
|
164
|
+
let bytes = 0;
|
|
165
|
+
let source = '';
|
|
166
|
+
try {
|
|
167
|
+
bytes = fs.statSync(abs).size;
|
|
168
|
+
source = fs.readFileSync(abs, 'utf8');
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
entries.set(rel, { path: rel, bytes });
|
|
173
|
+
|
|
174
|
+
const dir = path.dirname(abs);
|
|
175
|
+
for (const spec of parseImportSpecifiers(source)) {
|
|
176
|
+
const targetAbs = path.resolve(dir, spec);
|
|
177
|
+
const targetRel = toRepoRel(root, targetAbs);
|
|
178
|
+
if (!visited.has(targetRel) && fs.existsSync(targetAbs)) {
|
|
179
|
+
queue.push(targetAbs);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read the resolved `project.docsContextFiles` list from a config object,
|
|
189
|
+
* prefixed by `project.paths.docsRoot`. Mirrors the `contextDocs` half of
|
|
190
|
+
* `resolveDocList` in `validate-docs-freshness.js` (new-shape first, legacy
|
|
191
|
+
* top-level fallback).
|
|
192
|
+
*
|
|
193
|
+
* @param {object} config resolved config (`resolveConfig()` output)
|
|
194
|
+
* @returns {string[]} repo-relative posix doc paths (existence not yet checked)
|
|
195
|
+
*/
|
|
196
|
+
export function docsContextPaths(config) {
|
|
197
|
+
const project = config?.project ?? config;
|
|
198
|
+
const contextDocs = Array.isArray(project?.docsContextFiles)
|
|
199
|
+
? project.docsContextFiles
|
|
200
|
+
: Array.isArray(config?.docsContextFiles)
|
|
201
|
+
? config.docsContextFiles
|
|
202
|
+
: [];
|
|
203
|
+
const docsRoot =
|
|
204
|
+
project?.paths?.docsRoot ?? config?.paths?.docsRoot ?? 'docs';
|
|
205
|
+
return contextDocs.map((f) => path.posix.join(docsRoot, f));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the four documentation read-tiers, each entry `{ path, bytes }`,
|
|
210
|
+
* partitioned so no path appears in more than one tier (highest tier wins).
|
|
211
|
+
*
|
|
212
|
+
* @param {object} config resolved config (`resolveConfig()` output)
|
|
213
|
+
* @param {{ root?: string, fs?: FsLike }} [opts]
|
|
214
|
+
* @returns {{ tiers: {
|
|
215
|
+
* alwaysLoaded: Array<{ path: string, bytes: number }>,
|
|
216
|
+
* mandatoryRead: Array<{ path: string, bytes: number }>,
|
|
217
|
+
* digestVisible: Array<{ path: string, bytes: number }>,
|
|
218
|
+
* onDemand: Array<{ path: string, bytes: number }>,
|
|
219
|
+
* } }}
|
|
220
|
+
*/
|
|
221
|
+
export function resolveDocTiers(
|
|
222
|
+
config,
|
|
223
|
+
{ root = process.cwd(), fs = nodeFs } = {},
|
|
224
|
+
) {
|
|
225
|
+
const claimed = new Set();
|
|
226
|
+
const collect = (relPaths) => {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const rel of relPaths) {
|
|
229
|
+
const entry = fileEntry(root, rel, fs);
|
|
230
|
+
if (!entry) continue;
|
|
231
|
+
if (claimed.has(entry.path)) continue;
|
|
232
|
+
claimed.add(entry.path);
|
|
233
|
+
out.push(entry);
|
|
234
|
+
}
|
|
235
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// 1. always-loaded: CLAUDE.md @-import closure. Pre-claim its paths first so
|
|
239
|
+
// a lower tier never re-lists a closure member.
|
|
240
|
+
const alwaysLoaded = resolveAlwaysLoadedClosure(root, { fs });
|
|
241
|
+
for (const e of alwaysLoaded) claimed.add(e.path);
|
|
242
|
+
|
|
243
|
+
// 2. mandatory-read: resolved docsContextFiles (existing files only).
|
|
244
|
+
const docsRoot =
|
|
245
|
+
config?.project?.paths?.docsRoot ?? config?.paths?.docsRoot ?? 'docs';
|
|
246
|
+
const mandatoryRead = collect(docsContextPaths(config));
|
|
247
|
+
|
|
248
|
+
// 3. digest-visible: situational Conditional-Read docs.
|
|
249
|
+
const digestVisible = collect(
|
|
250
|
+
CONDITIONAL_DOCS.map((f) => path.posix.join(docsRoot, f)),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// 4. on-demand: every .agents/rules/*.md that is not an always-on core rule
|
|
254
|
+
// (the always-on ones already live in the alwaysLoaded closure).
|
|
255
|
+
const onDemand = collect(listOnDemandRules(root, fs));
|
|
256
|
+
|
|
257
|
+
return { tiers: { alwaysLoaded, mandatoryRead, digestVisible, onDemand } };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* List the on-demand rule files (repo-relative posix): every `.agents/rules/
|
|
262
|
+
* *.md` whose basename is not an always-on core rule. Returns [] when the
|
|
263
|
+
* rules directory is absent.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} root absolute repo root
|
|
266
|
+
* @param {FsLike} fs
|
|
267
|
+
* @returns {string[]}
|
|
268
|
+
*/
|
|
269
|
+
function listOnDemandRules(root, fs) {
|
|
270
|
+
const rulesDir = path.resolve(root, '.agents', 'rules');
|
|
271
|
+
let names;
|
|
272
|
+
try {
|
|
273
|
+
names = fs.readdirSync(rulesDir);
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
return names
|
|
278
|
+
.filter((n) => n.endsWith('.md') && !ALWAYS_ON_RULES.includes(n))
|
|
279
|
+
.map((n) => path.posix.join('.agents', 'rules', n))
|
|
280
|
+
.sort();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Sum the `bytes` of every entry in a tier array.
|
|
285
|
+
*
|
|
286
|
+
* @param {Array<{ bytes: number }>} entries
|
|
287
|
+
* @returns {number}
|
|
288
|
+
*/
|
|
289
|
+
export function tierTotalBytes(entries) {
|
|
290
|
+
return (entries ?? []).reduce((sum, e) => sum + (e?.bytes ?? 0), 0);
|
|
291
|
+
}
|
|
@@ -54,9 +54,12 @@ export const ACCEPTANCE_TABLE_HEADING = '## Acceptance Table';
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Regex matching the Tech Spec's required opening heading (same variants
|
|
57
|
-
* `spec-section-validator.js` accepts).
|
|
57
|
+
* `spec-section-validator.js` accepts). Exported so it is the single
|
|
58
|
+
* source of truth for this pattern — `spec-section-validator.js`,
|
|
59
|
+
* `epic-plan-clarity.js`, and `consolidation-precondition.js` all import
|
|
60
|
+
* it rather than each carrying their own copy.
|
|
58
61
|
*/
|
|
59
|
-
const DELIVERY_SLICING_RE = /^##\s+(?:Delivery\s+)?Slicing\s*$/im;
|
|
62
|
+
export const DELIVERY_SLICING_RE = /^##\s+(?:Delivery\s+)?Slicing\s*$/im;
|
|
60
63
|
|
|
61
64
|
/**
|
|
62
65
|
* @param {'techSpec'|'acceptanceTable'} kind
|
|
@@ -237,3 +237,86 @@ export function releaseEpicMergeLock(handle, fsImpl = fs) {
|
|
|
237
237
|
if (err.code !== 'ENOENT') throw err;
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
|
+
|
|
241
|
+
const LOCK_FILE_PREFIX = 'epic-';
|
|
242
|
+
const LOCK_FILE_SUFFIX = '.merge.lock';
|
|
243
|
+
|
|
244
|
+
// Extract the epic id from a lock filename, or `null` when `entry` isn't a
|
|
245
|
+
// `epic-*.merge.lock` file. Split out of `findForeignActiveEpicLock` so
|
|
246
|
+
// that function stays a flat filter+map instead of a nested-conditional
|
|
247
|
+
// loop body (Story #4460).
|
|
248
|
+
function parseLockFileEpicId(entry) {
|
|
249
|
+
if (
|
|
250
|
+
!entry.startsWith(LOCK_FILE_PREFIX) ||
|
|
251
|
+
!entry.endsWith(LOCK_FILE_SUFFIX)
|
|
252
|
+
) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return entry.slice(
|
|
256
|
+
LOCK_FILE_PREFIX.length,
|
|
257
|
+
entry.length - LOCK_FILE_SUFFIX.length,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Read `<dir>/<entry>`'s lock meta and report it only when the recorded
|
|
262
|
+
// pid is still alive — a foreign lock whose pid is dead (or whose meta is
|
|
263
|
+
// unreadable/corrupt) is stale debris, not an active holder, mirroring the
|
|
264
|
+
// pid-liveness half of `tryStealStale`'s heuristic.
|
|
265
|
+
function readLiveLock(dir, entry, otherEpicId, fsImpl, killFn) {
|
|
266
|
+
const filePath = path.join(dir, entry);
|
|
267
|
+
const meta = readLockMeta(filePath, fsImpl);
|
|
268
|
+
if (!meta || !isProcessRunning(meta.pid, killFn)) return null;
|
|
269
|
+
return {
|
|
270
|
+
epicId: otherEpicId,
|
|
271
|
+
filePath,
|
|
272
|
+
pid: meta.pid,
|
|
273
|
+
acquiredAt: meta.acquiredAt,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Scan the shared common `.git/` dir for a *different* epic's live
|
|
279
|
+
* merge lock (Story #4460 — cross-epic shared-checkout guard).
|
|
280
|
+
*
|
|
281
|
+
* `acquireEpicMergeLock` only ever contends against locks for the *same*
|
|
282
|
+
* `epicId` (its own lock filename). This helper is the cross-epic
|
|
283
|
+
* counterpart: it lists every `epic-*.merge.lock` file in the common
|
|
284
|
+
* gitdir, skips the caller's own `epicId` namespace, and returns the
|
|
285
|
+
* first foreign lock whose recorded `pid` is still alive.
|
|
286
|
+
*
|
|
287
|
+
* @param {number|string} epicId Caller's own epic id (excluded from the scan).
|
|
288
|
+
* @param {{
|
|
289
|
+
* repoRoot: string,
|
|
290
|
+
* fsImpl?: object,
|
|
291
|
+
* killFn?: (pid:number, signal:number)=>void,
|
|
292
|
+
* }} opts
|
|
293
|
+
* @returns {{ epicId: string, filePath: string, pid: number, acquiredAt: number }|null}
|
|
294
|
+
*/
|
|
295
|
+
export function findForeignActiveEpicLock(
|
|
296
|
+
epicId,
|
|
297
|
+
{ repoRoot, fsImpl = fs, killFn = process.kill.bind(process) } = {},
|
|
298
|
+
) {
|
|
299
|
+
if (!repoRoot) {
|
|
300
|
+
throw new Error('findForeignActiveEpicLock: repoRoot is required');
|
|
301
|
+
}
|
|
302
|
+
const dir = resolveGitCommonDir(repoRoot, fsImpl);
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = fsImpl.readdirSync(dir);
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const foreignEntries = entries
|
|
311
|
+
.map((entry) => ({ entry, otherEpicId: parseLockFileEpicId(entry) }))
|
|
312
|
+
.filter(
|
|
313
|
+
({ otherEpicId }) =>
|
|
314
|
+
otherEpicId !== null && String(otherEpicId) !== String(epicId),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
for (const { entry, otherEpicId } of foreignEntries) {
|
|
318
|
+
const live = readLiveLock(dir, entry, otherEpicId, fsImpl, killFn);
|
|
319
|
+
if (live) return live;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
* Pure ESM, no I/O.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
+
import { DELIVERY_SLICING_RE } from './epic-body-sections.js';
|
|
44
|
+
|
|
43
45
|
const SECTION_RE = {
|
|
44
46
|
context:
|
|
45
47
|
/^##\s+(?:Context(?:\s+&\s+Problem)?|Background|Problem(?:\s+Statement)?)\s*$/im,
|
|
@@ -69,7 +71,7 @@ export const SECTION_NAMES = Object.freeze([
|
|
|
69
71
|
* `spec-section-validator.js`.
|
|
70
72
|
*/
|
|
71
73
|
const PLANNING_SECTION_RE = {
|
|
72
|
-
deliverySlicing:
|
|
74
|
+
deliverySlicing: DELIVERY_SLICING_RE,
|
|
73
75
|
acceptanceTable: /^##\s+Acceptance\s+Table\s*$/im,
|
|
74
76
|
};
|
|
75
77
|
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* audit-results-graduator.js — Auto-graduate non-blocking audit findings
|
|
3
|
-
* from the Epic's `
|
|
4
|
-
* follow-up issues. Story #2615 / Epic #2586.
|
|
3
|
+
* from the Epic's unified `verification-results` structured comment into
|
|
4
|
+
* routed GitHub follow-up issues. Story #2615 / Epic #2586.
|
|
5
|
+
*
|
|
6
|
+
* Story #4411 (Epic #4405) unified the former `audit-results` and
|
|
7
|
+
* `code-review` structured-comment contracts into one `verification-results`
|
|
8
|
+
* contract: this graduator now reads the shared
|
|
9
|
+
* {@link VERIFICATION_RESULTS_MARKER} comment rather than the retired
|
|
10
|
+
* audit-results structured-comment marker. The lens-aware finding
|
|
11
|
+
* parser, the `audit-results::<severity>` + `domain::<lens>` label shape,
|
|
12
|
+
* and the audit idempotency marker are unchanged.
|
|
5
13
|
*
|
|
6
14
|
* As of Story #3845 / Epic #3823 the spawn helper, the path/idempotency
|
|
7
15
|
* probes, the `gh issue create` filer, the toggle reader, and the
|
|
@@ -12,8 +20,8 @@
|
|
|
12
20
|
* title / body shape, and the audit idempotency marker. Behaviour is
|
|
13
21
|
* identical to the pre-consolidation graduator.
|
|
14
22
|
*
|
|
15
|
-
* - Read the `
|
|
16
|
-
* via the injected provider (`getTicketComments`).
|
|
23
|
+
* - Read the unified `verification-results` structured comment off the
|
|
24
|
+
* Epic ticket via the injected provider (`getTicketComments`).
|
|
17
25
|
* - For each non-blocking finding (severity high/medium/low/suggestion
|
|
18
26
|
* — i.e. anything that is NOT a 🔴 Critical Blocker), check that
|
|
19
27
|
* the cited file still exists in the merged tree.
|
|
@@ -38,23 +46,22 @@
|
|
|
38
46
|
*/
|
|
39
47
|
|
|
40
48
|
import {
|
|
49
|
+
contentFingerprint,
|
|
41
50
|
graduate,
|
|
42
51
|
makeIsAutoFileEnabled,
|
|
43
|
-
|
|
52
|
+
NO_VERIFICATION_RESULTS_COMMENT_REASON,
|
|
53
|
+
VERIFICATION_RESULTS_MARKER,
|
|
44
54
|
} from './graduator-core.js';
|
|
45
55
|
|
|
46
56
|
/**
|
|
47
57
|
* Resolve the toggle from the resolved agentrc config. Defaults to `true`
|
|
48
|
-
* — the feature is opt-out, not opt-in
|
|
58
|
+
* — the feature is opt-out, not opt-in.
|
|
49
59
|
*
|
|
50
60
|
* @param {object|undefined|null} config
|
|
51
61
|
* @returns {boolean}
|
|
52
62
|
*/
|
|
53
63
|
export const isAutoFileEnabled = makeIsAutoFileEnabled('auditResultsAutoFile');
|
|
54
64
|
|
|
55
|
-
// Re-export the shared path probe so existing importers keep working.
|
|
56
|
-
export { probePathExists };
|
|
57
|
-
|
|
58
65
|
/**
|
|
59
66
|
* Severity → label mapping. Only non-blocking severities have a route;
|
|
60
67
|
* 🔴 Critical Blocker is explicitly filtered out upstream.
|
|
@@ -79,9 +86,12 @@ function metaSourceLabel(source) {
|
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
/**
|
|
82
|
-
* Build the idempotency marker for a given epicId / finding
|
|
83
|
-
*
|
|
84
|
-
*
|
|
89
|
+
* Build the **legacy** idempotency marker for a given epicId / finding
|
|
90
|
+
* index. Superseded by the content-hash marker
|
|
91
|
+
* ({@link buildContentMarker}) at the Story #4415 cutover, but still
|
|
92
|
+
* probed for so follow-ups filed before the cutover are recognized and
|
|
93
|
+
* not re-filed. An HTML comment so it survives markdown rendering without
|
|
94
|
+
* leaking into the visible body, but stays indexable via `gh search`.
|
|
85
95
|
*
|
|
86
96
|
* @param {number} epicId
|
|
87
97
|
* @param {number} index — zero-based finding ordinal within the Epic.
|
|
@@ -91,6 +101,26 @@ export function buildIdempotencyMarker(epicId, index) {
|
|
|
91
101
|
return `<!-- audit-results-followup: epic-${epicId}-finding-${index} -->`;
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Build the content-hash idempotency marker embedded in freshly filed
|
|
106
|
+
* follow-up bodies. Derived from the finding's `lens|path|summary` triple
|
|
107
|
+
* so the marker is stable across sibling insert/remove/reorder churn in
|
|
108
|
+
* the source `audit-results` comment (Story #4415). An HTML comment so it
|
|
109
|
+
* survives markdown rendering but stays indexable via `gh search`.
|
|
110
|
+
*
|
|
111
|
+
* @param {number} epicId
|
|
112
|
+
* @param {{ lens?: string, path?: string, summary?: string }} finding
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function buildContentMarker(epicId, finding) {
|
|
116
|
+
const fp = contentFingerprint({
|
|
117
|
+
category: finding.lens,
|
|
118
|
+
path: finding.path,
|
|
119
|
+
title: finding.summary,
|
|
120
|
+
});
|
|
121
|
+
return `<!-- audit-results-followup: epic-${epicId}-${fp} -->`;
|
|
122
|
+
}
|
|
123
|
+
|
|
94
124
|
/**
|
|
95
125
|
* Parse the rendered audit-results markdown into a list of findings.
|
|
96
126
|
* The format produced by `epic-audit.md` Step 4 groups findings under
|
|
@@ -98,6 +128,13 @@ export function buildIdempotencyMarker(epicId, index) {
|
|
|
98
128
|
* emoji and embeds the cited path inside backticks. 🔴 critical findings
|
|
99
129
|
* are filtered out (they're blocking — the Epic stops on those).
|
|
100
130
|
*
|
|
131
|
+
* Findings the Phase 4 remediation loop already fixed on-branch are
|
|
132
|
+
* rendered under a **"Fixed on-branch"** heading (Story #4399) with a ✅
|
|
133
|
+
* prefix so they no longer parse as open findings. As a belt-and-suspenders
|
|
134
|
+
* guard the parser also skips every line inside a Fixed-on-branch section
|
|
135
|
+
* outright, so a remediated 🟡 Medium never spawns a ghost follow-up issue
|
|
136
|
+
* even if its line retains its original severity emoji.
|
|
137
|
+
*
|
|
101
138
|
* Pure. Exported so the parser can be unit-tested in isolation.
|
|
102
139
|
*
|
|
103
140
|
* @param {string} body
|
|
@@ -109,18 +146,25 @@ export function parseFindings(body) {
|
|
|
109
146
|
const lines = body.split(/\r?\n/);
|
|
110
147
|
let idx = 0;
|
|
111
148
|
let lens = 'unknown';
|
|
149
|
+
let inFixedSection = false;
|
|
112
150
|
for (const rawLine of lines) {
|
|
113
151
|
const trimmed = rawLine.trim();
|
|
114
152
|
if (trimmed.length === 0) continue;
|
|
115
153
|
|
|
116
|
-
//
|
|
117
|
-
// names a known audit family (`audit-*`).
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
154
|
+
// Any markdown heading resets the Fixed-on-branch guard and, when it
|
|
155
|
+
// names a known audit family (`audit-*`), sets the active lens. A
|
|
156
|
+
// "Fixed on-branch" heading opens a section whose entries never
|
|
157
|
+
// graduate (Story #4399).
|
|
158
|
+
const headingMatch = trimmed.match(/^#{2,6}\s+(.+)$/);
|
|
159
|
+
if (headingMatch) {
|
|
160
|
+
inFixedSection = /fixed on-branch/i.test(headingMatch[1]);
|
|
161
|
+
const lensMatch = headingMatch[1].match(/^(audit-[a-z0-9-]+)/i);
|
|
162
|
+
if (lensMatch) lens = lensMatch[1];
|
|
121
163
|
continue;
|
|
122
164
|
}
|
|
123
165
|
|
|
166
|
+
if (inFixedSection) continue;
|
|
167
|
+
|
|
124
168
|
let severity = null;
|
|
125
169
|
if (trimmed.startsWith('🔴')) {
|
|
126
170
|
// Critical Blocker — skip; never graduates.
|
|
@@ -180,10 +224,12 @@ export async function graduateAuditResults(opts = {}) {
|
|
|
180
224
|
spec: {
|
|
181
225
|
fnName: 'graduateAuditResults',
|
|
182
226
|
isAutoFileEnabled,
|
|
183
|
-
commentMarker:
|
|
184
|
-
noCommentReason:
|
|
227
|
+
commentMarker: VERIFICATION_RESULTS_MARKER,
|
|
228
|
+
noCommentReason: NO_VERIFICATION_RESULTS_COMMENT_REASON,
|
|
185
229
|
parseFindings,
|
|
186
|
-
|
|
230
|
+
buildContentMarker,
|
|
231
|
+
buildLegacyMarker: buildIdempotencyMarker,
|
|
232
|
+
crossRepoCommentAttrs: { graduator: 'audit-results' },
|
|
187
233
|
decorateRecord: (record, finding) => {
|
|
188
234
|
record.lens = finding.lens;
|
|
189
235
|
return record;
|