session-orchestrator 5.0.0 → 5.2.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/autopilot/SKILL.md +1 -0
- package/.agents/skills/bootstrap/SKILL.md +2 -0
- package/.agents/skills/brainstorm/SKILL.md +3 -0
- package/.agents/skills/close/SKILL.md +17 -0
- package/.agents/skills/debug/SKILL.md +2 -0
- package/.agents/skills/discovery/SKILL.md +2 -1
- package/.agents/skills/dispatcher/SKILL.md +2 -0
- package/.agents/skills/eli5/SKILL.md +2 -0
- package/.agents/skills/eval/SKILL.md +1 -0
- package/.agents/skills/evolve/SKILL.md +2 -1
- package/.agents/skills/go/SKILL.md +18 -0
- package/.agents/skills/grill/SKILL.md +2 -0
- package/.agents/skills/harness-audit/SKILL.md +16 -0
- package/.agents/skills/memory-cleanup/SKILL.md +1 -0
- package/.agents/skills/persona-panel/SKILL.md +1 -0
- package/.agents/skills/plan/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +17 -0
- package/.agents/skills/reconcile/SKILL.md +1 -0
- package/.agents/skills/release/SKILL.md +18 -0
- package/.agents/skills/repo-audit/SKILL.md +1 -0
- package/.agents/skills/spinout/SKILL.md +1 -0
- package/.agents/skills/sunset-review/SKILL.md +2 -0
- package/.agents/skills/test/SKILL.md +17 -0
- package/.agents/skills/ux-grill/SKILL.md +2 -0
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
- package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
- package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
- package/.codex-plugin/skills/close/SKILL.md +3 -3
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +2 -0
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/debug/SKILL.md +11 -4
- package/.codex-plugin/skills/discovery/SKILL.md +8 -4
- package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
- package/.codex-plugin/skills/eli5/SKILL.md +9 -4
- package/.codex-plugin/skills/eval/SKILL.md +9 -4
- package/.codex-plugin/skills/evolve/SKILL.md +9 -4
- package/.codex-plugin/skills/go/SKILL.md +3 -3
- package/.codex-plugin/skills/grill/SKILL.md +11 -4
- package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
- package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
- package/.codex-plugin/skills/npm-publish/SKILL.md +2 -0
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
- package/.codex-plugin/skills/plan/SKILL.md +8 -4
- package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
- package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
- package/.codex-plugin/skills/release/SKILL.md +3 -3
- package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
- package/.codex-plugin/skills/spinout/SKILL.md +4 -4
- package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
- package/.codex-plugin/skills/test/SKILL.md +3 -3
- package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
- package/.cursor/commands/autopilot.md +4 -4
- package/.cursor/commands/bootstrap.md +5 -4
- package/.cursor/commands/brainstorm.md +5 -4
- package/.cursor/commands/close.md +4 -3
- package/.cursor/commands/convergence-monitoring.md +13 -0
- package/.cursor/commands/debug.md +4 -4
- package/.cursor/commands/discovery.md +4 -4
- package/.cursor/commands/dispatcher.md +4 -4
- package/.cursor/commands/eli5.md +4 -4
- package/.cursor/commands/eval.md +4 -4
- package/.cursor/commands/evolve.md +4 -4
- package/.cursor/commands/go.md +4 -3
- package/.cursor/commands/grill.md +4 -4
- package/.cursor/commands/harness-audit.md +3 -3
- package/.cursor/commands/memory-cleanup.md +4 -4
- package/.cursor/commands/npm-publish.md +13 -0
- package/.cursor/commands/persona-panel.md +4 -4
- package/.cursor/commands/plan.md +5 -4
- package/.cursor/commands/portfolio.md +3 -3
- package/.cursor/commands/reconcile.md +4 -4
- package/.cursor/commands/release.md +4 -3
- package/.cursor/commands/repo-audit.md +4 -4
- package/.cursor/commands/spinout.md +4 -4
- package/.cursor/commands/sunset-review.md +4 -4
- package/.cursor/commands/test.md +3 -3
- package/.cursor/commands/ux-grill.md +4 -4
- package/.cursor/rules/010-session-workflow.mdc +2 -2
- package/.cursor/skills/bootstrap/SKILL.md +1 -0
- package/.cursor/skills/close/SKILL.md +13 -0
- package/.cursor/skills/debug/SKILL.md +0 -1
- package/.cursor/skills/discovery/SKILL.md +0 -1
- package/.cursor/skills/dispatcher/SKILL.md +0 -1
- package/.cursor/skills/eli5/SKILL.md +0 -1
- package/.cursor/skills/evolve/SKILL.md +0 -1
- package/.cursor/skills/go/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +0 -1
- package/.cursor/skills/harness-audit/SKILL.md +12 -0
- package/.cursor/skills/portfolio/SKILL.md +12 -0
- package/.cursor/skills/release/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +0 -1
- package/.cursor/skills/sunset-review/SKILL.md +0 -1
- package/.cursor/skills/test/SKILL.md +12 -0
- package/.cursor/skills/ux-grill/SKILL.md +0 -1
- package/.cursor-plugin/plugin.json +2 -2
- package/.orchestrator/policy/blocked-commands.json +10 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +74 -235
- package/commands/session.md +10 -0
- package/docs/USER-GUIDE.md +24 -0
- package/docs/ci-setup.md +53 -0
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +12 -5
- package/docs/events-schema.md +5 -1
- package/docs/install.md +128 -0
- package/docs/persona-panel.md +1 -1
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +83 -14
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +6 -4
- package/docs/session-config-template.md +38 -0
- package/docs/telemetry.md +15 -0
- package/hooks/_lib/hook-import-set.json +46 -6
- package/hooks/_lib/subagent-paths.mjs +15 -0
- package/hooks/_lib/vcs-create-matcher.mjs +217 -62
- package/hooks/enforce-scope.mjs +42 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +14 -2
- package/hooks/on-stop.mjs +43 -1
- package/hooks/post-bash-write-verify.mjs +3 -0
- package/hooks/pre-auq-clarity.mjs +3 -0
- package/hooks/pre-bash-issue-budget.mjs +103 -17
- package/hooks/pre-task-scope-disjoint.mjs +152 -3
- package/hooks/skill-invocation-telemetry.mjs +2 -1
- package/package.json +3 -2
- package/pi/prompts/autopilot.md +3 -3
- package/pi/prompts/bootstrap.md +3 -3
- package/pi/prompts/brainstorm.md +3 -3
- package/pi/prompts/close.md +2 -2
- package/pi/prompts/convergence-monitoring.md +11 -0
- package/pi/prompts/debug.md +3 -3
- package/pi/prompts/discovery.md +3 -3
- package/pi/prompts/dispatcher.md +3 -3
- package/pi/prompts/eli5.md +3 -3
- package/pi/prompts/eval.md +3 -3
- package/pi/prompts/evolve.md +3 -3
- package/pi/prompts/go.md +2 -2
- package/pi/prompts/grill.md +3 -3
- package/pi/prompts/harness-audit.md +2 -3
- package/pi/prompts/memory-cleanup.md +3 -3
- package/pi/prompts/npm-publish.md +11 -0
- package/pi/prompts/persona-panel.md +3 -3
- package/pi/prompts/plan.md +3 -3
- package/pi/prompts/portfolio.md +2 -2
- package/pi/prompts/reconcile.md +3 -3
- package/pi/prompts/release.md +3 -3
- package/pi/prompts/repo-audit.md +3 -4
- package/pi/prompts/session.md +1 -1
- package/pi/prompts/spinout.md +3 -3
- package/pi/prompts/sunset-review.md +3 -3
- package/pi/prompts/templates-ack.md +1 -1
- package/pi/prompts/test.md +3 -3
- package/pi/prompts/ux-grill.md +3 -3
- package/scripts/archive-closed-prds.mjs +2 -2
- package/scripts/auq-audit.mjs +2 -3
- package/scripts/backfill-abandoned-sessions.mjs +57 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +2 -2
- package/scripts/check-package-manager.mjs +2 -2
- package/scripts/ci/assert-vitest-green.mjs +2 -1
- package/scripts/emit-session.mjs +2 -3
- package/scripts/export-hw-learnings.mjs +2 -1
- package/scripts/express-path.mjs +1 -1
- package/scripts/gc-stale-worktrees.mjs +2 -1
- package/scripts/generate-codex-skills.mjs +48 -4
- package/scripts/generate-cursor-adapter.mjs +173 -9
- package/scripts/generate-hook-import-set.mjs +12 -27
- package/scripts/generate-pi-prompts.mjs +183 -13
- package/scripts/github-protection-audit.mjs +2 -3
- package/scripts/lib/agent-frontmatter.mjs +23 -1
- package/scripts/lib/claude-md-budget-lint.mjs +2 -5
- package/scripts/lib/command-blocker.mjs +209 -9
- package/scripts/lib/config/drift-check.mjs +19 -0
- package/scripts/lib/convergence-monitor.mjs +2 -2
- package/scripts/lib/cursor-hook-bridge.mjs +2 -2
- package/scripts/lib/description-surface.mjs +2 -5
- package/scripts/lib/dispatcher/cli.mjs +2 -1
- package/scripts/lib/ecosystem-wizard.mjs +2 -1
- package/scripts/lib/fetch-baseline.mjs +3 -8
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
- package/scripts/lib/instruction-budget-guard.mjs +186 -46
- package/scripts/lib/is-main-module.mjs +82 -0
- package/scripts/lib/locks/index.mjs +32 -25
- package/scripts/lib/maintenance-due-banner.mjs +69 -3
- package/scripts/lib/peer-discovery.mjs +2 -5
- package/scripts/lib/playwright-driver/runner.mjs +63 -2
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +642 -0
- package/scripts/lib/rules-sync.mjs +2 -5
- package/scripts/lib/scope-echo.mjs +392 -7
- package/scripts/lib/session-close-backfill.mjs +58 -6
- package/scripts/lib/state-md.mjs +84 -3
- package/scripts/lib/sunset/walker.mjs +31 -4
- package/scripts/lib/tests-src-ratio.mjs +2 -6
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +2 -1
- package/scripts/lib/user-invocable-skills.mjs +185 -0
- package/scripts/lib/validate/check-banner-parity.mjs +2 -2
- package/scripts/lib/validate/check-cursor-adapter.mjs +2 -2
- package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
- package/scripts/lib/validate/check-entry-guard.mjs +366 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
- package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
- package/scripts/lib/validate/check-skill-links.mjs +27 -6
- package/scripts/lib/validate/check-skill-script-paths.mjs +2 -2
- package/scripts/lib/validate/check-test-git-config-target.mjs +2 -2
- package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +2 -2
- package/scripts/lib/validate/check-unwired-features.mjs +266 -11
- package/scripts/lib/validate/check-validator-registration.mjs +2 -2
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
- package/scripts/lib/validate-vendored-rules.mjs +35 -9
- package/scripts/lib/wave-transcript-tail.mjs +2 -2
- package/scripts/lock-reaper.mjs +2 -1
- package/scripts/materialize-wave-scope.mjs +87 -4
- package/scripts/migrate-sessions-jsonl.mjs +2 -1
- package/scripts/migrate-vault-paths.mjs +2 -3
- package/scripts/release.mjs +124 -35
- package/scripts/relocate-vault-corpus.mjs +2 -3
- package/scripts/repair-invalid-sessions.mjs +2 -2
- package/scripts/session-shape.mjs +2 -2
- package/scripts/site-numbers.mjs +35 -11
- package/scripts/sweep-expired-rules.mjs +216 -0
- package/scripts/validate-plugin.mjs +9 -0
- package/scripts/vault-consolidate.mjs +2 -2
- package/scripts/vault-mirror.mjs +2 -3
- package/scripts/wave-scope-binding.mjs +2 -3
- package/skills/_shared/bootstrap-gate.md +1 -1
- package/skills/_shared/monitor-patterns.md +1 -1
- package/skills/_shared/research-evidence.md +53 -0
- package/skills/_shared/state-ownership.md +3 -0
- package/skills/autopilot/SKILL.md +58 -4
- package/skills/bootstrap/SKILL.md +51 -1
- package/skills/brainstorm/SKILL.md +16 -0
- package/skills/claude-md-drift-check/checker.mjs +49 -11
- package/{commands/close.md → skills/close/SKILL.md} +9 -3
- package/skills/debug/SKILL.md +10 -0
- package/skills/discovery/SKILL.md +24 -1
- package/skills/discovery/probes-session.md +2 -2
- package/skills/dispatcher/SKILL.md +38 -7
- package/skills/eli5/SKILL.md +11 -0
- package/skills/eval/SKILL.md +14 -0
- package/skills/evolve/SKILL.md +8 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +6 -2
- package/{commands/go.md → skills/go/SKILL.md} +9 -1
- package/skills/grill/SKILL.md +19 -0
- package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
- package/skills/hook-development/SKILL.md +46 -41
- package/skills/memory-cleanup/SKILL.md +7 -0
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +56 -1
- package/skills/persona-panel/persona-format.md +1 -1
- package/skills/plan/SKILL.md +28 -1
- package/skills/playwright-driver/SKILL.md +7 -10
- package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
- package/skills/reconcile/SKILL.md +10 -0
- package/{commands/release.md → skills/release/SKILL.md} +16 -2
- package/skills/repo-audit/SKILL.md +7 -0
- package/skills/session-end/plan-verification.md +2 -2
- package/skills/session-plan/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +5 -4
- package/skills/session-start/phase-8-5-express-path.md +6 -6
- package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
- package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +4 -3
- package/skills/spinout/SKILL.md +12 -1
- package/skills/sunset-review/SKILL.md +13 -0
- package/{commands/test.md → skills/test/SKILL.md} +10 -4
- package/skills/ux-grill/SKILL.md +19 -1
- package/skills/wave-executor/SKILL.md +7 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +13 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- package/skills/wave-executor/references/wave-loop-review.md +17 -1
- package/commands/autopilot.md +0 -80
- package/commands/bootstrap.md +0 -56
- package/commands/brainstorm.md +0 -48
- package/commands/debug.md +0 -36
- package/commands/discovery.md +0 -32
- package/commands/dispatcher.md +0 -59
- package/commands/eli5.md +0 -33
- package/commands/eval.md +0 -28
- package/commands/evolve.md +0 -10
- package/commands/grill.md +0 -45
- package/commands/memory-cleanup.md +0 -26
- package/commands/persona-panel.md +0 -121
- package/commands/plan.md +0 -15
- package/commands/reconcile.md +0 -23
- package/commands/repo-audit.md +0 -24
- package/commands/spinout.md +0 -15
- package/commands/sunset-review.md +0 -27
- package/commands/ux-grill.md +0 -51
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
* node scripts/lib/scope-echo.mjs --help
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
import { readFileSync } from 'node:fs';
|
|
41
|
-
import { resolve } from 'node:path';
|
|
42
|
-
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
41
|
+
import { join, resolve } from 'node:path';
|
|
43
42
|
|
|
44
43
|
import { digestSha256Short } from './crypto-digest-utils.mjs';
|
|
44
|
+
import { isMainModule } from './is-main-module.mjs';
|
|
45
45
|
|
|
46
46
|
/** Marker the agent must emit. Case-sensitive by design — a lowercase lookalike is not an echo. */
|
|
47
47
|
export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
|
|
@@ -49,6 +49,15 @@ export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
|
|
|
49
49
|
/** Event name for the post-wave verdict. */
|
|
50
50
|
export const SCOPE_ECHO_EVENT = 'orchestrator.wave_dispatch.scope_echo_checked';
|
|
51
51
|
|
|
52
|
+
/** The SEND-side record `hooks/pre-task-scope-disjoint.mjs` writes per dispatch. */
|
|
53
|
+
export const SCOPE_CHECKED_EVENT = 'orchestrator.wave_dispatch.scope_checked';
|
|
54
|
+
|
|
55
|
+
/** The DEGRADATION record `scripts/materialize-wave-scope.mjs` writes per wave. */
|
|
56
|
+
export const SCOPE_MATERIALIZED_EVENT = 'orchestrator.wave_dispatch.scope_materialized';
|
|
57
|
+
|
|
58
|
+
/** The per-WAVE join verdict `--verify` emits. */
|
|
59
|
+
export const SCOPE_VERIFIED_EVENT = 'orchestrator.wave_dispatch.scope_verified';
|
|
60
|
+
|
|
52
61
|
/** Max characters retained for `agent_id` in the payload (same clamp as the send-side hook). */
|
|
53
62
|
const AGENT_ID_MAX = 120;
|
|
54
63
|
|
|
@@ -212,6 +221,308 @@ export function scopeEchoPayload(verdict, meta = {}) {
|
|
|
212
221
|
};
|
|
213
222
|
}
|
|
214
223
|
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// --verify — join the two halves of one wave ON THE DIGEST (#1092)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Closed verdict enum. Order is PRECEDENCE, not preference — the first matching
|
|
230
|
+
* row wins, and every (file, dispatch, echo) triple lands on exactly one row:
|
|
231
|
+
*
|
|
232
|
+
* duplicate-claim ≥2 DISTINCT agent ids claimed one digest at dispatch.
|
|
233
|
+
* That is agent A's scope reported for agent B, and it is
|
|
234
|
+
* a defect whatever the other two bits say — so it is
|
|
235
|
+
* checked first.
|
|
236
|
+
* echoed-not-injected an echo names a digest NO dispatch claimed.
|
|
237
|
+
* digest-unknown no scope FILE on disk carries this digest: the record
|
|
238
|
+
* exists but the artefact that would ground it does not
|
|
239
|
+
* (a scope file rewritten or reconciled away after the
|
|
240
|
+
* dispatch).
|
|
241
|
+
* matched file ∧ dispatch ∧ echo.
|
|
242
|
+
* injected-not-echoed file ∧ dispatch, no echo — the normal state DURING a
|
|
243
|
+
* wave, before the reports land.
|
|
244
|
+
* injection-missing file ∧ neither — a scope file no dispatch claimed and
|
|
245
|
+
* no agent echoed. This is AC-2: the omitted injection.
|
|
246
|
+
*
|
|
247
|
+
* Every member is kebab-case. `echo-only` is NOT one of the six: it is the
|
|
248
|
+
* DEGRADED value every verdict collapses to when the wave's transport is
|
|
249
|
+
* unobservable (see {@link verifyWaveScope}), so it is never produced by the
|
|
250
|
+
* precedence chain above and never belongs in this array.
|
|
251
|
+
*
|
|
252
|
+
* `injection-missing` was spelled `injection_missing` until 2026-09-16 — the
|
|
253
|
+
* one snake_case member of an otherwise kebab-case enum. Ledger records written
|
|
254
|
+
* before that date may still carry the old spelling in `by_verdict`; there is no
|
|
255
|
+
* dual-emit, so a consumer reading historical rows must accept both keys.
|
|
256
|
+
*/
|
|
257
|
+
export const SCOPE_VERDICTS = Object.freeze([
|
|
258
|
+
'duplicate-claim',
|
|
259
|
+
'echoed-not-injected',
|
|
260
|
+
'digest-unknown',
|
|
261
|
+
'matched',
|
|
262
|
+
'injected-not-echoed',
|
|
263
|
+
'injection-missing',
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
/** A well-formed digest. Anything else is ignored rather than joined on. */
|
|
267
|
+
const DIGEST_RE = /^[0-9a-f]{8}$/;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Parse a JSONL ledger leniently: a malformed line is skipped, never thrown on
|
|
271
|
+
* — and COUNTED, which is the load-bearing half.
|
|
272
|
+
*
|
|
273
|
+
* A crashed writer leaves a truncated final line (`{"event":"…","wave":4` with
|
|
274
|
+
* no closing brace) — a measured shape in this repo's own ledger. Skipping it
|
|
275
|
+
* silently truncates the join, and a truncated join reports
|
|
276
|
+
* `dispatches N / injected N / echoed N` with every digest `matched`: a CLEAN
|
|
277
|
+
* wave, from the instrument built to detect silent failure. The count is what
|
|
278
|
+
* separates "nothing was wrong" from "I could not read part of the evidence".
|
|
279
|
+
*
|
|
280
|
+
* A malformed line is any NON-BLANK line that does not yield a plain object:
|
|
281
|
+
* one that does not start with `{`, one `JSON.parse` rejects, and one that
|
|
282
|
+
* parses to an array or a scalar. Blank lines are not malformed — a trailing
|
|
283
|
+
* newline is the normal end of a JSONL file.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} raw
|
|
286
|
+
* @returns {{records: Array<Record<string, unknown>>, malformed: number}}
|
|
287
|
+
*/
|
|
288
|
+
function parseJsonl(raw) {
|
|
289
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
290
|
+
const records = [];
|
|
291
|
+
let malformed = 0;
|
|
292
|
+
if (typeof raw !== 'string') return { records, malformed };
|
|
293
|
+
for (const line of raw.split('\n')) {
|
|
294
|
+
const trimmed = line.trim();
|
|
295
|
+
if (trimmed.length === 0) continue;
|
|
296
|
+
if (trimmed[0] !== '{') {
|
|
297
|
+
malformed += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const rec = JSON.parse(trimmed);
|
|
302
|
+
if (rec !== null && typeof rec === 'object' && !Array.isArray(rec)) records.push(rec);
|
|
303
|
+
else malformed += 1;
|
|
304
|
+
} catch {
|
|
305
|
+
malformed += 1; // a half-written line is not a verdict — but it is not nothing either
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { records, malformed };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Digest every per-agent scope file of one wave — shape (a) of the two scope
|
|
313
|
+
* shapes (CLAUDE.md / AGENTS.md § allowedPaths).
|
|
314
|
+
*
|
|
315
|
+
* @param {string} stateDir
|
|
316
|
+
* @param {number} wave
|
|
317
|
+
* @param {{readDir?: typeof readdirSync, readFile?: typeof readFileSync}} [io]
|
|
318
|
+
* @returns {Map<string, string[]>} digest → agent ids (file basenames)
|
|
319
|
+
*/
|
|
320
|
+
export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFile = readFileSync } = {}) {
|
|
321
|
+
const out = new Map();
|
|
322
|
+
const dir = resolve(String(stateDir), 'filescopes', `wave-${wave}`);
|
|
323
|
+
let names;
|
|
324
|
+
try {
|
|
325
|
+
names = readDir(dir);
|
|
326
|
+
} catch {
|
|
327
|
+
return out; // no directory yet is not a failure — it is "nothing materialized"
|
|
328
|
+
}
|
|
329
|
+
for (const name of names) {
|
|
330
|
+
const file = typeof name === 'string' ? name : name?.name;
|
|
331
|
+
if (typeof file !== 'string' || !file.endsWith('.json')) continue;
|
|
332
|
+
let parsed;
|
|
333
|
+
try {
|
|
334
|
+
parsed = JSON.parse(readFile(join(dir, file), 'utf8'));
|
|
335
|
+
} catch { continue; }
|
|
336
|
+
if (!Array.isArray(parsed) || normalizeScopePaths(parsed).length === 0) continue;
|
|
337
|
+
const digest = scopeDigest(parsed);
|
|
338
|
+
const ids = out.get(digest) ?? [];
|
|
339
|
+
ids.push(file.slice(0, -'.json'.length));
|
|
340
|
+
out.set(digest, ids);
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Verify ONE wave's FILE-SCOPE injection end to end.
|
|
347
|
+
*
|
|
348
|
+
* ## Why the join key is the DIGEST and never `agent_id`
|
|
349
|
+
*
|
|
350
|
+
* Measured 2026-09-16 over this host's `.orchestrator/metrics/events.jsonl`
|
|
351
|
+
* corpus: 609 `scope_checked` records against 51 `scope_echo_checked`, and the
|
|
352
|
+
* two agent-id sets OVERLAP IN ZERO ELEMENTS — the send side records the
|
|
353
|
+
* dispatch tool's `description` plus `subagent_type`
|
|
354
|
+
* (`"i-3 #1353 refund event (session-orchestrator:code-implementer)"`), the
|
|
355
|
+
* receive side the coordinator's short handle (`"i-3"`). One session showed 23
|
|
356
|
+
* echoes against 18 injections and the halves could not be joined at all. The
|
|
357
|
+
* digest is the one value both halves derive from the SAME artefact, so it joins
|
|
358
|
+
* them by construction.
|
|
359
|
+
*
|
|
360
|
+
* ## Observability, not enforcement
|
|
361
|
+
*
|
|
362
|
+
* Every verdict exits 0. This tool reports; the wave-executor turns
|
|
363
|
+
* `injection-missing` / `duplicate-claim` into a STATE.md deviation
|
|
364
|
+
* (`wave-loop-review.md` step 3d-bis), never into a block.
|
|
365
|
+
*
|
|
366
|
+
* ## A partial read is never a clean wave
|
|
367
|
+
*
|
|
368
|
+
* `malformed_lines` counts the ledger lines that could not be parsed (a crashed
|
|
369
|
+
* writer's truncated append is the measured shape). It is ALWAYS present,
|
|
370
|
+
* including as `0`: the ledger read either happened or the file was absent, and
|
|
371
|
+
* both are measurements. A non-zero count means the join ran on INCOMPLETE
|
|
372
|
+
* evidence — the counts and verdicts below are a floor, not a census — and the
|
|
373
|
+
* human table says so beside them.
|
|
374
|
+
*
|
|
375
|
+
* ## Transport degradation
|
|
376
|
+
*
|
|
377
|
+
* On a platform with no `PreToolUse` `Agent` matcher (Codex, Cursor, Pi) the
|
|
378
|
+
* send-side record cannot exist at all, so a missing dispatch is NOT evidence of
|
|
379
|
+
* a missing injection. When the wave's `scope_materialized` record says
|
|
380
|
+
* `transport_observable: false`, `transport` reads `unobservable` and every
|
|
381
|
+
* verdict degrades to `echo-only` — the receive half is all the platform can
|
|
382
|
+
* carry, and reporting `injection-missing` there would be an accusation derived
|
|
383
|
+
* from an instrument that is not installed.
|
|
384
|
+
*
|
|
385
|
+
* @param {object} params
|
|
386
|
+
* @param {number} params.wave
|
|
387
|
+
* @param {string} params.stateDir
|
|
388
|
+
* @param {string} [params.session] semantic (or raw) session id to filter on
|
|
389
|
+
* @param {string} [params.eventsPath] defaults to `.orchestrator/metrics/events.jsonl`
|
|
390
|
+
* @param {typeof readFileSync} [params.readFile]
|
|
391
|
+
* @param {typeof readdirSync} [params.readDir]
|
|
392
|
+
* @returns {{wave: number, transport: 'observable'|'unobservable', dispatches: number,
|
|
393
|
+
* injected: number, echoed: number, malformed_lines: number,
|
|
394
|
+
* by_verdict: Record<string, number>,
|
|
395
|
+
* agents: Array<{agent_id: string, verdict: string, digest: string}>}}
|
|
396
|
+
*/
|
|
397
|
+
export function verifyWaveScope({
|
|
398
|
+
wave,
|
|
399
|
+
stateDir,
|
|
400
|
+
session,
|
|
401
|
+
eventsPath,
|
|
402
|
+
readFile = readFileSync,
|
|
403
|
+
readDir = readdirSync,
|
|
404
|
+
}) {
|
|
405
|
+
const ledgerPath = eventsPath || join('.orchestrator', 'metrics', 'events.jsonl');
|
|
406
|
+
let records = [];
|
|
407
|
+
let malformedLines = 0;
|
|
408
|
+
try {
|
|
409
|
+
({ records, malformed: malformedLines } = parseJsonl(readFile(ledgerPath, 'utf8')));
|
|
410
|
+
} catch { /* no ledger yet — every count is legitimately 0 */ }
|
|
411
|
+
|
|
412
|
+
const mine = (rec) => {
|
|
413
|
+
if (rec.wave !== wave) return false;
|
|
414
|
+
if (!session) return true;
|
|
415
|
+
return rec.semantic_session_id === session || rec.session_id === session;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/** @type {Map<string, Set<string>>} digest → distinct dispatching agent ids */
|
|
419
|
+
const dispatchIds = new Map();
|
|
420
|
+
/** @type {Map<string, string>} digest → first echoing agent id */
|
|
421
|
+
const echoIds = new Map();
|
|
422
|
+
let dispatches = 0;
|
|
423
|
+
let injected = 0;
|
|
424
|
+
let echoed = 0;
|
|
425
|
+
// A wave may be materialized more than once (#1103); the LAST record wins,
|
|
426
|
+
// because that is the state the dispatches below actually ran against.
|
|
427
|
+
let transportObservable = null;
|
|
428
|
+
|
|
429
|
+
for (const rec of records) {
|
|
430
|
+
if (rec.event === SCOPE_MATERIALIZED_EVENT && mine(rec)) {
|
|
431
|
+
if (typeof rec.transport_observable === 'boolean') transportObservable = rec.transport_observable;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (rec.event === SCOPE_CHECKED_EVENT && mine(rec)) {
|
|
435
|
+
dispatches += 1;
|
|
436
|
+
if (rec.injected === true) injected += 1;
|
|
437
|
+
const digest = rec.scope_digest;
|
|
438
|
+
if (typeof digest === 'string' && DIGEST_RE.test(digest)) {
|
|
439
|
+
const ids = dispatchIds.get(digest) ?? new Set();
|
|
440
|
+
ids.add(typeof rec.agent_id === 'string' ? rec.agent_id : 'unnamed-agent');
|
|
441
|
+
dispatchIds.set(digest, ids);
|
|
442
|
+
}
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (rec.event === SCOPE_ECHO_EVENT && mine(rec)) {
|
|
446
|
+
if (rec.echoed === true) echoed += 1;
|
|
447
|
+
// The agent's OWN claim first (`actual_digest`); the coordinator-side
|
|
448
|
+
// expectation only as a fallback, so a mismatched echo still joins to the
|
|
449
|
+
// scope file it was checked against rather than vanishing from the report.
|
|
450
|
+
const digest = [rec.actual_digest, rec.expected_digest]
|
|
451
|
+
.find((d) => typeof d === 'string' && DIGEST_RE.test(d));
|
|
452
|
+
if (digest !== undefined && !echoIds.has(digest)) {
|
|
453
|
+
echoIds.set(digest, typeof rec.agent_id === 'string' ? rec.agent_id : 'unnamed-agent');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const fileIds = digestScopeFiles(stateDir, wave, { readDir, readFile });
|
|
459
|
+
const digests = [...new Set([...fileIds.keys(), ...dispatchIds.keys(), ...echoIds.keys()])].sort();
|
|
460
|
+
|
|
461
|
+
const unobservable = transportObservable === false;
|
|
462
|
+
/** @type {Record<string, number>} */
|
|
463
|
+
const byVerdict = {};
|
|
464
|
+
const agents = digests.map((digest) => {
|
|
465
|
+
const claimants = dispatchIds.get(digest);
|
|
466
|
+
const hasDispatch = claimants !== undefined && claimants.size > 0;
|
|
467
|
+
const hasEcho = echoIds.has(digest);
|
|
468
|
+
const hasFile = fileIds.has(digest);
|
|
469
|
+
|
|
470
|
+
let verdict;
|
|
471
|
+
if (hasDispatch && claimants.size > 1) verdict = 'duplicate-claim';
|
|
472
|
+
else if (hasEcho && !hasDispatch) verdict = 'echoed-not-injected';
|
|
473
|
+
else if (!hasFile) verdict = 'digest-unknown';
|
|
474
|
+
else if (hasDispatch && hasEcho) verdict = 'matched';
|
|
475
|
+
else if (hasDispatch) verdict = 'injected-not-echoed';
|
|
476
|
+
else verdict = 'injection-missing';
|
|
477
|
+
if (unobservable) verdict = 'echo-only';
|
|
478
|
+
|
|
479
|
+
byVerdict[verdict] = (byVerdict[verdict] ?? 0) + 1;
|
|
480
|
+
const agentId = (hasDispatch ? [...claimants].sort().join(' + ') : null)
|
|
481
|
+
?? echoIds.get(digest)
|
|
482
|
+
?? (fileIds.get(digest) ?? []).join(' + ')
|
|
483
|
+
?? 'unknown';
|
|
484
|
+
return { agent_id: agentId, verdict, digest };
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
wave,
|
|
489
|
+
transport: unobservable ? 'unobservable' : 'observable',
|
|
490
|
+
dispatches,
|
|
491
|
+
injected,
|
|
492
|
+
echoed,
|
|
493
|
+
malformed_lines: malformedLines,
|
|
494
|
+
by_verdict: byVerdict,
|
|
495
|
+
agents,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Telemetry payload for a `--verify` run. Counts, closed enums and 8-hex digests
|
|
501
|
+
* ONLY — no agent id, no path, no prompt text: this record travels over the same
|
|
502
|
+
* unredacted Clank Event-Bus webhook as its two halves, and an `agent_id` is a
|
|
503
|
+
* free-form coordinator string that has carried private project slugs before.
|
|
504
|
+
*
|
|
505
|
+
* `malformed_lines` travels with the counts and, like them, is ALWAYS present
|
|
506
|
+
* including as `0` — it is the denominator's honesty check: without it a join
|
|
507
|
+
* that silently dropped half the ledger is indistinguishable in the record from
|
|
508
|
+
* a wave where nothing went wrong (`.claude/rules/host-resources.md` § HR-105).
|
|
509
|
+
*
|
|
510
|
+
* @param {ReturnType<typeof verifyWaveScope>} report
|
|
511
|
+
* @returns {Record<string, unknown>}
|
|
512
|
+
*/
|
|
513
|
+
export function scopeVerifiedPayload(report) {
|
|
514
|
+
return {
|
|
515
|
+
wave: report.wave,
|
|
516
|
+
transport_observable: report.transport === 'observable',
|
|
517
|
+
dispatches: report.dispatches,
|
|
518
|
+
injected: report.injected,
|
|
519
|
+
echoed: report.echoed,
|
|
520
|
+
malformed_lines: report.malformed_lines ?? 0,
|
|
521
|
+
by_verdict: report.by_verdict,
|
|
522
|
+
digests: report.agents.map((a) => a.digest),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
215
526
|
// ---------------------------------------------------------------------------
|
|
216
527
|
// CLI
|
|
217
528
|
// ---------------------------------------------------------------------------
|
|
@@ -229,9 +540,19 @@ const USAGE = `Usage:
|
|
|
229
540
|
an ${SCOPE_ECHO_EVENT} row to .orchestrator/metrics/events.jsonl
|
|
230
541
|
(best-effort: a failed emit never changes the verdict or the exit code).
|
|
231
542
|
|
|
543
|
+
scope-echo --verify --wave <N> --state-dir <dir> [--session <id>]
|
|
544
|
+
[--events <path>] [--json] [--emit]
|
|
545
|
+
Join ONE wave's send side (${SCOPE_CHECKED_EVENT}),
|
|
546
|
+
its scope files and its echoes ON THE DIGEST, and print the per-agent
|
|
547
|
+
verdict table. --emit additionally appends one ${SCOPE_VERIFIED_EVENT}
|
|
548
|
+
row per wave (counts and 8-hex digests only).
|
|
549
|
+
Verdicts: ${SCOPE_VERDICTS.join(' · ')} — plus echo-only when the wave's
|
|
550
|
+
transport is unobservable.
|
|
551
|
+
|
|
232
552
|
scope-echo --help
|
|
233
553
|
|
|
234
|
-
Exit codes: 0 for every verdict, 1 for a missing --scope-file
|
|
554
|
+
Exit codes: 0 for every verdict, 1 for a missing --scope-file (or, under
|
|
555
|
+
--verify, a missing --wave / --state-dir).
|
|
235
556
|
`;
|
|
236
557
|
|
|
237
558
|
/**
|
|
@@ -259,6 +580,68 @@ function parseArgv(argv) {
|
|
|
259
580
|
return out;
|
|
260
581
|
}
|
|
261
582
|
|
|
583
|
+
/**
|
|
584
|
+
* `--verify` mode — the per-wave join (see {@link verifyWaveScope}).
|
|
585
|
+
*
|
|
586
|
+
* Exit 0 for EVERY verdict, including `injection-missing`: this is an
|
|
587
|
+
* observability tool, and the wave-executor decides what a verdict costs
|
|
588
|
+
* (`wave-loop-review.md` step 3d-bis writes a STATE.md deviation, never a
|
|
589
|
+
* block). Exit 1 is reserved for the caller's own mistake — a missing required
|
|
590
|
+
* flag — per `.claude/rules/cli-design.md`.
|
|
591
|
+
*
|
|
592
|
+
* @param {Record<string, string|boolean>} args
|
|
593
|
+
* @returns {Promise<number>}
|
|
594
|
+
*/
|
|
595
|
+
async function mainVerify(args) {
|
|
596
|
+
const wave = Number(args.wave);
|
|
597
|
+
const stateDir = typeof args['state-dir'] === 'string' ? args['state-dir'] : '';
|
|
598
|
+
if (!Number.isSafeInteger(wave) || wave <= 0 || stateDir === '') {
|
|
599
|
+
process.stderr.write('scope-echo: --verify requires --wave <positive-int> and --state-dir <dir>\n');
|
|
600
|
+
process.stderr.write(USAGE);
|
|
601
|
+
return 1;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const report = verifyWaveScope({
|
|
605
|
+
wave,
|
|
606
|
+
stateDir,
|
|
607
|
+
session: typeof args.session === 'string' ? args.session : undefined,
|
|
608
|
+
eventsPath: typeof args.events === 'string' ? args.events : undefined,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
if (args.emit) {
|
|
612
|
+
try {
|
|
613
|
+
const repoRoot = resolve(stateDir, '..');
|
|
614
|
+
const { emitEvent, sessionAttribution } = await import('./events.mjs');
|
|
615
|
+
await emitEvent(
|
|
616
|
+
SCOPE_VERIFIED_EVENT,
|
|
617
|
+
{ ...scopeVerifiedPayload(report), ...sessionAttribution(repoRoot) },
|
|
618
|
+
{ repoRoot },
|
|
619
|
+
);
|
|
620
|
+
} catch (err) {
|
|
621
|
+
process.stderr.write(`scope-echo: emit failed — ${err?.message ?? err}\n`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (args.json) {
|
|
626
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
const lines = [
|
|
630
|
+
`wave ${report.wave} — transport: ${report.transport} — `
|
|
631
|
+
+ `${report.injected}/${report.dispatches} injected, ${report.echoed} echoed`,
|
|
632
|
+
// A partial read must never read as a clean wave: the counts above are a
|
|
633
|
+
// FLOOR when lines were dropped, and this is the only place the human table
|
|
634
|
+
// can say so.
|
|
635
|
+
...(report.malformed_lines > 0
|
|
636
|
+
? [` WARNING: ${report.malformed_lines} malformed ledger line(s) skipped — `
|
|
637
|
+
+ 'counts and verdicts below are a floor, not a census']
|
|
638
|
+
: []),
|
|
639
|
+
...report.agents.map((a) => ` ${a.digest} ${a.verdict.padEnd(20)} ${a.agent_id}`),
|
|
640
|
+
];
|
|
641
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
|
|
262
645
|
/**
|
|
263
646
|
* @param {string[]} [argv]
|
|
264
647
|
* @returns {Promise<number>} process exit code: 0 for every verdict (this is an
|
|
@@ -272,6 +655,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
272
655
|
process.stdout.write(USAGE);
|
|
273
656
|
return 0;
|
|
274
657
|
}
|
|
658
|
+
// --verify is its own mode and takes no --scope-file: it reads EVERY scope
|
|
659
|
+
// file of the wave, so the check below must not reject it first.
|
|
660
|
+
if (args.verify) return mainVerify(args);
|
|
661
|
+
|
|
275
662
|
const scopeFilePath = typeof args['scope-file'] === 'string' ? args['scope-file'] : '';
|
|
276
663
|
if (!scopeFilePath) {
|
|
277
664
|
process.stderr.write('scope-echo: --scope-file <path> is required\n');
|
|
@@ -337,9 +724,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
337
724
|
return 0;
|
|
338
725
|
}
|
|
339
726
|
|
|
340
|
-
const invokedAsCli =
|
|
341
|
-
process.argv[1] !== undefined &&
|
|
342
|
-
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
727
|
+
const invokedAsCli = isMainModule(import.meta.url);
|
|
343
728
|
|
|
344
729
|
if (invokedAsCli) {
|
|
345
730
|
process.exitCode = await main();
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `scripts/backfill-abandoned-sessions.mjs`.
|
|
15
15
|
*
|
|
16
16
|
* A second, sibling export — `backfillCompletedFromStateMd` — closes a
|
|
17
|
-
* different gap (#429): `
|
|
17
|
+
* different gap (#429): `skills/close/SKILL.md`'s Pre-Check treats STATE.md
|
|
18
18
|
* `status: completed` as proof the session-end writer already ran and refuses
|
|
19
19
|
* to invoke it again. When that status was set by hand (or by any path that
|
|
20
20
|
* never reached Phase 3.7), no sessions.jsonl record is EVER written — the
|
|
@@ -545,13 +545,38 @@ function isBackfillStub(record) {
|
|
|
545
545
|
return BACKFILL_STUB_STATUSES.has(record.status);
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
/**
|
|
549
|
+
* How far a caller-supplied `started_at` may drift from a legacy-label record's
|
|
550
|
+
* own `started_at` before the two are judged to name DIFFERENT sessions (#1368).
|
|
551
|
+
*
|
|
552
|
+
* Six hours is deliberately wider than any plausible write-lag and narrower
|
|
553
|
+
* than a day, because the legacy label the drift gate corroborates
|
|
554
|
+
* (`<branch>-<YYYY-MM-DD>-<mode>-<n>`) ALREADY carries the date: two records
|
|
555
|
+
* that share a label and sit inside the same six-hour window are the same
|
|
556
|
+
* session, and two that share a label across a day boundary cannot exist,
|
|
557
|
+
* because the label's date component would differ.
|
|
558
|
+
*/
|
|
559
|
+
export const STARTED_AT_DRIFT_TOLERANCE_MS = 6 * 3600 * 1000;
|
|
560
|
+
|
|
548
561
|
/**
|
|
549
562
|
* Find the newest ledger record for one physical session. Shared by the close
|
|
550
563
|
* precheck and backfill dedupe; no file I/O or mutation.
|
|
551
564
|
*
|
|
552
565
|
* Native identity wins over the attribution label. A conflicting native UUID
|
|
553
|
-
* vetoes a label match. Legacy records without a native join remain readable
|
|
554
|
-
*
|
|
566
|
+
* vetoes a label match. Legacy records without a native join remain readable.
|
|
567
|
+
*
|
|
568
|
+
* `started_at` is a CORROBORATING SIGNAL on the legacy-label path, never an
|
|
569
|
+
* identity key (#1368). The two timestamps being compared are written by
|
|
570
|
+
* different producers at different moments — STATE.md's is written by hand
|
|
571
|
+
* when the coordinator initialises the file (measured 2026-09-13: 48 minutes
|
|
572
|
+
* after the lock's own `started_at`), while the ledger's comes from the
|
|
573
|
+
* `session.started` event or from STATE.md itself. Demanding an
|
|
574
|
+
* EXACT-millisecond match therefore vetoed a correct label match on the only
|
|
575
|
+
* path that can reach it, which is what made `/close`'s #429 pre-check report
|
|
576
|
+
* "no matching record" for a session that WAS recorded. Only a drift larger
|
|
577
|
+
* than {@link STARTED_AT_DRIFT_TOLERANCE_MS} now vetoes — a window, not an
|
|
578
|
+
* off-switch, so two genuinely different same-label sessions still fail to
|
|
579
|
+
* match.
|
|
555
580
|
*
|
|
556
581
|
* @param {object[]} records parsed JSONL records, in append order
|
|
557
582
|
* @param {{sessionId?: string|null, semanticSessionId?: string|null, startedAt?: string|null}} ids
|
|
@@ -581,7 +606,8 @@ export function findRecordedSession(records, { sessionId = null, semanticSession
|
|
|
581
606
|
const incompleteStart = Array.isArray(record._backfill_incomplete_fields)
|
|
582
607
|
&& record._backfill_incomplete_fields.includes('started_at');
|
|
583
608
|
const recordStart = !incompleteStart && typeof record.started_at === 'string' ? Date.parse(record.started_at) : NaN;
|
|
584
|
-
if (Number.isFinite(startMs) && Number.isFinite(recordStart)
|
|
609
|
+
if (Number.isFinite(startMs) && Number.isFinite(recordStart)
|
|
610
|
+
&& Math.abs(startMs - recordStart) > STARTED_AT_DRIFT_TOLERANCE_MS) continue;
|
|
585
611
|
legacyMatch ??= record;
|
|
586
612
|
}
|
|
587
613
|
return legacyMatch;
|
|
@@ -670,6 +696,21 @@ function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId
|
|
|
670
696
|
* historical migration CLI only — `hooks/on-session-end.mjs` must NEVER
|
|
671
697
|
* pass this (a hook-time foreign lock is by definition a real, active
|
|
672
698
|
* session, not stale history).
|
|
699
|
+
* @param {boolean} [args.ownSessionIsEnding=false]
|
|
700
|
+
* #1376 — the caller IS the session this candidate names, and it is tearing
|
|
701
|
+
* itself down right now. An OWN lock that is still live means "this session
|
|
702
|
+
* is RUNNING" only when the asker is somebody else: at `SessionEnd` the
|
|
703
|
+
* ending session's own heartbeat is by construction fresh (it was refreshed
|
|
704
|
+
* minutes ago, TTL 4h), so the `skipped-own-live-lock` branch below rejected
|
|
705
|
+
* every unclosed session's own stub and the ledger never got one. Pass
|
|
706
|
+
* `true` ONLY from `hooks/on-session-end.mjs`, and ONLY inside the
|
|
707
|
+
* `isRecordedSession` guard that already gates `duration_ms` /
|
|
708
|
+
* `semantic_session_id` — a FOREIGN terminating window that inherited this
|
|
709
|
+
* session's semantic id from the shared `current-session.json` (#863 defect
|
|
710
|
+
* (b)) must never set it. The migration CLI and the SessionStart recovery
|
|
711
|
+
* path (`scripts/backfill-abandoned-sessions.mjs`) NEVER pass it: there the
|
|
712
|
+
* candidate's live own lock genuinely means a running session.
|
|
713
|
+
* The FOREIGN-live-lock guard is untouched by this flag.
|
|
673
714
|
* @param {number|null} [args.assumeDeadBeforeMs=null]
|
|
674
715
|
* #731 — operator-supplied cutoff (ms-since-epoch). A candidate whose
|
|
675
716
|
* `lastEventMs` strictly predates this value bypasses a foreign live lock
|
|
@@ -685,6 +726,7 @@ export async function backfillAbandonedSession({
|
|
|
685
726
|
now = Date.now(),
|
|
686
727
|
dryRun = false,
|
|
687
728
|
relaxDeadByAge = false,
|
|
729
|
+
ownSessionIsEnding = false,
|
|
688
730
|
assumeDeadBeforeMs = null,
|
|
689
731
|
deps = {},
|
|
690
732
|
} = {}) {
|
|
@@ -827,7 +869,17 @@ export async function backfillAbandonedSession({
|
|
|
827
869
|
// only ever fires when `foreign` is true). A STALE own lock
|
|
828
870
|
// (isLockLive === false) falls through unchanged — this is a
|
|
829
871
|
// liveness gate, not a blanket own-session off-switch.
|
|
830
|
-
|
|
872
|
+
//
|
|
873
|
+
// #1376 — "own + live" is a RUNNING session only when the caller is
|
|
874
|
+
// NOT that session's own teardown. At SessionEnd the ending session's
|
|
875
|
+
// heartbeat is still fresh by construction, so without
|
|
876
|
+
// `ownSessionIsEnding` this branch swallowed the stub of every session
|
|
877
|
+
// that never reached /close — the exact gap the close-through backfill
|
|
878
|
+
// exists to close. The caller carries the attestation (see the param
|
|
879
|
+
// docblock); this module never re-derives it, because the only signal
|
|
880
|
+
// available here — the lock's own liveness — is what is being
|
|
881
|
+
// qualified.
|
|
882
|
+
if (own && isLockLive(lock, nowMs) && !ownSessionIsEnding) {
|
|
831
883
|
return { action: 'skipped-own-live-lock', sessionId: recordId };
|
|
832
884
|
}
|
|
833
885
|
|
|
@@ -934,7 +986,7 @@ export async function backfillAbandonedSession({
|
|
|
934
986
|
* matching record (#429).
|
|
935
987
|
*
|
|
936
988
|
* ── THE GAP THIS CLOSES ──────────────────────────────────────────────────
|
|
937
|
-
* `
|
|
989
|
+
* `skills/close/SKILL.md`'s Pre-Check treats `STATE.md status: completed` as
|
|
938
990
|
* PROOF that the session-end skill's Phase 3.7 writer already ran, and
|
|
939
991
|
* stops before invoking it — including when `status: completed` was set by
|
|
940
992
|
* hand (or by any path that never reached Phase 3.7). The session then has
|
package/scripts/lib/state-md.mjs
CHANGED
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
* @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, parseMissionStatusStrict, MISSION_STATUS_VALUES, writeMissionStatus, setMissionStatus, setMissionStatusDetailed, readMissionStatus, recoverFrontmatterMissionStatusDetailed, writeMissionStatusOnDisk, setMissionStatusOnDisk
|
|
11
11
|
* @see scripts/lib/state-md/recommendations.mjs parseRecommendations
|
|
12
12
|
*
|
|
13
|
-
* Plus
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Plus TWO small non-re-export surfaces at the bottom of this file (each with
|
|
14
|
+
* its own docblock explaining why it is composed here rather than added as a
|
|
15
|
+
* fourth mutator module): the `session-profile` frontmatter accessors, and the
|
|
16
|
+
* `started_at` / `session-id` frontmatter SOURCES (#1368).
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import { parseStateMd as _parseStateMd } from './state-md/yaml-parser.mjs';
|
|
19
20
|
import { updateFrontmatterFields as _updateFrontmatterFields } from './state-md/frontmatter-mutators.mjs';
|
|
21
|
+
import { readLock as _readLock } from './session-lock.mjs';
|
|
20
22
|
|
|
21
23
|
export { parseStateMd, serializeStateMd } from './state-md/yaml-parser.mjs';
|
|
22
24
|
|
|
@@ -140,3 +142,82 @@ export function setSessionProfile(contents, profile) {
|
|
|
140
142
|
[SESSION_PROFILE_FIELD]: profile === null ? null : profile.trim(),
|
|
141
143
|
});
|
|
142
144
|
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// STATE.md frontmatter SOURCES (#1368) — `started_at` / `session-id`.
|
|
148
|
+
//
|
|
149
|
+
// These two helpers exist because the frontmatter they feed is written by the
|
|
150
|
+
// coordinator LLM from prose, and prose with no named source gets written from
|
|
151
|
+
// `new Date()` at WRITE time. `started_at` then names the moment STATE.md was
|
|
152
|
+
// created, not the moment the session began — measured 2026-09-13: 48 minutes
|
|
153
|
+
// apart. Every other producer of that timestamp (the lock's own `started_at`,
|
|
154
|
+
// `orchestrator.session.lock.acquired`, `orchestrator.session.started`) agrees
|
|
155
|
+
// within 1 ms, because all three descend from `buildLock()`. So the lock is
|
|
156
|
+
// the source, and these functions are how the template cites it.
|
|
157
|
+
//
|
|
158
|
+
// They live in this barrel rather than in `state-md/`: the barrel is the
|
|
159
|
+
// documented entry point for STATE.md work, and the edge barrel → session-lock
|
|
160
|
+
// already exists (state-md/frontmatter-mutators.mjs imports `withStateMdLock`
|
|
161
|
+
// from it), so no cycle is created — session-lock.mjs imports nothing from
|
|
162
|
+
// this module or its submodules.
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The canonical `started_at` for a STATE.md written during THIS session.
|
|
167
|
+
*
|
|
168
|
+
* Returns the live lock's `started_at`, normalised to ISO-8601 UTC ("Z"), so
|
|
169
|
+
* STATE.md, the lock, both lock events and the ledger all name the SAME
|
|
170
|
+
* instant. Falls back to `new Date().toISOString()` ONLY when no usable lock
|
|
171
|
+
* exists — a missing lock means `persistence: false`, a lock that could not be
|
|
172
|
+
* acquired, or a corrupt lock file, and in all three the writer has no better
|
|
173
|
+
* source than the present moment. The fallback is deliberately silent: it is a
|
|
174
|
+
* degradation of PRECISION, not a failure.
|
|
175
|
+
*
|
|
176
|
+
* Never throws.
|
|
177
|
+
*
|
|
178
|
+
* @param {{ repoRoot?: string }} [opts]
|
|
179
|
+
* @returns {string} ISO-8601 UTC timestamp.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveSessionStartedAt({ repoRoot } = {}) {
|
|
182
|
+
try {
|
|
183
|
+
const lock = _readLock({ repoRoot });
|
|
184
|
+
const raw = lock?.started_at;
|
|
185
|
+
if (typeof raw === 'string' && raw.length > 0) {
|
|
186
|
+
const ms = Date.parse(raw);
|
|
187
|
+
if (Number.isFinite(ms)) return new Date(ms).toISOString();
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
/* readLock is no-throw by contract; the contract is not ours to trust */
|
|
191
|
+
}
|
|
192
|
+
return new Date().toISOString();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The session ids a STATE.md writer may stamp into frontmatter: `session-id`
|
|
197
|
+
* (the RAW/native harness id) and `session` (the semantic attribution label),
|
|
198
|
+
* both read from the live lock.
|
|
199
|
+
*
|
|
200
|
+
* Both fields are `null` when no usable lock exists — ABSENCE IS NEVER
|
|
201
|
+
* COERCED, and a writer must then OMIT the key rather than write a
|
|
202
|
+
* placeholder. Neither id grants ownership of anything (see
|
|
203
|
+
* `skills/_shared/state-ownership.md`); `session-id` exists so `/close`'s #429
|
|
204
|
+
* pre-check can join STATE.md to sessions.jsonl on a native identity instead
|
|
205
|
+
* of falling through to the label + timestamp path.
|
|
206
|
+
*
|
|
207
|
+
* Never throws.
|
|
208
|
+
*
|
|
209
|
+
* @param {{ repoRoot?: string }} [opts]
|
|
210
|
+
* @returns {{ session_id: string|null, semantic_session_id: string|null }}
|
|
211
|
+
*/
|
|
212
|
+
export function resolveSessionIds({ repoRoot } = {}) {
|
|
213
|
+
const pick = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null);
|
|
214
|
+
try {
|
|
215
|
+
const lock = _readLock({ repoRoot });
|
|
216
|
+
return {
|
|
217
|
+
session_id: pick(lock?.session_id),
|
|
218
|
+
semantic_session_id: pick(lock?.semantic_session_id),
|
|
219
|
+
};
|
|
220
|
+
} catch {
|
|
221
|
+
return { session_id: null, semantic_session_id: null };
|
|
222
|
+
}
|
|
223
|
+
}
|