session-orchestrator 5.7.0 → 5.8.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/.agents/skills/architecture/SKILL.md +3 -0
- package/.agents/skills/architecture/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
- package/.agents/skills/eval/SKILL.md +1 -1
- package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
- package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
- package/.agents/skills/hook-development/SKILL.md +1 -0
- package/.agents/skills/mcp-builder/SKILL.md +1 -0
- package/.agents/skills/tmux-layout/SKILL.md +3 -0
- package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +1 -0
- package/.agents/skills/vault-sync/SKILL.md +1 -0
- package/.agents/skills/write-executable-plan/SKILL.md +3 -0
- package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/architecture/SKILL.md +3 -0
- package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
- package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
- package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
- package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
- package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
- package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
- package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
- package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
- package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
- package/.cursor/commands/architecture.md +13 -0
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/tmux-layout.md +13 -0
- package/.cursor/commands/write-executable-plan.md +13 -0
- package/.cursor/skills/architecture/SKILL.md +0 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/tmux-layout/SKILL.md +0 -1
- package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +10 -9
- package/agents/eval-judge.md +5 -5
- package/docs/baseline.md +4 -3
- package/docs/codex-setup.md +6 -4
- package/docs/components.md +5 -4
- package/docs/events-schema.md +4 -3
- package/docs/github-mirror-protection.md +3 -2
- package/docs/install.md +39 -3
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +2 -2
- package/docs/session-config-reference.md +7 -5
- package/docs/session-config-template.md +3 -2
- package/evals/README.md +60 -0
- package/evals/instruction-ablation/README.md +147 -0
- package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
- package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
- package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
- package/evals/instruction-ablation/run.mjs +336 -0
- package/evals/psa-003-foreign-work/case.yaml +16 -0
- package/evals/psa-003-foreign-work/fixture.sh +12 -0
- package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
- package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
- package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
- package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
- package/evals/psa-003-foreign-work/prompt.md +6 -0
- package/hooks/_lib/hook-import-set.json +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/post-tool-batch-wave-signal.mjs +1 -0
- package/hooks/post-tooluse-frontend-slop.mjs +11 -1
- package/hooks/pre-task-scope-disjoint.mjs +8 -6
- package/monitors/monitors.json +1 -1
- package/package.json +3 -1
- package/pi/prompts/architecture.md +11 -0
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/tmux-layout.md +11 -0
- package/pi/prompts/write-executable-plan.md +11 -0
- package/scripts/backfill-abandoned-sessions.mjs +2 -2
- package/scripts/backfill-sessions.mjs +9 -7
- package/scripts/codex-install.mjs +108 -23
- package/scripts/emit-session.mjs +15 -2
- package/scripts/fleet-auflagen.mjs +54 -0
- package/scripts/generate-agents-skills.mjs +9 -16
- package/scripts/generate-codex-skills.mjs +8 -6
- package/scripts/generate-cursor-adapter.mjs +9 -74
- package/scripts/generate-pi-prompts.mjs +8 -74
- package/scripts/github-protection-audit.mjs +8 -0
- package/scripts/lib/auto-dialectic.mjs +10 -9
- package/scripts/lib/auto-dream.mjs +8 -10
- package/scripts/lib/autopilot/telemetry.mjs +12 -6
- package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
- package/scripts/lib/config/drift-check.mjs +1 -1
- package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/convergence-monitor.mjs +5 -3
- package/scripts/lib/ecosystem-health.mjs +5 -3
- package/scripts/lib/eval/engine.mjs +61 -27
- package/scripts/lib/eval/judge.mjs +14 -8
- package/scripts/lib/eval/session-resolve.mjs +7 -2
- package/scripts/lib/events-retention-banner.mjs +5 -2
- package/scripts/lib/events.mjs +19 -5
- package/scripts/lib/fleet-protocol.mjs +98 -1
- package/scripts/lib/instruction-budget-guard.mjs +25 -18
- package/scripts/lib/io.mjs +172 -35
- package/scripts/lib/learnings/candidates.mjs +7 -28
- package/scripts/lib/learnings/io.mjs +7 -2
- package/scripts/lib/learnings/judgment.mjs +5 -8
- package/scripts/lib/learnings/select.mjs +3 -3
- package/scripts/lib/maintenance-due-banner.mjs +2 -2
- package/scripts/lib/memory-proposals/sink.mjs +5 -6
- package/scripts/lib/memory-proposals/store.mjs +3 -5
- package/scripts/lib/mirror-issues-banner.mjs +11 -1
- package/scripts/lib/native-command.mjs +48 -0
- package/scripts/lib/orphan-reaper/scan.mjs +29 -3
- package/scripts/lib/peer-cards/writer.mjs +6 -18
- package/scripts/lib/platform.mjs +113 -4
- package/scripts/lib/plugin-package-stage.mjs +169 -0
- package/scripts/lib/plugin-surface.mjs +121 -0
- package/scripts/lib/quality-gate.mjs +30 -25
- package/scripts/lib/reconcile/idempotency.mjs +9 -10
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
- package/scripts/lib/reconcile/writer.mjs +11 -17
- package/scripts/lib/rule-loader.mjs +32 -6
- package/scripts/lib/scope-echo.mjs +12 -2
- package/scripts/lib/scope-gate.mjs +2 -1
- package/scripts/lib/session-close-backfill.mjs +3 -4
- package/scripts/lib/session-id.mjs +2 -1
- package/scripts/lib/session-record-repair.mjs +9 -13
- package/scripts/lib/session-schema/constants.mjs +20 -5
- package/scripts/lib/session-start-probes.mjs +2 -2
- package/scripts/lib/sessions-staleness-banner.mjs +2 -3
- package/scripts/lib/skill-evolution/engine.mjs +6 -9
- package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
- package/scripts/lib/slopcheck.mjs +9 -7
- package/scripts/lib/sunset/walker.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +3 -1
- package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
- package/scripts/lib/test-sibling-coverage.mjs +167 -0
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
- package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
- package/scripts/lib/user-invocable-skills.mjs +37 -10
- package/scripts/lib/ux-grill/run-record.mjs +5 -3
- package/scripts/lib/validate/check-banner-parity.mjs +1 -0
- package/scripts/lib/validate/check-skills.mjs +12 -2
- package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
- package/scripts/lib/validate/check-test-value-bans.mjs +3 -2
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/vault-archive.mjs +2 -4
- package/scripts/lib/vault-mirror/process.mjs +207 -26
- package/scripts/lib/vault-mirror/utils.mjs +16 -1
- package/scripts/lib/vault-sync-baseline.mjs +9 -8
- package/scripts/lib/wave-transcript-tail.mjs +8 -4
- package/scripts/lib/worktree/meta.mjs +7 -3
- package/scripts/mcp-server.mjs +158 -0
- package/scripts/migrate-sessions-jsonl.mjs +7 -5
- package/scripts/migrate-vault-paths.mjs +7 -4
- package/scripts/print-learnings-index.mjs +2 -1
- package/scripts/relocate-vault-corpus.mjs +5 -3
- package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
- package/scripts/self-update.mjs +179 -39
- package/scripts/site-numbers.mjs +4 -3
- package/scripts/sweep-expired-learnings.mjs +6 -4
- package/scripts/sync-vault-schema.mjs +3 -7
- package/scripts/windows-setup.mjs +45 -0
- package/skills/_shared/fleet-protocol.md +8 -0
- package/skills/architecture/SKILL.md +1 -0
- package/skills/bootstrap/_shared-template.md +5 -2
- package/skills/claude-md-drift-check/SKILL.md +4 -1
- package/skills/claude-md-drift-check/checker.mjs +83 -10
- package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
- package/skills/eval/SKILL.md +12 -13
- package/skills/eval/rubric-v3.md +521 -0
- package/skills/frontmatter-guard/SKILL.md +1 -0
- package/skills/gitlab-portfolio/SKILL.md +1 -0
- package/skills/hook-development/SKILL.md +1 -0
- package/skills/mcp-builder/SKILL.md +1 -0
- package/skills/repo-audit/SKILL.md +2 -2
- package/skills/session-end/SKILL.md +32 -1
- package/skills/session-end/vault-operations.md +12 -2
- package/skills/session-start/SKILL.md +2 -2
- package/skills/session-start/phase-7-1-premise-check.md +2 -1
- package/skills/session-start/references/operations-contract.md +4 -1
- package/skills/tmux-layout/SKILL.md +1 -0
- package/skills/vault-mirror/SKILL.md +1 -0
- package/skills/vault-sync/SKILL.md +6 -6
- package/skills/vault-sync/validator.sh +29 -10
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
- package/skills/write-executable-plan/SKILL.md +1 -0
package/scripts/lib/io.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* #457; writeJsonAtomicSync extracted for #558 M1.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { writeFile, rename, mkdir } from 'node:fs/promises';
|
|
19
|
+
import { writeFile, rename, mkdir, copyFile, unlink } from 'node:fs/promises';
|
|
20
20
|
import {
|
|
21
21
|
mkdirSync,
|
|
22
22
|
writeFileSync,
|
|
@@ -888,7 +888,10 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
|
|
|
888
888
|
* @param {object} [opts]
|
|
889
889
|
* @param {number} [opts.indent=2] JSON.stringify indent.
|
|
890
890
|
* @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
|
|
891
|
-
* @returns {{ ok: true } | { ok: false, reason: 'fs-error', error: string }}
|
|
891
|
+
* @returns {{ ok: true } | { ok: false, reason: 'fs-error', error: string, code: string|null }}
|
|
892
|
+
* `code` is the failing call's `err.code` (`'ENOSPC'`, `'EACCES'`, …), or `null`
|
|
893
|
+
* when the error carried none (a serialization TypeError, the partial-adapter
|
|
894
|
+
* refusal). Additive (#1032): `error` keeps its exact text.
|
|
892
895
|
*/
|
|
893
896
|
export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
894
897
|
const { indent = 2, tmpPrefix = '.tmp' } = opts;
|
|
@@ -897,7 +900,7 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
897
900
|
try {
|
|
898
901
|
body = JSON.stringify(data, null, indent) + '\n';
|
|
899
902
|
} catch (err) {
|
|
900
|
-
return
|
|
903
|
+
return _fsErrorEnvelope(err);
|
|
901
904
|
}
|
|
902
905
|
|
|
903
906
|
const res = atomicWriteWithBackup(filePath, body, { tmpPrefix });
|
|
@@ -941,9 +944,10 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
941
944
|
* envelope is returned. Without that, every failed write leaves a
|
|
942
945
|
* `<tmpPrefix>.<hex>` behind — the board writer's failure mode is a retry loop,
|
|
943
946
|
* so the litter accumulates in the operator's vault directory. The cleanup is
|
|
944
|
-
* attempted
|
|
945
|
-
*
|
|
946
|
-
* neither may mask the original
|
|
947
|
+
* attempted once the tmp path has been chosen — including a write that created
|
|
948
|
+
* the file and then failed mid-way — and its own failure is swallowed: a leaked
|
|
949
|
+
* tmp is worse than a silent unlink miss, and neither may mask the original
|
|
950
|
+
* error.
|
|
947
951
|
*
|
|
948
952
|
* ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
|
|
949
953
|
* THREE PRODUCTION CALL-SITES (re-measured 2026-10-03 @ 0e0c4b08 with
|
|
@@ -964,8 +968,8 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
964
968
|
* `backup: false` and no `fs`, so the backup half and the injection seam still
|
|
965
969
|
* rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
|
|
966
970
|
* migrates the remaining hand-rolled sites, re-check before widening:
|
|
967
|
-
* (a)
|
|
968
|
-
*
|
|
971
|
+
* (a) answered (#1032): the async twin is {@link atomicWriteText}, not a
|
|
972
|
+
* promise mode on this function; and (b) whether rotation
|
|
969
973
|
* belongs here after all (it does only if ≥2 migrated callers want the SAME
|
|
970
974
|
* keep-N). If NO caller ever passes `backup: true` in production, that half is
|
|
971
975
|
* still the part to shrink back.
|
|
@@ -982,7 +986,9 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
982
986
|
* Injectable fs (tests). Omitted methods fall back to `node:fs` — EXCEPT on
|
|
983
987
|
* the backup path, which fails closed (see below).
|
|
984
988
|
* @returns {{ ok: true, path: string, bytes: number, backupPath: string|null }
|
|
985
|
-
* | { ok: false, reason: 'fs-error', error: string }}
|
|
989
|
+
* | { ok: false, reason: 'fs-error', error: string, code: string|null }}
|
|
990
|
+
* `code` is the failing call's `err.code`, or `null` when there was none
|
|
991
|
+
* (additive, #1032 — a caller that must re-throw with the same code can now).
|
|
986
992
|
*/
|
|
987
993
|
export function atomicWriteWithBackup(filePath, body, opts = {}) {
|
|
988
994
|
const {
|
|
@@ -1016,20 +1022,15 @@ export function atomicWriteWithBackup(filePath, body, opts = {}) {
|
|
|
1016
1022
|
// methods are guarded, not just `copyFileSync` — a missing `existsSync` probes
|
|
1017
1023
|
// the real target and silently decides the backup branch from it, which is the
|
|
1018
1024
|
// same escape one step earlier.
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
if (typeof injectedFs[method] !== 'function') {
|
|
1022
|
-
return {
|
|
1023
|
-
ok: false,
|
|
1024
|
-
reason: 'fs-error',
|
|
1025
|
-
error: `partial fs adapter: ${method} required for backup`,
|
|
1026
|
-
};
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1025
|
+
const refusal = _partialAdapterRefusal(backup, injectedFs, ['existsSync', 'copyFileSync']);
|
|
1026
|
+
if (refusal) return refusal;
|
|
1030
1027
|
|
|
1028
|
+
// Assigned BEFORE the write, and cleanup keys on it rather than on the write
|
|
1029
|
+
// having returned: `writeFileSync` can create the tmp file and THEN throw
|
|
1030
|
+
// (ENOSPC / EIO mid-write), and a flag set only after it returned leaked
|
|
1031
|
+
// exactly that file (#1032). Unlinking a tmp that was never created throws
|
|
1032
|
+
// ENOENT, which the cleanup swallows.
|
|
1031
1033
|
let tmpFile = null;
|
|
1032
|
-
let tmpCreated = false;
|
|
1033
1034
|
|
|
1034
1035
|
try {
|
|
1035
1036
|
const dir = dirname(filePath);
|
|
@@ -1037,34 +1038,170 @@ export function atomicWriteWithBackup(filePath, body, opts = {}) {
|
|
|
1037
1038
|
|
|
1038
1039
|
let backupPath = null;
|
|
1039
1040
|
if (backup && fsExists(filePath)) {
|
|
1040
|
-
|
|
1041
|
-
backupPath = `${filePath}.bak-${stamp}`;
|
|
1041
|
+
backupPath = _backupPathFor(filePath, now);
|
|
1042
1042
|
fsCopyFile(filePath, backupPath);
|
|
1043
1043
|
}
|
|
1044
1044
|
|
|
1045
|
-
tmpFile =
|
|
1045
|
+
tmpFile = _tmpPathFor(dir, tmpPrefix);
|
|
1046
1046
|
fsWriteFile(tmpFile, body, encoding);
|
|
1047
|
-
tmpCreated = true;
|
|
1048
1047
|
fsRename(tmpFile, filePath);
|
|
1049
1048
|
|
|
1050
|
-
return {
|
|
1051
|
-
ok: true,
|
|
1052
|
-
path: filePath,
|
|
1053
|
-
bytes: Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding),
|
|
1054
|
-
backupPath,
|
|
1055
|
-
};
|
|
1049
|
+
return { ok: true, path: filePath, bytes: _byteLength(body, encoding), backupPath };
|
|
1056
1050
|
} catch (err) {
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
if (
|
|
1051
|
+
// The name carries 12 hex chars of entropy, so this cannot collide with a
|
|
1052
|
+
// caller's real file.
|
|
1053
|
+
if (tmpFile !== null) {
|
|
1060
1054
|
try {
|
|
1061
1055
|
fsUnlink(tmpFile);
|
|
1062
1056
|
} catch {
|
|
1063
1057
|
// Best-effort: never let cleanup replace the error the caller needs.
|
|
1064
1058
|
}
|
|
1065
1059
|
}
|
|
1066
|
-
return
|
|
1060
|
+
return _fsErrorEnvelope(err);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Async twin of {@link atomicWriteWithBackup} (#1032): same tmp naming
|
|
1066
|
+
* (`<dir>/<tmpPrefix>.<12 hex>`), same backup stamp, SAME envelope — and like
|
|
1067
|
+
* the sync helper it RESOLVES on failure and never rejects. A `promise` mode on
|
|
1068
|
+
* the sync function was rejected on purpose: one contract with two failure
|
|
1069
|
+
* directions (`ok:false` vs. reject) is the bug class this envelope exists to
|
|
1070
|
+
* end. Callers whose contract is to throw re-raise via {@link envelopeToError},
|
|
1071
|
+
* which keeps the message and `err.code`.
|
|
1072
|
+
*
|
|
1073
|
+
* Shared with the sync helper: tmp/backup naming, byte count, the error
|
|
1074
|
+
* envelope and the partial-adapter refusal. The five-step flow itself is
|
|
1075
|
+
* spelled twice — sync calls and awaited calls cannot share one body without a
|
|
1076
|
+
* generator driver, which would put an indirection on the hook-hot sync path
|
|
1077
|
+
* for ~15 lines of saved control flow.
|
|
1078
|
+
*
|
|
1079
|
+
* Backup differs in one detail: it copies and treats `ENOENT` as "first write,
|
|
1080
|
+
* nothing to snapshot" instead of probing `existsSync` first — the same result
|
|
1081
|
+
* without a check-then-act window.
|
|
1082
|
+
*
|
|
1083
|
+
* @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
|
|
1084
|
+
* @param {string|Buffer} body Bytes to write, verbatim. Never inspected.
|
|
1085
|
+
* @param {object} [opts]
|
|
1086
|
+
* @param {BufferEncoding} [opts.encoding='utf8'] Encoding for a string `body`.
|
|
1087
|
+
* @param {boolean} [opts.backup=false] Snapshot the existing file to `.bak-<ISO>` first.
|
|
1088
|
+
* @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
|
|
1089
|
+
* @param {Date} [opts.now] Clock seam for the backup stamp (tests).
|
|
1090
|
+
* @param {{ mkdir?: Function, writeFile?: Function, rename?: Function,
|
|
1091
|
+
* copyFile?: Function, unlink?: Function }} [opts.fs]
|
|
1092
|
+
* Injectable `fs/promises`-shaped object (tests). Omitted methods fall back to
|
|
1093
|
+
* `node:fs/promises` — except `copyFile` under `backup: true`, which fails closed.
|
|
1094
|
+
* @returns {Promise<{ ok: true, path: string, bytes: number, backupPath: string|null }
|
|
1095
|
+
* | { ok: false, reason: 'fs-error', error: string, code: string|null }>}
|
|
1096
|
+
*/
|
|
1097
|
+
export async function atomicWriteText(filePath, body, opts = {}) {
|
|
1098
|
+
const {
|
|
1099
|
+
encoding = 'utf8',
|
|
1100
|
+
backup = false,
|
|
1101
|
+
tmpPrefix = '.tmp',
|
|
1102
|
+
now = new Date(),
|
|
1103
|
+
fs: injectedFs,
|
|
1104
|
+
} = opts;
|
|
1105
|
+
|
|
1106
|
+
const fsMkdir = injectedFs?.mkdir ?? mkdir;
|
|
1107
|
+
const fsWriteFile = injectedFs?.writeFile ?? writeFile;
|
|
1108
|
+
const fsRename = injectedFs?.rename ?? rename;
|
|
1109
|
+
const fsCopyFile = injectedFs?.copyFile ?? copyFile;
|
|
1110
|
+
const fsUnlink = injectedFs?.unlink ?? unlink;
|
|
1111
|
+
|
|
1112
|
+
const refusal = _partialAdapterRefusal(backup, injectedFs, ['copyFile']);
|
|
1113
|
+
if (refusal) return refusal;
|
|
1114
|
+
|
|
1115
|
+
let tmpFile = null;
|
|
1116
|
+
|
|
1117
|
+
try {
|
|
1118
|
+
const dir = dirname(filePath);
|
|
1119
|
+
await fsMkdir(dir, { recursive: true });
|
|
1120
|
+
|
|
1121
|
+
let backupPath = null;
|
|
1122
|
+
if (backup) {
|
|
1123
|
+
const candidate = _backupPathFor(filePath, now);
|
|
1124
|
+
try {
|
|
1125
|
+
await fsCopyFile(filePath, candidate);
|
|
1126
|
+
backupPath = candidate;
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
tmpFile = _tmpPathFor(dir, tmpPrefix);
|
|
1133
|
+
await fsWriteFile(tmpFile, body, encoding);
|
|
1134
|
+
await fsRename(tmpFile, filePath);
|
|
1135
|
+
|
|
1136
|
+
return { ok: true, path: filePath, bytes: _byteLength(body, encoding), backupPath };
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
if (tmpFile !== null) {
|
|
1139
|
+
try {
|
|
1140
|
+
await fsUnlink(tmpFile);
|
|
1141
|
+
} catch {
|
|
1142
|
+
// Best-effort: never let cleanup replace the error the caller needs.
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return _fsErrorEnvelope(err);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Re-raise a failed {@link atomicWriteText} / {@link atomicWriteWithBackup}
|
|
1151
|
+
* envelope as an Error carrying the original message and `code`, for call-sites
|
|
1152
|
+
* whose own contract is to throw (#1032).
|
|
1153
|
+
*
|
|
1154
|
+
* @param {{ error: string, code?: string|null }} envelope
|
|
1155
|
+
* @returns {Error & { code?: string }}
|
|
1156
|
+
*/
|
|
1157
|
+
export function envelopeToError(envelope) {
|
|
1158
|
+
const err = new Error(envelope.error);
|
|
1159
|
+
if (envelope.code) err.code = envelope.code;
|
|
1160
|
+
return err;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** @returns {{ ok: false, reason: 'fs-error', error: string, code: string|null }} */
|
|
1164
|
+
function _fsErrorEnvelope(err) {
|
|
1165
|
+
return {
|
|
1166
|
+
ok: false,
|
|
1167
|
+
reason: 'fs-error',
|
|
1168
|
+
error: err?.message ?? String(err),
|
|
1169
|
+
code: typeof err?.code === 'string' ? err.code : null,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Backup-path fail-closed for a partial injected adapter (see the rationale in
|
|
1175
|
+
* {@link atomicWriteWithBackup}). Returns the refusal envelope, or null.
|
|
1176
|
+
*/
|
|
1177
|
+
function _partialAdapterRefusal(backup, injectedFs, methods) {
|
|
1178
|
+
if (!backup || !injectedFs) return null;
|
|
1179
|
+
for (const method of methods) {
|
|
1180
|
+
if (typeof injectedFs[method] !== 'function') {
|
|
1181
|
+
return {
|
|
1182
|
+
ok: false,
|
|
1183
|
+
reason: 'fs-error',
|
|
1184
|
+
error: `partial fs adapter: ${method} required for backup`,
|
|
1185
|
+
code: null,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1067
1188
|
}
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** `<filePath>.bak-<ISO>` with `:`/`.` → `-`, so a lexical sort is chronological. */
|
|
1193
|
+
function _backupPathFor(filePath, now) {
|
|
1194
|
+
const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
|
|
1195
|
+
return `${filePath}.bak-${stamp}`;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** Sibling tmp path (same filesystem → atomic rename): `<dir>/<tmpPrefix>.<12 hex>`. */
|
|
1199
|
+
function _tmpPathFor(dir, tmpPrefix) {
|
|
1200
|
+
return path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function _byteLength(body, encoding) {
|
|
1204
|
+
return Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding);
|
|
1068
1205
|
}
|
|
1069
1206
|
|
|
1070
1207
|
/**
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
* re-derived (not re-cited) if a threshold is ever revisited.
|
|
49
49
|
*
|
|
50
50
|
* **Stage 0 — corpus prep (once per run).**
|
|
51
|
-
* 1.
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* 1. Canonicalise producer dialects (`files` → `file_paths`) and type
|
|
52
|
+
* aliases (`gotcha` → `anti-pattern`): the caller reads through the
|
|
53
|
+
* existing funnel (`io.mjs::readLearnings` → `schema.mjs::normalizeLearning`),
|
|
54
|
+
* and the entry point re-applies {@link normalizeDialects} so raw
|
|
55
|
+
* in-memory records score the same shape.
|
|
56
56
|
* 2. Drop expired records — they may neither seed a pool nor join one.
|
|
57
57
|
* 3. Derive `learning_key` = `` `${type}/${kebab(title || subject)}` `` using
|
|
58
58
|
* the SHARED {@link kebab}. A divergent kebab does not produce an ugly
|
|
@@ -184,7 +184,6 @@
|
|
|
184
184
|
*/
|
|
185
185
|
|
|
186
186
|
import { tokenize } from './affinity.mjs';
|
|
187
|
-
import { readLearnings } from './io.mjs';
|
|
188
187
|
import { learningKeyOf } from './kebab.mjs';
|
|
189
188
|
import { normalizeDialects } from './schema.mjs';
|
|
190
189
|
|
|
@@ -218,7 +217,7 @@ export const PATH_BOOST_EXACT = 0.05;
|
|
|
218
217
|
export const PATH_BOOST_DIR = 0.025;
|
|
219
218
|
|
|
220
219
|
/** How many leading directory segments define the directory-prefix boost. */
|
|
221
|
-
|
|
220
|
+
const DIR_PREFIX_SEGMENTS = 2;
|
|
222
221
|
|
|
223
222
|
/**
|
|
224
223
|
* Documented ceiling for consumers (accepted failure mode 2): a pool graph walk
|
|
@@ -229,7 +228,7 @@ export const DIR_PREFIX_SEGMENTS = 2;
|
|
|
229
228
|
export const MAX_POOL_HOPS = 2;
|
|
230
229
|
|
|
231
230
|
/** Token length floor — tokens shorter than this are dropped before stoplisting. */
|
|
232
|
-
|
|
231
|
+
const MIN_TOKEN_LENGTH = 3;
|
|
233
232
|
|
|
234
233
|
/** Cap on the reported `sharedTokens` — a diagnostic for the judge, not a payload. */
|
|
235
234
|
const SHARED_TOKEN_CAP = 8;
|
|
@@ -705,23 +704,3 @@ export function buildCandidatePools(records, opts = {}) {
|
|
|
705
704
|
return emptyPools();
|
|
706
705
|
}
|
|
707
706
|
}
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* File entry-point: read through the existing funnel (`readLearnings` →
|
|
711
|
-
* `normalizeLearning`, so dialects and type aliases are canonical), then pool.
|
|
712
|
-
*
|
|
713
|
-
* There is no second reader here on purpose — a private read path would be a
|
|
714
|
-
* second place for the dialect normalization to drift out of.
|
|
715
|
-
*
|
|
716
|
-
* @param {string} filePath — absolute path to learnings.jsonl
|
|
717
|
-
* @param {object} [opts] — everything {@link buildCandidatePools} accepts
|
|
718
|
-
* @returns {Promise<CandidatePools>} {@link emptyPools} on a missing/unreadable file
|
|
719
|
-
*/
|
|
720
|
-
export async function buildCandidatePoolsFromFile(filePath, opts = {}) {
|
|
721
|
-
try {
|
|
722
|
-
const { entries } = await readLearnings(filePath);
|
|
723
|
-
return buildCandidatePools(entries, opts);
|
|
724
|
-
} catch {
|
|
725
|
-
return emptyPools();
|
|
726
|
-
}
|
|
727
|
-
}
|
|
@@ -262,6 +262,11 @@ export async function readLearningsSnapshot(filePath) {
|
|
|
262
262
|
* malformed too (#1500): `normalizeLearning` turned it into a record of
|
|
263
263
|
* defaults alone, and every rewrite wrote that invented record back.
|
|
264
264
|
*
|
|
265
|
+
* A UTF-8 BOM (U+FEFF) in front of the FIRST line — an editor's save — is
|
|
266
|
+
* stripped before parsing, so that line is an entry, not malformed (#1506.5b).
|
|
267
|
+
* A BOM inside the file (line 2+) stays malformed: that is a concatenation
|
|
268
|
+
* artefact the drop-malformed preview reports as `bom`.
|
|
269
|
+
*
|
|
265
270
|
* @param {string} raw
|
|
266
271
|
* @returns {{entries: object[], malformed: string[], malformedLineNumbers: number[]}}
|
|
267
272
|
* `malformedLineNumbers[i]` is the 1-based line of `malformed[i]` in `raw`
|
|
@@ -273,7 +278,7 @@ export function parseLearningsText(raw) {
|
|
|
273
278
|
raw.split('\n').forEach((line, i) => {
|
|
274
279
|
if (line.trim().length === 0) return;
|
|
275
280
|
try {
|
|
276
|
-
const parsed = JSON.parse(line);
|
|
281
|
+
const parsed = JSON.parse(i === 0 && line.charCodeAt(0) === 0xfeff ? line.slice(1) : line);
|
|
277
282
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
278
283
|
throw new TypeError('not a JSON object');
|
|
279
284
|
}
|
|
@@ -414,7 +419,7 @@ const BACKUP_KEEP = 3;
|
|
|
414
419
|
* @param {string} fileName — sibling filename to classify
|
|
415
420
|
* @returns {string|null} the suffix after the delimiter, or `null`
|
|
416
421
|
*/
|
|
417
|
-
|
|
422
|
+
function backupSuffixOf(baseName, fileName) {
|
|
418
423
|
const stem = `${baseName}.bak`;
|
|
419
424
|
if (!fileName.startsWith(stem)) return null;
|
|
420
425
|
const delim = fileName[stem.length];
|
|
@@ -145,10 +145,7 @@ export const JUDGMENT_DECISIONS = Object.freeze([
|
|
|
145
145
|
]);
|
|
146
146
|
|
|
147
147
|
/** Decisions that assert a relation to at least one other record. */
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
/** Decisions that assert no relation (`skip`) or no judgment (`abstain`). */
|
|
151
|
-
export const NO_RELATION_DECISIONS = Object.freeze(['skip', 'abstain']);
|
|
148
|
+
const RELATION_DECISIONS = Object.freeze(['refine', 'supersede', 'merge', 'contradict']);
|
|
152
149
|
|
|
153
150
|
/**
|
|
154
151
|
* The eight failure modes. Closed set — the free-text `detail` field carries
|
|
@@ -174,7 +171,7 @@ export const FAILURE_MODES = Object.freeze([
|
|
|
174
171
|
* on purpose: registering the AUQ in the SAME map as the archive writer is what
|
|
175
172
|
* makes "an AUQ is a write" structural rather than a comment someone can miss.
|
|
176
173
|
*/
|
|
177
|
-
|
|
174
|
+
const EFFECT_BY_DECISION = Object.freeze({
|
|
178
175
|
skip: null,
|
|
179
176
|
abstain: null,
|
|
180
177
|
refine: 'refine',
|
|
@@ -191,7 +188,7 @@ export const EFFECT_BY_DECISION = Object.freeze({
|
|
|
191
188
|
* the corpus develops genuine clusters (max pairwise similarity > ~0.4) or if
|
|
192
189
|
* live judgments start naming a truncated-away record in their rationale.
|
|
193
190
|
*/
|
|
194
|
-
|
|
191
|
+
const DEFAULT_MAX_NEIGHBOURS = 12;
|
|
195
192
|
|
|
196
193
|
/**
|
|
197
194
|
* Judge wall-clock budget. Ceiling: 60s — a judgment pass runs at session-end
|
|
@@ -206,7 +203,7 @@ export const DEFAULT_JUDGE_TIMEOUT_MS = 60_000;
|
|
|
206
203
|
* the judge to lean on `scope` or `host_class` would be steering it by a field
|
|
207
204
|
* that is constant across the corpus.
|
|
208
205
|
*/
|
|
209
|
-
|
|
206
|
+
const SURFACE_HINT = [
|
|
210
207
|
'Judge the SURFACE, not the subject line.',
|
|
211
208
|
'',
|
|
212
209
|
'Measured 2026-08-13 over the 100 live records: `scope` is "local" in 100/100 and',
|
|
@@ -233,7 +230,7 @@ export const SURFACE_HINT = [
|
|
|
233
230
|
* restatement drifts silently, and the failure mode of drift here is that every
|
|
234
231
|
* batch voids and the feature is dead while looking wired.
|
|
235
232
|
*/
|
|
236
|
-
|
|
233
|
+
const JUDGMENT_OUTPUT_CONTRACT = [
|
|
237
234
|
'Answer with JSON only, exactly this shape:',
|
|
238
235
|
'',
|
|
239
236
|
'{"candidate_id": "<the candidate id you were given>",',
|
|
@@ -182,7 +182,7 @@ export const DEFAULT_MAX_GLOBAL = 4;
|
|
|
182
182
|
* overlaps only, so a directory-prefix match scores 0.75 without appearing
|
|
183
183
|
* there. Using it as a proxy would silently drop the strongest partial matches.
|
|
184
184
|
*/
|
|
185
|
-
|
|
185
|
+
const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
188
|
* Blend of relevance (affinity to this agent's scope) against quality
|
|
@@ -190,7 +190,7 @@ export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
|
|
|
190
190
|
* IS the acceptance criterion. Weight-normalized like `affinity()`, so the
|
|
191
191
|
* result stays in [0,1] for any non-negative pair.
|
|
192
192
|
*/
|
|
193
|
-
|
|
193
|
+
const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
196
|
* How many active entries the file entry-point pulls before ranking.
|
|
@@ -496,7 +496,7 @@ export function renderIndexLine(entry, opts = {}) {
|
|
|
496
496
|
* @param {{now?: Date|number, decay?: object, affinityOpts?: object}} [opts]
|
|
497
497
|
* @returns {{score: number, relevance: number, quality: number, pathScore: number}}
|
|
498
498
|
*/
|
|
499
|
-
|
|
499
|
+
function scoreLearning(entry, scope, opts = {}) {
|
|
500
500
|
const zero = { score: 0, relevance: 0, quality: 0, pathScore: 0 };
|
|
501
501
|
if (!_isRecord(entry)) return zero;
|
|
502
502
|
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
import { existsSync, statSync } from 'node:fs';
|
|
74
74
|
import path from 'node:path';
|
|
75
75
|
|
|
76
|
-
import { SCAN_CHUNK_BYTES, scanEventsBackwards } from './events.mjs';
|
|
76
|
+
import { SCAN_CHUNK_BYTES, eventsFilePath, scanEventsBackwards } from './events.mjs';
|
|
77
77
|
import { computeReconcileNudge } from './reconcile-nudge-banner.mjs';
|
|
78
78
|
import { sweepExpiredLearnings } from './learnings/expiry-sweep.mjs';
|
|
79
79
|
import { shouldDispatchAutoDialectic } from './auto-dialectic.mjs';
|
|
@@ -204,7 +204,7 @@ export const EVOLVE_SCAN_BUDGET_MS = 1000;
|
|
|
204
204
|
* `events-rotation.max-backups`), so a miss has not proven "never" either.
|
|
205
205
|
*/
|
|
206
206
|
function readLastEvolveRun(repoRoot) {
|
|
207
|
-
const file =
|
|
207
|
+
const file = eventsFilePath(repoRoot); // #1514: the repo's one ledger
|
|
208
208
|
let lastAt = null;
|
|
209
209
|
const scan = scanEventsBackwards({
|
|
210
210
|
filePath: file,
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
import { appendFile, mkdir, readdir, rm } from 'node:fs/promises';
|
|
45
|
-
import {
|
|
45
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
46
46
|
import path from 'node:path';
|
|
47
|
-
import crypto from 'node:crypto';
|
|
48
47
|
import { appendLearning } from '../learnings/io.mjs';
|
|
49
48
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
49
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
50
50
|
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
52
|
// Path constants (relative to repoRoot)
|
|
@@ -320,10 +320,9 @@ export async function clearProposalsJsonl({ repoRoot }) {
|
|
|
320
320
|
// target — a concurrent hook reading proposals.jsonl mid-clear observes
|
|
321
321
|
// either the pre-clear content or the fully-cleared file, never a
|
|
322
322
|
// partial truncate.
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
renameSync(tmpFile, proposalsPath);
|
|
323
|
+
// Same tmp-name shape as before: `.proposals.jsonl.tmp.<12 hex>`.
|
|
324
|
+
const res = atomicWriteWithBackup(proposalsPath, '', { tmpPrefix: '.proposals.jsonl.tmp' });
|
|
325
|
+
if (!res.ok) return { cleared: false, summariesCleared: 0 };
|
|
327
326
|
} catch {
|
|
328
327
|
return { cleared: false, summariesCleared: 0 };
|
|
329
328
|
}
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import crypto from 'node:crypto';
|
|
20
19
|
import { serializeProposal } from './schema.mjs';
|
|
21
20
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
22
21
|
import { tryAcquireFileLock, releaseFileLock } from '../file-lock.mjs';
|
|
22
|
+
import { writeJsonAtomicSync } from '../io.mjs';
|
|
23
23
|
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
// Constants
|
|
@@ -285,10 +285,8 @@ function incrementSummary(summaryPath, field) {
|
|
|
285
285
|
ensureDir(summaryPath);
|
|
286
286
|
const current = readSummary(summaryPath);
|
|
287
287
|
current[field] = (current[field] ?? 0) + 1;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
fs.writeFileSync(tmpFile, JSON.stringify(current, null, 2) + '\n', 'utf8');
|
|
291
|
-
fs.renameSync(tmpFile, summaryPath);
|
|
288
|
+
// Same bytes (indent 2 + trailing newline); a failure is swallowed as before.
|
|
289
|
+
writeJsonAtomicSync(summaryPath, current, { tmpPrefix: `.${path.basename(summaryPath)}.tmp` });
|
|
292
290
|
} catch {
|
|
293
291
|
/* best-effort: summary writes are non-critical */
|
|
294
292
|
}
|
|
@@ -61,7 +61,9 @@ const MESSAGE_ISSUE_CAP = 5;
|
|
|
61
61
|
*
|
|
62
62
|
* `query-failed` is the residual bucket for a `gh` that ran and exited
|
|
63
63
|
* non-zero for a reason that is neither missing-CLI, timeout, nor auth
|
|
64
|
-
* (network down, repo renamed, rate limit
|
|
64
|
+
* (network down, repo renamed, rate limit — including GitHub's HTTP 403
|
|
65
|
+
* rate-limit answer, which `classifyFailure` checks before its 403 → auth
|
|
66
|
+
* rule). It is a deliberate fifth member
|
|
65
67
|
* beyond the four originally specified: folding those into `parse-error`
|
|
66
68
|
* would mislabel a network failure as malformed output, reintroducing the
|
|
67
69
|
* dishonest-state class this module was built to remove.
|
|
@@ -122,6 +124,14 @@ function classifyFailure(err) {
|
|
|
122
124
|
|
|
123
125
|
const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
|
|
124
126
|
const haystack = `${message}\n${stderr}`.toLowerCase();
|
|
127
|
+
// Rate limit BEFORE the auth check (#1034 item 2): GitHub answers a primary
|
|
128
|
+
// or secondary rate limit with HTTP 403, so the `http 403` test below would
|
|
129
|
+
// otherwise tell the operator to re-authenticate when waiting is the fix. A
|
|
130
|
+
// 403 without rate-limit wording (expired token, missing scope) stays
|
|
131
|
+
// `auth-error`, and so does an UNAUTHENTICATED rate limit, whose fix is to
|
|
132
|
+
// log in. `abuse detection` is GitHub's older wording for the secondary limit.
|
|
133
|
+
const rateLimited = haystack.includes('rate limit') || haystack.includes('abuse detection');
|
|
134
|
+
if (rateLimited && !haystack.includes('authenticated requests get a higher')) return 'query-failed';
|
|
125
135
|
if (
|
|
126
136
|
haystack.includes('gh auth login') ||
|
|
127
137
|
haystack.includes('not logged in') ||
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Shell-free command launching for native Windows and Unix hosts. */
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Windows cannot spawn npm's .cmd shim without cmd.exe. Run its JavaScript
|
|
8
|
+
* entrypoint with this Node instead, preserving each argument literally.
|
|
9
|
+
* Supports Node's bundled npm (including nvm-windows) and npm on PATH.
|
|
10
|
+
* Other commands and Unix retain their normal executable lookup.
|
|
11
|
+
* @param {string} cmd
|
|
12
|
+
* @param {string[]} args
|
|
13
|
+
* @param {object} [context] Host overrides for platform regression tests.
|
|
14
|
+
* @returns {{cmd: string, args: string[]}}
|
|
15
|
+
*/
|
|
16
|
+
export function resolveNativeCommand(cmd, args, {
|
|
17
|
+
platform = process.platform, execPath = process.execPath, env = process.env, exists = existsSync,
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (platform !== 'win32' || !/^npm(?:\.cmd)?$/i.test(cmd)) return { cmd, args };
|
|
20
|
+
const winPath = path.win32;
|
|
21
|
+
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path');
|
|
22
|
+
const dirs = [winPath.dirname(execPath), ...(env[pathKey] || '').split(';')];
|
|
23
|
+
for (const dir of dirs) {
|
|
24
|
+
// Ignore empty/relative PATH entries: never select npm from the working repo.
|
|
25
|
+
const absolute = dir.replace(/^"(.*)"$/, '$1');
|
|
26
|
+
if (!winPath.isAbsolute(absolute)) continue;
|
|
27
|
+
const cli = winPath.join(absolute, 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
28
|
+
if (exists(cli)) return { cmd: execPath, args: [cli, ...args] };
|
|
29
|
+
}
|
|
30
|
+
throw Object.assign(new Error('Cannot locate npm-cli.js beside Node or on PATH; install npm with Node.js.'), { code: 'ENOENT' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Like spawnSync, with shell-free Windows npm support and spawn-style errors. */
|
|
34
|
+
export function spawnNativeSync(cmd, args, options = {}) {
|
|
35
|
+
try {
|
|
36
|
+
const command = resolveNativeCommand(cmd, args, { env: options.env ?? process.env });
|
|
37
|
+
return spawnSync(command.cmd, command.args, { ...options, shell: false });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return { error, status: null, stdout: '', stderr: '' };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Test for a command using the host's executable locator, without a shell. */
|
|
44
|
+
export function commandOnPath(bin) {
|
|
45
|
+
return spawnSync(process.platform === 'win32' ? 'where.exe' : 'which', [bin], {
|
|
46
|
+
encoding: 'utf8', timeout: 10_000, shell: false,
|
|
47
|
+
}).status === 0;
|
|
48
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
GATE_PROCESS_LEDGER_RELPATH,
|
|
11
12
|
killProcessGroup,
|
|
12
13
|
pruneGateProcessLedger,
|
|
13
14
|
readGateProcessLedger,
|
|
@@ -25,9 +26,16 @@ import {
|
|
|
25
26
|
} from './reaper-audit.mjs';
|
|
26
27
|
import { decideReapCandidates, verifyGroupMemberIdentity } from './reaper-decide.mjs';
|
|
27
28
|
|
|
28
|
-
/** Name const for the
|
|
29
|
+
/** Name const for the scan-summary event. @see docs/events-schema.md */
|
|
29
30
|
export const REAPER_SCAN_EVENT = 'orchestrator.reaper.scan_completed';
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Name const for the degraded-scan event (#1505 point 5a, owner decision
|
|
34
|
+
* 2026-10-03). An unreadable process register disables the scan; this record
|
|
35
|
+
* is what makes that countable (HR-105). @see docs/events-schema.md
|
|
36
|
+
*/
|
|
37
|
+
export const LEDGER_UNREADABLE_EVENT = 'orchestrator.ledger.unreadable';
|
|
38
|
+
|
|
31
39
|
/** Default own-session-id reader. Lazily imported so the static import closure
|
|
32
40
|
* of this module stays small — it is destined for a hot-path hook (#1432).
|
|
33
41
|
* @param {string} repoRoot @returns {Promise<string|null>} */
|
|
@@ -237,16 +245,34 @@ export async function runOrphanScan({
|
|
|
237
245
|
|
|
238
246
|
const { rows, malformed } = parsePsSnapshotDetailed(text);
|
|
239
247
|
|
|
248
|
+
// HR-105 (#1505 point 5a): an unreadable register switches the scan off, and
|
|
249
|
+
// the detached hook child ignores the exit 2 that says so — without a record
|
|
250
|
+
// the instrument is off with no trace. Once per degraded scan; the scan runs
|
|
251
|
+
// throttled, so this cannot become a per-fire signal (HR-101). Repo-relative
|
|
252
|
+
// path only, never a host path. A failing emit never fails the scan.
|
|
253
|
+
const unreadable = async (reason) => {
|
|
254
|
+
try {
|
|
255
|
+
await d.emitEvent(LEDGER_UNREADABLE_EVENT, {
|
|
256
|
+
path: GATE_PROCESS_LEDGER_RELPATH,
|
|
257
|
+
reason,
|
|
258
|
+
consumer: 'orphan-reaper',
|
|
259
|
+
}, { repoRoot });
|
|
260
|
+
} catch {
|
|
261
|
+
/* telemetry that can fail a scan would fail the hook the scan runs in */
|
|
262
|
+
}
|
|
263
|
+
return empty('ledger-unreadable', malformed);
|
|
264
|
+
};
|
|
265
|
+
|
|
240
266
|
let ledger;
|
|
241
267
|
try {
|
|
242
268
|
ledger = d.readLedger(repoRoot, { nowMs: startedAt });
|
|
243
269
|
} catch {
|
|
244
|
-
return
|
|
270
|
+
return unreadable('reader-threw');
|
|
245
271
|
}
|
|
246
272
|
// The default reader never throws: a refused or unreadable ledger comes back
|
|
247
273
|
// as no records with `state: 'unreadable'` (#1498). Read as "no register
|
|
248
274
|
// hits" it ran a clean-looking scan that measured nothing.
|
|
249
|
-
if (ledger?.state === 'unreadable') return
|
|
275
|
+
if (ledger?.state === 'unreadable') return unreadable('refused');
|
|
250
276
|
|
|
251
277
|
let ownSessionId = null;
|
|
252
278
|
try {
|