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
@@ -51,6 +51,7 @@ import {
51
51
  branchExistsLocally,
52
52
  branchExistsViaTrackingRef,
53
53
  classifyBranchSeed,
54
+ seedStoryBranchRef,
54
55
  } from './lib/git-branch-lifecycle.js';
55
56
  import { getStoryBranch, gitSpawn, gitSync } from './lib/git-utils.js';
56
57
  import { Logger } from './lib/Logger.js';
@@ -376,36 +377,28 @@ export async function materializeBaseBranch({
376
377
  * @param {Function} opts.progress
377
378
  */
378
379
  export function seedStoryBranch({ cwd, storyBranch, baseBranch, progress }) {
379
- const seedAction = decideStoryBranchSeed({
380
- localHas: branchExistsLocally(storyBranch, cwd),
381
- remoteHas: branchExistsViaTrackingRef(storyBranch, cwd),
380
+ // Standalone path: no concurrent creator to race, so create failures are
381
+ // fatal (`swallowCreateRace: false`) and a failed fetch throws. The
382
+ // seed-action switch shell is single-homed in `seedStoryBranchRef`
383
+ // (Story #4255); this caller only supplies its `baseRef`, its git seams
384
+ // bound to `cwd`, and its own log/error vocabulary.
385
+ seedStoryBranchRef({
386
+ storyBranch,
387
+ baseRef: baseBranch,
388
+ swallowCreateRace: false,
389
+ spawn: (args) => gitSpawn(cwd, ...args),
390
+ existsLocally: (b) => branchExistsLocally(b, cwd),
391
+ existsRemotely: (b) => branchExistsViaTrackingRef(b, cwd),
392
+ progress,
393
+ messages: {
394
+ reuse: (b) => `Reusing existing local story branch: ${b}`,
395
+ fetch: (b) => `Fetching remote story branch: ${b}`,
396
+ create: (b, ref) => `Creating story branch ref: ${b} from ${ref}`,
397
+ createError: (b, _ref, stderr) =>
398
+ `Failed to create story branch ${b}: ${stderr || '(no stderr)'}`,
399
+ fetchError: (b, stderr) => `Failed to fetch story branch ${b}: ${stderr}`,
400
+ },
382
401
  });
383
- if (seedAction === 'fetch') {
384
- progress('GIT', `Fetching remote story branch: ${storyBranch}`);
385
- const r = gitSpawn(cwd, 'fetch', 'origin', `${storyBranch}:${storyBranch}`);
386
- if (r.status !== 0) {
387
- throw new Error(
388
- `Failed to fetch story branch ${storyBranch}: ${r.stderr || '(no stderr)'}`,
389
- );
390
- }
391
- return;
392
- }
393
- if (seedAction === 'create') {
394
- progress(
395
- 'GIT',
396
- `Creating story branch ref: ${storyBranch} from ${baseBranch}`,
397
- );
398
- const r = gitSpawn(cwd, 'branch', storyBranch, baseBranch);
399
- if (r.status !== 0) {
400
- throw new Error(
401
- `Failed to create story branch ${storyBranch}: ${r.stderr || '(no stderr)'}`,
402
- );
403
- }
404
- return;
405
- }
406
- // seedAction === 'reuse' — the local ref already exists. Do NOT run
407
- // `git branch` here; re-creating an existing ref throws. Reuse it.
408
- progress('GIT', `Reusing existing local story branch: ${storyBranch}`);
409
402
  }
410
403
 
411
404
  /**
@@ -38,7 +38,6 @@ import {
38
38
  } from './lib/config-resolver.js';
39
39
  import { parseBlockedBy } from './lib/dependency-parser.js';
40
40
  import { getEpicBranch, getStoryBranch } from './lib/git-utils.js';
41
- import { runInstallCommand } from './lib/install-cmd-parser.js';
42
41
  import { Logger } from './lib/Logger.js';
43
42
  import { setActiveStoryEnv } from './lib/observability/active-story-env.js';
44
43
  import {
@@ -89,6 +88,8 @@ export async function runStoryInit({
89
88
  dryRun: dryRunParam,
90
89
  cwd: cwdParam,
91
90
  recutOf: recutOfParam,
91
+ prdId: prdIdParam,
92
+ techSpecId: techSpecIdParam,
92
93
  injectedProvider,
93
94
  injectedConfig,
94
95
  } = {}) {
@@ -99,10 +100,17 @@ export async function runStoryInit({
99
100
  dryRun: !!dryRunParam,
100
101
  cwd: cwdParam ?? null,
101
102
  recutOf: recutOfParam ?? null,
103
+ prdId: prdIdParam ?? null,
104
+ techSpecId: techSpecIdParam ?? null,
102
105
  }
103
106
  : parseSprintArgs();
104
107
  const { storyId, dryRun } = parsed;
105
108
  const recutOf = recutOfParam ?? parsed.recutOf ?? null;
109
+ // Story #4253: pre-resolved Epic linkages (from the /deliver fan-out's
110
+ // one-time Epic resolution). When both are present, hierarchy-tracer skips
111
+ // the per-Story getEpic round-trip; when absent it resolves them itself.
112
+ const threadedPrdId = prdIdParam ?? parsed.prdId ?? null;
113
+ const threadedTechSpecId = techSpecIdParam ?? parsed.techSpecId ?? null;
106
114
  // Worktree-aware cwd resolution: explicit param > --cwd flag > env > PROJECT_ROOT.
107
115
  const cwd = path.resolve(cwdParam ?? parsed.cwd ?? PROJECT_ROOT);
108
116
 
@@ -147,11 +155,13 @@ export async function runStoryInit({
147
155
  input: { storyId, recutOf, dryRun },
148
156
  });
149
157
 
150
- // Stage 2 — hierarchy.
158
+ // Stage 2 — hierarchy. When the /deliver fan-out threaded --prd/--tech-spec
159
+ // (both resolved once by the parent), this short-circuits the per-Story
160
+ // getEpic. Absent flags fall back to the legacy getEpic resolution.
151
161
  const { prdId, techSpecId } = await traceHierarchy({
152
162
  provider,
153
163
  logger: stageLogger,
154
- input: { epicId },
164
+ input: { epicId, prdId: threadedPrdId, techSpecId: threadedTechSpecId },
155
165
  });
156
166
 
157
167
  progress('CONTEXT', `Epic: #${epicId}, Parent: #${parentId ?? 'none'}`);
@@ -366,9 +376,16 @@ const VALID_INSTALLED_STATES = new Set(['true', 'false', 'skipped']);
366
376
 
367
377
  /**
368
378
  * Apply the dependenciesInstalled tri-state to derive the next install
369
- * action. Pure helper — exposes the Step 0.5 truth table as data so tests
379
+ * action. Pure helper — exposes the install truth table as data so tests
370
380
  * can pin each branch without spinning up a child process.
371
381
  *
382
+ * Story #4249: this is no longer wired into a post-init re-install. The
383
+ * in-`ensure` install (with its PM-aware retry budget) is the single install
384
+ * owner; the formerly-hardcoded `npm ci` retry inside `runStoryInitPrepare`
385
+ * was deleted. The tri-state still flows onto the `story-init` structured
386
+ * comment (`dependenciesInstalled`) as the workflow-facing signal, and this
387
+ * helper remains its canonical classifier.
388
+ *
372
389
  * @param {'true' | 'false' | 'skipped'} dependenciesInstalled
373
390
  * @param {{ skipInstall?: boolean }} [options]
374
391
  * @returns {'skip' | 'install'}
@@ -384,54 +401,33 @@ export function deriveInstallAction(dependenciesInstalled, options = {}) {
384
401
  }
385
402
 
386
403
  /**
387
- * Resolve the install command to run when `dependenciesInstalled === 'false'`.
388
- * `project.commands` does not currently carry a dedicated install key,
389
- * so this defaults to `npm ci`. Operators can override per-invocation via
390
- * the `installCmd` option.
404
+ * Post-init prepare step (Story #4017 formerly a standalone prepare CLI,
405
+ * now consuming the in-process init result instead of re-reading the
406
+ * `story-init` structured comment).
391
407
  *
392
- * @param {{ override?: string }} [options]
393
- * @returns {string}
394
- */
395
- export function resolveInstallCommand(options = {}) {
396
- const trimmed = options.override?.trim();
397
- if (trimmed) {
398
- return trimmed;
399
- }
400
- return 'npm ci';
401
- }
402
-
403
- /**
404
- * Post-init prepare step (Story #4017 — formerly a standalone prepare
405
- * CLI, now consuming the in-process init result
406
- * instead of re-reading the `story-init` structured comment):
408
+ * Story #4249: the install branch was deleted. The worktree install is owned
409
+ * entirely by `WorktreeManager.ensure` (via `installDependencies`), which now
410
+ * carries a PM-aware retry budget (`installRetryPolicy`) — so a transient
411
+ * first-attempt failure retries there with the correct package-manager
412
+ * command, never an unconditional `npm ci` re-run after init. This step is now
413
+ * purely the snapshot-render half:
407
414
  *
408
- * 1. Apply the `dependenciesInstalled` tri-state truth table `'false'`
409
- * (install attempted and failed) retries the install command in the
410
- * worktree; `'true'` / `'skipped'` proceed.
411
- * 2. Render the initial Story-phase snapshot with every phase pinned to
412
- * `pending` and `phase: 'init'` via `upsertStoryRunProgress`
413
- * (render-only since Story #3909 — no comment is posted). The
414
- * `renderedBody` markdown is relayed to chat by the delivery
415
- * workflows so operators see the initial progress block before the
416
- * first commit lands.
415
+ * - Render the initial Story-phase snapshot with every phase pinned to
416
+ * `pending` and `phase: 'init'` via `upsertStoryRunProgress` (render-only
417
+ * since Story #3909 — no comment is posted). The `renderedBody` markdown is
418
+ * relayed to chat by the delivery workflows so operators see the initial
419
+ * progress block before the first commit lands.
417
420
  *
418
- * Install failure throws (init exits non-zero); a snapshot-render failure
419
- * is non-fatal observability loss and only warns.
421
+ * A snapshot-render failure is non-fatal observability loss and only warns.
420
422
  *
421
423
  * @param {{
422
424
  * provider: object,
423
425
  * storyId: number,
424
- * result: { workCwd?: string, dependenciesInstalled?: string, storyBranch?: string },
426
+ * result: { storyBranch?: string },
425
427
  * notify?: Function | null,
426
- * runInstall?: (cmd: string, cwd: string) => { status: number, stderr?: string },
427
- * skipInstall?: boolean,
428
- * installCmd?: string,
429
428
  * logger?: object,
430
429
  * }} args
431
430
  * @returns {Promise<{
432
- * installAction: 'skip' | 'install',
433
- * installCmd: string | null,
434
- * installResult: { status: number, stderr?: string } | null,
435
431
  * snapshot: object | null,
436
432
  * renderedBody: string | null,
437
433
  * }>}
@@ -441,29 +437,8 @@ export async function runStoryInitPrepare({
441
437
  storyId,
442
438
  result,
443
439
  notify: notifyFn = null,
444
- runInstall = runInstallCommand,
445
- skipInstall = false,
446
- installCmd: installCmdOverride,
447
440
  logger = stageLogger,
448
441
  }) {
449
- const dependenciesInstalled = String(
450
- result?.dependenciesInstalled ?? 'skipped',
451
- );
452
- const installAction = deriveInstallAction(dependenciesInstalled, {
453
- skipInstall,
454
- });
455
- let installCmd = null;
456
- let installResult = null;
457
- if (installAction === 'install') {
458
- installCmd = resolveInstallCommand({ override: installCmdOverride });
459
- installResult = runInstall(installCmd, result.workCwd);
460
- if (installResult.status !== 0) {
461
- throw new Error(
462
- `runStoryInitPrepare: install command \`${installCmd}\` failed with status ${installResult.status}: ${installResult.stderr ?? ''}`,
463
- );
464
- }
465
- }
466
-
467
442
  let snapshot = null;
468
443
  let renderedBody = null;
469
444
  try {
@@ -483,7 +458,7 @@ export async function runStoryInitPrepare({
483
458
  );
484
459
  }
485
460
 
486
- return { installAction, installCmd, installResult, snapshot, renderedBody };
461
+ return { snapshot, renderedBody };
487
462
  }
488
463
 
489
464
  function buildStoryInitResult({
@@ -27,6 +27,12 @@
27
27
  * --story <id> Story ID (required).
28
28
  * --phase <init|implementing|closing|blocked|done>
29
29
  * Phase the Story is entering (required).
30
+ * --epic <id> Parent Epic id from the Step 0 envelope.
31
+ * When supplied, skips the readEpicIdFromStory
32
+ * GitHub read.
33
+ * --branch <name> Story branch from the Step 0 envelope.
34
+ * When supplied, skips the resolveStoryBranch
35
+ * GitHub read.
30
36
  * --no-heartbeat Suppress the lifecycle emit (tests).
31
37
  *
32
38
  * Stdout: a single JSON envelope
@@ -67,12 +73,17 @@ const VALID_PHASES = new Set([
67
73
 
68
74
  const HELP = `Usage: node .agents/scripts/story-phase.js \\
69
75
  --story <id> --phase <init|implementing|closing|blocked|done> \\
70
- [--no-heartbeat]
76
+ [--epic <id>] [--branch <name>] [--no-heartbeat]
71
77
 
72
78
  Renders the Story-phase snapshot for Story #<id> at the requested phase
73
79
  (returned as renderedBody for chat relay; no comment is posted) and
74
80
  (unless --no-heartbeat) appends one story.heartbeat record to the parent
75
81
  Epic's lifecycle ledger so the Idle Watchdog can confirm the Story is alive.
82
+
83
+ --epic / --branch let the caller pass the parent Epic id and Story branch
84
+ from story-init.js's Step 0 envelope, skipping the GitHub reads
85
+ (readEpicIdFromStory / resolveStoryBranch) that would otherwise re-fetch
86
+ these immutable values on every phase call. Omit both for interactive use.
76
87
  `;
77
88
 
78
89
  /**
@@ -216,9 +227,17 @@ function emitHeartbeatBestEffort({
216
227
  * End-to-end phase writer. DI-friendly: tests pass `provider`, override
217
228
  * the ledger path, and skip the heartbeat as needed.
218
229
  *
230
+ * When `epicId` / `branch` are supplied (the `/deliver` worker passes them
231
+ * from `story-init.js`'s Step 0 envelope), the corresponding GitHub read is
232
+ * skipped entirely: `epicId` short-circuits `readEpicIdFromStory` and
233
+ * `branch` short-circuits `resolveStoryBranch`. Omit both for interactive
234
+ * use to restore the original GitHub-read resolution.
235
+ *
219
236
  * @param {{
220
237
  * storyId: number,
221
238
  * phase: string,
239
+ * epicId?: number|null,
240
+ * branch?: string,
222
241
  * noHeartbeat?: boolean,
223
242
  * provider?: object,
224
243
  * config?: object,
@@ -230,6 +249,8 @@ export async function runStoryPhase(args) {
230
249
  const {
231
250
  storyId,
232
251
  phase,
252
+ epicId: epicIdOverride,
253
+ branch: branchOverride,
233
254
  noHeartbeat = false,
234
255
  provider: providerOverride,
235
256
  config: configOverride,
@@ -253,8 +274,17 @@ export async function runStoryPhase(args) {
253
274
  : (ticketId, payload, opts = {}) =>
254
275
  notify(ticketId, payload, { config, provider, ...opts });
255
276
 
256
- const branch = await resolveStoryBranch({ provider, storyId });
257
- const epicId = await readEpicIdFromStory({ provider, storyId });
277
+ // When the Step 0 envelope supplied the branch / epicId (the /deliver
278
+ // worker passes them via --branch / --epic), skip the GitHub reads that
279
+ // would otherwise re-fetch these immutable values on every phase call.
280
+ const branch =
281
+ typeof branchOverride === 'string' && branchOverride
282
+ ? branchOverride
283
+ : await resolveStoryBranch({ provider, storyId });
284
+ const epicId =
285
+ epicIdOverride !== undefined
286
+ ? epicIdOverride
287
+ : await readEpicIdFromStory({ provider, storyId });
258
288
  const phases = phasesForWorkflowPhase(phase, now);
259
289
 
260
290
  const { body: renderedBody, payload: snapshot } =
@@ -301,17 +331,29 @@ export function parseArgv(argv) {
301
331
  options: {
302
332
  story: { type: 'string' },
303
333
  phase: { type: 'string' },
334
+ epic: { type: 'string' },
335
+ branch: { type: 'string' },
304
336
  'no-heartbeat': { type: 'boolean' },
305
337
  help: { type: 'boolean' },
306
338
  },
307
339
  strict: false,
308
340
  });
309
- return {
341
+ // `--epic` absent → leave `epicId` undefined so runStoryPhase falls back to
342
+ // the readEpicIdFromStory GitHub read. `--branch` absent → leave `branch`
343
+ // undefined so it falls back to resolveStoryBranch.
344
+ const parsed = {
310
345
  help: Boolean(values.help),
311
346
  storyId: Number.parseInt(values.story ?? '', 10),
312
347
  phase: values.phase,
313
348
  noHeartbeat: Boolean(values['no-heartbeat']),
314
349
  };
350
+ if (values.epic !== undefined) {
351
+ parsed.epicId = Number.parseInt(values.epic, 10);
352
+ }
353
+ if (typeof values.branch === 'string' && values.branch) {
354
+ parsed.branch = values.branch;
355
+ }
356
+ return parsed;
315
357
  }
316
358
 
317
359
  export async function main(argv = process.argv.slice(2)) {
@@ -23,10 +23,22 @@
23
23
  * environment (CLI, IDE, GUI, web, SDK). On a machine that previously synced
24
24
  * the plugin tree, this script reaps it on the next run (see reapPluginTree).
25
25
  *
26
- * Only top-level .md files are projected. The `.agents/workflows/helpers/`
27
- * subdirectory holds path-included modules (e.g. epic-code-review, epic-retro)
28
- * that parent workflows read by relative path — they are intentionally **not**
29
- * exposed as commands, so helpers/ is skipped.
26
+ * Top-level .md files project flat (`/<name>`). The
27
+ * `.agents/workflows/helpers/` subdirectory holds path-included modules
28
+ * (e.g. epic-code-review, epic-retro) that parent workflows read by
29
+ * relative path — they are intentionally **not** exposed as commands, so
30
+ * helpers/ is skipped.
31
+ *
32
+ * The `.agents/workflows/loops/` subdirectory is the **one** exception to
33
+ * the skip-subdirectories rule (Story #4289, Epic #4284). Each loop unit
34
+ * there projects to `.claude/commands/loops/<name>.md`, preserving the
35
+ * subpath so Claude Code namespaces it as `/loops:<name>` (matching Claude
36
+ * Code's subdirectory-command namespacing). Hosts that flatten
37
+ * subdirectory commands surface the same file as the flat fallback
38
+ * `/loops-<name>` (i.e. `loops-<name>` in the command tree) — the
39
+ * projection writes the namespaced path; the flat form is the documented
40
+ * host-side fallback, not a second on-disk copy. No subdirectory other
41
+ * than `loops/` is recursed.
30
42
  *
31
43
  * Usage: node .agents/scripts/sync-claude-commands.js
32
44
  */
@@ -127,43 +139,111 @@ function reapPluginTree() {
127
139
  reapPluginTree();
128
140
  fs.mkdirSync(DEST_DIR, { recursive: true });
129
141
 
130
- // Only sync top-level .md files. Subdirectories (notably helpers/) are
131
- // ignoredthey contain path-included modules, not slash commands.
142
+ // The only namespaced subdirectory we recurse. Every other subdirectory
143
+ // (notably helpers/) is skipped those hold path-included modules, not
144
+ // slash commands. Loop units under workflows/loops/ project into
145
+ // .claude/commands/loops/ so Claude Code namespaces them as /loops:<name>
146
+ // (Story #4289).
147
+ const LOOPS_NS = 'loops';
148
+
149
+ // Top-level .md files project flat. Subdirectories are skipped here and the
150
+ // only one re-introduced is loops/ (handled by enumerateLoopUnits below).
132
151
  const isTopLevelWorkflow = (entry) =>
133
152
  entry.isFile() && entry.name.endsWith('.md');
134
153
 
154
+ /**
155
+ * `README.md` (any case) under `loops/` is namespace documentation, not a
156
+ * loop unit — it carries no `loop:` frontmatter and must not project as a
157
+ * `/loops:README` command. Exclude it from the loop-unit enumeration (this
158
+ * mirrors `check-loop-units.js#isLoopUnitFile`, which excludes it from the
159
+ * lint gate).
160
+ *
161
+ * @param {import('node:fs').Dirent} entry
162
+ * @returns {boolean}
163
+ */
164
+ const isLoopUnit = (entry) =>
165
+ isTopLevelWorkflow(entry) && entry.name.toLowerCase() !== 'readme.md';
166
+
167
+ /**
168
+ * Enumerate the loop units under a source dir's `loops/` subdirectory.
169
+ * Returns entries keyed by the namespaced relative path
170
+ * (`loops/<name>.md`) so they never collide with a flat top-level command
171
+ * of the same basename and so the reap can track them distinctly. The
172
+ * directory's `README.md` is skipped — it is documentation, not a command.
173
+ *
174
+ * @param {string} dir — a workflows source root (payload or local).
175
+ * @returns {Array<{dir: string, name: string, rel: string}>}
176
+ */
177
+ function enumerateLoopUnits(dir) {
178
+ const loopsDir = path.join(dir, LOOPS_NS);
179
+ if (!dirExists(loopsDir)) return [];
180
+ return fs
181
+ .readdirSync(loopsDir, { withFileTypes: true })
182
+ .filter(isLoopUnit)
183
+ .map((e) => ({
184
+ dir,
185
+ name: e.name,
186
+ rel: `${LOOPS_NS}/${e.name}`,
187
+ }));
188
+ }
189
+
135
190
  // Enumerate sources: payload first, then local (if it exists). Payload wins
136
- // on basename collision — a consumer must not silently shadow a core command.
191
+ // on relative-path collision — a consumer must not silently shadow a core
192
+ // command. Each entry carries its destination-relative path (`rel`): a bare
193
+ // basename for flat top-level commands, `loops/<name>.md` for loop units.
137
194
  const SRC_DIRS = [PAYLOAD_SRC, LOCAL_SRC].filter(dirExists);
138
195
 
139
- /** @type {Array<{dir: string, name: string}>} */
140
- const entries = SRC_DIRS.flatMap((dir) =>
141
- fs
196
+ /** @type {Array<{dir: string, name: string, rel: string}>} */
197
+ const entries = SRC_DIRS.flatMap((dir) => [
198
+ ...fs
142
199
  .readdirSync(dir, { withFileTypes: true })
143
200
  .filter(isTopLevelWorkflow)
144
- .map((e) => ({ dir, name: e.name })),
145
- );
201
+ .map((e) => ({ dir, name: e.name, rel: e.name })),
202
+ ...enumerateLoopUnits(dir),
203
+ ]);
146
204
 
147
- // Collision policy: payload wins, warn on a shadowed local file.
148
- const byName = new Map();
205
+ // Collision policy: payload wins, warn on a shadowed local file. Keyed by the
206
+ // destination-relative path so a flat `foo.md` and a `loops/foo.md` are
207
+ // distinct entries.
208
+ const byRel = new Map();
149
209
  for (const e of entries) {
150
- if (byName.has(e.name)) {
151
- Logger.warn(` shadowed ${e.name} (local copy ignored; payload wins)`);
210
+ if (byRel.has(e.rel)) {
211
+ Logger.warn(` shadowed ${e.rel} (local copy ignored; payload wins)`);
152
212
  continue;
153
213
  }
154
- byName.set(e.name, e);
214
+ byRel.set(e.rel, e);
155
215
  }
156
216
 
157
217
  // sourceSet drives the orphan-reap: any existing command not in this set is
158
- // removed. Local-projected commands are included, so they survive the reap.
159
- const sourceSet = new Set(byName.keys());
218
+ // removed. Keyed by destination-relative path so loop units are reaped from
219
+ // the loops/ namespace and flat commands from the root.
220
+ const sourceSet = new Set(byRel.keys());
160
221
 
161
- const existing = fs.readdirSync(DEST_DIR).filter((f) => f.endsWith('.md'));
222
+ /**
223
+ * List the destination-relative paths of every projected command currently
224
+ * on disk: flat `*.md` at the root plus `loops/*.md` in the namespace.
225
+ *
226
+ * @returns {string[]}
227
+ */
228
+ function listExistingCommands() {
229
+ const flat = fs
230
+ .readdirSync(DEST_DIR)
231
+ .filter((f) => f.endsWith('.md'))
232
+ .map((f) => f);
233
+ const loopsDest = path.join(DEST_DIR, LOOPS_NS);
234
+ const loops = dirExists(loopsDest)
235
+ ? fs
236
+ .readdirSync(loopsDest)
237
+ .filter((f) => f.endsWith('.md'))
238
+ .map((f) => `${LOOPS_NS}/${f}`)
239
+ : [];
240
+ return [...flat, ...loops];
241
+ }
162
242
 
163
- for (const file of existing) {
164
- if (!sourceSet.has(file)) {
165
- fs.unlinkSync(path.join(DEST_DIR, file));
166
- Logger.info(` removed ${file} (no longer in workflows)`);
243
+ for (const rel of listExistingCommands()) {
244
+ if (!sourceSet.has(rel)) {
245
+ fs.unlinkSync(path.join(DEST_DIR, rel));
246
+ Logger.info(` removed ${rel} (no longer in workflows)`);
167
247
  }
168
248
  }
169
249
 
@@ -173,15 +253,18 @@ for (const file of existing) {
173
253
  // Parallelised so the ~30-file sync doesn't serialise on per-file fs latency
174
254
  // (noticeable on Windows where each syscall pays a larger fixed cost).
175
255
  let synced = 0;
176
- const resolvedEntries = Array.from(byName.values());
256
+ const resolvedEntries = Array.from(byRel.values());
177
257
  await Promise.all(
178
- resolvedEntries.map(async ({ dir, name }) => {
258
+ resolvedEntries.map(async ({ dir, rel }) => {
179
259
  const isLocal = dir === LOCAL_SRC;
180
260
  const header = isLocal ? LOCAL_HEADER : HEADER;
181
- const content = await fs.promises.readFile(path.join(dir, name), 'utf8');
182
- const dest = path.join(DEST_DIR, name);
261
+ const content = await fs.promises.readFile(path.join(dir, rel), 'utf8');
262
+ const dest = path.join(DEST_DIR, rel);
183
263
  const target = applyHeader(content, header);
184
264
 
265
+ // Ensure the namespace subdirectory exists before writing a loop unit.
266
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
267
+
185
268
  // Skip write if content is already identical (avoid noisy git diffs).
186
269
  // Use try/catch over existsSync+readFile so we only pay one syscall.
187
270
  try {
@@ -193,7 +276,7 @@ await Promise.all(
193
276
 
194
277
  await fs.promises.writeFile(dest, target, 'utf8');
195
278
  synced++;
196
- Logger.info(` synced ${name}`);
279
+ Logger.info(` synced ${rel}`);
197
280
  }),
198
281
  );
199
282