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.
- package/.agents/docs/configuration.md +2 -2
- package/.agents/docs/workflows.md +19 -0
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
- package/.agents/schemas/loop-unit.schema.json +70 -0
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/check-doc-links.js +24 -1
- package/.agents/scripts/check-loop-units.js +204 -0
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/generate-workflows-doc.js +37 -4
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
- package/.agents/scripts/lib/cli-args.js +6 -0
- package/.agents/scripts/lib/close-validation/process.js +61 -5
- package/.agents/scripts/lib/close-validation/runner.js +42 -9
- package/.agents/scripts/lib/config/temp-paths.js +1 -1
- package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
- package/.agents/scripts/lib/config-resolver.js +4 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
- package/.agents/scripts/lib/mandrel-catalog.js +36 -0
- package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
- package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
- package/.agents/scripts/lib/story-body/story-body.js +76 -7
- package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
- package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
- package/.agents/scripts/lib/validation-evidence.js +63 -25
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
- package/.agents/scripts/providers/github/branch-protection.js +1 -1
- package/.agents/scripts/providers/github/errors.js +53 -2
- package/.agents/scripts/providers/github/labels.js +1 -1
- package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/run-lint.js +11 -0
- package/.agents/scripts/single-story-init.js +22 -29
- package/.agents/scripts/story-init.js +38 -63
- package/.agents/scripts/story-phase.js +46 -4
- package/.agents/scripts/sync-claude-commands.js +112 -29
- package/.agents/scripts/update-maintainability-baseline.js +19 -76
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
- package/.agents/workflows/helpers/deliver-epic.md +19 -2
- package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
- package/.agents/workflows/helpers/single-story-deliver.md +12 -0
- package/.agents/workflows/loops/README.md +65 -0
- package/.agents/workflows/loops/fix-failing-tests.md +74 -0
- package/.agents/workflows/loops/nightly-audit.md +71 -0
- package/.agents/workflows/loops/watch-ci.md +68 -0
- package/docs/CHANGELOG.md +51 -0
- package/package.json +1 -1
- 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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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
|
-
*
|
|
393
|
-
*
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
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
|
-
*
|
|
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: {
|
|
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 {
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
27
|
-
* subdirectory holds path-included modules
|
|
28
|
-
* that parent workflows read by
|
|
29
|
-
* exposed as commands, so
|
|
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
|
-
//
|
|
131
|
-
//
|
|
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
|
|
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
|
-
|
|
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 (
|
|
151
|
-
Logger.warn(` shadowed ${e.
|
|
210
|
+
if (byRel.has(e.rel)) {
|
|
211
|
+
Logger.warn(` shadowed ${e.rel} (local copy ignored; payload wins)`);
|
|
152
212
|
continue;
|
|
153
213
|
}
|
|
154
|
-
|
|
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.
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
if (!sourceSet.has(
|
|
165
|
-
fs.unlinkSync(path.join(DEST_DIR,
|
|
166
|
-
Logger.info(` removed ${
|
|
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(
|
|
256
|
+
const resolvedEntries = Array.from(byRel.values());
|
|
177
257
|
await Promise.all(
|
|
178
|
-
resolvedEntries.map(async ({ dir,
|
|
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,
|
|
182
|
-
const dest = path.join(DEST_DIR,
|
|
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 ${
|
|
279
|
+
Logger.info(` synced ${rel}`);
|
|
197
280
|
}),
|
|
198
281
|
);
|
|
199
282
|
|