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
|
@@ -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
|
|
72
|
-
* `useEvidence !== false`, each gate consults
|
|
73
|
-
* .shouldSkip()` against current HEAD + the gate's
|
|
74
|
-
* matching record skips the gate; a successful run
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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:
|
|
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 {
|
|
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
|
+
}
|