mandrel 1.61.0 → 1.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.agents/docs/SDLC.md +10 -3
  2. package/.agents/docs/workflows.md +1 -1
  3. package/.agents/scripts/check-action-pinning.js +260 -0
  4. package/.agents/scripts/check-arch-cycles.js +38 -14
  5. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  6. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  7. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  8. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  9. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  10. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  12. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  13. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  14. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  15. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  16. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  17. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  18. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  19. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  20. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  21. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  22. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  23. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  24. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  25. package/.agents/scripts/single-story-init.js +16 -3
  26. package/.agents/workflows/audit-architecture.md +9 -0
  27. package/.agents/workflows/deliver.md +87 -26
  28. package/.agents/workflows/helpers/deliver-epic.md +12 -5
  29. package/.agents/workflows/helpers/deliver-stories.md +13 -7
  30. package/.agents/workflows/plan.md +3 -1
  31. package/README.md +1 -1
  32. package/docs/CHANGELOG.md +40 -0
  33. package/lib/cli/registry.js +1 -1
  34. package/lib/cli/update.js +114 -8
  35. package/package.json +1 -1
@@ -143,6 +143,11 @@ From zero to shipped:
143
143
  a halt), re-run `/deliver <epicId>` — the wave loop picks up
144
144
  incomplete Stories from the dispatch manifest automatically. Standalone
145
145
  Stories (no `Epic: #N` reference) use `/deliver <storyId>` instead.
146
+ Mixed input — several Epics, or Epics plus standalone Stories — is
147
+ accepted in one invocation: `/deliver` composes a **sequential segment
148
+ plan** (the standalone-Story set as one segment, delivered first, then
149
+ each Epic as its own segment in input order) and executes the segments
150
+ one at a time through the same two path helpers, never interleaved.
146
151
 
147
152
  That is the whole happy path. Everything below is **detail** — branching
148
153
  conventions, HITL escalation, audit gates — that you only need when the
@@ -664,10 +669,12 @@ side-effects rather than inline calls at phase boundaries; the
664
669
  | **Standalone Story — plan** | `/plan` | Plan a one-off Story that does not belong to an Epic backlog. |
665
670
  | **Standalone Story — deliver** | `/deliver <storyId> [<storyId>...]` | Deliver one or more standalone Stories authored by `/plan`. |
666
671
  | **Standalone Story (worker)** | *helper* `helpers/single-story-deliver <storyId>` | Per-Story sub-agent called internally by `/deliver`; not an operator slash command. |
672
+ | **Mixed set** | `/deliver <ids...>` | Any mix of ≥1 Epics and standalone Stories. The router composes a sequential segment plan — standalone segment first, then Epic segments in input order — delegating each segment to the path helpers above. |
667
673
 
668
- The operator-facing entry points are `/deliver` (for Epics) and
669
- `/deliver` (for standalone Stories). The `helpers/` layer sits below
670
- both and is never invoked directly by the operator.
674
+ The single operator-facing entry point is `/deliver` it routes a lone
675
+ Epic, a standalone-Story set, or a mixed set (via the sequential segment
676
+ plan) to the right path helper(s). The `helpers/` layer sits below it and
677
+ is never invoked directly by the operator.
671
678
 
672
679
  ### Story-centric branching
673
680
 
@@ -44,7 +44,7 @@ description, edit the workflow file’s front-matter and regenerate.
44
44
  | `/audit-sre` | "Audit production-readiness for a release candidate: SLOs, observability, runbooks, error budgets, and rollback paths." |
45
45
  | `/audit-to-stories` | Convert findings produced by the audit-\* workflows into actionable GitHub Stories. Reads temp/audits/audit-\*-results.md, groups findings cross-audit, deduplicates against existing Issues by fingerprint, and either chains into /plan --idea or opens standalone Stories. |
46
46
  | `/audit-ux-ui` | Audit UX/UI consistency and design system adherence |
47
- | `/deliver` | Unified delivery entry point. Inspects the ticket type(s) and Epic-reference state of the supplied IDs, then routes to the Epic wave loop or the standalone multi-Story fan-out — preserving every flag and the parallel-delivery contract of the retired commands. |
47
+ | `/deliver` | Unified delivery entry point. Inspects the ticket type(s) and Epic-reference state of the supplied IDs, composes a sequential segment plan over any mix of Epics and standalone Stories, then delegates each segment to the Epic wave loop or the standalone multi-Story fan-out — preserving every flag and the parallel-delivery contract of the retired commands. |
48
48
  | `/explain` | Walk the operator through a code change until they genuinely understand it. Targets a PR, a branch, or the working-tree diff, then drives the `core/knowledge-transfer` skill (restate-first, why-ladder, mastery gates, persistent checklist) with an operator-controlled stop at every checkpoint. |
49
49
  | `/git-cleanup` | Tidy the local checkout in four phases: fast-forward `main`, prune stale remote-tracking refs, sweep merged branches (squash-aware), and triage `git stash` entries — each step gated by operator confirmation. |
50
50
  | `/git-commit-all` | Stage every untracked and modified file, then create a single conventional-commit on the current branch (no push). |
@@ -0,0 +1,260 @@
1
+ /**
2
+ * CLI: third-party GitHub Action pinning gate.
3
+ *
4
+ * Story #4079 (audit::devops). Closes a supply-chain regression window the
5
+ * `ci.yml` / `release-please.yml` comments *claimed* was guarded by a
6
+ * nonexistent `npm run audit-security` gate. There is no such npm script;
7
+ * `audit-security` is only a manual `/audit-security` slash-command lens.
8
+ * Nothing actually enforced that third-party `uses:` refs stay SHA-pinned,
9
+ * so a future edit reverting `trufflehog@<sha>` to `@main` would pass CI
10
+ * silently.
11
+ *
12
+ * This script scans `.github/workflows/*.yml` (and `*.yaml`), extracts every
13
+ * `uses:` ref, and fails the build when a **third-party** action (anything
14
+ * not under the first-party `actions/*` org) is pinned to a floating ref
15
+ * instead of a full 40-char commit SHA. First-party `actions/*` refs are
16
+ * allowed on major-version tags (`@v4`) — Dependabot's `github-actions`
17
+ * ecosystem bumps those in-place, matching the rationale in the workflow
18
+ * file headers.
19
+ *
20
+ * A "floating ref" is any of:
21
+ * - a branch head: `@main`, `@master`
22
+ * - a tag / partial-SHA that is not a full 40-hex-char commit SHA
23
+ * (`@v5`, `@v3.95.3`, `@release`, a 7-char short SHA, …)
24
+ *
25
+ * Contract:
26
+ * - Scans the workflows directory (default `.github/workflows`, override
27
+ * with `--dir <path>`).
28
+ * - Prints `<file>:<lineNo> <ref> — <reason>` for each violation, then a
29
+ * one-line summary even on a clean scan so operators see the "ok" signal.
30
+ * - With `--json`: writes a structured envelope to stdout and skips the
31
+ * human summary.
32
+ * - Exit codes: 0 = no violations; 1 = at least one floating third-party
33
+ * ref. A missing / empty workflows directory exits 0 (nothing to gate).
34
+ */
35
+
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import process from 'node:process';
39
+ import { runAsCli } from './lib/cli-utils.js';
40
+
41
+ /**
42
+ * Parse argv for `--dir <path>` and `--json`. Exported so unit tests can pin
43
+ * the parser.
44
+ *
45
+ * @param {string[]} argv
46
+ * @returns {{ dir: string | null, json: boolean }}
47
+ */
48
+ export function parseArgv(argv = []) {
49
+ let dir = null;
50
+ let json = false;
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const a = argv[i];
53
+ if (a === '--dir') {
54
+ const next = argv[i + 1];
55
+ if (next && !next.startsWith('--')) {
56
+ dir = next;
57
+ i += 1;
58
+ }
59
+ } else if (a === '--json') {
60
+ json = true;
61
+ }
62
+ }
63
+ return { dir, json };
64
+ }
65
+
66
+ /**
67
+ * Is the given ref suffix a full 40-char hex commit SHA?
68
+ *
69
+ * @param {string} ref The portion after the `@` in a `uses:` value.
70
+ * @returns {boolean}
71
+ */
72
+ export function isFullSha(ref) {
73
+ return /^[0-9a-f]{40}$/i.test(ref);
74
+ }
75
+
76
+ /**
77
+ * Is the action a first-party `actions/*` action (e.g. `actions/checkout`)?
78
+ * First-party refs are allowed to float on major-version tags because
79
+ * Dependabot's `github-actions` ecosystem bumps them in-place.
80
+ *
81
+ * Local (`./…`) and reusable-workflow (`owner/repo/.github/workflows/x.yml`)
82
+ * refs and Docker refs (`docker://…`) are out of scope for the SHA-pin gate;
83
+ * `isFirstParty` only matters for `owner/repo[@ref]` registry actions.
84
+ *
85
+ * @param {string} action The portion before the `@` in a `uses:` value.
86
+ * @returns {boolean}
87
+ */
88
+ export function isFirstParty(action) {
89
+ return /^actions\//.test(action);
90
+ }
91
+
92
+ /**
93
+ * Pure helper: scan a single workflow file's text for `uses:` refs and return
94
+ * the violations. A violation is a third-party `owner/repo@ref` where `ref`
95
+ * is not a full 40-char SHA.
96
+ *
97
+ * Skips:
98
+ * - local actions (`uses: ./path`)
99
+ * - Docker refs (`uses: docker://…`)
100
+ * - first-party `actions/*` refs (allowed on major-version tags)
101
+ * - refs with no `@` (pinned by default branch implicitly — flagged as a
102
+ * violation: an unpinned third-party ref floats on the default branch)
103
+ *
104
+ * @param {string} file Relative file label used in violation rows.
105
+ * @param {string} text The file contents.
106
+ * @returns {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>}
107
+ */
108
+ export function scanWorkflowText(file, text) {
109
+ const violations = [];
110
+ const lines = text.split(/\r?\n/);
111
+ // Match `uses:` values, optionally quoted. The value runs until whitespace
112
+ // or a `#` comment. Capture the raw value for downstream parsing.
113
+ const usesRe = /^\s*(?:-\s*)?uses:\s*['"]?([^'"#\s]+)['"]?/;
114
+ for (let i = 0; i < lines.length; i += 1) {
115
+ const m = usesRe.exec(lines[i]);
116
+ if (!m) continue;
117
+ const value = m[1];
118
+ const lineNo = i + 1;
119
+ // Local actions and Docker refs are out of scope for the SHA-pin gate.
120
+ if (value.startsWith('./') || value.startsWith('docker://')) continue;
121
+ const atIndex = value.indexOf('@');
122
+ const action = atIndex === -1 ? value : value.slice(0, atIndex);
123
+ const ref = atIndex === -1 ? null : value.slice(atIndex + 1);
124
+ // First-party actions/* may float on major-version tags.
125
+ if (isFirstParty(action)) continue;
126
+ if (ref === null) {
127
+ violations.push({
128
+ file,
129
+ line: lineNo,
130
+ action,
131
+ ref: null,
132
+ reason: 'third-party action with no ref floats on the default branch',
133
+ });
134
+ continue;
135
+ }
136
+ if (!isFullSha(ref)) {
137
+ const floating = ref === 'main' || ref === 'master';
138
+ violations.push({
139
+ file,
140
+ line: lineNo,
141
+ action,
142
+ ref,
143
+ reason: floating
144
+ ? `third-party action pinned to branch head @${ref} (CWE-1357)`
145
+ : `third-party action @${ref} is not a full 40-char commit SHA`,
146
+ });
147
+ }
148
+ }
149
+ return violations;
150
+ }
151
+
152
+ /**
153
+ * Enumerate workflow files (`*.yml` / `*.yaml`) directly under `dir`.
154
+ * Returns absolute paths sorted for deterministic output. A missing directory
155
+ * yields an empty list.
156
+ *
157
+ * @param {string} dir Absolute workflows directory.
158
+ * @returns {string[]}
159
+ */
160
+ export function listWorkflowFiles(dir) {
161
+ let entries;
162
+ try {
163
+ entries = fs.readdirSync(dir, { withFileTypes: true });
164
+ } catch {
165
+ return [];
166
+ }
167
+ return entries
168
+ .filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
169
+ .map((e) => path.join(dir, e.name))
170
+ .sort();
171
+ }
172
+
173
+ /**
174
+ * Pure helper: render the human-readable report. One line per violation
175
+ * followed by a one-line summary. The summary carries a `(gate fail)` /
176
+ * `(ok)` marker so the result is visible in CI output.
177
+ *
178
+ * @param {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>} violations
179
+ * @returns {string}
180
+ */
181
+ export function renderReport(violations) {
182
+ const lines = [];
183
+ for (const v of violations) {
184
+ const refLabel = v.ref === null ? '(no ref)' : `@${v.ref}`;
185
+ lines.push(`${v.file}:${v.line} ${v.action}${refLabel} — ${v.reason}`);
186
+ }
187
+ const tag = violations.length > 0 ? '(gate fail)' : '(ok)';
188
+ lines.push(`[action-pinning] violations=${violations.length} ${tag}`);
189
+ return lines.join('\n');
190
+ }
191
+
192
+ /**
193
+ * Top-level CLI entry. Exported so tests can drive the full pipeline against a
194
+ * fixture workflows directory without touching the repo's real workflows.
195
+ *
196
+ * @param {{
197
+ * argv?: string[],
198
+ * cwd?: string,
199
+ * stdout?: { write: (s: string) => void },
200
+ * stderr?: { write: (s: string) => void },
201
+ * }} [opts]
202
+ * @returns {Promise<number>} exit code: 0 = clean; 1 = floating third-party ref
203
+ */
204
+ export async function runCli({
205
+ argv = process.argv.slice(2),
206
+ cwd = process.cwd(),
207
+ stdout = process.stdout,
208
+ stderr = process.stderr,
209
+ } = {}) {
210
+ const { dir, json } = parseArgv(argv);
211
+ const resolvedDir = path.resolve(
212
+ cwd,
213
+ dir ?? path.join('.github', 'workflows'),
214
+ );
215
+
216
+ const files = listWorkflowFiles(resolvedDir);
217
+ const violations = [];
218
+ for (const file of files) {
219
+ let text;
220
+ try {
221
+ text = fs.readFileSync(file, 'utf-8');
222
+ } catch {
223
+ continue;
224
+ }
225
+ violations.push(...scanWorkflowText(path.relative(cwd, file), text));
226
+ }
227
+
228
+ const exitCode = violations.length > 0 ? 1 : 0;
229
+
230
+ if (json) {
231
+ const envelope = {
232
+ kind: 'action-pinning-report',
233
+ dir: resolvedDir,
234
+ filesScanned: files.length,
235
+ violations,
236
+ exitCode,
237
+ };
238
+ stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
239
+ } else {
240
+ if (files.length === 0) {
241
+ stderr.write(
242
+ `[action-pinning] ⚠ no workflow files found under ${resolvedDir}\n`,
243
+ );
244
+ }
245
+ stdout.write(`\n--- action-pinning scan ---\n`);
246
+ stdout.write(`${renderReport(violations)}\n`);
247
+ }
248
+
249
+ return exitCode;
250
+ }
251
+
252
+ async function main() {
253
+ return runCli();
254
+ }
255
+
256
+ runAsCli(import.meta.url, main, {
257
+ source: 'action-pinning',
258
+ propagateExitCode: true,
259
+ errorPrefix: '[action-pinning] ❌ Fatal error',
260
+ });
@@ -1,10 +1,19 @@
1
1
  /**
2
2
  * CLI: ratchet-down architecture gate for import cycles (Story #3991).
3
3
  *
4
- * Walks every `.js` file under `.agents/scripts/` (excluding
5
- * `node_modules`), parses relative static-import edges
6
- * (`from './…/x.js'`), detects directed cycles via DFS, and compares
7
- * them against the committed allowlist at `baselines/arch-cycles.json`.
4
+ * Walks every `.js` file across the project's **distributed surface**
5
+ * (the `files[]` set published to npm — `.agents/scripts/`, `bin/`, and
6
+ * the root `lib/`, excluding `node_modules`), parses relative
7
+ * static-import edges (`from './…/x.js'`), detects directed cycles via
8
+ * DFS, and compares them against the committed allowlist at
9
+ * `baselines/arch-cycles.json`.
10
+ *
11
+ * The multi-root scan resolves every root into a **single** import graph
12
+ * keyed by repository-relative module ids (Story #4071). This lets
13
+ * `findCycles` catch cycles that cross the documented lifecycle↔runtime
14
+ * partition — e.g. a `bin/` lifecycle script and an `.agents/scripts/lib`
15
+ * runtime module importing each other — which a single-root scan cannot
16
+ * see because it only walks one side of the partition.
8
17
  *
9
18
  * Ratchet semantics mirror `check-dead-exports.js`:
10
19
  * - Any detected cycle NOT in the allowlist → exit 1, cycle path printed.
@@ -19,8 +28,8 @@
19
28
  * Flags:
20
29
  * --baseline <path> override the allowlist path (default
21
30
  * `baselines/arch-cycles.json`, resolved from cwd)
22
- * --root <path> override the scanned root (default
23
- * `.agents/scripts`, resolved from cwd)
31
+ * --root <path> scan a single explicit root instead of the default
32
+ * distributed surface, relativized against that root
24
33
  * --json write the structured envelope to stdout
25
34
  */
26
35
 
@@ -29,6 +38,16 @@ import path from 'node:path';
29
38
  import process from 'node:process';
30
39
  import { runAsCli } from './lib/cli-utils.js';
31
40
 
41
+ /**
42
+ * Default scan roots making up the project's distributed surface — the
43
+ * directories published to npm via `package.json` `files[]`. Resolving
44
+ * them into one graph (relativized against the repo root) means a cycle
45
+ * crossing two roots is visible to `findCycles`.
46
+ *
47
+ * @type {string[]}
48
+ */
49
+ export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
50
+
32
51
  /**
33
52
  * Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
34
53
  * Exported so unit tests can pin the parser.
@@ -304,22 +323,27 @@ export async function runCli({
304
323
  stderr = process.stderr,
305
324
  } = {}) {
306
325
  const { baselinePath, rootPath, json } = parseArgv(argv);
307
- const resolvedRoot = path.resolve(
308
- cwd,
309
- rootPath ?? path.join('.agents', 'scripts'),
326
+ // With an explicit `--root`, scan that single root and relativize ids
327
+ // against it (unchanged contract). Without it, scan the full distributed
328
+ // surface and relativize every id against the repo root (`cwd`) so edges
329
+ // that cross two roots resolve into a single graph.
330
+ const graphRoot = rootPath ? path.resolve(cwd, rootPath) : path.resolve(cwd);
331
+ const scanDirs = (rootPath ? [rootPath] : DEFAULT_ROOTS).map((dir) =>
332
+ path.resolve(cwd, dir),
310
333
  );
311
334
  const resolvedBaselinePath = path.resolve(
312
335
  cwd,
313
336
  baselinePath ?? path.join('baselines', 'arch-cycles.json'),
314
337
  );
315
- if (!fs.existsSync(resolvedRoot)) {
316
- throw new Error(`[arch-cycles] scan root not found: ${resolvedRoot}`);
338
+ const presentScanDirs = scanDirs.filter((dir) => fs.existsSync(dir));
339
+ if (presentScanDirs.length === 0) {
340
+ throw new Error(`[arch-cycles] no scan root found: ${scanDirs.join(', ')}`);
317
341
  }
318
342
  const baseline = loadBaseline(resolvedBaselinePath);
319
343
  const allowlisted = Array.isArray(baseline?.cycles) ? baseline.cycles : [];
320
344
 
321
- const files = collectJsFiles(resolvedRoot);
322
- const graph = buildGraph(files, resolvedRoot);
345
+ const files = presentScanDirs.flatMap((dir) => collectJsFiles(dir));
346
+ const graph = buildGraph(files, graphRoot);
323
347
  const detected = findCycles(graph);
324
348
  const diff = diffCycles(allowlisted, detected);
325
349
  const exitCode = diff.added.length > 0 ? 1 : 0;
@@ -327,7 +351,7 @@ export async function runCli({
327
351
  if (json) {
328
352
  const envelope = {
329
353
  kind: 'arch-cycles-report',
330
- root: resolvedRoot,
354
+ root: graphRoot,
331
355
  baselinePath: resolvedBaselinePath,
332
356
  allowlisted: allowlisted.map(normalizeCycle),
333
357
  detected,