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
@@ -50,11 +50,18 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
50
50
  log(
51
51
  `[close-validation] ↳ ${gate.name} scoped to ${eligibleFiles.length} formatter-eligible changed file(s) from ${gate.changedFileScope.baseRef}...HEAD`,
52
52
  );
53
+ // The extension filter cannot see biome's own config-ignore axis
54
+ // (`files.includes` allowlist / `files.ignore` / `overrides`). When every
55
+ // eligible-by-extension path is also config-ignored, the scoped biome
56
+ // invocation exits 1 with "No files were processed" — a false negative for
57
+ // the gate (Story #4292). Flag the scoped run so the runner downgrades that
58
+ // specific exit to a clean skip instead of a formatting failure.
53
59
  return {
54
60
  gate,
55
61
  cmd: gate.cmd,
56
62
  args: [...args, ...eligibleFiles],
57
63
  skip: false,
64
+ tolerateNoFilesProcessed: true,
58
65
  };
59
66
  }
60
67
 
@@ -68,11 +75,18 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
68
75
  * `cwd` (the main checkout) because the per-Epic temp tree lives under
69
76
  * the main `.git/`. Failure messages name the worktree path.
70
77
  *
71
- * Evidence-aware: when both `storyId` and `epicId` are provided and
72
- * `useEvidence !== false`, each gate consults `validation-evidence
73
- * .shouldSkip()` against current HEAD + the gate's command-config hash. A
74
- * matching record skips the gate; a successful run is recorded so the
75
- * next caller in the local hot path can skip in turn.
78
+ * Evidence-aware: when `storyId` is provided alongside either `epicId` or
79
+ * `standalone: true`, and `useEvidence !== false`, each gate consults
80
+ * `validation-evidence.shouldSkip()` against current HEAD + the gate's
81
+ * command-config hash. A matching record skips the gate; a successful run
82
+ * is recorded so the next caller in the local hot path can skip in turn.
83
+ *
84
+ * Standalone keyspace (Story #4250): the standalone path has no parent
85
+ * Epic, so it passes `standalone: true` (and leaves `epicId` null) to route
86
+ * the evidence file to the storyId-anchored
87
+ * `<tempRoot>/standalone/stories/story-<id>/validation-evidence.json`
88
+ * keyspace instead of feeding a null `epicId` into the Epic-keyed path
89
+ * (which structurally disabled the cache before).
76
90
  *
77
91
  * `onGateStart` is invoked immediately before each gate's runner spawn.
78
92
  * story-close uses it to drive `phaseTimer.mark(...)` for per-gate
@@ -87,6 +101,7 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
87
101
  * onGateStart?: (gate: Gate) => void,
88
102
  * storyId?: number|null,
89
103
  * epicId?: number|null,
104
+ * standalone?: boolean,
90
105
  * useEvidence?: boolean,
91
106
  * evidenceClock?: () => number,
92
107
  * getHeadSha?: (cwd: string) => string|null,
@@ -104,6 +119,7 @@ export async function runCloseValidation({
104
119
  onGateStart,
105
120
  storyId = null,
106
121
  epicId = null,
122
+ standalone = false,
107
123
  useEvidence = true,
108
124
  evidenceClock = () => Date.now(),
109
125
  getHeadSha = (resolvedCwd) => defaultGetHeadSha(resolvedCwd),
@@ -112,7 +128,15 @@ export async function runCloseValidation({
112
128
  } = {}) {
113
129
  const failed = [];
114
130
  const skipped = [];
115
- const evidenceActive = useEvidence && storyId != null && epicId != null;
131
+ // Evidence is active when a Story id is present AND there is a keyspace to
132
+ // anchor on: either a real Epic id (Epic path) or `standalone: true`
133
+ // (Story #4250 — standalone storyId-anchored keyspace). A bare `epicId:
134
+ // null` without `standalone` keeps the cache off, as before.
135
+ const evidenceActive =
136
+ useEvidence && storyId != null && (epicId != null || standalone);
137
+ // The evidence-store opts: `standalone` routes to the storyId-anchored
138
+ // keyspace; otherwise the Epic-keyed path resolves under `epicId`.
139
+ const evidenceStoreOpts = { cwd, epicId, standalone };
116
140
  // Evidence keys against the main checkout's HEAD because the per-Epic
117
141
  // evidence file lives under the main `.git/`. Gate spawn, in contrast,
118
142
  // runs in the worktree when one is supplied — that's the whole point of
@@ -134,7 +158,7 @@ export async function runCloseValidation({
134
158
  configHash,
135
159
  inputFingerprint: gate.inputFingerprint ?? null,
136
160
  },
137
- { cwd, epicId },
161
+ evidenceStoreOpts,
138
162
  );
139
163
  if (verdict.skip) {
140
164
  const tsHint = verdict.record?.timestamp
@@ -160,7 +184,7 @@ export async function runCloseValidation({
160
184
  durationMs,
161
185
  inputFingerprint: gate.inputFingerprint ?? null,
162
186
  },
163
- { cwd, epicId },
187
+ evidenceStoreOpts,
164
188
  );
165
189
  } catch (err) {
166
190
  log(
@@ -192,6 +216,9 @@ export async function runCloseValidation({
192
216
  log,
193
217
  signal,
194
218
  ...(gate.env ? { env: gate.env } : {}),
219
+ ...(gate.tolerateNoFilesProcessed
220
+ ? { tolerateNoFilesProcessed: true }
221
+ : {}),
195
222
  });
196
223
  return { status: result?.status ?? 1 };
197
224
  };
@@ -238,7 +265,12 @@ export async function runCloseValidation({
238
265
  let result;
239
266
  try {
240
267
  result = await dispatchGate(
241
- { ...gate, cmd: execution.cmd, args: execution.args },
268
+ {
269
+ ...gate,
270
+ cmd: execution.cmd,
271
+ args: execution.args,
272
+ tolerateNoFilesProcessed: execution.tolerateNoFilesProcessed,
273
+ },
242
274
  ac.signal,
243
275
  );
244
276
  } catch (err) {
@@ -304,6 +336,7 @@ export async function runCloseValidation({
304
336
  ...gate,
305
337
  cmd: execution.cmd,
306
338
  args: execution.args,
339
+ tolerateNoFilesProcessed: execution.tolerateNoFilesProcessed,
307
340
  });
308
341
  if (result.status !== 0) {
309
342
  failed.push({ gate, status: result.status, cwd: spawnCwd });
@@ -128,7 +128,7 @@ export function _clearMainCheckoutRootCache() {
128
128
  * @param {string} tempRoot
129
129
  * @returns {string}
130
130
  */
131
- function anchorTempRoot(tempRoot) {
131
+ export function anchorTempRoot(tempRoot) {
132
132
  if (path.isAbsolute(tempRoot)) return tempRoot;
133
133
  const root = mainCheckoutRoot();
134
134
  return root ? path.join(root, tempRoot) : tempRoot;
@@ -10,10 +10,26 @@
10
10
  * silently disabling worktrees when the operator omitted the block).
11
11
  */
12
12
 
13
+ /**
14
+ * Default `nodeModulesStrategy`, platform-aware (Story #4249).
15
+ *
16
+ * darwin/linux default to `clone` — a copy-on-write (clonefile/reflink) clone
17
+ * of the donor's `node_modules` that is effectively free in time and disk on
18
+ * APFS / reflink-capable filesystems, with a clean fall-back to `per-worktree`
19
+ * on any failure (unsupported fs, cross-volume, etc.). Windows has no reflink
20
+ * equivalent on this path, so it keeps the `per-worktree` install default.
21
+ *
22
+ * @param {NodeJS.Platform} [platform]
23
+ * @returns {'clone' | 'per-worktree'}
24
+ */
25
+ export function defaultNodeModulesStrategy(platform = process.platform) {
26
+ return platform === 'win32' ? 'per-worktree' : 'clone';
27
+ }
28
+
13
29
  export const WORKTREE_ISOLATION_DEFAULTS = Object.freeze({
14
30
  enabled: true,
15
31
  root: '.worktrees',
16
- nodeModulesStrategy: 'per-worktree',
32
+ nodeModulesStrategy: defaultNodeModulesStrategy(),
17
33
  primeFromPath: null,
18
34
  allowSymlinkOnWindows: false,
19
35
  reapOnSuccess: true,
@@ -55,8 +71,7 @@ export function getWorktreeIsolation(config) {
55
71
  ? wi.enabled
56
72
  : WORKTREE_ISOLATION_DEFAULTS.enabled,
57
73
  root: wi.root ?? WORKTREE_ISOLATION_DEFAULTS.root,
58
- nodeModulesStrategy:
59
- wi.nodeModulesStrategy ?? WORKTREE_ISOLATION_DEFAULTS.nodeModulesStrategy,
74
+ nodeModulesStrategy: wi.nodeModulesStrategy ?? defaultNodeModulesStrategy(),
60
75
  primeFromPath:
61
76
  wi.primeFromPath === undefined
62
77
  ? WORKTREE_ISOLATION_DEFAULTS.primeFromPath
@@ -63,7 +63,10 @@ export {
63
63
  } from './config/runtime.js';
64
64
  export { resolveListValue } from './config/shared.js';
65
65
  export { validateOrchestrationConfig } from './config/validate-orchestration.js';
66
- export { WORKTREE_ISOLATION_DEFAULTS } from './config/worktree-isolation.js';
66
+ export {
67
+ defaultNodeModulesStrategy,
68
+ WORKTREE_ISOLATION_DEFAULTS,
69
+ } from './config/worktree-isolation.js';
67
70
  export { PROJECT_ROOT } from './project-root.js';
68
71
 
69
72
  // Cache keyed by absolute root path so callers passing different cwds
@@ -105,7 +105,7 @@ const WORKTREE_ISOLATION_SCHEMA = {
105
105
  root: { type: 'string', minLength: 1 },
106
106
  nodeModulesStrategy: {
107
107
  type: 'string',
108
- enum: ['per-worktree', 'symlink', 'pnpm-store'],
108
+ enum: ['per-worktree', 'clone', 'symlink', 'pnpm-store'],
109
109
  },
110
110
  primeFromPath: { type: ['string', 'null'], minLength: 1 },
111
111
  allowSymlinkOnWindows: { type: 'boolean' },
@@ -114,6 +114,96 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
114
114
  return 'create';
115
115
  }
116
116
 
117
+ /**
118
+ * Single-home for the story-branch seed-action *switch shell* that
119
+ * `single-story-init.js#seedStoryBranch` (standalone path) and
120
+ * `story-init/branch-initializer.js#ensureStoryBranchSeed` (Epic path) had
121
+ * each re-implemented (Story #4255). Both already delegated the (local,
122
+ * remote) decision to `classifyBranchSeed`; only the act-on-the-decision
123
+ * shell (reuse / fetch / create) was duplicated, and that shell was the
124
+ * drift surface for the seed-decision contract.
125
+ *
126
+ * The two callers differ in exactly two behavioural axes, both of which are
127
+ * parameters here — no other conditional branching is introduced:
128
+ * - **`baseRef`** — the ref to branch from on `create` (`main` for the
129
+ * standalone path, the Epic branch for the Epic path).
130
+ * - **`swallowCreateRace`** — when `true`, a `git branch` that exits
131
+ * non-zero with an "already exists" stderr is treated as reuse rather
132
+ * than a fatal error (closes the probe→create race the Epic path runs
133
+ * under concurrent wave dispatch). When `false`, any create failure
134
+ * throws (the standalone path has no concurrent creator to race).
135
+ *
136
+ * The asymmetric surrounding wrappers (merged-sweep, fast-forward,
137
+ * donor-prime, workspace-verify, phase-timer) are deliberately NOT folded
138
+ * in — they stay in their respective callers.
139
+ *
140
+ * Caller-specific log lines and error text are passed in as the `messages`
141
+ * data bag so behaviour stays byte-identical to the pre-extraction switches.
142
+ * The git seams (`spawn`, `existsLocally`, `existsRemotely`) are injected so
143
+ * each caller can bind its own cwd (and tests can mock them).
144
+ *
145
+ * @param {object} opts
146
+ * @param {string} opts.storyBranch
147
+ * @param {string} opts.baseRef Ref to branch from on `create`.
148
+ * @param {boolean} [opts.swallowCreateRace=false]
149
+ * @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} opts.spawn
150
+ * @param {(branch: string) => boolean} opts.existsLocally
151
+ * @param {(branch: string) => boolean} opts.existsRemotely
152
+ * @param {(level: string, message: string) => void} [opts.progress]
153
+ * @param {object} opts.messages
154
+ * @param {(b: string) => string} opts.messages.reuse
155
+ * @param {(b: string) => string} opts.messages.fetch
156
+ * @param {(b: string, ref: string) => string} opts.messages.create
157
+ * @param {(b: string) => string} [opts.messages.createRace] Used when `swallowCreateRace`.
158
+ * @param {(b: string, ref: string, stderr: string) => string} opts.messages.createError
159
+ * @param {(b: string, stderr: string) => string} [opts.messages.fetchError]
160
+ * When provided, a non-zero `fetch` exit throws with this message; when
161
+ * omitted, the fetch exit status is not inspected.
162
+ */
163
+ export function seedStoryBranchRef({
164
+ storyBranch,
165
+ baseRef,
166
+ swallowCreateRace = false,
167
+ spawn,
168
+ existsLocally,
169
+ existsRemotely,
170
+ progress = () => {},
171
+ messages,
172
+ }) {
173
+ const action = classifyBranchSeed({
174
+ localHas: existsLocally(storyBranch),
175
+ remoteHas: existsRemotely(storyBranch),
176
+ });
177
+
178
+ if (action === 'local') {
179
+ progress('GIT', messages.reuse(storyBranch));
180
+ return;
181
+ }
182
+
183
+ if (action === 'fetch') {
184
+ progress('GIT', messages.fetch(storyBranch));
185
+ const r = spawn(['fetch', 'origin', `${storyBranch}:${storyBranch}`]);
186
+ if (messages.fetchError && r.status !== 0) {
187
+ throw new Error(
188
+ messages.fetchError(storyBranch, r.stderr || '(no stderr)'),
189
+ );
190
+ }
191
+ return;
192
+ }
193
+
194
+ // action === 'create'
195
+ progress('GIT', messages.create(storyBranch, baseRef));
196
+ const r = spawn(['branch', storyBranch, baseRef]);
197
+ if (r.status !== 0) {
198
+ const stderr = r.stderr || r.stdout || '';
199
+ if (swallowCreateRace && /already exists/i.test(stderr)) {
200
+ progress('GIT', messages.createRace(storyBranch));
201
+ return;
202
+ }
203
+ throw new Error(messages.createError(storyBranch, baseRef, stderr));
204
+ }
205
+ }
206
+
117
207
  /**
118
208
  * Ensure an Epic branch exists and is published to `origin`. Handles all
119
209
  * four states of the (local, remote) matrix.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * lib/loop-units/validate-loop-unit.js — loop-unit frontmatter validator.
3
+ *
4
+ * Parses a loop-unit markdown file's YAML frontmatter and AJV-validates
5
+ * it against `.agents/schemas/loop-unit.schema.json` (Ajv2020). Mirrors
6
+ * the validation pattern established by `lib/spec/loader.js` (Ajv2020 +
7
+ * ajv-formats + js-yaml, cached compiled validator, normalised
8
+ * `{ path, message }` issues).
9
+ *
10
+ * A "loop unit" is a markdown file under `.agents/workflows/loops/` whose
11
+ * leading `---`-fenced YAML frontmatter block defines a recurring unit of
12
+ * work (cadence, goal, conditional verify, round cap, exhaustion policy).
13
+ *
14
+ * Public surface:
15
+ * • `parseFrontmatter(source)` → extracts and YAML-parses the leading
16
+ * `---`-fenced block. Returns the parsed object (or `{}` for an empty
17
+ * block). Throws `LoopUnitParseError` when the block is absent or the
18
+ * YAML does not parse.
19
+ * • `validateLoopUnit(filePath, opts?)` → reads the file, parses its
20
+ * frontmatter, validates against the schema, and returns
21
+ * `{ valid, issues, data }`. `issues` is an array of
22
+ * `{ path, message }` (empty when valid). Never throws on a *validation*
23
+ * failure — it reports it via `valid: false` — but does throw
24
+ * `LoopUnitParseError` for an unreadable file or unparseable
25
+ * frontmatter so callers can distinguish "structurally broken file"
26
+ * from "schema-invalid unit".
27
+ *
28
+ * The module makes no GitHub calls and no process mutations; it is pure
29
+ * file I/O + schema validation. The `opts` bag accepts `{ schemaPath, fs }`
30
+ * so tests can point at a sandbox schema without monkey-patching globals.
31
+ */
32
+
33
+ import {
34
+ existsSync as defaultExistsSync,
35
+ readFileSync as defaultReadFileSync,
36
+ } from 'node:fs';
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+ import Ajv2020 from 'ajv/dist/2020.js';
40
+ import addFormats from 'ajv-formats';
41
+ import yaml from 'js-yaml';
42
+
43
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
44
+
45
+ // scripts/lib/loop-units/ → scripts/lib/ → scripts/ → .agents/
46
+ const PROJECT_AGENTS_DIR = path.resolve(__dirname, '..', '..', '..');
47
+ export const DEFAULT_SCHEMA_PATH = path.join(
48
+ PROJECT_AGENTS_DIR,
49
+ 'schemas',
50
+ 'loop-unit.schema.json',
51
+ );
52
+
53
+ const defaultFsAdapter = Object.freeze({
54
+ existsSync: defaultExistsSync,
55
+ readFileSync: defaultReadFileSync,
56
+ });
57
+
58
+ let cachedValidator = null;
59
+ let cachedValidatorKey = null;
60
+
61
+ /**
62
+ * Compile (and cache) the Ajv2020 validator for the loop-unit schema.
63
+ * Cached by absolute schema path so tests can swap to a sandbox schema.
64
+ *
65
+ * @param {string} schemaPath
66
+ * @param {{ readFileSync: typeof defaultReadFileSync }} fs
67
+ * @returns {(data: unknown) => boolean}
68
+ */
69
+ function getValidator(schemaPath, fs) {
70
+ if (cachedValidator && cachedValidatorKey === schemaPath) {
71
+ return cachedValidator;
72
+ }
73
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
74
+ addFormats(ajv);
75
+ const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
76
+ cachedValidator = ajv.compile(schema);
77
+ cachedValidatorKey = schemaPath;
78
+ return cachedValidator;
79
+ }
80
+
81
+ /**
82
+ * Raised when a loop-unit file cannot be read, has no YAML frontmatter
83
+ * block, or the frontmatter does not parse as YAML.
84
+ */
85
+ export class LoopUnitParseError extends Error {
86
+ /**
87
+ * @param {string} filePath
88
+ * @param {string} reason
89
+ */
90
+ constructor(filePath, reason) {
91
+ super(`Loop unit ${filePath} could not be parsed: ${reason}`);
92
+ this.name = 'LoopUnitParseError';
93
+ this.filePath = filePath;
94
+ this.reason = reason;
95
+ }
96
+ }
97
+
98
+ // Leading `---`-fenced YAML block. Tolerates CRLF and a leading BOM. The
99
+ // closing fence is a `---` (or `...`) on its own line.
100
+ const FRONTMATTER_RE = /^?---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\s*(?:\r?\n|$)/;
101
+
102
+ /**
103
+ * Extract and YAML-parse the leading `---`-fenced frontmatter block from a
104
+ * markdown source string.
105
+ *
106
+ * @param {string} source raw file contents
107
+ * @param {string} [filePath] used only for error messages
108
+ * @returns {object} the parsed frontmatter object (`{}` if empty)
109
+ * @throws {LoopUnitParseError} when no fence is present or the YAML fails
110
+ */
111
+ export function parseFrontmatter(source, filePath = '<string>') {
112
+ const match = FRONTMATTER_RE.exec(source);
113
+ if (!match) {
114
+ throw new LoopUnitParseError(
115
+ filePath,
116
+ 'no YAML frontmatter block (expected a leading "---" fence)',
117
+ );
118
+ }
119
+ let parsed;
120
+ try {
121
+ parsed = yaml.load(match[1], { filename: filePath });
122
+ } catch (err) {
123
+ throw new LoopUnitParseError(
124
+ filePath,
125
+ `frontmatter is not valid YAML: ${err.message}`,
126
+ );
127
+ }
128
+ if (parsed == null) return {};
129
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
130
+ throw new LoopUnitParseError(
131
+ filePath,
132
+ 'frontmatter must be a YAML mapping',
133
+ );
134
+ }
135
+ return parsed;
136
+ }
137
+
138
+ /**
139
+ * Convert Ajv's error array into a `{ path, message }` shape. For
140
+ * `required` errors Ajv leaves the missing property in
141
+ * `params.missingProperty` rather than the instance path, so we append it
142
+ * so the caller sees `/loop/verify` instead of `/loop` and the message
143
+ * names the missing field.
144
+ *
145
+ * @param {Array<{instancePath:string,message:string,keyword:string,params?:Record<string,unknown>}>} ajvErrors
146
+ * @returns {Array<{path:string,message:string}>}
147
+ */
148
+ function normaliseAjvErrors(ajvErrors) {
149
+ return (ajvErrors ?? []).map((err) => {
150
+ let p = err.instancePath || '/';
151
+ let message = err.message ?? 'validation failed';
152
+ if (
153
+ err.keyword === 'required' &&
154
+ typeof err.params?.missingProperty === 'string'
155
+ ) {
156
+ const sep = p === '/' ? '' : '/';
157
+ p = `${p}${sep}${err.params.missingProperty}`;
158
+ message = `must have required property '${err.params.missingProperty}'`;
159
+ }
160
+ return { path: p, message };
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Read, parse, and schema-validate a loop-unit markdown file.
166
+ *
167
+ * @param {string} filePath
168
+ * @param {{ schemaPath?: string, fs?: typeof defaultFsAdapter }} [opts]
169
+ * @returns {{ valid: boolean, issues: Array<{path:string,message:string}>, data: object }}
170
+ * @throws {LoopUnitParseError} when the file is unreadable or its
171
+ * frontmatter is missing/unparseable.
172
+ */
173
+ export function validateLoopUnit(filePath, opts = {}) {
174
+ const fs = opts.fs ?? defaultFsAdapter;
175
+ const schemaPath = opts.schemaPath ?? DEFAULT_SCHEMA_PATH;
176
+
177
+ if (!fs.existsSync(filePath)) {
178
+ throw new LoopUnitParseError(filePath, 'file does not exist');
179
+ }
180
+
181
+ let raw;
182
+ try {
183
+ raw = fs.readFileSync(filePath, 'utf8');
184
+ } catch (err) {
185
+ throw new LoopUnitParseError(filePath, `unreadable: ${err.message}`);
186
+ }
187
+
188
+ const data = parseFrontmatter(raw, filePath);
189
+ const validate = getValidator(schemaPath, fs);
190
+ const ok = validate(data);
191
+
192
+ return {
193
+ valid: ok,
194
+ issues: ok ? [] : normaliseAjvErrors(validate.errors),
195
+ data,
196
+ };
197
+ }
@@ -140,6 +140,42 @@ export function buildCatalog(workflowsDir) {
140
140
  return catalog;
141
141
  }
142
142
 
143
+ /**
144
+ * Build the loop-unit catalog from a workflows directory's `loops/`
145
+ * namespace. Loop units live at `.agents/workflows/loops/<name>.md` and
146
+ * project to the namespaced `/loops:<name>` slash command (Story #4289).
147
+ * They are catalogued separately from the flat top-level commands because
148
+ * they carry a distinct invocation form.
149
+ *
150
+ * Returns an empty array when the `loops/` subdirectory is absent (the
151
+ * common case before the starter loops land in a later Story) — an absent
152
+ * namespace is a clean "no loop units", not an error.
153
+ *
154
+ * @param {string} workflowsDir — absolute path to `.agents/workflows/`.
155
+ * @returns {Array<{ name: string, description: string | null, vague: boolean }>}
156
+ */
157
+ export function buildLoopCatalog(workflowsDir) {
158
+ const loopsDir = path.join(workflowsDir, 'loops');
159
+ if (!fs.existsSync(loopsDir)) return [];
160
+ const entries = fs.readdirSync(loopsDir, { withFileTypes: true });
161
+ const catalog = [];
162
+ for (const entry of entries) {
163
+ if (!entry.isFile()) continue;
164
+ if (!entry.name.endsWith('.md')) continue;
165
+ if (entry.name === 'README.md') continue;
166
+ const filePath = path.join(loopsDir, entry.name);
167
+ const source = fs.readFileSync(filePath, 'utf8');
168
+ const description = extractDescription(source);
169
+ catalog.push({
170
+ name: entry.name.replace(/\.md$/, ''),
171
+ description,
172
+ vague: isVagueDescription(description),
173
+ });
174
+ }
175
+ catalog.sort((a, b) => a.name.localeCompare(b.name));
176
+ return catalog;
177
+ }
178
+
143
179
  /**
144
180
  * Render the catalog as a plain-markdown bullet list. Kept as a
145
181
  * lightweight alternative rendering of the same catalog backend that
@@ -0,0 +1,128 @@
1
+ /**
2
+ * auto-merge-cwd.js — resolve a worktree-collision-safe cwd for arming
3
+ * GitHub native auto-merge (Story #4282).
4
+ *
5
+ * Root cause this module exists to defeat:
6
+ * Arming auto-merge runs, in effect,
7
+ * `gh pr merge <pr> --auto --squash --delete-branch`. The
8
+ * `--delete-branch` flag makes `gh` shell out to local `git` to leave
9
+ * and delete the PR head branch — including a `git checkout <base>` to
10
+ * switch the working tree off the head branch. When the arm runs from a
11
+ * per-Story worktree cwd (checked out on the head branch `story-<id>`)
12
+ * while the base branch (`main`) is already occupied by the primary
13
+ * worktree, `gh`'s internal `git checkout <base>` collides:
14
+ *
15
+ * fatal: '<base>' is already used by worktree at '<primary>'
16
+ *
17
+ * The arm fails (non-fatally), defeating the unattended auto-merge
18
+ * contract — the operator must re-run the merge manually from a clean cwd.
19
+ *
20
+ * Fix (advisory direction #1 from the Story — "ensure the cwd is already
21
+ * on the base branch so gh's `git checkout <base>` is a no-op"):
22
+ * Re-point the arm at the **primary worktree root** — the working tree
23
+ * that holds the base branch — discovered via `git worktree list
24
+ * --porcelain`. From the primary worktree, `gh`'s `--delete-branch`
25
+ * cleanup never has to `git checkout <base>` (it is already there), so
26
+ * the collision cannot occur. `--delete-branch` is preserved verbatim,
27
+ * so the PR head branch is still removed on merge with no dependency on
28
+ * the consumer's repo-level "auto-delete head branches" toggle.
29
+ *
30
+ * Non-fatal by construction: any failure to resolve the primary worktree
31
+ * (not a git repo, `git` missing, single-worktree layout, parse failure)
32
+ * degrades to returning the original `cwd` unchanged. Worst case is the
33
+ * pre-fix behaviour; this helper never throws and never blocks arming.
34
+ */
35
+
36
+ import { gitSpawn as defaultGitSpawn } from '../git-utils.js';
37
+
38
+ /**
39
+ * Parse `git worktree list --porcelain` output into structured records.
40
+ *
41
+ * The porcelain format emits one stanza per worktree, blank-line
42
+ * separated, e.g.:
43
+ *
44
+ * worktree /abs/path/to/primary
45
+ * HEAD <sha>
46
+ * branch refs/heads/main
47
+ *
48
+ * worktree /abs/path/to/.worktrees/story-4282
49
+ * HEAD <sha>
50
+ * branch refs/heads/story-4282
51
+ *
52
+ * A linked worktree with a detached HEAD emits `detached` instead of a
53
+ * `branch` line. Pure — exported for tests.
54
+ *
55
+ * @param {string} stdout
56
+ * @returns {Array<{ path: string, branch: string|null }>}
57
+ */
58
+ export function parseWorktreeList(stdout) {
59
+ const text = String(stdout ?? '');
60
+ const records = [];
61
+ let current = null;
62
+ for (const rawLine of text.split('\n')) {
63
+ const line = rawLine.replace(/\r$/, '');
64
+ if (line.startsWith('worktree ')) {
65
+ if (current) records.push(current);
66
+ current = { path: line.slice('worktree '.length).trim(), branch: null };
67
+ } else if (line.startsWith('branch ') && current) {
68
+ current.branch = line
69
+ .slice('branch '.length)
70
+ .trim()
71
+ .replace(/^refs\/heads\//, '');
72
+ }
73
+ // `HEAD <sha>`, `detached`, `bare`, `locked`, `prunable` lines carry
74
+ // no field we need; ignored.
75
+ }
76
+ if (current) records.push(current);
77
+ return records;
78
+ }
79
+
80
+ /**
81
+ * Pick the primary worktree from a parsed worktree list. The primary
82
+ * worktree is the first stanza `git worktree list` emits — it is the
83
+ * original (non-linked) working tree and, during delivery, the one that
84
+ * holds the base branch. Returns its absolute path, or `null` when the
85
+ * list is empty / unparseable.
86
+ *
87
+ * Pure — exported for tests.
88
+ *
89
+ * @param {Array<{ path: string, branch: string|null }>} records
90
+ * @returns {string|null}
91
+ */
92
+ export function pickPrimaryWorktreePath(records) {
93
+ if (!Array.isArray(records) || records.length === 0) return null;
94
+ const first = records[0];
95
+ return first && typeof first.path === 'string' && first.path.length > 0
96
+ ? first.path
97
+ : null;
98
+ }
99
+
100
+ /**
101
+ * Resolve a worktree-collision-safe cwd for arming auto-merge.
102
+ *
103
+ * Returns the primary worktree root (which holds the base branch) when it
104
+ * can be discovered AND it differs from `cwd`; otherwise returns `cwd`
105
+ * unchanged. Never throws.
106
+ *
107
+ * @param {string} cwd — the cwd the caller would otherwise arm from
108
+ * (often a per-Story worktree on the head branch).
109
+ * @param {{ gitSpawn?: typeof import('../git-utils.js').gitSpawn }} [deps]
110
+ * @returns {string} a cwd safe to run `gh pr merge --delete-branch` from.
111
+ */
112
+ export function resolveAutoMergeArmCwd(
113
+ cwd,
114
+ { gitSpawn = defaultGitSpawn } = {},
115
+ ) {
116
+ if (typeof cwd !== 'string' || cwd.length === 0) return cwd;
117
+ try {
118
+ const result = gitSpawn(cwd, 'worktree', 'list', '--porcelain');
119
+ if (!result || result.status !== 0) return cwd;
120
+ const primary = pickPrimaryWorktreePath(parseWorktreeList(result.stdout));
121
+ if (!primary) return cwd;
122
+ return primary;
123
+ } catch {
124
+ // Any unexpected failure (git missing, non-repo cwd, etc.) degrades
125
+ // to the original cwd — arming stays best-effort.
126
+ return cwd;
127
+ }
128
+ }