session-orchestrator 3.23.0 → 3.24.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/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
|
@@ -60,6 +60,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
60
60
|
import { emitEvent, sessionAttribution } from './events.mjs';
|
|
61
61
|
import { tryAcquireFileLock, releaseFileLock } from './file-lock.mjs';
|
|
62
62
|
import { readLock, isLockLive } from './session-lock.mjs';
|
|
63
|
+
import { resolveSubagentSidecar } from '../../hooks/_lib/subagent-paths.mjs';
|
|
63
64
|
|
|
64
65
|
const DEFAULT_INTERVAL_S = 2;
|
|
65
66
|
const EVENTS_FILE_REL = '.orchestrator/metrics/events.jsonl';
|
|
@@ -606,13 +607,32 @@ function mayCarrySignal(line) {
|
|
|
606
607
|
}
|
|
607
608
|
|
|
608
609
|
/**
|
|
609
|
-
*
|
|
610
|
+
* Read the agent TYPE from the sidecar `agent-<id>.meta.json` the harness
|
|
611
|
+
* writes next to the subagent transcript. `agentId` here comes from a
|
|
612
|
+
* `readdirSync()` filename match (tailLoop below), not from untrusted stdin —
|
|
613
|
+
* but it is validated through the SAME consolidated derivation (#1196) as the
|
|
614
|
+
* hook-side copies for consistency: an id readdirSync happened to list that
|
|
615
|
+
* fails `{1,64}`/charset/`'unknown'` short-circuits to the honest `'unknown'`
|
|
616
|
+
* return, same as any other lookup failure.
|
|
617
|
+
*
|
|
618
|
+
* `projectsDir`/`sessionId` reconstruct the PARENT transcript path
|
|
619
|
+
* (`<projectsDir>/<sessionId>.jsonl`) that `resolveSubagentSidecar()` expects —
|
|
620
|
+
* the real on-disk location of the coordinator's own transcript, sibling of
|
|
621
|
+
* the `subagents/` directory this function reads from.
|
|
622
|
+
*
|
|
623
|
+
* @param {string} projectsDir
|
|
624
|
+
* @param {string} sessionId
|
|
610
625
|
* @param {string} agentId
|
|
611
626
|
* @returns {string}
|
|
612
627
|
*/
|
|
613
|
-
function readAgentType(
|
|
628
|
+
function readAgentType(projectsDir, sessionId, agentId) {
|
|
629
|
+
const sidecar = resolveSubagentSidecar({
|
|
630
|
+
transcriptPath: join(projectsDir, `${sessionId}.jsonl`),
|
|
631
|
+
agentId,
|
|
632
|
+
});
|
|
633
|
+
if (sidecar === null) return 'unknown';
|
|
614
634
|
try {
|
|
615
|
-
const meta = JSON.parse(readFileSync(
|
|
635
|
+
const meta = JSON.parse(readFileSync(sidecar.meta, 'utf8'));
|
|
616
636
|
const t = meta?.agentType;
|
|
617
637
|
return typeof t === 'string' && t ? t : 'unknown';
|
|
618
638
|
} catch {
|
|
@@ -705,7 +725,7 @@ async function tailLoop({ intervalS }) {
|
|
|
705
725
|
offsets.set(file, tick.offset);
|
|
706
726
|
if (tick.lines.length === 0) continue;
|
|
707
727
|
const agentId = file.slice('agent-'.length, -'.jsonl'.length);
|
|
708
|
-
if (!agentTypes.has(agentId)) agentTypes.set(agentId, readAgentType(
|
|
728
|
+
if (!agentTypes.has(agentId)) agentTypes.set(agentId, readAgentType(projectsDir, sessionId, agentId));
|
|
709
729
|
for (const line of tick.lines) {
|
|
710
730
|
if (!mayCarrySignal(line)) continue;
|
|
711
731
|
let rec;
|
|
@@ -13,12 +13,20 @@
|
|
|
13
13
|
* After the aggregate is published, per-agent declarations left behind by an
|
|
14
14
|
* earlier materialization of the SAME wave are reconciled away (#1103) — but
|
|
15
15
|
* only against a proven session owner. See {@link reconcileOrphans}.
|
|
16
|
+
*
|
|
17
|
+
* PEER RECORDS (`peer-session-*`, #1195) get NO per-agent file: shape (a) is
|
|
18
|
+
* `$AGENT_FILESCOPE_JSON`, addressed by an agent id at dispatch time, and no
|
|
19
|
+
* agent is ever dispatched for a peer session — nothing reads it. They are
|
|
20
|
+
* still written into the aggregate, where `--assert-disjoint` and
|
|
21
|
+
* `hooks/post-bash-write-verify.mjs` do read them; `--union` excludes them so
|
|
22
|
+
* `allowedPaths` never grants a peer's territory to this wave's agents.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import { readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
19
26
|
import { resolve } from 'node:path';
|
|
20
27
|
import { fileURLToPath } from 'node:url';
|
|
21
28
|
import { writeJsonAtomicSync } from './lib/io.mjs';
|
|
29
|
+
import { isPeerRecordId } from './lib/scope-gate.mjs';
|
|
22
30
|
|
|
23
31
|
const HELP = `Usage: node scripts/materialize-wave-scope.mjs --state-dir <dir> --wave <positive-int> [--json]
|
|
24
32
|
|
|
@@ -50,6 +58,9 @@ Examples:
|
|
|
50
58
|
|
|
51
59
|
Writes:
|
|
52
60
|
<state-dir>/filescopes/wave-N/<id>.json Bare string[] for each record
|
|
61
|
+
EXCEPT a peer-session-* record (#1195), which is
|
|
62
|
+
aggregate-only: no agent is dispatched for it, so nothing
|
|
63
|
+
would read its per-agent file.
|
|
53
64
|
<state-dir>/filescopes/wave-N.scopes.json Aggregate [{id, files}, ...]
|
|
54
65
|
|
|
55
66
|
Removes (only with a proven owner — see --session):
|
|
@@ -368,7 +379,12 @@ export function materializeWaveScope(records, {
|
|
|
368
379
|
}) {
|
|
369
380
|
const scopeDir = resolve(stateDir, 'filescopes', `wave-${wave}`);
|
|
370
381
|
const aggregatePath = resolve(stateDir, 'filescopes', `wave-${wave}.scopes.json`);
|
|
371
|
-
|
|
382
|
+
// #1195 — peer records are aggregate-only (see the header note): no reader
|
|
383
|
+
// exists for a per-agent file that no dispatch will ever address. Excluding
|
|
384
|
+
// them from `keepIds` below also lets a stale `peer-session-*.json` written
|
|
385
|
+
// before this rule be reconciled away like any other orphan.
|
|
386
|
+
const agentRecords = records.filter((r) => !isPeerRecordId(r.id));
|
|
387
|
+
const perAgentPaths = agentRecords.map(({ id }) => resolve(scopeDir, `${id}.json`));
|
|
372
388
|
|
|
373
389
|
try {
|
|
374
390
|
unlinkSync(aggregatePath);
|
|
@@ -378,8 +394,8 @@ export function materializeWaveScope(records, {
|
|
|
378
394
|
}
|
|
379
395
|
}
|
|
380
396
|
|
|
381
|
-
for (let index = 0; index <
|
|
382
|
-
const result = writeJson(perAgentPaths[index],
|
|
397
|
+
for (let index = 0; index < agentRecords.length; index++) {
|
|
398
|
+
const result = writeJson(perAgentPaths[index], agentRecords[index].files, { tmpPrefix: '.materialize-wave-scope' });
|
|
383
399
|
if (!result?.ok) {
|
|
384
400
|
throw new WriteError(`cannot write per-agent declaration ${perAgentPaths[index]}: ${result?.error ?? 'unknown write failure'}`);
|
|
385
401
|
}
|
|
@@ -396,7 +412,7 @@ export function materializeWaveScope(records, {
|
|
|
396
412
|
// no aggregate at all — deleting coverage that nothing replaced.
|
|
397
413
|
const { removed, retained } = reconcileOrphans({
|
|
398
414
|
scopeDir,
|
|
399
|
-
keepIds:
|
|
415
|
+
keepIds: agentRecords.map(({ id }) => id),
|
|
400
416
|
ownerIds: manifestSessionIds(stateDir, readFile),
|
|
401
417
|
sessionId: session,
|
|
402
418
|
readDir,
|
|
@@ -46,6 +46,8 @@ import { dirname, join, isAbsolute } from 'node:path';
|
|
|
46
46
|
import { fileURLToPath } from 'node:url';
|
|
47
47
|
|
|
48
48
|
import { isWaveAgentContext, WAVE_AGENT_ENV_VAR, WAVE_AGENT_ENV_VALUE } from './lib/wave-context.mjs';
|
|
49
|
+
import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
|
|
50
|
+
import { readLockDetailed } from './lib/session-lock.mjs';
|
|
49
51
|
|
|
50
52
|
// ---------------------------------------------------------------------------
|
|
51
53
|
// Constants & defaults
|
|
@@ -259,11 +261,14 @@ const confidence = confidenceVal;
|
|
|
259
261
|
|
|
260
262
|
let frontmatter = {};
|
|
261
263
|
let stateMdReadOk = false;
|
|
264
|
+
/** Directory holding STATE.md — the same state-dir the wave manifest lives in (#1166). */
|
|
265
|
+
let stateDir = null;
|
|
262
266
|
|
|
263
267
|
try {
|
|
264
268
|
// resolveStateMdPath from state-md.mjs: falls back to .claude/STATE.md
|
|
265
269
|
const stateMdMod = await import('./lib/state-md.mjs');
|
|
266
270
|
const stateMdPath = stateMdMod.resolveStateMdPath(process.cwd());
|
|
271
|
+
stateDir = dirname(stateMdPath);
|
|
267
272
|
|
|
268
273
|
if (!existsSync(stateMdPath)) {
|
|
269
274
|
if (!dryRun) {
|
|
@@ -352,17 +357,136 @@ if (!dryRun && (currentWaveRaw === undefined || currentWaveRaw === null || curre
|
|
|
352
357
|
}
|
|
353
358
|
|
|
354
359
|
// ---------------------------------------------------------------------------
|
|
355
|
-
// Step 3 — Build wave ID
|
|
356
|
-
// when !dryRun; falls back to a 'W-dryrun' placeholder under --dry-run when
|
|
357
|
-
// STATE.md was absent/unparseable/missing the field — #741.3)
|
|
360
|
+
// Step 3 — Build the RUNNING wave ID (#1166)
|
|
358
361
|
// ---------------------------------------------------------------------------
|
|
362
|
+
//
|
|
363
|
+
// #1166 — two writers, two semantics: `skills/wave-executor/wave-loop.md` § 3a
|
|
364
|
+
// writes STATE.md `current-wave` = the JUST-COMPLETED wave, while the
|
|
365
|
+
// coordinator's `<state-dir>/wave-scope.json` carries `wave` = the RUNNING
|
|
366
|
+
// wave. Stamping proposals off `current-wave` verbatim filed every wave-N+1
|
|
367
|
+
// proposal into the W<N> quota bucket. Priority: the manifest ONLY when it is
|
|
368
|
+
// bound to THIS session, else `current-wave + 1`. An UNBOUND manifest is not
|
|
369
|
+
// trusted (#1177 FX1) — since #1123 both writers stamp the binding, so a
|
|
370
|
+
// binding-less manifest is a peer's or a stale artefact.
|
|
371
|
+
//
|
|
372
|
+
// The session binding compares `manifest.semantic_session` — the SEMANTIC label
|
|
373
|
+
// — against the semantic id looked up in `session.lock`, and that lookup is only
|
|
374
|
+
// honoured when this PROCESS's own raw session id equals the lock's raw
|
|
375
|
+
// `session_id` (#1188). STATE.md's `session` field is NOT a witness here: it is
|
|
376
|
+
// written by the lock owner, so under a peer's lock both "sides" name the peer.
|
|
377
|
+
|
|
378
|
+
/** @returns {string|undefined} */
|
|
379
|
+
function resolveRunningWaveId() {
|
|
380
|
+
if (stateDir !== null) {
|
|
381
|
+
try {
|
|
382
|
+
const manifestPath = join(stateDir, 'wave-scope.json');
|
|
383
|
+
if (existsSync(manifestPath)) {
|
|
384
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
385
|
+
const boundSession = manifest?.semantic_session ?? manifest?.session;
|
|
386
|
+
const unbound =
|
|
387
|
+
boundSession === undefined || boundSession === null || boundSession === '';
|
|
388
|
+
// BOTH sides must be non-empty strings. `undefined === undefined` is
|
|
389
|
+
// true, so a STATE.md WITHOUT a `session` key previously "matched" any
|
|
390
|
+
// manifest whose `semantic_session` was also absent-but-present-in-the
|
|
391
|
+
// -comparison — adopting a foreign coordinator's wave number as its own.
|
|
392
|
+
const mineSide = manifest?.semantic_session;
|
|
393
|
+
// #1188 — STATE.md is a working-copy artefact of the LOCK OWNER, not a
|
|
394
|
+
// process-local witness: when a peer holds the lock the peer wrote BOTH
|
|
395
|
+
// STATE.md and the manifest, so the two "independent" sides agree about
|
|
396
|
+
// the PEER. Same class as #1177 FX1; same shape as attributionForRecord
|
|
397
|
+
// (scripts/lib/events.mjs): the lock is a raw->semantic LOOKUP,
|
|
398
|
+
// authorised by a process-local match on the RAW id. Measured
|
|
399
|
+
// 2026-09-02: a wave-agent process carries the COORDINATOR's raw uuid in
|
|
400
|
+
// CLAUDE_CODE_SESSION_ID and no semantic id at all, so comparing the
|
|
401
|
+
// process-local id against `semantic_session` directly never matches.
|
|
402
|
+
// CEILING (BV-004): a harness exporting no session id (Codex CLI,
|
|
403
|
+
// Cursor) resolves no process-local id -> binding unprovable ->
|
|
404
|
+
// current-wave + 1 fallback with a stderr line. REVISIT if that fallback
|
|
405
|
+
// rate is measured non-trivial in events.jsonl.
|
|
406
|
+
const lock = readLockDetailed({ repoRoot: process.cwd() });
|
|
407
|
+
const processLocal = readProcessLocalSessionIds();
|
|
408
|
+
const lockRaw =
|
|
409
|
+
lock.status === 'ok' ? String(lock.lock.session_id ?? '').trim() : '';
|
|
410
|
+
const authorised = lockRaw !== '' && processLocal.includes(lockRaw);
|
|
411
|
+
const oursSide = authorised ? (lock.lock.semantic_session_id ?? '') : '';
|
|
412
|
+
if (!authorised) {
|
|
413
|
+
// A SILENT non-match is the exact failure mode this fix exists to make
|
|
414
|
+
// visible: without this line, a proposal bucketed by the fallback is
|
|
415
|
+
// indistinguishable from one bucketed by a trusted manifest.
|
|
416
|
+
process.stderr.write(
|
|
417
|
+
`⚠ memory-propose: session binding unprovable (session.lock ${lock.status}` +
|
|
418
|
+
`${lockRaw ? `, raw ${lockRaw}` : ''}; process-local ids: ` +
|
|
419
|
+
`${processLocal.length > 0 ? processLocal.join(', ') : 'none'}) — ` +
|
|
420
|
+
'ignoring wave-scope.json, falling back to current-wave + 1\n',
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
const mine =
|
|
424
|
+
typeof mineSide === 'string' && mineSide.length > 0 &&
|
|
425
|
+
typeof oursSide === 'string' && oursSide.length > 0 &&
|
|
426
|
+
mineSide === oursSide;
|
|
427
|
+
// An UNBOUND manifest is NOT trusted (#1177 FX1). It used to be, as
|
|
428
|
+
// "legacy" — but since #1123 BOTH writers stamp the binding, so a
|
|
429
|
+
// manifest without one today is a PEER's or a stale artefact, and
|
|
430
|
+
// trusting it files this session's proposals into a foreign quota
|
|
431
|
+
// bucket. Unbound → say so once on stderr and fall back.
|
|
432
|
+
if (unbound) {
|
|
433
|
+
process.stderr.write(
|
|
434
|
+
'memory-propose: wave-scope.json unbound (no semantic_session) — ignored\n',
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
if (mine) {
|
|
438
|
+
const raw = manifest?.wave;
|
|
439
|
+
const num =
|
|
440
|
+
typeof raw === 'number'
|
|
441
|
+
? raw
|
|
442
|
+
: typeof raw === 'string' && raw.trim() !== ''
|
|
443
|
+
? Number(raw)
|
|
444
|
+
: NaN;
|
|
445
|
+
if (Number.isInteger(num)) return `W${num}`;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} catch (err) {
|
|
449
|
+
// Unreadable / malformed manifest → fall through to the +1 fallback, but
|
|
450
|
+
// SAY SO. A swallowed parse error makes a wrongly-bucketed proposal look
|
|
451
|
+
// like ordinary fallback behaviour; the operator then has no signal that
|
|
452
|
+
// the coordinator's own manifest is corrupt. Diagnostics on stderr keeps
|
|
453
|
+
// the stdout JSON contract intact (cli-design.md § JSON-First Output).
|
|
454
|
+
process.stderr.write(
|
|
455
|
+
`⚠ memory-propose: cannot read ${join(stateDir, 'wave-scope.json')} ` +
|
|
456
|
+
`(${err?.message ?? String(err)}) — falling back to current-wave + 1\n`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (currentWaveRaw === undefined || currentWaveRaw === null || currentWaveRaw === '') {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
const completed = Number(currentWaveRaw);
|
|
465
|
+
if (!Number.isFinite(completed)) return undefined; // never 'WNaN'
|
|
466
|
+
// CEILING (BV-004): `+1` assumes waves run consecutively and that STATE.md is
|
|
467
|
+
// maintained — it is WRONG when a wave is skipped, when the plan is adapted
|
|
468
|
+
// mid-session, or under `persistence: false` where `current-wave` never
|
|
469
|
+
// advances. It is the fallback precisely because wave-scope.json is the only
|
|
470
|
+
// artefact that KNOWS the running wave.
|
|
471
|
+
// REVISIT when a wave-scope.json manifest is present in every dispatch (then
|
|
472
|
+
// delete the fallback and reject instead), or when a proposal is observed in
|
|
473
|
+
// the wrong quota bucket while the manifest was readable.
|
|
474
|
+
return `W${completed + 1}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const resolvedWaveId = resolveRunningWaveId();
|
|
359
478
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
479
|
+
if (!dryRun && resolvedWaveId === undefined) {
|
|
480
|
+
exit(
|
|
481
|
+
{
|
|
482
|
+
status: STATUS.REJECTED_WRONG_CONTEXT,
|
|
483
|
+
detail: `Cannot resolve the running wave: no usable wave-scope.json and STATE.md 'current-wave' is not numeric (got '${currentWaveRaw}')`,
|
|
484
|
+
},
|
|
485
|
+
3,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
364
488
|
|
|
365
|
-
const waveId = dryRun ? (
|
|
489
|
+
const waveId = dryRun ? (resolvedWaveId ?? 'W-dryrun') : resolvedWaveId;
|
|
366
490
|
|
|
367
491
|
// ---------------------------------------------------------------------------
|
|
368
492
|
// Step 4 — Read Session Config (quota + floor)
|
|
@@ -23,6 +23,7 @@ import { homedir } from 'node:os';
|
|
|
23
23
|
import { spawnSync } from 'node:child_process';
|
|
24
24
|
import { getCrossRepoProjects, getConfinementRoot } from './lib/config/cross-repo.mjs';
|
|
25
25
|
import { validatePathInsideProject } from './lib/path-utils.mjs';
|
|
26
|
+
import { expandTilde } from './lib/common.mjs';
|
|
26
27
|
|
|
27
28
|
const COMMIT_MSG = 'chore(orchestrator): Promote vault-integration to strict mode — refs #305';
|
|
28
29
|
|
|
@@ -53,18 +54,6 @@ const noBaseline = args.includes('--no-baseline');
|
|
|
53
54
|
const repoIdx = args.indexOf('--repo');
|
|
54
55
|
const singleRepo = repoIdx !== -1 && args[repoIdx + 1] ? args[repoIdx + 1] : null;
|
|
55
56
|
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Path resolution
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
/** Expand leading ~ to the user's home directory. */
|
|
61
|
-
function expandHome(p) {
|
|
62
|
-
if (p.startsWith('~/')) {
|
|
63
|
-
return join(homedir(), p.slice(2));
|
|
64
|
-
}
|
|
65
|
-
return p;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
57
|
// ---------------------------------------------------------------------------
|
|
69
58
|
// Vault-integration mode replacement
|
|
70
59
|
// ---------------------------------------------------------------------------
|
|
@@ -424,7 +413,7 @@ async function main() {
|
|
|
424
413
|
|
|
425
414
|
if (singleRepo) {
|
|
426
415
|
// Single-repo mode
|
|
427
|
-
const absRepo =
|
|
416
|
+
const absRepo = expandTilde(singleRepo);
|
|
428
417
|
const singleRoot = getConfinementRoot();
|
|
429
418
|
const singleGuard = validatePathInsideProject(absRepo, singleRoot);
|
|
430
419
|
if (!singleGuard.ok) {
|
|
@@ -449,7 +438,7 @@ async function main() {
|
|
|
449
438
|
|
|
450
439
|
const batchRoot = getConfinementRoot();
|
|
451
440
|
for (const rawPath of eligibleRepos) {
|
|
452
|
-
const absRepo =
|
|
441
|
+
const absRepo = expandTilde(rawPath);
|
|
453
442
|
const guard = validatePathInsideProject(absRepo, batchRoot);
|
|
454
443
|
if (!guard.ok) {
|
|
455
444
|
process.stderr.write(
|
|
@@ -469,7 +458,7 @@ async function main() {
|
|
|
469
458
|
(p) => p === 'projects-baseline' || p.endsWith('/projects-baseline')
|
|
470
459
|
);
|
|
471
460
|
if (baselineEntry) {
|
|
472
|
-
const baselineRepoDir =
|
|
461
|
+
const baselineRepoDir = expandTilde(
|
|
473
462
|
baselineEntry.startsWith('~') || baselineEntry.startsWith('/')
|
|
474
463
|
? baselineEntry
|
|
475
464
|
: `~/Projects/${baselineEntry}`
|
package/scripts/site-numbers.mjs
CHANGED
|
@@ -79,6 +79,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
79
79
|
import { fileURLToPath } from 'node:url';
|
|
80
80
|
|
|
81
81
|
import { writeStdoutLineSync, writeJsonAtomicSync } from './lib/io.mjs';
|
|
82
|
+
import { readCanonicalSessions } from './lib/sessions-canonical.mjs';
|
|
82
83
|
|
|
83
84
|
/** Machine-readable schema tag for the --json envelope. */
|
|
84
85
|
export const SCHEMA = 'site-numbers/1';
|
|
@@ -401,10 +402,31 @@ export const METRIC_DEFS = Object.freeze([
|
|
|
401
402
|
// Gitignored ledger (`.gitignore`: local-only observability data) — absent
|
|
402
403
|
// in every fresh clone, CI checkout and tarball build.
|
|
403
404
|
snapshotFallback: true,
|
|
404
|
-
|
|
405
|
+
// #1186: `grep -c` over-counts — sessions.jsonl is append-only, so the same
|
|
406
|
+
// physical session can carry more than one line (crash-recovery re-appends,
|
|
407
|
+
// #1068 stub/supersede pairs). readCanonicalSessions collapses those to one
|
|
408
|
+
// record per session_id before the count; see scripts/lib/sessions-canonical.mjs
|
|
409
|
+
// for the three collapse rules. `learnings` below stays a raw line count —
|
|
410
|
+
// that ledger has no session_id-shaped identity to dedupe on.
|
|
411
|
+
// Measured on THIS repo's own ledger @ 2ccea0f2: raw line count 289 vs
|
|
412
|
+
// canonical (deduped) count 278 — an over-count of 11 records, which is the
|
|
413
|
+
// bug's magnitude this migration removes.
|
|
414
|
+
source: 'grep -c . .orchestrator/metrics/sessions.jsonl (then deduped per session_id — see readCanonicalSessions, #1186)',
|
|
405
415
|
compute: (root) => {
|
|
406
|
-
const
|
|
407
|
-
|
|
416
|
+
const file = join(root, '.orchestrator', 'metrics', 'sessions.jsonl');
|
|
417
|
+
// Same absent/non-file guard as countJsonlEntries, kept explicit here
|
|
418
|
+
// because readCanonicalSessions returns [] (not null) on a missing ledger
|
|
419
|
+
// — collapsing that into fmtCount(0) would silently defeat the
|
|
420
|
+
// snapshotFallback precedence above (a fresh clone would report "0
|
|
421
|
+
// sessions" instead of falling back to the tracked snapshot).
|
|
422
|
+
if (!existsSync(file) || !statSync(file).isFile()) return null;
|
|
423
|
+
// Same reader every other #1186 consumer uses (autopilot.mjs,
|
|
424
|
+
// build-live-signals.mjs, telemetry/sync.mjs) — a record with no
|
|
425
|
+
// `session_id` is dropped rather than counted, because it cannot be
|
|
426
|
+
// identified for dedup. Measured @ 2ccea0f2 on this repo's own ledger:
|
|
427
|
+
// 0 such rows (raw 289 lines == 289 identified), so the drop is inert
|
|
428
|
+
// here; it only matters for a future malformed/legacy row.
|
|
429
|
+
return fmtCount(readCanonicalSessions({ filePath: file }).length);
|
|
408
430
|
},
|
|
409
431
|
},
|
|
410
432
|
{
|
|
@@ -623,7 +645,17 @@ export function collect(root) {
|
|
|
623
645
|
]) {
|
|
624
646
|
const r = countJsonlEntries(join(root, '.orchestrator', 'metrics', file));
|
|
625
647
|
if (r && r.malformed > 0) {
|
|
626
|
-
|
|
648
|
+
// #1186: the two ledgers now disagree on what a malformed line DOES to
|
|
649
|
+
// the published count. `learnings` is still the raw countJsonlEntries
|
|
650
|
+
// total, which counts a malformed line as an entry regardless of its
|
|
651
|
+
// content. `sessions` is readCanonicalSessions, whose parse loop skips a
|
|
652
|
+
// line it cannot JSON.parse — so a malformed session line is silently
|
|
653
|
+
// ABSENT from the count rather than included in it.
|
|
654
|
+
const consequence =
|
|
655
|
+
name === 'sessions'
|
|
656
|
+
? `they are DROPPED from the sessions count (readCanonicalSessions skips unparseable lines)`
|
|
657
|
+
: `the ${name} count includes them`;
|
|
658
|
+
warnings.push(`${file}: ${r.malformed} non-empty line(s) are not valid JSON — ${consequence}`);
|
|
627
659
|
}
|
|
628
660
|
}
|
|
629
661
|
|
|
@@ -318,6 +318,16 @@ runCheck('check-vcs-repo-flag.mjs');
|
|
|
318
318
|
process.stdout.write('\n');
|
|
319
319
|
runCheck('check-doc-cli-commands.mjs');
|
|
320
320
|
|
|
321
|
+
// BLOCKING (#1176): a `scripts/**.mjs` path cited in skills/, commands/ or
|
|
322
|
+
// agents/ prose that does not exist. Unlike the WARN-only check above, the
|
|
323
|
+
// oracle is this repository's own filesystem — no version skew of a foreign
|
|
324
|
+
// binary can red an unrelated commit, so a finding is always a real defect.
|
|
325
|
+
// A deliberately absent path is annotated in place:
|
|
326
|
+
// `<!-- path-check: planned #<iid> | historical | example -->`, honoured on the
|
|
327
|
+
// citation's own line or the line immediately above.
|
|
328
|
+
process.stdout.write('\n');
|
|
329
|
+
if (runCheck('check-skill-script-paths.mjs') !== 0) checkFailed = 1;
|
|
330
|
+
|
|
321
331
|
// WARN-only: a state-mutating `git` call in tests/ that names no target resolves
|
|
322
332
|
// its destination from the ambient cwd (or an inherited GIT_DIR) — the 2026-08-19
|
|
323
333
|
// incident, where fixture commits, a fixture remote and a fixture identity landed
|
|
@@ -337,6 +347,22 @@ runCheck('check-doc-cli-commands.mjs');
|
|
|
337
347
|
process.stdout.write('\n');
|
|
338
348
|
runCheck('check-test-git-config-target.mjs');
|
|
339
349
|
|
|
350
|
+
// BLOCKING (#1183): every `emitEvent()` call site under hooks/**/*.mjs must be
|
|
351
|
+
// try/catch-guarded (emitEvent() throws EventValidationError on a malformed
|
|
352
|
+
// record — an unguarded call aborts the hook process). Five pre-existing
|
|
353
|
+
// unguarded sites are baselined inside the checker itself and report WARN,
|
|
354
|
+
// never FAIL, so this can be blocking-by-default without going red on
|
|
355
|
+
// arrival; only a NEW unguarded site fails the build.
|
|
356
|
+
process.stdout.write('\n');
|
|
357
|
+
if (runCheck('check-hooks-emit-event-guard.mjs') !== 0) checkFailed = 1;
|
|
358
|
+
|
|
359
|
+
// BLOCKING (#1184): every scripts/lib/validate/check-*.mjs must be referenced
|
|
360
|
+
// by basename from this file, .husky/pre-commit, or .gitlab-ci.yml — or carry
|
|
361
|
+
// a `// registration: standalone <reason>` header marker declaring itself
|
|
362
|
+
// deliberately CLI-only. A checker nobody runs is built work with no effect.
|
|
363
|
+
process.stdout.write('\n');
|
|
364
|
+
if (runCheck('check-validator-registration.mjs') !== 0) checkFailed = 1;
|
|
365
|
+
|
|
340
366
|
// ---------------------------------------------------------------------------
|
|
341
367
|
// Summary
|
|
342
368
|
// ---------------------------------------------------------------------------
|
|
@@ -84,10 +84,9 @@
|
|
|
84
84
|
|
|
85
85
|
import { promises as fs } from 'node:fs';
|
|
86
86
|
import path from 'node:path';
|
|
87
|
-
import os from 'node:os';
|
|
88
87
|
import { pathToFileURL } from 'node:url';
|
|
89
88
|
|
|
90
|
-
import { die, utcTimestamp } from './lib/common.mjs';
|
|
89
|
+
import { die, utcTimestamp, expandTilde } from './lib/common.mjs';
|
|
91
90
|
import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
|
|
92
91
|
import {
|
|
93
92
|
SCRIPT_NAME,
|
|
@@ -150,13 +149,6 @@ Exit codes:
|
|
|
150
149
|
process.stderr.write(usage);
|
|
151
150
|
}
|
|
152
151
|
|
|
153
|
-
function expandHome(p) {
|
|
154
|
-
if (typeof p !== 'string' || p.length === 0) return p;
|
|
155
|
-
if (p === '~') return os.homedir();
|
|
156
|
-
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
157
|
-
return p;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
152
|
async function isDir(p) {
|
|
161
153
|
try {
|
|
162
154
|
const st = await fs.stat(p);
|
|
@@ -222,8 +214,8 @@ async function main() {
|
|
|
222
214
|
const isDryRun = !isApply;
|
|
223
215
|
const isJson = parsed.values.json === true;
|
|
224
216
|
|
|
225
|
-
const sourceRoot = path.resolve(
|
|
226
|
-
const canonicalRoot = path.resolve(
|
|
217
|
+
const sourceRoot = path.resolve(expandTilde(parsed.values.source ?? DEFAULT_SOURCE));
|
|
218
|
+
const canonicalRoot = path.resolve(expandTilde(parsed.values.canonical ?? DEFAULT_CANONICAL));
|
|
227
219
|
|
|
228
220
|
// Parse --resolve flags into a Map<relPath, "src"|"dst"|"skip">
|
|
229
221
|
const resolutions = new Map();
|
|
@@ -31,11 +31,10 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { spawnSync } from 'node:child_process';
|
|
34
|
-
import { join } from 'node:path';
|
|
35
|
-
import { homedir } from 'node:os';
|
|
36
34
|
import { getCrossRepoProjects, getConfinementRoot } from './lib/config/cross-repo.mjs';
|
|
37
35
|
import { validatePathInsideProject } from './lib/path-utils.mjs';
|
|
38
36
|
import { resolveRepoSpec, redactUrlCredentials } from './lib/vcs-repo-spec.mjs';
|
|
37
|
+
import { expandTilde } from './lib/common.mjs';
|
|
39
38
|
|
|
40
39
|
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
41
40
|
|
|
@@ -332,9 +331,8 @@ async function main() {
|
|
|
332
331
|
// Load the config-driven repo list before doing any glab work
|
|
333
332
|
const watcherRoot = getConfinementRoot();
|
|
334
333
|
// Home-expand each entry before confinement, matching sibling scripts (W4-Q2 LOW).
|
|
335
|
-
const expandHome = (p) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p);
|
|
336
334
|
const flipRepos = (await getCrossRepoProjects()).filter((r) => {
|
|
337
|
-
const guard = validatePathInsideProject(
|
|
335
|
+
const guard = validatePathInsideProject(expandTilde(r), watcherRoot);
|
|
338
336
|
if (!guard.ok) {
|
|
339
337
|
process.stderr.write(
|
|
340
338
|
`vault-integration-watcher: WARN rejecting confined-path violation for ${JSON.stringify(r)} (reason: ${guard.reason})\n`
|