mandrel 1.76.0 → 1.78.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 (68) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/docs/workflows.md +19 -0
  3. package/.agents/schemas/agentrc.schema.json +1 -1
  4. package/.agents/schemas/dispatch-manifest.json +1 -1
  5. package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
  6. package/.agents/schemas/loop-unit.schema.json +70 -0
  7. package/.agents/schemas/validation-evidence.schema.json +2 -1
  8. package/.agents/scripts/audit-to-stories.js +43 -1
  9. package/.agents/scripts/check-doc-links.js +24 -1
  10. package/.agents/scripts/check-loop-units.js +204 -0
  11. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  12. package/.agents/scripts/evidence-gate.js +48 -12
  13. package/.agents/scripts/generate-workflows-doc.js +37 -4
  14. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  15. package/.agents/scripts/lib/cli-args.js +6 -0
  16. package/.agents/scripts/lib/close-validation/process.js +61 -5
  17. package/.agents/scripts/lib/close-validation/runner.js +42 -9
  18. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  19. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  20. package/.agents/scripts/lib/config-resolver.js +4 -1
  21. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  22. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  23. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
  24. package/.agents/scripts/lib/mandrel-catalog.js +36 -0
  25. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  26. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  27. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
  28. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  29. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  30. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  34. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  35. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  36. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  37. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  38. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  39. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  40. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  41. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  43. package/.agents/scripts/lib/validation-evidence.js +63 -25
  44. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  45. package/.agents/scripts/providers/github/branch-protection.js +1 -1
  46. package/.agents/scripts/providers/github/errors.js +53 -2
  47. package/.agents/scripts/providers/github/labels.js +1 -1
  48. package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
  49. package/.agents/scripts/resync-status-column.js +5 -0
  50. package/.agents/scripts/run-coverage.js +85 -45
  51. package/.agents/scripts/run-lint.js +11 -0
  52. package/.agents/scripts/single-story-init.js +22 -29
  53. package/.agents/scripts/story-init.js +38 -63
  54. package/.agents/scripts/story-phase.js +46 -4
  55. package/.agents/scripts/sync-claude-commands.js +112 -29
  56. package/.agents/scripts/update-maintainability-baseline.js +19 -76
  57. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  59. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  60. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  61. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  62. package/.agents/workflows/loops/README.md +65 -0
  63. package/.agents/workflows/loops/fix-failing-tests.md +74 -0
  64. package/.agents/workflows/loops/nightly-audit.md +71 -0
  65. package/.agents/workflows/loops/watch-ci.md +68 -0
  66. package/docs/CHANGELOG.md +51 -0
  67. package/package.json +1 -1
  68. package/.agents/scripts/providers/github/transient-retry.js +0 -62
@@ -138,7 +138,7 @@ top-level keys are validation errors.
138
138
  | `worktreeIsolation` | No | `object` | — | Nested configuration block. |
139
139
  | `worktreeIsolation.enabled` | No | `boolean` | — | — |
140
140
  | `worktreeIsolation.root` | No | `string` | — | — |
141
- | `worktreeIsolation.nodeModulesStrategy` | No | `"per-worktree"` \| `"symlink"` \| `"pnpm-store"` | — | — |
141
+ | `worktreeIsolation.nodeModulesStrategy` | No | `"per-worktree"` \| `"clone"` \| `"symlink"` \| `"pnpm-store"` | — | — |
142
142
  | `worktreeIsolation.primeFromPath` | No | `string` \| `null` | — | — |
143
143
  | `worktreeIsolation.allowSymlinkOnWindows` | No | `boolean` | — | — |
144
144
  | `worktreeIsolation.reapOnSuccess` | No | `boolean` | — | — |
@@ -526,7 +526,7 @@ checkout's HEAD.
526
526
  | ----------------------- | --------------- | ---------------- | ----------------------------------------------------------- |
527
527
  | `enabled` | No | `false` | Master switch. |
528
528
  | `root` | Conditional | `.worktrees` | Required when `enabled: true`. Worktree parent directory. |
529
- | `nodeModulesStrategy` | No | `per-worktree` | One of `per-worktree`, `symlink`, `pnpm-store`. |
529
+ | `nodeModulesStrategy` | No | `clone` (darwin/linux); `per-worktree` (Windows) | One of `per-worktree`, `clone`, `symlink`, `pnpm-store`. `clone` copy-on-write (reflink/clonefile) clones the donor's `node_modules` and skips the per-tree install on a byte-exact lockfile match, falling back to `per-worktree` on any failure. |
530
530
  | `primeFromPath` | No | `null` | Optional source path used to prime `node_modules`. |
531
531
  | `allowSymlinkOnWindows` | No | `false` | Permit symlink strategy on Windows (requires admin/dev mode). |
532
532
  | `reapOnSuccess` | No | `true` | Reap the worktree after a successful Story close. |
@@ -20,6 +20,13 @@ slash command (e.g. `/deliver`). The projection writes only
20
20
  `.claude/commands/<name>.md` — there is no plugin manifest and no
21
21
  marketplace listing. The commands load in every Claude Code environment.
22
22
 
23
+ Loop units are the one namespaced exception: files under
24
+ `.agents/workflows/loops/<name>.md` project to
25
+ `.claude/commands/loops/<name>.md` and are invoked as the namespaced
26
+ `/loops:<name>` command. On hosts that flatten subdirectory commands the
27
+ same unit surfaces under the flat fallback `/loops-<name>`. They are
28
+ listed separately in the **Loops namespace** section below.
29
+
23
30
  This index is regenerated from each workflow’s front-matter `description:`
24
31
  by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
25
32
  fails when it drifts from the on-disk workflow set. To change a command’s
@@ -54,3 +61,15 @@ description, edit the workflow file’s front-matter and regenerate.
54
61
  | `/qa-assist` | Human-led QA assist loop — set up, then ride a rolling multi-observation intake session. The operator reports observations in any order; the agent enriches each (repro + root-cause file:line + coverage verdict for bugs; analysis + options + recommendation for enhancements), asks clarifying questions only when ambiguous, and appends a redacted ledger item — recording, never planning — to a persistent, resumable session under temp/qa/. Only when the operator says they are done does it review the full ledger and hand off to /plan. |
55
62
  | `/qa-explore` | Agent-led exploratory-QA loop — the agent Plans a surface with an explicit static-vs-drive method choice, drives it (browser MCP or static), and captures ledger items read-only, then Triages — a bounded per-surface session, HITL-gated at every phase transition, routed through the shared dedup/coverage/classification/missing-test/redaction/session core under temp/qa/ |
56
63
  | `/qa-run` | Drive Gherkin scenarios through a real browser as an agent-driven QA sweep |
64
+
65
+ ## Loops namespace (3)
66
+
67
+ Loop units project to `.claude/commands/loops/<name>.md` and are invoked
68
+ as `/loops:<name>` (flat fallback `/loops-<name>` on hosts that flatten
69
+ subdirectory commands).
70
+
71
+ | Command | Description |
72
+ | --- | --- |
73
+ | `/loops:fix-failing-tests` | Self-paced convergence loop that drives a red test suite to green. Each round reads the latest failure, applies the smallest fix, and re-runs the verify oracle (`npm test`); the loop terminates when the oracle exits 0. The host (`/loop`) owns iteration and pacing — mandrel supplies the action, the goal, and the terminating oracle. |
74
+ | `/loops:nightly-audit` | Cron maintenance loop that runs a nightly audit sweep over the repository and files actionable findings. Each run executes the audit workflows and routes the results; the host (`/schedule` or a cron-driven `/loop`) owns the cadence. verify is optional for a cron loop — the scheduler owns iteration, so this unit ships the action and goal, not a terminating oracle. |
75
+ | `/loops:watch-ci` | Interval watch loop that polls a pull request's CI checks until they settle. Each round runs `gh pr checks` and reports the delta; the host (`/loop 5m`) owns the cadence and re-invokes the unit on its schedule. verify is optional for an interval loop — the externally-scheduled host owns iteration, so this unit ships the action and goal, not a terminating oracle. |
@@ -635,7 +635,7 @@
635
635
  },
636
636
  "nodeModulesStrategy": {
637
637
  "type": "string",
638
- "enum": ["per-worktree", "symlink", "pnpm-store"]
638
+ "enum": ["per-worktree", "clone", "symlink", "pnpm-store"]
639
639
  },
640
640
  "primeFromPath": {
641
641
  "type": ["string", "null"],
@@ -125,7 +125,7 @@
125
125
  },
126
126
  "verify": {
127
127
  "type": "array",
128
- "description": "Inline verify commands or shell snippets executed at close to prove the Story.",
128
+ "description": "Inline verify commands or shell snippets run by the pre-close acceptance self-eval critic as required, binding evidence that the Story's acceptance criteria are met — NOT by the close-validation gate chain (which runs only the canonical gate list).",
129
129
  "items": { "type": "string" }
130
130
  },
131
131
  "dependsOn": {
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/lifecycle/loop.tick.schema.json",
4
+ "title": "loop.tick",
5
+ "description": "Emitted once per pass of a host-driven loop (e.g. a recurring loop command or a long-running poll) so each round lands an inspectable ledger record. Surfaces the loop as forward-progress evidence the /deliver idle watchdog already scans — distinct from story.heartbeat, which carries Story-phase info for a single in-flight Story. A loop is not tied to a Story tier: loop.tick carries a free-form loopName, a monotonic round counter, the configured cadence, and a status so a host loop never runs silently. cadence is the loop's configured interval label (e.g. '5m', 'self-paced'); status is the per-round verdict (running while the loop continues, done when it terminates normally, blocked when it stalls).",
6
+ "type": "object",
7
+ "required": ["event", "loopName", "round", "cadence", "status", "timestamp"],
8
+ "properties": {
9
+ "event": { "type": "string", "const": "loop.tick" },
10
+ "loopName": { "type": "string", "minLength": 1 },
11
+ "round": { "type": "integer", "minimum": 0 },
12
+ "cadence": { "type": "string", "minLength": 1 },
13
+ "status": {
14
+ "type": "string",
15
+ "enum": ["running", "done", "blocked"]
16
+ },
17
+ "timestamp": { "type": "string", "format": "date-time" }
18
+ },
19
+ "additionalProperties": false
20
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/loop-unit.schema.json",
4
+ "version": "1.0.0",
5
+ "title": "Loop Unit (recurring-work definition)",
6
+ "description": "Schema for the YAML frontmatter of a loop unit — a markdown file under .agents/workflows/loops/ that defines a recurring/iterative unit of work with a checkable definition of done. The loop block carries the cadence (how the loop is paced), the goal (what the loop is trying to achieve), an optional verify command/array (the at-keyboard checks proving a round is complete — required for self-paced cadence, optional for interval/cron), a maxRounds backstop, and an onExhaust policy describing what happens when the round cap is hit without the goal being met.",
7
+ "type": "object",
8
+ "additionalProperties": true,
9
+ "required": ["loop"],
10
+ "properties": {
11
+ "$schema": {
12
+ "type": "string",
13
+ "description": "Optional reference to this schema file so YAML editors with $schema-aware autocomplete can resolve it from the unit file itself."
14
+ },
15
+ "description": {
16
+ "type": "string",
17
+ "description": "Optional human-readable summary of what this loop unit does."
18
+ },
19
+ "loop": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "required": ["cadence", "goal"],
23
+ "description": "The loop definition block. Carries the cadence, goal, conditional verify, round cap, and exhaustion policy.",
24
+ "properties": {
25
+ "cadence": {
26
+ "type": "string",
27
+ "enum": ["self-paced", "interval", "cron"],
28
+ "description": "How the loop is paced. 'self-paced' lets the agent decide when to run the next round and therefore MUST carry a verify[] so each round has a checkable definition of done. 'interval' and 'cron' are externally scheduled, so verify is optional."
29
+ },
30
+ "goal": {
31
+ "type": "string",
32
+ "minLength": 1,
33
+ "description": "Required. What the loop is trying to achieve — the standing objective each round works toward."
34
+ },
35
+ "verify": {
36
+ "description": "Command(s) that prove a round is complete. A single command string or an array of command strings. Required when cadence is 'self-paced'; optional for 'interval' / 'cron'.",
37
+ "oneOf": [
38
+ { "type": "string", "minLength": 1 },
39
+ {
40
+ "type": "array",
41
+ "items": { "type": "string", "minLength": 1 },
42
+ "minItems": 1
43
+ }
44
+ ]
45
+ },
46
+ "maxRounds": {
47
+ "type": "integer",
48
+ "minimum": 1,
49
+ "description": "Optional positive-integer backstop on the number of loop rounds before the onExhaust policy fires."
50
+ },
51
+ "onExhaust": {
52
+ "type": "string",
53
+ "enum": ["block", "report", "hand-back"],
54
+ "description": "What happens when maxRounds is hit without the goal met. 'block' transitions to a HITL gate; 'report' emits a summary and stops; 'hand-back' returns control to the caller."
55
+ }
56
+ },
57
+ "allOf": [
58
+ {
59
+ "if": {
60
+ "properties": { "cadence": { "const": "self-paced" } },
61
+ "required": ["cadence"]
62
+ },
63
+ "then": {
64
+ "required": ["verify"]
65
+ }
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "validation-evidence",
4
4
  "title": "Validation Evidence",
5
- "description": "Per-scope record of which validation gates have passed against which commit SHA. Written by lib/validation-evidence.js under the per-Epic temp tree at temp/epic-<epicId>/validation-evidence.json (Epic-scoped) or temp/epic-<epicId>/stories/story-<storyId>/validation-evidence.json (Story-scoped); both are gitignored via temp/. Consumed by close-validation, epic-code-review, and /deliver Phase 3 (close-validation) to skip identical re-runs against an already-validated tree. The `storyId` field carries the scope id and equals the epic id for Epic-scoped records.",
5
+ "description": "Per-scope record of which validation gates have passed against which commit SHA. Written by lib/validation-evidence.js under the per-Epic temp tree at temp/epic-<epicId>/validation-evidence.json (Epic-scoped) or temp/epic-<epicId>/stories/story-<storyId>/validation-evidence.json (Story-scoped). Standalone Stories (no parent Epic) use a storyId-anchored keyspace at temp/standalone/stories/story-<storyId>/validation-evidence.json. All paths are gitignored via temp/. Consumed by close-validation, epic-code-review, the acceptance self-eval critic, and /deliver Phase 3 (close-validation) to skip identical re-runs against an already-validated tree. The `storyId` field carries the scope id and equals the epic id for Epic-scoped records.",
6
6
  "type": "object",
7
7
  "required": ["storyId", "schemaVersion", "records"],
8
8
  "properties": {
@@ -32,6 +32,7 @@
32
32
  "gateName": {
33
33
  "type": "string",
34
34
  "enum": [
35
+ "typecheck",
35
36
  "lint",
36
37
  "test",
37
38
  "format",
@@ -40,6 +40,7 @@ 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
42
  import { Logger } from './lib/Logger.js';
43
+ import { parse as parseStoryBody } from './lib/story-body/story-body.js';
43
44
 
44
45
  const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
45
46
  const DEFAULT_GLOB = 'temp/audits/audit-*-results.md';
@@ -227,6 +228,46 @@ function loadPlan(planPath) {
227
228
  return JSON.parse(fs.readFileSync(planPath, 'utf8'));
228
229
  }
229
230
 
231
+ /**
232
+ * Build every eligible group into a `{ title, body, labels }` Story object and
233
+ * gate the batch against the inline-contract bar BEFORE any issue is opened.
234
+ *
235
+ * The `--emit-stories` path opens GitHub issues directly (no decomposer
236
+ * round-trip), so `assertEveryStoryHasInlineContract` never runs against these
237
+ * bodies. This gate restores that guarantee at the standalone seam: each
238
+ * emitted body is re-parsed through the canonical `story-body` parser and must
239
+ * carry a non-empty `acceptance[]` AND a non-empty `verify[]`. A body that
240
+ * fails throws, surfacing the gap instead of opening an ungated Story
241
+ * (Story #4270).
242
+ *
243
+ * @param {Array<{ group: object }>} eligible — classifications eligible to create.
244
+ * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} edges — sequencing edges.
245
+ * @returns {Array<{ title: string, body: string, labels: string[] }>}
246
+ */
247
+ function buildAndGateStories(eligible, edges) {
248
+ const built = eligible.map((g) => buildStoryBody({ group: g, edges }));
249
+ const offenders = [];
250
+ for (const story of built) {
251
+ const { body } = parseStoryBody(story.body);
252
+ const ok =
253
+ Array.isArray(body.acceptance) &&
254
+ body.acceptance.length > 0 &&
255
+ Array.isArray(body.verify) &&
256
+ body.verify.length > 0;
257
+ if (!ok) offenders.push(story.title);
258
+ }
259
+ if (offenders.length > 0) {
260
+ throw new Error(
261
+ `inline-contract gate failed: ${offenders.length} generated audit Story/Stories lack a non-empty acceptance[] + verify[] contract: ${offenders
262
+ .map((t) => `"${t}"`)
263
+ .join(
264
+ ', ',
265
+ )}. No issues were opened. Every emitted Story must carry both arrays.`,
266
+ );
267
+ }
268
+ return built;
269
+ }
270
+
230
271
  function persist(text, outPath) {
231
272
  if (!outPath) {
232
273
  process.stdout.write(text);
@@ -242,6 +283,7 @@ export const __testing = {
242
283
  buildPlan,
243
284
  loadProvider,
244
285
  dedupSkippedWarning,
286
+ buildAndGateStories,
245
287
  };
246
288
 
247
289
  async function main() {
@@ -289,7 +331,7 @@ async function main() {
289
331
  const eligible = (plan.classifications ?? [])
290
332
  .filter((c) => c.action === 'create')
291
333
  .map((c) => c.group);
292
- const built = eligible.map((g) => buildStoryBody({ group: g }));
334
+ const built = buildAndGateStories(eligible, plan.edges ?? []);
293
335
  const out = values.json
294
336
  ? JSON.stringify(built, null, 2)
295
337
  : built
@@ -258,7 +258,14 @@ function stripAnchorAndQuery(target) {
258
258
  // `temp/epic-[ID]/tickets.json` (preceded by `]`).
259
259
  // - the following char is NOT a word char, `-`, or `.`, so file
260
260
  // extensions like `/tickets.json` and identifier suffixes don't match.
261
- const SLASH_TOKEN_RE = /(?<![\w/:.>\])])\/([a-z][a-z0-9-]*)(?![\w.-])/g;
261
+ // The optional `(?::[a-z][a-z0-9-]*)?` tail captures the namespaced
262
+ // `/loops:<name>` command form (Story #4289). Without it the matcher would
263
+ // stop at `loops` and try to resolve `.agents/workflows/loops.md`, which does
264
+ // not exist — loop units live under `loops/<name>.md`. The resolver below
265
+ // splits the captured `loops:<name>` token on the `:` to resolve the
266
+ // namespaced path.
267
+ const SLASH_TOKEN_RE =
268
+ /(?<![\w/:.>\])])\/([a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?)(?![\w.-])/g;
262
269
 
263
270
  export function extractSlashTokens(masked) {
264
271
  const out = [];
@@ -334,6 +341,22 @@ export function checkFile(absPath, repoRoot) {
334
341
  for (const { token, line } of slashTokens) {
335
342
  if (RETIRED_COMMANDS.has(token)) continue;
336
343
  if (SLASH_ALLOWLIST.has(token)) continue;
344
+ // Namespaced loop commands (`/loops:<name>`, Story #4289) resolve to a
345
+ // loop unit under `.agents/workflows/loops/<name>.md`. Split on the `:`
346
+ // and resolve the namespaced path rather than a flat `loops:<name>.md`.
347
+ if (token.includes(':')) {
348
+ const [ns, name] = token.split(':');
349
+ const nsFile = path.join(workflowsDir, ns, `${name}.md`);
350
+ if (!fs.existsSync(nsFile)) {
351
+ violations.push({
352
+ file: relFile,
353
+ line,
354
+ kind: 'unknown-command',
355
+ message: `slash command /${token} does not resolve to .agents/workflows/${ns}/${name}.md`,
356
+ });
357
+ }
358
+ continue;
359
+ }
337
360
  const workflowFile = path.join(workflowsDir, `${token}.md`);
338
361
  const helperFile = path.join(workflowsDir, 'helpers', `${token}.md`);
339
362
  if (!fs.existsSync(workflowFile) && !fs.existsSync(helperFile)) {
@@ -0,0 +1,204 @@
1
+ /**
2
+ * CLI: loop-unit lint gate (Story #4288, Epic #4284).
3
+ *
4
+ * Validates every loop-unit markdown file under `.agents/workflows/loops/`
5
+ * against `.agents/schemas/loop-unit.schema.json` via
6
+ * `lib/loop-units/validate-loop-unit.js`. An absent or empty loops
7
+ * directory is a **clean pass** (exit 0) — the gate only fails when a unit
8
+ * file is present and invalid.
9
+ *
10
+ * On any invalid (or structurally unparseable) unit the CLI prints a
11
+ * message naming the offending file and the missing/invalid field, then
12
+ * exits non-zero. This is wired into `npm run lint` so a malformed loop
13
+ * unit fails the lint gate.
14
+ *
15
+ * Flags:
16
+ * --dir <path> override the loops directory (default
17
+ * `.agents/workflows/loops`, resolved from cwd)
18
+ * --json write a structured envelope to stdout instead of the
19
+ * human-readable preview
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import process from 'node:process';
25
+ import { runAsCli } from './lib/cli-utils.js';
26
+ import {
27
+ LoopUnitParseError,
28
+ validateLoopUnit,
29
+ } from './lib/loop-units/validate-loop-unit.js';
30
+
31
+ export const DEFAULT_LOOPS_DIR = path.join('.agents', 'workflows', 'loops');
32
+
33
+ /**
34
+ * Parse argv for `--dir <path>` and `--json`. Exported so tests can pin
35
+ * the parser.
36
+ *
37
+ * @param {string[]} argv
38
+ * @returns {{ dir: string | null, json: boolean }}
39
+ */
40
+ export function parseArgv(argv = []) {
41
+ let dir = null;
42
+ let json = false;
43
+ for (let i = 0; i < argv.length; i += 1) {
44
+ const a = argv[i];
45
+ if (a === '--dir') {
46
+ const next = argv[i + 1];
47
+ if (next && !next.startsWith('--')) {
48
+ dir = next;
49
+ i += 1;
50
+ }
51
+ } else if (a === '--json') {
52
+ json = true;
53
+ }
54
+ }
55
+ return { dir, json };
56
+ }
57
+
58
+ /**
59
+ * `README.md` (any case) under the loops directory is namespace
60
+ * documentation, not a loop unit — it carries no `loop:` frontmatter and is
61
+ * not projected as a `/loops:` command (see `sync-claude-commands.js`). It is
62
+ * excluded from the loop-unit collector so the lint gate never flags the
63
+ * directory's own README as a malformed unit.
64
+ *
65
+ * @param {string} name a directory-entry basename
66
+ * @returns {boolean}
67
+ */
68
+ export function isLoopUnitFile(name) {
69
+ return name.endsWith('.md') && name.toLowerCase() !== 'readme.md';
70
+ }
71
+
72
+ /**
73
+ * Collect `*.md` loop-unit files directly under `dir`, sorted. Returns an
74
+ * empty array when the directory is absent (the clean-pass case). The
75
+ * directory's `README.md` is excluded — it is namespace documentation, not a
76
+ * unit (see `isLoopUnitFile`).
77
+ *
78
+ * @param {string} dir absolute path
79
+ * @returns {string[]} absolute paths
80
+ */
81
+ export function collectLoopUnitFiles(dir) {
82
+ let entries;
83
+ try {
84
+ entries = fs.readdirSync(dir, { withFileTypes: true });
85
+ } catch {
86
+ return [];
87
+ }
88
+ return entries
89
+ .filter((e) => e.isFile() && isLoopUnitFile(e.name))
90
+ .map((e) => path.join(dir, e.name))
91
+ .sort();
92
+ }
93
+
94
+ /**
95
+ * Validate every loop unit under `dir`. Returns the per-file results and a
96
+ * roll-up `failures` array carrying `{ file, issues }` for each invalid or
97
+ * unparseable unit.
98
+ *
99
+ * @param {string} dir absolute path
100
+ * @param {{ schemaPath?: string }} [opts]
101
+ * @returns {{ files: string[], failures: Array<{ file: string, issues: Array<{path:string,message:string}> }> }}
102
+ */
103
+ export function checkLoopUnits(dir, opts = {}) {
104
+ const files = collectLoopUnitFiles(dir);
105
+ const failures = [];
106
+ for (const file of files) {
107
+ try {
108
+ const { valid, issues } = validateLoopUnit(file, opts);
109
+ if (!valid) failures.push({ file, issues });
110
+ } catch (err) {
111
+ if (err instanceof LoopUnitParseError) {
112
+ failures.push({ file, issues: [{ path: '/', message: err.reason }] });
113
+ } else {
114
+ throw err;
115
+ }
116
+ }
117
+ }
118
+ return { files, failures };
119
+ }
120
+
121
+ /**
122
+ * Render the human-readable report. Each failure lists the offending file
123
+ * and one line per issue naming the field path and message.
124
+ *
125
+ * @param {{ files: string[], failures: Array<{ file: string, issues: Array<{path:string,message:string}> }> }} result
126
+ * @returns {string}
127
+ */
128
+ export function renderReport({ files, failures }) {
129
+ const lines = [];
130
+ if (files.length === 0) {
131
+ lines.push('[check-loop-units] no loop units found (ok)');
132
+ return lines.join('\n');
133
+ }
134
+ for (const { file, issues } of failures) {
135
+ lines.push(`✖ ${file}`);
136
+ for (const issue of issues) {
137
+ lines.push(` ${issue.path}: ${issue.message}`);
138
+ }
139
+ }
140
+ const tag = failures.length > 0 ? '(gate fail)' : '(ok)';
141
+ lines.push(
142
+ `[check-loop-units] checked=${files.length} invalid=${failures.length} ${tag}`,
143
+ );
144
+ return lines.join('\n');
145
+ }
146
+
147
+ /**
148
+ * Top-level CLI entry. Exported so tests can drive the full pipeline
149
+ * against a tmpdir fixture directory.
150
+ *
151
+ * @param {{
152
+ * argv?: string[],
153
+ * cwd?: string,
154
+ * stdout?: { write: (s: string) => void },
155
+ * stderr?: { write: (s: string) => void },
156
+ * }} [opts]
157
+ * @returns {Promise<number>} 0 = clean; 1 = at least one invalid unit
158
+ */
159
+ export async function runCli({
160
+ argv = process.argv.slice(2),
161
+ cwd = process.cwd(),
162
+ stdout = process.stdout,
163
+ stderr = process.stderr,
164
+ } = {}) {
165
+ const { dir, json } = parseArgv(argv);
166
+ const loopsDir = path.resolve(cwd, dir ?? DEFAULT_LOOPS_DIR);
167
+ const result = checkLoopUnits(loopsDir);
168
+ const exitCode = result.failures.length > 0 ? 1 : 0;
169
+
170
+ if (json) {
171
+ stdout.write(
172
+ `${JSON.stringify(
173
+ {
174
+ kind: 'loop-units-report',
175
+ dir: loopsDir,
176
+ checked: result.files.length,
177
+ failures: result.failures,
178
+ exitCode,
179
+ },
180
+ null,
181
+ 2,
182
+ )}\n`,
183
+ );
184
+ } else {
185
+ const report = renderReport(result);
186
+ if (exitCode === 0) {
187
+ stdout.write(`${report}\n`);
188
+ } else {
189
+ stderr.write(`${report}\n`);
190
+ }
191
+ }
192
+
193
+ return exitCode;
194
+ }
195
+
196
+ async function main() {
197
+ return runCli();
198
+ }
199
+
200
+ runAsCli(import.meta.url, main, {
201
+ source: 'check-loop-units',
202
+ propagateExitCode: true,
203
+ errorPrefix: '[check-loop-units] ❌ Fatal error',
204
+ });
@@ -38,6 +38,7 @@ import { runAsCli } from './lib/cli-utils.js';
38
38
  import { getRunners, resolveConfig } from './lib/config-resolver.js';
39
39
  import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
40
40
  import { getEpicBranch, gitSpawn } from './lib/git-utils.js';
41
+ import { parseLinkedIssues } from './lib/issue-link-parser.js';
41
42
  import { Logger } from './lib/Logger.js';
42
43
  import {
43
44
  resolveOperator,
@@ -143,6 +144,8 @@ function resolveGitUserEmail(cwd) {
143
144
  * storyCount: number,
144
145
  * concurrencyCap: number,
145
146
  * stories: Array<{ storyId: number, title: string, worktree?: string }>,
147
+ * prdId: number|null,
148
+ * techSpecId: number|null,
146
149
  * checkpointInitializedAt: string,
147
150
  * }>}
148
151
  */
@@ -374,11 +377,21 @@ export async function runEpicDeliverPrepare({
374
377
  });
375
378
  }
376
379
 
380
+ // Story #4253: resolve the Epic's PRD / Tech-Spec linkages ONCE here and
381
+ // surface them in the prepare envelope. The /deliver fan-out threads these
382
+ // into each per-Story `story-init.js --prd/--tech-spec`, collapsing the
383
+ // N per-Story `getEpic` round-trips to this single parent-side resolution.
384
+ // The Epic snapshot is already in hand (`state.epic`), so this adds no
385
+ // extra fetch; the body-parse fallback mirrors hierarchy-tracer's source.
386
+ const { prdId, techSpecId } = resolveEpicLinkages(state.epic);
387
+
377
388
  return {
378
389
  epicId,
379
390
  storyCount: openStories.length,
380
391
  concurrencyCap,
381
392
  stories,
393
+ prdId,
394
+ techSpecId,
382
395
  checkpointInitializedAt:
383
396
  checkpointState.startedAt ??
384
397
  checkpointState.lastUpdatedAt ??
@@ -388,6 +401,24 @@ export async function runEpicDeliverPrepare({
388
401
  };
389
402
  }
390
403
 
404
+ /**
405
+ * Resolve an Epic's linked PRD / Tech-Spec issue ids from the snapshot ticket.
406
+ * Prefers the provider-supplied `linkedIssues` map and falls back to parsing
407
+ * the Epic body's `## Planning Artifacts` section — the same two sources
408
+ * `hierarchy-tracer.js` reads — so the threaded ids match what an unthreaded
409
+ * `story-init.js` run would have resolved itself. Story #4253.
410
+ *
411
+ * @param {{ linkedIssues?: { prd?: number|null, techSpec?: number|null }|null, body?: string }|null|undefined} epic
412
+ * @returns {{ prdId: number|null, techSpecId: number|null }}
413
+ */
414
+ function resolveEpicLinkages(epic) {
415
+ const linked = epic?.linkedIssues ?? parseLinkedIssues(epic?.body ?? '');
416
+ return {
417
+ prdId: linked?.prd ?? null,
418
+ techSpecId: linked?.techSpec ?? null,
419
+ };
420
+ }
421
+
391
422
  async function main() {
392
423
  const { values } = parseArgs({
393
424
  options: {