session-orchestrator 5.9.0 → 5.11.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-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +65 -2
- package/README.md +11 -9
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/USER-GUIDE.md +2 -0
- package/docs/agent-authoring.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +37 -6
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +15 -2
- package/docs/session-config-reference.md +1 -1
- package/evals/instruction-ablation/run.mjs +11 -6
- package/hooks/_lib/hook-import-set.json +27 -2
- package/hooks/_lib/orchestrator-ignore.mjs +112 -0
- package/hooks/enforce-commands.mjs +9 -19
- package/hooks/enforce-scope.mjs +23 -42
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +8 -1
- package/hooks/on-session-start.mjs +7 -0
- package/hooks/post-bash-write-verify.mjs +54 -24
- package/hooks/pre-task-scope-disjoint.mjs +12 -0
- package/hooks/subagent-telemetry.mjs +8 -3
- package/hooks/wave-scope-commit-guard.mjs +4 -1
- package/package.json +2 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +28 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/named-vault-resolver.mjs +1 -1
- package/scripts/lib/owner-yaml.mjs +33 -3
- package/scripts/lib/platform.mjs +70 -1
- package/scripts/lib/pre-push-remote-gate.mjs +107 -0
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/quality-gate.mjs +6 -1
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/scope-gate.mjs +68 -5
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
- package/scripts/lib/vault-backfill/template.mjs +1 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
- package/scripts/lib/vault-status/board-writer.mjs +15 -7
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +463 -48
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +22 -9
- package/skills/npm-publish/SKILL.md +3 -3
- package/skills/release/SKILL.md +2 -2
- package/skills/repo-audit/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +28 -4
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -0
- package/skills/wave-executor/references/wave-loop-review.md +26 -2
|
@@ -226,6 +226,49 @@ function failedFilesFromGateStdout(stdout) {
|
|
|
226
226
|
return named.length > 0 ? named : null;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The reporting variants — they exit 0 even after a failing check, so their
|
|
231
|
+
* event NAME never carries the check verdict (#1459). `full-gate` exits non-zero
|
|
232
|
+
* on a failing check, so the name already says it there.
|
|
233
|
+
*/
|
|
234
|
+
const REPORTING_VARIANTS = new Set(['baseline', 'incremental', 'per-file']);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Did any check of a reporting-variant envelope fail? (#1487 point 3)
|
|
238
|
+
*
|
|
239
|
+
* Three states, never two — the same absent-is-not-zero contract as `counts`:
|
|
240
|
+
* - `true` — `typecheck` or `test` carries `status: 'fail'` (a killed command
|
|
241
|
+
* is published as `fail` too, see `runCheck`);
|
|
242
|
+
* - `false` — both command objects are present, neither is stubbed, at least
|
|
243
|
+
* one actually `pass`ed and none failed — the envelope MEASURED
|
|
244
|
+
* that nothing failed;
|
|
245
|
+
* - `null` — the envelope is unparseable or a command object is missing or
|
|
246
|
+
* carries an unknown status, a command is a stub (echo/noop
|
|
247
|
+
* reports `pass` without running), or both were skipped. The
|
|
248
|
+
* caller then OMITS the key: a
|
|
249
|
+
* default `false` would read as "measured green" for a run that
|
|
250
|
+
* measured nothing.
|
|
251
|
+
*
|
|
252
|
+
* Never throws.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} stdout — the gate sub-script's captured stdout.
|
|
255
|
+
* @returns {boolean|null}
|
|
256
|
+
*/
|
|
257
|
+
function checkFailedFromGateStdout(stdout) {
|
|
258
|
+
if (typeof stdout !== 'string' || !stdout.trim()) return null;
|
|
259
|
+
let parsed;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(stdout);
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const statuses = ['typecheck', 'test'].map((name) => parsed?.[name]?.status);
|
|
266
|
+
if (statuses.includes('fail')) return true;
|
|
267
|
+
if (parsed?.stubbed?.typecheck || parsed?.stubbed?.test) return null;
|
|
268
|
+
if (!statuses.every((st) => st === 'pass' || st === 'skip')) return null;
|
|
269
|
+
return statuses.includes('pass') ? false : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
229
272
|
/**
|
|
230
273
|
* Validate and resolve the `--ledger-root` flag (see the telemetry block below).
|
|
231
274
|
*
|
|
@@ -570,12 +613,16 @@ async function main() {
|
|
|
570
613
|
// record that run as `passed`. Their exit code stays 0 on purpose: it has
|
|
571
614
|
// never carried the check verdict there (a failing check exits 0 too).
|
|
572
615
|
const failed = exitCode !== 0 || killFields.timed_out === true;
|
|
616
|
+
// #1487 point 3 (owner decision, option a): the reporting variants keep
|
|
617
|
+
// `.passed` and their exit code, but carry the check verdict as a field.
|
|
618
|
+
const checkFailed = REPORTING_VARIANTS.has(variant) ? checkFailedFromGateStdout(gateStdout) : null;
|
|
573
619
|
await emitEvent(
|
|
574
620
|
`orchestrator.quality_gate.${failed ? 'failed' : 'passed'}`,
|
|
575
621
|
{
|
|
576
622
|
variant,
|
|
577
623
|
exit_code: exitCode,
|
|
578
624
|
...killFields,
|
|
625
|
+
...(checkFailed !== null ? { check_failed: checkFailed } : {}),
|
|
579
626
|
...(counts ? { counts } : {}),
|
|
580
627
|
...(failedFiles ? { failed_files: failedFiles } : {}),
|
|
581
628
|
...(waveNumber !== null ? { wave_number: waveNumber } : {}),
|
|
@@ -92,6 +92,7 @@ import {
|
|
|
92
92
|
assertFileScopeSubset,
|
|
93
93
|
assertTestSiblingCoverage,
|
|
94
94
|
testSiblingExpansionApplies,
|
|
95
|
+
testSiblingsFor,
|
|
95
96
|
TEST_SIBLING_EXPANSION_ROLES,
|
|
96
97
|
findScopeCollisions,
|
|
97
98
|
unionFileScopes,
|
|
@@ -663,6 +664,44 @@ function knownRepoFiles() {
|
|
|
663
664
|
}
|
|
664
665
|
}
|
|
665
666
|
|
|
667
|
+
/**
|
|
668
|
+
* WARN — never fail — when two agents' RAW scopes are disjoint but test-sibling
|
|
669
|
+
* expansion hands both the same synthesized glob (#1026 point 2, owner decision
|
|
670
|
+
* option a). `scripts/lib/foo.mjs` and `hooks/foo.mjs` share no file, yet both
|
|
671
|
+
* expand to `tests/**\/foo*.test.mjs`, so the union grants each agent the
|
|
672
|
+
* other's tests. Exit code is unchanged: the glob is a GRANT that may match no
|
|
673
|
+
* file at all, so failing the wave on it would block plans that never collide.
|
|
674
|
+
*
|
|
675
|
+
* `role` gates expansion exactly as `--union` does; `undefined` (sidecar-only
|
|
676
|
+
* `--no-manifest`, or a manifest without a role) checks anyway, because there
|
|
677
|
+
* the expansion that will run later is unknown and silence would hide it.
|
|
678
|
+
* Compares synthesized siblings only, by exact string — a sibling glob one agent
|
|
679
|
+
* DECLARED by hand is not matched (named ceiling; revisit if a wave plan is seen
|
|
680
|
+
* declaring sibling globs explicitly). Overlapping globs of DIFFERENT basenames
|
|
681
|
+
* (`foo*` also covers `foo-bar.test.mjs`) are not matched either.
|
|
682
|
+
*
|
|
683
|
+
* @param {Array<{id?: string, files: string[]}>} agentScopes
|
|
684
|
+
* @param {string|undefined} role
|
|
685
|
+
*/
|
|
686
|
+
function warnSiblingGlobCollisions(agentScopes, role) {
|
|
687
|
+
if (role !== undefined && !testSiblingExpansionApplies({ role })) return;
|
|
688
|
+
const owners = new Map(); // sibling glob -> agent ids, first-seen order
|
|
689
|
+
agentScopes.forEach((rec, i) => {
|
|
690
|
+
const id = typeof rec.id === 'string' && rec.id.length > 0 ? rec.id : `<unnamed#${i}>`;
|
|
691
|
+
for (const glob of testSiblingsFor(rec.files)) {
|
|
692
|
+
const ids = owners.get(glob) ?? [];
|
|
693
|
+
if (!ids.includes(id)) ids.push(id);
|
|
694
|
+
owners.set(glob, ids);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
for (const [glob, ids] of owners) {
|
|
698
|
+
if (ids.length < 2) continue;
|
|
699
|
+
warn(
|
|
700
|
+
`sibling-glob: agents ${ids.map((id) => `"${id}"`).join(', ')} are disjoint as declared but test-sibling expansion grants each of them ${glob} — split the same-basename files across waves or name the tests explicitly (#1026)`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
666
705
|
/**
|
|
667
706
|
* Assert that no file is claimed by two agents of the SAME wave (#1020).
|
|
668
707
|
* Exits 1 with one message per collision (plus one per duplicate agent id);
|
|
@@ -670,8 +709,8 @@ function knownRepoFiles() {
|
|
|
670
709
|
*
|
|
671
710
|
* @param {string} sidecarPath
|
|
672
711
|
*/
|
|
673
|
-
function assertDisjointOrDie(sidecarPath) {
|
|
674
|
-
if (!reportScopeCollisions(sidecarPath)) process.exit(1);
|
|
712
|
+
function assertDisjointOrDie(sidecarPath, role) {
|
|
713
|
+
if (!reportScopeCollisions(sidecarPath, role)) process.exit(1);
|
|
675
714
|
}
|
|
676
715
|
|
|
677
716
|
/**
|
|
@@ -683,12 +722,17 @@ function assertDisjointOrDie(sidecarPath) {
|
|
|
683
722
|
* @param {string} sidecarPath
|
|
684
723
|
* @returns {boolean} true when the wave is disjoint
|
|
685
724
|
*/
|
|
686
|
-
function reportScopeCollisions(sidecarPath) {
|
|
725
|
+
function reportScopeCollisions(sidecarPath, role) {
|
|
687
726
|
const agentScopes = readAgentScopesOrDie(sidecarPath, '--assert-disjoint');
|
|
688
727
|
const { ok, collisions, duplicateIds } = findScopeCollisions(agentScopes, {
|
|
689
728
|
knownFiles: knownRepoFiles(),
|
|
690
729
|
});
|
|
691
|
-
|
|
730
|
+
// Only a raw-disjoint wave gets the sibling hint: its text says the scopes
|
|
731
|
+
// are disjoint as declared, which is false once a real collision exists.
|
|
732
|
+
if (ok) {
|
|
733
|
+
warnSiblingGlobCollisions(agentScopes, role);
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
692
736
|
|
|
693
737
|
// Duplicate ids FIRST: they are a malformed plan, and a reader who fixes them
|
|
694
738
|
// may well change which collisions remain.
|
|
@@ -708,6 +752,15 @@ function reportScopeCollisions(sidecarPath) {
|
|
|
708
752
|
return false;
|
|
709
753
|
}
|
|
710
754
|
|
|
755
|
+
/**
|
|
756
|
+
* The manifest's `role` when it is a string, else `undefined` (unknown).
|
|
757
|
+
* @param {unknown} obj
|
|
758
|
+
* @returns {string|undefined}
|
|
759
|
+
*/
|
|
760
|
+
function roleOf(obj) {
|
|
761
|
+
return obj !== null && typeof obj === 'object' && typeof obj.role === 'string' ? obj.role : undefined;
|
|
762
|
+
}
|
|
763
|
+
|
|
711
764
|
/**
|
|
712
765
|
* QUERY MODE (#1020): print `expandTestSiblings(unionFileScopes(scopes), {role})`
|
|
713
766
|
* as JSON on stdout, using the MANIFEST'S own role. Replaces the manifest echo —
|
|
@@ -757,7 +810,7 @@ function validate(
|
|
|
757
810
|
}
|
|
758
811
|
// #1026.4 — the collision check reads only the sidecar, so a schema error
|
|
759
812
|
// must not hide its findings: report both, exit 1 either way.
|
|
760
|
-
if (assertDisjointPath) reportScopeCollisions(assertDisjointPath);
|
|
813
|
+
if (assertDisjointPath) reportScopeCollisions(assertDisjointPath, roleOf(obj));
|
|
761
814
|
process.exit(1);
|
|
762
815
|
}
|
|
763
816
|
|
|
@@ -771,7 +824,7 @@ function validate(
|
|
|
771
824
|
// violates BOTH the subset relation and disjointness must keep the older,
|
|
772
825
|
// byte-pinned subset message. Only ever ADDS a failure mode.
|
|
773
826
|
if (assertDisjointPath) {
|
|
774
|
-
assertDisjointOrDie(assertDisjointPath);
|
|
827
|
+
assertDisjointOrDie(assertDisjointPath, roleOf(obj));
|
|
775
828
|
}
|
|
776
829
|
|
|
777
830
|
// #1020 QUERY MODE — replaces the echo below; see the STDOUT CONTRACT note in
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* vault-consolidate.mjs — One-shot vault consolidation migration (PRD F1.1, Issue #499).
|
|
4
4
|
*
|
|
5
5
|
* Folds the redundant vault at `~/Projects/vault/` into the canonical
|
|
6
|
-
*
|
|
6
|
+
* host-configured destination, with per-file conflict resolution, idempotent
|
|
7
7
|
* re-runs, and a compressed backup of the source side BEFORE any write.
|
|
8
8
|
*
|
|
9
9
|
* Design — two-phase coordinator-driven flow for merge resolution
|
|
@@ -95,10 +95,10 @@ import {
|
|
|
95
95
|
stageBackup,
|
|
96
96
|
compressAndCleanupBackup,
|
|
97
97
|
} from './lib/vault-consolidate-fs.mjs';
|
|
98
|
+
import { loadHostPaths, resolveHostPath } from './lib/config/host-paths.mjs';
|
|
98
99
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
99
100
|
|
|
100
101
|
const DEFAULT_SOURCE = '~/Projects/vault';
|
|
101
|
-
const DEFAULT_CANONICAL = '~/Projects/Bernhard/vault';
|
|
102
102
|
|
|
103
103
|
// ---------------------------------------------------------------------------
|
|
104
104
|
// CLI parsing
|
|
@@ -122,7 +122,7 @@ Options:
|
|
|
122
122
|
--source <path> Path to the redundant (source) vault.
|
|
123
123
|
Default: ${DEFAULT_SOURCE}
|
|
124
124
|
--canonical <path> Path to the canonical vault.
|
|
125
|
-
Default:
|
|
125
|
+
Default: SO_VAULT_DIR or owner.yaml paths.vault-dir (required when source exists)
|
|
126
126
|
--json Newline-delimited JSON output (one record per
|
|
127
127
|
file, plus a final summary record).
|
|
128
128
|
--resolve <rel>=<choice> Record a merge decision for the file at the
|
|
@@ -215,7 +215,9 @@ async function main() {
|
|
|
215
215
|
const isJson = parsed.values.json === true;
|
|
216
216
|
|
|
217
217
|
const sourceRoot = path.resolve(expandTilde(parsed.values.source ?? DEFAULT_SOURCE));
|
|
218
|
-
const
|
|
218
|
+
const canonicalTarget = parsed.values.canonical ?? resolveHostPath('vault-dir', null, loadHostPaths());
|
|
219
|
+
const canonicalRoot = typeof canonicalTarget === 'string' && canonicalTarget.trim()
|
|
220
|
+
? path.resolve(expandTilde(canonicalTarget.trim())) : null;
|
|
219
221
|
|
|
220
222
|
// Parse --resolve flags into a Map<relPath, "src"|"dst"|"skip">
|
|
221
223
|
const resolutions = new Map();
|
|
@@ -255,6 +257,11 @@ async function main() {
|
|
|
255
257
|
process.exit(0);
|
|
256
258
|
}
|
|
257
259
|
|
|
260
|
+
if (!canonicalRoot) {
|
|
261
|
+
process.stderr.write('ERROR: canonical vault is unconfigured — pass --canonical or set SO_VAULT_DIR / owner.yaml paths.vault-dir\n');
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
|
|
258
265
|
if (!(await isDir(canonicalRoot))) {
|
|
259
266
|
process.stderr.write(
|
|
260
267
|
`ERROR: canonical vault not found at ${canonicalRoot} — refusing to consolidate\n`
|
|
@@ -262,6 +269,14 @@ async function main() {
|
|
|
262
269
|
process.exit(2);
|
|
263
270
|
}
|
|
264
271
|
|
|
272
|
+
if (await fs.realpath(sourceRoot) === await fs.realpath(canonicalRoot)) {
|
|
273
|
+
const summary = { kind:'summary', mode:isDryRun ? 'dry-run' : 'apply', source:sourceRoot,
|
|
274
|
+
canonical:canonicalRoot, counts:{copy:0,'skip-already-present':0,merge:0,'conflict-needs-review':0},
|
|
275
|
+
notice:'source and canonical resolve to the same vault — nothing to do' };
|
|
276
|
+
process.stdout.write(isJson ? JSON.stringify(summary) + '\n' : summary.notice + '\n');
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
|
|
265
280
|
// -------------------------------------------------------------------------
|
|
266
281
|
// Output helpers (close over isJson + the per-run records buffer)
|
|
267
282
|
// -------------------------------------------------------------------------
|
|
@@ -34,7 +34,7 @@ PHASE 1 IMPLEMENTED (2026-04-19). Session-end opt-in quality gate. Upstream of `
|
|
|
34
34
|
|
|
35
35
|
| # | Check | What it scans | How |
|
|
36
36
|
|---|-------|---------------|-----|
|
|
37
|
-
| 1 | `path-resolver` | Every absolute path `/Users/…` in scope files | `existsSync(path)` |
|
|
37
|
+
| 1 | `path-resolver` | Every absolute path `/Users/…` in scope files | `existsSync(path)`; missing paths under validated owner.yaml `drift-check.foreign-host-prefixes` are warnings (#937) |
|
|
38
38
|
| 2 | `project-count-sync` | Hardcoded "N registered" / "N projects" claims next to `01-projects/` | compare to `ls -d 01-projects/*/` |
|
|
39
39
|
| 3 | `issue-reference-freshness` | `#NN` in forward-looking sections (What's Next, Backlog, Open Issues, Offene Themen, Todo, Next Steps) | `glab issue view NN --repo <origin>` |
|
|
40
40
|
| 4 | `session-file-existence` | `50-sessions/YYYY-MM-DD-*.md` references anywhere in scope | `existsSync(vault/50-sessions/<file>)` |
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { readFileSync, readdirSync, existsSync, statSync, lstatSync, realpathSync } from 'node:fs';
|
|
34
|
-
import { join, relative, resolve } from 'node:path';
|
|
34
|
+
import { join, relative, resolve, posix } from 'node:path';
|
|
35
35
|
import { execFileSync } from 'node:child_process';
|
|
36
36
|
import { createRequire } from 'node:module';
|
|
37
37
|
import { resolveInstructionFile } from '../../scripts/lib/common.mjs';
|
|
@@ -41,6 +41,7 @@ import { isSessionConfigHeading } from '../../scripts/lib/config/section-extract
|
|
|
41
41
|
import { FRONTMATTER_NOT_AT_TOP, isRuleExpired, parseGlobsFrontmatter } from '../../scripts/lib/rule-loader.mjs';
|
|
42
42
|
import { resolveRepoSpec } from '../../scripts/lib/vcs-repo-spec.mjs';
|
|
43
43
|
import { userInvocableSkills } from '../../scripts/lib/user-invocable-skills.mjs';
|
|
44
|
+
import { loadOwnerConfig } from '../../scripts/lib/owner-yaml.mjs';
|
|
44
45
|
import { learningKeyOf } from '../../scripts/lib/learnings/kebab.mjs';
|
|
45
46
|
|
|
46
47
|
const FORWARD_HEADING_RE =
|
|
@@ -554,10 +555,11 @@ function buildSurfaceDescriptors(vaultDir, commandsDir) {
|
|
|
554
555
|
id: 'test-count',
|
|
555
556
|
noun: 'test files',
|
|
556
557
|
actual: countTestFiles(vaultDir),
|
|
557
|
-
// "N test files"
|
|
558
|
+
// "N test files" or "N+ test files" (an explicit lower bound).
|
|
559
|
+
// The README badge claims a runtime PASS count
|
|
558
560
|
// ("9303 tests"), which a static checker cannot derive, so we match only
|
|
559
|
-
// the explicit file-count phrasing.
|
|
560
|
-
claimRe: /\b(\d+)
|
|
561
|
+
// the explicit file-count phrasing.
|
|
562
|
+
claimRe: /\b(\d+)(\+)?\s+test\s+files?\b/gi,
|
|
561
563
|
skipMsg: 'test-count: no tests/ directory found',
|
|
562
564
|
},
|
|
563
565
|
];
|
|
@@ -1016,6 +1018,9 @@ function main() {
|
|
|
1016
1018
|
if (!args.skipSessionFiles) checksRun.push('session-file-existence');
|
|
1017
1019
|
for (const s of activeSurfaces) checksRun.push(s.id);
|
|
1018
1020
|
|
|
1021
|
+
const foreignHostPrefixes = args.skipPathResolver ? [] :
|
|
1022
|
+
(loadOwnerConfig().config['drift-check']?.['foreign-host-prefixes'] ?? [])
|
|
1023
|
+
.map((prefix) => prefix.replace(/\/+$/, ''));
|
|
1019
1024
|
const errors = [];
|
|
1020
1025
|
const warnings = [];
|
|
1021
1026
|
// Informational findings — never block, never count as a warning. Currently
|
|
@@ -1731,9 +1736,16 @@ function main() {
|
|
|
1731
1736
|
while ((m = pathRegex.exec(line)) !== null) {
|
|
1732
1737
|
const p = m[0].replace(/[.,;:)\]`"']+$/, '');
|
|
1733
1738
|
if (!existsSync(p)) {
|
|
1734
|
-
|
|
1739
|
+
// Lexical containment must use the effective POSIX path: '..' can
|
|
1740
|
+
// escape an allowed foreign directory. Keep p for diagnostics.
|
|
1741
|
+
const normalizedPath = posix.normalize(p);
|
|
1742
|
+
const foreign = foreignHostPrefixes.some((prefix) =>
|
|
1743
|
+
normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
|
|
1744
|
+
(foreign ? warnings : errors).push({
|
|
1735
1745
|
check: 'path-resolver', file: rel, line: lineNum,
|
|
1736
|
-
message:
|
|
1746
|
+
message: foreign
|
|
1747
|
+
? `Known foreign-host path does not exist on this host: ${p}`
|
|
1748
|
+
: `Absolute path does not exist: ${p}`,
|
|
1737
1749
|
extracted: p,
|
|
1738
1750
|
});
|
|
1739
1751
|
}
|
|
@@ -1820,7 +1832,7 @@ function main() {
|
|
|
1820
1832
|
}
|
|
1821
1833
|
}
|
|
1822
1834
|
|
|
1823
|
-
// Surface
|
|
1835
|
+
// Surface counts are exact, except explicit "N+ test files" lower bounds.
|
|
1824
1836
|
// A surface only fires when the doc makes an explicit numeric claim that
|
|
1825
1837
|
// its regex matches; an unclaimed surface produces no errors (skip).
|
|
1826
1838
|
for (const surface of activeSurfaces) {
|
|
@@ -1829,10 +1841,11 @@ function main() {
|
|
|
1829
1841
|
let m;
|
|
1830
1842
|
while ((m = re.exec(line)) !== null) {
|
|
1831
1843
|
const claimed = parseInt(m[1], 10);
|
|
1832
|
-
|
|
1844
|
+
const lowerBound = surface.id === 'test-count' && m[2] === '+';
|
|
1845
|
+
if (lowerBound ? surface.actual < claimed : claimed !== surface.actual) {
|
|
1833
1846
|
const err = {
|
|
1834
1847
|
check: surface.id, file: rel, line: lineNum,
|
|
1835
|
-
message: `Narrative claims ${claimed} ${surface.noun} but actual on-disk count is ${surface.actual}`,
|
|
1848
|
+
message: `Narrative claims ${claimed}${lowerBound ? '+' : ''} ${surface.noun} but actual on-disk count is ${surface.actual}`,
|
|
1836
1849
|
extracted: m[0],
|
|
1837
1850
|
count: { surface: surface.id, actual: surface.actual, claimed },
|
|
1838
1851
|
};
|
|
@@ -39,7 +39,7 @@ The script gates mechanics. These three are yours, and it will not make them for
|
|
|
39
39
|
|
|
40
40
|
**2. What a leak means when one is found.** A hit from the leakage gate is not a pattern to silence. Decide which of two it is: a real leak (fix `package.json` `files`, re-pack, re-check) or genuine over-matching (fix `LEAKAGE_PATTERNS` in `scripts/release.mjs` **with a test**). There is no third option, and neither is "publish anyway and clean it up in the next version" — an npm publish is not revocable, and unpublishing burns the version number permanently. Operator handling detail: `docs/distribution/npm-publish-checklist.md` § 3.
|
|
41
41
|
|
|
42
|
-
**3. When to abort instead of repair.** Abort — do not patch forward — when the failure is upstream of the target-confirmed npm receipt: a red preflight row, a lagging `github` mirror, CI not green on the exact commit **on either platform** (`--check` carries both `ci-green-on-head` for GitLab and `ci-green-on-head-github` for the mirror, whose macOS matrix leg has no GitLab equivalent), a dead token, or a publish that did not issue the target receipt. These are cheap to fix and re-run from the top. Repair-in-place is only appropriate *after* that receipt, where the version is already immutable: registry
|
|
42
|
+
**3. When to abort instead of repair.** Abort — do not patch forward — when the failure is upstream of the target-confirmed npm receipt: a red preflight row, a lagging `github` mirror, CI not green on the exact commit **on either platform** (`--check` carries both `ci-green-on-head` for GitLab and `ci-green-on-head-github` for the mirror, whose macOS matrix leg has no GitLab equivalent), a dead token, or a publish that did not issue the target receipt. These are cheap to fix and re-run from the top. Repair-in-place is only appropriate *after* that receipt, where the version is already immutable: registry availability (`--reconcile`), a missing GitHub release, or a lagging site deploy can be reconciled because npm already has the correct artifact. When `--publish` reports **Post-publish reconciliation required**, **do not rerun `--publish`**; repair the listed state directly. `skills/release/SKILL.md` § Abort criteria is the operative list.
|
|
43
43
|
|
|
44
44
|
## Failure-mode table
|
|
45
45
|
|
|
@@ -48,13 +48,13 @@ The script gates mechanics. These three are yours, and it will not make them for
|
|
|
48
48
|
| `E403 ... Two-factor authentication or granular access token with bypass 2fa enabled is required` — no OTP prompt | Account has no 2FA enrolled AND token (if any) lacks Bypass-2FA | Create granular token with all four requirements above, or enroll 2FA |
|
|
49
49
|
| Same E403 despite a fresh token | Token created without the Bypass-2FA checkbox, or Read-only, or package-scoped on a first publish | Re-create: RW + All packages + Bypass-2FA |
|
|
50
50
|
| `npm whoami` silent or non-zero | Token expired, or `.env.local` missing | Re-create the token; do not proceed — the preflight fails this row on purpose |
|
|
51
|
-
| `E404` on `npm view
|
|
51
|
+
| `E404` on `npm view <pkg>@<target>` after a target-confirmed publish receipt (npm answered HTTP 202) | The upload is **accepted, not yet available**. npm scans every new publish before it becomes installable: "typically around five minutes", "up to 15 minutes or more" at peak or depending on content/size, no service guarantee; the outcome may be published, **held for manual review**, or **blocked** (GitHub changelog 2026-07-28, read 2026-10-08, npm CLI 11.19.0). 5.10.0 stayed 404 for more than 25 minutes. A 404 alone does **not** tell you which of these applies — the package token cannot see the scan state | Let the script finish its tag/push/GitHub/site tail, then run `node scripts/release.mjs --reconcile --target <target>` (read-only, resumable; exit 0 only once installable). While still `pending`, check the owner-visible side: npm says a blocked package's publisher "may receive a notification with the option to appeal" — look at the npm account notifications/e-mail of the publishing account and the package page on npmjs.com. Do **not** rerun `--publish` and do not declare it blocked from the 404 |
|
|
52
52
|
| `ENEEDAUTH` | No login/token at all | Token flow above, or `npm login` |
|
|
53
53
|
| OTP prompt appears but flow is non-interactive (`!`-prefix, script) | No TTY for the prompt | Use the token flow, or a real terminal |
|
|
54
54
|
|
|
55
55
|
## Post-publish — the human half
|
|
56
56
|
|
|
57
|
-
`--publish` attempts registry verification and polls the live site itself. A target-confirmed receipt plus a delayed registry result is a reconciliation outcome, not a failed publish or a retry instruction. What still needs a person:
|
|
57
|
+
`--publish` attempts registry verification and polls the live site itself. A target-confirmed receipt plus a delayed registry result is a reconciliation outcome, not a failed publish or a retry instruction: the receipt is persisted as `.orchestrator/runtime/release-<target>.json`, and `--reconcile` resumes from it. The proof lives in the gitignored runtime dir of the checkout or worktree that ran `--publish`: run `--reconcile` from that same checkout, because any other checkout reports `proof-missing`. What still needs a person:
|
|
58
58
|
|
|
59
59
|
1. **Rotate/delete the token** at https://www.npmjs.com/settings/<user>/tokens. A token that ever transited a conversation, a screenshot, or any log is burned — rotate immediately.
|
|
60
60
|
2. **pi.dev gallery**: indexing is asynchronous — check https://pi.dev/packages later; do not block on it.
|
package/skills/release/SKILL.md
CHANGED
|
@@ -48,7 +48,7 @@ The same reading shows the other half: the GitHub releases for 3.15/3.18/3.19/3.
|
|
|
48
48
|
6. **Preflight.** `node scripts/release.mjs --check --json` — every row green. Run this after the release commit is pushed and its CI succeeds: the preflight requires a clean working tree, exact HEAD parity on both remotes and green CI on that commit. It derives its target from `package.json`, so checking the pre-bump version instead produces registry- and tag-collision failures.
|
|
49
49
|
7. **Publish.** `node scripts/release.mjs --publish` — **give it ≥600 s of wall clock, or run it in the background.** The script sets no spawn timeout on purpose (a kill mid-`npm publish` or mid-push is the very failure the receipt boundary exists to avoid), and the tail is slow by construction: each of the two `git push` remotes re-runs the husky pre-push full gate, plus up to 120 s of live-site polling. A 3-minute default command timeout kills it mid-tail.
|
|
50
50
|
|
|
51
|
-
The target-confirmed npm receipt is the irreversible boundary. Before that receipt, any failure aborts normally. After it, never rerun `--publish`: registry
|
|
51
|
+
The target-confirmed npm receipt is the irreversible boundary. Before that receipt, any failure aborts normally. After it, never rerun `--publish`: the script writes the publish proof `.orchestrator/runtime/release-<target>.json` (checked archive sha256 + sha512 integrity, receipt time, commit, tag), and a registry availability timeout/query/wait failure is reconciliation while the script still tags, pushes `main` + tag to both remotes, handles the GitHub release, and polls the live site. If tag/push fails after the receipt, the dependent GitHub-release and site steps are skipped and the script returns structured reconciliation guidance instead. Add the GitHub release for the new tag (`gh release create`) as part of this step, not "later" — "later" is what produced the three-second backfill above.
|
|
52
52
|
|
|
53
53
|
Steps 2–7 are one continuous act. A release left parked between step 4 and step 7 is exactly the `3.18.0` state: every surface says released, the registry disagrees.
|
|
54
54
|
|
|
@@ -68,7 +68,7 @@ Steps 2–7 are one continuous act. A release left parked between step 4 and ste
|
|
|
68
68
|
|
|
69
69
|
## After
|
|
70
70
|
|
|
71
|
-
`--publish` reports either **Release complete** or **Post-publish reconciliation required**. The latter means npm accepted the target release but registry propagation, tag/push, GitHub-release handling, or the live-site check still needs repair; a tag/push failure explicitly skips its dependent GitHub-release and site steps. **Do not rerun `--publish`**: reconcile the listed state directly, because a second publish cannot replace the immutable version. Exit `1` means a preflight/check failure or post-publish reconciliation is required; exit `2` remains a system/usage failure before the receipt.
|
|
71
|
+
`--publish` reports either **Release complete** or **Post-publish reconciliation required**. The latter means npm accepted the target release but registry propagation, tag/push, GitHub-release handling, or the live-site check still needs repair; a tag/push failure explicitly skips its dependent GitHub-release and site steps. **Do not rerun `--publish`**: reconcile the listed state directly, because a second publish cannot replace the immutable version. For the registry state run `node scripts/release.mjs --reconcile --target X.Y.Z [--json]` — read-only (no publish, tag, push or repo pack), bounded and resumable: it reports `installable` (exit 0) only when the exact version is served as `latest` with the proof's integrity AND a consumer download hashes to it, or `superseded` (exit 0: same checks pass, but `latest` already points at a later release); otherwise `pending` (rerun later), `failed` (integrity mismatch) or `proof-missing` (exit 1 — success is never inferred from `latest`). See `skills/npm-publish/SKILL.md` § Failure-mode table for why an accepted upload can stay unavailable. Exit `1` means a preflight/check failure or post-publish reconciliation is required; exit `2` remains a system/usage failure before the receipt.
|
|
72
72
|
|
|
73
73
|
After a complete release, `--publish` prints the remaining manual items (token rotation, async gallery indexing). Rotate the npm token — write tokens are short-lived by policy, and a token that transited a log or a chat is burned.
|
|
74
74
|
|
|
@@ -118,7 +118,7 @@ Commands resolved from Session Config per Phase 1.
|
|
|
118
118
|
| Auth-at-boundary pattern (`requireAuth`) | `grep -r 'requireAuth' --include='*.ts' src/ 2>/dev/null \|\| echo "N/A (no src/)"` |
|
|
119
119
|
| Zod validation on inputs | `grep -r 'z\.object\|z\.string\|z\.parse\|safeParse' --include='*.ts' src/ 2>/dev/null \|\| echo "N/A"` |
|
|
120
120
|
| No hardcoded secrets (scan for API key patterns) | `grep -r 'sk-\|api_key\s*=\s*"' --include='*.ts' --include='*.mts' --exclude-dir=node_modules . 2>/dev/null` — warn if found |
|
|
121
|
-
| No PAT/token in settings-allowlist entries (`.claude/settings.json`, `.claude/settings.local.json` — on-disk, incl. untracked) | `grep -nEo 'glpat-[A-Za-z0-9_-]{20,}\|ghp_[A-Za-z0-9]{36,}\|github_pat_[A-Za-z0-9_]{22,}\|sk-ant-[A-Za-z0-9_-]{20,}\|AKIA[0-9A-Z]{16}' .claude/settings.json .claude/settings.local.json 2>/dev/null \| grep -vE 'AKIAIOSFODNN7EXAMPLE\|-PLACEHOLDER'` — **fail** if any match (hard — unlike the `sk-` heuristic above, these 5 prefixes are high-signal; see SEC-
|
|
121
|
+
| No PAT/token in settings-allowlist entries (`.claude/settings.json`, `.claude/settings.local.json` — on-disk, incl. untracked) | `grep -nEo 'glpat-[A-Za-z0-9_-]{20,}\|ghp_[A-Za-z0-9]{36,}\|github_pat_[A-Za-z0-9_]{22,}\|sk-ant-[A-Za-z0-9_-]{20,}\|AKIA[0-9A-Z]{16}' .claude/settings.json .claude/settings.local.json 2>/dev/null \| grep -vE 'AKIAIOSFODNN7EXAMPLE\|-PLACEHOLDER'` — **fail** if any match (hard — unlike the `sk-` heuristic above, these 5 prefixes are high-signal; see SEC-025 in `.claude/rules/security.md`). This check is required because `check-owner-leakage.mjs` scans `git ls-files` only — `settings.local.json` is gitignored/untracked by convention and structurally invisible to it. |
|
|
122
122
|
| `.env*` files not tracked | `git ls-files | grep '\.env'` — fail if any `.env` (not `.env.example`) tracked |
|
|
123
123
|
| `.env.example` documents all secrets | `ls .env.example 2>/dev/null` |
|
|
124
124
|
|
|
@@ -17,9 +17,9 @@ Finalize session metrics by reading the wave data accumulated during execution:
|
|
|
17
17
|
- `total_duration_seconds`: from `started_at` to now (ISO 8601 diff)
|
|
18
18
|
- `total_waves`: count of completed waves
|
|
19
19
|
- `total_agents`: sum of agents across all waves
|
|
20
|
-
- `total_files_changed`: unique files changed across entire session (from `git diff --stat`)
|
|
20
|
+
- `total_files_changed`: unique files changed across entire session (from `git diff --stat`). **Not measured** (empty `$SESSION_RANGE`, #1505): the value is unknown, not zero — write `total_files_changed: 0` (`validateSession` requires a non-negative number; `null` or omitting the key is rejected) AND `_backfill_incomplete_fields: ["total_files_changed"]` (append if the array exists), which marks that 0 as a placeholder, the same pair `scripts/lib/session-record-repair.mjs` writes. A bare 0 without the marker claims a measured "no changes".
|
|
21
21
|
- `agent_summary`: `{complete: N, partial: N, failed: N, spiral: N}`
|
|
22
|
-
- **Coordinator-direct fallback (#1321):** when Wave History is empty AND the session ran coordinator-direct (housekeeping — `scripts/lib/session-shape.mjs:364-376` resolves it to ONE coordinator-direct wave that `/go` never hands to wave-executor, so no wave metrics are captured — or the express path), do NOT write `waves: []` / `total_waves: 0`. Write exactly one wave entry `{wave: 1, role: "Housekeeping", agent_count: 0, coordinator_direct: true, files_changed: <N>, quality: "<pass|fail|skip — the close quality-gate result>"}` with `total_waves: 1`, `total_agents: 0`. On this path both that wave's `files_changed` and `total_files_changed` are `git diff --name-only "$SESSION_RANGE" | wc -l` (accessor: `plan-verification.md` § SESSION_START_REF accessor; an empty `$SESSION_RANGE` is a skip, not zero changes — the
|
|
22
|
+
- **Coordinator-direct fallback (#1321):** when Wave History is empty AND the session ran coordinator-direct (housekeeping — `scripts/lib/session-shape.mjs:364-376` resolves it to ONE coordinator-direct wave that `/go` never hands to wave-executor, so no wave metrics are captured — or the express path), do NOT write `waves: []` / `total_waves: 0`. Write exactly one wave entry `{wave: 1, role: "Housekeeping", agent_count: 0, coordinator_direct: true, files_changed: <N>, quality: "<pass|fail|skip — the close quality-gate result>"}` with `total_waves: 1`, `total_agents: 0`. On this path both that wave's `files_changed` and `total_files_changed` are `git diff --name-only "$SESSION_RANGE" | wc -l` (accessor: `plan-verification.md` § SESSION_START_REF accessor; an empty `$SESSION_RANGE` is a skip, not zero changes — write the unmeasured pair from the `total_files_changed` bullet above and name the accessor's reason in the record's `notes`). The single definition of this shape is `isCoordinatorDirectHousekeeping(record)` in `scripts/lib/session-schema/filters.mjs` (every wave `role: "Housekeeping"` AND `coordinator_direct: true`). Consumers key on it (eval gate-health, evolve effective-sizing), so write `role` and `coordinator_direct` exactly as shown. `session_type` alone never marks it.
|
|
23
23
|
3. Read `.orchestrator/metrics/events.jsonl` **once** and build both event aggregates in a single pass. If the file does not exist, treat both aggregates as zero events (omit both fields per the rules below) — do NOT fail the session close.
|
|
24
24
|
|
|
25
25
|
Filter all lines where `session == <session_id>`, then partition by `event` value:
|
|
@@ -184,9 +184,33 @@ Finalize session metrics by reading the wave data accumulated during execution:
|
|
|
184
184
|
> ' .orchestrator/metrics/events.jsonl | tail -1
|
|
185
185
|
> ```
|
|
186
186
|
>
|
|
187
|
-
> `WAVE` and `SEMANTIC_SESSION_ID` come from the existing own session/wave identity; never construct either for this lookup. Filtering by both is mandatory: the ledger accumulates across sessions, and a different session or wave must not supply these counts. `counts.passed` → `suite_passed`, `counts.failed` → `suite_failed`. `tail -1` selects the last matching gate run of the wave, including auto-fix. `-R` with `fromjson? | objects` skips an unreadable ledger line (a torn tail from a killed writer, #1401) instead of letting one parse error end `jq` and
|
|
187
|
+
> `WAVE` and `SEMANTIC_SESSION_ID` come from the existing own session/wave identity; never construct either for this lookup. Filtering by both is mandatory: the ledger accumulates across sessions, and a different session or wave must not supply these counts. `counts.passed` → `suite_passed`, `counts.failed` → `suite_failed`. `tail -1` selects the last matching gate run of the wave, including auto-fix. `-R` with `fromjson? | objects` skips an unreadable ledger line (a torn tail from a killed writer, #1401) instead of letting one parse error end `jq`. The old pipeline could emit an earlier matching value before the parse error; `tail -1` retained that stale value and could hide the jq failure. Known limit: an interrupted `appendFile` can leave a truncated record that `fromjson?` discards completely; `tail -1` can still select an older matching run, which alone is not evidence of the current gate result. The library emits one record per `runQualityGateWithRetry` call, not per retry; `attempts` is not a record count.
|
|
188
188
|
>
|
|
189
|
-
>
|
|
189
|
+
> An empty counts selector cannot distinguish a missing event, missing wave attribution,
|
|
190
|
+
> or an attributed event without counts after fail-fast. Inspect the own session's
|
|
191
|
+
> records without the counts filter before deciding whether a documented count
|
|
192
|
+
> fallback case applies:
|
|
193
|
+
>
|
|
194
|
+
> ```bash
|
|
195
|
+
> jq -cR --argjson w "$WAVE" --arg s "$SEMANTIC_SESSION_ID" '
|
|
196
|
+
> fromjson? | objects
|
|
197
|
+
> | select((.event | type) == "string" and (.event | startswith("orchestrator.quality_gate.")))
|
|
198
|
+
> | select(.semantic_session_id == $s)
|
|
199
|
+
> | select(.wave_number == $w or .wave_number == null)
|
|
200
|
+
> | {event, semantic_session_id, wave_number, counts}
|
|
201
|
+
> ' .orchestrator/metrics/events.jsonl
|
|
202
|
+
> ```
|
|
203
|
+
>
|
|
204
|
+
> This is a diagnostic, not a replacement counts source. No rows means no readable
|
|
205
|
+
> matching event; a row without `wave_number` is not proof of this wave's run;
|
|
206
|
+
> a row for this wave without `counts` supplies no measurement. Keep the session
|
|
207
|
+
> filter and never copy counts from another session or wave, or from a row without
|
|
208
|
+
> wave attribution. An event without `semantic_session_id` is not attributable by
|
|
209
|
+
> this query either; diagnose missing session attribution from independent evidence
|
|
210
|
+
> of the own gate run, not by adopting an unrelated unattributed event. Missing
|
|
211
|
+
> attribution or an empty result alone does not prove a fallback source exists.
|
|
212
|
+
>
|
|
213
|
+
> **Count fallback is limited to three cases:** pre-#954 sessions, a gate run outside the event wrapper, or missing wave attribution (including an auto-fix call without a readable own wave scope). An event without `semantic_session_id` cannot match the selector — emitters set it only from the session lock via `sessionAttribution()` — and is treated like a gate run outside the wrapper. In those cases only, read the STATE.md Wave History header `— suite <passed>/<failed> on <platform>`. An auto-fix call with a known wave uses the event selector above. An empty selector alone is not a fallback case: an event without `counts`, for example after fail-fast before the test suite, supplies no replacement zeros and does not by itself permit count fallback.
|
|
190
214
|
>
|
|
191
215
|
> If no counts are available and none of those fallback cases applies, omit `suite_passed` and `suite_failed`. Absent means "not measured"; a present `suite_failed: 0` means "measured, zero failures". Never zero-fill missing measurements.
|
|
192
216
|
>
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
1a. **Token Rollup (#644, #1244 — mechanical since #1436)** — no coordinator action. `scripts/emit-session.mjs` (step 2) runs `rollupSessionTokens()` itself whenever the incoming record carries no `total_tokens`: it resolves the session's raw UUID from `.orchestrator/current-session.json` (adopted only when its `semantic_session_id` equals the record's `session_id` and no process-local session id contradicts it), joins the sibling `subagents.jsonl` on `parent_session_id`, and fills `total_tokens`, `total_token_input`, `total_token_output`, the three cache buckets, `total_cost_usd`, `subagents_with_tokens`, `matched_records`, `cost_records_priced`, `cost_records_total` and `_token_schema` for every key the record does not already carry — plus `raw_session_id`. No matching records, an unreadable ledger or a foreign marker omits the fields with a WARN on stderr, never a fabricated `0`. An explicit key on `$METRICS_ENTRY` still wins. The same UUID also backs `session_start_ref` when STATE.md has none (the `head_sha` of the session's own `orchestrator.session.started` event).
|
|
33
33
|
|
|
34
|
-
**Semantics:** `null` totals mean "no token data was captured for this session" — this is NOT the same as zero cost. Do NOT coerce null to 0 when displaying or summing across sessions. The same holds for `total_cost_usd`: it is omitted whenever `cost_records_priced < cost_records_total` (#1475) — at least one cost-relevant subagent record could not be priced (a model the price table does not know, a token-bearing turn naming no model, a transcript that yielded no tokens, e.g. one over the hook's size cap, or an agent with a start record whose stop found no transcript of its own, mostly `workflow-subagent`, or an agent known only from `schema_version` 1 records — #1487 Pkt 1) — and also when `cost_records_total` is 0, because there was nothing to price. It is never "$0" for an unknown cost and never a partial sum over the v2 agents the ledger can see. Phantom stops (#939, no start record) are no agents and never count. The two counters are persisted beside it, so a missing cost says whether part of the session was unpriceable or nothing was priceable. Blind spots remain: an agent whose start record fell outside the hook's tail window looks like a phantom, an agent with neither start nor stop record is invisible. A session spanning the 2026-09-09 schema boundary therefore reports no cost (its v1 agents are unpriced candidates since 2026-10-02; before, it was priced over its v2 records only — 4 distinct fleet sessions carried such a partial sum). A subagent whose turns span several models carries a per-model `models_usage` breakdown in `subagents.jsonl` (#1470) and is priced part by part. The emit-session warning names the reason via `match_status` (ledger absent, ledger with no readable records, or records present but none for the own raw UUID); none of these means zero cost.
|
|
34
|
+
**Semantics:** `null` totals mean "no token data was captured for this session" — this is NOT the same as zero cost. Do NOT coerce null to 0 when displaying or summing across sessions. The same holds for `total_cost_usd`: it is omitted whenever `cost_records_priced < cost_records_total` (#1475) — at least one cost-relevant subagent record could not be priced (a model the price table does not know, a token-bearing turn naming no model, a transcript that yielded no tokens, e.g. one over the hook's size cap, or an agent with a start record whose stop found no transcript of its own, mostly `workflow-subagent`, or an agent known only from `schema_version` 1 records — #1487 Pkt 1) — and also when `cost_records_total` is 0, because there was nothing to price. It is never "$0" for an unknown cost and never a partial sum over the v2 agents the ledger can see. Phantom stops (#939, no start record) are no agents and never count. The two counters are persisted beside it, so a missing cost says whether part of the session was unpriceable or nothing was priceable. Blind spots remain: an agent whose start record fell outside the hook's tail window looks like a phantom, an agent with neither start nor stop record is invisible. A session spanning the 2026-09-09 schema boundary therefore reports no cost (its v1 agents are unpriced candidates since 2026-10-02; before, it was priced over its v2 records only — 4 distinct fleet sessions carried such a partial sum). A subagent whose turns span several models carries a per-model `models_usage` breakdown in `subagents.jsonl` (#1470) and is priced part by part. A written `total_cost_usd` is the **API list-price equivalent** of the metered tokens, never a payment: subscription quota, purchased credits and the amount actually billed are invisible to the rollup, and no record states any of them. Since #1470 emit-session marks this with `cost_basis: "api-list-equivalent"` beside every `total_cost_usd` it writes, and writes `pricing_table_date` (the `PRICING_TABLE_DATE` of `scripts/lib/telemetry/pricing.mjs` that priced the session) beside the cost counters. A record without these fields predates #1470; its cost is a list-price estimate too, priced by an older table, not a verified bill. The emit-session warning names the reason via `match_status` (ledger absent, ledger with no readable records, or records present but none for the own raw UUID); none of these means zero cost.
|
|
35
35
|
|
|
36
36
|
**Provenance (#949):** the rollup sums ONLY records carrying `subagent_transcript_found: true` — the flag the producer sets when it read the subagent's own transcript. Pre-#949 records carry the PARENT transcript's running totals and are excluded, so a session made up entirely of them now reports `null` rather than a fabricated sum (73 historical sessions, 96,148,781 phantom tokens, measured 2026-08-11). Two consequences for readers: totals already written into `sessions.jsonl` before 2026-08-11 were produced by the unfiltered recipe and are a series break, not a trend; and `matched_records` counts start records and phantom stops alike, so it is NOT the denominator for a coverage ratio — use `subagents_with_tokens` against the session's real agent count.
|
|
37
37
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
Acquire a distributed session-lock to detect parallel sessions in the same repo before initializing STATE.md. This prevents two concurrent Claude/Codex sessions from stomping each other's wave state and metrics writes.
|
|
12
12
|
|
|
13
|
-
**Mechanical wiring (Epic #583, 2026-05-27):** The SessionStart hook (`hooks/on-session-start.mjs` → `hooks/_lib/lock-bootstrap.mjs`) now writes `.orchestrator/session.lock` mechanically BEFORE this skill's prose runs. The prose Phase 1.2 becomes confirmatory — it verifies the lock exists with the expected shape via `readLock({ repoRoot: process.cwd() })`. Re-call `acquire()` only if `readLock()` returns `null` (mechanical hook failed) OR the existing lock's raw `session_id` does not exactly match the current session's raw id (a rare divergence — surface via AUQ before overwriting). A matching `semantic_session_id`, STATE.md `session`, or owner proof cannot repair that mismatch. The decision flow below still applies to all five outcomes (active / stale / corrupt / unreadable / fs-error) when the prose path needs to acquire.
|
|
13
|
+
**Mechanical wiring (Epic #583, 2026-05-27):** The SessionStart hook (`hooks/on-session-start.mjs` → `hooks/_lib/lock-bootstrap.mjs`) now writes `.orchestrator/session.lock` mechanically BEFORE this skill's prose runs. The prose Phase 1.2 becomes confirmatory — it verifies the lock exists with the expected shape via `readLock({ repoRoot: process.cwd() })`. Re-call `acquire()` only if `readLock()` returns `null` (mechanical hook failed) OR the existing lock's raw `session_id` does not exactly match the current session's raw id (a rare divergence — surface via AUQ before overwriting). A matching `semantic_session_id`, STATE.md `session`, or owner proof cannot repair that mismatch. The decision flow below still applies to all five outcomes (active / stale / corrupt / unreadable / fs-error) when the prose path needs to acquire. Every `ok: true` from `acquire()` / `forceAcquire()` also rewrites `.orchestrator/runtime/lock-owner-proof.json` for the new lock (#1541), so the `/close` release stays proof-gated; a failed proof write is reported as `result.ownerProof: { ok: false, reason }`, never thrown.
|
|
14
14
|
|
|
15
15
|
```javascript
|
|
16
16
|
import { acquire, forceAcquire } from 'scripts/lib/session-lock.mjs';
|
|
@@ -50,6 +50,7 @@ The coordinator determines each agent's status after the wave completes:
|
|
|
50
50
|
| **partial** | Agent made progress but couldn't finish | Turn limit hit, or agent reports `STATUS: partial` | Carry forward remaining work to next wave with context |
|
|
51
51
|
| **failed** | Agent couldn't make meaningful progress | Tool errors, invalid assumptions, agent reports `STATUS: failed` | Re-dispatch with corrected instructions and narrower scope |
|
|
52
52
|
| **spiral** | Agent got stuck in an edit loop | Same file edited 3+ times (detected post-wave) | Revert agent's changes, narrow scope, split task if needed |
|
|
53
|
+
| **limit** | Agent stopped by a session/usage limit or HTTP 429 mid-wave — not by its task | Output ends in a limit/429 error instead of a `STATUS:` line | No re-dispatch, no carryover issue: read `git diff` in the agent's worktree, finish the small remaining fix passes coordinator-direct, log a Deviation via `appendDeviationOnDisk()` (`scripts/lib/state-md.mjs`) |
|
|
53
54
|
|
|
54
55
|
3. **Recovery protocol**:
|
|
55
56
|
- FAILED agent → log in STATE.md, add fix task to next wave with corrected instructions, AND auto-create a carryover issue (see "Carryover Auto-Create" below) the first time this task is marked FAILED (check STATE.md Wave History for a prior `→ issue #NNN` on this task before filing).
|
|
@@ -386,9 +386,33 @@ If the commit itself fails (e.g., nothing to commit, pre-commit hook rejects), d
|
|
|
386
386
|
' .orchestrator/metrics/events.jsonl | tail -1
|
|
387
387
|
```
|
|
388
388
|
|
|
389
|
-
`WAVE` and `SEMANTIC_SESSION_ID` come from the existing own session/wave identity; never construct either for this lookup. Filtering by both is mandatory: the ledger accumulates across sessions, and a different session or wave must not supply these counts. `counts.passed` → `suite_passed`, `counts.failed` → `suite_failed`. `tail -1` selects the last matching gate run of the wave, including auto-fix. `-R` with `fromjson? | objects` skips an unreadable ledger line (a torn tail from a killed writer, #1401) instead of letting one parse error end `jq` and
|
|
389
|
+
`WAVE` and `SEMANTIC_SESSION_ID` come from the existing own session/wave identity; never construct either for this lookup. Filtering by both is mandatory: the ledger accumulates across sessions, and a different session or wave must not supply these counts. `counts.passed` → `suite_passed`, `counts.failed` → `suite_failed`. `tail -1` selects the last matching gate run of the wave, including auto-fix. `-R` with `fromjson? | objects` skips an unreadable ledger line (a torn tail from a killed writer, #1401) instead of letting one parse error end `jq`. The old pipeline could emit an earlier matching value before the parse error; `tail -1` retained that stale value and could hide the jq failure. Known limit: an interrupted `appendFile` can leave a truncated record that `fromjson?` discards completely; `tail -1` can still select an older matching run, which alone is not evidence of the current gate result. The library emits one record per `runQualityGateWithRetry` call, not per retry; `attempts` is not a record count.
|
|
390
390
|
|
|
391
|
-
|
|
391
|
+
An empty counts selector cannot distinguish a missing event, missing wave attribution,
|
|
392
|
+
or an attributed event without counts after fail-fast. Inspect the own session's
|
|
393
|
+
records without the counts filter before deciding whether a documented count
|
|
394
|
+
fallback case applies:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
jq -cR --argjson w "$WAVE" --arg s "$SEMANTIC_SESSION_ID" '
|
|
398
|
+
fromjson? | objects
|
|
399
|
+
| select((.event | type) == "string" and (.event | startswith("orchestrator.quality_gate.")))
|
|
400
|
+
| select(.semantic_session_id == $s)
|
|
401
|
+
| select(.wave_number == $w or .wave_number == null)
|
|
402
|
+
| {event, semantic_session_id, wave_number, counts}
|
|
403
|
+
' .orchestrator/metrics/events.jsonl
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
This is a diagnostic, not a replacement counts source. No rows means no readable
|
|
407
|
+
matching event; a row without `wave_number` is not proof of this wave's run;
|
|
408
|
+
a row for this wave without `counts` supplies no measurement. Keep the session
|
|
409
|
+
filter and never copy counts from another session or wave, or from a row without
|
|
410
|
+
wave attribution. An event without `semantic_session_id` is not attributable by
|
|
411
|
+
this query either; diagnose missing session attribution from independent evidence
|
|
412
|
+
of the own gate run, not by adopting an unrelated unattributed event. Missing
|
|
413
|
+
attribution or an empty result alone does not prove a fallback source exists.
|
|
414
|
+
|
|
415
|
+
**Count fallback is limited to three cases:** pre-#954 sessions, a gate run outside the event wrapper, or missing wave attribution (including an auto-fix call without a readable own wave scope). An event without `semantic_session_id` cannot match the selector — emitters set it only from the session lock via `sessionAttribution()` — and is treated like a gate run outside the wrapper. In those cases only, fall back to the gate output you read for this wave — the same run whose counts go into the § 3a header — and say so in the progress update. Do not read the STATE.md Wave History header here: this step runs before § 3a writes this wave's header, so the newest header line there belongs to the previous wave. An auto-fix call with a known wave uses the event selector above. An empty selector alone is not a fallback case: an event without `counts`, for example after fail-fast before the test suite, supplies no replacement zeros and does not by itself permit count fallback.
|
|
392
416
|
|
|
393
417
|
If no counts are available and none of those fallback cases applies, omit `suite_passed` and `suite_failed`. Absent means "not measured"; a present `suite_failed: 0` means "measured, zero failures". Never zero-fill missing measurements.
|
|
394
418
|
|