session-orchestrator 3.17.0 → 3.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* repair-invalid-sessions.mjs — one-time repair CLI for schema-invalid session
|
|
4
|
+
* ledger records (GitLab #1004).
|
|
5
|
+
*
|
|
6
|
+
* Thin driver: argument parsing, output rendering, exit codes. All repair logic
|
|
7
|
+
* lives in `scripts/lib/session-record-repair.mjs` so tests import functions
|
|
8
|
+
* instead of spawning a process.
|
|
9
|
+
*
|
|
10
|
+
* node scripts/repair-invalid-sessions.mjs [--dry-run|--apply] [--json]
|
|
11
|
+
* [--file PATH] [--repo-root PATH]
|
|
12
|
+
*
|
|
13
|
+
* SAFETY: `--dry-run` is the DEFAULT — nothing is written unless `--apply` is
|
|
14
|
+
* passed. Under `--apply` the original is ALWAYS copied to `<file>.bak-<stamp>`
|
|
15
|
+
* BEFORE the swap (the backup is mandatory — there is no opt-out, because this
|
|
16
|
+
* is a write path to a protected ledger and the `.bak` is its only forensic
|
|
17
|
+
* trail; MED-3), the new content is written to `<file>.tmp-<pid>` and renamed
|
|
18
|
+
* over the target (atomic), and the result is then re-verified against BOTH
|
|
19
|
+
* `validateSession` and `checkSessionsIntegrity`. A failed verification restores
|
|
20
|
+
* the backup byte-identically and exits 3 — the ledger is never left in a state
|
|
21
|
+
* worse than the one this CLI found.
|
|
22
|
+
*
|
|
23
|
+
* TARGET CONSTRAINT (MED-3): `--file` MUST resolve inside
|
|
24
|
+
* `<repo-root>/.orchestrator/metrics/`. Any target outside it — including a
|
|
25
|
+
* `..` traversal or a symlinked parent that escapes the dir — is refused with
|
|
26
|
+
* exit 1 BEFORE any read or write. This CLI is a write path to a protected
|
|
27
|
+
* ledger that the pre-bash-sessions-ledger-guard does not see (it resolves to
|
|
28
|
+
* verb `node`), so the target it may touch is bounded here.
|
|
29
|
+
*
|
|
30
|
+
* Exit codes (`.claude/rules/cli-design.md`):
|
|
31
|
+
* 0 — completed (dry-run or apply); the ledger is clean or unchanged
|
|
32
|
+
* 1 — user/input error (bad flag, unknown argument)
|
|
33
|
+
* 2 — system error (unreadable/unwritable ledger, I/O failure)
|
|
34
|
+
* 3 — post-verification failed; the backup was restored and nothing was kept
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import { parseArgs } from 'node:util';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
|
|
42
|
+
import { repairLedger, CANONICAL_LEDGER_REL } from './lib/session-record-repair.mjs';
|
|
43
|
+
import { SO_PROJECT_DIR } from './lib/platform.mjs';
|
|
44
|
+
|
|
45
|
+
const USAGE =
|
|
46
|
+
'Usage: node scripts/repair-invalid-sessions.mjs [--dry-run|--apply] [--json]\n' +
|
|
47
|
+
' [--file PATH] [--repo-root PATH]\n' +
|
|
48
|
+
' --dry-run preview only (DEFAULT — nothing is written)\n' +
|
|
49
|
+
' --apply rewrite the ledger in place (mandatory backup + atomic rename)\n' +
|
|
50
|
+
' --json emit the summary as JSON to stdout\n' +
|
|
51
|
+
' --file ledger path (default: <repo-root>/' +
|
|
52
|
+
CANONICAL_LEDGER_REL +
|
|
53
|
+
');\n' +
|
|
54
|
+
' must resolve inside <repo-root>/.orchestrator/metrics/\n' +
|
|
55
|
+
' --repo-root project root (default: resolved SO_PROJECT_DIR)\n' +
|
|
56
|
+
'Exit codes: 0 completed, 1 arg error, 2 system error, 3 post-verification failed\n';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve `--file` and REFUSE any target outside
|
|
60
|
+
* `<repoRoot>/.orchestrator/metrics/` (MED-3). Symlinks are resolved on the
|
|
61
|
+
* metrics dir and on the target's PARENT (the file itself may not exist yet),
|
|
62
|
+
* so a symlinked parent cannot escape the dir; `path.resolve` collapses any
|
|
63
|
+
* `..` traversal before the prefix check.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} file - the requested target (default or `--file` value).
|
|
66
|
+
* @param {string} repoRoot
|
|
67
|
+
* @returns {{ok: true, file: string}|{ok: false, metricsDir: string, resolved: string}}
|
|
68
|
+
*/
|
|
69
|
+
function resolveLedgerTarget(file, repoRoot) {
|
|
70
|
+
const metricsDir = path.resolve(repoRoot, '.orchestrator', 'metrics');
|
|
71
|
+
// `absFile` (normalized, `..` collapsed) is what the repair uses — NOT the
|
|
72
|
+
// symlink-resolved form, so the canonical-path equality in `verifyWritten`
|
|
73
|
+
// (which compares against a non-realpath'd repoRoot) still recognises it.
|
|
74
|
+
const absFile = path.resolve(file);
|
|
75
|
+
|
|
76
|
+
// The CONTAINMENT decision resolves symlinks on both the metrics dir and the
|
|
77
|
+
// target's PARENT (the file itself may not exist yet), so a symlinked parent
|
|
78
|
+
// cannot smuggle a path that textually sits under metrics but physically
|
|
79
|
+
// escapes it (`metrics/link -> /etc`, then `--file metrics/link/passwd`).
|
|
80
|
+
let realMetricsDir = metricsDir;
|
|
81
|
+
try {
|
|
82
|
+
realMetricsDir = fs.realpathSync(metricsDir);
|
|
83
|
+
} catch {
|
|
84
|
+
/* not created yet — fall back to the normalized path */
|
|
85
|
+
}
|
|
86
|
+
let realParent = path.dirname(absFile);
|
|
87
|
+
try {
|
|
88
|
+
realParent = fs.realpathSync(path.dirname(absFile));
|
|
89
|
+
} catch {
|
|
90
|
+
/* parent may not exist yet — fall back to the normalized parent */
|
|
91
|
+
}
|
|
92
|
+
const realFile = path.join(realParent, path.basename(absFile));
|
|
93
|
+
|
|
94
|
+
const prefix = realMetricsDir.endsWith(path.sep) ? realMetricsDir : realMetricsDir + path.sep;
|
|
95
|
+
if (!realFile.startsWith(prefix)) {
|
|
96
|
+
return { ok: false, metricsDir: realMetricsDir, resolved: realFile };
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, file: absFile };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Human-readable summary — mirrors `scripts/backfill-abandoned-sessions.mjs`
|
|
103
|
+
* `renderHuman()` so the two migration CLIs read the same way.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} s summary from `repairLedger`
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function renderHuman(s) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
lines.push(`Repair invalid session records — ${s.mode}`);
|
|
111
|
+
lines.push(` file: ${s.file}`);
|
|
112
|
+
lines.push(` lines: ${s.total}`);
|
|
113
|
+
lines.push(` invalid before: ${s.invalid_before}`);
|
|
114
|
+
lines.push(` ${s.mode === 'apply' ? 'repaired: ' : 'would repair: '} ${s.repaired}`);
|
|
115
|
+
lines.push(` invalid after: ${s.invalid_after}${s.mode === 'apply' ? '' : ' (projected)'}`);
|
|
116
|
+
if (s.unparseable > 0) {
|
|
117
|
+
lines.push(` unparseable: ${s.unparseable} (passed through untouched)`);
|
|
118
|
+
}
|
|
119
|
+
if (s.duplicate_ids_observed.length > 0) {
|
|
120
|
+
const ids = s.duplicate_ids_observed.map((d) => `${d.session_id} x${d.count}`).join(', ');
|
|
121
|
+
lines.push(` duplicate ids: ${s.duplicate_ids_observed.length} (${ids}) — preserved, never deduped`);
|
|
122
|
+
}
|
|
123
|
+
const classes = Object.entries(s.defects_by_class);
|
|
124
|
+
if (classes.length > 0) {
|
|
125
|
+
lines.push(' defects by class:');
|
|
126
|
+
for (const [cls, n] of classes.sort((a, b) => b[1] - a[1])) lines.push(` ${cls}: ${n}`);
|
|
127
|
+
}
|
|
128
|
+
if (s.backup_path) lines.push(` backup: ${s.backup_path}`);
|
|
129
|
+
if (s.errors.length > 0) {
|
|
130
|
+
lines.push(` errors: ${s.errors.length} (original line kept)`);
|
|
131
|
+
for (const e of s.errors) lines.push(` line ${e.line} ${e.session_id ?? '<no session_id>'}: ${e.error}`);
|
|
132
|
+
}
|
|
133
|
+
if (s.post_verify && s.post_verify.integrity !== 'clean' && s.post_verify.integrity !== 'skipped-not-canonical-path') {
|
|
134
|
+
lines.push(` integrity: ${s.post_verify.integrity.message ?? 'FAILED'}`);
|
|
135
|
+
}
|
|
136
|
+
if (s.ok === false) {
|
|
137
|
+
lines.push(' POST-VERIFICATION FAILED — backup restored, ledger unchanged.');
|
|
138
|
+
}
|
|
139
|
+
return lines.join('\n') + '\n';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function main() {
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = parseArgs({
|
|
146
|
+
args: process.argv.slice(2),
|
|
147
|
+
options: {
|
|
148
|
+
apply: { type: 'boolean', default: false },
|
|
149
|
+
'dry-run': { type: 'boolean', default: false },
|
|
150
|
+
json: { type: 'boolean', default: false },
|
|
151
|
+
file: { type: 'string' },
|
|
152
|
+
'repo-root': { type: 'string' },
|
|
153
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
154
|
+
},
|
|
155
|
+
allowPositionals: false,
|
|
156
|
+
});
|
|
157
|
+
} catch (err) {
|
|
158
|
+
process.stderr.write(`repair-invalid-sessions: ${err.message}\n${USAGE}`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { values } = parsed;
|
|
163
|
+
|
|
164
|
+
if (values.help) {
|
|
165
|
+
process.stdout.write(USAGE);
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --apply is an explicit opt-in; absent it (or with --dry-run) we never write.
|
|
170
|
+
const apply = values.apply === true && values['dry-run'] !== true;
|
|
171
|
+
const repoRoot = values['repo-root'] || SO_PROJECT_DIR;
|
|
172
|
+
const requestedFile = values.file || path.join(repoRoot, CANONICAL_LEDGER_REL);
|
|
173
|
+
|
|
174
|
+
// MED-3: bound the target to <repoRoot>/.orchestrator/metrics/ BEFORE any I/O.
|
|
175
|
+
const target = resolveLedgerTarget(requestedFile, repoRoot);
|
|
176
|
+
if (!target.ok) {
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`repair-invalid-sessions: --file must resolve inside ${target.metricsDir}${path.sep} ` +
|
|
179
|
+
`(got ${target.resolved})\n${USAGE}`
|
|
180
|
+
);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
const file = target.file;
|
|
184
|
+
|
|
185
|
+
let summary;
|
|
186
|
+
try {
|
|
187
|
+
// The backup is mandatory (MED-3): this is a write path to a protected
|
|
188
|
+
// ledger and the `.bak` is its only forensic trail — there is no opt-out.
|
|
189
|
+
summary = repairLedger({ file, repoRoot, apply, backup: true });
|
|
190
|
+
} catch (err) {
|
|
191
|
+
process.stderr.write(`repair-invalid-sessions: ${err?.message ?? String(err)}\n`);
|
|
192
|
+
process.exit(2);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (values.json) {
|
|
196
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
197
|
+
} else {
|
|
198
|
+
process.stdout.write(renderHuman(summary));
|
|
199
|
+
}
|
|
200
|
+
process.exit(summary.ok === false ? 3 : 0);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
204
|
+
if (isDirectRun) {
|
|
205
|
+
main().catch((err) => {
|
|
206
|
+
process.stderr.write(`repair-invalid-sessions: unexpected error: ${err?.stack ?? err}\n`);
|
|
207
|
+
process.exit(2);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -38,7 +38,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
38
38
|
|
|
39
39
|
import { die, warn } from './lib/common.mjs';
|
|
40
40
|
import { loadQualityGatesPolicy, resolveCommand } from './lib/quality-gates-policy.mjs';
|
|
41
|
-
import { emitEvent } from './lib/events.mjs';
|
|
41
|
+
import { emitEvent, sessionAttribution } from './lib/events.mjs';
|
|
42
|
+
import { admitSuiteCounts } from './lib/gates/gate-helpers.mjs';
|
|
43
|
+
import { findScopeFile } from './lib/scope-gate.mjs';
|
|
42
44
|
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
46
|
// Constants
|
|
@@ -49,6 +51,14 @@ const GATES_DIR = join(__dirname, 'lib', 'gates');
|
|
|
49
51
|
|
|
50
52
|
const VALID_VARIANTS = ['baseline', 'incremental', 'full-gate', 'per-file'];
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Ceiling on the gate sub-script's captured stdout. A gate writes one JSON line
|
|
56
|
+
* (5-line command tails + ≤50 debug artifacts), so this is effectively
|
|
57
|
+
* unreachable — it exists only so a pathological gate cannot be killed
|
|
58
|
+
* mid-write by the default 1 MiB spawnSync cap.
|
|
59
|
+
*/
|
|
60
|
+
const GATE_STDOUT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
61
|
+
|
|
52
62
|
const DEFAULT_TEST_CMD = 'npm test';
|
|
53
63
|
const DEFAULT_TYPECHECK_CMD = 'npm run typecheck';
|
|
54
64
|
const DEFAULT_LINT_CMD = 'npm run lint';
|
|
@@ -148,6 +158,89 @@ function extractCommand(policy, policyKey, configKey, configJson, defaultCmd) {
|
|
|
148
158
|
return defaultCmd;
|
|
149
159
|
}
|
|
150
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Lift the suite counts out of a gate sub-script's JSON stdout envelope (#954).
|
|
163
|
+
*
|
|
164
|
+
* This function is the ENVELOPE ADAPTER only. The numeric admission policy —
|
|
165
|
+
* which triples count as a measurement and which are refused — lives once in
|
|
166
|
+
* {@link admitSuiteCounts} (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2),
|
|
167
|
+
* shared with `suiteCountsFromOutput` in `scripts/lib/quality-gate.mjs`. Before
|
|
168
|
+
* that split, the same `counts` field was written under two different policies
|
|
169
|
+
* and a consumer had to know both to read one number.
|
|
170
|
+
*
|
|
171
|
+
* The four rejections that stay here are envelope-shaped, not numeric:
|
|
172
|
+
*
|
|
173
|
+
* 1. stdout is absent or not parseable JSON;
|
|
174
|
+
* 2. `test` is not the object form — only `gate-full.mjs` reports numbers;
|
|
175
|
+
* `gate-{baseline,incremental,per-file}.mjs` emit a bare status STRING, so
|
|
176
|
+
* a non-full-gate variant structurally cannot carry counts;
|
|
177
|
+
* 3. the test gate was skipped (`status` neither `pass` nor `fail`);
|
|
178
|
+
* 4. the test COMMAND was detected as a stub (`echo …` / no-op) — a stub's
|
|
179
|
+
* output parses to 0/0, which would be a fabricated zero.
|
|
180
|
+
*
|
|
181
|
+
* `failed` is NO LONGER derived here. Since #967 item 1 `gate-full.mjs`
|
|
182
|
+
* publishes it explicitly, so the whole `test` object is handed through and
|
|
183
|
+
* `admitSuiteCounts`'s `passed + failed === total` check becomes a real guard
|
|
184
|
+
* against producer/consumer envelope drift instead of an identity that a local
|
|
185
|
+
* `total - passed` derivation could never fail.
|
|
186
|
+
*
|
|
187
|
+
* Never throws — a malformed envelope yields `null`.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} stdout — the gate sub-script's captured stdout.
|
|
190
|
+
* @returns {{ passed: number, failed: number, total: number }|null}
|
|
191
|
+
*/
|
|
192
|
+
function suiteCountsFromGateStdout(stdout) {
|
|
193
|
+
if (typeof stdout !== 'string' || !stdout.trim()) return null;
|
|
194
|
+
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(stdout);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
202
|
+
|
|
203
|
+
const test = parsed.test;
|
|
204
|
+
if (test === null || typeof test !== 'object' || Array.isArray(test)) return null;
|
|
205
|
+
if (test.status !== 'pass' && test.status !== 'fail') return null;
|
|
206
|
+
if (parsed.stubbed && typeof parsed.stubbed === 'object' && parsed.stubbed.test) return null;
|
|
207
|
+
|
|
208
|
+
return admitSuiteCounts(test);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve the active wave number from the wave-scope sidecar (#966 step 1).
|
|
213
|
+
*
|
|
214
|
+
* Mirrors `resolveWave()` in `hooks/pre-bash-memory-propose-audit.mjs` — the
|
|
215
|
+
* same `.{pi,cursor,codex,claude}/wave-scope.json` precedence via
|
|
216
|
+
* {@link findScopeFile} — with ONE deliberate difference: the hook returns `0`
|
|
217
|
+
* for "no wave-scope file", this returns `null`.
|
|
218
|
+
*
|
|
219
|
+
* Absent is not zero. A human running `npm run quality-gate` from a `git push`
|
|
220
|
+
* has no wave at all, and that is the common case; publishing `wave_number: 0`
|
|
221
|
+
* would invent a wave 0 that every consumer then has to special-case. The
|
|
222
|
+
* caller spreads the result so the KEY is omitted, exactly as `counts` is.
|
|
223
|
+
*
|
|
224
|
+
* A non-positive or non-numeric `wave` field is treated the same way — waves
|
|
225
|
+
* are 1-indexed, so `0` on disk carries no more information than an absent file.
|
|
226
|
+
*
|
|
227
|
+
* Never throws.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} projectDir — directory whose wave-scope sidecar to read.
|
|
230
|
+
* @returns {number|null} positive wave number, or `null` when there is no wave.
|
|
231
|
+
*/
|
|
232
|
+
function resolveWaveNumber(projectDir) {
|
|
233
|
+
try {
|
|
234
|
+
const waveFile = findScopeFile(projectDir);
|
|
235
|
+
if (!waveFile || !existsSync(waveFile)) return null;
|
|
236
|
+
const wave = JSON.parse(readFileSync(waveFile, 'utf8'))?.wave;
|
|
237
|
+
if (typeof wave !== 'number' || !Number.isFinite(wave) || wave <= 0) return null;
|
|
238
|
+
return Math.trunc(wave);
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
151
244
|
// Load policy file (never throws)
|
|
152
245
|
const repoRoot = process.cwd();
|
|
153
246
|
const policy = loadQualityGatesPolicy(repoRoot);
|
|
@@ -193,23 +286,48 @@ const env = {
|
|
|
193
286
|
SESSION_START_REF: sessionStartRef,
|
|
194
287
|
};
|
|
195
288
|
|
|
289
|
+
// stdout is PIPED (not inherited) so the suite counts the gate already computed
|
|
290
|
+
// can be lifted straight off its JSON envelope into telemetry (#954) instead of
|
|
291
|
+
// travelling as prose through the STATE.md header. The envelope is re-emitted
|
|
292
|
+
// verbatim below, so the stdout contract is unchanged — a gate sub-script writes
|
|
293
|
+
// exactly one JSON line at the very end (its own child commands are captured by
|
|
294
|
+
// `runCheck`), so nothing streamed before and nothing streams now. stderr stays
|
|
295
|
+
// inherited, keeping warnings live.
|
|
196
296
|
const result = spawnSync('node', [gatePath], {
|
|
197
297
|
env,
|
|
198
|
-
stdio: 'inherit',
|
|
298
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
299
|
+
encoding: 'utf8',
|
|
300
|
+
maxBuffer: GATE_STDOUT_MAX_BUFFER_BYTES,
|
|
199
301
|
});
|
|
200
302
|
|
|
201
|
-
|
|
303
|
+
const gateStdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
304
|
+
if (gateStdout) process.stdout.write(gateStdout);
|
|
305
|
+
|
|
306
|
+
if (result.error && typeof result.status !== 'number') {
|
|
202
307
|
die(`Failed to run gate script: ${result.error.message}`);
|
|
203
308
|
}
|
|
204
309
|
|
|
205
310
|
// Quality-gate telemetry — one canonical event per gate run via emitEvent
|
|
206
|
-
// (single emission path).
|
|
207
|
-
//
|
|
311
|
+
// (single emission path). `sessionAttribution` is the shared helper in
|
|
312
|
+
// events.mjs (#941); this CLI wrapper runs against the CWD `repoRoot`, so the
|
|
313
|
+
// bare emitEvent destination (SO_PROJECT_DIR default) is correct here.
|
|
314
|
+
// Best-effort: a telemetry failure must NEVER alter the gate's authoritative
|
|
315
|
+
// exit code — which is why the counts parse also lives inside this try.
|
|
208
316
|
const exitCode = result.status ?? 1;
|
|
209
317
|
try {
|
|
318
|
+
const counts = suiteCountsFromGateStdout(gateStdout);
|
|
319
|
+
// Wave-scope sidecar is read from the SAME project dir the event lands in
|
|
320
|
+
// (emitEvent's own destination precedence), so a tmp-scoped run cannot pick
|
|
321
|
+
// up the host repo's live wave. Mirrors the hook's projectDir resolution.
|
|
322
|
+
const waveNumber = resolveWaveNumber(
|
|
323
|
+
process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEX_PROJECT_DIR ?? repoRoot,
|
|
324
|
+
);
|
|
210
325
|
await emitEvent(`orchestrator.quality_gate.${exitCode === 0 ? 'passed' : 'failed'}`, {
|
|
211
326
|
variant,
|
|
212
327
|
exit_code: exitCode,
|
|
328
|
+
...(counts ? { counts } : {}),
|
|
329
|
+
...(waveNumber !== null ? { wave_number: waveNumber } : {}),
|
|
330
|
+
...sessionAttribution(repoRoot),
|
|
213
331
|
});
|
|
214
332
|
} catch { /* best-effort telemetry — gate result is authoritative */ }
|
|
215
333
|
|
|
@@ -1,39 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* sweep-expired-learnings.mjs — CLI for the mechanical
|
|
4
|
-
* (Epic #723 B4)
|
|
3
|
+
* sweep-expired-learnings.mjs — CLI for the two mechanical archive-safe writers
|
|
4
|
+
* over `learnings.jsonl`: the time-driven expiry sweep (Epic #723 B4, default)
|
|
5
|
+
* and the decision-driven prune (`--prune`, issue #1017).
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
7
|
+
* Both move records OUT of the active store and INTO an append-only archive
|
|
8
|
+
* sidecar. NEVER deletes data — archived entries remain readable in the archive
|
|
9
|
+
* file, tagged with `_archived_at` and an `_archive_reason` from the closed
|
|
10
|
+
* enum `expired | pruned | superseded | merged`.
|
|
10
11
|
*
|
|
11
12
|
* All read/partition/write logic lives in
|
|
12
|
-
* `scripts/lib/learnings/expiry-sweep.mjs` (`sweepExpiredLearnings`
|
|
13
|
-
* delegates the destructive store rewrite to
|
|
14
|
-
* `scripts/lib/learnings/io.mjs` — automatic
|
|
15
|
-
* rotation (#721) protects every `--apply` run.
|
|
13
|
+
* `scripts/lib/learnings/expiry-sweep.mjs` (`sweepExpiredLearnings` /
|
|
14
|
+
* `pruneLearnings`), which delegates the destructive store rewrite to
|
|
15
|
+
* `rewriteLearnings()` from `scripts/lib/learnings/io.mjs` — automatic
|
|
16
|
+
* `.bak-<ISO>` backup + keep-3 rotation (#721) protects every `--apply` run.
|
|
17
|
+
*
|
|
18
|
+
* Why `--prune` is a subcommand here and not an inline `node -e` block in
|
|
19
|
+
* `skills/evolve/SKILL.md`: a mechanism that lives inside prose has no test, no
|
|
20
|
+
* `--help`, and no exit-code contract. `/evolve` names this command; the
|
|
21
|
+
* mechanism stays in code.
|
|
16
22
|
*
|
|
17
23
|
* Usage:
|
|
18
|
-
* node scripts/sweep-expired-learnings.mjs [--dry-run|--apply] [--json]
|
|
19
|
-
* [--grace-days N] [--file PATH] [--archive PATH]
|
|
24
|
+
* node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json]
|
|
25
|
+
* [--grace-days N] [--entries PATH] [--file PATH] [--archive PATH]
|
|
20
26
|
*
|
|
21
27
|
* Flags:
|
|
22
|
-
* --
|
|
23
|
-
*
|
|
28
|
+
* --prune Decision-driven prune+consolidate+rewrite instead of the
|
|
29
|
+
* time-driven expiry sweep (issue #1017)
|
|
30
|
+
* --dry-run Preview counts; write nothing (DEFAULT, both modes)
|
|
31
|
+
* --apply Perform the archive append + store rewrite
|
|
24
32
|
* --json Emit a single machine-parseable JSON summary line
|
|
25
33
|
* (default: human-readable one-liner)
|
|
26
|
-
* --grace-days N Days past expiry before archiving (default: 14)
|
|
34
|
+
* --grace-days N Days past expiry before archiving (default: 14).
|
|
35
|
+
* SWEEP ONLY — `--prune` has no grace window by design.
|
|
36
|
+
* --entries PATH JSONL sidecar holding the caller's next store generation.
|
|
37
|
+
* PRUNE ONLY. Must exist, parse cleanly, and hold at least
|
|
38
|
+
* one record — absent/malformed/empty all exit 1 untouched.
|
|
39
|
+
* Omitted ⇒ a pure prune+consolidate pass over the on-disk
|
|
40
|
+
* store.
|
|
27
41
|
* --file PATH Learnings store (default: .orchestrator/metrics/learnings.jsonl)
|
|
28
42
|
* --archive PATH Archive sidecar (default: .orchestrator/metrics/learnings-archive.jsonl)
|
|
29
43
|
*
|
|
30
44
|
* Exit codes:
|
|
31
45
|
* 0 Success (including no-op when nothing is archive-eligible)
|
|
32
|
-
* 1 Usage error (bad flag/value
|
|
33
|
-
*
|
|
46
|
+
* 1 Usage/input error (bad flag/value, flag used in the wrong mode, or an
|
|
47
|
+
* absent/malformed/empty `--entries` sidecar)
|
|
48
|
+
* 2 Sweep/prune error (I/O or validation failure inside the lib)
|
|
34
49
|
*/
|
|
35
50
|
|
|
36
|
-
import {
|
|
51
|
+
import { existsSync } from 'node:fs';
|
|
52
|
+
import { sweepExpiredLearnings, pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
|
|
53
|
+
import { readLearnings } from './lib/learnings/io.mjs';
|
|
37
54
|
|
|
38
55
|
const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
|
|
39
56
|
const DEFAULT_ARCHIVE = '.orchestrator/metrics/learnings-archive.jsonl';
|
|
@@ -41,32 +58,51 @@ const DEFAULT_GRACE_DAYS = 14;
|
|
|
41
58
|
|
|
42
59
|
function printHelp() {
|
|
43
60
|
process.stdout.write(
|
|
44
|
-
`Usage: node scripts/sweep-expired-learnings.mjs [--dry-run|--apply] [--json] [--grace-days N] [--file PATH] [--archive PATH]
|
|
61
|
+
`Usage: node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json] [--grace-days N] [--entries PATH] [--file PATH] [--archive PATH]
|
|
62
|
+
|
|
63
|
+
Modes:
|
|
64
|
+
(default) Expiry sweep — archive entries expired past the grace window
|
|
65
|
+
--prune Prune + consolidate + rewrite (issue #1017): archives
|
|
66
|
+
expired / zero-confidence / superseded / caller-dropped
|
|
67
|
+
records instead of deleting them
|
|
45
68
|
|
|
46
69
|
Options:
|
|
47
70
|
--dry-run Preview counts; write nothing (default)
|
|
48
|
-
--apply
|
|
71
|
+
--apply Perform the archive append + store rewrite
|
|
49
72
|
--json Emit a single machine-parseable JSON summary line
|
|
50
|
-
--grace-days N Days past expiry before archiving (default: ${DEFAULT_GRACE_DAYS})
|
|
73
|
+
--grace-days N Days past expiry before archiving (default: ${DEFAULT_GRACE_DAYS}); sweep only
|
|
74
|
+
--entries PATH JSONL sidecar with the next store generation; prune only.
|
|
75
|
+
Must exist, parse cleanly, and hold >= 1 record
|
|
51
76
|
--file PATH Learnings store (default: ${DEFAULT_FILE})
|
|
52
77
|
--archive PATH Archive sidecar (default: ${DEFAULT_ARCHIVE})
|
|
53
78
|
|
|
54
|
-
Exit codes: 0 success 1 usage error 2 sweep error
|
|
79
|
+
Exit codes: 0 success 1 usage/input error 2 sweep/prune error
|
|
55
80
|
`
|
|
56
81
|
);
|
|
57
82
|
}
|
|
58
83
|
|
|
84
|
+
/** Exit 1 with a diagnostic on stderr (usage/input errors). */
|
|
85
|
+
function usageError(message) {
|
|
86
|
+
process.stderr.write(`sweep-expired-learnings: ${message}\n`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
59
90
|
function parseArgs(argv) {
|
|
60
91
|
const args = {
|
|
92
|
+
prune: false,
|
|
61
93
|
dryRun: true,
|
|
62
94
|
json: false,
|
|
63
95
|
graceDays: DEFAULT_GRACE_DAYS,
|
|
96
|
+
graceDaysExplicit: false,
|
|
97
|
+
entries: null,
|
|
64
98
|
file: DEFAULT_FILE,
|
|
65
99
|
archive: DEFAULT_ARCHIVE,
|
|
66
100
|
};
|
|
67
101
|
for (let i = 0; i < argv.length; i++) {
|
|
68
102
|
const a = argv[i];
|
|
69
|
-
if (a === '--
|
|
103
|
+
if (a === '--prune') {
|
|
104
|
+
args.prune = true;
|
|
105
|
+
} else if (a === '--apply') {
|
|
70
106
|
args.dryRun = false;
|
|
71
107
|
} else if (a === '--dry-run') {
|
|
72
108
|
args.dryRun = true;
|
|
@@ -76,12 +112,12 @@ function parseArgs(argv) {
|
|
|
76
112
|
const raw = argv[++i];
|
|
77
113
|
const v = Number(raw);
|
|
78
114
|
if (!Number.isFinite(v) || v < 0) {
|
|
79
|
-
|
|
80
|
-
`sweep-expired-learnings: --grace-days requires a non-negative number, got: ${raw}\n`
|
|
81
|
-
);
|
|
82
|
-
process.exit(1);
|
|
115
|
+
usageError(`--grace-days requires a non-negative number, got: ${raw}`);
|
|
83
116
|
}
|
|
84
117
|
args.graceDays = v;
|
|
118
|
+
args.graceDaysExplicit = true;
|
|
119
|
+
} else if (a === '--entries') {
|
|
120
|
+
args.entries = argv[++i];
|
|
85
121
|
} else if (a === '--file') {
|
|
86
122
|
args.file = argv[++i];
|
|
87
123
|
} else if (a === '--archive') {
|
|
@@ -90,16 +126,29 @@ function parseArgs(argv) {
|
|
|
90
126
|
printHelp();
|
|
91
127
|
process.exit(0);
|
|
92
128
|
} else {
|
|
93
|
-
|
|
94
|
-
process.exit(1);
|
|
129
|
+
usageError(`unknown argument: ${a}`);
|
|
95
130
|
}
|
|
96
131
|
}
|
|
132
|
+
|
|
133
|
+
// Mode/flag mismatches are usage errors, never silent no-ops: a `--grace-days`
|
|
134
|
+
// that the prune path ignores would read as "the grace window applied" in a
|
|
135
|
+
// transcript, and an `--entries` the sweep ignores would read as "my next
|
|
136
|
+
// generation was written".
|
|
137
|
+
if (args.prune && args.graceDaysExplicit) {
|
|
138
|
+
usageError('--grace-days is not valid with --prune (the prune path has no grace window)');
|
|
139
|
+
}
|
|
140
|
+
if (!args.prune && args.entries !== null) {
|
|
141
|
+
usageError('--entries is only valid with --prune');
|
|
142
|
+
}
|
|
97
143
|
return args;
|
|
98
144
|
}
|
|
99
145
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Time-driven expiry sweep (Epic #723 B4) — the default mode.
|
|
148
|
+
*
|
|
149
|
+
* @param {ReturnType<typeof parseArgs>} args
|
|
150
|
+
*/
|
|
151
|
+
async function runSweep(args) {
|
|
103
152
|
let result;
|
|
104
153
|
try {
|
|
105
154
|
result = await sweepExpiredLearnings({
|
|
@@ -129,6 +178,117 @@ async function main() {
|
|
|
129
178
|
}
|
|
130
179
|
}
|
|
131
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the `--entries` sidecar into the caller's next store generation.
|
|
183
|
+
*
|
|
184
|
+
* Fails closed on THREE input conditions, all of which yield the same lethal
|
|
185
|
+
* value — an empty next generation, which makes `pruneLearnings()` treat the
|
|
186
|
+
* ENTIRE store as caller-dropped:
|
|
187
|
+
*
|
|
188
|
+
* 1. **absent file** — `readLearnings()` returns `{entries: [], malformed: []}`
|
|
189
|
+
* for a missing path, so one mistyped path would archive every active
|
|
190
|
+
* learning. A path the operator named and the filesystem does not have is
|
|
191
|
+
* an input error, not an empty set.
|
|
192
|
+
* 2. **malformed line** — a half-written sidecar reads as a SHORTER next
|
|
193
|
+
* generation, pruning every record the truncated tail omitted.
|
|
194
|
+
* 3. **parses to zero records** — a 0-byte or blank-line-only file. Guard (1)
|
|
195
|
+
* closes ABSENCE, which is a different condition: an empty file EXISTS, so
|
|
196
|
+
* it sails past `existsSync` and parses to a legitimate-looking empty
|
|
197
|
+
* generation. Measured on a 3-record fixture before this guard: a 0-byte
|
|
198
|
+
* `--entries` archived all 3 and exited 0.
|
|
199
|
+
*
|
|
200
|
+
* Condition 3 is REJECTED rather than obeyed because at a file boundary an
|
|
201
|
+
* empty parse is indistinguishable from a truncated write, a failed producer,
|
|
202
|
+
* or a typo that landed on an unrelated empty file — and no caller expresses
|
|
203
|
+
* "archive the whole corpus" through this flag: `/evolve`'s next generation
|
|
204
|
+
* always carries the survivors. The cost of rejecting a genuinely-intended
|
|
205
|
+
* empty generation is one re-run; the cost of obeying a corrupt one is the
|
|
206
|
+
* active store. Note this guard is deliberately NOT in `pruneLearnings()`: an
|
|
207
|
+
* explicit `entries: []` written in CODE is a statement, and the lib keeps it
|
|
208
|
+
* expressible. Only the FILE is ambiguous, so only the file is guarded.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} entriesPath
|
|
211
|
+
* @returns {Promise<object[]>} the validated, non-empty next generation
|
|
212
|
+
*/
|
|
213
|
+
async function loadEntriesSidecar(entriesPath) {
|
|
214
|
+
if (!existsSync(entriesPath)) {
|
|
215
|
+
usageError(
|
|
216
|
+
`--entries sidecar not found: ${entriesPath} (refusing to prune — an absent ` +
|
|
217
|
+
`next generation would archive the whole store)`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
let read;
|
|
221
|
+
try {
|
|
222
|
+
read = await readLearnings(entriesPath);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
usageError(`--entries sidecar unreadable: ${entriesPath}: ${err.message}`);
|
|
225
|
+
}
|
|
226
|
+
if (read.malformed.length > 0) {
|
|
227
|
+
usageError(
|
|
228
|
+
`refusing to prune — ${read.malformed.length} malformed line(s) in ${entriesPath}`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (read.entries.length === 0) {
|
|
232
|
+
usageError(
|
|
233
|
+
`--entries sidecar holds no records: ${entriesPath} (refusing to prune — an empty ` +
|
|
234
|
+
`next generation would archive every record in the store; omit --entries for a ` +
|
|
235
|
+
`pure prune+consolidate pass)`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return read.entries;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Decision-driven prune + consolidate + rewrite (issue #1017).
|
|
243
|
+
*
|
|
244
|
+
* @param {ReturnType<typeof parseArgs>} args
|
|
245
|
+
*/
|
|
246
|
+
async function runPrune(args) {
|
|
247
|
+
const entries = args.entries === null ? undefined : await loadEntriesSidecar(args.entries);
|
|
248
|
+
|
|
249
|
+
let result;
|
|
250
|
+
try {
|
|
251
|
+
result = await pruneLearnings({
|
|
252
|
+
filePath: args.file,
|
|
253
|
+
archivePath: args.archive,
|
|
254
|
+
entries,
|
|
255
|
+
dryRun: args.dryRun,
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
process.stderr.write(`sweep-expired-learnings: prune failed: ${err.message}\n`);
|
|
259
|
+
process.exit(2);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const summary = {
|
|
263
|
+
file: args.file,
|
|
264
|
+
entries_from: args.entries,
|
|
265
|
+
...result,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (args.json) {
|
|
269
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
270
|
+
} else {
|
|
271
|
+
const byReason =
|
|
272
|
+
Object.entries(summary.byReason)
|
|
273
|
+
.map(([reason, n]) => `${reason}:${n}`)
|
|
274
|
+
.join(',') || '-';
|
|
275
|
+
process.stdout.write(
|
|
276
|
+
`sweep-expired-learnings: prune scanned=${summary.scanned} kept=${summary.kept} ` +
|
|
277
|
+
`archived=${summary.archived} by_reason=${byReason} dry_run=${summary.dryRun} ` +
|
|
278
|
+
`archive=${summary.archivePath}\n`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function main() {
|
|
284
|
+
const args = parseArgs(process.argv.slice(2));
|
|
285
|
+
if (args.prune) {
|
|
286
|
+
await runPrune(args);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
await runSweep(args);
|
|
290
|
+
}
|
|
291
|
+
|
|
132
292
|
main().catch((err) => {
|
|
133
293
|
process.stderr.write(`sweep-expired-learnings: unexpected error: ${err?.stack ?? err}\n`);
|
|
134
294
|
process.exit(2);
|