mandrel 2.52.0 → 2.54.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/agents/story-worker.md +4 -5
- package/.agents/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -0
- package/.agents/scripts/check-audit-attribution.js +245 -0
- package/.agents/scripts/check-pinned-override-notes.js +102 -0
- package/.agents/scripts/coverage-capture.js +36 -21
- package/.agents/scripts/lib/audit-attribution.js +112 -0
- package/.agents/scripts/lib/close-validation/commands.js +27 -1
- package/.agents/scripts/lib/close-validation/gates.js +33 -14
- package/.agents/scripts/lib/config/commands.js +14 -12
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/config-settings-schema.js +9 -2
- package/.agents/scripts/lib/coverage-capture.js +70 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +27 -0
- package/.agents/scripts/lib/orchestration/epic-rollup.js +23 -4
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +56 -6
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +40 -6
- package/.agents/scripts/lib/pinned-override-notes.js +100 -0
- package/.agents/scripts/resolve-stories.js +9 -3
- package/.agents/workflows/helpers/deliver-digest.md +4 -6
- package/.agents/workflows/helpers/deliver-reference.md +5 -2
- package/.agents/workflows/helpers/plan-reference.md +1 -1
- package/docs/CHANGELOG.md +20 -0
- package/package.json +5 -5
|
@@ -87,6 +87,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
87
87
|
'bootstrap.js',
|
|
88
88
|
'check-action-pinning.js',
|
|
89
89
|
'check-arch-cycles.js',
|
|
90
|
+
'check-audit-attribution.js',
|
|
90
91
|
'check-baseline-drift.js',
|
|
91
92
|
'check-baseline-scope.js',
|
|
92
93
|
'check-baselines.js',
|
|
@@ -98,6 +99,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
98
99
|
'check-gherkin-corpus.js',
|
|
99
100
|
'check-knip-entries.js',
|
|
100
101
|
'check-lifecycle-lint.js',
|
|
102
|
+
'check-pinned-override-notes.js',
|
|
101
103
|
'check-schema-references.js',
|
|
102
104
|
'check-test-temp-hygiene.js',
|
|
103
105
|
'check-windows-git-perf.js',
|
|
@@ -110,6 +110,33 @@ export function isEpicTicket(issue) {
|
|
|
110
110
|
return normalizeLabels(issue?.labels).includes(TYPE_LABELS.EPIC);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the GraphQL node id an Epic's native sub-issue read addresses it by.
|
|
115
|
+
*
|
|
116
|
+
* Both casings are accepted because the field name depends on which provider
|
|
117
|
+
* method produced the object, and neither caller can tell from the value it
|
|
118
|
+
* holds: `getTicket` (and every other single-issue read) runs through
|
|
119
|
+
* `issueToTicket`, which renames `node_id` to `nodeId`, while
|
|
120
|
+
* `listIssuesByLabel` returns the REST payload **verbatim** — six consumers
|
|
121
|
+
* read its raw shape, so mapping it there would be a far wider change than
|
|
122
|
+
* the two reads that actually need the id.
|
|
123
|
+
*
|
|
124
|
+
* Returns `null` when neither name carries one. That is the load-bearing
|
|
125
|
+
* half: an absent id reaches GraphQL as `$id: ID!` = `undefined`, which the
|
|
126
|
+
* API rejects and `classifyGithubError` calls `permanent` — so the gateway
|
|
127
|
+
* rethrows with no retry and no feature-disabled fallback, and the caller
|
|
128
|
+
* degrades to the body checklist while reporting a hard API failure it never
|
|
129
|
+
* really had. Callers skip the read on a `null` instead, the same clean
|
|
130
|
+
* no-op `providers/github/board-add.js` makes with `reason: 'no-node-id'`.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ nodeId?: unknown, node_id?: unknown }} epic
|
|
133
|
+
* @returns {string|null}
|
|
134
|
+
*/
|
|
135
|
+
export function resolveEpicNodeId(epic) {
|
|
136
|
+
const nodeId = epic?.nodeId ?? epic?.node_id;
|
|
137
|
+
return typeof nodeId === 'string' && nodeId !== '' ? nodeId : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
113
140
|
/**
|
|
114
141
|
* Render a container Epic's body.
|
|
115
142
|
*
|
|
@@ -40,12 +40,17 @@
|
|
|
40
40
|
* @module lib/orchestration/epic-rollup
|
|
41
41
|
* @see Story #5205
|
|
42
42
|
* @see Story #5210 — fail closed on a degraded child read.
|
|
43
|
+
* @see Story #5255 — a closed child contributes no `agent::*` state.
|
|
43
44
|
*/
|
|
44
45
|
|
|
45
46
|
import { Logger } from '../Logger.js';
|
|
46
47
|
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
47
48
|
import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
|
|
48
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
isEpicTicket,
|
|
51
|
+
readEpicChildIdsFrom,
|
|
52
|
+
resolveEpicNodeId,
|
|
53
|
+
} from './epic-container.js';
|
|
49
54
|
import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
|
|
50
55
|
import { deriveParentState } from './ticketing/bulk.js';
|
|
51
56
|
|
|
@@ -54,6 +59,13 @@ import { deriveParentState } from './ticketing/bulk.js';
|
|
|
54
59
|
* Epic carries an owner. `agent::blocked` counts: a blocked child is still
|
|
55
60
|
* this operator's problem, and dropping the assignee at the moment someone
|
|
56
61
|
* needs to be found would invert the signal.
|
|
62
|
+
*
|
|
63
|
+
* "Blocked" here means an **open** blocked child. `deriveParentState` stopped
|
|
64
|
+
* reading closed children's `agent::*` labels in Story #5255 — a superseded
|
|
65
|
+
* Story closed while still wearing `agent::blocked` is not someone's problem
|
|
66
|
+
* to pick up, and the stale label used to derive `agent::blocked` forever,
|
|
67
|
+
* which both held an owner on the container and pinned it open past the
|
|
68
|
+
* `derived !== DONE` bail below.
|
|
57
69
|
*/
|
|
58
70
|
const IN_FLIGHT_STATES = new Set([
|
|
59
71
|
AGENT_LABELS.EXECUTING,
|
|
@@ -68,15 +80,22 @@ const IN_FLIGHT_STATES = new Set([
|
|
|
68
80
|
* borrow four lines would invert the dependency direction for no gain. What
|
|
69
81
|
* matters is that the *reader* handed to `readEpicChildIdsFrom` behaves the
|
|
70
82
|
* same on both paths, which is what keeps an Epic from being expandable but
|
|
71
|
-
* unclosable.
|
|
83
|
+
* unclosable. The shared `resolveEpicNodeId` is what makes "the same" true of
|
|
84
|
+
* the id itself: the Epics reaching this reader come from
|
|
85
|
+
* `listIssuesByLabel`, whose raw REST payload carries `node_id`, while the
|
|
86
|
+
* expansion path's come from `getTicket`, whose mapped ticket carries
|
|
87
|
+
* `nodeId`.
|
|
72
88
|
*
|
|
73
89
|
* @param {object} provider
|
|
74
90
|
* @returns {(epic: object) => Promise<number[]>}
|
|
75
91
|
*/
|
|
76
92
|
function nativeChildReader(provider) {
|
|
77
93
|
return async (epic) => {
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
const nodeId = resolveEpicNodeId(epic);
|
|
95
|
+
if (nodeId === null) return [];
|
|
96
|
+
return (
|
|
97
|
+
provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
|
|
98
|
+
);
|
|
80
99
|
};
|
|
81
100
|
}
|
|
82
101
|
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
* partial failure reports which tickets were and were not closed so the
|
|
29
29
|
* operator can finish by hand.
|
|
30
30
|
*
|
|
31
|
+
* The close also strips the source ticket's `agent::*` label (Story #5255).
|
|
32
|
+
* A retired ticket has no agent state, and the one it kept was read as live
|
|
33
|
+
* work: `agent::blocked` is the state a Story must be in to be re-planned, so
|
|
34
|
+
* this path closes exactly the tickets carrying it, and a closed-but-blocked
|
|
35
|
+
* child pinned its container Epic open on every rollup thereafter. The
|
|
36
|
+
* derivation in `ticketing/bulk.js` now ignores closed children's labels too —
|
|
37
|
+
* that half covers the tickets already closed and the ones closed by hand;
|
|
38
|
+
* this one stops new ones being written.
|
|
39
|
+
*
|
|
31
40
|
* Idempotency is keyed off the `superseded-by` structured-comment marker
|
|
32
41
|
* (`upsertStructuredComment`), not a bare `postComment`, so a re-run cannot
|
|
33
42
|
* double-comment.
|
|
@@ -36,6 +45,7 @@
|
|
|
36
45
|
*/
|
|
37
46
|
|
|
38
47
|
import { Logger } from '../../Logger.js';
|
|
48
|
+
import { AGENT_LABELS } from '../../label-constants.js';
|
|
39
49
|
import {
|
|
40
50
|
concurrentMap,
|
|
41
51
|
FANOUT_CONCURRENCY,
|
|
@@ -55,6 +65,40 @@ const SUPERSEDED_BY_COMMENT_TYPE = 'superseded-by';
|
|
|
55
65
|
*/
|
|
56
66
|
export const SUPERSEDE_CLOSE_REASON = 'not_planned';
|
|
57
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Every `agent::*` label, as the set the supersede close strips.
|
|
70
|
+
*
|
|
71
|
+
* A retired ticket has no agent state. `agent::done` would be the wrong
|
|
72
|
+
* substitute — the work was re-planned, never delivered — so the label is
|
|
73
|
+
* removed rather than rewritten, and the ticket ends carrying only its
|
|
74
|
+
* `type::`/domain labels and the supersede comment that explains it.
|
|
75
|
+
*/
|
|
76
|
+
const AGENT_STATE_LABELS = Object.freeze(Object.values(AGENT_LABELS));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The single `updateTicket` mutation that retires a source ticket.
|
|
80
|
+
*
|
|
81
|
+
* Closing and clearing the state ride one write: two calls could leave the
|
|
82
|
+
* ticket closed but still wearing `agent::blocked`, which is the shape that
|
|
83
|
+
* pinned a container Epic open forever (Story #5255).
|
|
84
|
+
*
|
|
85
|
+
* A ticket with no `agent::*` label gets the bare close, unchanged from before
|
|
86
|
+
* that Story — `updateTicket` merges a `labels` mutation by reading the issue
|
|
87
|
+
* back, so an unconditional empty `remove` would buy a wasted round-trip per
|
|
88
|
+
* superseded ticket. `_ticketSnapshot` feeds that merge the copy
|
|
89
|
+
* `probeSourceTicket` already fetched.
|
|
90
|
+
*
|
|
91
|
+
* @param {{ labels?: unknown }} ticket The probe's fresh copy.
|
|
92
|
+
* @returns {object} Mutations for `provider.updateTicket`.
|
|
93
|
+
*/
|
|
94
|
+
function supersedeCloseMutations(ticket) {
|
|
95
|
+
const close = { state: 'closed', state_reason: SUPERSEDE_CLOSE_REASON };
|
|
96
|
+
const labels = Array.isArray(ticket?.labels) ? ticket.labels : [];
|
|
97
|
+
const remove = AGENT_STATE_LABELS.filter((label) => labels.includes(label));
|
|
98
|
+
if (remove.length === 0) return close;
|
|
99
|
+
return { ...close, labels: { remove }, _ticketSnapshot: ticket };
|
|
100
|
+
}
|
|
101
|
+
|
|
58
102
|
/**
|
|
59
103
|
* Coerce one `supersedes[]` entry into `{ id, note }`.
|
|
60
104
|
*
|
|
@@ -329,13 +373,22 @@ export function buildSupersedeCommentBody({
|
|
|
329
373
|
/**
|
|
330
374
|
* Resolve the live state of a source ticket.
|
|
331
375
|
*
|
|
332
|
-
*
|
|
376
|
+
* The ticket itself rides along so the close can strip the `agent::*` label
|
|
377
|
+
* without a second read: `updateTicket`'s label merge takes a
|
|
378
|
+
* `_ticketSnapshot` for exactly this, and this probe has already paid for the
|
|
379
|
+
* fresh copy.
|
|
380
|
+
*
|
|
381
|
+
* @returns {Promise<{ ok: true, state: string, ticket: object } | { ok: false, reason: string }>}
|
|
333
382
|
*/
|
|
334
383
|
async function probeSourceTicket(provider, id) {
|
|
335
384
|
try {
|
|
336
385
|
const ticket = await provider.getTicket(id, { fresh: true });
|
|
337
386
|
if (!ticket) return { ok: false, reason: 'not-found' };
|
|
338
|
-
return {
|
|
387
|
+
return {
|
|
388
|
+
ok: true,
|
|
389
|
+
state: String(ticket.state ?? 'open').toLowerCase(),
|
|
390
|
+
ticket,
|
|
391
|
+
};
|
|
339
392
|
} catch (err) {
|
|
340
393
|
return { ok: false, reason: `inaccessible: ${err.message}` };
|
|
341
394
|
}
|
|
@@ -370,10 +423,7 @@ async function closeOneSupersededTicket({
|
|
|
370
423
|
sourceTicketIds,
|
|
371
424
|
}),
|
|
372
425
|
);
|
|
373
|
-
await provider.updateTicket(id,
|
|
374
|
-
state: 'closed',
|
|
375
|
-
state_reason: SUPERSEDE_CLOSE_REASON,
|
|
376
|
-
});
|
|
426
|
+
await provider.updateTicket(id, supersedeCloseMutations(probe.ticket));
|
|
377
427
|
return { outcome: 'closed' };
|
|
378
428
|
} catch (err) {
|
|
379
429
|
return { outcome: 'failed', reason: err.message };
|
|
@@ -401,25 +401,59 @@ async function cascadeCompletion(provider, ticketId, opts = {}) {
|
|
|
401
401
|
* Derive the parent `agent::*` state from the composition of its children.
|
|
402
402
|
*
|
|
403
403
|
* Rules (Story #2676):
|
|
404
|
-
* - Any child carrying `agent::blocked` → parent should be
|
|
404
|
+
* - Any **open** child carrying `agent::blocked` → parent should be
|
|
405
|
+
* `agent::blocked`.
|
|
405
406
|
* - Otherwise, every child is `agent::done` (or closed) → parent should be
|
|
406
407
|
* `agent::done`.
|
|
407
|
-
* - Otherwise, any child carrying `agent::executing` or
|
|
408
|
-
* parent should be `agent::executing`.
|
|
408
|
+
* - Otherwise, any **open** child carrying `agent::executing` or
|
|
409
|
+
* `agent::closing` → parent should be `agent::executing`.
|
|
409
410
|
* - Otherwise (e.g. all children still `agent::ready`) → return `null` to
|
|
410
411
|
* signal "leave the parent unchanged". A parent already partway through
|
|
411
412
|
* the lifecycle MUST NOT be downgraded just because one child reverted.
|
|
412
413
|
*
|
|
414
|
+
* **A closed child contributes no `agent::*` state** (Story #5255). Its label
|
|
415
|
+
* records the state it was in when it stopped, not outstanding work, and
|
|
416
|
+
* nothing clears it on the way out: a Story re-planned out of `agent::blocked`
|
|
417
|
+
* is closed as superseded still wearing that label, and the blocked rule then
|
|
418
|
+
* pinned its container Epic open forever — `epic-rollup.js` bails before its
|
|
419
|
+
* close path on any derived state other than `agent::done`, so the Epic
|
|
420
|
+
* reported `pending` every run with every child long since closed. Filtering
|
|
421
|
+
* here rather than at that one call site is what also covers a child closed by
|
|
422
|
+
* hand with a stale state label attached. The all-done branch already counted
|
|
423
|
+
* `state === 'closed'` as done, so a closed child keeps exactly that meaning
|
|
424
|
+
* and loses only its vote on the other two.
|
|
425
|
+
*
|
|
413
426
|
* The function is pure and exported so the rule can be exercised in
|
|
414
427
|
* isolation by unit tests without dragging the cascade I/O surface in.
|
|
415
428
|
*
|
|
416
429
|
* @param {Array<{ labels?: string[], state?: string }>} siblings
|
|
417
430
|
* @returns {string|null} A `STATE_LABELS.*` value, or `null` for no-op.
|
|
418
431
|
*/
|
|
432
|
+
/**
|
|
433
|
+
* The labels that still describe **live** work on this child.
|
|
434
|
+
*
|
|
435
|
+
* Empty for a closed child: its `agent::*` label records the state it stopped
|
|
436
|
+
* in, not outstanding work, and the two live-state rules in
|
|
437
|
+
* {@link deriveParentState} must not read it. The all-done rule reads the
|
|
438
|
+
* child's labels directly, so a closed child keeps counting as done.
|
|
439
|
+
*
|
|
440
|
+
* Module-level rather than another local arrow inside `deriveParentState`:
|
|
441
|
+
* the CRAP baseline keys anonymous functions positionally within their
|
|
442
|
+
* enclosing scope, so adding or removing one there renumbers every later
|
|
443
|
+
* arrow and reports the shift as drift on code that did not change.
|
|
444
|
+
*
|
|
445
|
+
* @param {{ labels?: string[], state?: string }} sibling
|
|
446
|
+
* @returns {string[]}
|
|
447
|
+
*/
|
|
448
|
+
function liveChildLabels(sibling) {
|
|
449
|
+
if (sibling?.state === 'closed') return [];
|
|
450
|
+
return Array.isArray(sibling?.labels) ? sibling.labels : [];
|
|
451
|
+
}
|
|
452
|
+
|
|
419
453
|
export function deriveParentState(siblings) {
|
|
420
454
|
if (!Array.isArray(siblings) || siblings.length === 0) return null;
|
|
421
455
|
const labelsOf = (s) => (Array.isArray(s?.labels) ? s.labels : []);
|
|
422
|
-
if (siblings.some((s) =>
|
|
456
|
+
if (siblings.some((s) => liveChildLabels(s).includes(STATE_LABELS.BLOCKED))) {
|
|
423
457
|
return STATE_LABELS.BLOCKED;
|
|
424
458
|
}
|
|
425
459
|
const allDone = siblings.every(
|
|
@@ -428,8 +462,8 @@ export function deriveParentState(siblings) {
|
|
|
428
462
|
if (allDone) return STATE_LABELS.DONE;
|
|
429
463
|
const anyActive = siblings.some(
|
|
430
464
|
(s) =>
|
|
431
|
-
|
|
432
|
-
|
|
465
|
+
liveChildLabels(s).includes(STATE_LABELS.EXECUTING) ||
|
|
466
|
+
liveChildLabels(s).includes(STATE_LABELS.CLOSING),
|
|
433
467
|
);
|
|
434
468
|
if (anyActive) return STATE_LABELS.EXECUTING;
|
|
435
469
|
return null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pinned-override-notes.js — keep a load-bearing dependency note honest.
|
|
3
|
+
*
|
|
4
|
+
* `package.json` carries a `"//"` block of prose notes keyed by dotted config
|
|
5
|
+
* path. Two of them are safety notes about a pinned `overrides` entry: they
|
|
6
|
+
* record WHY the pin exists, which advisories removing it reintroduces, and
|
|
7
|
+
* that the `overrides` range and the direct `dependencies` range MUST move in
|
|
8
|
+
* lockstep because npm has no way to reference one from the other.
|
|
9
|
+
*
|
|
10
|
+
* A note like that is consulted precisely when someone is deciding whether a
|
|
11
|
+
* bump is safe — so a stale version inside it hands out wrong premises about
|
|
12
|
+
* a coupling the note itself calls a trap. Nothing enforced either claim, and
|
|
13
|
+
* both had drifted: the note described `^4.2.0` while the pin had moved twice.
|
|
14
|
+
*
|
|
15
|
+
* This module derives the checks from the `"//"` keys themselves rather than
|
|
16
|
+
* naming any package, so a second pinned override gets the same guarantee by
|
|
17
|
+
* writing its note.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A `"//"` key that documents a pinned override, e.g. `overrides.js-yaml`. */
|
|
21
|
+
const OVERRIDE_NOTE_KEY = /^overrides\.(.+)$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract every semver range that appears literally in a note's prose.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately permissive about the surrounding words — a note is prose, and
|
|
27
|
+
* pinning its phrasing would make it unwritable. What matters is only that
|
|
28
|
+
* the range it quotes is the range in force.
|
|
29
|
+
*
|
|
30
|
+
* Not exported: it is an implementation detail of the audit below, and its
|
|
31
|
+
* behaviour is observable through that — a note quoting only bare versions
|
|
32
|
+
* yields no `stale-note`, a note quoting a mismatched range yields one.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
function quotedRanges(text) {
|
|
38
|
+
if (typeof text !== 'string') return [];
|
|
39
|
+
return [...text.matchAll(/[\^~]\d+\.\d+\.\d+/g)].map((m) => m[0]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Audit one package document's pinned-override notes.
|
|
44
|
+
*
|
|
45
|
+
* Two findings per documented override, each naming the drift rather than
|
|
46
|
+
* just asserting a mismatch:
|
|
47
|
+
* - `lockstep` — `overrides.<name>` and `dependencies.<name>` disagree. The
|
|
48
|
+
* companion note declares they must not; a split silently gives the
|
|
49
|
+
* direct and transitive resolutions different floors.
|
|
50
|
+
* - `stale-note` — the note quotes at least one range but not the one in
|
|
51
|
+
* force, so its stated version is behind the pin it describes. A note
|
|
52
|
+
* quoting no range at all is not scored: prose that names no version
|
|
53
|
+
* cannot go stale.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ '//'?: Record<string,string>, overrides?: Record<string,string>, dependencies?: Record<string,string> }} pkg
|
|
56
|
+
* @returns {{ findings: Array<{ kind: string, name: string, detail: string }>, checked: string[] }}
|
|
57
|
+
*/
|
|
58
|
+
export function auditPinnedOverrideNotes(pkg) {
|
|
59
|
+
const notes = pkg?.['//'] ?? {};
|
|
60
|
+
const overrides = pkg?.overrides ?? {};
|
|
61
|
+
const dependencies = pkg?.dependencies ?? {};
|
|
62
|
+
const findings = [];
|
|
63
|
+
const checked = [];
|
|
64
|
+
|
|
65
|
+
for (const [key, text] of Object.entries(notes)) {
|
|
66
|
+
const match = OVERRIDE_NOTE_KEY.exec(key);
|
|
67
|
+
if (!match) continue;
|
|
68
|
+
const name = match[1];
|
|
69
|
+
const pinned = overrides[name];
|
|
70
|
+
if (typeof pinned !== 'string') {
|
|
71
|
+
findings.push({
|
|
72
|
+
kind: 'orphan-note',
|
|
73
|
+
name,
|
|
74
|
+
detail: `"//"["${key}"] documents an override that no longer exists in the overrides block. Delete the note or restore the pin — a safety note for a pin nobody has is read as though the pin were still there.`,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
checked.push(name);
|
|
79
|
+
|
|
80
|
+
const direct = dependencies[name];
|
|
81
|
+
if (typeof direct === 'string' && direct !== pinned) {
|
|
82
|
+
findings.push({
|
|
83
|
+
kind: 'lockstep',
|
|
84
|
+
name,
|
|
85
|
+
detail: `overrides.${name} is "${pinned}" but dependencies.${name} is "${direct}". The "//" note declares these move in lockstep; a split gives the direct and transitive resolutions different floors.`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const quoted = quotedRanges(text);
|
|
90
|
+
if (quoted.length > 0 && !quoted.includes(pinned)) {
|
|
91
|
+
findings.push({
|
|
92
|
+
kind: 'stale-note',
|
|
93
|
+
name,
|
|
94
|
+
detail: `"//"["${key}"] quotes ${quoted.map((q) => `"${q}"`).join(', ')} but the pin in force is "${pinned}". The note is what tells the next author whether a bump is safe, so it must state the version it is describing.`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { findings, checked };
|
|
100
|
+
}
|
|
@@ -39,6 +39,7 @@ import { parseArgs } from 'node:util';
|
|
|
39
39
|
import { runAsCli } from './lib/cli-utils.js';
|
|
40
40
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
41
41
|
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
42
|
+
import { resolveEpicNodeId } from './lib/orchestration/epic-container.js';
|
|
42
43
|
import { expandEpicIds } from './lib/orchestration/epic-expansion.js';
|
|
43
44
|
import {
|
|
44
45
|
buildStoriesEnvelope,
|
|
@@ -98,15 +99,20 @@ export function resolveStoriesProvider({
|
|
|
98
99
|
* Injected into `expandEpicIds` so the lib layer stays provider-agnostic,
|
|
99
100
|
* exactly as `paginate` is injected into `readNativeBlockedBy`. A provider
|
|
100
101
|
* without the GraphQL surface yields `[]`, and the Epic body's checklist
|
|
101
|
-
* carries the children on its own
|
|
102
|
+
* carries the children on its own — as does an Epic carrying no resolvable
|
|
103
|
+
* node id, which `resolveEpicNodeId` reports rather than letting an
|
|
104
|
+
* `undefined` reach the API as a rejected `ID!` variable.
|
|
102
105
|
*
|
|
103
106
|
* @param {object} provider
|
|
104
107
|
* @returns {(epic: object) => Promise<number[]>}
|
|
105
108
|
*/
|
|
106
109
|
export function nativeChildReader(provider) {
|
|
107
110
|
return async (epic) => {
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
const nodeId = resolveEpicNodeId(epic);
|
|
112
|
+
if (nodeId === null) return [];
|
|
113
|
+
return (
|
|
114
|
+
provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
|
|
115
|
+
);
|
|
110
116
|
};
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
description: >-
|
|
3
3
|
The deliver path's one bundled framework read: dispatch decision, engine
|
|
4
4
|
invariants, the change-set/ceremony incantation, the acceptance-eval gate,
|
|
5
|
-
the credited full-suite run, and the terminal envelope contract
|
|
6
|
-
engine reads one file, not the helper/schema set, each session.
|
|
5
|
+
the credited full-suite run, and the terminal envelope contract.
|
|
7
6
|
---
|
|
8
7
|
|
|
9
8
|
# Deliver digest (read once per session)
|
|
@@ -123,14 +122,13 @@ node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
|
|
|
123
122
|
--scope-id <storyId> --gate test --worktree <workCwd> -- npm test
|
|
124
123
|
```
|
|
125
124
|
|
|
126
|
-
Dispatch it in the **background**: it outruns the host's
|
|
127
|
-
ceiling, and its completion re-invokes you. Never spawn a task to poll or
|
|
125
|
+
Dispatch it in the **background**: it outruns the host's sync Bash ceiling, and its completion re-invokes you. Never spawn a task to poll or
|
|
128
126
|
`sleep`-loop against it ([`parallel-tooling.md`](parallel-tooling.md)
|
|
129
127
|
Rule 2).
|
|
130
128
|
|
|
131
129
|
Read the **output**, not the exit code: capture skips — no test run, no
|
|
132
|
-
credit — when nothing changed under the CRAP `targetDirs
|
|
133
|
-
|
|
130
|
+
credit — when nothing changed under the CRAP `targetDirs`. Run the scoped
|
|
131
|
+
projects for the roots you changed plus `verify[]`, not the whole suite.
|
|
134
132
|
|
|
135
133
|
`verify[]` is scoped entries **plus** this one run: an entry that is itself a
|
|
136
134
|
full-suite command is reported credited against the same stamp, never
|
|
@@ -309,8 +309,11 @@ children at both per-Story lifecycle edges — the `agent::executing` flip in
|
|
|
309
309
|
`epicRollup` step) — which is why it holds at **N=1**, where no epilogue runs.
|
|
310
310
|
|
|
311
311
|
- **Status** follows the children's composition (`deriveParentState` mapped
|
|
312
|
-
onto the board's three options): any child executing or blocked →
|
|
313
|
-
Progress`, every child `agent::done` or closed → `Done`.
|
|
312
|
+
onto the board's three options): any **open** child executing or blocked →
|
|
313
|
+
`In Progress`, every child `agent::done` or closed → `Done`. A **closed**
|
|
314
|
+
child contributes no `agent::*` state at all — its label records where it
|
|
315
|
+
stopped, and a superseded Story closed still wearing `agent::blocked` used
|
|
316
|
+
to pin its container open forever. It is written
|
|
314
317
|
**directly**, never via a label: the container carries no `agent::*` label
|
|
315
318
|
by construction, which is what keeps it out of the bare `/mandrel-deliver`
|
|
316
319
|
ready list.
|
|
@@ -598,7 +598,7 @@ link that makes the history readable.
|
|
|
598
598
|
|
|
599
599
|
| Behaviour | Contract |
|
|
600
600
|
| --- | --- |
|
|
601
|
-
| Default | Comment + close every source ticket as `not_planned
|
|
601
|
+
| Default | Comment + close every source ticket as `not_planned`, **clearing its `agent::*` label** in the same write — a retired ticket has no agent state, and `agent::done` would claim a delivery that never happened. |
|
|
602
602
|
| `--no-close-superseded` | Skips all commenting and closing. Story creation is unchanged. Use it for a genuinely partial supersede — when the plan folded in only *part* of an issue and the remainder must stay open. |
|
|
603
603
|
| `--dry-run` | Posts no comment and closes nothing; reports what it would have done. |
|
|
604
604
|
| Re-run | Idempotent — the comment is keyed off a `superseded-by` structured-comment marker, and an already-closed source is skipped. |
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,26 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.54.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.53.0...mandrel-v2.54.0) (2026-09-09)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
* a closed child pins its container Epic open forever: the rollup reads its stale agent::* label, and the supersede close is what writes one ([#5255](https://github.com/dsj1984/mandrel/issues/5255)) ([#5256](https://github.com/dsj1984/mandrel/issues/5256)) ([672428f](https://github.com/dsj1984/mandrel/commit/672428f1e0fba1388a1a001d2840e7eb827b9373))
|
|
24
|
+
|
|
25
|
+
## [2.53.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.52.0...mandrel-v2.53.0) (2026-09-09)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
* a red SCA check says whose defect it is, and the js-yaml override's load-bearing note can no longer drift from the pin it describes ([#5248](https://github.com/dsj1984/mandrel/issues/5248)) ([#5250](https://github.com/dsj1984/mandrel/issues/5250)) ([38f4c3a](https://github.com/dsj1984/mandrel/commit/38f4c3a6acbf6e5a0bb6f9586c5a04c1f3abed02))
|
|
31
|
+
* close stops paying for whole-repo work already done: the lint gate resolves project.commands.lint, and coverage-capture announces (or refuses) an uncredited full-suite run before it spawns one ([#5244](https://github.com/dsj1984/mandrel/issues/5244)) ([#5245](https://github.com/dsj1984/mandrel/issues/5245)) ([bb4216c](https://github.com/dsj1984/mandrel/commit/bb4216cf1f443ebeb3bac6a22171fffa107d028d))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
* epic rollup's native sub-issue read passes an undefined node id, degrading every Epic's child list ([#5251](https://github.com/dsj1984/mandrel/issues/5251)) ([#5252](https://github.com/dsj1984/mandrel/issues/5252)) ([5da2df5](https://github.com/dsj1984/mandrel/commit/5da2df5322a8527c33cd4ebafa4584bdfedf867d))
|
|
37
|
+
|
|
18
38
|
## [2.52.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.51.0...mandrel-v2.52.0) (2026-09-08)
|
|
19
39
|
|
|
20
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.54.0",
|
|
4
4
|
"description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
|
|
5
5
|
"files": [
|
|
6
6
|
".agents/",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"baselines:scope": "node .agents/scripts/check-baseline-scope.js",
|
|
34
34
|
"baselines:prune": "node .agents/scripts/prune-baseline-orphans.js",
|
|
35
35
|
"lint:md": "markdownlint-cli2 \".agents/**/*.md\" \"*.md\" \"!node_modules/**\" \"!.worktrees/**\"",
|
|
36
|
-
"lint": "node .agents/scripts/run-lint.js && node .agents/scripts/check-generated-validator.js --check && npm run docs:check",
|
|
36
|
+
"lint": "node .agents/scripts/run-lint.js && node .agents/scripts/check-generated-validator.js --check && node .agents/scripts/check-pinned-override-notes.js && npm run docs:check",
|
|
37
37
|
"docs:gen": "node .agents/scripts/generate-config-docs.js && node .agents/scripts/generate-workflows-doc.js && node .agents/scripts/generate-lens-checklists.js",
|
|
38
38
|
"docs:check": "node .agents/scripts/generate-config-docs.js --check && node .agents/scripts/generate-workflows-doc.js --check && node .agents/scripts/generate-lens-checklists.js --check && node .agents/scripts/check-doc-links.js && npm run skills:check",
|
|
39
39
|
"skills:index": "node .agents/scripts/generate-skills-index.js",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"dependencies": {
|
|
111
111
|
"ajv": "^8.20.0",
|
|
112
112
|
"ajv-formats": "^3.0.1",
|
|
113
|
-
"js-yaml": "^4.3.
|
|
113
|
+
"js-yaml": "^4.3.2",
|
|
114
114
|
"minimatch": "^10.0.0",
|
|
115
115
|
"picomatch": "^4.0.4",
|
|
116
116
|
"typhonjs-escomplex": "^0.1.0"
|
|
@@ -129,11 +129,11 @@
|
|
|
129
129
|
},
|
|
130
130
|
"//": {
|
|
131
131
|
"peerDependencies.@cucumber/gherkin": "OPTIONAL peer, mirroring the `typescript` precedent, and deliberately NOT a runtime dependency. `check-gherkin-corpus.js` is the only consumer and it is opt-in behind `qa.gherkinLint`, so a consumer with no BDD tier must gain nothing from an upgrade. The devDependency alongside it is what lets this repository's own suite drive the real parser. The gate resolves it through a require path anchored at the consumer project — `.agents/` reaches a consumer by plain file copy, so a bare specifier would resolve against the consumer's module chain, which under a non-hoisting linker need not hold it.",
|
|
132
|
-
"overrides.js-yaml": "COUPLED to devDependencies.markdownlint-cli2 — do not bump either alone, and do NOT drop this override. It is load-bearing: markdownlint-cli2 0.22.x pulls js-yaml 4.1.1, which carries GHSA-52cp-r559-cp3m (high) and GHSA-h67p-54hq-rp68 (moderate); removing the override was measured to reintroduce both (1 high + 1 moderate), while with it in place `npm audit` is clean. An npm override also wins over a transitive package's own pin, so this tree-wide ^4.2
|
|
132
|
+
"overrides.js-yaml": "COUPLED to devDependencies.markdownlint-cli2 — do not bump either alone, and do NOT drop this override. It is load-bearing: markdownlint-cli2 0.22.x pulls js-yaml 4.1.1, which carries GHSA-52cp-r559-cp3m (high) and GHSA-h67p-54hq-rp68 (moderate); removing the override was measured to reintroduce both (1 high + 1 moderate), while with it in place `npm audit` is clean. An npm override also wins over a transitive package's own pin, so this tree-wide ^4.3.2 is imposed on every js-yaml consumer regardless of what they declare — and markdownlint-cli2 0.23.x declares an exact js-yaml 5.2.1. A dry-run bump confirmed the trap: markdownlint-cli2 0.23.1 resolves against js-yaml 4.3.0, two majors off what it declares, silently. The only safe move is to raise this override and bump markdownlint-cli2 in ONE reviewed commit (first confirming cosmiconfig, under @commitlint/cli, tolerates the same major). renovate.json excludes markdownlint-cli2 from devDependency auto-merge so that pair cannot drift apart unattended. The floor has since been raised twice for advisories against the pinned range itself (most recently to ^4.3.2 for GHSA-2883-xcg3-v3hh, whose fix is 4.3.2) — raising it is safe and does NOT touch the coupling above; check-pinned-override-notes.js now fails the build if this sentence and the pin disagree.",
|
|
133
133
|
"dependencies.js-yaml": "States the SAME range as overrides.js-yaml above. The two are deliberate duplicates — npm has no way to reference the direct range from the overrides block — so they MUST move in lockstep; changing one without the other silently splits the direct and transitive resolutions."
|
|
134
134
|
},
|
|
135
135
|
"overrides": {
|
|
136
|
-
"js-yaml": "^4.3.
|
|
136
|
+
"js-yaml": "^4.3.2",
|
|
137
137
|
"markdown-it": "^14.2.0"
|
|
138
138
|
}
|
|
139
139
|
}
|