mandrel 2.53.0 → 2.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -42,6 +42,39 @@ const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
42
42
  /** Script names that mean "the whole suite" rather than a scoped subset. */
43
43
  const FULL_SUITE_SCRIPTS = new Set(['test', 'test:coverage']);
44
44
 
45
+ /**
46
+ * Flags that narrow a runner's work to a subset of the suite (Story #5278).
47
+ *
48
+ * A positional path is not the only way to scope a run: Node's test runner
49
+ * takes filter flags that select a fraction of the tests while the argv still
50
+ * reads as a bare `node --test`. Crediting one of those against the full-suite
51
+ * stamp reports a filtered run as the whole suite — the exact false positive
52
+ * {@link isFullSuiteCommand} exists to refuse. Matched by prefix so both
53
+ * spellings (`--test-only`, `--test-name-pattern=x`, `--test-name-pattern x`)
54
+ * are caught.
55
+ */
56
+ const NARROWING_FLAGS = Object.freeze([
57
+ '--test-name-pattern',
58
+ '--test-skip-pattern',
59
+ '--test-only',
60
+ ]);
61
+
62
+ /**
63
+ * Does any token narrow the run to a subset of the suite? Pure helper for
64
+ * {@link isFullSuiteCommand}, split out so that function's own branching stays
65
+ * inside its committed cyclomatic budget.
66
+ *
67
+ * @param {string[]} tokens
68
+ * @returns {boolean}
69
+ */
70
+ function hasNarrowingFlag(tokens) {
71
+ return tokens.some((token) =>
72
+ NARROWING_FLAGS.some(
73
+ (flag) => token === flag || token.startsWith(`${flag}=`),
74
+ ),
75
+ );
76
+ }
77
+
45
78
  /**
46
79
  * Split a Story `verify[]` line into its command and its tier tag.
47
80
  *
@@ -76,6 +109,10 @@ export function isFullSuiteCommand(command) {
76
109
  .split(/\s+/)
77
110
  .filter(Boolean);
78
111
  if (tokens.length === 0) return false;
112
+ // A narrowing flag scopes the run wherever it appears — before the package
113
+ // manager's `--` as much as after it — so the probe runs over the whole
114
+ // token list rather than per-branch below.
115
+ if (hasNarrowingFlag(tokens)) return false;
79
116
 
80
117
  if (tokens[0] === 'node') {
81
118
  // `node --test` with no path argument walks the default test globs.
@@ -20,30 +20,13 @@
20
20
  /** A `"//"` key that documents a pinned override, e.g. `overrides.js-yaml`. */
21
21
  const OVERRIDE_NOTE_KEY = /^overrides\.(.+)$/;
22
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
- }
23
+ import { resolvePin, scoreNote } from './pinned-override-resolve.js';
41
24
 
42
25
  /**
43
26
  * Audit one package document's pinned-override notes.
44
27
  *
45
- * Two findings per documented override, each naming the drift rather than
46
- * just asserting a mismatch:
28
+ * Findings per documented override, each naming the drift rather than just
29
+ * asserting a mismatch:
47
30
  * - `lockstep` — `overrides.<name>` and `dependencies.<name>` disagree. The
48
31
  * companion note declares they must not; a split silently gives the
49
32
  * direct and transitive resolutions different floors.
@@ -51,50 +34,55 @@ function quotedRanges(text) {
51
34
  * force, so its stated version is behind the pin it describes. A note
52
35
  * quoting no range at all is not scored: prose that names no version
53
36
  * cannot go stale.
37
+ * - `unsupported-shape` — the note's key resolves to something that is not a
38
+ * range (a nested override object, or a `$name` reference to a dependency
39
+ * that does not exist). Distinct from `orphan-note`: the pin is *there*,
40
+ * the note just does not name it, and telling an author to "delete the note
41
+ * or restore the pin" would be wrong advice.
54
42
  *
55
43
  * @param {{ '//'?: Record<string,string>, overrides?: Record<string,string>, dependencies?: Record<string,string> }} pkg
56
44
  * @returns {{ findings: Array<{ kind: string, name: string, detail: string }>, checked: string[] }}
57
45
  */
58
46
  export function auditPinnedOverrideNotes(pkg) {
59
- const notes = pkg?.['//'] ?? {};
60
47
  const overrides = pkg?.overrides ?? {};
61
48
  const dependencies = pkg?.dependencies ?? {};
62
49
  const findings = [];
63
50
  const checked = [];
64
51
 
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
- }
52
+ for (const { key, name, text } of documentedOverrides(pkg)) {
53
+ const resolved = resolvePin({ overrides, dependencies, name });
54
+ if (isCheckable(resolved)) checked.push(name);
55
+ findings.push(
56
+ ...scoreNote({ key, name, text, resolved, direct: dependencies[name] }),
57
+ );
97
58
  }
98
59
 
99
60
  return { findings, checked };
100
61
  }
62
+
63
+ /**
64
+ * The `"//"` entries that document an override, as `{ key, name, text }`.
65
+ * Every other note in the block — a peer-dependency rationale, say — is not
66
+ * this gate's business and is skipped rather than scored.
67
+ *
68
+ * @param {object} pkg
69
+ * @returns {Array<{ key: string, name: string, text: unknown }>}
70
+ */
71
+ function documentedOverrides(pkg) {
72
+ return Object.entries(pkg?.['//'] ?? {})
73
+ .map(([key, text]) => ({ key, text, match: OVERRIDE_NOTE_KEY.exec(key) }))
74
+ .filter((entry) => entry.match !== null)
75
+ .map(({ key, text, match }) => ({ key, text, name: match[1] }));
76
+ }
77
+
78
+ /**
79
+ * Was the note's override resolved to a range at all? Only then does the name
80
+ * belong in `checked` — the list is what tells a caller which pins this gate
81
+ * actually stands behind.
82
+ *
83
+ * @param {{ kind: string }} resolved
84
+ * @returns {boolean}
85
+ */
86
+ function isCheckable(resolved) {
87
+ return resolved.kind !== 'missing' && resolved.kind !== 'unsupported';
88
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * pinned-override-resolve.js — resolve a documented override, and score its note.
3
+ *
4
+ * The reading half of `pinned-override-notes.js` next door: how an
5
+ * `overrides` entry resolves to the range it actually imposes, and which
6
+ * findings that range earns against the note describing it. The audit loop
7
+ * stays there; everything a single note is judged by lives here.
8
+ *
9
+ * Pure: no filesystem, no npm, no network. The caller supplies the parsed
10
+ * package document.
11
+ */
12
+
13
+ /**
14
+ * npm's own reference syntax inside an `overrides` block: `"js-yaml": "$js-yaml"`
15
+ * means "whatever `dependencies.js-yaml` says". It is the sanctioned way to
16
+ * express the lockstep coupling these notes describe, so reading it as a
17
+ * literal range — which is what a bare string compare does — flagged the fix as
18
+ * the defect: `"$js-yaml" !== "^4.3.2"` produced a `lockstep` finding against a
19
+ * pair that npm guarantees can never split, and a `stale-note` against a note
20
+ * quoting the range actually in force.
21
+ */
22
+ const OVERRIDE_REFERENCE = /^\$(.*)$/;
23
+
24
+ /**
25
+ * A semver **range**, not only a caret/tilde one. The old pattern required a
26
+ * `^` or `~` prefix, so a note describing an exact pin (`"1.2.3"`) quoted a
27
+ * version the matcher could not see: `quoted` came back empty, the staleness
28
+ * check short-circuits on an empty list, and the note was silently exempt from
29
+ * the guarantee it exists to provide.
30
+ */
31
+ const SEMVER_RANGE = /(?:[\^~]|>=?|<=?|=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g;
32
+
33
+ /**
34
+ * Extract every semver range that appears literally in a note's prose.
35
+ *
36
+ * Deliberately permissive about the surrounding words — a note is prose, and
37
+ * pinning its phrasing would make it unwritable. What matters is only that
38
+ * the range it quotes is the range in force.
39
+ *
40
+ * Not exported: it is an implementation detail of the audit below, and its
41
+ * behaviour is observable through that — a note quoting only bare versions
42
+ * yields no `stale-note`, a note quoting a mismatched range yields one.
43
+ *
44
+ * @param {string} text
45
+ * @returns {string[]}
46
+ */
47
+ function quotedRanges(text) {
48
+ if (typeof text !== 'string') return [];
49
+ return [...text.matchAll(SEMVER_RANGE)].map((m) => m[0]);
50
+ }
51
+
52
+ /**
53
+ * Look a dotted note key up in the `overrides` tree.
54
+ *
55
+ * An `overrides` value may be a nested object — `{"foo": {"bar": "1.2.3"}}`
56
+ * scopes the `bar` override to `foo`'s subtree — so `overrides.foo.bar` names a
57
+ * real pin two levels down. The exact key is tried first at every level,
58
+ * because package names legitimately contain dots (`lodash.merge`), and only
59
+ * then is the key split.
60
+ *
61
+ * @param {unknown} node
62
+ * @param {string} dottedName
63
+ * @returns {{ found: boolean, value?: unknown }}
64
+ */
65
+ function lookupOverride(node, dottedName) {
66
+ if (!node || typeof node !== 'object') return { found: false };
67
+ if (Object.hasOwn(node, dottedName)) {
68
+ return { found: true, value: node[dottedName] };
69
+ }
70
+ const parts = dottedName.split('.');
71
+ for (let i = 1; i < parts.length; i += 1) {
72
+ const child = node[parts.slice(0, i).join('.')];
73
+ const hit = lookupOverride(child, parts.slice(i).join('.'));
74
+ if (hit.found) return hit;
75
+ }
76
+ return { found: false };
77
+ }
78
+
79
+ /**
80
+ * Resolve the range a documented override actually imposes.
81
+ *
82
+ * @param {object} params
83
+ * @returns {{ kind: 'missing'|'pinned'|'reference'|'unsupported', range?: string,
84
+ * raw?: unknown, referenced?: string, detail?: string }}
85
+ */
86
+ export function resolvePin({ overrides, dependencies, name }) {
87
+ const hit = lookupOverride(overrides, name);
88
+ if (!hit.found) return { kind: 'missing' };
89
+ if (typeof hit.value !== 'string') {
90
+ return {
91
+ kind: 'unsupported',
92
+ raw: hit.value,
93
+ detail:
94
+ 'resolves to a nested override object rather than a range. Key the note at the leaf ' +
95
+ '(e.g. "overrides.<parent>.<name>") so the range it describes is the one checked.',
96
+ };
97
+ }
98
+ const reference = OVERRIDE_REFERENCE.exec(hit.value);
99
+ return reference
100
+ ? resolveReference({
101
+ raw: hit.value,
102
+ target: reference[1] || name,
103
+ dependencies,
104
+ })
105
+ : { kind: 'pinned', range: hit.value };
106
+ }
107
+
108
+ /**
109
+ * Resolve npm's `"$name"` reference to the direct range it points at.
110
+ *
111
+ * @param {{ raw: string, target: string, dependencies: object }} params
112
+ * @returns {{ kind: 'reference'|'unsupported', range?: string, raw?: string,
113
+ * referenced: string, detail?: string }}
114
+ */
115
+ function resolveReference({ raw, target, dependencies }) {
116
+ const direct = dependencies[target];
117
+ if (typeof direct !== 'string') {
118
+ return {
119
+ kind: 'unsupported',
120
+ raw,
121
+ referenced: target,
122
+ detail: `references "${raw}", but dependencies.${target} does not exist, so npm has nothing to resolve the override to.`,
123
+ };
124
+ }
125
+ return { kind: 'reference', range: direct, referenced: target };
126
+ }
127
+
128
+ /**
129
+ * Score one documented override against its note. Split out of the loop above
130
+ * so each finding's rationale sits next to the condition that raises it.
131
+ *
132
+ * @param {{ key: string, name: string, text: unknown, resolved: object,
133
+ * direct: string|undefined }} params
134
+ * @returns {Array<{ kind: string, name: string, detail: string }>}
135
+ */
136
+ export function scoreNote({ key, name, text, resolved, direct }) {
137
+ const refusal = refuseNote({ key, name, resolved });
138
+ if (refusal) return [refusal];
139
+ return [
140
+ ...scoreLockstep({ name, resolved, direct }),
141
+ ...scoreStaleness({ key, name, text, range: resolved.range }),
142
+ ];
143
+ }
144
+
145
+ /**
146
+ * The two ways a note names nothing checkable: the pin is gone, or the key
147
+ * resolves to something that is not a range. They are deliberately different
148
+ * findings — telling an author to "delete the note or restore the pin" when the
149
+ * pin is right there, one level down, is wrong advice.
150
+ *
151
+ * @param {{ key: string, name: string, resolved: object }} params
152
+ * @returns {{ kind: string, name: string, detail: string }|null}
153
+ */
154
+ function refuseNote({ key, name, resolved }) {
155
+ if (resolved.kind === 'missing') {
156
+ return {
157
+ kind: 'orphan-note',
158
+ name,
159
+ 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.`,
160
+ };
161
+ }
162
+ if (resolved.kind === 'unsupported') {
163
+ return {
164
+ kind: 'unsupported-shape',
165
+ name,
166
+ detail: `"//"["${key}"] ${resolved.detail}`,
167
+ };
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Does the override range still agree with the direct one? A `$name` override
174
+ * IS `dependencies[name]`, so npm cannot split the pair the note is warning
175
+ * about and there is nothing to compare.
176
+ *
177
+ * @param {{ name: string, resolved: object, direct: string|undefined }} params
178
+ * @returns {Array<object>}
179
+ */
180
+ function scoreLockstep({ name, resolved, direct }) {
181
+ const split =
182
+ resolved.kind === 'pinned' &&
183
+ typeof direct === 'string' &&
184
+ direct !== resolved.range;
185
+ if (!split) return [];
186
+ return [
187
+ {
188
+ kind: 'lockstep',
189
+ name,
190
+ detail: `overrides.${name} is "${resolved.range}" but dependencies.${name} is "${direct}". The "//" note declares these move in lockstep; a split gives the direct and transitive resolutions different floors. npm's own "$${name}" reference is the way to make the split impossible.`,
191
+ },
192
+ ];
193
+ }
194
+
195
+ /**
196
+ * Does the note still state the range in force? A note quoting no range at all
197
+ * is not scored: prose that names no version cannot go stale.
198
+ *
199
+ * @param {{ key: string, name: string, text: unknown, range: string }} params
200
+ * @returns {Array<object>}
201
+ */
202
+ function scoreStaleness({ key, name, text, range }) {
203
+ const quoted = quotedRanges(text);
204
+ if (quoted.length === 0 || quoted.includes(range)) return [];
205
+ return [
206
+ {
207
+ kind: 'stale-note',
208
+ name,
209
+ detail: `"//"["${key}"] quotes ${quoted.map((q) => `"${q}"`).join(', ')} but the range in force is "${range}". The note is what tells the next author whether a bump is safe, so it must state the version it is describing.`,
210
+ },
211
+ ];
212
+ }
@@ -265,6 +265,14 @@ function toOrigin(value) {
265
265
  * than mid-sweep. The resolved `SKILL.md` path is attached as
266
266
  * `skillPath` so the harness reads the file the check actually found.
267
267
  *
268
+ * The two failures get **different** messages (Story #5285). A well-formed id
269
+ * that resolves nowhere is a skill to author; a malformed one is an id to
270
+ * fix, and telling the operator to author `../../secrets/SKILL.md` would be
271
+ * advice they cannot take. A config validated against the shipped schema
272
+ * never reaches the malformed branch — the `pattern` rejects it first — but
273
+ * this resolver also runs over hand-assembled contracts in tests and over
274
+ * configs loaded past a degraded validator, so the branch is real.
275
+ *
268
276
  * @param {object | undefined} seam
269
277
  * @param {string} envName Environment name, for the error message.
270
278
  * @param {{ repoRoot?: string }} options
@@ -276,6 +284,16 @@ function resolveSignInSeam(seam, envName, options) {
276
284
 
277
285
  const repoRoot = options.repoRoot ?? PROJECT_ROOT;
278
286
  const found = resolveSkillFile(repoRoot, seam.skill);
287
+ if (found?.reason === 'invalid-id') {
288
+ throw new Error(
289
+ `qa: environment \`${envName}\` declares signInSeam.skill ` +
290
+ `\`${seam.skill}\`, which is not a well-formed skill id. ` +
291
+ 'An id is two or more lowercase segments of letters, digits, `.`, ' +
292
+ '`_` or `-` joined by `/` — e.g. `stack/qa/acme-sso`. Traversals, ' +
293
+ 'absolute paths, backslashes and uppercase segments are rejected ' +
294
+ 'outright, so no skills root was searched. Correct the id.',
295
+ );
296
+ }
279
297
  if (found === null) {
280
298
  throw new Error(
281
299
  `qa: environment \`${envName}\` declares signInSeam.skill ` +
@@ -102,6 +102,44 @@ function heartbeatIntervalFor(timeoutMs) {
102
102
  return Math.max(MIN_HEARTBEAT_MS, derived);
103
103
  }
104
104
 
105
+ /**
106
+ * Signals whose default disposition kills the process. A holder that takes
107
+ * one MUST drop its lockfile before it dies: the `'exit'` guard in
108
+ * {@link buildAcquired} never runs for a signal Node has not been asked to
109
+ * handle, so before Story #5278 a Ctrl-C during a full suite left a lockfile
110
+ * behind that every sibling then had to wait `timeoutMs` to break.
111
+ */
112
+ const RELEASE_ON_SIGNALS = Object.freeze(['SIGINT', 'SIGTERM']);
113
+
114
+ /**
115
+ * Is `pid` a process this host is still running?
116
+ *
117
+ * `kill(pid, 0)` performs the permission and existence checks without
118
+ * delivering a signal. Three outcomes matter:
119
+ *
120
+ * - it returns → the process exists and is ours: **alive**;
121
+ * - `EPERM` → the process exists but belongs to another user: **alive**
122
+ * (an existence check that we are not allowed to complete is not
123
+ * evidence of death);
124
+ * - `ESRCH` → no such process: **dead**.
125
+ *
126
+ * A pid that cannot be read at all resolves to `null` — "unknown", which
127
+ * leaves the mtime heuristic in charge exactly as before.
128
+ *
129
+ * @param {number|null} pid
130
+ * @param {(pid: number, signal: number) => void} [killFn]
131
+ * @returns {boolean|null} `null` when the pid is unknown.
132
+ */
133
+ function isHolderAlive(pid, killFn = process.kill.bind(process)) {
134
+ if (!Number.isInteger(pid) || pid <= 0) return null;
135
+ try {
136
+ killFn(pid, 0);
137
+ return true;
138
+ } catch (err) {
139
+ return err?.code === 'ESRCH' ? false : true;
140
+ }
141
+ }
142
+
105
143
  /**
106
144
  * Read the lockfile's *identity* — the tuple that distinguishes "the file I
107
145
  * observed" from "a different file that now sits at the same path". `dev` +
@@ -265,7 +303,11 @@ function tryCreateLock(lockPath, ownerId, fsImpl = fs) {
265
303
  * `timeoutMs`. `0` disables it.
266
304
  * @param {Function} [opts.setIntervalFn] Timer seam for tests.
267
305
  * @param {Function} [opts.clearIntervalFn] Timer seam for tests.
268
- * @returns {{ acquired: true, release: () => void, ownerId: string }
306
+ * @param {Function} [opts.killFn] `process.kill` seam for the holder
307
+ * liveness probe (Story #5278).
308
+ * @param {object} [opts.processImpl] `process` seam for the
309
+ * release-on-signal handlers.
310
+ * @returns {{ acquired: true, release: () => void, refresh: () => boolean, ownerId: string }
269
311
  * | { acquired: false, reason: 'contended' | 'error', detail?: string }}
270
312
  */
271
313
  export function acquireSweepLock({
@@ -277,6 +319,8 @@ export function acquireSweepLock({
277
319
  heartbeatMs,
278
320
  setIntervalFn = setInterval,
279
321
  clearIntervalFn = clearInterval,
322
+ killFn,
323
+ processImpl = process,
280
324
  } = {}) {
281
325
  if (typeof lockPath !== 'string' || lockPath.length === 0) {
282
326
  return {
@@ -294,6 +338,8 @@ export function acquireSweepLock({
294
338
  heartbeatMs: heartbeatMs ?? heartbeatIntervalFor(timeoutMs),
295
339
  setIntervalFn,
296
340
  clearIntervalFn,
341
+ killFn,
342
+ processImpl,
297
343
  };
298
344
  try {
299
345
  if (
@@ -321,16 +367,56 @@ export function acquireSweepLock({
321
367
  * @param {number} timeoutMs
322
368
  * @returns {boolean}
323
369
  */
324
- function tryStaleTakeover({ lockPath, ownerId, fsImpl, nowFn }, timeoutMs) {
370
+ function tryStaleTakeover(
371
+ { lockPath, ownerId, fsImpl, nowFn, killFn },
372
+ timeoutMs,
373
+ ) {
325
374
  const observed = readLockIdentity(lockPath, fsImpl);
326
375
  if (observed === null) return false;
327
- if (!isLockStale(observed.mtimeMs, nowFn(), timeoutMs)) return false;
376
+ if (
377
+ !isHolderStale({ lockPath, fsImpl, nowFn, killFn, observed, timeoutMs })
378
+ ) {
379
+ return false;
380
+ }
328
381
  return (
329
382
  breakStaleLock(lockPath, observed, fsImpl) &&
330
383
  tryCreateLock(lockPath, ownerId, fsImpl)
331
384
  );
332
385
  }
333
386
 
387
+ /**
388
+ * Is the observed holder dead enough to take over? Story #5278 adds the
389
+ * holder's **pid** as a way to answer "yes" *sooner* — never as a way to
390
+ * answer "no".
391
+ *
392
+ * - pid **dead** (`ESRCH`) → stale immediately, whatever the mtime says. A
393
+ * crashed or Ctrl-C'd holder no longer costs every sibling a full
394
+ * `timeoutMs` wait for a lockfile nobody is behind.
395
+ * - anything else (alive, or an unreadable pid) → the mtime heuristic
396
+ * decides, byte-for-byte the pre-#5278 rule.
397
+ *
398
+ * Deliberately one-directional. Letting a live pid *veto* the mtime rule
399
+ * would make a hung holder immortal and would break the reclaim contract the
400
+ * full-suite lock depends on; keeping the holder's mtime advancing while it
401
+ * works is the heartbeat's job (`refreshLockSync`), not this predicate's.
402
+ *
403
+ * @param {{ lockPath: string, fsImpl: object, nowFn: () => number, killFn?: Function, observed: {mtimeMs: number}, timeoutMs: number }} args
404
+ * @returns {boolean}
405
+ */
406
+ function isHolderStale({
407
+ lockPath,
408
+ fsImpl,
409
+ nowFn,
410
+ killFn,
411
+ observed,
412
+ timeoutMs,
413
+ }) {
414
+ if (isHolderAlive(readLockHolderPid(lockPath, fsImpl), killFn) === false) {
415
+ return true;
416
+ }
417
+ return isLockStale(observed.mtimeMs, nowFn(), timeoutMs);
418
+ }
419
+
334
420
  /**
335
421
  * Unlink a stale lockfile — but only when it is still byte-for-byte the
336
422
  * instance the caller observed. Returns `true` when this call removed that
@@ -362,10 +448,23 @@ function breakStaleLock(lockPath, observed, fsImpl) {
362
448
  * line is no longer ours — after a steal the file belongs to someone else and
363
449
  * bumping its mtime would keep *their* lock alive on our behalf.
364
450
  *
451
+ * **The synchronous refresh entry point (Story #5278).** The interval-driven
452
+ * heartbeat below only fires when the holder's event loop gets a turn, which
453
+ * a `spawnSync` critical section never gives it. A caller on such a stack
454
+ * calls this directly — immediately before it blocks — so the lock it is
455
+ * about to sit on carries a current mtime rather than the one it was created
456
+ * with minutes earlier.
457
+ *
458
+ * @param {{ lockPath: string, ownerId: string, fsImpl?: object, nowFn?: () => number }} holder
365
459
  * @returns {boolean} `true` when the refresh landed; `false` when the lock is
366
460
  * no longer ours (the caller stops heartbeating).
367
461
  */
368
- function refreshLockMtime({ lockPath, ownerId, fsImpl, nowFn }) {
462
+ export function refreshLockSync({
463
+ lockPath,
464
+ ownerId,
465
+ fsImpl = fs,
466
+ nowFn = Date.now,
467
+ }) {
369
468
  if (readLockOwner(lockPath, fsImpl) !== ownerId) return false;
370
469
  try {
371
470
  const stamp = new Date(nowFn());
@@ -398,7 +497,7 @@ function startHeartbeat(holder) {
398
497
  }
399
498
  };
400
499
  timer = setIntervalFn(() => {
401
- if (!refreshLockMtime(holder)) stop();
500
+ if (!refreshLockSync(holder)) stop();
402
501
  }, heartbeatMs);
403
502
  if (timer && typeof timer.unref === 'function') timer.unref();
404
503
  return stop;
@@ -424,22 +523,83 @@ function unlinkIfOwned(lockPath, ownerId, fsImpl) {
424
523
  }
425
524
 
426
525
  function buildAcquired(holder) {
427
- const { lockPath, ownerId, fsImpl } = holder;
526
+ const { lockPath, ownerId, fsImpl, processImpl = process } = holder;
428
527
  const stopHeartbeat = startHeartbeat(holder);
429
528
  let released = false;
529
+ let detachSignals = () => {};
430
530
  const release = () => {
431
531
  if (released) return;
432
532
  released = true;
433
533
  stopHeartbeat();
534
+ detachSignals();
434
535
  unlinkIfOwned(lockPath, ownerId, fsImpl);
435
536
  };
436
537
  // Belt-and-braces: process exit also clears the lockfile so a
437
538
  // crashed run doesn't leave a stale-but-not-yet-old artifact behind.
438
539
  const exitCleanup = () => release();
439
- if (typeof process.once === 'function') {
440
- process.once('exit', exitCleanup);
540
+ if (typeof processImpl?.once === 'function') {
541
+ processImpl.once('exit', exitCleanup);
441
542
  }
442
- return { acquired: true, release, ownerId };
543
+ detachSignals = attachSignalRelease(processImpl, release);
544
+ return {
545
+ acquired: true,
546
+ release,
547
+ refresh: () => refreshLockSync(holder),
548
+ ownerId,
549
+ };
550
+ }
551
+
552
+ /**
553
+ * Drop the lock on SIGINT / SIGTERM, then re-raise so the process still dies
554
+ * the way its caller asked it to (Story #5278).
555
+ *
556
+ * The `'exit'` guard above does not cover this: Node only runs `'exit'`
557
+ * handlers for a signal it has been asked to handle, so an unhandled Ctrl-C
558
+ * terminates the process with the lockfile still on disk. Registering here
559
+ * changes only *cleanup*, never the outcome — the handler removes itself and
560
+ * re-sends the same signal, so with no other listener the process dies under
561
+ * the default disposition and exits 128 + signum (130 for SIGINT), exactly as
562
+ * it did before.
563
+ *
564
+ * Returns a detach callback so a released holder stops intercepting signals
565
+ * it no longer has anything to clean up for.
566
+ *
567
+ * @param {object} processImpl
568
+ * @param {() => void} release
569
+ * @returns {() => void} detach
570
+ */
571
+ function attachSignalRelease(processImpl, release) {
572
+ if (
573
+ typeof processImpl?.once !== 'function' ||
574
+ typeof processImpl?.off !== 'function' ||
575
+ typeof processImpl?.kill !== 'function'
576
+ ) {
577
+ return () => {};
578
+ }
579
+ const handlers = RELEASE_ON_SIGNALS.map((signal) => {
580
+ const handler = () => {
581
+ release();
582
+ // `release()` has already detached every handler, so this re-raise
583
+ // reaches the default disposition (or another listener) rather than
584
+ // looping back into us.
585
+ try {
586
+ processImpl.kill(processImpl.pid, signal);
587
+ } catch {
588
+ // A process that cannot signal itself is already on its way out.
589
+ }
590
+ };
591
+ processImpl.once(signal, handler);
592
+ return [signal, handler];
593
+ });
594
+ return () => {
595
+ for (const [signal, handler] of handlers) {
596
+ try {
597
+ processImpl.off(signal, handler);
598
+ } catch {
599
+ // Best-effort: a seam may not implement removal.
600
+ }
601
+ }
602
+ };
443
603
  }
444
604
 
445
605
  const DEFAULT_WAIT_MS = 8_000;
@@ -501,6 +661,8 @@ export async function acquireLockWithWait({
501
661
  heartbeatMs,
502
662
  setIntervalFn = setInterval,
503
663
  clearIntervalFn = clearInterval,
664
+ killFn,
665
+ processImpl,
504
666
  } = {}) {
505
667
  const deadline = nowFn() + Math.max(0, waitMs);
506
668
  for (;;) {
@@ -513,6 +675,8 @@ export async function acquireLockWithWait({
513
675
  heartbeatMs,
514
676
  setIntervalFn,
515
677
  clearIntervalFn,
678
+ killFn,
679
+ processImpl,
516
680
  });
517
681
  if (res.acquired) return res;
518
682
  // A hard error will not resolve by retrying — surface it immediately.