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.
@@ -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
- // Any open typed plan ticket counts `type::story` plus pre-v4
345
- // `type::feature` leftovers. The prefix check is legacy-data
346
- // detection, not compat support: the guard only refuses, it never
347
- // processes the legacy tier. Context tickets (`context::*`) are
348
- // untouched.
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
- // always inject TYPE_LABELS.STORY so a spec that omits the labels array
324
- // cannot produce an unlabeled, undispatchable Story. Dedupe to avoid
325
- // duplicates when the caller already carries the label.
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 labels = callerLabels.includes(TYPE_LABELS.STORY)
328
- ? callerLabels
329
- : [TYPE_LABELS.STORY, ...callerLabels];
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/ into a flat `.claude/commands/` tree so Claude
5
- * Code exposes each workflow as a bare `/<name>` slash command. The workflows
6
- * directory remains the single source of truth; this script is the only writer
7
- * of `.claude/commands/`.
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
- const SRC_DIR =
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). Parallelised so the ~30-file sync doesn't serialise on
122
- // per-file fs latency (noticeable on Windows where each syscall pays a larger
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
- sources.map(async (file) => {
127
- const content = await fs.promises.readFile(
128
- path.join(SRC_DIR, file),
129
- 'utf8',
130
- );
131
- const dest = path.join(DEST_DIR, file);
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 ${file}`);
196
+ Logger.info(` synced ${name}`);
146
197
  }),
147
198
  );
148
199
 
149
200
  Logger.info(
150
- `\n✔ ${synced} file(s) synced, ${sources.length} total commands in .claude/commands/`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.74.0",
3
+ "version": "1.76.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",