mandrel 1.77.0 → 1.79.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.
Files changed (30) hide show
  1. package/.agents/docs/workflows.md +19 -0
  2. package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
  3. package/.agents/schemas/loop-unit.schema.json +70 -0
  4. package/.agents/scripts/check-doc-links.js +24 -1
  5. package/.agents/scripts/check-loop-units.js +204 -0
  6. package/.agents/scripts/generate-workflows-doc.js +37 -4
  7. package/.agents/scripts/lib/close-validation/process.js +61 -5
  8. package/.agents/scripts/lib/close-validation/runner.js +17 -1
  9. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
  10. package/.agents/scripts/lib/mandrel-catalog.js +36 -0
  11. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +7 -2
  12. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +15 -36
  13. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
  14. package/.agents/scripts/lib/story-lifecycle.js +12 -4
  15. package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -5
  16. package/.agents/scripts/providers/github/branch-protection.js +1 -1
  17. package/.agents/scripts/providers/github/errors.js +53 -2
  18. package/.agents/scripts/providers/github/labels.js +1 -1
  19. package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
  20. package/.agents/scripts/run-lint.js +11 -0
  21. package/.agents/scripts/sync-claude-commands.js +112 -29
  22. package/.agents/scripts/update-maintainability-baseline.js +19 -76
  23. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +10 -7
  24. package/.agents/workflows/loops/README.md +65 -0
  25. package/.agents/workflows/loops/fix-failing-tests.md +74 -0
  26. package/.agents/workflows/loops/nightly-audit.md +71 -0
  27. package/.agents/workflows/loops/watch-ci.md +68 -0
  28. package/docs/CHANGELOG.md +26 -0
  29. package/package.json +1 -1
  30. package/.agents/scripts/providers/github/transient-retry.js +0 -62
@@ -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
@@ -22,9 +22,13 @@ import { applyBudget } from '../../planning-context-budget.js';
22
22
 
23
23
  export function buildDecomposerSystemPrompt(
24
24
  heuristics = [],
25
- { maxTickets, maxTokenBudget } = {},
25
+ { maxTickets, maxTokenBudget, epicId } = {},
26
26
  ) {
27
- const base = renderDecomposerSystemPrompt({ maxTickets, maxTokenBudget });
27
+ const base = renderDecomposerSystemPrompt({
28
+ maxTickets,
29
+ maxTokenBudget,
30
+ epicId,
31
+ });
28
32
  const heuristicsStr =
29
33
  heuristics.length > 0
30
34
  ? `### RISK HEURISTICS (planning metadata if any apply):\n- ${heuristics.join('\n- ')}`
@@ -121,6 +125,7 @@ export async function buildDecompositionContext(
121
125
  const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
122
126
  maxTickets,
123
127
  maxTokenBudget,
128
+ epicId,
124
129
  });
125
130
 
126
131
  const budgeted = applyBudget(
@@ -80,6 +80,7 @@
80
80
  * issue with an empty body.
81
81
  */
82
82
 
83
+ import { composeStoryBody } from '../../providers/github/tickets.js';
83
84
  import { assertPlanLabelAllowList } from './epic-spec-reconciler-discriminator.js';
84
85
  import {
85
86
  closeOp,
@@ -208,34 +209,6 @@ function stripFooter(body) {
208
209
  return value.replace(ORCHESTRATOR_FOOTER_RE, '').replace(/\s+$/, '');
209
210
  }
210
211
 
211
- /**
212
- * Render the canonical orchestrator footer (no leading newline). Format
213
- * matches the byte-stable shape that the cascade-reading consumers
214
- * (story-init, dispatcher, manifest, close-gate) parse line-anchored:
215
- *
216
- * ---
217
- * parent: #<parentId>
218
- * [Epic: #<epicId>] // only when epicId !== parentId
219
- *
220
- * [blocked by #<dep>] // one per dependency
221
- *
222
- * @param {{parentId: number, epicId?: number, dependencies?: number[]}} opts
223
- * @returns {string}
224
- */
225
- function renderFooter({ parentId, epicId, dependencies = [] }) {
226
- const lines = ['---', `parent: #${parentId}`];
227
- if (epicId !== undefined && epicId !== null && epicId !== parentId) {
228
- lines.push(`Epic: #${epicId}`);
229
- }
230
- if (dependencies.length > 0) {
231
- lines.push('');
232
- for (const dep of dependencies) {
233
- lines.push(`blocked by #${dep}`);
234
- }
235
- }
236
- return lines.join('\n');
237
- }
238
-
239
212
  /**
240
213
  * Compose the canonical orchestrator footer onto a spec body for non-epic
241
214
  * entities. Resolves `parentSlug`/`dependsOn` slugs against the running
@@ -246,12 +219,19 @@ function renderFooter({ parentId, epicId, dependencies = [] }) {
246
219
  * the YAML spec writes just the description, silently stripping
247
220
  * `parent: #N` / `Epic: #M` / `blocked by #X` and breaking the cascade.
248
221
  *
249
- * Story #3185 — the footer compose/strip logic is inlined here rather
250
- * than reused from the legacy Task-body renderer module. That renderer
251
- * was removed, so the diff engine carries its own footer shape. The shape
252
- * is byte-identical to the legacy renderer's `parent: #<n>` /
253
- * `Epic: #<m>` / `blocked by #<x>` output so cascade-readers continue
254
- * to parse it unchanged.
222
+ * Story #4300 — the footer rendering is single-sourced from
223
+ * `composeStoryBody` (`providers/github/tickets.js`), the same helper the
224
+ * CREATE path (`epic-spec-reconciler-apply.js` `provider.createTicket`)
225
+ * uses. Story #3185 previously inlined a parallel `renderFooter` here to
226
+ * avoid depending on the (now-removed) legacy Task-body renderer; that
227
+ * inlined copy silently diverged from `composeStoryBody` by gating the
228
+ * `Epic: #<id>` line on `epicId !== parentId` — a 3-tier-era condition
229
+ * that is always false under the 2-tier hierarchy (a Story's parent IS
230
+ * the Epic), so force re-decompose (`/plan --force`, which routes through
231
+ * this UPDATE path) silently dropped `Epic: #<id>` from every refreshed
232
+ * Story body and broke `story-init.js`'s hierarchy resolution. Importing
233
+ * `composeStoryBody` directly makes that divergence structurally
234
+ * impossible going forward.
255
235
  *
256
236
  * @param {{entity: string, parentSlug?: string|null, dependsOn?: string[]}} specEntity
257
237
  * @param {string} specBody
@@ -284,8 +264,7 @@ function composeBodyWithFooter(specEntity, specBody, ctx) {
284
264
  // included) or emits a canonical-form body. With the strip, the
285
265
  // function is idempotent against its own output.
286
266
  const head = stripFooter(specBody);
287
- const footer = renderFooter({ parentId, epicId, dependencies });
288
- return `${head}\n\n${footer}`;
267
+ return composeStoryBody({ body: head, parentId, epicId, dependencies });
289
268
  }
290
269
 
291
270
  /**
@@ -0,0 +1,183 @@
1
+ /**
2
+ * emit-loop-tick.js — Story #4287 (Epic #4284).
3
+ *
4
+ * Programmatic helper that emits a single `loop.tick` lifecycle event
5
+ * THROUGH the lifecycle bus so a host-driven loop (e.g. a `/loop`-style
6
+ * recurring command or a long-running poll) lands a per-pass record in
7
+ * the on-disk ledger the `/deliver` idle watchdog already scans. The
8
+ * record is what keeps a host loop from running silently: each round
9
+ * appends an inspectable `emitted` line a reconciler can read for
10
+ * forward-progress evidence.
11
+ *
12
+ * Distinct from `story.heartbeat` (emit-story-heartbeat.js): the
13
+ * heartbeat carries Story-phase info for a single in-flight Story and is
14
+ * always Epic-scoped (its ledger path is `epicLedgerPath(epicId)`). A
15
+ * host loop is not bound to a Story tier, so `loop.tick` carries a
16
+ * free-form `loopName`, a monotonic `round` counter, the loop's
17
+ * configured `cadence` label, and a per-round `status` instead. Keeping
18
+ * the two events separate means a loop tick never masquerades as Story
19
+ * progress (and vice versa).
20
+ *
21
+ * Bus path (Story acceptance: "Emitting a loop.tick event THROUGH the
22
+ * lifecycle bus appends a record to the per-run ledger"): this helper
23
+ * constructs a `Bus`, registers a `LedgerWriter` against it, and calls
24
+ * `bus.emit('loop.tick', payload)`. The bus validates the payload against
25
+ * `loop.tick.schema.json` before any listener runs, and the
26
+ * LedgerWriter's privileged `onEmitted` hook lands the `emitted` record
27
+ * on disk — exactly the same persistence path every other lifecycle
28
+ * event flows through. The helper does NOT bypass the bus with a direct
29
+ * `appendFileSync`; routing through the bus is what gives the record its
30
+ * schema-validated, seqId-stamped guarantee.
31
+ *
32
+ * Schema contract (loop.tick.schema.json):
33
+ * { event, loopName, round, cadence, status, timestamp }
34
+ *
35
+ * The schema declares `additionalProperties: false`, so this emitter's
36
+ * signature is deliberately narrow: only the schema-allowed fields are
37
+ * accepted. `status` is one of running|done|blocked.
38
+ *
39
+ * Ledger path resolution: a caller supplies EITHER an explicit
40
+ * `ledgerPath` (the host-loop case — the loop owns where its ledger
41
+ * lives) OR an `epicId`, in which case the canonical
42
+ * `epicLedgerPath(epicId)` is used so an Epic-scoped loop's ticks land
43
+ * in the same `temp/epic-<id>/lifecycle.ndjson` the rest of the run
44
+ * reads. Exactly one of the two MUST be supplied.
45
+ */
46
+
47
+ import path from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
49
+
50
+ import { epicLedgerPath } from '../../config/temp-paths.js';
51
+ import { createBus } from './bus.js';
52
+ import { createLedgerWriter } from './ledger-writer.js';
53
+
54
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
55
+ const SCHEMA_DIR = path.resolve(
56
+ __dirname,
57
+ '..',
58
+ '..',
59
+ '..',
60
+ '..',
61
+ 'schemas',
62
+ 'lifecycle',
63
+ );
64
+
65
+ const VALID_STATUSES = new Set(['running', 'done', 'blocked']);
66
+
67
+ /**
68
+ * Parse `temp/epic-<id>/lifecycle.ndjson` (or any
69
+ * `<dir>/epic-<id>/lifecycle.ndjson`) back into `{ tempRoot, epicId }`
70
+ * so a LedgerWriter — which is constructed from `{ epicId, tempRoot }`
71
+ * rather than a raw path — can be bound to the supplied ledger path.
72
+ *
73
+ * The LedgerWriter intentionally re-derives the ledger path from its
74
+ * `tempRoot` + `epicId` (so it can recreate the directory if a listener
75
+ * moves it mid-run), so we decompose the path the caller gave us into
76
+ * those two parts here.
77
+ *
78
+ * @param {string} ledgerPath
79
+ * @returns {{ tempRoot: string, epicId: number }}
80
+ */
81
+ function decomposeLedgerPath(ledgerPath) {
82
+ const epicDir = path.dirname(ledgerPath);
83
+ const tempRoot = path.dirname(epicDir);
84
+ const epicDirName = path.basename(epicDir);
85
+ const m = /^epic-(\d+)$/.exec(epicDirName);
86
+ if (!m) {
87
+ throw new Error(
88
+ `emitLoopTick: ledgerPath does not match <tempRoot>/epic-<id>/lifecycle.ndjson layout (got ${ledgerPath})`,
89
+ );
90
+ }
91
+ const epicId = Number.parseInt(m[1], 10);
92
+ return { tempRoot, epicId };
93
+ }
94
+
95
+ /**
96
+ * Emit exactly one `loop.tick` event through the lifecycle bus, landing
97
+ * an `emitted` (and `completed`) NDJSON record in the resolved ledger.
98
+ *
99
+ * @param {object} opts
100
+ * @param {string} opts.loopName Free-form loop identifier (non-empty).
101
+ * @param {number} opts.round Monotonic pass counter (integer >= 0).
102
+ * @param {string} opts.cadence Configured interval label, e.g. '5m'.
103
+ * @param {string} [opts.status='running']
104
+ * One of running|done|blocked.
105
+ * @param {string} [opts.timestamp] ISO-8601 wall clock. Defaults to now().
106
+ * @param {number} [opts.epicId] When supplied (and no `ledgerPath`),
107
+ * the canonical `epicLedgerPath(epicId)`
108
+ * is used for the ledger.
109
+ * @param {object} [opts.config] Optional resolved config for tempRoot
110
+ * (only consulted on the `epicId` path).
111
+ * @param {string} [opts.ledgerPath] Explicit ledger path (host-loop case).
112
+ * Mutually exclusive with `epicId`.
113
+ * @returns {Promise<{ ledgerPath: string, payload: object, seqId: number }>}
114
+ */
115
+ export async function emitLoopTick(opts) {
116
+ const {
117
+ loopName,
118
+ round,
119
+ cadence,
120
+ status = 'running',
121
+ timestamp = new Date().toISOString(),
122
+ epicId,
123
+ config,
124
+ ledgerPath: ledgerPathOverride,
125
+ } = opts ?? {};
126
+
127
+ if (typeof loopName !== 'string' || loopName.length === 0) {
128
+ throw new Error('emitLoopTick: loopName must be a non-empty string');
129
+ }
130
+ if (!Number.isInteger(round) || round < 0) {
131
+ throw new Error('emitLoopTick: round must be a non-negative integer');
132
+ }
133
+ if (typeof cadence !== 'string' || cadence.length === 0) {
134
+ throw new Error('emitLoopTick: cadence must be a non-empty string');
135
+ }
136
+ if (!VALID_STATUSES.has(status)) {
137
+ throw new Error(
138
+ `emitLoopTick: status "${status}" must be one of: ${[...VALID_STATUSES].join(', ')}`,
139
+ );
140
+ }
141
+
142
+ const hasEpicId = epicId !== undefined;
143
+ const hasLedgerPath = ledgerPathOverride !== undefined;
144
+ if (hasEpicId === hasLedgerPath) {
145
+ throw new Error('emitLoopTick: supply exactly one of epicId or ledgerPath');
146
+ }
147
+
148
+ let ledgerPath;
149
+ if (hasLedgerPath) {
150
+ if (
151
+ typeof ledgerPathOverride !== 'string' ||
152
+ ledgerPathOverride.length === 0
153
+ ) {
154
+ throw new Error('emitLoopTick: ledgerPath must be a non-empty string');
155
+ }
156
+ ledgerPath = ledgerPathOverride;
157
+ } else {
158
+ if (!Number.isInteger(epicId) || epicId < 1) {
159
+ throw new Error('emitLoopTick: epicId must be a positive integer');
160
+ }
161
+ ledgerPath = epicLedgerPath(epicId, config);
162
+ }
163
+
164
+ const payload = {
165
+ event: 'loop.tick',
166
+ loopName,
167
+ round,
168
+ cadence,
169
+ status,
170
+ timestamp,
171
+ };
172
+
173
+ // Route through the bus so the payload is schema-validated and the
174
+ // LedgerWriter's privileged onEmitted hook persists the record — the
175
+ // same path every lifecycle event flows through.
176
+ const { tempRoot, epicId: ledgerEpicId } = decomposeLedgerPath(ledgerPath);
177
+ const bus = createBus({ schemaDir: SCHEMA_DIR });
178
+ const writer = createLedgerWriter({ epicId: ledgerEpicId, tempRoot });
179
+ writer.register(bus);
180
+
181
+ const { seqId } = await bus.emit('loop.tick', payload);
182
+ return { ledgerPath: writer.ledgerPath, payload, seqId };
183
+ }
@@ -23,6 +23,15 @@ import {
23
23
  /**
24
24
  * Parse the `Epic: #N` and `parent: #N` references from a Story body.
25
25
  *
26
+ * Story #4300 (defense-in-depth): under the 2-tier hierarchy
27
+ * (Epic → Story) a Story's `parent: #N` marker always IS the parent
28
+ * Epic, so when the `Epic: #N` line is missing — e.g. a Story body
29
+ * refreshed by the reconciler's UPDATE op before Story #4300's
30
+ * single-sourced footer rendering landed — `epicId` falls back to the
31
+ * resolved `parentId` rather than reporting `null` and aborting
32
+ * delivery. A body that carries neither marker still resolves
33
+ * `epicId: null` (no parent to fall back to).
34
+ *
26
35
  * @param {string} body Raw Story body Markdown.
27
36
  * @returns {{ epicId: number|null, parentId: number|null }}
28
37
  */
@@ -30,10 +39,9 @@ export function resolveStoryHierarchy(body) {
30
39
  const source = body ?? '';
31
40
  const epicMatch = source.match(/(?:^\s*epic:\s*#(\d+))/im);
32
41
  const parentMatch = source.match(/(?:^\s*parent:\s*#(\d+))/im);
33
- return {
34
- epicId: epicMatch ? Number.parseInt(epicMatch[1], 10) : null,
35
- parentId: parentMatch ? Number.parseInt(parentMatch[1], 10) : null,
36
- };
42
+ const parentId = parentMatch ? Number.parseInt(parentMatch[1], 10) : null;
43
+ const epicId = epicMatch ? Number.parseInt(epicMatch[1], 10) : parentId;
44
+ return { epicId, parentId };
37
45
  }
38
46
 
39
47
  /**
@@ -36,8 +36,9 @@ import {
36
36
  export function renderDecomposerSystemPrompt({
37
37
  maxTickets = LIMITS_DEFAULTS.maxTickets,
38
38
  maxTokenBudget = LIMITS_DEFAULTS.maxTokenBudget,
39
+ epicId = null,
39
40
  } = {}) {
40
- return render2TierPrompt({ maxTickets, maxTokenBudget });
41
+ return render2TierPrompt({ maxTickets, maxTokenBudget, epicId });
41
42
  }
42
43
 
43
44
  /**
@@ -46,7 +47,7 @@ export function renderDecomposerSystemPrompt({
46
47
  * on the Story body so the executing agent has everything it needs in one
47
48
  * ticket. Thematic grouping lives as prose in the Epic body / Tech Spec.
48
49
  */
49
- function render2TierPrompt({ maxTickets, maxTokenBudget }) {
50
+ function render2TierPrompt({ maxTickets, maxTokenBudget, epicId = null }) {
50
51
  // Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
51
52
  // (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
52
53
  const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
@@ -66,6 +67,13 @@ function render2TierPrompt({ maxTickets, maxTokenBudget }) {
66
67
  advisoryCaveat,
67
68
  newFileContract,
68
69
  } = AUTHORING_ALTITUDE_GUIDANCE;
70
+ // The namespaced AC-tag token the wave-0 BDD scaffold section below must
71
+ // require on every scaffolded scenario (Story #4301). When the Epic ID is
72
+ // known at render time, interpolate the concrete tag so the author has no
73
+ // placeholder to get wrong; otherwise fall back to the documented pattern.
74
+ const acTagExample = Number.isInteger(epicId)
75
+ ? `@epic-${epicId}-ac-1`
76
+ : '@epic-<id>-ac-N';
69
77
  return `You are an expert Senior Project Manager and Orchestrator.
70
78
  Your job is to take a Product Requirements Document (PRD) and a Technical Specification and decompose them into a flat list of Story tickets for an AI Agent to execute.
71
79
 
@@ -96,7 +104,7 @@ You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown
96
104
  }
97
105
  ]
98
106
 
99
- **Slug format**: \`^[a-z0-9][a-z0-9-]*\$\` — hyphen-case only. Underscores are rejected by the validator.
107
+ **Slug format**: \`^[a-z0-9][a-z0-9-]*$\` — hyphen-case only. Underscores are rejected by the validator.
100
108
 
101
109
  ### STORY BODY SCHEMA (REQUIRED FOR EVERY STORY):
102
110
  \`body\` MUST be a **string** — the serialized markdown produced by \`serialize()\` from \`lib/story-body/story-body.js\`. Do NOT emit \`body\` as a JSON object: an object body throws \`StoryBodyParseError\` in the reconciler (Story #3302) and is discarded by the GitHub provider, producing an empty issue body. Stories are consumed by non-interactive sub-agents that must self-verify from the Story ticket alone — so the ticket must carry everything an agent needs to execute and self-verify.
@@ -196,8 +204,9 @@ When the Acceptance Spec contains **one or more \`Disposition: new\` rows**, you
196
204
  - **goal**: contains the literal token \`bdd-scaffold\` (e.g. "bdd-scaffold: create the @skip-tagged feature files the implementation Stories verify against").
197
205
  - **depends_on**: EMPTY (\`[]\`) — it runs first, in wave 0.
198
206
  - **changes**: one entry per distinct \`.feature\` file named in a \`new\` row, each \`{ "path": "<feature file path>", "assumption": "creates" }\`.
199
- - **acceptance**: MUST assert (a) every new \`.feature\` file exists, and (b) every new scenario within them carries an \`@skip\` tag. Keep these observable (a grep/validate command exits 0, a file exists at a path).
200
- - **verify**: a grep/validate command (tier \`validate\`), NOT an e2e runner verifying that a file exists with a tag needs no browser/playwright run. Example: \`grep -rL '@skip' tests/features/<area>/*.feature (validate)\` paired with an existence check.
207
+ - **acceptance**: MUST assert (a) every new \`.feature\` file exists, (b) every new scenario within them carries an \`@skip\` tag, AND (c) every new scenario also carries its **namespaced per-Epic AC tag** \`${acTagExample}\` (one tag per AC ID the scenario satisfies — see below). Keep these observable (a grep/validate command exits 0, a file exists at a path).
208
+ - **Namespaced AC tag is REQUIRED at scaffold time, not only at de-skip time.** Phase 7 finalize's \`acceptance-spec-reconciler.js\` matches AC IDs only against \`@epic-<id>-ac-*\` / \`@pending\` tags in \`tests/features/**\` a bare \`@ac-N\` tag is deliberately ignored to prevent cross-Epic collision. A scaffolded scenario that carries \`@skip\` but omits \`@epic-<id>-ac-N\` reads as \`missing[]\` at finalize and aborts the close even after the implementation Story de-skips it, because the tag was never added. Tag each scenario with both \`@skip\` AND \`${acTagExample}\` (substituting the AC's own number) in this SAME wave-0 pass — do not defer the AC tag to the later de-skip edit.
209
+ - **verify**: a grep/validate command (tier \`validate\`), NOT an e2e runner — verifying that a file exists with the required tags needs no browser/playwright run. Example: \`grep -rL '@skip' tests/features/<area>/*.feature (validate)\` paired with an existence check, AND a check that every new AC ID's namespaced tag (\`${acTagExample}\`) appears in the scaffolded files, e.g. \`grep -q '${acTagExample}' tests/features/<area>/<file>.feature (validate)\` for each new AC row.
201
210
  - Each implementation Story whose \`verify[]\` references one of these scaffolded \`.feature\` paths MUST \`depends_on\` the scaffold Story (so the scaffold lands in an earlier wave). Omitting the link trips the soft \`missing-bdd-scaffold\` validator finding.
202
211
 
203
212
  When the Acceptance Spec contains **zero \`new\`-disposition rows** (every row is \`updated\` or \`unchanged\`), do NOT emit a scaffold Story — there is nothing to create.
@@ -13,8 +13,8 @@
13
13
  * @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
14
14
  */
15
15
 
16
+ import { withTransientRetry } from './errors.js';
16
17
  import { parseApiJson } from './request-helpers.js';
17
- import { withTransientRetry } from './transient-retry.js';
18
18
 
19
19
  /**
20
20
  * Detect a 404 across both error surfaces: