session-orchestrator 5.10.0 → 5.12.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 +12 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +51 -0
- package/README.md +22 -11
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/commands/session.md +3 -1
- package/docs/agent-authoring.md +2 -0
- package/docs/events-schema.md +2 -1
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/install.md +12 -8
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +3 -0
- package/docs/session-config-reference.md +7 -7
- package/docs/session-config-template.md +2 -2
- 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/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 +26 -10
- package/hooks/post-bash-write-verify.mjs +16 -3
- 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 +1 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +13 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/fleet-checkin.mjs +61 -3
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config.mjs +1 -1
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/fleet-protocol.mjs +17 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-registry.mjs +14 -4
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/so-hud/collect.mjs +130 -0
- package/scripts/lib/so-hud/runtime.mjs +76 -0
- package/scripts/lib/state-md/idle-reset.mjs +239 -0
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/lib/vault-mirror/namespace.mjs +3 -2
- package/scripts/lib/vault-mirror/process.mjs +143 -40
- package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
- package/scripts/lib/vault-mirror/utils.mjs +27 -13
- package/scripts/lib/vault-status/board-writer.mjs +32 -4
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/release.mjs +359 -11
- package/scripts/resolve-session-invocation.mjs +64 -8
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/so-hud.mjs +45 -0
- package/scripts/state-md-idle-reset.mjs +81 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +24 -2
- package/skills/_shared/state-ownership.md +3 -3
- package/skills/autopilot/SKILL.md +3 -2
- package/skills/claude-md-drift-check/checker.mjs +8 -6
- 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/SKILL.md +3 -2
- package/skills/session-end/metrics-collection.md +2 -2
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/SKILL.md +1 -1
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
- package/skills/wave-executor/circuit-breaker.md +1 -0
- package/AGENTS.md +0 -205
|
@@ -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 } : {}),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify, parseArgs } from 'node:util';
|
|
8
|
+
import { measure } from './lib/so-hud/runtime.mjs';
|
|
9
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
const SO_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
12
|
+
|
|
13
|
+
export async function main(argv, deps = {}) {
|
|
14
|
+
const output = deps.output ?? (s => process.stdout.write(s));
|
|
15
|
+
const error = deps.error ?? (s => process.stderr.write(s));
|
|
16
|
+
let values;
|
|
17
|
+
try {
|
|
18
|
+
({ values } = parseArgs({ args: argv, options: { json: { type: 'boolean' }, 'repo-root': { type: 'string' }, 'register-root': { type: 'boolean' }, help: { type: 'boolean' } }, allowPositionals: false }));
|
|
19
|
+
if (values.help) {
|
|
20
|
+
output('so-hud --json [--repo-root <path>] [--register-root]\nRead-only HUD JSON v1. --register-root registers this SO installation (0600).\nDefault repo: git toplevel of cwd. Exit 0: measured; 2: invocation/registration error; 6: repository unavailable.\n');
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
if (!values.json && !values['register-root']) throw new Error('--json required');
|
|
24
|
+
} catch { error('so-hud: --json erforderlich; siehe --help\n'); return 2; }
|
|
25
|
+
if (values['register-root']) {
|
|
26
|
+
try {
|
|
27
|
+
const dir = path.join(deps.home ?? os.homedir(), '.config/session-orchestrator/hud');
|
|
28
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
await fs.chmod(dir, 0o700);
|
|
30
|
+
const target = path.join(dir, 'so-root');
|
|
31
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
32
|
+
try { await fs.writeFile(temp, `${SO_ROOT}\n`, { mode: 0o600, flag: 'wx' }); await fs.rename(temp, target); }
|
|
33
|
+
finally { await fs.unlink(temp).catch(() => {}); }
|
|
34
|
+
} catch { error('so-hud: Registrierung fehlgeschlagen\n'); return 2; }
|
|
35
|
+
if (!values.json) return 0;
|
|
36
|
+
}
|
|
37
|
+
let root;
|
|
38
|
+
try {
|
|
39
|
+
root = values['repo-root'] ? path.resolve(values['repo-root']) : (await (deps.exec ?? exec)('git', ['rev-parse', '--show-toplevel'], { timeout: 700 })).stdout.trim();
|
|
40
|
+
if (!root || !(await fs.stat(root)).isDirectory()) throw new Error('missing');
|
|
41
|
+
} catch { error('so-hud: Repo nicht ermittelbar\n'); return 6; }
|
|
42
|
+
try { output(`${JSON.stringify(await (deps.measure ?? measure)(root))}\n`); return 0; }
|
|
43
|
+
catch { error('so-hud: Messung fehlgeschlagen\n'); return 6; }
|
|
44
|
+
}
|
|
45
|
+
if (isMainModule(import.meta.url)) process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** session-start Phase 1.5 Idle Reset (#1560): rewrite a completed STATE.md to idle, under the STATE.md lock. */
|
|
3
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { resetStateMdToIdleOnDisk } from './lib/state-md/idle-reset.mjs';
|
|
7
|
+
|
|
8
|
+
const HELP = `Usage: node scripts/state-md-idle-reset.mjs [--repo-root <dir>] [--json]
|
|
9
|
+
|
|
10
|
+
Reset the repo's STATE.md to idle when its status is completed (session-start
|
|
11
|
+
Phase 1.5, Idle Reset rules 1-7): status idle, current-wave 0, Wave History
|
|
12
|
+
demoted into Previous Session, Deviations emptied, scope-baseline-* keys
|
|
13
|
+
deleted. The five recommendation keys stay in the frontmatter so the Phase 7.5
|
|
14
|
+
Mode-Selector can read them. Any other status is a no-op; reasons:
|
|
15
|
+
status-not-completed, unparseable, missing (no or empty STATE.md).
|
|
16
|
+
|
|
17
|
+
--repo-root <dir> Repository root holding the state dir (default: cwd)
|
|
18
|
+
--json Emit {"changed", "reason", "path"} instead of one line
|
|
19
|
+
--help Show this help
|
|
20
|
+
--version Show the plugin version
|
|
21
|
+
|
|
22
|
+
Example: node scripts/state-md-idle-reset.mjs --json
|
|
23
|
+
Exit codes: 0 reset or no-op, 1 bad CLI arguments,
|
|
24
|
+
2 I/O error, --repo-root not a directory, or a write the
|
|
25
|
+
STATE.md guards refused
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/** Reasons under which writeStateMd refused a wanted write. */
|
|
29
|
+
const REFUSED = new Set(['size-ceiling', 'frontmatter-unsafe']);
|
|
30
|
+
|
|
31
|
+
let options;
|
|
32
|
+
try {
|
|
33
|
+
options = parseArgs({
|
|
34
|
+
options: {
|
|
35
|
+
'repo-root': { type: 'string' },
|
|
36
|
+
json: { type: 'boolean' },
|
|
37
|
+
help: { type: 'boolean' },
|
|
38
|
+
version: { type: 'boolean' },
|
|
39
|
+
},
|
|
40
|
+
allowPositionals: false,
|
|
41
|
+
}).values;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
process.stderr.write(`state-md-idle-reset: ${error.message}\n`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.help) {
|
|
48
|
+
process.stdout.write(HELP);
|
|
49
|
+
} else if (options.version) {
|
|
50
|
+
process.stdout.write(
|
|
51
|
+
`${JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}\n`,
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
const repoRoot = resolve(options['repo-root'] ?? process.cwd());
|
|
55
|
+
// The STATE.md lock would otherwise create <repoRoot>/.orchestrator/ for a
|
|
56
|
+
// root that does not exist and report a harmless-looking `missing`.
|
|
57
|
+
let rootStat;
|
|
58
|
+
try {
|
|
59
|
+
rootStat = statSync(repoRoot, { throwIfNoEntry: false });
|
|
60
|
+
} catch {
|
|
61
|
+
rootStat = undefined; // EACCES / ELOOP: an I/O error, exit 2 below
|
|
62
|
+
}
|
|
63
|
+
if (!rootStat?.isDirectory()) {
|
|
64
|
+
process.stderr.write(`state-md-idle-reset: --repo-root is not a directory: ${repoRoot}\n`);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await resetStateMdToIdleOnDisk(repoRoot);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
process.stderr.write(`state-md-idle-reset: ${error.message}\n`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
const reason = result.reason ?? null;
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
options.json
|
|
77
|
+
? `${JSON.stringify({ changed: result.changed, reason, path: result.path })}\n`
|
|
78
|
+
: `${result.changed ? 'reset to idle' : `unchanged (${reason})`}: ${result.path}\n`,
|
|
79
|
+
);
|
|
80
|
+
if (REFUSED.has(reason)) process.exit(2);
|
|
81
|
+
}
|
|
@@ -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
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* vault-orphans.mjs - CLI for `findVaultOrphans` (#1563, session-end Phase 3.7e).
|
|
4
|
+
*
|
|
5
|
+
* Names the untracked files in the Meta-Vault modified since a given instant
|
|
6
|
+
* (the session's start). Read-only: it never commits, moves or deletes anything
|
|
7
|
+
* in the vault. See scripts/lib/session-end/vault-orphans.mjs for the design.
|
|
8
|
+
*
|
|
9
|
+
* USAGE
|
|
10
|
+
* node scripts/vault-orphans.mjs --since <ISO> [--vault-dir <dir>] [--json]
|
|
11
|
+
*
|
|
12
|
+
* EXIT CODES
|
|
13
|
+
* 0 measured (including zero candidates)
|
|
14
|
+
* 1 bad arguments
|
|
15
|
+
* 2 could not measure (no vault dir resolvable, not a git repo, git failed)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { parseArgs } from 'node:util';
|
|
21
|
+
|
|
22
|
+
import { findVaultOrphans } from './lib/session-end/vault-orphans.mjs';
|
|
23
|
+
import { _parseVaultIntegration } from './lib/config/vault-integration.mjs';
|
|
24
|
+
import { loadHostPaths } from './lib/config/host-paths.mjs';
|
|
25
|
+
import { resolveVaultDir } from './lib/config.mjs';
|
|
26
|
+
import { expandTilde } from './lib/common.mjs';
|
|
27
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
28
|
+
|
|
29
|
+
const HELP = `vault-orphans.mjs - name untracked vault files written since a session started (#1563)
|
|
30
|
+
|
|
31
|
+
USAGE
|
|
32
|
+
node scripts/vault-orphans.mjs --since <ISO> [--vault-dir <dir>] [--json]
|
|
33
|
+
|
|
34
|
+
DESCRIPTION
|
|
35
|
+
Runs the read-only \`git -C <vault-dir> ls-files --others --exclude-standard\`
|
|
36
|
+
and lists every untracked file whose mtime is at or after --since. Files whose
|
|
37
|
+
frontmatter names one of this plugin's generators (\`_generator:
|
|
38
|
+
session-orchestrator-...\`) are left out: their writer owns them.
|
|
39
|
+
|
|
40
|
+
The results are CANDIDATES: mtime is a hint, not proof that this session
|
|
41
|
+
wrote the file. Nothing is committed, moved or deleted.
|
|
42
|
+
|
|
43
|
+
OPTIONS
|
|
44
|
+
--since <ISO> Required. ISO 8601 instant with Z or an offset, e.g. the
|
|
45
|
+
STATE.md \`started_at\` (2026-10-11T08:00:00.000Z).
|
|
46
|
+
--vault-dir <dir> Vault working tree. Default: the host-local resolution
|
|
47
|
+
SO_VAULT_DIR > owner.yaml > committed
|
|
48
|
+
vault-integration.vault-dir in ./CLAUDE.md or ./AGENTS.md.
|
|
49
|
+
--json Print the result object as JSON on stdout.
|
|
50
|
+
--help Show this help.
|
|
51
|
+
--version Print the plugin version.
|
|
52
|
+
|
|
53
|
+
EXIT CODES
|
|
54
|
+
0 measured, including zero candidates
|
|
55
|
+
1 bad arguments
|
|
56
|
+
2 could not measure (no vault dir, not a git repo, git failed)
|
|
57
|
+
|
|
58
|
+
EXAMPLE
|
|
59
|
+
node scripts/vault-orphans.mjs --since 2026-10-11T08:00:00.000Z --json
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
/** ISO 8601 with an explicit zone - a zone-less value would be read as local time. */
|
|
63
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the vault dir like the other vault writers: SO_VAULT_DIR > owner.yaml
|
|
67
|
+
* `vault-dirs:` cwd match > owner.yaml `paths.vault-dir` > committed value.
|
|
68
|
+
*
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
function resolveDefaultVaultDir() {
|
|
72
|
+
let content = '';
|
|
73
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
74
|
+
const p = join(process.cwd(), name);
|
|
75
|
+
if (existsSync(p)) {
|
|
76
|
+
try {
|
|
77
|
+
content = readFileSync(p, 'utf8');
|
|
78
|
+
break;
|
|
79
|
+
} catch {
|
|
80
|
+
/* try the next file */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const vi = _parseVaultIntegration(content);
|
|
85
|
+
const value = resolveVaultDir(vi['vault-dir'], loadHostPaths()).value;
|
|
86
|
+
return typeof value === 'string' && value.trim() !== '' ? expandTilde(value) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {{status: 'ok', candidates: Array<{path: string}>, total: number, truncated: boolean, untracked: number}} r */
|
|
90
|
+
function renderHuman(r, since) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
if (r.total === 0) {
|
|
93
|
+
lines.push(`vault-orphans: no untracked vault file modified since ${since} (${r.untracked} untracked in total)`);
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(
|
|
96
|
+
`vault-orphans: ${r.total} untracked vault file(s) modified since ${since} - candidates only, ` +
|
|
97
|
+
`mtime does not prove this session wrote them (${r.untracked} untracked in total)`,
|
|
98
|
+
);
|
|
99
|
+
for (const c of r.candidates) lines.push(` ${c.path}`);
|
|
100
|
+
if (r.truncated) lines.push(` ... showing ${r.candidates.length} of ${r.total}`);
|
|
101
|
+
}
|
|
102
|
+
return `${lines.join('\n')}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* CLI entry.
|
|
107
|
+
*
|
|
108
|
+
* @param {string[]} [argv]
|
|
109
|
+
* @param {{stdout: {write: Function}, stderr: {write: Function}}} [io]
|
|
110
|
+
* @returns {number} exit code
|
|
111
|
+
*/
|
|
112
|
+
export function main(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
113
|
+
let values;
|
|
114
|
+
try {
|
|
115
|
+
({ values } = parseArgs({
|
|
116
|
+
args: argv,
|
|
117
|
+
options: {
|
|
118
|
+
since: { type: 'string' },
|
|
119
|
+
'vault-dir': { type: 'string' },
|
|
120
|
+
json: { type: 'boolean', default: false },
|
|
121
|
+
help: { type: 'boolean', default: false },
|
|
122
|
+
version: { type: 'boolean', default: false },
|
|
123
|
+
},
|
|
124
|
+
strict: true,
|
|
125
|
+
allowPositionals: false,
|
|
126
|
+
}));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
io.stderr.write(`vault-orphans: ${err.message}\nRun with --help for usage.\n`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (values.help) {
|
|
133
|
+
io.stdout.write(HELP);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (values.version) {
|
|
137
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
138
|
+
io.stdout.write(`${pkg.version}\n`);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const since = values.since;
|
|
143
|
+
const sinceMs = typeof since === 'string' && ISO_RE.test(since) ? Date.parse(since) : NaN;
|
|
144
|
+
if (!Number.isFinite(sinceMs)) {
|
|
145
|
+
io.stderr.write('vault-orphans: --since <ISO> is required, e.g. 2026-10-11T08:00:00.000Z\n');
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
if (values['vault-dir'] !== undefined && values['vault-dir'].trim() === '') {
|
|
149
|
+
io.stderr.write('vault-orphans: --vault-dir must not be empty\n');
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const vaultDir =
|
|
154
|
+
values['vault-dir'] !== undefined ? expandTilde(values['vault-dir']) : resolveDefaultVaultDir();
|
|
155
|
+
const result = findVaultOrphans({ vaultDir, sinceMs });
|
|
156
|
+
|
|
157
|
+
if (result.status !== 'ok') {
|
|
158
|
+
io.stderr.write(
|
|
159
|
+
`vault-orphans: could not measure (${result.reason}${result.detail ? `: ${result.detail}` : ''})\n`,
|
|
160
|
+
);
|
|
161
|
+
if (values.json) io.stdout.write(`${JSON.stringify(result)}\n`);
|
|
162
|
+
return 2;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
io.stdout.write(values.json ? `${JSON.stringify(result)}\n` : renderHuman(result, since));
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (isMainModule(import.meta.url)) {
|
|
170
|
+
process.exitCode = main();
|
|
171
|
+
}
|
|
@@ -75,7 +75,14 @@ die vom Lease-Validator akzeptierte `adresse` einer aktiven Lease, sonst `null`
|
|
|
75
75
|
Lease, kein Eintrag oder ein abgelehnter Wert — nie ein ungeprüfter String). `fallback_due_at` ist
|
|
76
76
|
der Zeitpunkt, ab dem die Standard-Auflagen (unten) gelten, in der Form von `zeit` (Code:
|
|
77
77
|
`fallbackDueAt()`); er steht nur auf stdout, die Check-in-Datei behält ihr Schema. Exit 0
|
|
78
|
-
geschrieben, 2 ungültige Eingabe (Fehlerliste auf stderr, keine Datei)
|
|
78
|
+
geschrieben, 2 ungültige Eingabe (Fehlerliste auf stderr, keine Datei) oder semantische Session-ID
|
|
79
|
+
von einem anderen Repo belegt (eine Zeile auf stderr mit JSON-escaped Repo-Namen, fremde Datei unverändert,
|
|
80
|
+
tmp entfernt, kein Event), 1 Schreibfehler.
|
|
81
|
+
Vor dem `rename` lehnt der Schreiber ein vorhandenes String-`repo` ungleich dem eigenen ab;
|
|
82
|
+
gleiches `repo`, unlesbare Dateien und ungültiges JSON dürfen überschrieben werden.
|
|
83
|
+
Die Prüfung folgt keinen Symlinks, öffnet nichtblockierend und liest nur reguläre Dateien
|
|
84
|
+
bis 64 KiB. Symlinks, andere Dateitypen, größere Dateien oder fehlende sichere Open-Flags
|
|
85
|
+
gelten für diese Prüfung als unlesbar (fail-open zum `rename`).
|
|
79
86
|
|
|
80
87
|
| Feld | Inhalt |
|
|
81
88
|
|---|---|
|
|
@@ -130,9 +137,24 @@ aktiven Navigator ist `fallback_due_at` gleich `zeit`, die Auflagen gelten also
|
|
|
130
137
|
Lesende Arbeit und Schreiben im eigenen Worktree laufen währenddessen weiter; der Rückfall ist ein
|
|
131
138
|
Dateistand mit Frist, kein Warten auf eine Antwort.
|
|
132
139
|
|
|
140
|
+
## Verhalten ohne Flotte
|
|
141
|
+
|
|
142
|
+
Flottenintegration liegt vor, wenn `NAVIGATOR_CONFIG_DIR` gesetzt und nicht leer ist (ausdrückliches
|
|
143
|
+
Opt-in, auch vor dem ersten Anlegen) oder `~/.config/navigator/` als Verzeichnis existiert (Code:
|
|
144
|
+
`fleetIntegrationEnabled()`, #1559). Ein Flottenhost hat das Verzeichnis, weil der Koordinator dort
|
|
145
|
+
die Lease schreibt. Ohne Flottenintegration validiert `fleet-checkin.mjs` die Eingabe wie immer
|
|
146
|
+
(ungültig → Exit 2), schreibt dann aber nichts, legt kein Verzeichnis an, emittiert kein Event und
|
|
147
|
+
meldet `{"ok":true,"skipped":"no-fleet",…}`. Es gelten keine Auflagen, auch keine Standard-Auflagen:
|
|
148
|
+
ohne Flotte gibt es nichts zu koordinieren. Die Navigator-Zeile im Sessionstart-Banner entfällt
|
|
149
|
+
ebenso. Das gilt auch für `plattform: kopflos`: der Datensatz beweist keinen Koordinator, und ein
|
|
150
|
+
Schreiben legte das Verzeichnis an, das danach jede Solo-Session zur Flotte macht. Ein kopfloser
|
|
151
|
+
Lauf ohne Flottenintegration wendet stattdessen die Standard-Auflagen sofort an — er kann niemanden
|
|
152
|
+
fragen. Ein Flotten-Starter, der auf einem frischen Remote-Host startet, setzt deshalb
|
|
153
|
+
`NAVIGATOR_CONFIG_DIR` oder legt das Verzeichnis vorher an.
|
|
154
|
+
|
|
133
155
|
## Verhalten ohne Navigator
|
|
134
156
|
|
|
135
|
-
Lease `none` oder `unreadable`: die Check-in-Datei wird **trotzdem** geschrieben (sie ist der
|
|
157
|
+
Flottenintegration vorhanden, Lease `none` oder `unreadable`: die Check-in-Datei wird **trotzdem** geschrieben (sie ist der
|
|
136
158
|
zählbare Beleg), die Standard-Auflagen gelten sofort, es geht keine Nachricht ab. Kopflose Läufe
|
|
137
159
|
sind per Nachricht nicht erreichbar und nutzen dieselben Dateien: vor jeder Welle `auflagen/` lesen.
|
|
138
160
|
|
|
@@ -40,7 +40,7 @@ The `session-type: none` + `status: idle` combination is used only for bootstrap
|
|
|
40
40
|
| `## Wave History` | Completed wave records | wave-executor (post-wave) |
|
|
41
41
|
| `## Deviations` | Plan adaptation log | wave-executor (step 3) |
|
|
42
42
|
| `## What Not To Retry` | Failed/abandoned approaches not to repeat (#623) | session-end (Phase 1.6) |
|
|
43
|
-
| `## Open Questions` | Unresolved user-facing questions (agent → gate → next session) | wave-executor (inter-wave) + session-end (marks answered) |
|
|
43
|
+
| `## Open Questions` | Unresolved user-facing questions (agent → gate → next session) | wave-executor (inter-wave) + session-end (marks answered; appends one vault-orphan entry at Phase 3.7e, #1563) |
|
|
44
44
|
|
|
45
45
|
Wave History lines MAY include a `→ issue #NNN` suffix (or `→ existing #NNN` when a duplicate was detected) for SPIRAL/FAILED agents, linking to the auto-created carryover issue (#261). This is optional and backward-compatible; readers that do not recognize the notation can skip it. Session-end Phase 1.6 uses the presence of this suffix to decide whether to retro-file a carryover as a fallback safety net.
|
|
46
46
|
|
|
@@ -139,8 +139,8 @@ growing across sessions is the CCU-009 anti-pattern.
|
|
|
139
139
|
| Skill | Access | Operations |
|
|
140
140
|
|-------|--------|------------|
|
|
141
141
|
| **wave-executor** | Read + Write (owner) | Creates STATE.md (Pre-Wave 1b), updates after each wave (current-wave, Wave History, Deviations); appends deduped `## Open Questions` at inter-wave checkpoints via `appendOpenQuestionOnDisk` (see `wave-loop.md` § 3e + Post-Wave step 6). |
|
|
142
|
-
| **session-end** | Read + Status-only write | Reads for metrics extraction (Phase 1.7), sets `status: completed` (Phase 3.4). Exception: only fields modified are `status` in frontmatter and marking entries answered in `## Open Questions` via `markOpenQuestionAnsweredOnDisk` (Close Handover-Alignment-Gate). |
|
|
143
|
-
| **session-start** | Read + conditional reset | Reads for continuity checks (Phase 1.5): inspects `status` field to detect crashed/paused sessions. Surfaces `## What Not To Retry` as a forced-read HISTORICAL block (Phase 6.5.1). May reset STATE.md to idle at the boundary between a completed session and a new session — only when prior `status: completed
|
|
142
|
+
| **session-end** | Read + Status-only write | Reads for metrics extraction (Phase 1.7), sets `status: completed` (Phase 3.4). Exception: only fields modified are `status` in frontmatter and marking entries answered in `## Open Questions` via `markOpenQuestionAnsweredOnDisk` (Close Handover-Alignment-Gate), plus at most one appended `## Open Questions` entry naming vault-orphan candidates via `appendOpenQuestionOnDisk` (Phase 3.7e, #1563). |
|
|
143
|
+
| **session-start** | Read + conditional reset | Reads for continuity checks (Phase 1.5): inspects `status` field to detect crashed/paused sessions. Surfaces `## What Not To Retry` as a forced-read HISTORICAL block (Phase 6.5.1). May reset STATE.md to idle at the boundary between a completed session and a new session — only when prior `status: completed`, via `scripts/state-md-idle-reset.mjs` (#1560). The reset keeps the five recommendation keys in the frontmatter for the Phase 7.5 Mode-Selector, clears `current-wave` (→ 0), sets `status: idle`, demotes `## Wave History` into `## Previous Session`, and empties `## Deviations` — but PRESERVES `## What Not To Retry` (cross-session continuity, #623) and `## Open Questions` (Close Handover-Alignment-Gate, PRD 2026-07-07). Never resets on `active` or `paused` (those paths are user-interactive). |
|
|
144
144
|
| **evolve** | Read-only | Reads `## Deviations` section for deviation pattern extraction (Step 2.2, pattern 5) |
|
|
145
145
|
|
|
146
146
|
### Shared-File Single-Writer Rule (`isolation: none` waves)
|
|
@@ -249,8 +249,9 @@ import { detectPeers } from '$PLUGIN_ROOT/scripts/lib/session-registry.mjs';
|
|
|
249
249
|
const flags = parseFlags(process.argv.slice(2));
|
|
250
250
|
|
|
251
251
|
const modeSelector = async () => {
|
|
252
|
-
// Each iteration rebuilds signals from current disk state. STATE.md will
|
|
253
|
-
//
|
|
252
|
+
// Each iteration rebuilds signals from current disk state. STATE.md will
|
|
253
|
+
// carry the recommendation the previous /close wrote (the idle reset at the
|
|
254
|
+
// next session-start keeps those keys, #1560), sessions.jsonl will have the
|
|
254
255
|
// new tail entry, etc. This is the contract: live signals every iteration.
|
|
255
256
|
// `repoRoot` is passed explicitly (#1071) rather than left to the helper's
|
|
256
257
|
// cwd default. Do NOT hand-write a `backlogLimit` here — the window default
|
|
@@ -555,10 +555,11 @@ function buildSurfaceDescriptors(vaultDir, commandsDir) {
|
|
|
555
555
|
id: 'test-count',
|
|
556
556
|
noun: 'test files',
|
|
557
557
|
actual: countTestFiles(vaultDir),
|
|
558
|
-
// "N test files"
|
|
558
|
+
// "N test files" or "N+ test files" (an explicit lower bound).
|
|
559
|
+
// The README badge claims a runtime PASS count
|
|
559
560
|
// ("9303 tests"), which a static checker cannot derive, so we match only
|
|
560
|
-
// the explicit file-count phrasing.
|
|
561
|
-
claimRe: /\b(\d+)
|
|
561
|
+
// the explicit file-count phrasing.
|
|
562
|
+
claimRe: /\b(\d+)(\+)?\s+test\s+files?\b/gi,
|
|
562
563
|
skipMsg: 'test-count: no tests/ directory found',
|
|
563
564
|
},
|
|
564
565
|
];
|
|
@@ -1831,7 +1832,7 @@ function main() {
|
|
|
1831
1832
|
}
|
|
1832
1833
|
}
|
|
1833
1834
|
|
|
1834
|
-
// Surface
|
|
1835
|
+
// Surface counts are exact, except explicit "N+ test files" lower bounds.
|
|
1835
1836
|
// A surface only fires when the doc makes an explicit numeric claim that
|
|
1836
1837
|
// its regex matches; an unclaimed surface produces no errors (skip).
|
|
1837
1838
|
for (const surface of activeSurfaces) {
|
|
@@ -1840,10 +1841,11 @@ function main() {
|
|
|
1840
1841
|
let m;
|
|
1841
1842
|
while ((m = re.exec(line)) !== null) {
|
|
1842
1843
|
const claimed = parseInt(m[1], 10);
|
|
1843
|
-
|
|
1844
|
+
const lowerBound = surface.id === 'test-count' && m[2] === '+';
|
|
1845
|
+
if (lowerBound ? surface.actual < claimed : claimed !== surface.actual) {
|
|
1844
1846
|
const err = {
|
|
1845
1847
|
check: surface.id, file: rel, line: lineNum,
|
|
1846
|
-
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}`,
|
|
1847
1849
|
extracted: m[0],
|
|
1848
1850
|
count: { surface: surface.id, actual: surface.actual, claimed },
|
|
1849
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.
|