mandrel 1.74.0 → 1.76.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/instructions.md +9 -0
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +14 -5
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +11 -0
- package/.agents/scripts/providers/github/tickets.js +19 -6
- package/.agents/scripts/sync-claude-commands.js +73 -22
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
package/.agents/instructions.md
CHANGED
|
@@ -76,6 +76,15 @@ environment variables that override project defaults. The config resolver
|
|
|
76
76
|
deep-merges `.agentrc.local.json` over `.agentrc.json` (local wins; absent
|
|
77
77
|
local file is a no-op). Do not modify these local files unless requested.
|
|
78
78
|
|
|
79
|
+
**Durable slash commands.** Any `.md` file placed at
|
|
80
|
+
`.agents/local/workflows/<name>.md` is automatically projected into
|
|
81
|
+
`.claude/commands/<name>.md` by `sync-claude-commands.js`, making it
|
|
82
|
+
invocable as `/<name>`. Because the entire `.agents/local/` subtree is
|
|
83
|
+
exempt from `mandrel sync`'s prune pass, these commands survive
|
|
84
|
+
`npm install`, `mandrel sync`, and `mandrel update` with no manual
|
|
85
|
+
re-sync. Core payload commands of the same basename always win (the
|
|
86
|
+
local copy is ignored with a `shadowed` warning).
|
|
87
|
+
|
|
79
88
|
### F. Modular Global Rules
|
|
80
89
|
|
|
81
90
|
Before writing code or documentation, verify if any domain-agnostic rules
|
|
@@ -341,11 +341,20 @@ export async function assertNoOpenPlanChildren({
|
|
|
341
341
|
const openChildren = (children ?? []).filter((t) => {
|
|
342
342
|
const labels = Array.isArray(t.labels) ? t.labels : [];
|
|
343
343
|
const isOpen = t.state === undefined || t.state === 'open';
|
|
344
|
-
//
|
|
345
|
-
// `
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
344
|
+
// Context spec tickets (PRD / Tech Spec / Acceptance Spec) carry a
|
|
345
|
+
// `context::*` label and — since the `createTicket` factory injects
|
|
346
|
+
// `type::story` by default — also `type::story`. They are reference
|
|
347
|
+
// artifacts that stay open across delivery, NOT plan children, so they
|
|
348
|
+
// MUST be excluded here: counting them would make every first decompose
|
|
349
|
+
// (where the three context tickets are the only open children) refuse.
|
|
350
|
+
const isContext = labels.some(
|
|
351
|
+
(l) => typeof l === 'string' && l.startsWith('context::'),
|
|
352
|
+
);
|
|
353
|
+
if (isContext) return false;
|
|
354
|
+
// Any remaining open typed plan ticket counts — `type::story` plus pre-v4
|
|
355
|
+
// `type::feature` leftovers. The prefix check is legacy-data detection,
|
|
356
|
+
// not compat support: the guard only refuses, it never processes the
|
|
357
|
+
// legacy tier.
|
|
349
358
|
return (
|
|
350
359
|
isOpen &&
|
|
351
360
|
labels.some((l) => typeof l === 'string' && l.startsWith('type::'))
|
|
@@ -35,6 +35,17 @@ export async function discoverOpenStories({ epicId, provider }) {
|
|
|
35
35
|
for (const t of descendants) {
|
|
36
36
|
const labels = t.labels ?? [];
|
|
37
37
|
if (!labels.includes(TYPE_LABELS.STORY)) continue;
|
|
38
|
+
// Defense-in-depth: never enumerate a context spec ticket (PRD / Tech
|
|
39
|
+
// Spec / Acceptance Spec) as a deliverable Story even if it carries
|
|
40
|
+
// `type::story`. The `createTicket` factory no longer stamps context
|
|
41
|
+
// tickets that way, but a context ticket mislabelled out-of-band must
|
|
42
|
+
// still never reach a delivery wave (it has no `story-<id>` branch or
|
|
43
|
+
// acceptance contract to deliver against).
|
|
44
|
+
if (
|
|
45
|
+
labels.some((l) => typeof l === 'string' && l.startsWith('context::'))
|
|
46
|
+
) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
38
49
|
const rawState = t.state ?? 'open';
|
|
39
50
|
const norm = typeof rawState === 'string' ? rawState.toLowerCase() : 'open';
|
|
40
51
|
if (norm !== 'open') continue;
|
|
@@ -320,13 +320,26 @@ export class TicketGateway {
|
|
|
320
320
|
});
|
|
321
321
|
|
|
322
322
|
// Mirror the Epic create path (issues.js:160 → `labels: TYPE_LABELS.EPIC`):
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
323
|
+
// inject TYPE_LABELS.STORY so a spec that omits the labels array cannot
|
|
324
|
+
// produce an unlabeled, undispatchable Story. Dedupe to avoid duplicates
|
|
325
|
+
// when the caller already carries the label.
|
|
326
|
+
//
|
|
327
|
+
// Context spec tickets (PRD / Tech Spec / Acceptance Spec) are a distinct
|
|
328
|
+
// ticket class created through this same factory carrying a `context::*`
|
|
329
|
+
// label (and no `type::story`). They MUST NOT be stamped `type::story`:
|
|
330
|
+
// doing so makes every `type::story`-counting consumer — the decompose
|
|
331
|
+
// open-children guard (`assertNoOpenPlanChildren`), the delivery wave
|
|
332
|
+
// builder (`discoverOpenStories`), the plan healthcheck — mis-classify
|
|
333
|
+
// them as deliverable Stories. Skip the injection when the caller's labels
|
|
334
|
+
// already classify the ticket as context.
|
|
326
335
|
const callerLabels = ticketData.labels ?? [];
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
336
|
+
const isContextTicket = callerLabels.some(
|
|
337
|
+
(l) => typeof l === 'string' && l.startsWith('context::'),
|
|
338
|
+
);
|
|
339
|
+
const labels =
|
|
340
|
+
isContextTicket || callerLabels.includes(TYPE_LABELS.STORY)
|
|
341
|
+
? callerLabels
|
|
342
|
+
: [TYPE_LABELS.STORY, ...callerLabels];
|
|
330
343
|
const result = await this._gh.api({
|
|
331
344
|
method: 'POST',
|
|
332
345
|
endpoint: `/repos/${this.owner}/${this.repo}/issues`,
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Projects .agents/workflows/
|
|
5
|
-
* Code exposes each workflow as a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Projects .agents/workflows/ (and .agents/local/workflows/ when present) into
|
|
5
|
+
* a flat `.claude/commands/` tree so Claude Code exposes each workflow as a
|
|
6
|
+
* bare `/<name>` slash command. Two source directories are enumerated in order:
|
|
7
|
+
*
|
|
8
|
+
* 1. PAYLOAD_SRC — `.agents/workflows/` (the installed Mandrel payload)
|
|
9
|
+
* 2. LOCAL_SRC — `.agents/local/workflows/` (consumer-authored, prune-exempt)
|
|
10
|
+
*
|
|
11
|
+
* The payload directory wins on basename collision: if both sources supply
|
|
12
|
+
* `foo.md`, the payload copy is projected and the local copy is ignored with a
|
|
13
|
+
* `shadowed` warning. Because both sources are unioned into `sourceSet`, local
|
|
14
|
+
* commands are also protected from the orphan-reap — they survive
|
|
15
|
+
* `npm install`, `mandrel sync`, and `mandrel update` with no manual re-sync.
|
|
8
16
|
*
|
|
9
17
|
* Flat projection (reverts the #3576 plugin cutover): the plugin command tree
|
|
10
18
|
* (`.claude/plugins/mandrel/`) and the repo-local marketplace
|
|
@@ -48,9 +56,14 @@ const PROJECT_ROOT = process.cwd();
|
|
|
48
56
|
// fixture workflow tree in isolation (regression test for the Epic #1185
|
|
49
57
|
// frontmatter pass-through contract). When unset, behaviour is unchanged
|
|
50
58
|
// — the script defaults to the real workflows / commands directories.
|
|
51
|
-
|
|
59
|
+
// SYNC_CLAUDE_COMMANDS_SRC overrides the PAYLOAD source only; the LOCAL_SRC
|
|
60
|
+
// is always derived from the project root so fixture tests can isolate the
|
|
61
|
+
// payload source while still allowing LOCAL_SRC to be present if needed.
|
|
62
|
+
const PAYLOAD_SRC =
|
|
52
63
|
process.env.SYNC_CLAUDE_COMMANDS_SRC ??
|
|
53
64
|
path.join(PROJECT_ROOT, '.agents', 'workflows');
|
|
65
|
+
const LOCAL_SRC = path.join(PROJECT_ROOT, '.agents', 'local', 'workflows');
|
|
66
|
+
|
|
54
67
|
const DEST_DIR =
|
|
55
68
|
process.env.SYNC_CLAUDE_COMMANDS_DEST ??
|
|
56
69
|
path.join(PROJECT_ROOT, '.claude', 'commands');
|
|
@@ -58,6 +71,23 @@ const DEST_DIR =
|
|
|
58
71
|
export const HEADER =
|
|
59
72
|
'<!-- AUTO-GENERATED — do not edit. Source of truth: .agents/workflows/ -->\n<!-- Re-run: npm run sync:commands -->\n\n';
|
|
60
73
|
|
|
74
|
+
export const LOCAL_HEADER =
|
|
75
|
+
'<!-- AUTO-GENERATED from .agents/local/ — do not edit. Source of truth: .agents/local/workflows/ -->\n<!-- Re-run: npm run sync:commands -->\n\n';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Return true when the given directory path exists and is accessible.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} dir
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
function dirExists(dir) {
|
|
84
|
+
try {
|
|
85
|
+
return fs.statSync(dir).isDirectory();
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
61
91
|
/**
|
|
62
92
|
* Reap the generated plugin projection (the #3576 surface) so the namespaced
|
|
63
93
|
* `/mandrel:<name>` commands and the repo-local marketplace stop shadowing the
|
|
@@ -102,12 +132,33 @@ fs.mkdirSync(DEST_DIR, { recursive: true });
|
|
|
102
132
|
const isTopLevelWorkflow = (entry) =>
|
|
103
133
|
entry.isFile() && entry.name.endsWith('.md');
|
|
104
134
|
|
|
135
|
+
// Enumerate sources: payload first, then local (if it exists). Payload wins
|
|
136
|
+
// on basename collision — a consumer must not silently shadow a core command.
|
|
137
|
+
const SRC_DIRS = [PAYLOAD_SRC, LOCAL_SRC].filter(dirExists);
|
|
138
|
+
|
|
139
|
+
/** @type {Array<{dir: string, name: string}>} */
|
|
140
|
+
const entries = SRC_DIRS.flatMap((dir) =>
|
|
141
|
+
fs
|
|
142
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
143
|
+
.filter(isTopLevelWorkflow)
|
|
144
|
+
.map((e) => ({ dir, name: e.name })),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Collision policy: payload wins, warn on a shadowed local file.
|
|
148
|
+
const byName = new Map();
|
|
149
|
+
for (const e of entries) {
|
|
150
|
+
if (byName.has(e.name)) {
|
|
151
|
+
Logger.warn(` shadowed ${e.name} (local copy ignored; payload wins)`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
byName.set(e.name, e);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// sourceSet drives the orphan-reap: any existing command not in this set is
|
|
158
|
+
// removed. Local-projected commands are included, so they survive the reap.
|
|
159
|
+
const sourceSet = new Set(byName.keys());
|
|
160
|
+
|
|
105
161
|
const existing = fs.readdirSync(DEST_DIR).filter((f) => f.endsWith('.md'));
|
|
106
|
-
const sources = fs
|
|
107
|
-
.readdirSync(SRC_DIR, { withFileTypes: true })
|
|
108
|
-
.filter(isTopLevelWorkflow)
|
|
109
|
-
.map((entry) => entry.name);
|
|
110
|
-
const sourceSet = new Set(sources);
|
|
111
162
|
|
|
112
163
|
for (const file of existing) {
|
|
113
164
|
if (!sourceSet.has(file)) {
|
|
@@ -118,18 +169,18 @@ for (const file of existing) {
|
|
|
118
169
|
|
|
119
170
|
// Copy each workflow, injecting the auto-generated header after any leading
|
|
120
171
|
// frontmatter (so the `---` block stays on line 1 and Claude Code parses the
|
|
121
|
-
// command description).
|
|
122
|
-
//
|
|
123
|
-
// fixed cost).
|
|
172
|
+
// command description). Use a distinct header comment for local-origin files.
|
|
173
|
+
// Parallelised so the ~30-file sync doesn't serialise on per-file fs latency
|
|
174
|
+
// (noticeable on Windows where each syscall pays a larger fixed cost).
|
|
124
175
|
let synced = 0;
|
|
176
|
+
const resolvedEntries = Array.from(byName.values());
|
|
125
177
|
await Promise.all(
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
);
|
|
131
|
-
const
|
|
132
|
-
const target = applyHeader(content, HEADER);
|
|
178
|
+
resolvedEntries.map(async ({ dir, name }) => {
|
|
179
|
+
const isLocal = dir === LOCAL_SRC;
|
|
180
|
+
const header = isLocal ? LOCAL_HEADER : HEADER;
|
|
181
|
+
const content = await fs.promises.readFile(path.join(dir, name), 'utf8');
|
|
182
|
+
const dest = path.join(DEST_DIR, name);
|
|
183
|
+
const target = applyHeader(content, header);
|
|
133
184
|
|
|
134
185
|
// Skip write if content is already identical (avoid noisy git diffs).
|
|
135
186
|
// Use try/catch over existsSync+readFile so we only pay one syscall.
|
|
@@ -142,10 +193,10 @@ await Promise.all(
|
|
|
142
193
|
|
|
143
194
|
await fs.promises.writeFile(dest, target, 'utf8');
|
|
144
195
|
synced++;
|
|
145
|
-
Logger.info(` synced ${
|
|
196
|
+
Logger.info(` synced ${name}`);
|
|
146
197
|
}),
|
|
147
198
|
);
|
|
148
199
|
|
|
149
200
|
Logger.info(
|
|
150
|
-
`\n✔ ${synced} file(s) synced, ${
|
|
201
|
+
`\n✔ ${synced} file(s) synced, ${sourceSet.size} total commands in .claude/commands/`,
|
|
151
202
|
);
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.76.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.75.0...mandrel-v1.76.0) (2026-06-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
* **plan:** stop stamping context spec tickets as type::story so first decompose isn't blocked ([#4246](https://github.com/dsj1984/mandrel/issues/4246)) ([#4247](https://github.com/dsj1984/mandrel/issues/4247)) ([564cfb8](https://github.com/dsj1984/mandrel/commit/564cfb81876f87ad5f61f3c670efb7cfc2c95540))
|
|
11
|
+
|
|
12
|
+
## [1.75.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.74.0...mandrel-v1.75.0) (2026-06-19)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
* **sync:** project .agents/local/workflows/ as prune-exempt slash commands ([#4244](https://github.com/dsj1984/mandrel/issues/4244)) ([9470f6a](https://github.com/dsj1984/mandrel/commit/9470f6a567b656eee508c2ef10910454a084ee43))
|
|
18
|
+
|
|
5
19
|
## [1.74.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.73.0...mandrel-v1.74.0) (2026-06-19)
|
|
6
20
|
|
|
7
21
|
|
package/package.json
CHANGED