session-orchestrator 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* skill-evidence-window.mjs — the INPUT the skill-applied judge reasons over
|
|
4
|
+
* (issue #1399).
|
|
5
|
+
*
|
|
6
|
+
* `scripts/lib/skill-judge.mjs` has carried a `transcriptTail = ''` default
|
|
7
|
+
* since #645 and nothing in the tree ever produced that string. Measured
|
|
8
|
+
* 2026-09-19 at `8f15f77b`: `buildJudgePrompt(['<one skill>'], '', <32-hex
|
|
9
|
+
* nonce>)` is 1387 characters = 346 estimated tokens, comfortably under
|
|
10
|
+
* `DEFAULT_BUDGET.input` (8000) — so the budget gate PASSED the empty fence and
|
|
11
|
+
* the judge was dispatched to judge a transcript it had never seen. This module
|
|
12
|
+
* is the missing producer.
|
|
13
|
+
*
|
|
14
|
+
* Why not reuse `readTranscriptTail` (`hooks/_lib/subagent-transcript.mjs`):
|
|
15
|
+
* it concatenates the `text` blocks of the last N **assistant** records and
|
|
16
|
+
* drops `tool_use` entirely — which is exactly where a skill invocation lives
|
|
17
|
+
* (`{name:"Skill", input:{skill:"…"}}`). The last-N-records window also has no
|
|
18
|
+
* reason to contain the invocation at all: a `/session deep` fires at record 21
|
|
19
|
+
* of 4000.
|
|
20
|
+
*
|
|
21
|
+
* Shape of the answer: a WINDOW, not a tail. For each judged skill we locate
|
|
22
|
+
* its anchors (the invocation itself), then render bounded excerpts around the
|
|
23
|
+
* first anchor, the last anchor, and the end of the skill's attribution span,
|
|
24
|
+
* plus ONE shared excerpt of the session's closing records. Excerpts are joined
|
|
25
|
+
* with `\n[…]\n` so the judge can see that material was elided.
|
|
26
|
+
*
|
|
27
|
+
* Layering: pure cores first (`locateSkillAnchors`, `renderEvidence`), a thin
|
|
28
|
+
* IO layer after (`readTranscriptRecords`, `buildSkillEvidence`). The cores
|
|
29
|
+
* take records, never paths, so the tests need no filesystem; the IO layer
|
|
30
|
+
* takes `transcriptPath` / `projectsDir` as dependency-injection parameters.
|
|
31
|
+
*
|
|
32
|
+
* Substrate, measured 2026-09-19 over the 57 transcripts in
|
|
33
|
+
* `~/.claude/projects/<encoded-repo>/` (188 skill invocations in 46 files):
|
|
34
|
+
* (a) assistant `tool_use` `{name:"Skill", input:{skill:"<plugin>:<name>"}}`
|
|
35
|
+
* — 179 prefixed, 9 bare, hence the prefix-tolerant match below;
|
|
36
|
+
* (b) a user record carrying `<command-name>/<plugin>:<name></command-name>`;
|
|
37
|
+
* (c) the body arrives as the following `isMeta` record — WITH the header
|
|
38
|
+
* "Base directory for this skill:" for form (a) (169 of 188), and
|
|
39
|
+
* WITHOUT it for 91 of 101 slash-command bodies. Keying body detection on
|
|
40
|
+
* that header is what made the #1399 study builder drop them;
|
|
41
|
+
* (d) a failed call shows as `tool_result.is_error` on the anchor's id;
|
|
42
|
+
* (e) subagent invocations live in `<uuid>/subagents/agent-*.jsonl`;
|
|
43
|
+
* (f) assistant records carry an undocumented optional `attributionSkill`
|
|
44
|
+
* string (7843 occurrences) — used ONLY as an additional hint for where a
|
|
45
|
+
* skill's section ENDS, never as the anchor itself.
|
|
46
|
+
*
|
|
47
|
+
* Read-only by contract: nothing here writes a file.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs';
|
|
51
|
+
import { homedir } from 'node:os';
|
|
52
|
+
import { join } from 'node:path';
|
|
53
|
+
import { createInterface } from 'node:readline';
|
|
54
|
+
|
|
55
|
+
import { readLock, isLockLive } from './session-lock.mjs';
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Constants (every ceiling named, per .claude/rules/build-value.md BV-004)
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** Default total character budget for the rendered evidence text. */
|
|
62
|
+
export const DEFAULT_BUDGET_CHARS = 30_500;
|
|
63
|
+
|
|
64
|
+
/** Characters reserved ONCE for the shared session-closing excerpt. */
|
|
65
|
+
export const DEFAULT_CLOSING_CHARS = 3000;
|
|
66
|
+
|
|
67
|
+
/** A skill rendered with less than this is not worth rendering — report instead. */
|
|
68
|
+
export const DEFAULT_MIN_PER_SKILL_CHARS = 1500;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Share of the per-skill pool that SUBAGENT-ONLY skills may take when
|
|
72
|
+
* coordinator-anchored skills are also present (#1412).
|
|
73
|
+
*
|
|
74
|
+
* Why a cap at all: with `includeSubagents: true` the record count grows ~4.4x
|
|
75
|
+
* (measured 2026-09-20 over 3 sessions) and admits a second class of judged
|
|
76
|
+
* skill. Under ONE shared pool that class both shrinks every coordinator
|
|
77
|
+
* skill's excerpt and can push one out over the `minPerSkillChars` floor —
|
|
78
|
+
* purely by its position in the requested-skills array. The cap plus the
|
|
79
|
+
* coordinator-first ordering below bound that: coordinator skills keep at
|
|
80
|
+
* least 1 - this share of the pool, and whatever does not fit is the NEWLY
|
|
81
|
+
* admitted material, reported in `skipped` with `truncated: true`.
|
|
82
|
+
*
|
|
83
|
+
* Ceiling: 0.25 is a proportion, not a measurement — the whole pool is 27,500
|
|
84
|
+
* characters by default and the largest window measured to date is 6,021, so
|
|
85
|
+
* the cap binds only under a caller-shrunk budget or a large judged set.
|
|
86
|
+
* Revisit if a real session ever reports a subagent-only skill skipped as
|
|
87
|
+
* `budget-insufficient` while the coordinator sections sit far under budget.
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_SUBAGENT_POOL_SHARE = 0.25;
|
|
90
|
+
|
|
91
|
+
/** Cap for a single `tool_use` input / `tool_result` payload inside an excerpt. */
|
|
92
|
+
export const DEFAULT_TOOL_TEXT_MAX = 1500;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Per-block read-time cap. Skill bodies measured at median 39,667 and max
|
|
96
|
+
* 129,144 characters; a 24 MB transcript holds hundreds of them, so keeping
|
|
97
|
+
* them whole in memory buys nothing — the first heading, the only part we
|
|
98
|
+
* render, sits in the first few hundred characters. 4000 is 2.6x the
|
|
99
|
+
* `toolTextMax` cap above, so no rendered excerpt is ever clipped by THIS
|
|
100
|
+
* limit first. Revisit if an excerpt form ever needs more than 4000 characters
|
|
101
|
+
* of a single block.
|
|
102
|
+
*/
|
|
103
|
+
export const DEFAULT_MAX_BLOCK_CHARS = 4000;
|
|
104
|
+
|
|
105
|
+
/** Records rendered before / after an anchor record. */
|
|
106
|
+
const CONTEXT_BEFORE = 1;
|
|
107
|
+
const CONTEXT_AFTER = 3;
|
|
108
|
+
|
|
109
|
+
/** How far past an anchor to look for its `isMeta` body record. */
|
|
110
|
+
const BODY_LOOKAHEAD = 4;
|
|
111
|
+
|
|
112
|
+
/** Records in the shared session-closing excerpt. */
|
|
113
|
+
const CLOSING_RECORDS = 12;
|
|
114
|
+
|
|
115
|
+
/** Separator between two non-adjacent excerpts. */
|
|
116
|
+
const ELISION = '\n[…]\n';
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Pure helpers
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The bare skill name — everything after the last `:`. `session-orchestrator:plan`
|
|
124
|
+
* and a bare `plan` name the same skill in a transcript (9 of 188 measured
|
|
125
|
+
* invocations carry no plugin prefix), so both spellings must match.
|
|
126
|
+
*
|
|
127
|
+
* @param {unknown} name
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function bareSkillName(name) {
|
|
131
|
+
const s = typeof name === 'string' ? name.trim() : '';
|
|
132
|
+
const idx = s.lastIndexOf(':');
|
|
133
|
+
return idx === -1 ? s : s.slice(idx + 1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Prefix-tolerant skill match. Exact equality wins; otherwise the bare names
|
|
138
|
+
* are compared. Ceiling: two plugins exporting the same bare name would match
|
|
139
|
+
* each other. Accepted — the consequence is a slightly wider evidence window,
|
|
140
|
+
* never a missing anchor, and the alternative (exact-only) silently loses the
|
|
141
|
+
* 9-of-188 bare-call form.
|
|
142
|
+
*
|
|
143
|
+
* @param {unknown} candidate
|
|
144
|
+
* @param {unknown} skill
|
|
145
|
+
* @returns {boolean}
|
|
146
|
+
*/
|
|
147
|
+
export function skillMatches(candidate, skill) {
|
|
148
|
+
if (typeof candidate !== 'string' || typeof skill !== 'string') return false;
|
|
149
|
+
const a = candidate.trim();
|
|
150
|
+
const b = skill.trim();
|
|
151
|
+
if (!a || !b) return false;
|
|
152
|
+
if (a === b) return true;
|
|
153
|
+
return bareSkillName(a) === bareSkillName(b);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @param {unknown} rec @returns {Array<Record<string, unknown>>} */
|
|
157
|
+
function contentBlocks(rec) {
|
|
158
|
+
const content = rec?.message?.content;
|
|
159
|
+
if (Array.isArray(content)) return content;
|
|
160
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }];
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Concatenated plain text of a record's `text` blocks (thinking excluded). */
|
|
165
|
+
function recordText(rec) {
|
|
166
|
+
const parts = [];
|
|
167
|
+
for (const b of contentBlocks(rec)) {
|
|
168
|
+
if (b?.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
169
|
+
}
|
|
170
|
+
return parts.join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @param {string} s @param {number} max @returns {string} */
|
|
174
|
+
function clip(s, max) {
|
|
175
|
+
const str = typeof s === 'string' ? s : String(s ?? '');
|
|
176
|
+
if (str.length <= max) return str;
|
|
177
|
+
return `${str.slice(0, max)}…[+${str.length - max} chars]`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** First markdown heading of a body, else its first non-empty line. */
|
|
181
|
+
function firstHeading(text) {
|
|
182
|
+
const lines = String(text ?? '').split('\n');
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
if (/^#{1,6}\s+\S/.test(line)) return line.trim();
|
|
185
|
+
}
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
if (line.trim()) return clip(line.trim(), 120);
|
|
188
|
+
}
|
|
189
|
+
return '(empty body)';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Is this record a SUBAGENT record? `_subagent` is stamped by
|
|
194
|
+
* `readTranscriptRecords` for `<uuid>/subagents/agent-*.jsonl`; `isSidechain`
|
|
195
|
+
* is stamped by the harness itself. ONE definition, because two consumers read
|
|
196
|
+
* it: the anchor classifier below and the budget split in `renderEvidence`.
|
|
197
|
+
*
|
|
198
|
+
* @param {unknown} rec
|
|
199
|
+
* @returns {boolean}
|
|
200
|
+
*/
|
|
201
|
+
function isSubagentRecord(rec) {
|
|
202
|
+
return rec?._subagent === true || rec?.isSidechain === true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** `<command-name>/session-orchestrator:close</command-name>` → the name. */
|
|
206
|
+
function commandNames(text) {
|
|
207
|
+
const out = [];
|
|
208
|
+
const re = /<command-name>\s*\/?([^<\s]+)\s*<\/command-name>/g;
|
|
209
|
+
let m;
|
|
210
|
+
while ((m = re.exec(text)) !== null) out.push(m[1]);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Core 1 — locateSkillAnchors
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @typedef {Object} SkillAnchor
|
|
220
|
+
* @property {'skill-call'|'slash-command'|'subagent-skill-call'} kind
|
|
221
|
+
* @property {number} recordIndex — index into `records` of the invoking record
|
|
222
|
+
* @property {boolean} errored — the matching `tool_result` carried `is_error: true`
|
|
223
|
+
* @property {number|null} bodyRecordIndex — index of the following `isMeta` body record
|
|
224
|
+
*/
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @typedef {Object} SkillLocation
|
|
228
|
+
* @property {boolean} found
|
|
229
|
+
* @property {SkillAnchor[]} anchors
|
|
230
|
+
* @property {number} invocations
|
|
231
|
+
* @property {number|null} spanEndIndex — last record with `attributionSkill === skill`
|
|
232
|
+
* @property {number|null} spanEndIndexMain — same, but restricted to MAIN-transcript
|
|
233
|
+
* records (`!isSubagentRecord`). Identical to `spanEndIndex` whenever no subagent
|
|
234
|
+
* records are in `records` at all; diverges only under `includeSubagents: true`. #1421.
|
|
235
|
+
*/
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Locate every invocation anchor of every requested skill. PURE — takes
|
|
239
|
+
* records, returns indices; reads no file and renders no text.
|
|
240
|
+
*
|
|
241
|
+
* A record is treated as a subagent record when it carries `_subagent: true`
|
|
242
|
+
* (stamped by `readTranscriptRecords`) or `isSidechain: true`.
|
|
243
|
+
*
|
|
244
|
+
* @param {Array<Record<string, unknown>>} records
|
|
245
|
+
* @param {string[]} skills
|
|
246
|
+
* @returns {Record<string, SkillLocation>} keyed by the REQUESTED skill spelling
|
|
247
|
+
*/
|
|
248
|
+
export function locateSkillAnchors(records, skills) {
|
|
249
|
+
/** @type {Record<string, SkillLocation>} */
|
|
250
|
+
const out = Object.create(null);
|
|
251
|
+
const wanted = Array.isArray(skills)
|
|
252
|
+
? skills.filter((s) => typeof s === 'string' && s.trim())
|
|
253
|
+
: [];
|
|
254
|
+
for (const skill of wanted) {
|
|
255
|
+
out[skill] = {
|
|
256
|
+
found: false,
|
|
257
|
+
anchors: [],
|
|
258
|
+
invocations: 0,
|
|
259
|
+
spanEndIndex: null,
|
|
260
|
+
spanEndIndexMain: null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (!Array.isArray(records) || wanted.length === 0) return out;
|
|
264
|
+
|
|
265
|
+
/** tool_use id → the anchor object awaiting its tool_result verdict. */
|
|
266
|
+
const pendingByToolUseId = new Map();
|
|
267
|
+
|
|
268
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
269
|
+
const rec = records[i];
|
|
270
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
271
|
+
const isSubagent = isSubagentRecord(rec);
|
|
272
|
+
|
|
273
|
+
for (const block of contentBlocks(rec)) {
|
|
274
|
+
if (block?.type === 'tool_use' && block.name === 'Skill') {
|
|
275
|
+
const called = block?.input?.skill;
|
|
276
|
+
for (const skill of wanted) {
|
|
277
|
+
if (!skillMatches(called, skill)) continue;
|
|
278
|
+
/** @type {SkillAnchor} */
|
|
279
|
+
const anchor = {
|
|
280
|
+
kind: isSubagent ? 'subagent-skill-call' : 'skill-call',
|
|
281
|
+
recordIndex: i,
|
|
282
|
+
errored: false,
|
|
283
|
+
bodyRecordIndex: null,
|
|
284
|
+
};
|
|
285
|
+
out[skill].anchors.push(anchor);
|
|
286
|
+
if (typeof block.id === 'string') {
|
|
287
|
+
const list = pendingByToolUseId.get(block.id) ?? [];
|
|
288
|
+
list.push(anchor);
|
|
289
|
+
pendingByToolUseId.set(block.id, list);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} else if (block?.type === 'tool_result' && block.is_error === true) {
|
|
293
|
+
const list = pendingByToolUseId.get(block.tool_use_id);
|
|
294
|
+
if (list) for (const a of list) a.errored = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Slash-command form. The `<command-name>` marker lives in a plain-text
|
|
299
|
+
// user record; the body follows as the next `isMeta` record, usually with
|
|
300
|
+
// NO "Base directory for this skill:" header.
|
|
301
|
+
const text = recordText(rec);
|
|
302
|
+
if (text.includes('<command-name>')) {
|
|
303
|
+
for (const name of commandNames(text)) {
|
|
304
|
+
for (const skill of wanted) {
|
|
305
|
+
if (!skillMatches(name, skill)) continue;
|
|
306
|
+
out[skill].anchors.push({
|
|
307
|
+
kind: 'slash-command',
|
|
308
|
+
recordIndex: i,
|
|
309
|
+
errored: false,
|
|
310
|
+
bodyRecordIndex: null,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// (f) attributionSkill — the END of the skill's section, never the anchor.
|
|
317
|
+
// Tracked TWICE: over every record, and over MAIN-transcript records only.
|
|
318
|
+
// Under `includeSubagents: true` the subagent files are concatenated after
|
|
319
|
+
// the main ones, so a coordinator-dispatched skill whose work continues
|
|
320
|
+
// inside a subagent would otherwise take its span end from that subagent
|
|
321
|
+
// file (#1421: `wave-executor` in session 58f82af0 moved 357 → 7449).
|
|
322
|
+
if (typeof rec.attributionSkill === 'string') {
|
|
323
|
+
for (const skill of wanted) {
|
|
324
|
+
if (!skillMatches(rec.attributionSkill, skill)) continue;
|
|
325
|
+
out[skill].spanEndIndex = i;
|
|
326
|
+
if (!isSubagent) out[skill].spanEndIndexMain = i;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Body record per anchor: the next `isMeta` record carrying text, header or not.
|
|
332
|
+
for (const skill of wanted) {
|
|
333
|
+
const loc = out[skill];
|
|
334
|
+
for (const anchor of loc.anchors) {
|
|
335
|
+
for (let k = anchor.recordIndex + 1; k <= anchor.recordIndex + BODY_LOOKAHEAD; k += 1) {
|
|
336
|
+
const rec = records[k];
|
|
337
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
338
|
+
if (rec.isMeta !== true) continue;
|
|
339
|
+
if (!recordText(rec).trim()) continue;
|
|
340
|
+
anchor.bodyRecordIndex = k;
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
loc.invocations = loc.anchors.length;
|
|
345
|
+
loc.found = loc.anchors.length > 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Core 2 — renderEvidence
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Render ONE record as a single evidence line. Thinking blocks are dropped
|
|
357
|
+
* (they are the largest and the least evidential part of a transcript);
|
|
358
|
+
* `tool_use` / `tool_result` payloads are clipped at `toolTextMax`; a skill
|
|
359
|
+
* body collapses to `[skill body: <name>] <first heading>`.
|
|
360
|
+
*
|
|
361
|
+
* @param {Record<string, unknown>} rec
|
|
362
|
+
* @param {number} index
|
|
363
|
+
* @param {{toolTextMax: number, bodyOf?: string|null}} opts
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
function renderRecord(rec, index, { toolTextMax, bodyOf = null }) {
|
|
367
|
+
const role = typeof rec?.type === 'string' ? rec.type : 'unknown';
|
|
368
|
+
if (bodyOf) {
|
|
369
|
+
return `#${index} ${role} [skill body: ${bodyOf}] ${firstHeading(recordText(rec))}`;
|
|
370
|
+
}
|
|
371
|
+
const parts = [];
|
|
372
|
+
for (const block of contentBlocks(rec)) {
|
|
373
|
+
const t = block?.type;
|
|
374
|
+
if (t === 'thinking' || t === 'redacted_thinking') continue;
|
|
375
|
+
if (t === 'text' && typeof block.text === 'string') {
|
|
376
|
+
if (block.text.trim()) parts.push(clip(block.text.trim(), toolTextMax));
|
|
377
|
+
} else if (t === 'tool_use') {
|
|
378
|
+
let input;
|
|
379
|
+
try {
|
|
380
|
+
input = JSON.stringify(block.input ?? {});
|
|
381
|
+
} catch {
|
|
382
|
+
input = '(uninspectable input)';
|
|
383
|
+
}
|
|
384
|
+
parts.push(`[tool_use ${String(block.name ?? '?')}] ${clip(input, toolTextMax)}`);
|
|
385
|
+
} else if (t === 'tool_result') {
|
|
386
|
+
const payload =
|
|
387
|
+
typeof block.content === 'string'
|
|
388
|
+
? block.content
|
|
389
|
+
: Array.isArray(block.content)
|
|
390
|
+
? block.content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n')
|
|
391
|
+
: '';
|
|
392
|
+
const flag = block.is_error === true ? ' is_error=true' : '';
|
|
393
|
+
parts.push(`[tool_result${flag}] ${clip(payload, toolTextMax)}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (parts.length === 0) return '';
|
|
397
|
+
return `#${index} ${role} ${parts.join(' | ')}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Merge overlapping/adjacent `{start,end}` windows. */
|
|
401
|
+
function mergeWindows(windows) {
|
|
402
|
+
const sorted = windows
|
|
403
|
+
.filter((w) => w && w.end >= w.start)
|
|
404
|
+
.sort((a, b) => a.start - b.start);
|
|
405
|
+
const merged = [];
|
|
406
|
+
for (const w of sorted) {
|
|
407
|
+
const last = merged[merged.length - 1];
|
|
408
|
+
if (last && w.start <= last.end + 1) last.end = Math.max(last.end, w.end);
|
|
409
|
+
else merged.push({ ...w });
|
|
410
|
+
}
|
|
411
|
+
return merged;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Render ONE window backwards from its last record, so the budget is spent on
|
|
416
|
+
* the records CLOSEST to the end. Used for the shared closing excerpt, whose
|
|
417
|
+
* whole job is to show how the session finished: a forward fill spends the
|
|
418
|
+
* budget on whatever tool spam happens to sit at the window's start and never
|
|
419
|
+
* reaches the last record at all (observed with 12 clipped 1500-character
|
|
420
|
+
* `tool_use` lines against a 3000-character closing budget).
|
|
421
|
+
*/
|
|
422
|
+
function renderWindowFromEnd(records, window, { toolTextMax, budget, bodyIndexes }) {
|
|
423
|
+
const lines = [];
|
|
424
|
+
let used = 0;
|
|
425
|
+
let truncated = false;
|
|
426
|
+
const start = Math.max(0, window.start);
|
|
427
|
+
const end = Math.min(records.length - 1, window.end);
|
|
428
|
+
for (let i = end; i >= start; i -= 1) {
|
|
429
|
+
const line = renderRecord(records[i], i, { toolTextMax, bodyOf: bodyIndexes.get(i) ?? null });
|
|
430
|
+
if (!line) continue;
|
|
431
|
+
if (used + line.length + 1 > budget) {
|
|
432
|
+
truncated = true;
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
lines.unshift(line);
|
|
436
|
+
used += line.length + 1;
|
|
437
|
+
}
|
|
438
|
+
return { text: lines.join('\n'), truncated };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Render merged windows into elision-joined text, stopping at `budget`. */
|
|
442
|
+
function renderWindows(records, windows, { toolTextMax, budget, bodyIndexes }) {
|
|
443
|
+
const chunks = [];
|
|
444
|
+
let used = 0;
|
|
445
|
+
let truncated = false;
|
|
446
|
+
for (const w of windows) {
|
|
447
|
+
const lines = [];
|
|
448
|
+
for (let i = Math.max(0, w.start); i <= Math.min(records.length - 1, w.end); i += 1) {
|
|
449
|
+
const line = renderRecord(records[i], i, {
|
|
450
|
+
toolTextMax,
|
|
451
|
+
bodyOf: bodyIndexes.get(i) ?? null,
|
|
452
|
+
});
|
|
453
|
+
if (!line) continue;
|
|
454
|
+
if (used + line.length + 1 > budget) {
|
|
455
|
+
truncated = true;
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
lines.push(line);
|
|
459
|
+
used += line.length + 1;
|
|
460
|
+
}
|
|
461
|
+
if (lines.length > 0) chunks.push(lines.join('\n'));
|
|
462
|
+
if (truncated) break;
|
|
463
|
+
}
|
|
464
|
+
return { text: chunks.join(ELISION), truncated };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* @typedef {Object} EvidenceRender
|
|
469
|
+
* @property {string} text
|
|
470
|
+
* @property {number} chars
|
|
471
|
+
* @property {boolean} truncated
|
|
472
|
+
* @property {Array<{skill: string, found: boolean, invocations: number, chars: number, anchorIndexes: number[]}>} perSkill
|
|
473
|
+
* @property {Array<{skill: string, reason: string}>} skipped
|
|
474
|
+
*/
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Render the evidence text from the located anchors. PURE — no filesystem.
|
|
478
|
+
*
|
|
479
|
+
* Budget split: the shared closing excerpt is paid ONCE, the rest is divided
|
|
480
|
+
* evenly across the skills that were actually found. A skill that cannot get
|
|
481
|
+
* `minPerSkillChars` is NOT silently squeezed — it is reported in `skipped`
|
|
482
|
+
* with reason `budget-insufficient` and `truncated` is set, so the caller can
|
|
483
|
+
* see that the judge is reasoning over a partial set.
|
|
484
|
+
*
|
|
485
|
+
* SOURCE SPLIT (#1412): once subagent transcripts are read in, the found set
|
|
486
|
+
* carries two classes. Skills with at least one COORDINATOR anchor are
|
|
487
|
+
* allotted first and from their own pool; SUBAGENT-ONLY skills share at most
|
|
488
|
+
* `DEFAULT_SUBAGENT_POOL_SHARE` of the pool — unless there are no coordinator
|
|
489
|
+
* skills at all, in which case they get all of it (the whole point of #1412 is
|
|
490
|
+
* that a subagent-only skill must be judgeable). With no subagent-only skill
|
|
491
|
+
* present the split is a no-op and the numbers are identical to before.
|
|
492
|
+
*
|
|
493
|
+
* `mainRecordCount` bounds the shared closing excerpt to the MAIN transcript.
|
|
494
|
+
* Subagent records are CONCATENATED after it, so without the bound the section
|
|
495
|
+
* headed "session closing" renders the tail of the last subagent file instead
|
|
496
|
+
* — mislabelled evidence, which is worse for a judge than none.
|
|
497
|
+
*
|
|
498
|
+
* @param {Array<Record<string, unknown>>} records
|
|
499
|
+
* @param {Record<string, SkillLocation>} located
|
|
500
|
+
* @param {{budgetChars?: number, closingChars?: number, minPerSkillChars?: number, toolTextMax?: number, subagentPoolShare?: number, mainRecordCount?: number}} [opts]
|
|
501
|
+
* @returns {EvidenceRender}
|
|
502
|
+
*/
|
|
503
|
+
export function renderEvidence(records, located, opts = {}) {
|
|
504
|
+
const budgetChars = Number.isFinite(opts.budgetChars) ? opts.budgetChars : DEFAULT_BUDGET_CHARS;
|
|
505
|
+
const closingChars = Number.isFinite(opts.closingChars)
|
|
506
|
+
? opts.closingChars
|
|
507
|
+
: DEFAULT_CLOSING_CHARS;
|
|
508
|
+
const minPerSkillChars = Number.isFinite(opts.minPerSkillChars)
|
|
509
|
+
? opts.minPerSkillChars
|
|
510
|
+
: DEFAULT_MIN_PER_SKILL_CHARS;
|
|
511
|
+
const toolTextMax = Number.isFinite(opts.toolTextMax) ? opts.toolTextMax : DEFAULT_TOOL_TEXT_MAX;
|
|
512
|
+
const subagentPoolShare = Number.isFinite(opts.subagentPoolShare)
|
|
513
|
+
? Math.min(1, Math.max(0, opts.subagentPoolShare))
|
|
514
|
+
: DEFAULT_SUBAGENT_POOL_SHARE;
|
|
515
|
+
|
|
516
|
+
const recs = Array.isArray(records) ? records : [];
|
|
517
|
+
const mainRecordCount = Number.isFinite(opts.mainRecordCount)
|
|
518
|
+
? Math.min(recs.length, Math.max(0, opts.mainRecordCount))
|
|
519
|
+
: recs.length;
|
|
520
|
+
const loc = located && typeof located === 'object' ? located : {};
|
|
521
|
+
const skills = Object.keys(loc);
|
|
522
|
+
|
|
523
|
+
/** @type {Array<{skill: string, reason: string}>} */
|
|
524
|
+
const skipped = [];
|
|
525
|
+
const foundSkills = [];
|
|
526
|
+
for (const skill of skills) {
|
|
527
|
+
if (loc[skill]?.found) foundSkills.push(skill);
|
|
528
|
+
else skipped.push({ skill, reason: 'not-found' });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Body-record index → the skill whose body it is (for the collapsed render).
|
|
532
|
+
const bodyIndexes = new Map();
|
|
533
|
+
for (const skill of foundSkills) {
|
|
534
|
+
for (const a of loc[skill].anchors) {
|
|
535
|
+
if (typeof a.bodyRecordIndex === 'number') bodyIndexes.set(a.bodyRecordIndex, skill);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const closingBudget = recs.length > 0 ? Math.max(0, Math.min(closingChars, budgetChars)) : 0;
|
|
540
|
+
const perSkillPool = Math.max(0, budgetChars - closingBudget);
|
|
541
|
+
|
|
542
|
+
// Source split — coordinator-anchored skills first, subagent-only after.
|
|
543
|
+
const coordinatorSkills = [];
|
|
544
|
+
const subagentOnlySkills = [];
|
|
545
|
+
for (const skill of foundSkills) {
|
|
546
|
+
const anchors = loc[skill].anchors;
|
|
547
|
+
if (anchors.some((a) => !isSubagentRecord(recs[a.recordIndex]))) coordinatorSkills.push(skill);
|
|
548
|
+
else subagentOnlySkills.push(skill);
|
|
549
|
+
}
|
|
550
|
+
const coordinatorAnchored = new Set(coordinatorSkills);
|
|
551
|
+
let subagentPool = 0;
|
|
552
|
+
if (subagentOnlySkills.length > 0) {
|
|
553
|
+
subagentPool =
|
|
554
|
+
coordinatorSkills.length === 0
|
|
555
|
+
? perSkillPool
|
|
556
|
+
: Math.floor(perSkillPool * subagentPoolShare);
|
|
557
|
+
}
|
|
558
|
+
const coordinatorPool = perSkillPool - subagentPool;
|
|
559
|
+
|
|
560
|
+
let truncated = false;
|
|
561
|
+
/** Allot `pool` over `list`; overflow past the floor is reported, never squeezed. */
|
|
562
|
+
const allot = (list, pool) => {
|
|
563
|
+
const capacity = minPerSkillChars > 0 ? Math.floor(pool / minPerSkillChars) : list.length;
|
|
564
|
+
const take = Math.max(0, capacity);
|
|
565
|
+
for (const skill of list.slice(take)) {
|
|
566
|
+
skipped.push({ skill, reason: 'budget-insufficient' });
|
|
567
|
+
truncated = true;
|
|
568
|
+
}
|
|
569
|
+
const kept = list.slice(0, take);
|
|
570
|
+
const budget = kept.length > 0 ? Math.floor(pool / kept.length) : 0;
|
|
571
|
+
return kept.map((skill) => ({ skill, budget }));
|
|
572
|
+
};
|
|
573
|
+
const rendered = [
|
|
574
|
+
...allot(coordinatorSkills, coordinatorPool),
|
|
575
|
+
...allot(subagentOnlySkills, subagentPool),
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
const sections = [];
|
|
579
|
+
/** @type {EvidenceRender['perSkill']} */
|
|
580
|
+
const perSkill = [];
|
|
581
|
+
|
|
582
|
+
for (const { skill, budget: perSkillBudget } of rendered) {
|
|
583
|
+
const info = loc[skill];
|
|
584
|
+
const anchors = info.anchors;
|
|
585
|
+
const picks = [anchors[0], anchors[anchors.length - 1]].filter(Boolean);
|
|
586
|
+
const windows = picks.map((a) => ({
|
|
587
|
+
start: a.recordIndex - CONTEXT_BEFORE,
|
|
588
|
+
end: Math.max(a.recordIndex + CONTEXT_AFTER, a.bodyRecordIndex ?? -1),
|
|
589
|
+
}));
|
|
590
|
+
// Span end (#1421). A COORDINATOR-anchored skill takes its span end from the
|
|
591
|
+
// MAIN transcript only: the skill was dispatched by the coordinator, so its
|
|
592
|
+
// window must not end inside a subagent file that merely carries the same
|
|
593
|
+
// `attributionSkill`. A subagent-only skill has no main-transcript span end
|
|
594
|
+
// by construction and keeps the unrestricted one. Both fields are equal
|
|
595
|
+
// whenever the caller passed no subagent records (the library default
|
|
596
|
+
// `includeSubagents: false`), so this is a no-op for every other caller.
|
|
597
|
+
const spanEnd = coordinatorAnchored.has(skill) ? info.spanEndIndexMain : info.spanEndIndex;
|
|
598
|
+
if (typeof spanEnd === 'number') {
|
|
599
|
+
windows.push({ start: spanEnd - CONTEXT_BEFORE, end: spanEnd });
|
|
600
|
+
}
|
|
601
|
+
const body = renderWindows(recs, mergeWindows(windows), {
|
|
602
|
+
toolTextMax,
|
|
603
|
+
budget: perSkillBudget,
|
|
604
|
+
bodyIndexes,
|
|
605
|
+
});
|
|
606
|
+
if (body.truncated) truncated = true;
|
|
607
|
+
const errored = anchors.some((a) => a.errored);
|
|
608
|
+
const header =
|
|
609
|
+
`### ${skill} — ${info.invocations} invocation(s), kinds: ` +
|
|
610
|
+
`${[...new Set(anchors.map((a) => a.kind))].join(', ')}` +
|
|
611
|
+
`${errored ? ', at least one call errored' : ''}`;
|
|
612
|
+
sections.push(`${header}\n${body.text}`);
|
|
613
|
+
perSkill.push({
|
|
614
|
+
skill,
|
|
615
|
+
found: true,
|
|
616
|
+
invocations: info.invocations,
|
|
617
|
+
chars: body.text.length,
|
|
618
|
+
anchorIndexes: anchors.map((a) => a.recordIndex),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
for (const entry of skipped) {
|
|
623
|
+
perSkill.push({
|
|
624
|
+
skill: entry.skill,
|
|
625
|
+
found: loc[entry.skill]?.found === true,
|
|
626
|
+
invocations: loc[entry.skill]?.invocations ?? 0,
|
|
627
|
+
chars: 0,
|
|
628
|
+
anchorIndexes: (loc[entry.skill]?.anchors ?? []).map((a) => a.recordIndex),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// The shared closing is EVIDENCE FOR a judged skill ("did its work complete?"),
|
|
633
|
+
// never evidence on its own. With no skill rendered there is nothing to attach
|
|
634
|
+
// it to, and emitting it alone would hand the judge a non-empty fence that
|
|
635
|
+
// contains no invocation — the #1399 defect in a new costume.
|
|
636
|
+
if (closingBudget > 0 && rendered.length > 0 && mainRecordCount > 0) {
|
|
637
|
+
const closing = renderWindowFromEnd(
|
|
638
|
+
recs,
|
|
639
|
+
{ start: Math.max(0, mainRecordCount - CLOSING_RECORDS), end: mainRecordCount - 1 },
|
|
640
|
+
{ toolTextMax, budget: closingBudget, bodyIndexes },
|
|
641
|
+
);
|
|
642
|
+
if (closing.truncated) truncated = true;
|
|
643
|
+
if (closing.text) sections.push(`### session closing\n${closing.text}`);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const text = sections.length > 0 ? `## Skill evidence (excerpts)\n\n${sections.join('\n\n')}` : '';
|
|
647
|
+
return { text, chars: text.length, truncated, perSkill, skipped };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// IO layer
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Encode a repo path the way Claude Code names its projects directory: every
|
|
656
|
+
* `/` and `.` becomes `-`. Same encoding as
|
|
657
|
+
* `scripts/lib/wave-transcript-tail.mjs::encodeProjectDir`, duplicated here
|
|
658
|
+
* rather than imported so this module stays free of that tailer's `events.mjs`
|
|
659
|
+
* / file-lock / subagent-paths closure.
|
|
660
|
+
*
|
|
661
|
+
* @param {string} repoRoot
|
|
662
|
+
* @returns {string}
|
|
663
|
+
*/
|
|
664
|
+
export function encodeProjectDir(repoRoot) {
|
|
665
|
+
return String(repoRoot).replace(/[/.]/g, '-');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Resolve the RAW harness session id (the UUID that names the transcript file).
|
|
670
|
+
*
|
|
671
|
+
* Deliberately WITHOUT the newest-by-mtime tier that
|
|
672
|
+
* `wave-transcript-tail.mjs::resolveSessionId` carries: in a shared working
|
|
673
|
+
* copy the newest transcript directory can belong to a PEER session, and
|
|
674
|
+
* judging this session's skills against a peer's transcript is worse than
|
|
675
|
+
* judging nothing (`.claude/rules/identity-and-locks.md` — rank witnesses,
|
|
676
|
+
* never fall back onto a peer's artefact). No id resolves → the caller reports
|
|
677
|
+
* `no-transcript`.
|
|
678
|
+
*
|
|
679
|
+
* @param {{repoRoot?: string, env?: Record<string, string|undefined>}} [opts]
|
|
680
|
+
* @returns {{sessionId: string, source: 'env'|'session.lock'} | null}
|
|
681
|
+
*/
|
|
682
|
+
export function resolveRawSessionId({ repoRoot, env = process.env } = {}) {
|
|
683
|
+
const fromEnv = typeof env.CLAUDE_CODE_SESSION_ID === 'string'
|
|
684
|
+
? env.CLAUDE_CODE_SESSION_ID.trim()
|
|
685
|
+
: '';
|
|
686
|
+
if (fromEnv) return { sessionId: fromEnv, source: 'env' };
|
|
687
|
+
if (!repoRoot) return null;
|
|
688
|
+
let lock;
|
|
689
|
+
try {
|
|
690
|
+
lock = readLock({ repoRoot });
|
|
691
|
+
} catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
if (!lock || !isLockLive(lock)) return null;
|
|
695
|
+
const fromLock = typeof lock.session_id === 'string' ? lock.session_id.trim() : '';
|
|
696
|
+
return fromLock ? { sessionId: fromLock, source: 'session.lock' } : null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Stream-read a transcript JSONL.
|
|
701
|
+
*
|
|
702
|
+
* `malformed_lines` is part of the RETURN VALUE, not a silent skip: a
|
|
703
|
+
* half-written line turns a partial result into a clean-looking verdict, which
|
|
704
|
+
* is precisely the failure the judge exists to notice
|
|
705
|
+
* (`.claude/rules/measurement-discipline.md` — "Ein still ueberspringender
|
|
706
|
+
* JSONL-Parser macht aus einem Teilergebnis ein sauberes Verdikt").
|
|
707
|
+
*
|
|
708
|
+
* Text blocks are clipped at `maxBlockChars` AT READ TIME — see
|
|
709
|
+
* `DEFAULT_MAX_BLOCK_CHARS` for the ceiling and its revisit trigger.
|
|
710
|
+
*
|
|
711
|
+
* @param {string} path
|
|
712
|
+
* @param {{maxBlockChars?: number, subagent?: boolean}} [opts]
|
|
713
|
+
* @returns {Promise<{records: Array<Record<string, unknown>>, malformed_lines: number, bytes: number}>}
|
|
714
|
+
*/
|
|
715
|
+
export async function readTranscriptRecords(path, opts = {}) {
|
|
716
|
+
const maxBlockChars = Number.isFinite(opts.maxBlockChars)
|
|
717
|
+
? opts.maxBlockChars
|
|
718
|
+
: DEFAULT_MAX_BLOCK_CHARS;
|
|
719
|
+
const records = [];
|
|
720
|
+
let malformedLines = 0;
|
|
721
|
+
let bytes;
|
|
722
|
+
if (typeof path !== 'string' || !path || !existsSync(path)) {
|
|
723
|
+
return { records, malformed_lines: 0, bytes: 0 };
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
bytes = statSync(path).size;
|
|
727
|
+
} catch {
|
|
728
|
+
bytes = 0;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const rl = createInterface({
|
|
732
|
+
input: createReadStream(path, { encoding: 'utf8' }),
|
|
733
|
+
crlfDelay: Infinity,
|
|
734
|
+
});
|
|
735
|
+
for await (const line of rl) {
|
|
736
|
+
const trimmed = line.trim();
|
|
737
|
+
if (!trimmed) continue;
|
|
738
|
+
let rec;
|
|
739
|
+
try {
|
|
740
|
+
rec = JSON.parse(trimmed);
|
|
741
|
+
} catch {
|
|
742
|
+
malformedLines += 1;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) {
|
|
746
|
+
malformedLines += 1;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const content = rec?.message?.content;
|
|
750
|
+
if (Array.isArray(content)) {
|
|
751
|
+
for (const block of content) {
|
|
752
|
+
if (block && typeof block.text === 'string' && block.text.length > maxBlockChars) {
|
|
753
|
+
block.text = clip(block.text, maxBlockChars);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (opts.subagent === true) rec._subagent = true;
|
|
758
|
+
records.push(rec);
|
|
759
|
+
}
|
|
760
|
+
return { records, malformed_lines: malformedLines, bytes };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* @typedef {Object} SkillEvidence
|
|
765
|
+
* @property {'ok'|'no-transcript'|'no-evidence'} status
|
|
766
|
+
* @property {{path: string|null, bytes: number, records: number, malformed_lines: number}} source
|
|
767
|
+
* @property {EvidenceRender['perSkill']} perSkill
|
|
768
|
+
* @property {Array<{skill: string, reason: string}>} skipped
|
|
769
|
+
* @property {string} text
|
|
770
|
+
* @property {number} chars
|
|
771
|
+
* @property {boolean} truncated
|
|
772
|
+
*/
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Build the judge's evidence window for `skills` from this session's transcript.
|
|
776
|
+
*
|
|
777
|
+
* `transcriptPath` and `projectsDir` are dependency-injection parameters —
|
|
778
|
+
* pass them in tests; in production omit both and the path resolves to
|
|
779
|
+
* `~/.claude/projects/<encodeProjectDir(repoRoot)>/<sessionId>.jsonl`.
|
|
780
|
+
*
|
|
781
|
+
* NEVER throws: every failure degrades to a status the caller can log.
|
|
782
|
+
*
|
|
783
|
+
* @param {object} opts
|
|
784
|
+
* @param {string} [opts.repoRoot]
|
|
785
|
+
* @param {string} [opts.sessionId] — the RAW harness UUID, not the semantic id
|
|
786
|
+
* @param {string[]} [opts.skills]
|
|
787
|
+
* @param {number} [opts.budgetChars]
|
|
788
|
+
* @param {string} [opts.transcriptPath] — DI: read exactly this file
|
|
789
|
+
* @param {string} [opts.projectsDir] — DI: `<projects>/<encoded-repo>` directory
|
|
790
|
+
* @param {boolean} [opts.includeSubagents=false]
|
|
791
|
+
* @param {Record<string, string|undefined>} [opts.env]
|
|
792
|
+
* @returns {Promise<SkillEvidence>}
|
|
793
|
+
*/
|
|
794
|
+
export async function buildSkillEvidence({
|
|
795
|
+
repoRoot,
|
|
796
|
+
sessionId,
|
|
797
|
+
skills = [],
|
|
798
|
+
budgetChars = DEFAULT_BUDGET_CHARS,
|
|
799
|
+
transcriptPath,
|
|
800
|
+
projectsDir,
|
|
801
|
+
includeSubagents = false,
|
|
802
|
+
env = process.env,
|
|
803
|
+
} = {}) {
|
|
804
|
+
const empty = (status, path = null, extra = {}) => ({
|
|
805
|
+
status,
|
|
806
|
+
source: { path, bytes: 0, records: 0, malformed_lines: 0, ...extra },
|
|
807
|
+
perSkill: [],
|
|
808
|
+
skipped: (Array.isArray(skills) ? skills : [])
|
|
809
|
+
.filter((s) => typeof s === 'string' && s.trim())
|
|
810
|
+
.map((skill) => ({ skill, reason: status })),
|
|
811
|
+
text: '',
|
|
812
|
+
chars: 0,
|
|
813
|
+
truncated: false,
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
const wanted = Array.isArray(skills) ? skills.filter((s) => typeof s === 'string' && s.trim()) : [];
|
|
817
|
+
if (wanted.length === 0) return empty('no-evidence');
|
|
818
|
+
|
|
819
|
+
const rawId =
|
|
820
|
+
typeof sessionId === 'string' && sessionId.trim()
|
|
821
|
+
? sessionId.trim()
|
|
822
|
+
: resolveRawSessionId({ repoRoot, env })?.sessionId ?? '';
|
|
823
|
+
|
|
824
|
+
const baseDir =
|
|
825
|
+
projectsDir ||
|
|
826
|
+
(repoRoot ? join(homedir(), '.claude', 'projects', encodeProjectDir(repoRoot)) : '');
|
|
827
|
+
const path = transcriptPath || (rawId && baseDir ? join(baseDir, `${rawId}.jsonl`) : '');
|
|
828
|
+
if (!path || !existsSync(path)) return empty('no-transcript', path || null);
|
|
829
|
+
|
|
830
|
+
let main;
|
|
831
|
+
try {
|
|
832
|
+
main = await readTranscriptRecords(path);
|
|
833
|
+
} catch {
|
|
834
|
+
return empty('no-transcript', path);
|
|
835
|
+
}
|
|
836
|
+
const records = main.records;
|
|
837
|
+
// Captured BEFORE the subagent records are concatenated — it is the boundary
|
|
838
|
+
// `renderEvidence` needs to keep the "session closing" excerpt on the MAIN
|
|
839
|
+
// transcript instead of on the last subagent file.
|
|
840
|
+
const mainRecordCount = records.length;
|
|
841
|
+
let malformed = main.malformed_lines;
|
|
842
|
+
let bytes = main.bytes;
|
|
843
|
+
|
|
844
|
+
if (includeSubagents && rawId && baseDir) {
|
|
845
|
+
const subDir = join(baseDir, rawId, 'subagents');
|
|
846
|
+
let entries;
|
|
847
|
+
try {
|
|
848
|
+
entries = existsSync(subDir)
|
|
849
|
+
? readdirSync(subDir).filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl'))
|
|
850
|
+
: [];
|
|
851
|
+
} catch {
|
|
852
|
+
entries = [];
|
|
853
|
+
}
|
|
854
|
+
for (const entry of entries.sort()) {
|
|
855
|
+
try {
|
|
856
|
+
const sub = await readTranscriptRecords(join(subDir, entry), { subagent: true });
|
|
857
|
+
records.push(...sub.records);
|
|
858
|
+
malformed += sub.malformed_lines;
|
|
859
|
+
bytes += sub.bytes;
|
|
860
|
+
} catch {
|
|
861
|
+
// An unreadable subagent file removes evidence, never correctness.
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const source = { path, bytes, records: records.length, malformed_lines: malformed };
|
|
867
|
+
const located = locateSkillAnchors(records, wanted);
|
|
868
|
+
const render = renderEvidence(records, located, { budgetChars, mainRecordCount });
|
|
869
|
+
|
|
870
|
+
if (!render.text) {
|
|
871
|
+
return {
|
|
872
|
+
status: 'no-evidence',
|
|
873
|
+
source,
|
|
874
|
+
perSkill: render.perSkill,
|
|
875
|
+
skipped: render.skipped,
|
|
876
|
+
text: '',
|
|
877
|
+
chars: 0,
|
|
878
|
+
truncated: render.truncated,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
status: 'ok',
|
|
884
|
+
source,
|
|
885
|
+
perSkill: render.perSkill,
|
|
886
|
+
skipped: render.skipped,
|
|
887
|
+
text: render.text,
|
|
888
|
+
chars: render.chars,
|
|
889
|
+
truncated: render.truncated,
|
|
890
|
+
};
|
|
891
|
+
}
|