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
|
@@ -86,7 +86,16 @@ strip_fences "$CLAUDE" > "$CLAUDE_NOFENCE"
|
|
|
86
86
|
# ---------------------------------------------------------------------------
|
|
87
87
|
# Check 3: live `## Session Config` present in CLAUDE.md
|
|
88
88
|
# ---------------------------------------------------------------------------
|
|
89
|
-
|
|
89
|
+
# The pattern is EXACT, mirroring isSessionConfigHeading() in
|
|
90
|
+
# scripts/lib/config/section-extractor.mjs (the SSOT) — CR tolerated for CRLF
|
|
91
|
+
# checkouts, nothing else. Shell cannot import the JS predicate, so the
|
|
92
|
+
# alignment is by hand and this comment names the authority. It was
|
|
93
|
+
# `[[:space:]]*$` until #968, which accepted `## Session Config␠` — a heading
|
|
94
|
+
# the runtime parser rejects. That made this check, whose entire purpose is to
|
|
95
|
+
# report whether the runtime can find the block, answer "present" for the one
|
|
96
|
+
# state it exists to catch.
|
|
97
|
+
CR=$(printf '\r')
|
|
98
|
+
if ! grep -qE "^## Session Config${CR}?\$" "$CLAUDE"; then
|
|
90
99
|
emit "$CLAUDE:1" "live-config-missing" "## Session Config heading not found (skills/_shared/config-reading.md depends on it)"
|
|
91
100
|
fi
|
|
92
101
|
|
|
@@ -94,6 +103,13 @@ fi
|
|
|
94
103
|
# Check 1: H2 parity (CLAUDE.md → README.md)
|
|
95
104
|
# Canonical runtime-only headings live in CLAUDE.md exclusively and are exempt.
|
|
96
105
|
# ---------------------------------------------------------------------------
|
|
106
|
+
# NOT tightened to the SSOT (#968), deliberately. This is a README↔CLAUDE H2
|
|
107
|
+
# PARITY-exemption list, not a comparator against "can the runtime read the
|
|
108
|
+
# Session Config block" — four of its five entries have no SSOT at all, and a
|
|
109
|
+
# heading it wrongly exempts costs a missing parity warning, never a silent
|
|
110
|
+
# config fallback. Check 3 above is the site that answers the runtime question,
|
|
111
|
+
# and that one is exact. Tightening only the `Session Config` alternative would
|
|
112
|
+
# make section-extractor.mjs the authority for a fact it does not govern.
|
|
97
113
|
EXEMPT_HEADINGS_RE='^## (Structure|Destructive-Command Guard|Agent Authoring Rules|Current State|Session Config)[[:space:]]*$'
|
|
98
114
|
|
|
99
115
|
# Extract README H2 set (one per line, normalized — heading text without the leading `## `)
|
package/scripts/emit-session.mjs
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { readFileSync } from 'node:fs';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
29
|
import { appendJsonl } from './lib/common.mjs';
|
|
30
|
+
import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
|
|
30
31
|
import {
|
|
31
32
|
validateSession,
|
|
32
33
|
ValidationError,
|
|
@@ -35,45 +36,7 @@ import {
|
|
|
35
36
|
aliasLegacyEndedAt,
|
|
36
37
|
} from './lib/session-schema.mjs';
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
* Serialize `validated` to a single JSONL line and prove it round-trips:
|
|
40
|
-
* the line MUST be JSON-parseable AND the parsed-back object MUST still pass
|
|
41
|
-
* `validateSession`. This is the pre-write self-validation seam (#662) that
|
|
42
|
-
* mirrors the `appendLearning` guard — it catches non-serializable values
|
|
43
|
-
* (`undefined`, `NaN`, `Infinity`, `BigInt`, circular refs) that
|
|
44
|
-
* `JSON.stringify` silently drops or that produce a line which won't parse
|
|
45
|
-
* back to the same schema-valid shape.
|
|
46
|
-
*
|
|
47
|
-
* Throws ValidationError (consumed by the caller as a validation failure,
|
|
48
|
-
* exit 1). Does not write — the caller appends only after this returns.
|
|
49
|
-
*
|
|
50
|
-
* @param {object} validated — already validated session entry
|
|
51
|
-
* @returns {string} the verified JSONL line (newline-terminated)
|
|
52
|
-
* @throws {ValidationError} when the serialized line does not round-trip
|
|
53
|
-
*/
|
|
54
|
-
export function serializeSessionLineChecked(validated) {
|
|
55
|
-
let line;
|
|
56
|
-
try {
|
|
57
|
-
line = JSON.stringify(validated);
|
|
58
|
-
} catch (err) {
|
|
59
|
-
throw new ValidationError(`session is not JSON-serializable: ${err.message}`);
|
|
60
|
-
}
|
|
61
|
-
if (typeof line !== 'string' || line.length === 0) {
|
|
62
|
-
throw new ValidationError('session serialized to an empty line');
|
|
63
|
-
}
|
|
64
|
-
let reparsed;
|
|
65
|
-
try {
|
|
66
|
-
reparsed = JSON.parse(line);
|
|
67
|
-
} catch (err) {
|
|
68
|
-
throw new ValidationError(
|
|
69
|
-
`serialized session line does not parse back as JSON: ${err.message}`
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
// Re-validate the round-tripped shape — catches required fields that were
|
|
73
|
-
// present as `undefined`/`NaN` before stringify but vanished after.
|
|
74
|
-
validateSession(reparsed);
|
|
75
|
-
return line + '\n';
|
|
76
|
-
}
|
|
39
|
+
export { serializeSessionLineChecked };
|
|
77
40
|
|
|
78
41
|
function parseArgs(argv) {
|
|
79
42
|
const args = { file: '.orchestrator/metrics/sessions.jsonl', entry: null };
|
|
@@ -167,7 +130,7 @@ async function main() {
|
|
|
167
130
|
// sessions.jsonl and only surface on the NEXT session's read. Treated as a
|
|
168
131
|
// validation failure (exit 1); file is left untouched.
|
|
169
132
|
try {
|
|
170
|
-
serializeSessionLineChecked(
|
|
133
|
+
serializeSessionLineChecked(repaired);
|
|
171
134
|
} catch (err) {
|
|
172
135
|
if (err instanceof ValidationError) {
|
|
173
136
|
process.stderr.write(
|
package/scripts/eval-session.mjs
CHANGED
|
@@ -10,6 +10,36 @@
|
|
|
10
10
|
* - Data → stdout, diagnostics → stderr.
|
|
11
11
|
* - Exit codes: 0 success/match · 1 user-error/drift · 2 system error.
|
|
12
12
|
*
|
|
13
|
+
* ## Append failures are NOT exit 0 (GitLab #969)
|
|
14
|
+
*
|
|
15
|
+
* `appendEvalRecord` is a never-throw sink: it returns
|
|
16
|
+
* `{ ok:false, reason:'validation'|'fs-error' }` instead of raising. This CLI
|
|
17
|
+
* used to log that WARN and still `process.exit(0)` — the caller saw success
|
|
18
|
+
* while the journal had no record, one surface reporting a fact the other did
|
|
19
|
+
* not carry. A failed append now exits non-zero, split by reason:
|
|
20
|
+
*
|
|
21
|
+
* - `fs-error` → EXIT_SYSTEM (2). The filesystem refused the append
|
|
22
|
+
* (permissions, ENOTDIR, full disk) — "system error" per cli-design.md.
|
|
23
|
+
* - `validation` → EXIT_USER (1). Reachable from the invocation: `--handle ""`
|
|
24
|
+
* survives the `?? null` default as an empty string and trips the
|
|
25
|
+
* "handle must be a non-empty string or null" rule in eval/schema.mjs.
|
|
26
|
+
* Malformed metrics input reaches the same path, and cli-design.md maps
|
|
27
|
+
* "bad args, invalid file" to 1. This matches the rest of this file, where
|
|
28
|
+
* every input-derived failure is EXIT_USER and only engine crashes are
|
|
29
|
+
* EXIT_SYSTEM. (An engine-internal invariant breach also lands here — a
|
|
30
|
+
* mild under-classification, accepted so the reachable case stays honest.)
|
|
31
|
+
*
|
|
32
|
+
* Both branches emit the `--json` / human payload to stdout BEFORE exiting, and
|
|
33
|
+
* do so via `writeStdoutLineSync` rather than a queued write: `process.stdout`
|
|
34
|
+
* is asynchronous on a pipe on macOS, so `process.exit()` discards whatever is
|
|
35
|
+
* still in libuv's queue past the 65 536-byte kernel buffer. Losing the record
|
|
36
|
+
* alongside the error is the exact fail-open shape #906 fixed in the hook layer.
|
|
37
|
+
* Real records measure ≤ 5 210 bytes today, but `evidence` strings are
|
|
38
|
+
* engine-generated free text, so the bound is empirical, not structural.
|
|
39
|
+
*
|
|
40
|
+
* Session-end Phase 3.7d remains advisory and MUST NOT gate on this exit code —
|
|
41
|
+
* it catches a non-zero exit and logs a WARN (skills/session-end/SKILL.md).
|
|
42
|
+
*
|
|
13
43
|
* Usage:
|
|
14
44
|
* eval-session.mjs [--session <id>] [--json] [--no-write]
|
|
15
45
|
* [--metrics-dir <path>] [--rubric <path>]
|
|
@@ -29,6 +59,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
29
59
|
|
|
30
60
|
import { evaluateSession, diffDimensions, DEFAULT_RUBRIC_PATH, RUBRIC_VERSION } from './lib/eval/engine.mjs';
|
|
31
61
|
import { appendEvalRecord, readEvalRecords } from './lib/eval/sink.mjs';
|
|
62
|
+
import { writeStdoutLineSync } from './lib/io.mjs';
|
|
32
63
|
import { VALID_MODEL_SOURCES } from './lib/eval/schema.mjs';
|
|
33
64
|
import { SessionResolutionError } from './lib/eval/session-resolve.mjs';
|
|
34
65
|
|
|
@@ -69,8 +100,9 @@ OPTIONS
|
|
|
69
100
|
|
|
70
101
|
EXIT CODES
|
|
71
102
|
0 success / verify match
|
|
72
|
-
1 user error (session not found, unknown run-id
|
|
73
|
-
|
|
103
|
+
1 user error (session not found, unknown run-id, record failed validation)
|
|
104
|
+
/ verify drift
|
|
105
|
+
2 system error (could not append the record to the eval journal)
|
|
74
106
|
`;
|
|
75
107
|
|
|
76
108
|
function fail(exitCode, message) {
|
|
@@ -144,18 +176,27 @@ function runEvaluate(values) {
|
|
|
144
176
|
const { record, summary } = result;
|
|
145
177
|
|
|
146
178
|
let writeResult = null;
|
|
179
|
+
let appendFailureReason = null;
|
|
147
180
|
if (!values['no-write']) {
|
|
148
181
|
writeResult = appendEvalRecord(record, { path: path.join(metricsDir, 'eval.jsonl') });
|
|
149
182
|
if (!writeResult.ok) {
|
|
150
|
-
// never-throw sink already emitted
|
|
183
|
+
// The never-throw sink already emitted its own stderr WARN; add the
|
|
184
|
+
// CLI-level line, then carry the reason to the exit code below.
|
|
151
185
|
process.stderr.write(`[eval-session] append failed (${writeResult.reason}): ${writeResult.error}\n`);
|
|
186
|
+
appendFailureReason = writeResult.reason;
|
|
152
187
|
}
|
|
153
188
|
}
|
|
154
189
|
|
|
190
|
+
// stdout FIRST, synchronously — the caller must not lose the payload along
|
|
191
|
+
// with the error (see the append-failure note in the module docblock).
|
|
155
192
|
if (values.json) {
|
|
156
|
-
|
|
193
|
+
writeStdoutLineSync(JSON.stringify(record));
|
|
157
194
|
} else {
|
|
158
|
-
|
|
195
|
+
writeStdoutLineSync(renderHuman(record, summary, writeResult));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (appendFailureReason !== null) {
|
|
199
|
+
process.exit(appendFailureReason === 'validation' ? EXIT_USER : EXIT_SYSTEM);
|
|
159
200
|
}
|
|
160
201
|
process.exit(EXIT_OK);
|
|
161
202
|
}
|
|
@@ -195,15 +236,15 @@ function runVerify(runId, values) {
|
|
|
195
236
|
|
|
196
237
|
if (diffs.length === 0) {
|
|
197
238
|
if (values.json) {
|
|
198
|
-
|
|
239
|
+
writeStdoutLineSync(JSON.stringify({ run_id: runId, match: true, dimensions: fresh.dimensions.length }));
|
|
199
240
|
} else {
|
|
200
|
-
|
|
241
|
+
writeStdoutLineSync(`MATCH: ${runId} re-evaluates identically across ${fresh.dimensions.length} dimension(s).`);
|
|
201
242
|
}
|
|
202
243
|
process.exit(EXIT_OK);
|
|
203
244
|
}
|
|
204
245
|
|
|
205
246
|
if (values.json) {
|
|
206
|
-
|
|
247
|
+
writeStdoutLineSync(JSON.stringify({ run_id: runId, match: false, diffs }));
|
|
207
248
|
} else {
|
|
208
249
|
const out = [`DRIFT: ${runId} re-evaluation differs from the stored record:`];
|
|
209
250
|
for (const d of diffs) {
|
|
@@ -213,7 +254,7 @@ function runVerify(runId, values) {
|
|
|
213
254
|
out.push(` ${d.id}.${d.field}: stored=${JSON.stringify(d.stored)} fresh=${JSON.stringify(d.fresh)}`);
|
|
214
255
|
}
|
|
215
256
|
}
|
|
216
|
-
|
|
257
|
+
writeStdoutLineSync(out.join('\n'));
|
|
217
258
|
}
|
|
218
259
|
process.exit(EXIT_USER);
|
|
219
260
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fleet-instruction-scan.mjs — always-on instruction load across every repo on the host.
|
|
4
|
+
*
|
|
5
|
+
* `scripts/measure-context-overhead.sh` measures one directory precisely but
|
|
6
|
+
* costs an API call per measurement. This scans the whole fleet for free by
|
|
7
|
+
* counting bytes and classifying them the way `scripts/lib/rule-loader.mjs`
|
|
8
|
+
* does: a rule file is always-on unless its frontmatter carries `globs:` or
|
|
9
|
+
* `paths:` (issue #795), and it reaches wave agents unless it is tagged
|
|
10
|
+
* `tier: coordinator-only` (issue #692).
|
|
11
|
+
*
|
|
12
|
+
* Token estimates use the ratio measured 2026-07-30 on claude-opus-5[1m]:
|
|
13
|
+
* 108589 always-on bytes in this repo produced roughly 67396 context tokens,
|
|
14
|
+
* i.e. about 1 token per 1.6 bytes. Treat the estimate as an order of
|
|
15
|
+
* magnitude and confirm a specific repo with measure-context-overhead.sh.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node scripts/fleet-instruction-scan.mjs
|
|
19
|
+
* node scripts/fleet-instruction-scan.mjs ~/Projects/intern ~/Projects/extern
|
|
20
|
+
* node scripts/fleet-instruction-scan.mjs --json
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { basename, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const BYTES_PER_TOKEN = 1.6;
|
|
28
|
+
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const asJson = argv.includes('--json');
|
|
31
|
+
const roots = argv.filter((a) => !a.startsWith('--'));
|
|
32
|
+
const ROOTS = roots.length
|
|
33
|
+
? roots
|
|
34
|
+
: [
|
|
35
|
+
join(homedir(), 'Projects', 'extern'),
|
|
36
|
+
join(homedir(), 'Projects', 'intern'),
|
|
37
|
+
join(homedir(), 'Projects', 'Ventures'),
|
|
38
|
+
join(homedir(), 'Projects', 'Bernhard'),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Classify one rule file the way rule-loader.mjs does. */
|
|
42
|
+
function classify(path) {
|
|
43
|
+
const body = readFileSync(path, 'utf8');
|
|
44
|
+
const fmMatch = body.match(/^---\n([\s\S]*?)\n---/);
|
|
45
|
+
const fm = fmMatch ? fmMatch[1] : '';
|
|
46
|
+
const tierMatch = fm.match(/^tier:\s*(\S+)/m);
|
|
47
|
+
return {
|
|
48
|
+
scoped: /^(globs|paths):/m.test(fm),
|
|
49
|
+
tier: tierMatch ? tierMatch[1] : 'always',
|
|
50
|
+
bytes: Buffer.byteLength(body),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scanRepo(dir) {
|
|
55
|
+
let alwaysBytes = 0;
|
|
56
|
+
let waveBytes = 0;
|
|
57
|
+
let files = 0;
|
|
58
|
+
let scopedFiles = 0;
|
|
59
|
+
const rulesDir = join(dir, '.claude', 'rules');
|
|
60
|
+
if (existsSync(rulesDir)) {
|
|
61
|
+
for (const f of readdirSync(rulesDir).filter((n) => n.endsWith('.md'))) {
|
|
62
|
+
const c = classify(join(rulesDir, f));
|
|
63
|
+
files++;
|
|
64
|
+
if (c.scoped) {
|
|
65
|
+
scopedFiles++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
alwaysBytes += c.bytes;
|
|
69
|
+
if (c.tier !== 'coordinator-only') waveBytes += c.bytes;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const instructionFile = ['CLAUDE.md', 'AGENTS.md'].map((f) => join(dir, f)).find(existsSync);
|
|
73
|
+
const cmBytes = instructionFile ? statSync(instructionFile).size : 0;
|
|
74
|
+
return { files, scopedFiles, alwaysBytes, waveBytes, cmBytes, total: alwaysBytes + cmBytes };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const root of ROOTS) {
|
|
79
|
+
if (!existsSync(root)) continue;
|
|
80
|
+
for (const name of readdirSync(root)) {
|
|
81
|
+
const dir = join(root, name);
|
|
82
|
+
try {
|
|
83
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!existsSync(join(dir, '.git'))) continue;
|
|
88
|
+
rows.push({ repo: `${basename(root)}/${name}`, ...scanRepo(dir) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
rows.sort((a, b) => b.total - a.total);
|
|
92
|
+
|
|
93
|
+
if (asJson) {
|
|
94
|
+
console.log(JSON.stringify({ bytesPerToken: BYTES_PER_TOKEN, repos: rows }, null, 2));
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pad = (s, n) => String(s).padStart(n);
|
|
99
|
+
console.log(
|
|
100
|
+
'REPO'.padEnd(36),
|
|
101
|
+
pad('RULES', 6),
|
|
102
|
+
pad('SCOPED', 7),
|
|
103
|
+
pad('ALWAYS_B', 9),
|
|
104
|
+
pad('WAVE_B', 8),
|
|
105
|
+
pad('INSTR_B', 8),
|
|
106
|
+
pad('EST_TOK', 8),
|
|
107
|
+
);
|
|
108
|
+
for (const r of rows) {
|
|
109
|
+
console.log(
|
|
110
|
+
r.repo.padEnd(36),
|
|
111
|
+
pad(r.files, 6),
|
|
112
|
+
pad(r.scopedFiles, 7),
|
|
113
|
+
pad(r.alwaysBytes, 9),
|
|
114
|
+
pad(r.waveBytes, 8),
|
|
115
|
+
pad(r.cmBytes, 8),
|
|
116
|
+
pad(Math.round(r.total / BYTES_PER_TOKEN), 8),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const totalFiles = rows.reduce((a, r) => a + r.files, 0);
|
|
121
|
+
const totalScoped = rows.reduce((a, r) => a + r.scopedFiles, 0);
|
|
122
|
+
const median = (xs) => {
|
|
123
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
124
|
+
return s.length ? s[Math.floor(s.length / 2)] : 0;
|
|
125
|
+
};
|
|
126
|
+
const withRules = rows.filter((r) => r.files > 0);
|
|
127
|
+
|
|
128
|
+
console.log(`\nrepos scanned : ${rows.length} (${withRules.length} with rule files)`);
|
|
129
|
+
console.log(`rule files : ${totalFiles} (${totalScoped} glob/path-scoped, ${totalFiles - totalScoped} always-on)`);
|
|
130
|
+
console.log(`median always-on bytes : ${median(withRules.map((r) => r.alwaysBytes))}`);
|
|
131
|
+
console.log(`median est. tokens/session: ${Math.round(median(withRules.map((r) => r.total)) / BYTES_PER_TOKEN)}`);
|
|
132
|
+
|
|
133
|
+
// A value shared by many repos means a vendored rule set: trimming it at the
|
|
134
|
+
// source propagates everywhere, which is the cheapest lever in the fleet.
|
|
135
|
+
const byBytes = new Map();
|
|
136
|
+
for (const r of withRules) byBytes.set(r.alwaysBytes, (byBytes.get(r.alwaysBytes) || 0) + 1);
|
|
137
|
+
const shared = [...byBytes.entries()].filter(([, n]) => n > 1).sort((a, b) => b[1] - a[1]);
|
|
138
|
+
if (shared.length) {
|
|
139
|
+
console.log('\nidentical always-on totals (vendored rule sets — trim once, propagate widely):');
|
|
140
|
+
for (const [bytes, n] of shared.slice(0, 5)) console.log(` ${bytes} B in ${n} repos`);
|
|
141
|
+
}
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
import { execFile as execFileCb } from 'node:child_process';
|
|
14
14
|
import { promisify } from 'node:util';
|
|
15
15
|
|
|
16
|
+
import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
|
|
17
|
+
|
|
16
18
|
const realExecFile = promisify(execFileCb);
|
|
17
19
|
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
@@ -112,13 +114,22 @@ export function validateMrInputs(title, description) {
|
|
|
112
114
|
* @param {'glab'|'gh'} opts.vcs
|
|
113
115
|
* @param {string} opts.branchName
|
|
114
116
|
* @param {Function} [opts.execFile] - defaults to promisified execFile from node:child_process
|
|
117
|
+
* @param {string} [opts.repoRoot] - defaults to process.cwd()
|
|
118
|
+
* @param {(opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string | undefined} [opts.resolveRepoSpecFn]
|
|
119
|
+
* Injectable seam for the `-R`/`--repo` host-pinning resolution (#872) —
|
|
120
|
+
* defaults to the real `resolveRepoSpec` (shells out to `git remote
|
|
121
|
+
* get-url`); tests inject a stub instead of shelling out.
|
|
115
122
|
* @returns {Promise<{hasMR: boolean, mrIid: number|null, mrUrl: string|null}>}
|
|
116
123
|
*/
|
|
117
124
|
export async function checkExistingMR(opts) {
|
|
118
|
-
const { vcs, branchName } = opts;
|
|
125
|
+
const { vcs, branchName, repoRoot = process.cwd() } = opts;
|
|
119
126
|
const execFile = typeof opts.execFile === 'function' ? opts.execFile : realExecFile;
|
|
127
|
+
const resolveRepoSpecFn =
|
|
128
|
+
typeof opts.resolveRepoSpecFn === 'function' ? opts.resolveRepoSpecFn : resolveRepoSpec;
|
|
120
129
|
|
|
121
130
|
if (vcs === 'glab') {
|
|
131
|
+
// Resolve the -R/--repo host-pinning spec ONCE for this call (#872).
|
|
132
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
122
133
|
const args = [
|
|
123
134
|
'mr',
|
|
124
135
|
'list',
|
|
@@ -129,6 +140,7 @@ export async function checkExistingMR(opts) {
|
|
|
129
140
|
'--output',
|
|
130
141
|
'json',
|
|
131
142
|
];
|
|
143
|
+
if (spec) args.push('-R', spec);
|
|
132
144
|
const { stdout } = await execFile('glab', args, { shell: false, timeout: 5_000 });
|
|
133
145
|
let mrs;
|
|
134
146
|
try {
|
|
@@ -148,7 +160,10 @@ export async function checkExistingMR(opts) {
|
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
if (vcs === 'gh') {
|
|
163
|
+
// Resolve the -R/--repo host-pinning spec ONCE for this call (#872).
|
|
164
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs: 'github' });
|
|
151
165
|
const args = ['pr', 'list', '--head', branchName, '--state', 'open', '--json', 'number,url'];
|
|
166
|
+
if (spec) args.push('-R', spec);
|
|
152
167
|
const { stdout } = await execFile('gh', args, { shell: false, timeout: 5_000 });
|
|
153
168
|
let prs;
|
|
154
169
|
try {
|
|
@@ -391,6 +406,11 @@ export function buildMrBody(ctx) {
|
|
|
391
406
|
* @param {object} [opts]
|
|
392
407
|
* @param {Function} [opts.execFile]
|
|
393
408
|
* @param {(level: string, msg: string) => void} [opts.log]
|
|
409
|
+
* @param {string} [opts.repoRoot] - defaults to process.cwd()
|
|
410
|
+
* @param {(opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string | undefined} [opts.resolveRepoSpecFn]
|
|
411
|
+
* Injectable seam for the `-R`/`--repo` host-pinning resolution (#872) —
|
|
412
|
+
* defaults to the real `resolveRepoSpec` (shells out to `git remote
|
|
413
|
+
* get-url`); tests inject a stub instead of shelling out.
|
|
394
414
|
* @returns {Promise<{created: boolean, deferred?: boolean, existing?: boolean, mrUrl?: string|null, error?: string}>}
|
|
395
415
|
*/
|
|
396
416
|
export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
@@ -399,6 +419,9 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
|
399
419
|
? opts.log
|
|
400
420
|
: (_level, _msg) => {}; // no-op default
|
|
401
421
|
const execFile = typeof opts.execFile === 'function' ? opts.execFile : realExecFile;
|
|
422
|
+
const repoRoot = typeof opts.repoRoot === 'string' ? opts.repoRoot : process.cwd();
|
|
423
|
+
const resolveRepoSpecFn =
|
|
424
|
+
typeof opts.resolveRepoSpecFn === 'function' ? opts.resolveRepoSpecFn : resolveRepoSpec;
|
|
402
425
|
|
|
403
426
|
const { draftMrPolicy, vcs, issueIid, issueTitle, branchName, parentRunId, worktreePath } = loop;
|
|
404
427
|
|
|
@@ -442,6 +465,11 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
|
442
465
|
worktreePath,
|
|
443
466
|
});
|
|
444
467
|
|
|
468
|
+
// Resolve the -R/--repo host-pinning spec ONCE (#872); reuse the same
|
|
469
|
+
// resolved value for both the collision check and the create call below
|
|
470
|
+
// (mirrors #839's spiral-carryover.mjs / issue-close-strip-labels.mjs idiom).
|
|
471
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs });
|
|
472
|
+
|
|
445
473
|
// Collision check — skip if MR already exists
|
|
446
474
|
let existingCheck;
|
|
447
475
|
try {
|
|
@@ -449,6 +477,7 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
|
449
477
|
vcs: vcsBin, // 'glab' or 'gh'
|
|
450
478
|
branchName,
|
|
451
479
|
execFile,
|
|
480
|
+
resolveRepoSpecFn: () => spec,
|
|
452
481
|
});
|
|
453
482
|
} catch (err) {
|
|
454
483
|
log('error', `mr-draft: collision check failed — ${err.message}`);
|
|
@@ -491,6 +520,7 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
|
491
520
|
branchName,
|
|
492
521
|
];
|
|
493
522
|
}
|
|
523
|
+
if (spec) createArgs.push('-R', spec);
|
|
494
524
|
|
|
495
525
|
// Execute MR/PR creation
|
|
496
526
|
try {
|
|
@@ -32,7 +32,8 @@ import fs, { realpathSync } from 'node:fs';
|
|
|
32
32
|
import { runLoop } from './loop.mjs';
|
|
33
33
|
import { maybeCreateDraftMR } from './mr-draft.mjs';
|
|
34
34
|
import { validateWorkspacePath } from '../worktree/lifecycle.mjs';
|
|
35
|
-
import { acquire, release } from '../session-lock.mjs';
|
|
35
|
+
import { acquire, release, buildLockOwnerProof } from '../session-lock.mjs';
|
|
36
|
+
import { emitEvent } from '../events.mjs';
|
|
36
37
|
import { main as gcMain } from '../../gc-stale-worktrees.mjs';
|
|
37
38
|
import { SEMANTIC_ID_RE } from '../session-id.mjs';
|
|
38
39
|
|
|
@@ -65,6 +66,9 @@ import { SEMANTIC_ID_RE } from '../session-id.mjs';
|
|
|
65
66
|
* @property {string|null} killSwitch
|
|
66
67
|
* @property {string|null} killSwitchDetail
|
|
67
68
|
* @property {number} iterationsCompleted
|
|
69
|
+
* @property {boolean} fallbackToManual - Mirrors loop.mjs's `fallback_to_manual` (#905): true
|
|
70
|
+
* when the inner loop broke out with no mode recommendation instead of doing real work.
|
|
71
|
+
* The caller (autopilot-multi.mjs) MUST NOT treat a true value as a completed loop.
|
|
68
72
|
* @property {number} spiralRecoveryCount
|
|
69
73
|
* @property {string|null} commitDependency
|
|
70
74
|
* @property {boolean} abortedByCohort
|
|
@@ -128,6 +132,42 @@ function repoBasename(repoRoot) {
|
|
|
128
132
|
return path.basename(repoRoot);
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Render a worktree path for an event record: repo-relative, never absolute.
|
|
137
|
+
*
|
|
138
|
+
* `events.jsonl` records get quoted into issues and MR descriptions, and this
|
|
139
|
+
* repo mirrors to a PUBLIC GitHub remote — so an absolute worktree path ships
|
|
140
|
+
* the operator's home directory (`/Users/<name>/…`) into a public artefact
|
|
141
|
+
* (#957 finding 3). The forensic value of the field is "WHICH worktree", which
|
|
142
|
+
* the repo-relative form carries in full.
|
|
143
|
+
*
|
|
144
|
+
* Escaping the repo root is expected, not an error: the default worktree root
|
|
145
|
+
* is `~/.so-worktrees`, so the normal output is a `../…` chain. That form is
|
|
146
|
+
* still host-agnostic — `path.relative` strips the COMMON PREFIX, which is
|
|
147
|
+
* exactly where `/Users/<name>` lives.
|
|
148
|
+
*
|
|
149
|
+
* Pure string math: no `realpathSync`, no fs access, cannot throw. This runs on
|
|
150
|
+
* the teardown path where the worktree may already have been removed.
|
|
151
|
+
*
|
|
152
|
+
* Residual, deliberately not defended against: a repo root OUTSIDE the home
|
|
153
|
+
* directory combined with a worktree root INSIDE it (e.g. `/srv/repo` +
|
|
154
|
+
* `~/.so-worktrees`) shares no prefix, so the `../` chain descends back through
|
|
155
|
+
* `Users/<name>/`. That configuration is not reachable from this module's
|
|
156
|
+
* defaults and a heuristic scrub would be less predictable than the plain
|
|
157
|
+
* relative path.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} repoRoot absolute path to the primary repo
|
|
160
|
+
* @param {string|null|undefined} worktreePath absolute worktree path, if known
|
|
161
|
+
* @returns {string|null} repo-relative path, or null when either input is unusable
|
|
162
|
+
*/
|
|
163
|
+
export function relativeWorktreePath(repoRoot, worktreePath) {
|
|
164
|
+
if (typeof worktreePath !== 'string' || worktreePath.length === 0) return null;
|
|
165
|
+
// Without a usable base there is no way to strip the host prefix. Report
|
|
166
|
+
// "unknown" rather than fall back to the absolute path this exists to avoid.
|
|
167
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
|
|
168
|
+
return path.relative(repoRoot, worktreePath);
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
// ---------------------------------------------------------------------------
|
|
132
172
|
// setupWorktree
|
|
133
173
|
// ---------------------------------------------------------------------------
|
|
@@ -230,6 +270,9 @@ export async function setupWorktree(context, opts = {}) {
|
|
|
230
270
|
* - If `result.killSwitch` is `'stall-timeout'`: leave the worktree intact
|
|
231
271
|
* for retry. Only release the lock.
|
|
232
272
|
* - Otherwise: call `opts.gcOnExit` to clean up, then release the lock.
|
|
273
|
+
* - The lock-release outcome is evaluated and emitted as an
|
|
274
|
+
* `orchestrator.session.lock.released` / `…release_failed` breadcrumb
|
|
275
|
+
* (`caller: 'worktree-pipeline'`) into the PARENT repo's events stream (#952).
|
|
233
276
|
* - Errors during teardown are swallowed (logged to stderr). Never re-throws.
|
|
234
277
|
*
|
|
235
278
|
* @param {StoryContext} context
|
|
@@ -267,13 +310,69 @@ export async function teardownWorktree(context, result, opts = {}) {
|
|
|
267
310
|
|
|
268
311
|
// Always attempt to release the per-worktree lock.
|
|
269
312
|
if (result._lockSessionId) {
|
|
313
|
+
/** @type {{ ok: boolean, deleted?: boolean, reason?: string, verified?: boolean }|null} */
|
|
314
|
+
let releaseResult = null;
|
|
270
315
|
try {
|
|
271
|
-
|
|
316
|
+
// #987 defense-in-depth: pass the in-memory genesis proof (captured by
|
|
317
|
+
// runStoryPipeline right after acquire — same process, no file I/O)
|
|
318
|
+
// so the delete is double-gated at the fs layer. Spread-guarded:
|
|
319
|
+
// release()'s proof gate triggers on `proof !== undefined`, so a
|
|
320
|
+
// null/absent proof MUST be omitted entirely or EVERY release would
|
|
321
|
+
// be refused with 'proof-mismatch'.
|
|
322
|
+
releaseResult = release({
|
|
323
|
+
sessionId: result._lockSessionId,
|
|
324
|
+
repoRoot: result.worktreePath,
|
|
325
|
+
...(result._lockOwnerProof ? { proof: result._lockOwnerProof } : {}),
|
|
326
|
+
});
|
|
272
327
|
} catch (lockErr) {
|
|
273
328
|
console.error(
|
|
274
329
|
`worktree-pipeline: lock release error for issue #${context.issueIid}: ${lockErr?.message ?? String(lockErr)}`,
|
|
275
330
|
);
|
|
276
331
|
}
|
|
332
|
+
|
|
333
|
+
// #952 (A) — observability breadcrumb for the SECOND release() call-site.
|
|
334
|
+
// Before this, the call above was fire-and-forget: its structured result was
|
|
335
|
+
// discarded entirely, so a per-worktree lock that failed to release — or
|
|
336
|
+
// that had already vanished — left NO trace anywhere. That is the blind
|
|
337
|
+
// spot behind the "#914 lock disappears in the start chain" class for
|
|
338
|
+
// worktree-isolated runs.
|
|
339
|
+
//
|
|
340
|
+
// Emitted at the CALL-SITE, never inside release(): session-lock.mjs
|
|
341
|
+
// deliberately carries no dependency on events.mjs, and release() is
|
|
342
|
+
// synchronous while emitEvent() is async. `caller` keeps this path
|
|
343
|
+
// distinguishable from hooks/on-session-end.mjs in the single stream.
|
|
344
|
+
//
|
|
345
|
+
// Destination is PINNED to the parent repo (`repoRoot` via the #941
|
|
346
|
+
// `opts.repoRoot` interface — same pattern as scripts/lib/lock-reaper.mjs)
|
|
347
|
+
// rather than the ambient SO_PROJECT_DIR: this pipeline deliberately runs
|
|
348
|
+
// across worktrees with an explicit repoRoot (#219 CWD-drift), and the
|
|
349
|
+
// worktree itself may already have been removed by the gc step above, so
|
|
350
|
+
// its own `.orchestrator/` is not a valid destination.
|
|
351
|
+
const benignAlreadyGone = releaseResult?.ok === true && releaseResult.reason === 'no-lock';
|
|
352
|
+
const releaseFailed = releaseResult === null
|
|
353
|
+
|| (!benignAlreadyGone && (releaseResult.ok !== true || releaseResult.deleted !== true));
|
|
354
|
+
try {
|
|
355
|
+
if (releaseFailed) {
|
|
356
|
+
await emitEvent('orchestrator.session.lock.release_failed', {
|
|
357
|
+
session_id: result._lockSessionId,
|
|
358
|
+
reason: releaseResult === null
|
|
359
|
+
? 'threw'
|
|
360
|
+
: (releaseResult.reason ?? (releaseResult.ok === true ? 'not-deleted' : 'fs-error')),
|
|
361
|
+
caller: 'worktree-pipeline',
|
|
362
|
+
worktree_path: relativeWorktreePath(repoRoot, result.worktreePath),
|
|
363
|
+
issue_iid: context.issueIid,
|
|
364
|
+
}, { repoRoot });
|
|
365
|
+
} else {
|
|
366
|
+
await emitEvent('orchestrator.session.lock.released', {
|
|
367
|
+
session_id: result._lockSessionId,
|
|
368
|
+
caller: 'worktree-pipeline',
|
|
369
|
+
outcome: benignAlreadyGone ? 'already-gone' : 'deleted',
|
|
370
|
+
verified: releaseResult.verified === true,
|
|
371
|
+
worktree_path: relativeWorktreePath(repoRoot, result.worktreePath),
|
|
372
|
+
issue_iid: context.issueIid,
|
|
373
|
+
}, { repoRoot });
|
|
374
|
+
}
|
|
375
|
+
} catch { /* observability is best-effort — teardown must never throw */ }
|
|
277
376
|
}
|
|
278
377
|
}
|
|
279
378
|
|
|
@@ -393,6 +492,7 @@ export async function runStoryPipeline(context, opts = {}) {
|
|
|
393
492
|
kill_switch: 'failed-wave',
|
|
394
493
|
kill_switch_detail: err?.message ?? String(err),
|
|
395
494
|
iterations_completed: 0,
|
|
495
|
+
fallback_to_manual: false,
|
|
396
496
|
stall_recovery_count: 0,
|
|
397
497
|
};
|
|
398
498
|
}
|
|
@@ -409,11 +509,18 @@ export async function runStoryPipeline(context, opts = {}) {
|
|
|
409
509
|
killSwitch: loopState.kill_switch ?? null,
|
|
410
510
|
killSwitchDetail: loopState.kill_switch_detail ?? null,
|
|
411
511
|
iterationsCompleted: loopState.iterations_completed ?? 0,
|
|
512
|
+
fallbackToManual: loopState.fallback_to_manual === true,
|
|
412
513
|
spiralRecoveryCount: loopState.stall_recovery_count ?? 0,
|
|
413
514
|
commitDependency: null,
|
|
414
515
|
abortedByCohort: loopState.kill_switch === 'peer-abort',
|
|
415
516
|
// Internal: used by teardownWorktree to release the lock.
|
|
416
517
|
_lockSessionId: lockSessionId,
|
|
518
|
+
// Internal (#987): in-memory genesis proof for the proof-gated release in
|
|
519
|
+
// teardownWorktree. Same process as the acquire above, so no file I/O is
|
|
520
|
+
// needed — buildLockOwnerProof() returns null on a partial/absent lock
|
|
521
|
+
// (e.g. a DI test stub without a full lock body), and teardown's
|
|
522
|
+
// spread-guard then falls back to the session_id-only release.
|
|
523
|
+
_lockOwnerProof: buildLockOwnerProof(lockResult.lock),
|
|
417
524
|
};
|
|
418
525
|
|
|
419
526
|
await teardownWorktree(context, result, { gcOnExit });
|
|
@@ -423,11 +530,12 @@ export async function runStoryPipeline(context, opts = {}) {
|
|
|
423
530
|
throw loopError;
|
|
424
531
|
}
|
|
425
532
|
|
|
426
|
-
// Strip internal
|
|
427
|
-
const { _lockSessionId: _removed, ...publicResult } = result;
|
|
533
|
+
// Strip internal fields before returning to caller.
|
|
534
|
+
const { _lockSessionId: _removed, _lockOwnerProof: _removedProof, ...publicResult } = result;
|
|
428
535
|
|
|
429
|
-
// Suppress unused-variable lint warning —
|
|
536
|
+
// Suppress unused-variable lint warning — both are intentionally discarded.
|
|
430
537
|
void _removed;
|
|
538
|
+
void _removedProof;
|
|
431
539
|
|
|
432
540
|
return publicResult;
|
|
433
541
|
}
|