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