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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +101 -0
- package/README.md +10 -10
- package/agents/memory-proposal-collector.md +6 -4
- package/commands/eli5.md +33 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/scope-collision-guard.md +3 -3
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +24 -92
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +7 -8
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/ci-status-banner.mjs +63 -57
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +147 -50
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +30 -0
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +6 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +32 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/discovery/SKILL.md +28 -24
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/session-end/SKILL.md +53 -20
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-start/SKILL.md +69 -35
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +27 -5
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- 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 {
|
|
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:
|
|
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
|
|
87
|
-
*
|
|
88
|
-
* neither created nor an error, because `setMissionStatus(contents,
|
|
89
|
-
* knows only `id` and `status` — it lacks the `task` and `wave`
|
|
90
|
-
* carries, so a synthesised entry would be shape-invalid yet look
|
|
91
|
-
* frontmatter consumers such as `vault-status/narrative-mirror.mjs`.
|
|
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.
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
284
|
+
return serializeMissionStatusUpdate(frontmatter, bodyOut);
|
|
178
285
|
}
|
|
179
286
|
|
|
180
|
-
|
|
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
|
|
317
|
+
return serializeMissionStatusUpdate(frontmatter, rebuilt.join('\n'));
|
|
218
318
|
}
|
|
219
319
|
}
|
|
220
320
|
|
|
221
|
-
return
|
|
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'`
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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);
|