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.
- package/.agents/docs/workflows.md +19 -0
- package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
- package/.agents/schemas/loop-unit.schema.json +70 -0
- package/.agents/scripts/check-doc-links.js +24 -1
- package/.agents/scripts/check-loop-units.js +204 -0
- package/.agents/scripts/generate-workflows-doc.js +37 -4
- package/.agents/scripts/lib/close-validation/process.js +61 -5
- package/.agents/scripts/lib/close-validation/runner.js +17 -1
- 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/epic-plan-decompose/phases/context.js +7 -2
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +15 -36
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
- package/.agents/scripts/lib/story-lifecycle.js +12 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -5
- 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/run-lint.js +11 -0
- 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 +10 -7
- 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 +26 -0
- package/package.json +1 -1
- 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({
|
|
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 #
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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-]
|
|
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,
|
|
200
|
-
- **
|
|
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:
|