session-orchestrator 5.3.0 → 5.5.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/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/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/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
package/scripts/autopilot.mjs
CHANGED
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
* --max-sessions=N Max iterations (1..50, default 5).
|
|
15
15
|
* --max-hours=H Max wall-clock hours (0.5..24.0, default 4.0).
|
|
16
16
|
* --confidence-threshold=0.X Mode confidence gate (0.0..1.0, default 0.85).
|
|
17
|
-
* --max-tokens=N Cumulative
|
|
17
|
+
* --max-tokens=N Cumulative token budget (0..10000000, default 0 = off). Counts
|
|
18
|
+
* SUBAGENT output tokens only (sessions.jsonl total_token_output,
|
|
19
|
+
* else total_tokens — both subagent-only; coordinator tokens are
|
|
20
|
+
* not counted). One deep session measured ~450k.
|
|
18
21
|
* --dry-run Emit a single record without spawning sessions.
|
|
19
22
|
* --verbose Pipe child process stdio (instead of inherit).
|
|
20
23
|
*
|
|
@@ -33,7 +36,7 @@
|
|
|
33
36
|
* "/autopilot Loop Command (Phase C)" (#277/#271; archived in the private Meta-Vault)
|
|
34
37
|
*/
|
|
35
38
|
|
|
36
|
-
import {
|
|
39
|
+
import { randomUUID } from 'node:crypto';
|
|
37
40
|
import { spawnSync, spawn } from 'node:child_process';
|
|
38
41
|
import { resolve } from 'node:path';
|
|
39
42
|
import { fileURLToPath } from 'node:url';
|
|
@@ -49,7 +52,7 @@ import { selectMode } from './lib/mode-selector.mjs';
|
|
|
49
52
|
import { probe, evaluate, DEFAULT_RESOURCE_THRESHOLDS } from './lib/resource-probe.mjs';
|
|
50
53
|
import { detectPeers } from './lib/session-registry.mjs';
|
|
51
54
|
import { normalizeSession } from './lib/session-schema.mjs';
|
|
52
|
-
import { readCanonicalSessions } from './lib/sessions-canonical.mjs';
|
|
55
|
+
import { isBackfillStub, readCanonicalSessions } from './lib/sessions-canonical.mjs';
|
|
53
56
|
|
|
54
57
|
// ---------------------------------------------------------------------------
|
|
55
58
|
// CLI-level flag extraction
|
|
@@ -94,10 +97,7 @@ const { maxSessions, maxHours, confidenceThreshold, maxTokens, dryRun } = parseF
|
|
|
94
97
|
*/
|
|
95
98
|
function loadConfig() {
|
|
96
99
|
try {
|
|
97
|
-
const scriptPath =
|
|
98
|
-
new URL('.', import.meta.url).pathname,
|
|
99
|
-
'parse-config.mjs'
|
|
100
|
-
);
|
|
100
|
+
const scriptPath = fileURLToPath(new URL('parse-config.mjs', import.meta.url));
|
|
101
101
|
const result = spawnSync(
|
|
102
102
|
process.execPath,
|
|
103
103
|
[scriptPath],
|
|
@@ -185,50 +185,49 @@ const branch = detectBranch();
|
|
|
185
185
|
const SESSIONS_JSONL_PATH = resolve('.orchestrator/metrics/sessions.jsonl');
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* Identities, not lines (#1167): the ledger is append-only, so one physical
|
|
192
|
-
* session can occupy two lines (an abandoned stub plus the authoritative
|
|
193
|
-
* record that supersedes it, or the systemic double-stub pair). The
|
|
194
|
-
* pre/post comparison below asks "did the session I just spawned record
|
|
195
|
-
* itself?", and a raw line count answers that question wrong in BOTH
|
|
196
|
-
* directions — a duplicate pair looks like two sessions, while a supersede
|
|
197
|
-
* append (+1 line, −1 stub) leaves a canonical COUNT unchanged. Comparing the
|
|
198
|
-
* id SETS is immune to both.
|
|
188
|
+
* Read the child's canonical record by its raw harness UUID, independent of
|
|
189
|
+
* semantic session IDs and peer append order. Zero or multiple matches throw;
|
|
190
|
+
* only the unique record supplies normalized fields and finite token usage.
|
|
199
191
|
*
|
|
200
|
-
*
|
|
192
|
+
* A unique record that is a BACKFILL STUB throws too (#1457 F2): a stub is a
|
|
193
|
+
* placeholder some backfill wrote because the child never closed — no waves,
|
|
194
|
+
* agents or token totals. Its usage is UNKNOWN, not 0, and its empty
|
|
195
|
+
* agent_summary/effectiveness would blind the post-session kill-switches as
|
|
196
|
+
* well, so it fails closed like the zero-match case above: the loop records
|
|
197
|
+
* kill_switch=failed-wave with a detail naming the stub, instead of counting a
|
|
198
|
+
* healthy 0-token iteration that silently undercounts --max-tokens.
|
|
199
|
+
* @returns {{session_id: string, agent_summary?: object, effectiveness?: object,
|
|
200
|
+
* usage?: {output_tokens?: number, total_tokens?: number}}}
|
|
201
201
|
*/
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
202
|
+
function readOwnSession(childSessionId) {
|
|
203
|
+
const own = readCanonicalSessions({ filePath: SESSIONS_JSONL_PATH }).filter((r) => r.raw_session_id === childSessionId);
|
|
204
|
+
if (own.length === 0) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`no session record carries raw_session_id=${childSessionId} (the child wrote none, or none stamped ` +
|
|
207
|
+
'with its raw id) — refusing to fall back to the ledger tail (#1457)'
|
|
208
|
+
);
|
|
209
209
|
}
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
function readTailSession() {
|
|
220
|
-
const raw = readFileSync(SESSIONS_JSONL_PATH, 'utf8');
|
|
221
|
-
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
|
|
222
|
-
if (lines.length === 0) {
|
|
223
|
-
throw new Error('sessions.jsonl is empty after session completed');
|
|
210
|
+
if (own.length > 1) {
|
|
211
|
+
throw new Error(`ambiguous: ${own.length} canonical records carry raw_session_id=${childSessionId} (#1457)`);
|
|
212
|
+
}
|
|
213
|
+
if (isBackfillStub(own[0])) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`the only record with raw_session_id=${childSessionId} is a backfill stub ` +
|
|
216
|
+
`(_backfill_source=${own[0]._backfill_source}, no waves/agents/token totals) — token usage unknown, ` +
|
|
217
|
+
'refusing to count it as a 0-token iteration (#1457 F2)'
|
|
218
|
+
);
|
|
224
219
|
}
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
return {
|
|
220
|
+
const normalized = normalizeSession(own[0]);
|
|
221
|
+
const result = {
|
|
228
222
|
session_id: normalized.session_id,
|
|
229
223
|
agent_summary: normalized.agent_summary,
|
|
230
224
|
effectiveness: normalized.effectiveness,
|
|
231
225
|
};
|
|
226
|
+
const usage = {};
|
|
227
|
+
if (Number.isFinite(normalized.total_token_output)) usage.output_tokens = normalized.total_token_output;
|
|
228
|
+
if (Number.isFinite(normalized.total_tokens)) usage.total_tokens = normalized.total_tokens;
|
|
229
|
+
if (Object.keys(usage).length > 0) result.usage = usage;
|
|
230
|
+
return result;
|
|
232
231
|
}
|
|
233
232
|
|
|
234
233
|
// ---------------------------------------------------------------------------
|
|
@@ -266,13 +265,16 @@ async function modeSelector() {
|
|
|
266
265
|
|
|
267
266
|
/**
|
|
268
267
|
* sessionRunner — spawns `claude` with the recommended mode and awaits exit.
|
|
269
|
-
*
|
|
268
|
+
* Sets a fresh raw harness UUID so a peer's later ledger append is never read
|
|
269
|
+
* as the child's record (emit-session stamps that UUID as `raw_session_id`).
|
|
270
|
+
* After exit 0, returns its unique canonical record; zero or multiple matches
|
|
271
|
+
* throw into the loop's failed-wave channel.
|
|
270
272
|
*
|
|
271
273
|
* @param {{mode: string, autopilotRunId: string}} args
|
|
272
274
|
* @returns {Promise<{session_id: string, agent_summary?: object, effectiveness?: object}>}
|
|
273
275
|
*/
|
|
274
276
|
async function sessionRunner({ mode, autopilotRunId }) {
|
|
275
|
-
const
|
|
277
|
+
const childSessionId = randomUUID();
|
|
276
278
|
|
|
277
279
|
await new Promise((res, rej) => {
|
|
278
280
|
const childStdio = hasVerbose
|
|
@@ -286,7 +288,7 @@ async function sessionRunner({ mode, autopilotRunId }) {
|
|
|
286
288
|
// resolves, and only when the plugin that defines it is on `--plugin-dir`.
|
|
287
289
|
const child = spawn(
|
|
288
290
|
'claude',
|
|
289
|
-
['-p', `/session-orchestrator:session ${mode}`, '--plugin-dir', PLUGIN_ROOT],
|
|
291
|
+
['-p', `/session-orchestrator:session ${mode}`, '--plugin-dir', PLUGIN_ROOT, '--session-id', childSessionId],
|
|
290
292
|
{
|
|
291
293
|
env: { ...process.env, AUTOPILOT_RUN_ID: autopilotRunId },
|
|
292
294
|
stdio: childStdio,
|
|
@@ -309,13 +311,7 @@ async function sessionRunner({ mode, autopilotRunId }) {
|
|
|
309
311
|
});
|
|
310
312
|
});
|
|
311
313
|
|
|
312
|
-
|
|
313
|
-
const appeared = [...postIds].some((id) => !preIds.has(id));
|
|
314
|
-
if (!appeared) {
|
|
315
|
-
throw new Error('no session record appended');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return readTailSession();
|
|
314
|
+
return readOwnSession(childSessionId);
|
|
319
315
|
}
|
|
320
316
|
|
|
321
317
|
/**
|
|
@@ -404,12 +400,19 @@ async function main() {
|
|
|
404
400
|
});
|
|
405
401
|
|
|
406
402
|
const sessionIds = state.sessions.join(', ') || '(none)';
|
|
403
|
+
// #1498: `tokens_unknown_sessions` (#1487 Pkt 4) reached state, return value
|
|
404
|
+
// and autopilot.jsonl, but no report showed it — so the token total read as
|
|
405
|
+
// the whole sum. Printed only when non-zero: then the total is a lower bound.
|
|
406
|
+
const unknownTokens = state.tokens_unknown_sessions > 0
|
|
407
|
+
? ` tokens_unknown_sessions=${state.tokens_unknown_sessions} (the token total is a lower bound)`
|
|
408
|
+
: '';
|
|
407
409
|
const summary =
|
|
408
410
|
`${state.autopilot_run_id} ` +
|
|
409
411
|
`kill_switch=${state.kill_switch ?? 'none'} ` +
|
|
410
412
|
`iterations=${state.iterations_completed} ` +
|
|
411
413
|
`sessions=[${sessionIds}] ` +
|
|
412
|
-
`duration=${state.duration_seconds}s
|
|
414
|
+
`duration=${state.duration_seconds}s ` +
|
|
415
|
+
`tokens=${state.total_tokens_used}${unknownTokens}`;
|
|
413
416
|
process.stdout.write(summary + '\n');
|
|
414
417
|
|
|
415
418
|
if (state.kill_switch === KILL_SWITCHES.FAILED_WAVE) {
|
|
@@ -43,6 +43,7 @@ import { parseArgs } from 'node:util';
|
|
|
43
43
|
|
|
44
44
|
import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
|
|
45
45
|
import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst } from './lib/events.mjs';
|
|
46
|
+
import { ARCHIVE_DIR_NAME, ROTATION_EVENT } from './lib/events-schema.mjs';
|
|
46
47
|
import { getProjectDir } from './lib/platform.mjs';
|
|
47
48
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
48
49
|
import { isLockLive, readLock } from './lib/session-lock.mjs';
|
|
@@ -52,6 +53,15 @@ const LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
|
|
|
52
53
|
const SESSION_STARTED = 'orchestrator.session.started';
|
|
53
54
|
const SESSION_ENDED = 'orchestrator.session.ended';
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Events-ledger window for the #1401 census (`scripts/lib/events-retention-banner.mjs`):
|
|
58
|
+
* `null` — {@link planSessions} takes every `session.started` ever written as a
|
|
59
|
+
* candidate, so no day count bounds it (`DEFAULT_TTL_HOURS` is a minimum AGE,
|
|
60
|
+
* not a window). A pruned history yields fewer candidates; the run summary says
|
|
61
|
+
* so as `history_gaps` (#1498).
|
|
62
|
+
*/
|
|
63
|
+
export const REQUIRED_EVENTS_WINDOW_DAYS = null;
|
|
64
|
+
|
|
55
65
|
/**
|
|
56
66
|
* Default cap on how many candidates may reach the (expensive) shared core in a
|
|
57
67
|
* single run. Only the SessionStart path passes a limit; the CLI stays uncapped.
|
|
@@ -59,6 +69,25 @@ const SESSION_ENDED = 'orchestrator.session.ended';
|
|
|
59
69
|
*/
|
|
60
70
|
export const SESSION_START_LIMIT = 25;
|
|
61
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Total wall-clock budget, per SessionStart run, for scanning the ROTATED
|
|
74
|
+
* ledger on behalf of candidates whose events left the active file (#1498).
|
|
75
|
+
* ONE deadline shared by every candidate; a candidate it leaves unread is
|
|
76
|
+
* `skipped-history-unread` and retried by the next start — recorded ones drop
|
|
77
|
+
* out through the sessions.jsonl pre-filter, so the backlog drains.
|
|
78
|
+
*
|
|
79
|
+
* Named ceiling (BV-004): at ≈0.55-0.8 ms per MB per candidate (measured
|
|
80
|
+
* 2026-10-02, see `gatherSessionEvents` in `session-close-backfill.mjs`) a full
|
|
81
|
+
* default ledger (6 × 10 MB) fits ~10-15 scans per start. Measured the same
|
|
82
|
+
* day over 58 MB with 25 archived candidates (dry run, load ~5): unbudgeted
|
|
83
|
+
* 1.41 s; budget 0 — planning and active-file reads only — 0.55 s; budget 500
|
|
84
|
+
* 0.83-0.84 s, 10 candidates answered and 15 deferred. A single scan longer
|
|
85
|
+
* than the budget — a ledger past ~600 MB — never completes here; the CLI path
|
|
86
|
+
* is unbudgeted and still records it. Revisit if `skipped-history-unread`
|
|
87
|
+
* appears on more than a few consecutive starts of one repo.
|
|
88
|
+
*/
|
|
89
|
+
export const SESSION_START_ARCHIVE_SCAN_BUDGET_MS = 500;
|
|
90
|
+
|
|
62
91
|
/**
|
|
63
92
|
* Read a JSONL file into parsed objects; MISSING (ENOENT) → `[]` silently,
|
|
64
93
|
* UNREADABLE (EACCES/EISDIR/…) → `[]` with a stderr WARN (#1188); malformed
|
|
@@ -126,6 +155,23 @@ function readJsonl(filePath) {
|
|
|
126
155
|
* @returns {Array<{ sessionId: string, semanticSessionId: string|null }>}
|
|
127
156
|
*/
|
|
128
157
|
export function planSessions({ repoRoot }) {
|
|
158
|
+
return planWithGaps({ repoRoot }).plan;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* {@link planSessions} plus the count of rotation tombstones whose archive is
|
|
163
|
+
* not one of the sources read (#1498). Pruned history used to shrink the plan
|
|
164
|
+
* silently; non-zero `historyGaps` says the candidate count is a floor.
|
|
165
|
+
*
|
|
166
|
+
* The check is `readEventsWithRotations` rule 1 (`scripts/lib/events.mjs`):
|
|
167
|
+
* resolved by BASENAME against this ledger's own `_archive/` (#1411), and a
|
|
168
|
+
* file that exists but is not a source (renamed out of `ARCHIVE_NAME_RE`)
|
|
169
|
+
* counts as a gap too — existence is not reading (#1423).
|
|
170
|
+
*
|
|
171
|
+
* @param {{ repoRoot: string }} args
|
|
172
|
+
* @returns {{ plan: Array<{ sessionId: string, semanticSessionId: string|null }>, historyGaps: number }}
|
|
173
|
+
*/
|
|
174
|
+
function planWithGaps({ repoRoot }) {
|
|
129
175
|
const eventsPath = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
|
|
130
176
|
|
|
131
177
|
// Two independent UUID -> semantic bridges (#1167). `lock.acquired` is the
|
|
@@ -150,9 +196,17 @@ export function planSessions({ repoRoot }) {
|
|
|
150
196
|
// (`hooks/on-session-start.mjs` → `backfillOnSessionStart`). Reversing the
|
|
151
197
|
// newest-first source list keeps first-seen order chronological, exactly as
|
|
152
198
|
// the single-file read produced it.
|
|
153
|
-
|
|
199
|
+
const sources = listEventSourcesNewestFirst({ filePath: eventsPath });
|
|
200
|
+
const read = new Set(sources.map((s) => s.path));
|
|
201
|
+
const archiveDir = path.join(path.dirname(eventsPath), ARCHIVE_DIR_NAME);
|
|
202
|
+
let historyGaps = 0;
|
|
203
|
+
for (const source of [...sources].reverse()) {
|
|
154
204
|
forEachJsonlRecord(source.path, (ev) => {
|
|
155
205
|
if (!ev || typeof ev !== 'object') return;
|
|
206
|
+
if (ev.event === ROTATION_EVENT && typeof ev.archived_as === 'string' && ev.archived_as.length > 0
|
|
207
|
+
&& !read.has(path.join(archiveDir, path.basename(ev.archived_as)))) {
|
|
208
|
+
historyGaps += 1;
|
|
209
|
+
}
|
|
156
210
|
if (typeof ev.session_id === 'string' && typeof ev.semantic_session_id === 'string') {
|
|
157
211
|
if (ev.event === LOCK_ACQUIRED) semanticFromLock.set(ev.session_id, ev.semantic_session_id);
|
|
158
212
|
else if (ev.event === SESSION_ENDED) {
|
|
@@ -181,7 +235,7 @@ export function planSessions({ repoRoot }) {
|
|
|
181
235
|
|| (semanticSessionId !== null && runningIds.has(semanticSessionId))) continue;
|
|
182
236
|
plan.push({ sessionId, semanticSessionId });
|
|
183
237
|
}
|
|
184
|
-
return plan;
|
|
238
|
+
return { plan, historyGaps };
|
|
185
239
|
}
|
|
186
240
|
|
|
187
241
|
/**
|
|
@@ -365,6 +419,10 @@ function cheapRecordId({ sessionId, semanticSessionId }) {
|
|
|
365
419
|
* those ancient candidates exhaust the whole budget before the run ever
|
|
366
420
|
* reaches the genuinely-abandoned recent ones. Measured on a copy of this
|
|
367
421
|
* repo's live store: first-seen order → backfilled 0, truncated true.
|
|
422
|
+
* @param {number|null} [args.archiveScanBudgetMs=null]
|
|
423
|
+
* #1498 — total budget for rotated-ledger scans across the whole run, turned
|
|
424
|
+
* into ONE deadline before the first candidate. Candidates it leaves unread
|
|
425
|
+
* count as `skipped.skipped-history-unread`. `null` (CLI default) = unbounded.
|
|
368
426
|
* @returns {Promise<object>} aggregate summary
|
|
369
427
|
*/
|
|
370
428
|
export async function runMigration({
|
|
@@ -373,13 +431,17 @@ export async function runMigration({
|
|
|
373
431
|
assumeDeadBeforeMs = null,
|
|
374
432
|
limit = null,
|
|
375
433
|
newestFirst = false,
|
|
434
|
+
archiveScanBudgetMs = null,
|
|
376
435
|
}) {
|
|
377
|
-
const planned =
|
|
436
|
+
const { plan: planned, historyGaps } = planWithGaps({ repoRoot });
|
|
378
437
|
const plan = newestFirst ? [...planned].reverse() : planned;
|
|
379
438
|
const summary = {
|
|
380
439
|
repoRoot,
|
|
381
440
|
mode: apply ? 'apply' : 'dry-run',
|
|
382
441
|
total: plan.length,
|
|
442
|
+
// #1498: rotated archives the ledger names but no longer holds. Non-zero ⇒
|
|
443
|
+
// sessions they recorded are not candidates and `total` is a floor.
|
|
444
|
+
history_gaps: historyGaps,
|
|
383
445
|
backfilled: 0,
|
|
384
446
|
would_backfill: 0,
|
|
385
447
|
dead_by_age: 0,
|
|
@@ -397,6 +459,10 @@ export async function runMigration({
|
|
|
397
459
|
// here so the dry-run total matches what --apply actually writes.
|
|
398
460
|
const projected = new Set();
|
|
399
461
|
|
|
462
|
+
const archiveScanDeadlineMs = Number.isFinite(archiveScanBudgetMs)
|
|
463
|
+
? Date.now() + archiveScanBudgetMs
|
|
464
|
+
: null;
|
|
465
|
+
|
|
400
466
|
for (const item of plan) {
|
|
401
467
|
// -- Cheap pre-filter: already recorded → skip WITHOUT entering the core --
|
|
402
468
|
const known = cheapRecordId(item);
|
|
@@ -423,6 +489,7 @@ export async function runMigration({
|
|
|
423
489
|
dryRun: !apply,
|
|
424
490
|
relaxDeadByAge: true,
|
|
425
491
|
assumeDeadBeforeMs,
|
|
492
|
+
archiveScanDeadlineMs,
|
|
426
493
|
});
|
|
427
494
|
switch (res.action) {
|
|
428
495
|
case 'backfilled':
|
|
@@ -529,16 +596,21 @@ async function emitBackfillCompleted(repoRoot, item, res) {
|
|
|
529
596
|
* @param {object} args
|
|
530
597
|
* @param {string} args.repoRoot
|
|
531
598
|
* @param {number|null} [args.limit=SESSION_START_LIMIT]
|
|
599
|
+
* @param {number|null} [args.archiveScanBudgetMs=SESSION_START_ARCHIVE_SCAN_BUDGET_MS]
|
|
532
600
|
* @returns {Promise<object|null>} the summary, or null when disabled/failed
|
|
533
601
|
*/
|
|
534
|
-
export async function backfillOnSessionStart({
|
|
602
|
+
export async function backfillOnSessionStart({
|
|
603
|
+
repoRoot,
|
|
604
|
+
limit = SESSION_START_LIMIT,
|
|
605
|
+
archiveScanBudgetMs = SESSION_START_ARCHIVE_SCAN_BUDGET_MS,
|
|
606
|
+
} = {}) {
|
|
535
607
|
try {
|
|
536
608
|
// Escape hatch for operators who never want ledger writes at session start.
|
|
537
609
|
if (process.env.SO_DISABLE_STARTUP_BACKFILL === '1') return null;
|
|
538
610
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
|
|
539
611
|
// newestFirst is mandatory here — see runMigration's param docs for the
|
|
540
612
|
// measured failure (budget burned on ancient already-recorded candidates).
|
|
541
|
-
const summary = await runMigration({ repoRoot, apply: true, limit, newestFirst: true });
|
|
613
|
+
const summary = await runMigration({ repoRoot, apply: true, limit, newestFirst: true, archiveScanBudgetMs });
|
|
542
614
|
// The telemetry flush rides the SAME start-time trigger, for the same reason
|
|
543
615
|
// the backfill does — see flushTelemetryOnSessionStart.
|
|
544
616
|
await flushTelemetryOnSessionStart(repoRoot);
|
|
@@ -596,6 +668,9 @@ function renderHuman(summary) {
|
|
|
596
668
|
lines.push(`Backfill abandoned sessions — ${summary.mode}`);
|
|
597
669
|
lines.push(` repo: ${summary.repoRoot}`);
|
|
598
670
|
lines.push(` sessions seen: ${summary.total}`);
|
|
671
|
+
if (summary.history_gaps > 0) {
|
|
672
|
+
lines.push(` history gaps: ${summary.history_gaps} rotated archive(s) no longer readable — sessions seen is a floor`);
|
|
673
|
+
}
|
|
599
674
|
if (summary.mode === 'apply') {
|
|
600
675
|
lines.push(` backfilled: ${summary.backfilled}`);
|
|
601
676
|
} else {
|
|
@@ -653,7 +728,7 @@ async function main() {
|
|
|
653
728
|
|
|
654
729
|
// --apply is an explicit opt-in; absent it (or with --dry-run) we never write.
|
|
655
730
|
const apply = values.apply === true && values['dry-run'] !== true;
|
|
656
|
-
const repoRoot = values['repo-root'] || getProjectDir();
|
|
731
|
+
const repoRoot = path.resolve(values['repo-root'] || getProjectDir());
|
|
657
732
|
|
|
658
733
|
let assumeDeadBeforeMs = null;
|
|
659
734
|
if (typeof values['assume-dead-before'] === 'string' && values['assume-dead-before'].length > 0) {
|
|
@@ -43,8 +43,9 @@
|
|
|
43
43
|
* a record that never left.
|
|
44
44
|
*
|
|
45
45
|
* Idempotence: dry-run is pure (reads only). `--apply` re-checks store+archive
|
|
46
|
-
* membership
|
|
47
|
-
*
|
|
46
|
+
* membership and appends inside ONE store-lock section (`withLearningsLock`),
|
|
47
|
+
* so a second `--apply` — sequential or concurrent — appends nothing and a
|
|
48
|
+
* later dry-run reports 0 orphans.
|
|
48
49
|
*
|
|
49
50
|
* NOTE on discovery: every file here is read through `node:fs`, never grep — a
|
|
50
51
|
* single NUL byte makes a tracked file invisible to grep (silent skip, exit 1,
|
|
@@ -70,7 +71,7 @@ import { parseSessionConfig } from './lib/config.mjs';
|
|
|
70
71
|
import { subjectToSlug, parseFrontmatter } from './lib/vault-mirror/utils.mjs';
|
|
71
72
|
import { kebab } from './lib/learnings/kebab.mjs';
|
|
72
73
|
import { validateLearning } from './lib/learnings/schema.mjs';
|
|
73
|
-
import { appendLearning, isBackupOf } from './lib/learnings/io.mjs';
|
|
74
|
+
import { appendLearning, isBackupOf, withLearningsLock } from './lib/learnings/io.mjs';
|
|
74
75
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
75
76
|
|
|
76
77
|
const DEFAULT_RULES_DIR = '.claude/rules';
|
|
@@ -907,28 +908,54 @@ export async function main(argv = [], deps = {}) {
|
|
|
907
908
|
});
|
|
908
909
|
}
|
|
909
910
|
|
|
910
|
-
// ── Apply (append-only, re-checked)
|
|
911
|
+
// ── Apply (append-only, re-checked under the store lock) ─────────────────
|
|
911
912
|
let applied = 0;
|
|
912
913
|
let skippedAlreadyPresent = 0;
|
|
913
914
|
if (apply) {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
915
|
+
const candidates = records.filter((r) => r.status === 'orphan' && r.restorable && r.record);
|
|
916
|
+
const failApply = (r, err) => {
|
|
917
|
+
r.applied = false;
|
|
918
|
+
r.apply_error = err.message;
|
|
919
|
+
process.stderr.write(`backfill-learnings-from-vault: append failed for ${r.learning_id}: ${err.message}\n`);
|
|
920
|
+
};
|
|
921
|
+
try {
|
|
922
|
+
// The membership re-check and the append are ONE critical section
|
|
923
|
+
// (#1487 item 12): checked outside the lock, two concurrent --apply runs
|
|
924
|
+
// — or one racing a session-end append — both saw "absent" and both
|
|
925
|
+
// appended. One section for the whole loop, not one per record: a single
|
|
926
|
+
// acquisition and a single read of store + archive instead of N of each.
|
|
927
|
+
// Hold time is linear in N small appends — fine for the tens of orphans a
|
|
928
|
+
// backfill restores; revisit (per-record sections) if a run ever holds
|
|
929
|
+
// the lock near the 10 s other writers wait. Archive movers (sweep/prune)
|
|
930
|
+
// also write under this lock, so the archive read is consistent too.
|
|
931
|
+
// appendLearning() re-enters the held lock (reentrant per async call
|
|
932
|
+
// chain, io.mjs).
|
|
933
|
+
await withLearningsLock(storeAbs, async () => {
|
|
934
|
+
const present = new Set([...idsInStore(storeAbs), ...idsInStore(archiveAbs)]);
|
|
935
|
+
for (const r of candidates) {
|
|
936
|
+
// This is what makes a second --apply a no-op rather than a
|
|
937
|
+
// duplicate append; `present.add` below covers two rules naming one id.
|
|
938
|
+
if (present.has(r.learning_id)) {
|
|
939
|
+
skippedAlreadyPresent += 1;
|
|
940
|
+
r.applied = false;
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
try {
|
|
944
|
+
await appendLearning(storeAbs, r.record);
|
|
945
|
+
applied += 1;
|
|
946
|
+
r.applied = true;
|
|
947
|
+
present.add(r.learning_id);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
failApply(r, err);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
} catch (err) {
|
|
954
|
+
// Lock not acquired (LearningsLockError), its directory not creatable, or
|
|
955
|
+
// a store read failed inside the section: no candidate still unhandled
|
|
956
|
+
// was written. Surface it per record, like any other append failure,
|
|
957
|
+
// instead of reporting a silent success.
|
|
958
|
+
for (const r of candidates) if (r.applied === undefined) failApply(r, err);
|
|
932
959
|
}
|
|
933
960
|
}
|
|
934
961
|
|
|
@@ -10,7 +10,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
10
10
|
for (let index = 0; index < argv.length; index++) {
|
|
11
11
|
const arg = argv[index];
|
|
12
12
|
if (arg === '--help' || arg === '-h') {
|
|
13
|
-
process.stdout.write('Usage: node scripts/baseline-archetypes.mjs [--repo PATH] [--archetype ID]\nOffline, read-only JSON lookup. Exit 0: public/private; exit 2: invalid configuration or contract.\n');
|
|
13
|
+
process.stdout.write('Usage: node scripts/baseline-archetypes.mjs [--repo PATH] [--archetype ID]\nOffline, read-only JSON lookup. Exit 0: public/private; exit 2: invalid configuration or contract.\nExit 2 reason producer-missing: the baseline directory exists but has no scripts/archetype-manifest.mjs exporter.\n');
|
|
14
14
|
return 0;
|
|
15
15
|
}
|
|
16
16
|
if (!['--repo', '--archetype'].includes(arg) || !argv[index + 1] || argv[index + 1].startsWith('--')) {
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
// (warn locally; upgraded to a hard failure under CI)
|
|
26
26
|
// c1 pnpm-lock.yaml must NOT be tracked in git
|
|
27
27
|
// c2 package-lock.json MUST be tracked in git
|
|
28
|
+
// 3. Husky activation (#1458, advisory only — never affects `ok`/exit code):
|
|
29
|
+
// h1 `.husky/` exists but git core.hooksPath is not `.husky/_` — the
|
|
30
|
+
// `prepare: husky` script never ran because ignore-scripts=true,
|
|
31
|
+
// so commit-msg/pre-commit/pre-push are inactive. Warns locally
|
|
32
|
+
// and recommends `npx husky`; silent under CI.
|
|
28
33
|
//
|
|
29
34
|
// SCOPE GUARD:
|
|
30
35
|
// This script only guards the REPOSITORY ROOT package.json. If invoked
|
|
@@ -64,8 +69,8 @@
|
|
|
64
69
|
// on any dependency install).
|
|
65
70
|
|
|
66
71
|
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
67
|
-
import { dirname, join, resolve } from 'node:path';
|
|
68
|
-
import { spawnSync } from 'node:child_process';
|
|
72
|
+
import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
|
|
73
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
69
74
|
import { fileURLToPath } from 'node:url';
|
|
70
75
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
71
76
|
|
|
@@ -266,11 +271,53 @@ export function parseCiEnv(value) {
|
|
|
266
271
|
return !['', '0', 'false', 'no'].includes(normalized);
|
|
267
272
|
}
|
|
268
273
|
|
|
274
|
+
/** One-line stderr WARN for an inactive Husky install (#1458). */
|
|
275
|
+
export const HUSKY_INACTIVE_WARNING =
|
|
276
|
+
'⚠ check-package-manager: git core.hooksPath is not set to .husky/_ — Husky hooks (commit-msg, pre-commit, pre-push) are INACTIVE in this clone because .npmrc ignore-scripts=true skips the prepare script (#1458). Run: npx husky';
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Advisory check (#1458): `.npmrc` `ignore-scripts=true` (SEC-020) means the
|
|
280
|
+
* `prepare: husky` script never runs on `npm ci`, so a fresh clone has no
|
|
281
|
+
* `core.hooksPath` and every Husky hook is silently inactive. This only
|
|
282
|
+
* REPORTS the state — it never runs `npx husky`, never writes git config and
|
|
283
|
+
* never affects the guard's `ok`/exit code. Silent under CI.
|
|
284
|
+
* @param {string} repoRoot
|
|
285
|
+
* @param {{ env?: NodeJS.ProcessEnv, ci?: boolean }} [opts] — `env` is also
|
|
286
|
+
* passed to the git subprocess (lets tests isolate from global git config).
|
|
287
|
+
* @returns {{ hooksPath: string, active: boolean, skipped: 'ci'|'no-husky-dir'|null }}
|
|
288
|
+
*/
|
|
289
|
+
export function checkHuskyHooksPath(repoRoot, { env = process.env, ci } = {}) {
|
|
290
|
+
if (ci ?? parseCiEnv(env.CI)) return { hooksPath: '', active: false, skipped: 'ci' };
|
|
291
|
+
if (!existsSync(join(repoRoot, '.husky'))) {
|
|
292
|
+
return { hooksPath: '', active: false, skipped: 'no-husky-dir' };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let hooksPath;
|
|
296
|
+
try {
|
|
297
|
+
hooksPath = execFileSync('git', ['-C', repoRoot, 'config', '--get', 'core.hooksPath'], {
|
|
298
|
+
encoding: 'utf8',
|
|
299
|
+
env,
|
|
300
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
301
|
+
}).trim();
|
|
302
|
+
} catch {
|
|
303
|
+
// Non-zero exit (key unset) or git unavailable — treat as unset.
|
|
304
|
+
hooksPath = '';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const norm = hooksPath.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
308
|
+
const active =
|
|
309
|
+
norm !== '' &&
|
|
310
|
+
(isAbsolute(hooksPath) ? norm.endsWith('/.husky/_') : posix.normalize(norm) === '.husky/_');
|
|
311
|
+
|
|
312
|
+
return { hooksPath, active, skipped: null };
|
|
313
|
+
}
|
|
314
|
+
|
|
269
315
|
/**
|
|
270
316
|
* Runs the full guard and returns a plain-object result. Pure(ish) — the
|
|
271
317
|
* only side effects are read-only fs/git calls. Never throws or exits.
|
|
272
|
-
*
|
|
273
|
-
* @
|
|
318
|
+
* `hooks` carries the advisory Husky check (#1458); it never affects `ok`.
|
|
319
|
+
* @param {{ cwd?: string, ci?: boolean, allowMissingNodeModules?: boolean, repoRoot?: string, env?: NodeJS.ProcessEnv }} [opts]
|
|
320
|
+
* @returns {{ ok: boolean, ci: boolean, skipped: boolean, repoRoot: string, findings: Array<{code: string, severity: string, effectiveSeverity: string, message: string}>, hooks?: { hooksPath: string, active: boolean, skipped: 'ci'|'no-husky-dir'|null } }}
|
|
274
321
|
*/
|
|
275
322
|
export function runPackageManagerGuard(opts = {}) {
|
|
276
323
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -302,7 +349,9 @@ export function runPackageManagerGuard(opts = {}) {
|
|
|
302
349
|
|
|
303
350
|
const ok = !findings.some((f) => f.effectiveSeverity === 'error');
|
|
304
351
|
|
|
305
|
-
|
|
352
|
+
const hooks = checkHuskyHooksPath(repoRoot, { env: opts.env ?? process.env, ci });
|
|
353
|
+
|
|
354
|
+
return { ok, ci, skipped: false, repoRoot, findings, hooks };
|
|
306
355
|
}
|
|
307
356
|
|
|
308
357
|
// ---------------------------------------------------------------------------
|
|
@@ -332,6 +381,9 @@ CHECKS
|
|
|
332
381
|
(warn locally; upgraded to a hard failure under CI, fail-closed)
|
|
333
382
|
c1 pnpm-lock.yaml must NOT be tracked in git
|
|
334
383
|
c2 package-lock.json MUST be tracked in git
|
|
384
|
+
Husky activation (advisory, never changes the exit code; silent under CI):
|
|
385
|
+
h1 .husky/ exists but git core.hooksPath is not .husky/_ (#1458) —
|
|
386
|
+
run \`npx husky\` once (ignore-scripts=true skips \`prepare\`)
|
|
335
387
|
|
|
336
388
|
OPTIONS
|
|
337
389
|
--allow-missing-node-modules Document that node_modules may not exist yet
|
|
@@ -412,6 +464,11 @@ if (isMain) {
|
|
|
412
464
|
|
|
413
465
|
const result = runPackageManagerGuard({ allowMissingNodeModules: opts.allowMissingNodeModules });
|
|
414
466
|
|
|
467
|
+
// Advisory only (#1458) — never changes the exit code.
|
|
468
|
+
if (result.hooks && !result.hooks.active && result.hooks.skipped === null) {
|
|
469
|
+
console.error(HUSKY_INACTIVE_WARNING);
|
|
470
|
+
}
|
|
471
|
+
|
|
415
472
|
if (opts.json) {
|
|
416
473
|
console.log(JSON.stringify(result));
|
|
417
474
|
process.exit(result.ok ? 0 : 1);
|
|
@@ -93,6 +93,9 @@ function runCodex(args) {
|
|
|
93
93
|
const result = spawnSync('codex', args, {
|
|
94
94
|
encoding: 'utf8',
|
|
95
95
|
timeout: CODEX_COMMAND_TIMEOUT_MS,
|
|
96
|
+
// 'codex plugin list --available --json' exceeds the 1 MiB default once a
|
|
97
|
+
// few marketplaces are registered (measured 2026-09-22: 2.19 MB → ENOBUFS).
|
|
98
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
96
99
|
});
|
|
97
100
|
|
|
98
101
|
if (result.error) {
|