session-orchestrator 3.22.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -20,11 +20,15 @@
|
|
|
20
20
|
* by the time this probe fires. Gating on "no live lock" would make the
|
|
21
21
|
* banner structurally silent forever. Instead:
|
|
22
22
|
*
|
|
23
|
-
* - `lastLedgerAt` =
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* - `lastLedgerAt` = the NEWEST instant any PARSEABLE `sessions.jsonl`
|
|
24
|
+
* record proves the ledger was alive at — a genuine
|
|
25
|
+
* record contributes its `completed_at` (or, absent
|
|
26
|
+
* one, its `started_at`), a backfill stub
|
|
27
|
+
* contributes ONLY its `started_at`. See
|
|
28
|
+
* "Backfill-stub self-erasure fix" below for why the
|
|
29
|
+
* stub's `completed_at` is excluded, and "#1125 —
|
|
30
|
+
* newest-across-all, not genuine-first" for why the
|
|
31
|
+
* two kinds are maxed rather than ranked.
|
|
28
32
|
* - `cutoff` = the CURRENT session's `session.lock`
|
|
29
33
|
* `started_at` (via `readLock()`); when no lock is
|
|
30
34
|
* readable, `cutoff = now` (all events count).
|
|
@@ -58,24 +62,50 @@
|
|
|
58
62
|
* inflate `deltaHours` for perfectly healthy, promptly-closed sessions
|
|
59
63
|
* (a session's own mid-session events would newly count as "after" the
|
|
60
64
|
* anchor), reintroducing false positives on the opposite side. (a) is
|
|
61
|
-
* chosen: `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* future backfill variant drops one of them while keeping the other; OR
|
|
65
|
+
* chosen: a stub's `completed_at` is NEVER an anchor candidate. Stub
|
|
66
|
+
* recognition uses EITHER marker (OR, not AND) deliberately — both are set by
|
|
67
|
+
* the same producer today, but requiring both would silently stop matching the
|
|
68
|
+
* day a future backfill variant drops one of them while keeping the other; OR
|
|
66
69
|
* degrades gracefully (still catches it), AND does not.
|
|
67
70
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
71
|
+
* #1125 — newest-across-ALL records, not genuine-first: excluding the stub's
|
|
72
|
+
* `completed_at` (above) is correct; excluding the whole STUB from the anchor
|
|
73
|
+
* search was not. The original implementation scanned from EOF backward and
|
|
74
|
+
* returned the first GENUINE record it met, reaching the stub `started_at`
|
|
75
|
+
* fallback only when no genuine record existed ANYWHERE. That is a PRIORITY
|
|
76
|
+
* ORDER, and it lets an OLD genuine record outrank a NEWER stub: whenever the
|
|
77
|
+
* most recent ledger activity is a stub, the anchor fell back to a stale
|
|
78
|
+
* genuine `completed_at` and the reported gap ballooned by the difference.
|
|
79
|
+
* Measured in the vault repo 2026-08-22 (issue #1125): a 173h `alert` — the
|
|
80
|
+
* probe's TOP severity — against a ledger whose newest records were from the
|
|
81
|
+
* same day, contradicted by three independent counter-measurements (231 of 233
|
|
82
|
+
* records carrying `completed_at`, newest `2026-08-22T16:23Z`;
|
|
83
|
+
* `backfill-abandoned-sessions.mjs --dry-run` finding 0 to backfill; all 15
|
|
84
|
+
* sessions since 2026-08-15 closed). A permanent top-severity false alarm
|
|
85
|
+
* trains exactly the looking-away this probe exists to prevent, so it is a
|
|
86
|
+
* defect of the same class as a missed gap, not a cosmetic one.
|
|
87
|
+
*
|
|
88
|
+
* The anchor is therefore the MAXIMUM over every record's own contribution:
|
|
89
|
+
* - GENUINE record → its `completed_at`; or, when that field is absent or
|
|
90
|
+
* unparseable, its `started_at` as a floor.
|
|
91
|
+
* - BACKFILL STUB → its `started_at` ONLY (never the fabricated
|
|
92
|
+
* `completed_at` — that is the axis-(a) rule above).
|
|
93
|
+
* A stub written for an abandoned session still PROVES the ledger was alive at
|
|
94
|
+
* that instant: that session IS recorded (as abandoned), so activity around it
|
|
95
|
+
* is not evidence of an unrecorded close-through. Taking a MAX can only move
|
|
96
|
+
* the anchor FORWARD relative to the genuine-only anchor, so it never
|
|
97
|
+
* re-introduces axis (b)'s inflation of `deltaHours`.
|
|
98
|
+
*
|
|
99
|
+
* Stub-anchored reporting (deliberately NOT null): when the winning anchor is a
|
|
100
|
+
* stub's `started_at` — either because every record is a stub (no session has
|
|
101
|
+
* EVER genuinely closed, a STRONGER close-through signal than an ordinary stale
|
|
102
|
+
* ledger) or merely because the newest ledger activity happens to be a stub —
|
|
103
|
+
* the result carries `stubFallback: true` so the message can say the anchor is
|
|
104
|
+
* a stub `started_at` (grounded in the real `orchestrator.session.started`
|
|
105
|
+
* event in the common case — see `synthesizeRecord()`) rather than imply a
|
|
106
|
+
* measured close. The module's usual fail-quiet convention (null on
|
|
107
|
+
* missing/empty/ambiguous input) does not extend to "we have data but all of it
|
|
108
|
+
* is synthetic" — that state IS the failure this banner exists to catch.
|
|
79
109
|
*
|
|
80
110
|
* Severity: warn above `2 × DEFAULT_TTL_HOURS` (8h, imported from
|
|
81
111
|
* `session-lock.mjs` rather than duplicated), alert above 24h.
|
|
@@ -158,57 +188,92 @@ function isBackfillStub(record) {
|
|
|
158
188
|
}
|
|
159
189
|
|
|
160
190
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* lines (bad JSON, non-object) are skipped, not treated as fatal.
|
|
191
|
+
* Parse one record field into an anchor candidate. Returns `null` when the
|
|
192
|
+
* field is absent, not a string, or not a parseable timestamp.
|
|
164
193
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
*
|
|
194
|
+
* @param {unknown} value
|
|
195
|
+
* @returns {{iso: string, ms: number}|null}
|
|
196
|
+
*/
|
|
197
|
+
function tsCandidate(value) {
|
|
198
|
+
if (typeof value !== 'string') return null;
|
|
199
|
+
const ms = Date.parse(value);
|
|
200
|
+
return Number.isFinite(ms) ? { iso: value, ms } : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Keep whichever of the two candidates is newer. A `null` candidate never
|
|
205
|
+
* displaces a real one. On an exact tie the LATER-seen candidate wins, which
|
|
206
|
+
* (with the forward scan in `lastLedgerEntry()`) preserves the previous
|
|
207
|
+
* "nearest-EOF record wins" tie-break.
|
|
208
|
+
*
|
|
209
|
+
* @param {{iso: string, ms: number}|null} current
|
|
210
|
+
* @param {{iso: string, ms: number}|null} candidate
|
|
211
|
+
* @returns {{iso: string, ms: number}|null}
|
|
212
|
+
*/
|
|
213
|
+
function keepNewer(current, candidate) {
|
|
214
|
+
if (candidate === null) return current;
|
|
215
|
+
if (current === null) return candidate;
|
|
216
|
+
return candidate.ms >= current.ms ? candidate : current;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Scan all `sessions.jsonl` lines and return the anchor instant to measure
|
|
221
|
+
* ledger staleness against: the NEWEST instant any record proves the ledger
|
|
222
|
+
* was alive at. Malformed or non-conforming lines (bad JSON, non-object) are
|
|
223
|
+
* skipped, not treated as fatal.
|
|
224
|
+
*
|
|
225
|
+
* Which timestamp each record kind contributes, and why (full reasoning in the
|
|
226
|
+
* module header, "#1125 — newest-across-ALL records"):
|
|
227
|
+
* - GENUINE (`!isBackfillStub()`) → `completed_at`, written by the real
|
|
228
|
+
* session-end path; falling back to `started_at` when `completed_at` is
|
|
229
|
+
* absent or unparseable, so a truncated/in-flight record still contributes
|
|
230
|
+
* the coverage it does prove. Taking the newer of the two can only move the
|
|
231
|
+
* anchor forward, never backward, so it does not inflate `deltaHours`.
|
|
232
|
+
* - BACKFILL STUB → `started_at` ONLY. Its `completed_at` may be the backfill
|
|
233
|
+
* RUN's own wall-clock rather than a measurement of when the session ended
|
|
234
|
+
* (see `synthesizeRecord()`), and anchoring on that would let a backfill run
|
|
235
|
+
* retroactively erase a real multi-day gap.
|
|
236
|
+
*
|
|
237
|
+
* The two kinds are MAXED, not ranked: an older genuine record must not outrank
|
|
238
|
+
* a newer stub (that priority order was the #1125 false-alert defect). When the
|
|
239
|
+
* winning candidate is a stub's `started_at`, `stubFallback: true` is set on the
|
|
240
|
+
* result so the caller can say the anchor is a stub start, not a measured close;
|
|
241
|
+
* the key is omitted (`undefined`) on the genuine path.
|
|
176
242
|
*
|
|
177
243
|
* @param {string[]} lines
|
|
178
244
|
* @returns {{iso: string, ms: number, stubFallback?: true}|null}
|
|
179
245
|
*/
|
|
180
246
|
function lastLedgerEntry(lines) {
|
|
181
|
-
let
|
|
247
|
+
let newestGenuine = null; // newest genuine completed_at (or started_at floor)
|
|
248
|
+
let newestStub = null; // newest stub started_at
|
|
182
249
|
|
|
183
|
-
for (
|
|
250
|
+
for (const line of lines) {
|
|
184
251
|
let record;
|
|
185
252
|
try {
|
|
186
|
-
record = JSON.parse(
|
|
253
|
+
record = JSON.parse(line);
|
|
187
254
|
} catch {
|
|
188
255
|
continue;
|
|
189
256
|
}
|
|
190
257
|
if (!record || typeof record !== 'object') continue;
|
|
191
258
|
|
|
192
259
|
if (isBackfillStub(record)) {
|
|
193
|
-
|
|
194
|
-
// own wall-clock) — remember it only as a fallback candidate, and only
|
|
195
|
-
// the first (nearest-EOF, i.e. newest-by-position) one seen.
|
|
196
|
-
if (newestStub === null && typeof record.started_at === 'string') {
|
|
197
|
-
const startedMs = Date.parse(record.started_at);
|
|
198
|
-
if (Number.isFinite(startedMs)) newestStub = { iso: record.started_at, ms: startedMs };
|
|
199
|
-
}
|
|
260
|
+
newestStub = keepNewer(newestStub, tsCandidate(record.started_at));
|
|
200
261
|
continue;
|
|
201
262
|
}
|
|
202
263
|
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
264
|
+
let own = tsCandidate(record.completed_at);
|
|
265
|
+
const started = tsCandidate(record.started_at);
|
|
266
|
+
// `completed_at` is the normal anchor and wins ties; `started_at` only
|
|
267
|
+
// takes over when it is genuinely newer or `completed_at` is unusable.
|
|
268
|
+
if (started !== null && (own === null || started.ms > own.ms)) own = started;
|
|
269
|
+
newestGenuine = keepNewer(newestGenuine, own);
|
|
207
270
|
}
|
|
208
271
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
272
|
+
if (newestGenuine === null && newestStub === null) return null;
|
|
273
|
+
|
|
274
|
+
const stubWins = newestGenuine === null || (newestStub !== null && newestStub.ms > newestGenuine.ms);
|
|
275
|
+
if (stubWins) return { iso: newestStub.iso, ms: newestStub.ms, stubFallback: true };
|
|
276
|
+
return { iso: newestGenuine.iso, ms: newestGenuine.ms };
|
|
212
277
|
}
|
|
213
278
|
|
|
214
279
|
/**
|
|
@@ -282,7 +347,7 @@ function resolveCutoffMs(repoRoot, nowMs) {
|
|
|
282
347
|
* event exists, the foreign event is not after the last ledger entry, or the
|
|
283
348
|
* resulting gap is under the warn threshold. Never throws.
|
|
284
349
|
*
|
|
285
|
-
* When the anchor
|
|
350
|
+
* When the newest anchor is a backfill stub (`ledger.stubFallback`),
|
|
286
351
|
* `lastLedgerAt` is a STUB's `started_at`, not a genuine `completed_at` — the
|
|
287
352
|
* message says so explicitly rather than implying a real close was measured.
|
|
288
353
|
*
|
|
@@ -327,11 +392,13 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
|
327
392
|
|
|
328
393
|
const severity = deltaHours > ALERT_THRESHOLD_HOURS ? 'alert' : 'warn';
|
|
329
394
|
|
|
330
|
-
// stubFallback (see lastLedgerEntry()):
|
|
331
|
-
// backfill stub — ledger.iso is
|
|
332
|
-
// completed_at. Say so explicitly rather than implying a real close.
|
|
395
|
+
// stubFallback (see lastLedgerEntry()): the newest anchor in sessions.jsonl
|
|
396
|
+
// is a backfill stub — ledger.iso is that STUB's started_at, not a measured
|
|
397
|
+
// completed_at. Say so explicitly rather than implying a real close. (The
|
|
398
|
+
// wording deliberately does NOT claim "stub-only": since #1125 a stub can
|
|
399
|
+
// win the anchor while older genuine records exist.)
|
|
333
400
|
const ledgerDescription = ledger.stubFallback
|
|
334
|
-
? `
|
|
401
|
+
? `newest sessions.jsonl entry is a backfill stub — its started_at ${ledger.iso}`
|
|
335
402
|
: `last sessions.jsonl entry ${ledger.iso}`;
|
|
336
403
|
|
|
337
404
|
const base =
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
formatBlockReason,
|
|
44
44
|
resolveIssueBudgetSessionId,
|
|
45
45
|
} from './issue-budget.mjs';
|
|
46
|
+
import { resolveProjectDir } from './platform.mjs';
|
|
46
47
|
import { resolveRepoSpec } from './vcs-repo-spec.mjs';
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -83,6 +84,27 @@ function isIssueCreateArgv(cmd, args) {
|
|
|
83
84
|
return args[0] === 'issue' && (args[1] === 'create' || args[1] === 'new');
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Under vitest, a `repoRoot` that resolves to the ambient working copy is almost
|
|
89
|
+
* always the default-parameter leak (#1105): the test meant to pass a synthetic
|
|
90
|
+
* `mkdtemp` root but did not, and the issue-budget ledger would be charged in
|
|
91
|
+
* the operator's real tree. Production keeps the cwd default unchanged; this
|
|
92
|
+
* gate only fires when `process.env.VITEST` is set.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} budgetRoot — ledger root about to be passed to `chargeIssueBudget`
|
|
95
|
+
* @returns {string|null} refusal message, or null when charging may proceed
|
|
96
|
+
*/
|
|
97
|
+
function refuseAmbientLedgerUnderVitest(budgetRoot) {
|
|
98
|
+
if (!process.env.VITEST) return null;
|
|
99
|
+
if (path.resolve(budgetRoot) === path.resolve(process.cwd())) {
|
|
100
|
+
return (
|
|
101
|
+
'spiral-carryover: refusing to charge the issue-budget ledger at the ambient ' +
|
|
102
|
+
'repo root under VITEST — pass an explicit synthetic repoRoot'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
/**
|
|
87
109
|
* Run a CLI command and return { ok, stdout, stderr }. Never throws.
|
|
88
110
|
*
|
|
@@ -97,14 +119,46 @@ function isIssueCreateArgv(cmd, args) {
|
|
|
97
119
|
* exactly the session-end promise at SKILL.md:319 / :1113 being preserved. It
|
|
98
120
|
* bites for any FUTURE non-exempt producer added to this module.
|
|
99
121
|
*
|
|
122
|
+
* REPO BINDING (#1058 follow-on). The ledger root is the `repoRoot` the CALLER
|
|
123
|
+
* named — the same value that already decides the `-R` host-pinning spec a few
|
|
124
|
+
* lines down. Before this parameter existed, `runCli` re-derived it from
|
|
125
|
+
* `process.env.CLAUDE_PROJECT_DIR || process.cwd()`, so one call could file an
|
|
126
|
+
* issue into repo A (via `-R`) while charging repo B's budget ledger. Two
|
|
127
|
+
* answers to "which repo" inside one call path is the defect; the caller's
|
|
128
|
+
* answer is the authoritative one.
|
|
129
|
+
*
|
|
130
|
+
* That split brain was also a live test leak, measured 2026-08-23: a sandboxed
|
|
131
|
+
* `CLAUDE_PROJECT_DIR=$(mktemp -d) npx vitest run tests/lib/spiral-carryover.test.mjs`
|
|
132
|
+
* left `{"sessionId":"1c2e5507-…","count":0,"exempt":15}` in the sandbox — 15
|
|
133
|
+
* bookings per run, carrying the REAL session id, which under a plain
|
|
134
|
+
* `npm test` land in this repo's live issue-budget ledger (since #1141 the
|
|
135
|
+
* per-session file `.orchestrator/runtime/issue-budget/<hash>.json`; the path
|
|
136
|
+
* is owned by `budgetStateRel` in `scripts/lib/issue-budget.mjs` — no caller,
|
|
137
|
+
* including this one, spells it out).
|
|
138
|
+
*
|
|
139
|
+
* The `repoRoot`-less fallback is `resolveProjectDir()` from `platform.mjs` —
|
|
140
|
+
* the SAME resolver `hooks/pre-bash-issue-budget.mjs` uses, so both producers
|
|
141
|
+
* of this ledger agree on which file they are charging. It strictly supersedes
|
|
142
|
+
* the hand-rolled expression it replaces: env fast-path first (including the
|
|
143
|
+
* Codex/Cursor/pi variants), then a walk-up for the instruction file — `CLAUDE.md`
|
|
144
|
+
* on Claude Code, `AGENTS.md` on Codex CLI (transparent aliases) — or `.git`, then cwd.
|
|
145
|
+
*
|
|
100
146
|
* @param {string} cmd
|
|
101
147
|
* @param {string[]} args
|
|
148
|
+
* @param {string} [repoRoot] — repo whose issue-budget ledger this call is
|
|
149
|
+
* charged against. Falls back to `resolveProjectDir()` when absent.
|
|
102
150
|
* @returns {{ ok: boolean, stdout: string, stderr: string, budgetBlocked?: boolean }}
|
|
103
151
|
*/
|
|
104
|
-
function runCli(cmd, args) {
|
|
152
|
+
function runCli(cmd, args, repoRoot) {
|
|
105
153
|
if (isIssueCreateArgv(cmd, args)) {
|
|
106
154
|
try {
|
|
107
|
-
const
|
|
155
|
+
const budgetRoot = (typeof repoRoot === 'string' && repoRoot.trim())
|
|
156
|
+
? repoRoot
|
|
157
|
+
: (resolveProjectDir() || process.cwd());
|
|
158
|
+
const vitestLedgerRefusal = refuseAmbientLedgerUnderVitest(budgetRoot);
|
|
159
|
+
if (vitestLedgerRefusal) {
|
|
160
|
+
return { ok: false, stdout: '', stderr: vitestLedgerRefusal, budgetBlocked: true };
|
|
161
|
+
}
|
|
108
162
|
// The harness exports CLAUDE_CODE_SESSION_ID (measured 2026-08-21: it is
|
|
109
163
|
// present in the Bash tool environment this module runs in). There is no
|
|
110
164
|
// CLAUDE_SESSION_ID — reading that name made this whole block dead code
|
|
@@ -113,16 +167,43 @@ function runCli(cmd, args) {
|
|
|
113
167
|
let currentSession = null;
|
|
114
168
|
if (typeof nativeRawId === 'string' && nativeRawId.length > 0) {
|
|
115
169
|
try {
|
|
116
|
-
|
|
117
|
-
readFileSync(path.join(
|
|
170
|
+
const parsed = JSON.parse(
|
|
171
|
+
readFileSync(path.join(budgetRoot, '.orchestrator', 'current-session.json'), 'utf8'),
|
|
118
172
|
);
|
|
173
|
+
// SESSION BINDING (#1058). `current-session.json` is repo-global —
|
|
174
|
+
// every session sharing this working copy writes the same path — so
|
|
175
|
+
// the file in hand may describe a PEER session, not us.
|
|
176
|
+
//
|
|
177
|
+
// The binding itself is not new: `resolveIssueBudgetSessionId`
|
|
178
|
+
// already requires `currentSession.session_id === nativeRawId` before
|
|
179
|
+
// it will adopt the file's semantic label, and falls back to the raw
|
|
180
|
+
// env id otherwise. What was missing is that the mismatch was
|
|
181
|
+
// INVISIBLE — the comparison lives one module over, so a foreign file
|
|
182
|
+
// was discarded with no trace, which is the same error class as using
|
|
183
|
+
// it silently. Deciding it HERE makes the outcome identical and the
|
|
184
|
+
// reason audible; it deliberately does not restate the wider
|
|
185
|
+
// multi-id-space classifier `scripts/lib/quality-gate.mjs` needs
|
|
186
|
+
// (that reader has no raw env id guaranteed in hand — this one does,
|
|
187
|
+
// by the `typeof nativeRawId === 'string'` guard above).
|
|
188
|
+
const foreignId = typeof parsed?.session_id === 'string' && parsed.session_id.length > 0
|
|
189
|
+
? parsed.session_id
|
|
190
|
+
: null;
|
|
191
|
+
if (foreignId !== null && foreignId !== nativeRawId) {
|
|
192
|
+
process.stderr.write(
|
|
193
|
+
'⚠️ spiral-carryover: .orchestrator/current-session.json belongs to another ' +
|
|
194
|
+
`session (${foreignId}) — using the native session id for issue-budget ` +
|
|
195
|
+
'accounting. Another session is active in this working copy (PSA-001).\n',
|
|
196
|
+
);
|
|
197
|
+
} else {
|
|
198
|
+
currentSession = parsed;
|
|
199
|
+
}
|
|
119
200
|
} catch {
|
|
120
201
|
// Missing or malformed records conservatively retain the native env key.
|
|
121
202
|
}
|
|
122
203
|
}
|
|
123
204
|
const titleIdx = args.indexOf('--title');
|
|
124
205
|
const verdict = chargeIssueBudget({
|
|
125
|
-
repoRoot,
|
|
206
|
+
repoRoot: budgetRoot,
|
|
126
207
|
sessionId: resolveIssueBudgetSessionId(nativeRawId, currentSession),
|
|
127
208
|
command: [cmd, ...args].join(' '),
|
|
128
209
|
title: titleIdx >= 0 ? (args[titleIdx + 1] ?? null) : null,
|
|
@@ -211,7 +292,7 @@ async function findExistingLabeledIssue({
|
|
|
211
292
|
'number,url,body',
|
|
212
293
|
];
|
|
213
294
|
if (spec) args.push('-R', spec);
|
|
214
|
-
const res = runCli('gh', args);
|
|
295
|
+
const res = runCli('gh', args, repoRoot);
|
|
215
296
|
if (!res.ok) return { exists: false };
|
|
216
297
|
let arr;
|
|
217
298
|
try {
|
|
@@ -231,7 +312,7 @@ async function findExistingLabeledIssue({
|
|
|
231
312
|
// Default: gitlab via glab.
|
|
232
313
|
const args = ['issue', 'list', '--label', label, '--per-page', '100', '--output', 'json'];
|
|
233
314
|
if (spec) args.push('-R', spec);
|
|
234
|
-
const res = runCli('glab', args);
|
|
315
|
+
const res = runCli('glab', args, repoRoot);
|
|
235
316
|
if (!res.ok) return { exists: false };
|
|
236
317
|
let arr;
|
|
237
318
|
try {
|
|
@@ -420,7 +501,7 @@ export async function createSpiralCarryoverIssue({
|
|
|
420
501
|
}
|
|
421
502
|
if (spec) args.push('-R', spec);
|
|
422
503
|
|
|
423
|
-
const res = runCli(cmd, args);
|
|
504
|
+
const res = runCli(cmd, args, repoRoot);
|
|
424
505
|
if (!res.ok) {
|
|
425
506
|
return { created: false, skipped: 'error', error: res.stderr.trim() || 'CLI invocation failed' };
|
|
426
507
|
}
|
|
@@ -609,7 +690,7 @@ export async function createBrokenWindowIssue({
|
|
|
609
690
|
}
|
|
610
691
|
if (spec) args.push('-R', spec);
|
|
611
692
|
|
|
612
|
-
const res = runCli(cmd, args);
|
|
693
|
+
const res = runCli(cmd, args, repoRoot);
|
|
613
694
|
if (!res.ok) {
|
|
614
695
|
return { created: false, skipped: 'error', error: res.stderr.trim() || 'CLI invocation failed' };
|
|
615
696
|
}
|
|
@@ -239,11 +239,20 @@ function extractFrontmatterBlock(text) {
|
|
|
239
239
|
*
|
|
240
240
|
* Scoped to the frontmatter block only (see the "Frontmatter-safe round-trip
|
|
241
241
|
* guard" comment above `DEFAULT_STATE_MD_SIZE_CEILING_BYTES` for the scoping
|
|
242
|
-
* rationale).
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
242
|
+
* rationale).
|
|
243
|
+
*
|
|
244
|
+
* `parseStateMd(after) === null` has TWO causes and they get OPPOSITE verdicts,
|
|
245
|
+
* because collapsing them is what made this guard blind:
|
|
246
|
+
* - NO frontmatter fence in `after` → SAFE. There is nothing frontmatter-shaped
|
|
247
|
+
* to verify, and non-STATE.md-shaped writes (arbitrary test fixtures, plain
|
|
248
|
+
* body text) stay ungated, matching `evaluateSizeCeiling`'s content-agnostic
|
|
249
|
+
* posture for that case.
|
|
250
|
+
* - A fence IS present but its contents do not parse → UNSAFE. A write whose
|
|
251
|
+
* frontmatter this repo's own parser cannot read back is precisely the
|
|
252
|
+
* corruption class this guard exists to refuse; treating it as "nothing to
|
|
253
|
+
* verify" let the single worst input pass unchecked (the 2026-08-24 #1111
|
|
254
|
+
* regression, where one flow item nulled the whole document, sat in exactly
|
|
255
|
+
* this hole).
|
|
247
256
|
*
|
|
248
257
|
* Never throws — mirrors the never-throw contract of yaml-parser.mjs.
|
|
249
258
|
*
|
|
@@ -251,12 +260,20 @@ function extractFrontmatterBlock(text) {
|
|
|
251
260
|
* @returns {{ unsafe: boolean, reason: string|null }}
|
|
252
261
|
*/
|
|
253
262
|
export function evaluateFrontmatterSafe(after) {
|
|
263
|
+
const beforeBlock = extractFrontmatterBlock(after);
|
|
254
264
|
const parsed = parseStateMd(after);
|
|
255
265
|
if (parsed === null) {
|
|
256
|
-
|
|
266
|
+
if (beforeBlock === null) {
|
|
267
|
+
return { unsafe: false, reason: null };
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
unsafe: true,
|
|
271
|
+
reason:
|
|
272
|
+
`frontmatter block is present but parseStateMd could not read it back ` +
|
|
273
|
+
`(${Buffer.byteLength(beforeBlock, 'utf8')}B unparseable)`,
|
|
274
|
+
};
|
|
257
275
|
}
|
|
258
276
|
const reserialized = serializeStateMd(parsed);
|
|
259
|
-
const beforeBlock = extractFrontmatterBlock(after);
|
|
260
277
|
const afterBlock = extractFrontmatterBlock(reserialized);
|
|
261
278
|
if (beforeBlock !== afterBlock) {
|
|
262
279
|
return {
|
|
@@ -320,7 +337,10 @@ function writeFileAtomic(filePath, contents) {
|
|
|
320
337
|
* deliberately left unshipped when issue #739 first landed, because the
|
|
321
338
|
* yaml-parser asymmetry it exists to catch made it false-positive on ordinary
|
|
322
339
|
* content at the time) — #747 closed that asymmetry in yaml-parser.mjs, so
|
|
323
|
-
* the check is safe to enforce.
|
|
340
|
+
* the check is safe to enforce. A frontmatter fence that is present but does
|
|
341
|
+
* NOT parse back is refused by the same guard (see `evaluateFrontmatterSafe`);
|
|
342
|
+
* only content with no fence at all leaves it inert. It catches FUTURE
|
|
343
|
+
* serializer/parser drift
|
|
324
344
|
* that reintroduces a non-idempotent frontmatter round-trip — the exact
|
|
325
345
|
* incident class behind #739 — rather than the symptom (file size) the
|
|
326
346
|
* size-ceiling guard was limited to. Same non-throw, WARN-then-refuse
|
|
@@ -395,6 +415,19 @@ export async function writeStateMd(repoRoot, transformer, opts = {}) {
|
|
|
395
415
|
return { written: false, path: statePath, contents: before, reason: 'frontmatter-unsafe' };
|
|
396
416
|
}
|
|
397
417
|
|
|
418
|
+
// A list item the parser cannot represent is dropped on READ (see
|
|
419
|
+
// yaml-parser.mjs `parseBlockValue`), so this write is what makes the drop
|
|
420
|
+
// permanent on disk. Dropping is the right call — the alternatives are a
|
|
421
|
+
// mangled key or a dead no-op — but it must not be SILENT, which is the
|
|
422
|
+
// whole complaint the per-item scoping answers. Name the items, then write
|
|
423
|
+
// anyway: blocking here would re-create the no-op this fix removed.
|
|
424
|
+
const droppedItems = parseStateMd(before)?.warnings;
|
|
425
|
+
if (droppedItems !== undefined) {
|
|
426
|
+
process.stderr.write(
|
|
427
|
+
`⚠ writeStateMd: ${statePath} — dropping ${droppedItems.length} unrepresentable list item(s): ` +
|
|
428
|
+
`${droppedItems.map((w) => `${w.key}[${w.index}] (${w.reason})`).join(', ')}\n`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
398
431
|
writeFileAtomic(statePath, after);
|
|
399
432
|
return { written: true, path: statePath, contents: after };
|
|
400
433
|
},
|