mandrel 1.77.0 → 1.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -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
+ }
@@ -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:
@@ -48,6 +48,40 @@ const TRANSIENT_MESSAGES = [
48
48
 
49
49
  const PERMISSION_MESSAGES = ['unauthorized', 'forbidden', 'permission'];
50
50
 
51
+ // Network/connectivity blips that the gh-CLI path surfaces on `err.stderr`
52
+ // (Go HTTP errors, e.g. `dial tcp ...: i/o timeout`) and the direct `fetch`
53
+ // path surfaces as `TypeError: fetch failed` with the real reason on
54
+ // `err.cause` (e.g. `ETIMEDOUT`, `ENOTFOUND`). Folded in from the former
55
+ // `transient-retry.js` predicate (Story #4298) so the single canonical
56
+ // classifier retries the **union** of transient HTTP statuses/codes AND
57
+ // transient network errors. The `\b50[234]\b` alternative also catches a
58
+ // bare 502/503/504 surfaced only in an error message string (no `.status`).
59
+ const TRANSIENT_NETWORK_RE =
60
+ /i\/o timeout|dial tcp|TLS handshake timeout|connection reset|connection refused|temporary failure|could not resolve host|no such host|network is unreachable|socket hang up|fetch failed|ConnectTimeoutError|UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|\b50[234]\b/i;
61
+
62
+ /**
63
+ * True when an error looks like a retryable network/connectivity blip.
64
+ * Scans the union of fields both transport paths populate (`stderr`,
65
+ * `message`, `code`, and the nested `cause.message` / `cause.code` the
66
+ * `fetch` path uses). Module-private — folded in from the former
67
+ * `transient-retry.js` predicate (Story #4298) and consumed only by
68
+ * `classifyGithubError` below; its behavior is exercised through that public
69
+ * classifier rather than as a standalone export (keeps the dead-export gate
70
+ * green — nothing outside this module imports it).
71
+ */
72
+ function isTransientNetworkError(err) {
73
+ const hay = [
74
+ err?.stderr,
75
+ err?.message,
76
+ err?.code,
77
+ err?.cause?.message,
78
+ err?.cause?.code,
79
+ ]
80
+ .filter(Boolean)
81
+ .join(' ');
82
+ return TRANSIENT_NETWORK_RE.test(hay);
83
+ }
84
+
51
85
  function matchesAny(haystack, needles) {
52
86
  for (const n of needles) if (haystack.includes(n)) return true;
53
87
  return false;
@@ -97,19 +131,36 @@ export function classifyGithubError(err) {
97
131
  if (matchesAny(lower, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
98
132
  if (isTransientStatus(status)) return 'transient';
99
133
  if (isTransientByCodeOrMessage(code, lower)) return 'transient';
134
+ // Union with the former `transient-retry.js` predicate (Story #4298):
135
+ // retry on network/connectivity blips the status/code checks above miss
136
+ // (e.g. a `dial tcp ... i/o timeout` on `err.stderr` from the gh-CLI path,
137
+ // or `ECONNREFUSED` / `ENETUNREACH`). Checked before the permission rule so
138
+ // a transient network failure never masquerades as a permanent denial.
139
+ if (isTransientNetworkError(err)) return 'transient';
100
140
  if (isPermissionSignal(status, lower)) return 'permission';
101
141
  return 'permanent';
102
142
  }
103
143
 
104
144
  // ---------------------------------------------------------------------------
105
- // Transient-retry helper (Story #2852)
145
+ // Transient-retry helper (Story #2852; unified in Story #4298)
106
146
  // ---------------------------------------------------------------------------
107
147
  //
148
+ // The single canonical `withTransientRetry` for the GitHub provider. Story
149
+ // #4298 collapsed the former two divergent same-named implementations (this
150
+ // one + the network-only one in the deleted `transient-retry.js`) into this
151
+ // one primitive. Its default classifier (`classifyGithubError`) is the
152
+ // **union** predicate — it retries on transient HTTP statuses/codes AND on
153
+ // transient network/connectivity errors — so every former consumer of either
154
+ // module keeps (or gains) its prior retry coverage with no shim.
155
+ //
108
156
  // Mirrors the addSubIssue retry contract in `sub-issues.js` so read-path
109
157
  // callers (paginateRest, getTicket, getNativeSubIssues, …) absorb the same
110
158
  // jittered exponential backoff on transient GitHub errors instead of
111
159
  // bubbling a one-shot 502/429/ECONNRESET that kills a longer pipeline
112
- // (e.g. the /deliver Phase E retro).
160
+ // (e.g. the /deliver Phase E retro). The network consumers repointed here
161
+ // (branch-protection, labels, projects-v2-graphql) call with no opts, so
162
+ // they adopt these defaults; their retry *classes* (the network blips) are
163
+ // preserved via the unified classifier above.
113
164
 
114
165
  export const TRANSIENT_RETRY_DEFAULTS = Object.freeze({
115
166
  maxAttempts: 6,
@@ -16,7 +16,7 @@
16
16
  * @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
17
17
  */
18
18
 
19
- import { withTransientRetry } from './transient-retry.js';
19
+ import { withTransientRetry } from './errors.js';
20
20
 
21
21
  /**
22
22
  * Detect the "label already exists" signal across the surfaces `gh label
@@ -7,7 +7,7 @@
7
7
  * `unavailable:true` envelopes. Wave 3 deletes the old submodules.
8
8
  */
9
9
  import { execSync } from 'node:child_process';
10
- import { withTransientRetry } from './transient-retry.js';
10
+ import { withTransientRetry } from './errors.js';
11
11
 
12
12
  // Resolve an owner node id per-scope. Querying `user` and `organization`
13
13
  // together in one request makes GitHub return a NOT_FOUND error for whichever
@@ -78,6 +78,17 @@ const tasks = [
78
78
  cmd: 'node',
79
79
  args: ['.agents/scripts/check-arch-cycles.js'],
80
80
  },
81
+ {
82
+ // Loop-unit frontmatter gate (Story #4288, Epic #4284). Validates
83
+ // every `.agents/workflows/loops/*.md` loop unit against
84
+ // `.agents/schemas/loop-unit.schema.json`. An absent/empty loops
85
+ // directory is a clean pass; a malformed unit (e.g. a self-paced
86
+ // cadence missing its required `verify`) fails the lint gate with a
87
+ // message naming the offending file + field.
88
+ name: 'loop-units',
89
+ cmd: 'node',
90
+ args: ['.agents/scripts/check-loop-units.js'],
91
+ },
81
92
  ];
82
93
 
83
94
  function runTask({ name, cmd, args }) {