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
|
@@ -146,6 +146,7 @@ import { join, resolve, sep } from 'node:path';
|
|
|
146
146
|
import { atomicWriteWithBackup } from '../io.mjs';
|
|
147
147
|
import { readLearnings } from '../learnings/io.mjs';
|
|
148
148
|
import { listMachineGeneratedRules } from '../instruction-budget-guard.mjs';
|
|
149
|
+
import { parseGlobsFrontmatter, ruleExpiryInstantMs } from '../rule-loader.mjs';
|
|
149
150
|
import { markCandidateProcessed } from './idempotency.mjs';
|
|
150
151
|
|
|
151
152
|
/** Repo-relative learnings store — the only source of a per-entry date. */
|
|
@@ -290,6 +291,18 @@ export function parseConsolidatedRule(content) {
|
|
|
290
291
|
break;
|
|
291
292
|
}
|
|
292
293
|
}
|
|
294
|
+
// The VALUE is taken the loader's way (quote-stripped): a hand-edited
|
|
295
|
+
// `"2026-06-01"` would otherwise reach Date.parse with its quotes and be read
|
|
296
|
+
// as LOCAL midnight — up to 26 h before the loader stops injecting the rule.
|
|
297
|
+
// The line index above stays the rewrite anchor.
|
|
298
|
+
if (expiresAtLine >= 0) {
|
|
299
|
+
try {
|
|
300
|
+
const loaderValue = parseGlobsFrontmatter(String(content ?? '')).meta['expires-at'];
|
|
301
|
+
if (typeof loaderValue === 'string') expiresAt = loaderValue;
|
|
302
|
+
} catch {
|
|
303
|
+
// frontmatter the loader rejects — keep the raw value (fail-open downstream)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
293
306
|
|
|
294
307
|
const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
|
|
295
308
|
for (let i = bodyStart; i < lines.length; i += 1) {
|
|
@@ -403,12 +416,23 @@ function dateOnly(iso) {
|
|
|
403
416
|
* `unresolvedPairIds`), so a file whose ids all fail to resolve yields `null`
|
|
404
417
|
* rather than a guess.
|
|
405
418
|
*
|
|
419
|
+
* One exclusion: a markers-only pair whose date lies before `cutoffMs` is
|
|
420
|
+
* dropped. Its substance is already gone, so its elapsed date protects nothing
|
|
421
|
+
* — yet left in, it pins the raise target at or below a header that has already
|
|
422
|
+
* passed, and `rule-loader.mjs` excludes the whole file forever (live
|
|
423
|
+
* 2026-10-04: `guard-design.md` header 2026-10-04 pinned by markers-only
|
|
424
|
+
* `d86c9b5f-…`, next absorbed date 2026-10-07). The pair itself stays in the
|
|
425
|
+
* file and in `N` — it is still a dedupe marker. Expired SUBSTANTIVE pairs stay
|
|
426
|
+
* in the population: raising past one would revive prose not yet swept.
|
|
427
|
+
*
|
|
406
428
|
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
407
429
|
* @param {Map<string, string>} expiryById
|
|
430
|
+
* @param {number} cutoffMs expiry cutoff (now minus grace)
|
|
408
431
|
* @returns {string|null}
|
|
409
432
|
*/
|
|
410
|
-
function earliestResolvableDate(parsed, expiryById) {
|
|
433
|
+
function earliestResolvableDate(parsed, expiryById, cutoffMs) {
|
|
411
434
|
const dates = parsed.pairs
|
|
435
|
+
.filter((p) => !p.markersOnly || !(Date.parse(expiryById.get(p.id) ?? '') < cutoffMs))
|
|
412
436
|
.map((p) => expiryById.get(p.id))
|
|
413
437
|
.filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
|
|
414
438
|
.sort();
|
|
@@ -423,15 +447,20 @@ function earliestResolvableDate(parsed, expiryById) {
|
|
|
423
447
|
* outlives its content is never lowered here — see the module header for why
|
|
424
448
|
* the two directions are not symmetric.
|
|
425
449
|
*
|
|
450
|
+
* Already-expired markers-only pairs do not hold the target down (see
|
|
451
|
+
* {@link earliestResolvableDate}); they stay in the file as dedupe markers and
|
|
452
|
+
* keep counting toward `N`.
|
|
453
|
+
*
|
|
426
454
|
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
427
455
|
* @param {Map<string, string>} expiryById
|
|
456
|
+
* @param {number} cutoffMs expiry cutoff (now minus grace)
|
|
428
457
|
* @returns {string|null}
|
|
429
458
|
*/
|
|
430
|
-
function headerRaiseTarget(parsed, expiryById) {
|
|
459
|
+
function headerRaiseTarget(parsed, expiryById, cutoffMs) {
|
|
431
460
|
if (!parsed.expiresAt || parsed.expiresAtLine < 0) return null;
|
|
432
461
|
const headerMs = Date.parse(parsed.expiresAt);
|
|
433
462
|
if (!Number.isFinite(headerMs)) return null;
|
|
434
|
-
const earliest = earliestResolvableDate(parsed, expiryById);
|
|
463
|
+
const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
|
|
435
464
|
if (earliest === null) return null;
|
|
436
465
|
const earliestMs = Date.parse(earliest);
|
|
437
466
|
if (!Number.isFinite(earliestMs) || earliestMs <= headerMs) return null;
|
|
@@ -450,15 +479,20 @@ function headerRaiseTarget(parsed, expiryById) {
|
|
|
450
479
|
*
|
|
451
480
|
* Deliberately NOT the `newExpiresAt` population: that one excludes expired
|
|
452
481
|
* pairs so a rewritten file is not instantly expired again. This is a
|
|
453
|
-
* diagnostic about the file AS COMMITTED, so it excludes nothing
|
|
482
|
+
* diagnostic about the file AS COMMITTED, so it excludes nothing — except an
|
|
483
|
+
* already-expired markers-only pair, the same exclusion the raise target makes.
|
|
484
|
+
* Without it, every file the raise (or an expired-entries rewrite) moved past
|
|
485
|
+
* such a pair would carry a permanent advisory naming the elapsed date, i.e.
|
|
486
|
+
* an instruction to lower the header back to where the loader drops the file.
|
|
454
487
|
*
|
|
455
488
|
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
456
489
|
* @param {Map<string, string>} expiryById
|
|
490
|
+
* @param {number} cutoffMs expiry cutoff (now minus grace)
|
|
457
491
|
* @returns {string|undefined} undefined when there is nothing to report
|
|
458
492
|
*/
|
|
459
|
-
function headerAdvisory(parsed, expiryById) {
|
|
493
|
+
function headerAdvisory(parsed, expiryById, cutoffMs) {
|
|
460
494
|
if (!parsed.expiresAt) return undefined;
|
|
461
|
-
const earliest = earliestResolvableDate(parsed, expiryById);
|
|
495
|
+
const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
|
|
462
496
|
if (earliest === null || earliest === parsed.expiresAt) return undefined;
|
|
463
497
|
return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
|
|
464
498
|
}
|
|
@@ -587,8 +621,8 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
587
621
|
// most likely to carry a stale header, `test-hygiene.md` among them — could
|
|
588
622
|
// never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
|
|
589
623
|
// where 6 of 7 have the discrepancy.
|
|
590
|
-
const advisory = headerAdvisory(parsed, expiryById);
|
|
591
|
-
const raiseTo = headerRaiseTarget(parsed, expiryById);
|
|
624
|
+
const advisory = headerAdvisory(parsed, expiryById, cutoffMs);
|
|
625
|
+
const raiseTo = headerRaiseTarget(parsed, expiryById, cutoffMs);
|
|
592
626
|
|
|
593
627
|
if (
|
|
594
628
|
parsed.counterLine === -1 &&
|
|
@@ -596,7 +630,7 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
596
630
|
parsed.pairs.length === 1 &&
|
|
597
631
|
substantive.length === 1 &&
|
|
598
632
|
parsed.expiresAt !== null &&
|
|
599
|
-
|
|
633
|
+
ruleExpiryInstantMs(parsed.expiresAt) !== null
|
|
600
634
|
) {
|
|
601
635
|
// SINGLE-ENTRY file (#1513): one learning, one pair, no `### ` entries,
|
|
602
636
|
// no counter sentence — the shape the reconciliation engine writes per
|
|
@@ -612,7 +646,10 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
612
646
|
// before the unlink, so `/reconcile` does not re-propose it.
|
|
613
647
|
const pair = substantive[0];
|
|
614
648
|
const storeAt = expiryById.has(pair.id) ? Date.parse(expiryById.get(pair.id)) : Number.NaN;
|
|
615
|
-
|
|
649
|
+
// The file's own `expires-at` goes through the loader's predicate: a bare
|
|
650
|
+
// date is inclusive (#1521), so the file is not deleted while
|
|
651
|
+
// `rule-loader.mjs` still injects it on its last day.
|
|
652
|
+
const fileAt = ruleExpiryInstantMs(parsed.expiresAt);
|
|
616
653
|
const effectiveAt = Number.isFinite(storeAt) ? Math.max(storeAt, fileAt) : fileAt;
|
|
617
654
|
const base = {
|
|
618
655
|
file: rule.file,
|
|
@@ -623,7 +660,7 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
623
660
|
headings: 0,
|
|
624
661
|
substantivePairs: 1,
|
|
625
662
|
};
|
|
626
|
-
if (effectiveAt
|
|
663
|
+
if (effectiveAt <= cutoffMs) {
|
|
627
664
|
plans.push({
|
|
628
665
|
...base,
|
|
629
666
|
expiredPairIds: [pair.id],
|
|
@@ -39,9 +39,10 @@
|
|
|
39
39
|
* pushes an error string.
|
|
40
40
|
*
|
|
41
41
|
* Atomic write strategy (rule files):
|
|
42
|
-
* -
|
|
43
|
-
*
|
|
44
|
-
* so the rule file is never partially visible.
|
|
42
|
+
* - `atomicWriteWithBackup` (`../io.mjs`): content goes to a sibling
|
|
43
|
+
* `.<file>.tmp.<hex>`, then is renamed over the final path. Same-filesystem
|
|
44
|
+
* rename is atomic on POSIX, so the rule file is never partially visible.
|
|
45
|
+
* Mirrors idempotency.mjs pattern.
|
|
45
46
|
*
|
|
46
47
|
* JSONL append strategy (rejected log):
|
|
47
48
|
* - `appendFileSync` for the rejected log — each record is a self-contained line,
|
|
@@ -63,17 +64,16 @@ import {
|
|
|
63
64
|
writeFileSync,
|
|
64
65
|
readFileSync,
|
|
65
66
|
readdirSync,
|
|
66
|
-
renameSync,
|
|
67
67
|
appendFileSync,
|
|
68
68
|
realpathSync,
|
|
69
69
|
rmSync,
|
|
70
70
|
statSync,
|
|
71
71
|
} from 'node:fs';
|
|
72
|
-
import { randomBytes } from 'node:crypto';
|
|
73
72
|
import { tmpdir } from 'node:os';
|
|
74
73
|
import path from 'node:path';
|
|
75
74
|
|
|
76
75
|
import { withFileLock } from '../file-lock.mjs';
|
|
76
|
+
import { atomicWriteWithBackup, envelopeToError } from '../io.mjs';
|
|
77
77
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
78
78
|
import { parseGlobsFrontmatter } from '../rule-loader.mjs';
|
|
79
79
|
import { markCandidateProcessed } from './idempotency.mjs';
|
|
@@ -308,24 +308,18 @@ function resolveDest(item, target, prep, roots, errors) {
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
|
-
* Write `content` to `destPath` atomically via tmp+rename
|
|
311
|
+
* Write `content` to `destPath` atomically via tmp+rename
|
|
312
|
+
* (`atomicWriteWithBackup`, random tmp suffix, tmp unlinked on failure).
|
|
312
313
|
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* under the lock this should not happen, but the pattern is cheap).
|
|
316
|
-
*
|
|
317
|
-
* Throws on filesystem errors — callers must catch.
|
|
314
|
+
* Throws on filesystem errors — callers must catch. The helper returns an
|
|
315
|
+
* envelope; `envelopeToError` re-raises it with the same message and `code`.
|
|
318
316
|
*
|
|
319
317
|
* @param {string} destPath - absolute path of the target rule file.
|
|
320
318
|
* @param {string} content - UTF-8 text content to write.
|
|
321
319
|
*/
|
|
322
320
|
function writeTextAtomic(destPath, content) {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const suffix = randomBytes(4).toString('hex');
|
|
326
|
-
const tmpPath = `${destPath}.${suffix}.tmp`;
|
|
327
|
-
writeFileSync(tmpPath, content, 'utf8');
|
|
328
|
-
renameSync(tmpPath, destPath);
|
|
321
|
+
const res = atomicWriteWithBackup(destPath, content, { tmpPrefix: `.${path.basename(destPath)}.tmp` });
|
|
322
|
+
if (!res.ok) throw envelopeToError(res);
|
|
329
323
|
}
|
|
330
324
|
|
|
331
325
|
/**
|
|
@@ -48,8 +48,9 @@
|
|
|
48
48
|
* Deterministic gating (issue #694) runs after a successful frontmatter parse
|
|
49
49
|
* and applies to BOTH always-on and glob-matched candidate rules — a rule must
|
|
50
50
|
* pass ALL active gates to be included:
|
|
51
|
-
* - Expiry: a rule
|
|
52
|
-
*
|
|
51
|
+
* - Expiry: a rule whose parseable `expires-at` has passed is EXCLUDED (with
|
|
52
|
+
* a mandatory stderr WARN). A bare date is inclusive — the rule loads
|
|
53
|
+
* through that whole UTC day ({@link ruleExpiryInstantMs}, #1521). A malformed `expires-at` never
|
|
53
54
|
* excludes (fail-open).
|
|
54
55
|
* - Mode-gating: when the `mode` param is non-null and the rule declares a
|
|
55
56
|
* `mode` that differs, the rule is EXCLUDED. A null `mode` param disables
|
|
@@ -411,16 +412,41 @@ function metaToEntryFields(meta) {
|
|
|
411
412
|
* @param {unknown} rawExpiry - the raw `expires-at` frontmatter value (or undefined)
|
|
412
413
|
* @param {number} [now] - epoch ms; defaults to `Date.now()`
|
|
413
414
|
* @param {(raw: unknown) => void} [onUnparseable] - called when `expires-at` is present but unparseable
|
|
414
|
-
* @returns {boolean} true only when a PARSEABLE `expires-at`
|
|
415
|
+
* @returns {boolean} true only when `now` has reached a PARSEABLE `expires-at`'s {@link ruleExpiryInstantMs}
|
|
415
416
|
*/
|
|
416
417
|
export function isRuleExpired(rawExpiry, now = Date.now(), onUnparseable) {
|
|
417
418
|
if (rawExpiry === undefined || rawExpiry === null) return false;
|
|
418
|
-
const ts =
|
|
419
|
-
if (
|
|
419
|
+
const ts = ruleExpiryInstantMs(rawExpiry);
|
|
420
|
+
if (ts === null) {
|
|
420
421
|
if (typeof onUnparseable === 'function') onUnparseable(rawExpiry);
|
|
421
422
|
return false;
|
|
422
423
|
}
|
|
423
|
-
return ts
|
|
424
|
+
return ts <= now;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const BARE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
428
|
+
const MS_PER_DAY = 86_400_000;
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The instant (epoch ms) from which a rule with this `expires-at` counts as
|
|
432
|
+
* expired, or `null` when the value is absent or unparseable.
|
|
433
|
+
*
|
|
434
|
+
* `expires-at` is INCLUSIVE (#1521): a bare `YYYY-MM-DD` keeps the rule live
|
|
435
|
+
* through the whole of that UTC day, so the instant is the FOLLOWING UTC
|
|
436
|
+
* midnight. A full timestamp is taken as written. Every consumer that judges
|
|
437
|
+
* "expired" — the loader gate above, `instruction-budget-guard.mjs`
|
|
438
|
+
* (maintenance probe S7) and `reconcile/rule-expiry-sweep.mjs` — goes through
|
|
439
|
+
* this one function so the three never disagree about the boundary day.
|
|
440
|
+
*
|
|
441
|
+
* @param {unknown} rawExpiry
|
|
442
|
+
* @returns {number|null}
|
|
443
|
+
*/
|
|
444
|
+
export function ruleExpiryInstantMs(rawExpiry) {
|
|
445
|
+
if (rawExpiry === undefined || rawExpiry === null) return null;
|
|
446
|
+
const text = String(rawExpiry).trim();
|
|
447
|
+
const ts = Date.parse(text);
|
|
448
|
+
if (Number.isNaN(ts)) return null;
|
|
449
|
+
return BARE_DATE_RE.test(text) ? ts + MS_PER_DAY : ts;
|
|
424
450
|
}
|
|
425
451
|
|
|
426
452
|
/**
|
|
@@ -420,7 +420,8 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
|
|
|
420
420
|
* @param {number} params.wave
|
|
421
421
|
* @param {string} params.stateDir
|
|
422
422
|
* @param {string} [params.session] semantic (or raw) session id to filter on
|
|
423
|
-
* @param {string} [params.eventsPath] defaults to `.orchestrator/metrics/events.jsonl
|
|
423
|
+
* @param {string} [params.eventsPath] defaults to the cwd-relative `.orchestrator/metrics/events.jsonl`;
|
|
424
|
+
* the CLI passes `eventsFilePath(<state-dir>/..)` (the repo's one ledger, #1514)
|
|
424
425
|
* @param {typeof readFileSync} [params.readFile]
|
|
425
426
|
* @param {typeof readdirSync} [params.readDir]
|
|
426
427
|
* @returns {{wave: number, transport: 'observable'|'unobservable', dispatches: number,
|
|
@@ -639,11 +640,20 @@ async function mainVerify(args) {
|
|
|
639
640
|
return 1;
|
|
640
641
|
}
|
|
641
642
|
|
|
643
|
+
// #1514 point 1 — read the ledger the send-side hook WRITES: the one ledger
|
|
644
|
+
// of the repo this state dir belongs to (`eventsFilePath(<root>)`, a linked
|
|
645
|
+
// worktree mapped to its main checkout), not a cwd-relative path that, run
|
|
646
|
+
// from an entered worktree, found 0 `scope_checked` records. Resolved here,
|
|
647
|
+
// not in `verifyWaveScope`, so this module stays pure (stdlib +
|
|
648
|
+
// `crypto-digest-utils.mjs`) for the hook that imports it.
|
|
649
|
+
const eventsPath = typeof args.events === 'string'
|
|
650
|
+
? args.events
|
|
651
|
+
: (await import('./events.mjs')).eventsFilePath(resolve(stateDir, '..'));
|
|
642
652
|
const report = verifyWaveScope({
|
|
643
653
|
wave,
|
|
644
654
|
stateDir,
|
|
645
655
|
session: typeof args.session === 'string' ? args.session : undefined,
|
|
646
|
-
eventsPath
|
|
656
|
+
eventsPath,
|
|
647
657
|
});
|
|
648
658
|
|
|
649
659
|
if (args.emit) {
|
|
@@ -405,7 +405,8 @@ export function testSiblingExpansionApplies(opts = {}) {
|
|
|
405
405
|
* tests/lib/X.test.mjs`). The DATED figures — population, both ratios, the
|
|
406
406
|
* measuring command and the SHA — live in ONE place:
|
|
407
407
|
* skills/wave-executor/references/wave-loop-scope-manifest.md § Test-Sibling
|
|
408
|
-
* Expansion (#970)
|
|
408
|
+
* Expansion (#970), measured by `scripts/lib/test-sibling-coverage.mjs` (#1030 P3).
|
|
409
|
+
* Cite and re-measure them there, never restate them here: an
|
|
409
410
|
* undated copy drifted to a second, contradicting set of numbers (#1026.5,
|
|
410
411
|
* PSA-006 item 4). The glob's failure mode is HARMLESS — it grants write access
|
|
411
412
|
* to files that may not exist. A computed concrete path is wrong more often AND
|
|
@@ -66,7 +66,7 @@ import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs'
|
|
|
66
66
|
import { canonicalizeSessions } from './sessions-canonical.mjs';
|
|
67
67
|
// Already in the hook import set (`emitEvent` lives there): the rotation-aware
|
|
68
68
|
// reader adds no new file to the SessionStart/SessionEnd hook graph.
|
|
69
|
-
import { scanEventsBackwards } from './events.mjs';
|
|
69
|
+
import { eventsFilePath, scanEventsBackwards } from './events.mjs'; // eventsFilePath: #1514, the repo's one ledger
|
|
70
70
|
// Leaf constants module (no imports of its own) and ALREADY in the hook import
|
|
71
71
|
// set via session-schema/validator.mjs — importing it here adds no new file to
|
|
72
72
|
// the SessionStart/SessionEnd hook graph. The profile enum must not be
|
|
@@ -114,7 +114,6 @@ const EVENT_STOPPED = 'orchestrator.session.stopped';
|
|
|
114
114
|
const EVENT_TURN_STOPPED = 'orchestrator.turn.stopped';
|
|
115
115
|
const EVENT_ENDED = 'orchestrator.session.ended';
|
|
116
116
|
|
|
117
|
-
const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
|
|
118
117
|
const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
|
|
119
118
|
const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.log'];
|
|
120
119
|
|
|
@@ -973,7 +972,7 @@ export async function backfillAbandonedSession({
|
|
|
973
972
|
return { action: 'skipped-no-identifier' };
|
|
974
973
|
}
|
|
975
974
|
|
|
976
|
-
const eventsPath =
|
|
975
|
+
const eventsPath = eventsFilePath(repoRoot);
|
|
977
976
|
const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
|
|
978
977
|
|
|
979
978
|
// -- Resolve the SEMANTIC record id (sessions.jsonl key) ----------------
|
|
@@ -1368,7 +1367,7 @@ export async function backfillCompletedFromStateMd({
|
|
|
1368
1367
|
if (supersedes) recordId = supersedes;
|
|
1369
1368
|
|
|
1370
1369
|
// -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
|
|
1371
|
-
const eventsPath =
|
|
1370
|
+
const eventsPath = eventsFilePath(repoRoot);
|
|
1372
1371
|
const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
|
|
1373
1372
|
|
|
1374
1373
|
// -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
import { readFile } from 'node:fs/promises';
|
|
43
43
|
import path from 'node:path';
|
|
44
44
|
|
|
45
|
+
import { eventsFilePath } from './events.mjs';
|
|
45
46
|
import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
46
47
|
import { readTailWindow } from './tail-window.mjs';
|
|
47
48
|
import { withStateMdLock } from './session-lock.mjs';
|
|
@@ -207,7 +208,7 @@ const EVENTS_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
|
207
208
|
* @returns {Promise<string[]>} Array of semantic_session_id strings (may include duplicates).
|
|
208
209
|
*/
|
|
209
210
|
async function readSessionIdsFromEvents(repoRoot) {
|
|
210
|
-
const filePath =
|
|
211
|
+
const filePath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
|
|
211
212
|
|
|
212
213
|
let window;
|
|
213
214
|
try {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
import fs from 'node:fs';
|
|
69
69
|
import path from 'node:path';
|
|
70
70
|
|
|
71
|
+
import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
|
|
71
72
|
import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
|
|
72
73
|
import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
|
|
73
74
|
import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-integrity-banner.mjs';
|
|
@@ -716,7 +717,6 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
716
717
|
renameSync = fs.renameSync,
|
|
717
718
|
copyFileSync = fs.copyFileSync,
|
|
718
719
|
unlinkSync = fs.unlinkSync,
|
|
719
|
-
existsSync = fs.existsSync,
|
|
720
720
|
} = deps;
|
|
721
721
|
|
|
722
722
|
const { checkIntegrity = defaultCheckIntegrity } = deps;
|
|
@@ -751,18 +751,14 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
751
751
|
}
|
|
752
752
|
|
|
753
753
|
// -- atomic swap ----------------------------------------------------------
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
/* best-effort cleanup */
|
|
763
|
-
}
|
|
764
|
-
throw err;
|
|
765
|
-
}
|
|
754
|
+
// The helper unlinks its tmp on failure; the backup above keeps its own
|
|
755
|
+
// `.bak-<compact stamp>` schema, so the helper takes none. The injected fs
|
|
756
|
+
// seams (`deps`) are threaded through so a test fake still sees the write.
|
|
757
|
+
const res = atomicWriteWithBackup(file, text, {
|
|
758
|
+
tmpPrefix: `.${path.basename(file)}.tmp`,
|
|
759
|
+
fs: { writeFileSync, renameSync, unlinkSync },
|
|
760
|
+
});
|
|
761
|
+
if (!res.ok) throw envelopeToError(res);
|
|
766
762
|
|
|
767
763
|
// -- post-verification ----------------------------------------------------
|
|
768
764
|
const verdict = verifyWritten({
|
|
@@ -162,11 +162,11 @@ export const AGENT_SUMMARY_FIELDS = Object.freeze(['complete', 'partial', 'faile
|
|
|
162
162
|
* #964). These are NOT in REQUIRED_FIELDS — older entries lacking them validate
|
|
163
163
|
* cleanly. Validator: see `_validateOptionalFields` in validator.mjs.
|
|
164
164
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
165
|
+
* Exhaustive since #986: every optional key `validateSession` inspects is
|
|
166
|
+
* declared here. tests/lib/session-schema/constants.test.mjs takes that census
|
|
167
|
+
* from the validator itself (a Proxy records the keys it reads) and also pins
|
|
168
|
+
* the declared-but-unchecked remainder (`session_start_ref`, `raw_session_id`,
|
|
169
|
+
* `_repair_source`).
|
|
170
170
|
*/
|
|
171
171
|
export const OPTIONAL_FIELDS = Object.freeze([
|
|
172
172
|
'agent_identity',
|
|
@@ -252,4 +252,19 @@ export const OPTIONAL_FIELDS = Object.freeze([
|
|
|
252
252
|
// 2026-10-01), so the counters say which of the two it is.
|
|
253
253
|
'cost_records_priced',
|
|
254
254
|
'cost_records_total',
|
|
255
|
+
// #986 — shape-checked by `_validateOptionalFields` since before this list
|
|
256
|
+
// existed, never declared until the census test made the gap visible.
|
|
257
|
+
'discovery_stats',
|
|
258
|
+
'review_stats',
|
|
259
|
+
'platform',
|
|
260
|
+
'branch',
|
|
261
|
+
'base_branch',
|
|
262
|
+
'notes',
|
|
263
|
+
'duration_seconds',
|
|
264
|
+
'issues_closed',
|
|
265
|
+
'issues_created',
|
|
266
|
+
// `express_path` (canonical boolean, legacy {activated} object tolerated) and
|
|
267
|
+
// its forensic sidecar `_express_path_detail` written by `normalizeSession`.
|
|
268
|
+
'express_path',
|
|
269
|
+
'_express_path_detail',
|
|
255
270
|
]);
|
|
@@ -50,7 +50,7 @@ import path from 'node:path';
|
|
|
50
50
|
import { pathToFileURL } from 'node:url';
|
|
51
51
|
import { promisify } from 'node:util';
|
|
52
52
|
|
|
53
|
-
import { emitEvent } from './events.mjs';
|
|
53
|
+
import { emitEvent, eventsFilePath } from './events.mjs';
|
|
54
54
|
import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE } from './events-schema.mjs';
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -762,7 +762,7 @@ export const PROBES = [
|
|
|
762
762
|
fn: 'checkEventsRetention',
|
|
763
763
|
network: false,
|
|
764
764
|
precondition: ({ repoRoot }) => {
|
|
765
|
-
const metrics = path.
|
|
765
|
+
const metrics = path.dirname(eventsFilePath(repoRoot)); // #1514: the ledger checkEventsRetention reads
|
|
766
766
|
return existsSync(path.join(metrics, 'events.jsonl')) || hasRotationArchive(metrics)
|
|
767
767
|
? null
|
|
768
768
|
: 'no-events-ledger';
|
|
@@ -143,6 +143,7 @@
|
|
|
143
143
|
import { existsSync, readFileSync } from 'node:fs';
|
|
144
144
|
import path from 'node:path';
|
|
145
145
|
|
|
146
|
+
import { eventsFilePath } from './events.mjs';
|
|
146
147
|
import { readLock, DEFAULT_TTL_HOURS } from './session-lock.mjs';
|
|
147
148
|
import { isRealSession } from './session-schema/filters.mjs';
|
|
148
149
|
import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
@@ -150,8 +151,6 @@ import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
|
150
151
|
/** Repo-relative path to the session ledger (one record per closed session). */
|
|
151
152
|
const SESSIONS_PATH = '.orchestrator/metrics/sessions.jsonl';
|
|
152
153
|
|
|
153
|
-
/** Repo-relative path to the generic telemetry stream (one line per event). */
|
|
154
|
-
const EVENTS_PATH = '.orchestrator/metrics/events.jsonl';
|
|
155
154
|
|
|
156
155
|
/** warn threshold: 2x the session-lock default TTL (8h) — imported, not duplicated. */
|
|
157
156
|
export const WARN_THRESHOLD_HOURS = DEFAULT_TTL_HOURS * 2;
|
|
@@ -527,7 +526,7 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
|
527
526
|
const ledger = lastLedgerEntry(sessionRecords);
|
|
528
527
|
if (ledger === null) return null;
|
|
529
528
|
|
|
530
|
-
const eventLines = readJsonlLines(
|
|
529
|
+
const eventLines = readJsonlLines(eventsFilePath(repoRoot)); // #1514: the repo's one ledger
|
|
531
530
|
if (eventLines === null || eventLines.length === 0) return null;
|
|
532
531
|
|
|
533
532
|
const { cutoffMs, ownSessionId } = resolveCutoff(repoRoot, nowMs);
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
* Part of Epic #643 → issue #647 (C2 auto-repair engine).
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
-
import { readFileSync
|
|
52
|
+
import { readFileSync } from 'node:fs';
|
|
53
53
|
import path from 'node:path';
|
|
54
|
-
import {
|
|
54
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
55
55
|
|
|
56
56
|
import { extractCandidates as realExtractCandidates } from './candidate-intake.mjs';
|
|
57
57
|
import {
|
|
@@ -281,13 +281,10 @@ async function defaultApplyConfigRepair(candidate, repoRoot) {
|
|
|
281
281
|
return { ok: true, applied: false, reason: 'already-current (no-op)' };
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
renameSync(tmpFile, abs);
|
|
289
|
-
} catch (err) {
|
|
290
|
-
return { ok: true, applied: false, reason: `write failed: ${err?.message ?? String(err)}` };
|
|
284
|
+
// The helper's mkdir -p is a no-op here: `abs` was just read, so its dir exists.
|
|
285
|
+
const written = atomicWriteWithBackup(abs, next, { tmpPrefix: `.${path.basename(abs)}.tmp` });
|
|
286
|
+
if (!written.ok) {
|
|
287
|
+
return { ok: true, applied: false, reason: `write failed: ${written.error}` };
|
|
291
288
|
}
|
|
292
289
|
|
|
293
290
|
return { ok: true, applied: true };
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
* Part of Epic #643 → issue #647 (C2 auto-repair engine).
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
48
|
+
import { readFileSync } from 'node:fs';
|
|
49
|
+
import { isAbsolute, join } from 'node:path';
|
|
50
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* @typedef {import('./candidate-intake.mjs').RepairCandidate} RepairCandidate
|
|
@@ -202,18 +202,16 @@ function readStore(absPath) {
|
|
|
202
202
|
* @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
|
|
203
203
|
*/
|
|
204
204
|
function writeStore(absPath, records) {
|
|
205
|
+
let content;
|
|
205
206
|
try {
|
|
206
|
-
const dir = dirname(absPath);
|
|
207
|
-
mkdirSync(dir, { recursive: true });
|
|
208
207
|
const body = records.map((r) => JSON.stringify(r)).join('\n');
|
|
209
|
-
|
|
210
|
-
const tmpFile = join(dir, `.repair-candidates.${randomBytes(6).toString('hex')}.tmp`);
|
|
211
|
-
writeFileSync(tmpFile, content, { encoding: 'utf8' });
|
|
212
|
-
renameSync(tmpFile, absPath);
|
|
213
|
-
return { ok: true, lines: records.length };
|
|
208
|
+
content = records.length > 0 ? body + '\n' : '';
|
|
214
209
|
} catch (err) {
|
|
215
210
|
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
216
211
|
}
|
|
212
|
+
// mkdir -p + tmp + rename; its failure envelope is this function's own.
|
|
213
|
+
const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.repair-candidates.tmp' });
|
|
214
|
+
return res.ok ? { ok: true, lines: records.length } : res;
|
|
217
215
|
}
|
|
218
216
|
|
|
219
217
|
// ---------------------------------------------------------------------------
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import { execFile } from 'node:child_process';
|
|
44
44
|
import fs from 'node:fs';
|
|
45
45
|
import path from 'node:path';
|
|
46
|
+
import { atomicWriteWithBackup } from './io.mjs';
|
|
46
47
|
|
|
47
48
|
// ---------------------------------------------------------------------------
|
|
48
49
|
// Constants
|
|
@@ -164,15 +165,16 @@ function ensureCacheLoaded(repoRoot) {
|
|
|
164
165
|
function persistCache(repoRoot) {
|
|
165
166
|
if (_cache === null) return;
|
|
166
167
|
const file = cachePathFor(repoRoot);
|
|
167
|
-
|
|
168
|
+
let body;
|
|
168
169
|
try {
|
|
169
|
-
|
|
170
|
-
const obj = Object.fromEntries(_cache.entries());
|
|
171
|
-
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
172
|
-
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
173
|
-
fs.renameSync(tmp, file);
|
|
170
|
+
body = JSON.stringify(Object.fromEntries(_cache.entries()), null, 2) + '\n';
|
|
174
171
|
} catch (err) {
|
|
175
|
-
console.warn(`slopcheck: cache write failed (
|
|
172
|
+
console.warn(`slopcheck: cache write failed (serialize): ${err.message}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const res = atomicWriteWithBackup(file, body, { tmpPrefix: `.${path.basename(file)}.tmp` });
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
console.warn(`slopcheck: cache write failed (${res.code ?? 'unknown'}): ${res.error}`);
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
|
|
@@ -606,8 +606,8 @@ export function staticReferenceScan(repoRoot, { kind, name }) {
|
|
|
606
606
|
* NOTE since the 2026-09-16 command→skill fold (#1370): this linkage now covers
|
|
607
607
|
* almost nothing. `commands/` holds two files (`session`, `templates-ack`), so
|
|
608
608
|
* `skillToCommands` is near-empty by construction — the operator entry point for
|
|
609
|
-
* every other slash command is
|
|
610
|
-
* own frontmatter, not a command file pointing at it. `runSunsetWalk` therefore
|
|
609
|
+
* every other slash command is a skill whose `user-invocable` is `true` or absent
|
|
610
|
+
* (Claude Code picker semantics, #1515) in its own frontmatter, not a command file pointing at it. `runSunsetWalk` therefore
|
|
611
611
|
* threads that flag alongside this linkage, and `classifyItem` treats the two as
|
|
612
612
|
* equivalent evidence of "an operator can invoke this directly". Without that, 26
|
|
613
613
|
* live slash commands would have been demoted the day their command file was
|
|
@@ -783,7 +783,7 @@ export function classifyItem({
|
|
|
783
783
|
const strictRefs = staticRefs.strictRefs;
|
|
784
784
|
const nonBoilerplateRefs = staticRefs.nonBoilerplateRefs;
|
|
785
785
|
const invokedByCommands = linkage?.invokedByCommands ?? [];
|
|
786
|
-
// #1370 fold:
|
|
786
|
+
// #1370 fold: a picker-visible skill (`user-invocable` true or absent, #1515) is the
|
|
787
787
|
// post-fold form of "an operator invokes this directly" — the same evidence a
|
|
788
788
|
// command file used to carry. Default false so older callers/tests that do not
|
|
789
789
|
// thread it behave exactly as before.
|
|
@@ -42,6 +42,7 @@ import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
|
|
|
42
42
|
import { loadOwnerConfig } from '../owner-yaml.mjs';
|
|
43
43
|
import { readJsonlFile } from '../io.mjs';
|
|
44
44
|
import { readEventsWithRotations } from '../events.mjs';
|
|
45
|
+
import { resolveEventsLedgerRoot } from '../platform.mjs';
|
|
45
46
|
import { readCanonicalSessions } from '../sessions-canonical.mjs';
|
|
46
47
|
import { resolvePrivateConfigDir } from '../config/private-config-dir.mjs';
|
|
47
48
|
import { readSessionProfile } from '../state-md.mjs';
|
|
@@ -382,7 +383,8 @@ export function deriveSessionFromEvents(metricsDir) {
|
|
|
382
383
|
// once-per-flush cold read. Revisit if `max-size-mb` is raised past ~100,
|
|
383
384
|
// if `max-backups` grows, or if the flush budget drops below ~1 s.
|
|
384
385
|
const ledger = readEventsWithRotations(undefined, {
|
|
385
|
-
|
|
386
|
+
// #1514: the repo's one ledger (identity unless metricsDir is in a linked worktree).
|
|
387
|
+
filePath: path.join(resolveEventsLedgerRoot(metricsDir), 'events.jsonl'),
|
|
386
388
|
});
|
|
387
389
|
const events = ledger.events;
|
|
388
390
|
// A missing archive is a GAP, never an empty window (#1407 AC-3). Carried
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { existsSync } from 'node:fs';
|
|
35
|
-
import path from 'node:path';
|
|
36
35
|
|
|
36
|
+
import { eventsFilePath } from './events.mjs';
|
|
37
37
|
import { readTailWindow } from './tail-window.mjs';
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -122,7 +122,7 @@ export function checkTelemetryFlushHealth({ repoRoot } = {}) {
|
|
|
122
122
|
try {
|
|
123
123
|
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
124
124
|
|
|
125
|
-
const file =
|
|
125
|
+
const file = eventsFilePath(repoRoot); // #1514: the repo's one ledger
|
|
126
126
|
if (!existsSync(file)) return null;
|
|
127
127
|
|
|
128
128
|
const tail = readTail(file);
|