session-orchestrator 3.21.0 → 3.22.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/040-discovery.mdc +6 -6
  6. package/.cursor/rules/050-plan.mdc +8 -8
  7. package/CHANGELOG.md +101 -0
  8. package/README.md +10 -10
  9. package/agents/memory-proposal-collector.md +6 -4
  10. package/commands/eli5.md +33 -0
  11. package/commands/release.md +5 -3
  12. package/commands/test.md +2 -2
  13. package/docs/components.md +6 -5
  14. package/docs/scope-collision-guard.md +3 -3
  15. package/docs/session-config-reference.md +31 -8
  16. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  17. package/hooks/hooks-codex.json +1 -1
  18. package/hooks/hooks.json +11 -1
  19. package/hooks/on-session-end.mjs +24 -92
  20. package/hooks/on-session-start.mjs +195 -104
  21. package/hooks/pre-auq-clarity.mjs +787 -0
  22. package/hooks/pre-bash-issue-budget.mjs +17 -18
  23. package/package.json +3 -1
  24. package/pi/prompts/eli5.md +12 -0
  25. package/scripts/auq-audit.mjs +825 -0
  26. package/scripts/autopilot.mjs +7 -8
  27. package/scripts/lib/auq/clarity.mjs +1314 -0
  28. package/scripts/lib/auq/parse.mjs +1006 -0
  29. package/scripts/lib/auq/schema.mjs +1457 -0
  30. package/scripts/lib/ci-status-banner.mjs +63 -57
  31. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  32. package/scripts/lib/config/vault-integration.mjs +12 -1
  33. package/scripts/lib/dispatcher/rank.mjs +4 -7
  34. package/scripts/lib/gates/gate-full.mjs +3 -3
  35. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  36. package/scripts/lib/io.mjs +239 -0
  37. package/scripts/lib/issue-budget.mjs +63 -9
  38. package/scripts/lib/owner-interview.mjs +78 -32
  39. package/scripts/lib/peer-discovery.mjs +73 -22
  40. package/scripts/lib/project-hygiene.mjs +64 -4
  41. package/scripts/lib/reconcile/renderer.mjs +17 -4
  42. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  43. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  44. package/scripts/lib/resource-probe.mjs +18 -2
  45. package/scripts/lib/spiral-carryover.mjs +23 -2
  46. package/scripts/lib/state-md/mission-status.mjs +147 -50
  47. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  48. package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
  49. package/scripts/lib/validate/check-rules.mjs +153 -9
  50. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  51. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  52. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  53. package/scripts/lib/wave-resource-gate.mjs +67 -73
  54. package/scripts/materialize-wave-scope.mjs +281 -0
  55. package/scripts/release.mjs +443 -122
  56. package/scripts/run-quality-gate.mjs +14 -0
  57. package/scripts/validate-plugin.mjs +3 -0
  58. package/scripts/validate-wave-scope.mjs +6 -1
  59. package/scripts/vault-backfill.mjs +32 -5
  60. package/skills/_shared/parallel-aware-auq.md +30 -24
  61. package/skills/_shared/parallel-aware-preamble.md +31 -2
  62. package/skills/_shared/state-ownership.md +32 -6
  63. package/skills/bootstrap/SKILL.md +2 -1
  64. package/skills/brainstorm/SKILL.md +18 -18
  65. package/skills/brainstorm/soul.md +12 -0
  66. package/skills/discovery/SKILL.md +28 -24
  67. package/skills/eli5/SKILL.md +43 -0
  68. package/skills/evolve/SKILL.md +8 -9
  69. package/skills/gitlab-ops/SKILL.md +30 -26
  70. package/skills/grill/SKILL.md +6 -6
  71. package/skills/grill/soul.md +16 -0
  72. package/skills/memory-cleanup/SKILL.md +2 -2
  73. package/skills/npm-publish/SKILL.md +4 -4
  74. package/skills/peekaboo-driver/SKILL.md +3 -3
  75. package/skills/plan/SKILL.md +18 -16
  76. package/skills/plan/mode-feature.md +1 -1
  77. package/skills/plan/mode-new.md +35 -23
  78. package/skills/plan/soul.md +12 -0
  79. package/skills/reconcile/SKILL.md +3 -3
  80. package/skills/session-end/SKILL.md +53 -20
  81. package/skills/session-end/phase-3-6-tail.md +37 -2
  82. package/skills/session-start/SKILL.md +69 -35
  83. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  84. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  85. package/skills/session-start/soul.md +110 -0
  86. package/skills/test-runner/SKILL.md +2 -2
  87. package/skills/using-orchestrator/SKILL.md +1 -1
  88. package/skills/wave-executor/wave-loop.md +27 -5
  89. package/skills/write-executable-plan/SKILL.md +6 -6
  90. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  91. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  92. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  93. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  94. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  95. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  96. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  97. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  98. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  99. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  100. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  101. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  102. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  103. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  104. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  105. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  106. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  107. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  108. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  109. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  110. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  111. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  112. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  113. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  114. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  115. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  116. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  117. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -18,6 +18,7 @@
18
18
  * cpu_load_5m: 1.8,
19
19
  * cpu_load_5m_pct: 45 | null, // 5m load-average as pct-of-cores; null on Windows/zero-load (#943)
20
20
  * claude_processes_count: 3 | null,
21
+ * peer_sessions_count: 2 | null, // live peer SESSIONS from the registry, self excluded (#1089)
21
22
  * codex_processes_count: 0 | null,
22
23
  * other_node_processes: 12 | null,
23
24
  * zombie_processes_count: 1 | null,
@@ -40,13 +41,14 @@
40
41
  */
41
42
 
42
43
  import os from 'node:os';
43
- import { ramSnapshot, cpuSnapshot, processCounts, swapUsedMb, memoryPressurePctFree, ramAvailableGb } from './resource-probe/probe-platform.mjs';
44
+ import { ramSnapshot, cpuSnapshot, processCounts, swapUsedMb, memoryPressurePctFree, ramAvailableGb, peerSessionsCount } from './resource-probe/probe-platform.mjs';
44
45
 
45
46
  // ---------------------------------------------------------------------------
46
47
  // Re-exports — preserve public API for all existing callers
47
48
  // ---------------------------------------------------------------------------
48
49
 
49
- export { evaluate } from './resource-probe/evaluate.mjs';
50
+ export { evaluate, PROCESSES_PER_SESSION, PRESSURE_HARD_PCT, PRESSURE_SOFT_PCT, PRESSURE_HEALTHY_PCT, DEFAULT_RESOURCE_THRESHOLDS } from './resource-probe/evaluate.mjs';
51
+ export { peerSessionsCount } from './resource-probe/probe-platform.mjs';
50
52
  export { parseEtimeToMinutes, countZombieProcesses, countProcessMatches, parseSwapUsageOutput, parseMemoryPressureOutput, parseVmStatAvailableGb } from './resource-probe/parsers.mjs';
51
53
 
52
54
  // ---------------------------------------------------------------------------
@@ -60,6 +62,9 @@ export { parseEtimeToMinutes, countZombieProcesses, countProcessMatches, parseSw
60
62
  * @param {boolean} [opts.skipExtendedSignals] — skip swap + memory_pressure calls (faster in tests)
61
63
  * @param {number|null} [opts.zombieThresholdMin] — when non-null, detect zombie Claude/Node
62
64
  * processes older than this many minutes with low CPU. Default null (feature disabled).
65
+ * @param {string|null} [opts.sessionId] — own session id, excluded from `peer_sessions_count`
66
+ * (#1089). Omit and the count simply includes every live registry entry, which
67
+ * over-counts by exactly one — pass it whenever the caller knows its own id.
63
68
  * @returns {Promise<object>}
64
69
  */
65
70
  export async function probe(opts = {}) {
@@ -85,6 +90,13 @@ export async function probe(opts = {}) {
85
90
  ? { claude_processes_count: null, codex_processes_count: null, other_node_processes: null, zombie_processes_count: null }
86
91
  : await processCounts(zombieThresholdMin);
87
92
 
93
+ // #1089: the registry-denominated concurrency signal. Gated on the same
94
+ // skipProcessCounts flag as the `ps` pass — both answer "who else is on this
95
+ // host", and tests that skip one always mean to skip the other.
96
+ const peer_sessions_count = opts.skipProcessCounts
97
+ ? null
98
+ : await peerSessionsCount({ sessionId: opts.sessionId ?? null });
99
+
88
100
  let swap_used_mb = null;
89
101
  let memory_pressure_pct_free = null;
90
102
  let ram_available_gb = null;
@@ -104,6 +116,10 @@ export async function probe(opts = {}) {
104
116
  cpu_load_5m,
105
117
  cpu_load_5m_pct,
106
118
  ...procs,
119
+ // Live peer SESSIONS (registry, self excluded) — the unit
120
+ // `concurrent-sessions-warn` is named for. null = registry unreadable, in
121
+ // which case evaluate() falls back to the rescaled process count (#1089).
122
+ peer_sessions_count,
107
123
  swap_used_mb,
108
124
  memory_pressure_pct_free,
109
125
  // macOS-only numeric available-RAM (free + reclaimable) from vm_stat; null
@@ -35,8 +35,14 @@
35
35
  */
36
36
 
37
37
  import { execFileSync } from 'node:child_process';
38
+ import { readFileSync } from 'node:fs';
39
+ import path from 'node:path';
38
40
  import { digestSha256Short } from './crypto-digest-utils.mjs';
39
- import { chargeIssueBudget, formatBlockReason } from './issue-budget.mjs';
41
+ import {
42
+ chargeIssueBudget,
43
+ formatBlockReason,
44
+ resolveIssueBudgetSessionId,
45
+ } from './issue-budget.mjs';
40
46
  import { resolveRepoSpec } from './vcs-repo-spec.mjs';
41
47
 
42
48
  /**
@@ -99,10 +105,25 @@ function runCli(cmd, args) {
99
105
  if (isIssueCreateArgv(cmd, args)) {
100
106
  try {
101
107
  const repoRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
108
+ // The harness exports CLAUDE_CODE_SESSION_ID (measured 2026-08-21: it is
109
+ // present in the Bash tool environment this module runs in). There is no
110
+ // CLAUDE_SESSION_ID — reading that name made this whole block dead code
111
+ // and left the cap permanently off on the programmatic path.
112
+ const nativeRawId = process.env.CLAUDE_CODE_SESSION_ID ?? null;
113
+ let currentSession = null;
114
+ if (typeof nativeRawId === 'string' && nativeRawId.length > 0) {
115
+ try {
116
+ currentSession = JSON.parse(
117
+ readFileSync(path.join(repoRoot, '.orchestrator', 'current-session.json'), 'utf8'),
118
+ );
119
+ } catch {
120
+ // Missing or malformed records conservatively retain the native env key.
121
+ }
122
+ }
102
123
  const titleIdx = args.indexOf('--title');
103
124
  const verdict = chargeIssueBudget({
104
125
  repoRoot,
105
- sessionId: process.env.CLAUDE_SESSION_ID || null,
126
+ sessionId: resolveIssueBudgetSessionId(nativeRawId, currentSession),
106
127
  command: [cmd, ...args].join(' '),
107
128
  title: titleIdx >= 0 ? (args[titleIdx + 1] ?? null) : null,
108
129
  });
@@ -16,6 +16,40 @@
16
16
  import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
17
17
  import { updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
18
18
 
19
+ const MISSION_STATUS_HEADING_RE = /^##\s+Mission Status\s*$/;
20
+ const WRITER_TIMESTAMP_SOURCE = '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z';
21
+ const WRITER_TIMESTAMP_RE = new RegExp(`^${WRITER_TIMESTAMP_SOURCE}$`);
22
+ const CANONICAL_MISSION_STATUS_ENTRY_RE = new RegExp(
23
+ `^- ([a-z][a-z0-9]*(?:-[a-z0-9]+)*-\\d+): (.*) \\(updated (${WRITER_TIMESTAMP_SOURCE})\\)$`
24
+ );
25
+
26
+ /**
27
+ * Finds the first exact `## Mission Status` section and its closing heading.
28
+ *
29
+ * @param {string[]} lines
30
+ * @returns {{ headingIdx: number, sectionEnd: number }|null}
31
+ */
32
+ function findMissionStatusSection(lines) {
33
+ if (!Array.isArray(lines)) return null;
34
+ const headingIdx = lines.findIndex((line) => MISSION_STATUS_HEADING_RE.test(line));
35
+ if (headingIdx === -1) return null;
36
+
37
+ let sectionEnd = lines.length;
38
+ for (let i = headingIdx + 1; i < lines.length; i++) {
39
+ if (/^##\s+/.test(lines[i])) {
40
+ sectionEnd = i;
41
+ break;
42
+ }
43
+ }
44
+ return { headingIdx, sectionEnd };
45
+ }
46
+
47
+ function isWriterTimestamp(timestamp) {
48
+ if (!WRITER_TIMESTAMP_RE.test(timestamp)) return false;
49
+ const parsed = new Date(timestamp);
50
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === timestamp;
51
+ }
52
+
19
53
  /**
20
54
  * Parses the optional `mission-status:` block from a STATE.md frontmatter object
21
55
  * (as returned by `parseStateMd(...).frontmatter`).
@@ -83,13 +117,17 @@ export function writeMissionStatus(contents, missionStatusArray) {
83
117
  * entry level — the input is never mutated, which keeps `parseMissionStatus`'s
84
118
  * shallow-copy contract intact for anything else holding the same nested entries).
85
119
  *
86
- * UPDATE-ONLY by design: when the key is absent, is not an array, or holds no entry
87
- * with a matching `id`, the frontmatter is returned unchanged. It is deliberately
88
- * neither created nor an error, because `setMissionStatus(contents, taskId, status)`
89
- * knows only `id` and `status` — it lacks the `task` and `wave` fields a full entry
90
- * carries, so a synthesised entry would be shape-invalid yet look authoritative to
91
- * frontmatter consumers such as `vault-status/narrative-mirror.mjs`. Throwing is
92
- * likewise excluded by the never-throw contract of `setMissionStatus`.
120
+ * UPDATE-ONLY by design: when the key is absent, is not an array, or holds no
121
+ * matching entry in a populated array, the frontmatter is returned unchanged. It is
122
+ * deliberately neither created nor an error, because `setMissionStatus(contents,
123
+ * taskId, status)` knows only `id` and `status` — it lacks the `task` and `wave`
124
+ * fields a full entry carries, so a synthesised entry would be shape-invalid yet look
125
+ * authoritative to frontmatter consumers such as `vault-status/narrative-mirror.mjs`.
126
+ * Throwing is likewise excluded by the never-throw contract of `setMissionStatus`.
127
+ *
128
+ * An empty array is recovered from the final body by
129
+ * `recoverFrontmatterMissionStatus`, which persists only the truthful `id` and
130
+ * `status` values available there. It does not fabricate absent metadata.
93
131
  *
94
132
  * `status` is mirrored verbatim without an enum check on purpose: gating it would
95
133
  * reintroduce the exact divergence (body says X, frontmatter says Y) this sync exists
@@ -125,6 +163,80 @@ function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
125
163
  return { ...frontmatter, 'mission-status': entries };
126
164
  }
127
165
 
166
+ /**
167
+ * Mirrors body-only task IDs into the frontmatter `mission-status` registry as
168
+ * partial `{ id, status }` entries. This is a SUPERSET merge: existing entries are
169
+ * never rewritten or reordered, so their full metadata (`task`, `wave`) survives —
170
+ * only IDs absent from the registry are appended.
171
+ *
172
+ * It must not be gated on an EMPTY registry. Recovering only from empty froze the
173
+ * registry after its first recovery: the sync path (`syncFrontmatterMissionStatus`)
174
+ * is update-only, so every subsequently added task ID was never mirrored again, and
175
+ * Phase 1.9/1.10 read a plausible undercount instead of the obvious zero. That is
176
+ * #1084 one write later — measured m-1/m-2/m-3 in the body against `[m-1]` in the
177
+ * frontmatter.
178
+ *
179
+ * The section parse stays strict and all-or-nothing: any nonblank line that is not a
180
+ * unique canonical writer bullet (unsafe ID, pipe, malformed timestamp, duplicate,
181
+ * prose) aborts the whole merge rather than fabricating entries from an ambiguous
182
+ * body. Known limitation: one foreign-but-writer-accepted ID (e.g. `Docs_2`) or a
183
+ * hand-written legacy bullet without a timestamp therefore suppresses the merge for
184
+ * the whole file, silently.
185
+ *
186
+ * @param {object} frontmatter
187
+ * @param {string} body
188
+ * @returns {object}
189
+ */
190
+ function recoverFrontmatterMissionStatus(frontmatter, body) {
191
+ if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
192
+ return frontmatter;
193
+ }
194
+ const raw = frontmatter['mission-status'];
195
+ if (!Array.isArray(raw) || typeof body !== 'string') return frontmatter;
196
+
197
+ const lines = body.split('\n');
198
+ const section = findMissionStatusSection(lines);
199
+ if (section === null) return frontmatter;
200
+
201
+ const entries = [];
202
+ const ids = new Set();
203
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
204
+ const line = lines[i];
205
+ if (line.trim() === '') continue;
206
+
207
+ const match = CANONICAL_MISSION_STATUS_ENTRY_RE.exec(line);
208
+ if (match === null) return frontmatter;
209
+ const [, id, status, timestamp] = match;
210
+ if (
211
+ status.length === 0 ||
212
+ status.includes('|') ||
213
+ status.includes('\n') ||
214
+ !isWriterTimestamp(timestamp) ||
215
+ ids.has(id)
216
+ ) {
217
+ return frontmatter;
218
+ }
219
+ ids.add(id);
220
+ entries.push({ id, status });
221
+ }
222
+
223
+ const known = new Set(
224
+ raw
225
+ .filter((e) => e !== null && typeof e === 'object' && !Array.isArray(e))
226
+ .map((e) => e.id)
227
+ );
228
+ const added = entries.filter((e) => !known.has(e.id));
229
+ if (added.length === 0) return frontmatter;
230
+ return { ...frontmatter, 'mission-status': [...raw, ...added] };
231
+ }
232
+
233
+ function serializeMissionStatusUpdate(frontmatter, body) {
234
+ return serializeStateMd({
235
+ frontmatter: recoverFrontmatterMissionStatus(frontmatter, body),
236
+ body,
237
+ });
238
+ }
239
+
128
240
  /**
129
241
  * Sets (or updates) the mission status for a single task in the `## Mission Status` body
130
242
  * section of STATE.md. Creates the section if it does not exist.
@@ -137,7 +249,10 @@ function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
137
249
  * during a wave; the frontmatter array is what `parseMissionStatus` consumers read
138
250
  * (`vault-status/narrative-mirror.mjs`, session-end Phase 1.9/1.10). Before this sync the
139
251
  * live writer and the reader sat on different surfaces and drifted apart in both
140
- * directions. Frontmatter mirroring is UPDATE-ONLY see `syncFrontmatterMissionStatus`.
252
+ * directions. A legacy empty registry is recovered from canonical body bullets as
253
+ * partial `{ id, status }` entries, which lets frontmatter readers classify the work
254
+ * without fabricated metadata. Ambiguous legacy bodies remain empty; other unmatched
255
+ * populated entries remain update-only. See `syncFrontmatterMissionStatus`.
141
256
  *
142
257
  * Pure function — no I/O. Returns original `contents` unchanged on bad input.
143
258
  *
@@ -160,31 +275,16 @@ export function setMissionStatus(contents, taskId, status) {
160
275
  const bullet = `- ${taskId}: ${status} (updated ${timestamp})`;
161
276
  const lines = parsed.body.split('\n');
162
277
 
163
- // Find existing ## Mission Status section
164
- let headingIdx = -1;
165
- for (let i = 0; i < lines.length; i++) {
166
- if (/^##\s+Mission Status\b/.test(lines[i])) {
167
- headingIdx = i;
168
- break;
169
- }
170
- }
171
-
172
- if (headingIdx === -1) {
278
+ const section = findMissionStatusSection(lines);
279
+ if (section === null) {
173
280
  // Section does not exist — append it at the end
174
281
  let bodyOut = parsed.body;
175
282
  if (!bodyOut.endsWith('\n')) bodyOut += '\n';
176
283
  bodyOut += `\n## Mission Status\n\n${bullet}\n`;
177
- return serializeStateMd({ frontmatter, body: bodyOut });
284
+ return serializeMissionStatusUpdate(frontmatter, bodyOut);
178
285
  }
179
286
 
180
- // Find end of section: next ## heading or end of lines
181
- let sectionEnd = lines.length;
182
- for (let i = headingIdx + 1; i < lines.length; i++) {
183
- if (/^##\s+/.test(lines[i])) {
184
- sectionEnd = i;
185
- break;
186
- }
187
- }
287
+ const { headingIdx, sectionEnd } = section;
188
288
 
189
289
  // Look for an existing entry with this taskId within the section
190
290
  const entryRe = new RegExp(`^-\\s+${taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`);
@@ -214,18 +314,20 @@ export function setMissionStatus(contents, taskId, status) {
214
314
  const before = lines.slice(0, headingIdx + 1);
215
315
  const after = lines.slice(insertAt);
216
316
  const rebuilt = [...before, '', bullet, ...after];
217
- return serializeStateMd({ frontmatter, body: rebuilt.join('\n') });
317
+ return serializeMissionStatusUpdate(frontmatter, rebuilt.join('\n'));
218
318
  }
219
319
  }
220
320
 
221
- return serializeStateMd({ frontmatter, body: lines.join('\n') });
321
+ return serializeMissionStatusUpdate(frontmatter, lines.join('\n'));
222
322
  }
223
323
 
224
324
  /**
225
325
  * Reads the current mission status for a single task from the `## Mission Status` body
226
326
  * section of STATE.md.
227
327
  *
228
- * Returns the status string (e.g. `'in-dev'`) or `null` if the task is not found or the
328
+ * Returns the full status string before a current-writer timestamp (e.g. `'in-dev'`
329
+ * or `'needs manual testing'`). For legacy body lines that lack that exact form, falls
330
+ * back to the first status token. Returns `null` if the task is not found or the
229
331
  * section does not exist. Never throws — returns `null` on any bad input.
230
332
  *
231
333
  * @param {string} contents - Current STATE.md file contents (string)
@@ -239,29 +341,24 @@ export function readMissionStatus(contents, taskId) {
239
341
  if (parsed === null) return null;
240
342
 
241
343
  const lines = parsed.body.split('\n');
242
- let headingIdx = -1;
243
- for (let i = 0; i < lines.length; i++) {
244
- if (/^##\s+Mission Status\b/.test(lines[i])) {
245
- headingIdx = i;
246
- break;
247
- }
248
- }
249
- if (headingIdx === -1) return null;
344
+ const section = findMissionStatusSection(lines);
345
+ if (section === null) return null;
250
346
 
251
- let sectionEnd = lines.length;
252
- for (let i = headingIdx + 1; i < lines.length; i++) {
253
- if (/^##\s+/.test(lines[i])) {
254
- sectionEnd = i;
255
- break;
256
- }
347
+ // Prefer the full current-writer status, including internal spaces.
348
+ const escapedId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
349
+ const writerEntryRe = new RegExp(
350
+ `^- ${escapedId}: (.*) \\(updated ${WRITER_TIMESTAMP_SOURCE}\\)$`
351
+ );
352
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
353
+ const match = writerEntryRe.exec(lines[i]);
354
+ if (match) return match[1];
257
355
  }
258
356
 
259
- // Match: - <taskId>: <status> (updated ...)
260
- const escapedId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
261
- const entryRe = new RegExp(`^-\\s+${escapedId}:\\s+(\\S+)`);
262
- for (let i = headingIdx + 1; i < sectionEnd; i++) {
263
- const m = entryRe.exec(lines[i]);
264
- if (m) return m[1];
357
+ // Reader-only legacy compatibility. Recovery remains canonical and all-or-nothing.
358
+ const legacyEntryRe = new RegExp(`^-\\s+${escapedId}:\\s+(\\S+)`);
359
+ for (let i = section.headingIdx + 1; i < section.sectionEnd; i++) {
360
+ const match = legacyEntryRe.exec(lines[i]);
361
+ if (match) return match[1];
265
362
  }
266
363
  return null;
267
364
  }
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ // check-auq-clarity.mjs — Ratchet guard for the AUQ question corpus (#1107).
3
+ //
4
+ // ## Why this file exists
5
+ //
6
+ // Wave 2 of #1107 built a deterministic measurer for the operator-facing
7
+ // AskUserQuestion corpus (`scripts/lib/auq/` + `scripts/auq-audit.mjs`); wave 3
8
+ // used it to bring the corpus from 21/72 to 72/72 clean. The measurer had zero
9
+ // callers — no npm script, no gate, no skill phase invoked it. That is this
10
+ // repo's documented systemic failure class ("built, documented, tested, never
11
+ // switched on"), and without a caller the corpus silently rots back.
12
+ //
13
+ // This validator is that caller. It is a RATCHET, not a cleanup order: the
14
+ // corpus stands at 0 broken hurdles today, so the check is green today and goes
15
+ // red exactly when someone lands a NEW question that breaks a hard limit.
16
+ //
17
+ // ## What it gates on — and, deliberately, what it does not
18
+ //
19
+ // It gates on the two HARD LIMITS only (`HURDLES` in scripts/lib/auq/schema.mjs):
20
+ //
21
+ // H1 header at most 12 Unicode CODE POINTS (the tool truncates past that —
22
+ // the operator never sees the rest)
23
+ // H2 2-4 options PER QUESTION (never per block), and a `(Recommended)`
24
+ // marker only on index 0
25
+ //
26
+ // It does NOT gate on the eight weighted criteria K1-K8 or on the resulting
27
+ // score. Their measured false-positive rates run 14%-25% (module head of
28
+ // scripts/lib/auq/clarity.mjs); a gate built on them would object to roughly
29
+ // every fourth CORRECT question, and a validator that cries wolf gets switched
30
+ // off — taking every true finding with it. H1 and H2 are the only two with a
31
+ // measured false-positive rate of 0%. That asymmetry is the whole design:
32
+ // a narrow gate that survives beats a broad gate that gets disabled.
33
+ //
34
+ // Score, grades and the K1-K8 findings remain available on demand via
35
+ // `node scripts/auq-audit.mjs .` — reported, never enforced.
36
+ //
37
+ // ## It measures nothing itself
38
+ //
39
+ // Every judgement comes from the existing modules: `parseRepo()` finds the
40
+ // templates (across ALL six populations, including `.cursor/rules/*.mdc`, which
41
+ // is the only surface a Cursor operator ever sees), `scoreBlocks()` scores them,
42
+ // and `HURDLES` names the limits. A second implementation of the criteria here
43
+ // would be the start of divergence — the two copies would disagree the first
44
+ // time a threshold moves, and the disagreement would be silent.
45
+ //
46
+ // Direct import rather than spawning `scripts/auq-audit.mjs --json`: the audit
47
+ // CLI emits a >1 MB envelope that would have to be re-parsed, and its exit code
48
+ // speaks a different dialect (3 = hard limit broken under `--strict`) than the
49
+ // 0/1/2 this validator family uses. Importing skips both translations.
50
+ //
51
+ // ## Language
52
+ //
53
+ // Comments and structural output are English, matching the 35 sibling
54
+ // validators in this directory. Quoted finding text is German because it comes
55
+ // verbatim from clarity.mjs, where it is written for the operator on purpose —
56
+ // re-wording it here would be exactly the second copy this file avoids.
57
+ //
58
+ // Usage: check-auq-clarity.mjs <plugin-root> [--file <repo-relative-path>]...
59
+ //
60
+ // `--file` restricts the corpus to the named files and bypasses `git ls-files`.
61
+ // Its purpose is testability: the default enumeration is git-backed, so inside a
62
+ // throwaway fixture directory (no git) the corpus would otherwise be empty. The
63
+ // orchestrator never passes it.
64
+ //
65
+ // Outputs lines of the form " PASS: ..." / " FAIL: ..." / " WARN: ..." plus a
66
+ // "Results: N passed, M failed" line so the validate-plugin orchestrator's
67
+ // PASS:/FAIL: tally counts it.
68
+ //
69
+ // Exit codes:
70
+ // 0 — clean (no question breaks a hard limit)
71
+ // 1 — at least one question breaks a hard limit, or the corpus is empty
72
+ // 2 — tool error (missing arg / unreadable root)
73
+
74
+ import { parseRepo } from '../auq/parse.mjs';
75
+ import { scoreBlocks } from '../auq/clarity.mjs';
76
+ import { HURDLES, HURDLE_IDS, POPULATIONS } from '../auq/schema.mjs';
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Argument parsing
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Splits argv into the plugin root and the optional `--file` restriction list.
84
+ *
85
+ * Deliberately NOT exported: this module's body runs the check on import, so an
86
+ * importing test would trigger a `process.exit()` instead of getting a function.
87
+ * Argument handling is covered through the subprocess, like the sibling checks.
88
+ *
89
+ * @param {string[]} argv - arguments after `node <script>`
90
+ * @returns {{root: string|null, files: string[], error: string|null}}
91
+ */
92
+ function parseCheckArgs(argv) {
93
+ let root = null;
94
+ const files = [];
95
+ for (let i = 0; i < argv.length; i++) {
96
+ const arg = argv[i];
97
+ if (arg === '--file') {
98
+ const value = argv[i + 1];
99
+ if (value === undefined || value.startsWith('--')) {
100
+ return { root, files, error: '--file needs a repo-relative path' };
101
+ }
102
+ files.push(value);
103
+ i++;
104
+ continue;
105
+ }
106
+ if (arg.startsWith('--')) return { root, files, error: `unknown flag: ${arg}` };
107
+ if (root === null) root = arg;
108
+ }
109
+ return { root, files, error: null };
110
+ }
111
+
112
+ const parsed = parseCheckArgs(process.argv.slice(2));
113
+
114
+ if (parsed.error) {
115
+ console.error(`Usage: check-auq-clarity.mjs <plugin-root> [--file <path>]... (${parsed.error})`);
116
+ process.exit(2);
117
+ }
118
+ if (!parsed.root) {
119
+ console.error('Usage: check-auq-clarity.mjs <plugin-root> [--file <path>]...');
120
+ process.exit(2);
121
+ }
122
+
123
+ let passed = 0;
124
+ let failed = 0;
125
+ let warned = 0;
126
+
127
+ function pass(msg) {
128
+ console.log(` PASS: ${msg}`);
129
+ passed++;
130
+ }
131
+
132
+ function fail(msg) {
133
+ console.log(` FAIL: ${msg}`);
134
+ failed++;
135
+ }
136
+
137
+ function warn(msg) {
138
+ console.log(` WARN: ${msg}`);
139
+ warned++;
140
+ }
141
+
142
+ // ============================================================================
143
+ // Check: AUQ hard limits (H1/H2) across the whole question corpus
144
+ // ============================================================================
145
+ console.log('--- Check: AUQ question corpus hard limits (H1 header, H2 options) ---');
146
+
147
+ let repo;
148
+ try {
149
+ repo = parseRepo({
150
+ repoRoot: parsed.root,
151
+ ...(parsed.files.length > 0 ? { files: parsed.files } : {}),
152
+ });
153
+ } catch (err) {
154
+ console.error(` tool-error: cannot parse the AUQ corpus: ${err?.message ?? String(err)}`);
155
+ process.exit(2);
156
+ }
157
+
158
+ // Read warnings are surfaced, never swallowed: each one is a template that
159
+ // silently left the corpus, and a shrinking corpus is how this guard would fade
160
+ // to a decoy without anyone noticing. WARN (not FAIL) matches the sibling
161
+ // convention and never touches the exit code.
162
+ for (const w of repo.warnings) warn(`AUQ corpus: ${w}`);
163
+
164
+ const scores = scoreBlocks(repo.blocks);
165
+
166
+ // --- Anti-decoy guard -------------------------------------------------------
167
+ // A validator that is wired up but measures nothing is indistinguishable from a
168
+ // green one, and that is precisely the failure this file was written to end. An
169
+ // empty corpus therefore FAILS rather than passing vacuously: it means the
170
+ // enumeration broke (git unavailable, corpus prefixes moved, `--file` pointed
171
+ // outside the corpus), never that the repo is clean.
172
+ if (scores.length === 0) {
173
+ fail(
174
+ 'the AUQ corpus is empty — 0 questions found, so this check measured nothing. ' +
175
+ 'Either the file enumeration broke (git ls-files unavailable, or scripts/lib/auq/parse.mjs ' +
176
+ 'corpus prefixes no longer match the tree) or a --file argument pointed outside the corpus. ' +
177
+ 'A silently empty corpus is a decoy gate, not a clean repo.',
178
+ );
179
+ console.log('');
180
+ console.log(`Results: ${passed} passed, ${failed} failed, ${warned} warned`);
181
+ process.exit(1);
182
+ }
183
+
184
+ // --- The two hard limits ----------------------------------------------------
185
+ // Note what is NOT consulted here: `score.points`, `score.grade`, and
186
+ // `score.findings[].severity === 'warn'`. Only `hurdlesBroken` drives the exit
187
+ // code. This is also what makes the `optionCountUnknown` case correct by
188
+ // construction: three-plus templates in the corpus end in an ellipsis rather
189
+ // than a full option list, so their true option count is UNKNOWN. clarity.mjs
190
+ // records that as a warn-level note and deliberately breaks NO hurdle for it
191
+ // ("not checkable" is not "violated"). Keying on `hurdlesBroken` inherits that
192
+ // judgement; keying on findings would invent violations that do not exist.
193
+ const brokenByHurdle = new Map(HURDLE_IDS.map((id) => [id, []]));
194
+
195
+ for (const score of scores) {
196
+ for (const id of score.hurdlesBroken) {
197
+ if (!brokenByHurdle.has(id)) brokenByHurdle.set(id, []);
198
+ brokenByHurdle.get(id).push(score);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * The operator-facing reason a question broke a hurdle, taken verbatim from the
204
+ * finding clarity.mjs already produced.
205
+ *
206
+ * Filtered on the finding's OWN `hurdle` tag, not on the hurdle's criterion.
207
+ * The criterion filter this used to carry over-reached: K6 produces four
208
+ * finding classes (description length, label length, payload size, option
209
+ * count) and only the last one breaks H2, so a FAIL line here could name a
210
+ * description that is 186 characters long — a finding that breaks nothing —
211
+ * while the real option-count break sat behind it. The same over-reach was
212
+ * measured in `hooks/pre-auq-clarity.mjs` and is fixed there too; both
213
+ * consumers had reconstructed a mapping that `clarity.mjs` already knew.
214
+ *
215
+ * The criterion path stays as a fallback so an older scorer that emits
216
+ * untagged findings degrades to the previous behaviour instead of producing a
217
+ * silent empty reason. Found by this session's architecture review (W4-Q7).
218
+ *
219
+ * @param {import('../auq/schema.mjs').AuqScore} score
220
+ * @param {string} hurdleId
221
+ * @returns {string}
222
+ */
223
+ function reasonFor(score, hurdleId) {
224
+ const criterion = HURDLES[hurdleId]?.criterion;
225
+ const tagged = score.findings.filter((f) => f.hurdle === hurdleId && f.severity === 'fail');
226
+ const pool = tagged.length > 0
227
+ ? tagged
228
+ : score.findings.filter((f) => f.criterion === criterion && f.severity === 'fail');
229
+ const messages = pool.map((f) => f.message);
230
+ return messages.length > 0 ? messages.join(' ') : '(no message recorded)';
231
+ }
232
+
233
+ for (const id of HURDLE_IDS) {
234
+ const offenders = brokenByHurdle.get(id) ?? [];
235
+ const hurdle = HURDLES[id];
236
+
237
+ if (offenders.length === 0) {
238
+ // Deliberately reports the DENOMINATOR too. A "0 violations" line is equally
239
+ // true of a corpus of zero questions, which is the state the anti-decoy
240
+ // guard above exists to catch — printing what was actually measured keeps
241
+ // the two distinguishable at a glance.
242
+ pass(
243
+ `${id} (${hurdle.title}) — 0 of ${scores.length} questions break this limit ` +
244
+ `[${hurdle.rule}]`,
245
+ );
246
+ continue;
247
+ }
248
+
249
+ for (const score of offenders) {
250
+ fail(
251
+ `${score.file}:${score.line} (question ${score.questionIndex + 1}) breaks hard limit ${id} ` +
252
+ `— ${hurdle.title}. ${reasonFor(score, id)} See .claude/rules/ask-via-tool.md (AUQ-003) ` +
253
+ 'and run `node scripts/auq-audit.mjs .` for the full report.',
254
+ );
255
+ }
256
+ }
257
+
258
+ // --- Corpus census (informational) ------------------------------------------
259
+ // Deliberately NOT routed through pass(): this is bookkeeping, not an audited
260
+ // invariant, and must not inflate the passed count the orchestrator tallies.
261
+ //
262
+ // It is printed because the most plausible way this guard degrades is by
263
+ // quietly narrowing to `.md`. The six populations include `.cursor/rules/*.mdc`
264
+ // — the only surface a Cursor operator ever sees — and runtime questions inside
265
+ // `.mjs`. A checker that read markdown only would hand those a clean bill of
266
+ // health while never opening them; a per-population line makes that visible
267
+ // instead of invisible.
268
+ const census = POPULATIONS.map((p) => `${p}:${repo.corpus[p] ?? 0}`).join(' ');
269
+ console.log(` (corpus: ${scores.length} questions in ${repo.blocks.length} blocks — ${census})`);
270
+
271
+ console.log('');
272
+ console.log(`Results: ${passed} passed, ${failed} failed, ${warned} warned`);
273
+
274
+ process.exit(failed > 0 ? 1 : 0);