session-orchestrator 5.6.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 +75 -0
- package/README.md +10 -10
- package/agents/eval-judge.md +5 -5
- package/docs/USER-GUIDE.md +1 -1
- 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 +12 -2
- package/docs/session-config-reference.md +8 -6
- package/docs/session-config-template.md +4 -3
- 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/hooks/wave-scope-commit-guard.mjs +41 -10
- 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 +6 -8
- package/scripts/backfill-learnings-from-vault.mjs +17 -6
- 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/dispatcher/rank.mjs +5 -1
- 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 +81 -54
- package/scripts/lib/fleet-protocol.mjs +98 -1
- package/scripts/lib/instruction-budget-guard.mjs +25 -18
- package/scripts/lib/io.mjs +181 -39
- package/scripts/lib/learnings/candidates.mjs +14 -44
- 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 +12 -7
- 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 +97 -8
- 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 +21 -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 +12 -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 +585 -72
- package/scripts/lib/vault-mirror/prune.mjs +413 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
- package/scripts/lib/vault-mirror/retention.mjs +388 -0
- package/scripts/lib/vault-mirror/utils.mjs +16 -1
- package/scripts/lib/vault-status/board-writer.mjs +6 -6
- package/scripts/lib/vault-sync-baseline.mjs +9 -8
- package/scripts/lib/wave-transcript-tail.mjs +19 -6
- 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-applicable-rules.mjs +1 -25
- package/scripts/print-learnings-index.mjs +2 -1
- package/scripts/release.mjs +4 -0
- 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/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- 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 +89 -26
- 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 +28 -2
- 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 +5 -3
- package/skills/write-executable-plan/SKILL.md +1 -0
- package/hooks/agent-teams-h3-test.sh +0 -362
- package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
- package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
- package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
- package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin-surface.mjs — the one enumeration of the plugin's public sources
|
|
3
|
+
* (`commands/*.md`, `skills/<name>/SKILL.md`) and the one hand-written
|
|
4
|
+
* frontmatter parser the Cursor and Pi wrapper generators share (#1240).
|
|
5
|
+
*
|
|
6
|
+
* Four generators derive harness surfaces from the same sources
|
|
7
|
+
* (`generate-agents-skills.mjs`, `generate-codex-skills.mjs`,
|
|
8
|
+
* `generate-cursor-adapter.mjs`, `generate-pi-prompts.mjs`). Before this module
|
|
9
|
+
* the agents/codex pair skipped names with a leading `.` or `_` while the
|
|
10
|
+
* cursor/pi pair did not, so a `commands/_draft.md` produced Cursor and Pi
|
|
11
|
+
* wrappers but no Codex or `.agents` mirror. {@link isHiddenSourceName} is now
|
|
12
|
+
* the single NAME filter all four apply. The file/directory test is not yet
|
|
13
|
+
* shared: the list helpers here follow symlinks (statSync), while
|
|
14
|
+
* generate-codex-skills.mjs keeps its own dirent loop, which does not. No
|
|
15
|
+
* source is a symlink today (`find skills commands -maxdepth 2 -type l` → 0);
|
|
16
|
+
* revisit when one is added.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A source name with a leading `.` or `_` is private (drafts, `_shared/`
|
|
24
|
+
* helpers, dotfiles) and never becomes a public command or skill.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} name file or directory name
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isHiddenSourceName(name) {
|
|
30
|
+
return /^[._]/.test(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isDir(p) {
|
|
34
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isFile(p) {
|
|
38
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Public `commands/*.md` file names (with extension), sorted.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} root plugin root
|
|
45
|
+
* @returns {string[]}
|
|
46
|
+
*/
|
|
47
|
+
export function listCommandFiles(root) {
|
|
48
|
+
const dir = path.join(root, 'commands');
|
|
49
|
+
if (!isDir(dir)) return [];
|
|
50
|
+
return readdirSync(dir)
|
|
51
|
+
.filter((name) => name.endsWith('.md') && !isHiddenSourceName(name) && isFile(path.join(dir, name)))
|
|
52
|
+
.sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Public `skills/<name>` directory names that carry a SKILL.md, sorted.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} root plugin root
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
export function listSkillDirs(root) {
|
|
62
|
+
const dir = path.join(root, 'skills');
|
|
63
|
+
if (!isDir(dir)) return [];
|
|
64
|
+
return readdirSync(dir)
|
|
65
|
+
.filter((name) => !isHiddenSourceName(name) && isDir(path.join(dir, name)) && existsSync(path.join(dir, name, 'SKILL.md')))
|
|
66
|
+
.sort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse YAML-ish frontmatter including `>` / `|` folded scalars, returning
|
|
71
|
+
* DECODED values (one layer of surrounding quotes stripped). Not js-yaml on
|
|
72
|
+
* purpose: the Cursor/Pi wrappers re-emit these values verbatim, and every
|
|
73
|
+
* merged skill uses `description: >`, which this parser folds to its text.
|
|
74
|
+
*
|
|
75
|
+
* A UTF-8 BOM before the opening `---`, or CRLF line endings, used to make the
|
|
76
|
+
* probes below miss the block entirely: the file parsed as "no frontmatter",
|
|
77
|
+
* so every flag in it (`user-invocable`, `disable-model-invocation`) silently
|
|
78
|
+
* disappeared and the skill was demoted out of the generated surface with no
|
|
79
|
+
* diagnostic anywhere. Both are normalised away first — the same treatment
|
|
80
|
+
* `parseAgentFrontmatter` (`scripts/lib/agent-frontmatter.mjs`) gives them.
|
|
81
|
+
*
|
|
82
|
+
* NOT replaced by the shared `parseSkillFrontmatter`: that one returns the
|
|
83
|
+
* `__BLOCK_SCALAR__` sentinel for a `description: >`, which would be emitted
|
|
84
|
+
* as the wrapper description.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} content
|
|
87
|
+
* @returns {Record<string, string>}
|
|
88
|
+
*/
|
|
89
|
+
export function parseFrontmatter(content) {
|
|
90
|
+
const text = (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content).replace(/\r\n?/g, '\n');
|
|
91
|
+
if (!text.startsWith('---\n')) return {};
|
|
92
|
+
const end = text.indexOf('\n---\n', 4);
|
|
93
|
+
if (end === -1) return {};
|
|
94
|
+
|
|
95
|
+
const lines = text.slice(4, end).split('\n');
|
|
96
|
+
const fields = {};
|
|
97
|
+
let i = 0;
|
|
98
|
+
while (i < lines.length) {
|
|
99
|
+
const match = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
100
|
+
if (!match) {
|
|
101
|
+
i += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const key = match[1];
|
|
105
|
+
const raw = match[2];
|
|
106
|
+
if (raw === '>' || raw === '| ' || raw === '|' || raw === '>-' || raw === '|-') {
|
|
107
|
+
const folded = [];
|
|
108
|
+
i += 1;
|
|
109
|
+
while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i] === '')) {
|
|
110
|
+
folded.push(lines[i].replace(/^\s+/, ''));
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
const joiner = raw.startsWith('|') ? '\n' : ' ';
|
|
114
|
+
fields[key] = folded.filter(Boolean).join(joiner).trim();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
fields[key] = raw.replace(/^["']|["']$/g, '');
|
|
118
|
+
i += 1;
|
|
119
|
+
}
|
|
120
|
+
return fields;
|
|
121
|
+
}
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
*/
|
|
63
63
|
|
|
64
64
|
import { spawnSync } from 'node:child_process';
|
|
65
|
-
import { existsSync,
|
|
65
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
66
66
|
import { dirname, join, resolve } from 'node:path';
|
|
67
67
|
import { fileURLToPath } from 'node:url';
|
|
68
68
|
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
spawnInGroup,
|
|
76
76
|
} from './process-group.mjs';
|
|
77
77
|
import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
78
|
+
import { atomicWriteWithBackup, writeJsonAtomicSync } from './io.mjs';
|
|
78
79
|
import { readProcessLocalSessionIds } from './session-identity/own-session.mjs';
|
|
79
80
|
|
|
80
81
|
export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
@@ -406,12 +407,10 @@ function writeLastGreenSha(repoRoot) {
|
|
|
406
407
|
if (sha.status !== 0 || !sha.stdout) return;
|
|
407
408
|
const head = sha.stdout.trim();
|
|
408
409
|
if (!head) return;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
writeFileSync(tmp, head + '\n', 'utf8');
|
|
414
|
-
renameSync(tmp, target);
|
|
410
|
+
// Never throws; an fs failure is the same silent best-effort as before.
|
|
411
|
+
atomicWriteWithBackup(join(repoRoot, '.orchestrator', 'runtime', 'last-green-sha.txt'), head + '\n', {
|
|
412
|
+
tmpPrefix: '.tmp-last-green-sha',
|
|
413
|
+
});
|
|
415
414
|
} catch {
|
|
416
415
|
// best-effort
|
|
417
416
|
}
|
|
@@ -657,14 +656,12 @@ function readCorrectiveContext(repoRoot) {
|
|
|
657
656
|
function writeDiagnosticsBundle(repoRoot, bundle) {
|
|
658
657
|
try {
|
|
659
658
|
const dir = join(repoRoot, '.orchestrator', 'metrics', 'verification-failures');
|
|
660
|
-
mkdirSync(dir, { recursive: true });
|
|
661
659
|
// Replace colons in ISO timestamp for cross-fs portability.
|
|
662
660
|
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
663
661
|
const target = join(dir, `${ts}.json`);
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
return target;
|
|
662
|
+
// Same bytes as before: indent 2 + trailing newline, mkdir -p of `dir`.
|
|
663
|
+
const res = writeJsonAtomicSync(target, redactDiagnosticsBundle(bundle), { tmpPrefix: '.tmp-diagnostics' });
|
|
664
|
+
return res.ok ? target : null;
|
|
668
665
|
} catch {
|
|
669
666
|
return null;
|
|
670
667
|
}
|
|
@@ -842,19 +839,27 @@ export function gateKillFields(run) {
|
|
|
842
839
|
* through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
|
|
843
840
|
* them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
|
|
844
841
|
* the STATE.md Wave History header into sessions.jsonl. `counts` is a SECOND,
|
|
845
|
-
* machine-measured emission of the same fact
|
|
846
|
-
*
|
|
847
|
-
* 4404
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
* `
|
|
842
|
+
* machine-measured emission of the same fact. Measured 2026-10-05 against this
|
|
843
|
+
* repo's `.orchestrator/metrics/events.jsonl` (main checkout @ 1386a98e; the
|
|
844
|
+
* file has been rotated since the 4404-record 2026-07-31 reading this note
|
|
845
|
+
* used to quote): 82 `orchestrator.quality_gate.*` records, 46 carrying
|
|
846
|
+
* `counts` (`grep '"orchestrator.quality_gate' <file> | grep -c '"counts"'`).
|
|
847
|
+
*
|
|
848
|
+
* `waves[].*` is PER-WAVE, so the event needs a per-wave key. The
|
|
849
|
+
* `scripts/run-quality-gate.mjs` wrapper emits `wave_number` when a wave-scope
|
|
850
|
+
* sidecar names one (#966 step 1, `resolveWaveNumber`) and OMITS it otherwise —
|
|
851
|
+
* 37 of the same 82 records carry it
|
|
852
|
+
* (`grep '"orchestrator.quality_gate' <file> | grep -c '"wave_number"'`).
|
|
853
|
+
* Readers exist: `skills/session-end/metrics-collection.md` § 1.7 reads
|
|
854
|
+
* `counts` by `wave_number` first and keeps the STATE.md header only as a
|
|
855
|
+
* fallback (#966 step 3), and `scripts/lib/convergence-monitor.mjs` folds a
|
|
856
|
+
* gate record only when it carries both. THIS emitter still sets no
|
|
857
|
+
* `wave_number` — its records are mid-wave auto-fix retries, so they fall to
|
|
858
|
+
* that fallback by design. A record without the key could only be attributed
|
|
859
|
+
* to a wave by a wall-clock window whose own boundaries (`waves[].started_at`
|
|
860
|
+
* / `completed_at`) are themselves LLM-written, against the posture
|
|
861
|
+
* `scripts/lib/eval/session-resolve.mjs` documents for window-attributed gate
|
|
862
|
+
* events ("a contaminated window means gate-attribution is unsafe").
|
|
858
863
|
*
|
|
859
864
|
* Note also that THIS emitter only runs under `verification-auto-fix.enabled:
|
|
860
865
|
* true` (default `false`, and `false` in this repo's Session Config). The gate
|
|
@@ -57,9 +57,10 @@
|
|
|
57
57
|
* (absence reads as `'written'`, the only outcome that existed then).
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
import { createHash
|
|
60
|
+
import { readFileSync } from 'node:fs';
|
|
61
|
+
import { isAbsolute, join } from 'node:path';
|
|
62
|
+
import { createHash } from 'node:crypto';
|
|
63
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
63
64
|
|
|
64
65
|
/** Default repo-relative location of the reconcile-candidate work-queue. */
|
|
65
66
|
export const DEFAULT_STORE_PATH = '.orchestrator/runtime/reconcile-candidates.jsonl';
|
|
@@ -159,18 +160,16 @@ function readStore(absPath) {
|
|
|
159
160
|
* @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
|
|
160
161
|
*/
|
|
161
162
|
function writeStore(absPath, records) {
|
|
163
|
+
let content;
|
|
162
164
|
try {
|
|
163
|
-
const dir = dirname(absPath);
|
|
164
|
-
mkdirSync(dir, { recursive: true });
|
|
165
165
|
const body = records.map((r) => JSON.stringify(r)).join('\n');
|
|
166
|
-
|
|
167
|
-
const tmpFile = join(dir, `.reconcile-candidates.${randomBytes(6).toString('hex')}.tmp`);
|
|
168
|
-
writeFileSync(tmpFile, content, { encoding: 'utf8' });
|
|
169
|
-
renameSync(tmpFile, absPath);
|
|
170
|
-
return { ok: true, lines: records.length };
|
|
166
|
+
content = records.length > 0 ? body + '\n' : '';
|
|
171
167
|
} catch (err) {
|
|
172
168
|
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
173
169
|
}
|
|
170
|
+
// mkdir -p + tmp + rename; its failure envelope is this function's own.
|
|
171
|
+
const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.reconcile-candidates.tmp' });
|
|
172
|
+
return res.ok ? { ok: true, lines: records.length } : res;
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
/**
|
|
@@ -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,63 @@ 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);
|
|
626
|
+
|
|
627
|
+
if (
|
|
628
|
+
parsed.counterLine === -1 &&
|
|
629
|
+
parsed.entries.length === 0 &&
|
|
630
|
+
parsed.pairs.length === 1 &&
|
|
631
|
+
substantive.length === 1 &&
|
|
632
|
+
parsed.expiresAt !== null &&
|
|
633
|
+
ruleExpiryInstantMs(parsed.expiresAt) !== null
|
|
634
|
+
) {
|
|
635
|
+
// SINGLE-ENTRY file (#1513): one learning, one pair, no `### ` entries,
|
|
636
|
+
// no counter sentence — the shape the reconciliation engine writes per
|
|
637
|
+
// learning in consumer repos (measured 2026-10-03: all 21 generated rules
|
|
638
|
+
// of the Meta-Vault). The fail-closed branch below skipped every one of
|
|
639
|
+
// them as `no-counter-sentence`, so they stayed on disk forever after
|
|
640
|
+
// expiry. Here the date is unambiguous — the file's own frontmatter
|
|
641
|
+
// `expires-at`, the one `rule-loader.mjs` acts on — and there is no
|
|
642
|
+
// body sentence to keep in agreement. The later of that date and the
|
|
643
|
+
// learning's store `expires_at` (when it resolves) decides, so a
|
|
644
|
+
// re-stamped learning never loses its rule. Expired → the same `delete`
|
|
645
|
+
// as a fully expired consolidated file: the pair is stamped terminal
|
|
646
|
+
// before the unlink, so `/reconcile` does not re-propose it.
|
|
647
|
+
const pair = substantive[0];
|
|
648
|
+
const storeAt = expiryById.has(pair.id) ? Date.parse(expiryById.get(pair.id)) : Number.NaN;
|
|
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);
|
|
653
|
+
const effectiveAt = Number.isFinite(storeAt) ? Math.max(storeAt, fileAt) : fileAt;
|
|
654
|
+
const base = {
|
|
655
|
+
file: rule.file,
|
|
656
|
+
expiredPairIds: [],
|
|
657
|
+
keptPairIds: [pair.id],
|
|
658
|
+
unresolvedPairIds: expiryById.has(pair.id) ? [] : [pair.id],
|
|
659
|
+
bytesBefore: Buffer.byteLength(content, 'utf8'),
|
|
660
|
+
headings: 0,
|
|
661
|
+
substantivePairs: 1,
|
|
662
|
+
};
|
|
663
|
+
if (effectiveAt <= cutoffMs) {
|
|
664
|
+
plans.push({
|
|
665
|
+
...base,
|
|
666
|
+
expiredPairIds: [pair.id],
|
|
667
|
+
keptPairIds: [],
|
|
668
|
+
unresolvedPairIds: [],
|
|
669
|
+
action: 'delete',
|
|
670
|
+
reason: 'single-entry-expired',
|
|
671
|
+
newExpiresAt: null,
|
|
672
|
+
newAbsorbedCount: 0,
|
|
673
|
+
bytesAfter: 0,
|
|
674
|
+
stampKeys: [pair.key],
|
|
675
|
+
});
|
|
676
|
+
} else {
|
|
677
|
+
plans.push({ ...base, action: 'keep', newExpiresAt: null, newAbsorbedCount: 1, bytesAfter: base.bytesBefore });
|
|
678
|
+
}
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
592
681
|
|
|
593
682
|
if (parsed.counterLine === -1) {
|
|
594
683
|
// FAIL-CLOSED (GH#70). Every write this module performs moves the
|
|
@@ -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,10 +114,25 @@ 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
|
|
|
120
|
+
/**
|
|
121
|
+
* Which outcomes reach the side-log (#1513). Only EVENTS — `backfilled`,
|
|
122
|
+
* `would-backfill`, `error` and any other non-skip action — are written; every
|
|
123
|
+
* `skipped-*` outcome is the steady state of a session close and stays out.
|
|
124
|
+
* Measured in the Meta-Vault 2026-10-03: 5.786 lines / 723 KB, 86 %
|
|
125
|
+
* `skipped-already-recorded`, one commit-worthy diff per close. The skips lose
|
|
126
|
+
* nothing: on-session-end emits every outcome, skips included, as a queryable
|
|
127
|
+
* event (#1068 AC2), and an injected `log` callback still receives them all.
|
|
128
|
+
*
|
|
129
|
+
* @param {unknown} action
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
export function isLoggedBackfillAction(action) {
|
|
133
|
+
return typeof action === 'string' && !action.startsWith('skipped-');
|
|
134
|
+
}
|
|
135
|
+
|
|
121
136
|
// ---------------------------------------------------------------------------
|
|
122
137
|
// Helpers
|
|
123
138
|
// ---------------------------------------------------------------------------
|
|
@@ -957,7 +972,7 @@ export async function backfillAbandonedSession({
|
|
|
957
972
|
return { action: 'skipped-no-identifier' };
|
|
958
973
|
}
|
|
959
974
|
|
|
960
|
-
const eventsPath =
|
|
975
|
+
const eventsPath = eventsFilePath(repoRoot);
|
|
961
976
|
const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
|
|
962
977
|
|
|
963
978
|
// -- Resolve the SEMANTIC record id (sessions.jsonl key) ----------------
|
|
@@ -1169,6 +1184,7 @@ export async function backfillAbandonedSession({
|
|
|
1169
1184
|
return;
|
|
1170
1185
|
}
|
|
1171
1186
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
|
|
1187
|
+
if (!isLoggedBackfillAction(res.action)) return;
|
|
1172
1188
|
const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
|
|
1173
1189
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1174
1190
|
fs.appendFileSync(
|
|
@@ -1351,7 +1367,7 @@ export async function backfillCompletedFromStateMd({
|
|
|
1351
1367
|
if (supersedes) recordId = supersedes;
|
|
1352
1368
|
|
|
1353
1369
|
// -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
|
|
1354
|
-
const eventsPath =
|
|
1370
|
+
const eventsPath = eventsFilePath(repoRoot);
|
|
1355
1371
|
const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
|
|
1356
1372
|
|
|
1357
1373
|
// -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
|
|
@@ -1432,6 +1448,7 @@ export async function backfillCompletedFromStateMd({
|
|
|
1432
1448
|
return;
|
|
1433
1449
|
}
|
|
1434
1450
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
|
|
1451
|
+
if (!isLoggedBackfillAction(res.action)) return;
|
|
1435
1452
|
const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
|
|
1436
1453
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
1437
1454
|
fs.appendFileSync(
|