session-orchestrator 3.16.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope-baseline.mjs — Scope Governor: baseline-freeze + drift-tripwire module.
|
|
3
|
+
*
|
|
4
|
+
* Freezes a per-session "scope baseline" into STATE.md frontmatter (five
|
|
5
|
+
* flat keys) exactly once per session, reads it back, and computes a
|
|
6
|
+
* warn-only drift ratio against it. This is the mechanical half of the
|
|
7
|
+
* Scope Governor's session-scoping self-validation (PRD "Scope Governor —
|
|
8
|
+
* Loop-Termination über Scope statt über Retry-Cap",
|
|
9
|
+
* docs/prd/2026-07-25-scope-governor.md, Epic #894).
|
|
10
|
+
*
|
|
11
|
+
* SCOPE: this module implements `writeBaseline()`, `readBaseline()`, the
|
|
12
|
+
* shared `DRIFT_EXCLUDE_PATTERNS` constant (issue #895 / S1), and
|
|
13
|
+
* `computeDrift()` — the 2x-tripwire drift calculation that consumes
|
|
14
|
+
* `DRIFT_EXCLUDE_PATTERNS` from both sides of the ratio (issue #896 / S2,
|
|
15
|
+
* delivered in this revision). The two distinguishable non-baseline
|
|
16
|
+
* `readBaseline()` return shapes (`null` vs `{ stale: true, ... }`) exist
|
|
17
|
+
* specifically so `computeDrift()` can pick its skip-reason additively
|
|
18
|
+
* without re-parsing STATE.md a second time: both functions share the
|
|
19
|
+
* internal `readFrontmatterOrReason()` + `classifyBaselineFromFrontmatter()`
|
|
20
|
+
* helpers below, so a single `computeDrift()` call performs exactly ONE
|
|
21
|
+
* `readFileSync`/`parseStateMd` pass over STATE.md.
|
|
22
|
+
*
|
|
23
|
+
* ---------------------------------------------------------------------
|
|
24
|
+
* Session-scoping semantics (the load-bearing part).
|
|
25
|
+
* ---------------------------------------------------------------------
|
|
26
|
+
* The canonical frontmatter key is `session` — NOT `session-id`. See
|
|
27
|
+
* skills/_shared/state-ownership.md:28 ("optional... writers SHOULD
|
|
28
|
+
* populate these fields but readers MUST tolerate their absence"). This
|
|
29
|
+
* module reads `frontmatter.session` directly; it does NOT call
|
|
30
|
+
* `parseSessionId(repoRoot)` — that function takes a session-ID STRING and
|
|
31
|
+
* performs no I/O (scripts/lib/session-id.mjs:21/200, "pure synchronous
|
|
32
|
+
* function — no I/O, no side effects"). The frontmatter read lives in that
|
|
33
|
+
* module's PRIVATE, unexported `readSessionIdFromStateMd()` helper, not in
|
|
34
|
+
* `parseSessionId()` itself.
|
|
35
|
+
*
|
|
36
|
+
* Three rules govern `scope-baseline-session` vs. the current `session`:
|
|
37
|
+
* 1. No `scope-baseline-session` key present at all → this STATE.md has
|
|
38
|
+
* never had a baseline frozen. Write unconditionally — even when the
|
|
39
|
+
* current `session` field is itself absent (freezes `null`).
|
|
40
|
+
* 2. A `scope-baseline-session` key IS present (its stored value may
|
|
41
|
+
* itself be `null`, from rule 1) → compare it against the current
|
|
42
|
+
* `session` value via plain equality on normalised values:
|
|
43
|
+
* - equal (this also covers BOTH sides being `null` — a missing
|
|
44
|
+
* optional `session` field must never render the tripwire
|
|
45
|
+
* permanently inert by making every comparison look "stale") →
|
|
46
|
+
* MATCH → `{ written: false, reason: 'already-frozen' }`, no
|
|
47
|
+
* mutation.
|
|
48
|
+
* - different → the stored baseline belongs to an earlier session →
|
|
49
|
+
* STALE → fully overwritten, `{ written: true }`.
|
|
50
|
+
*
|
|
51
|
+
* ---------------------------------------------------------------------
|
|
52
|
+
* Fail-open asymmetry — deliberate exception to a documented module
|
|
53
|
+
* convention (naming it explicitly so a future reader does not "fix" it
|
|
54
|
+
* away as an oversight).
|
|
55
|
+
* ---------------------------------------------------------------------
|
|
56
|
+
* `getEnforcementLevel()` and `gateEnabled()` (scope-gate.mjs:50-57 / :68-79)
|
|
57
|
+
* both fall toward ENFORCEMENT on unreadable data, and
|
|
58
|
+
* `assertFileScopeSubset()` documents "Fail-closed & no-throw (module
|
|
59
|
+
* convention)" at scope-gate.mjs:195-197. THIS module inverts that: on
|
|
60
|
+
* unreadable or missing STATE.md, both `writeBaseline()` and
|
|
61
|
+
* `readBaseline()` fail OPEN (a skip-ish result, never a throw, never a
|
|
62
|
+
* denial). Rationale: a *blocking* gate must be suspicious of unreadable
|
|
63
|
+
* data and fail toward the restrictive side; a *warning* signal — this
|
|
64
|
+
* module feeds the S2 warn-only drift tripwire, never a Deny — must stay
|
|
65
|
+
* SILENT on unreadable data instead, because a WARN fired from
|
|
66
|
+
* corrupt/missing input is noise that erodes the guard's credibility faster
|
|
67
|
+
* than a missed drift warning ever would. Do not unify this back to
|
|
68
|
+
* fail-closed. `computeDrift()` follows the SAME asymmetry: every
|
|
69
|
+
* unreadable/missing/stale/unresolvable condition is a silent `skipped`
|
|
70
|
+
* result, never a WARN and never a thrown error.
|
|
71
|
+
*
|
|
72
|
+
* ---------------------------------------------------------------------
|
|
73
|
+
* Sync/async rule.
|
|
74
|
+
* ---------------------------------------------------------------------
|
|
75
|
+
* `readBaseline()` and `computeDrift()` are SYNC (`readFileSync` +, for
|
|
76
|
+
* `computeDrift()`, `execFileSync('git', ...)` only) — pure read paths, kept
|
|
77
|
+
* consistent with the scope-gate.mjs all-sync hook-hot-path convention.
|
|
78
|
+
* `writeBaseline()` is the ONLY async export in this module, and ONLY
|
|
79
|
+
* because `writeStateMd()` (scripts/lib/state-md/frontmatter-mutators.mjs:348,
|
|
80
|
+
* re-exported from scripts/lib/state-md.mjs) is itself async — it takes the
|
|
81
|
+
* STATE.md lock (`withStateMdLock()`) INTERNALLY, so `writeBaseline()` MUST
|
|
82
|
+
* NOT wrap its own call in a second `withStateMdLock()` (that would deadlock
|
|
83
|
+
* on the lockfile). Routing through `writeStateMd()` instead of a private
|
|
84
|
+
* lock+read+write sequence also gets `writeBaseline()` two guards for free:
|
|
85
|
+
* a size-ceiling check and a frontmatter-round-trip-safety check (both
|
|
86
|
+
* documented at frontmatter-mutators.mjs:307-328) — a breach of either
|
|
87
|
+
* REFUSES the write (leaves STATE.md untouched) and surfaces as
|
|
88
|
+
* `{ written: false, reason: 'size-ceiling' | 'frontmatter-unsafe' }`
|
|
89
|
+
* instead of silently bypassing the guard the way the old private
|
|
90
|
+
* `writeStateMdAtomic()` helper did.
|
|
91
|
+
*
|
|
92
|
+
* `writeStateMd()` never throws by contract EXCEPT a `TypeError` when its
|
|
93
|
+
* transformer returns a non-string/non-null/non-undefined value — this
|
|
94
|
+
* module's transformer always returns a string or `null`. As of issue #903
|
|
95
|
+
* the `plannedFiles` argument is validated BEFORE `writeStateMd()` is ever
|
|
96
|
+
* called (see `writeBaseline()`'s param doc) — an invalid shape now short-
|
|
97
|
+
* circuits with `{ written: false, reason: 'invalid-planned-files' }`
|
|
98
|
+
* without reading STATE.md or taking the lock at all, so the transformer
|
|
99
|
+
* itself no longer has a `plannedFiles`-shape failure mode to throw from.
|
|
100
|
+
* The underlying `withStateMdLock()` THROWS a labelled Error on acquire
|
|
101
|
+
* failure BY DESIGN (state-md-lock.mjs:265-270: "throws a labelled Error so
|
|
102
|
+
* callers see the failure as an exception rather than a silent {ok:false}
|
|
103
|
+
* return", throw site :328, tagging `err.code` as `STATE_LOCK_TIMEOUT` or
|
|
104
|
+
* `STATE_LOCK_FS_ERROR`). This module's contract is "never throw" —
|
|
105
|
+
* `writeBaseline()` therefore wraps the `writeStateMd()` call in try/catch
|
|
106
|
+
* and maps the escaping error's `err.code` HONESTLY instead of collapsing
|
|
107
|
+
* every failure into one reason (#894 review finding F4 — a disk-full /
|
|
108
|
+
* permission failure on the lock directory must not misdirect the operator
|
|
109
|
+
* toward "retry, it's contention"):
|
|
110
|
+
* `STATE_LOCK_TIMEOUT` → `'lock-timeout'`, `STATE_LOCK_FS_ERROR` →
|
|
111
|
+
* `'lock-fs-error'`, anything else → `'unexpected-error'` (this branch has
|
|
112
|
+
* no currently-reachable trigger post-#903 but stays as a defensive
|
|
113
|
+
* catch-all).
|
|
114
|
+
*
|
|
115
|
+
* `branch` and `session-start-ref` are READ from the existing frontmatter —
|
|
116
|
+
* this module never writes or duplicates them (they are already session-
|
|
117
|
+
* pinned values STATE.md carries independently of this feature).
|
|
118
|
+
*
|
|
119
|
+
* No I/O at import time. Node 20+ stdlib only.
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
import { readFileSync } from 'node:fs';
|
|
123
|
+
import { execFileSync } from 'node:child_process';
|
|
124
|
+
|
|
125
|
+
import { parseStateMd, serializeStateMd, resolveStateMdPath, writeStateMd } from './state-md.mjs';
|
|
126
|
+
import { pathMatchesPattern } from './scope-gate.mjs';
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// DRIFT_EXCLUDE_PATTERNS — the SINGLE filter source for both the denominator
|
|
130
|
+
// and the numerator, applied through the SAME internal `filterExcluded()`
|
|
131
|
+
// helper below (#894 review finding F1 — the denominator used to be filtered
|
|
132
|
+
// only in PROSE, by the coordinator, before calling `writeBaseline()`; a
|
|
133
|
+
// prose instruction is not a code path, so a coordinator that forgot to
|
|
134
|
+
// filter silently broke the ratio). `writeBaseline()` now filters the
|
|
135
|
+
// denominator itself, in code, whenever its `plannedFiles` argument is the
|
|
136
|
+
// RAW array of planned file paths (`countPlannedFiles()`, exported below —
|
|
137
|
+
// the ONLY accepted call shape as of issue #903, which REMOVED the
|
|
138
|
+
// previously-accepted "already counted, store verbatim" plain-number
|
|
139
|
+
// back-compat shape: it was an unverified re-entry vector for exactly the
|
|
140
|
+
// F1 bug this filter exists to prevent — a caller could hand
|
|
141
|
+
// `writeBaseline()` an unfiltered count with no `filterExcluded()` pass at
|
|
142
|
+
// all). `computeDrift()` filters the numerator at measure time by running
|
|
143
|
+
// the live `git diff --name-only` output through the same
|
|
144
|
+
// `filterExcluded()` helper.
|
|
145
|
+
// Deliberately narrows skills/session-end/plan-verification.md:42-45's
|
|
146
|
+
// per-session-state exclusion from "the whole .claude/ directory" down to
|
|
147
|
+
// just the session ARTEFACTS — `.claude/rules/**` stays COUNTED because a
|
|
148
|
+
// rule file is a deliverable of this very epic, not a session artefact.
|
|
149
|
+
// plan-verification.md:43 (test-file reclassification) is a bucket
|
|
150
|
+
// RECLASSIFY — the file stays counted, never excluded — so it is NOT
|
|
151
|
+
// mirrored here; :44 (generated/lock) and :45 (per-session state, narrowed)
|
|
152
|
+
// are true exclusions and ARE mirrored below.
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
export const DRIFT_EXCLUDE_PATTERNS = [
|
|
155
|
+
// Tests — excluded on BOTH sides (deliberately NOT "reclassified" the way
|
|
156
|
+
// plan-verification.md:43 does).
|
|
157
|
+
'**/*.test.*', '**/*.spec.*', '**/__tests__/**',
|
|
158
|
+
// Generated / lock (covers plan-verification.md:44).
|
|
159
|
+
'package-lock.json', 'pnpm-lock.yaml', '*.lock', 'dist/**', 'node_modules/**',
|
|
160
|
+
// Per-session state — deliberately NARROWS plan-verification.md:45 to the
|
|
161
|
+
// ARTEFACTS instead of the whole directory. `.claude/rules/**` stays
|
|
162
|
+
// COUNTED: a rule file is a deliverable, not a session artefact.
|
|
163
|
+
'.claude/STATE.md', '.claude/wave-scope.json', '.claude/metrics/**',
|
|
164
|
+
'.codex/STATE.md', '.codex/wave-scope.json', '.codex/metrics/**',
|
|
165
|
+
'.cursor/STATE.md', '.cursor/wave-scope.json', '.cursor/metrics/**',
|
|
166
|
+
'.pi/STATE.md', '.pi/wave-scope.json', '.pi/metrics/**',
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Internal helpers
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Current time as an ISO-8601 UTC string.
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function nowIso() {
|
|
178
|
+
return new Date().toISOString();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Normalise a raw frontmatter session value for comparison: a non-empty
|
|
183
|
+
* string is kept as-is; anything else (undefined, `null`, or any
|
|
184
|
+
* non-string scalar the YAML parser could in principle produce) collapses
|
|
185
|
+
* to `null`. This is what makes "both sides absent" compare equal via a
|
|
186
|
+
* plain `===` at the call sites — no special-casing needed there.
|
|
187
|
+
* @param {unknown} v
|
|
188
|
+
* @returns {string|null}
|
|
189
|
+
*/
|
|
190
|
+
function normalizeSessionValue(v) {
|
|
191
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Round to 2 decimal places. Plain `Math.round` scaling — good enough for a
|
|
196
|
+
* human-facing ratio, not a precision-sensitive computation.
|
|
197
|
+
* @param {number} n
|
|
198
|
+
* @returns {number}
|
|
199
|
+
*/
|
|
200
|
+
function round2(n) {
|
|
201
|
+
return Math.round(n * 100) / 100;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Filter a raw file-path list through `DRIFT_EXCLUDE_PATTERNS`. THE shared
|
|
206
|
+
* filter primitive (#894 review finding F1) — both `computeDrift()`'s
|
|
207
|
+
* numerator (below) and `writeBaseline()`'s denominator (via
|
|
208
|
+
* `countPlannedFiles()`/`filterPlannedFiles()`, exported below) call this
|
|
209
|
+
* SAME function. No other code path in this module is allowed to
|
|
210
|
+
* re-implement the `DRIFT_EXCLUDE_PATTERNS` filter loop.
|
|
211
|
+
* @param {string[]} files
|
|
212
|
+
* @returns {string[]}
|
|
213
|
+
*/
|
|
214
|
+
function filterExcluded(files) {
|
|
215
|
+
return files.filter(
|
|
216
|
+
(f) => !DRIFT_EXCLUDE_PATTERNS.some((pattern) => pathMatchesPattern(f, pattern))
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Internal: read + parse STATE.md's frontmatter exactly once. Shared by
|
|
222
|
+
* `readBaseline()` and `computeDrift()` so a single `computeDrift()` call
|
|
223
|
+
* never re-parses the same file twice (see module docstring § SCOPE).
|
|
224
|
+
*
|
|
225
|
+
* @param {string|undefined} repoRoot
|
|
226
|
+
* @returns {{ ok: true, fm: object } | { ok: false, reason: 'no-state-md'|'unreadable-state-md' }}
|
|
227
|
+
*/
|
|
228
|
+
function readFrontmatterOrReason(repoRoot) {
|
|
229
|
+
const statePath = resolveStateMdPath(repoRoot);
|
|
230
|
+
let raw;
|
|
231
|
+
try {
|
|
232
|
+
raw = readFileSync(statePath, 'utf8');
|
|
233
|
+
} catch {
|
|
234
|
+
// No STATE.md at all (e.g. persistence: false, or never yet written).
|
|
235
|
+
return { ok: false, reason: 'no-state-md' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const parsed = parseStateMd(raw);
|
|
239
|
+
if (parsed === null) {
|
|
240
|
+
// Malformed/unparseable frontmatter — fail-open (see module docstring).
|
|
241
|
+
return { ok: false, reason: 'unreadable-state-md' };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { ok: true, fm: parsed.frontmatter };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Internal: classify a scope baseline from ALREADY-PARSED frontmatter.
|
|
249
|
+
* Mirrors `readBaseline()`'s public contract exactly (same three shapes) —
|
|
250
|
+
* extracted so `computeDrift()` can reuse the identical classification
|
|
251
|
+
* logic against the SAME parsed frontmatter object `readFrontmatterOrReason()`
|
|
252
|
+
* already produced, instead of re-parsing STATE.md a second time.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} fm — already-parsed STATE.md frontmatter
|
|
255
|
+
* @returns {null
|
|
256
|
+
* | { stale: true, baselineSession: string|null, currentSession: string|null }
|
|
257
|
+
* | { intent: unknown, ownerBoundary: unknown, plannedFiles: unknown,
|
|
258
|
+
* session: string|null, frozenAt: unknown, branch: unknown,
|
|
259
|
+
* sessionStartRef: unknown }}
|
|
260
|
+
*/
|
|
261
|
+
function classifyBaselineFromFrontmatter(fm) {
|
|
262
|
+
if (!Object.prototype.hasOwnProperty.call(fm, 'scope-baseline-session')) {
|
|
263
|
+
return null; // no baseline has ever been frozen
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const baselineSession = normalizeSessionValue(fm['scope-baseline-session']);
|
|
267
|
+
const currentSession = normalizeSessionValue(fm.session);
|
|
268
|
+
|
|
269
|
+
if (baselineSession !== currentSession) {
|
|
270
|
+
return { stale: true, baselineSession, currentSession };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
intent: fm['scope-baseline-intent'] ?? null,
|
|
275
|
+
ownerBoundary: fm['scope-baseline-owner-boundary'] ?? null,
|
|
276
|
+
plannedFiles: fm['scope-baseline-planned-files'] ?? null,
|
|
277
|
+
session: baselineSession,
|
|
278
|
+
frozenAt: fm['scope-baseline-frozen-at'] ?? null,
|
|
279
|
+
branch: fm.branch ?? null,
|
|
280
|
+
sessionStartRef: fm['session-start-ref'] ?? null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Public API
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Filter a RAW file-path list (e.g. the union of declared agent file scopes
|
|
290
|
+
* for a session) through `DRIFT_EXCLUDE_PATTERNS`, returning the surviving
|
|
291
|
+
* paths. This is the denominator half of the #894 review finding F1 fix —
|
|
292
|
+
* `writeBaseline()` calls `countPlannedFiles()` (below), which wraps this
|
|
293
|
+
* function, whenever its `plannedFiles` argument is an array. Exported
|
|
294
|
+
* directly for callers that want the filtered LIST rather than just the
|
|
295
|
+
* count (e.g. for logging which planned files were excluded).
|
|
296
|
+
* @param {string[]} files
|
|
297
|
+
* @returns {string[]}
|
|
298
|
+
*/
|
|
299
|
+
export function filterPlannedFiles(files) {
|
|
300
|
+
return filterExcluded(files);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Filter a RAW file-path list through `DRIFT_EXCLUDE_PATTERNS` and return
|
|
305
|
+
* the surviving count — the preferred way to compute `writeBaseline()`'s
|
|
306
|
+
* `plannedFiles` denominator. Calls the exact same `filterExcluded()`
|
|
307
|
+
* primitive `computeDrift()`'s numerator uses, so both sides of the S2
|
|
308
|
+
* drift ratio are provably produced by one function (#894 review finding
|
|
309
|
+
* F1).
|
|
310
|
+
* @param {string[]} files
|
|
311
|
+
* @returns {number}
|
|
312
|
+
*/
|
|
313
|
+
export function countPlannedFiles(files) {
|
|
314
|
+
return filterPlannedFiles(files).length;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Read the scope baseline from STATE.md frontmatter, without mutating
|
|
319
|
+
* anything. Sync, never throws.
|
|
320
|
+
*
|
|
321
|
+
* @param {string|undefined} repoRoot
|
|
322
|
+
* @returns {null
|
|
323
|
+
* | { stale: true, baselineSession: string|null, currentSession: string|null }
|
|
324
|
+
* | { intent: unknown, ownerBoundary: unknown, plannedFiles: unknown,
|
|
325
|
+
* session: string|null, frozenAt: unknown, branch: unknown,
|
|
326
|
+
* sessionStartRef: unknown }}
|
|
327
|
+
* `null` — no STATE.md, unreadable/malformed frontmatter, or no baseline
|
|
328
|
+
* has ever been frozen. `{ stale: true, ... }` — a baseline exists but
|
|
329
|
+
* belongs to a different session than the current `session` field.
|
|
330
|
+
* Otherwise the baseline object itself.
|
|
331
|
+
*/
|
|
332
|
+
export function readBaseline(repoRoot) {
|
|
333
|
+
const result = readFrontmatterOrReason(repoRoot);
|
|
334
|
+
if (!result.ok) return null;
|
|
335
|
+
return classifyBaselineFromFrontmatter(result.fm);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Freeze (or refresh, when the stored baseline is stale) the scope baseline
|
|
340
|
+
* into STATE.md frontmatter. Writes exactly five flat keys:
|
|
341
|
+
* `scope-baseline-intent`, `scope-baseline-owner-boundary`,
|
|
342
|
+
* `scope-baseline-planned-files`, `scope-baseline-session`,
|
|
343
|
+
* `scope-baseline-frozen-at`. `branch` and `session-start-ref` are read
|
|
344
|
+
* from the existing frontmatter, never written by this function.
|
|
345
|
+
*
|
|
346
|
+
* Routes its write through `writeStateMd()` (scripts/lib/state-md.mjs) —
|
|
347
|
+
* see module docstring § Sync/async rule for why this is the ONLY async
|
|
348
|
+
* export and why it must NOT nest its own `withStateMdLock()` call.
|
|
349
|
+
* Never throws.
|
|
350
|
+
*
|
|
351
|
+
* @param {object} args
|
|
352
|
+
* @param {string|undefined} args.repoRoot
|
|
353
|
+
* @param {string} args.intent
|
|
354
|
+
* @param {string} args.ownerBoundary
|
|
355
|
+
* @param {string[]} args.plannedFiles — the RAW array of planned file paths
|
|
356
|
+
* (e.g. the union of declared agent file scopes for the session) —
|
|
357
|
+
* filtered internally via `countPlannedFiles()`, which calls the SAME
|
|
358
|
+
* `filterExcluded()` helper `computeDrift()`'s numerator uses (see the
|
|
359
|
+
* `DRIFT_EXCLUDE_PATTERNS` doc comment above and #894 review finding F1).
|
|
360
|
+
* MUST be an array. Issue #903 REMOVED the previously-accepted
|
|
361
|
+
* "already-counted plain number" back-compat shape: it was an unverified
|
|
362
|
+
* re-entry vector for exactly the F1 bug the filtering above exists to
|
|
363
|
+
* prevent (a caller could hand this function an unfiltered count with no
|
|
364
|
+
* `filterExcluded()` pass at all). Anything that is not an array is
|
|
365
|
+
* rejected UP FRONT — before STATE.md is read or the lock is taken —
|
|
366
|
+
* as `{ written: false, reason: 'invalid-planned-files' }`.
|
|
367
|
+
* @returns {Promise<{ written: boolean, reason?: 'invalid-planned-files'|'already-frozen'|'no-state-md'|'unreadable-state-md'|'lock-timeout'|'lock-fs-error'|'unexpected-error'|'size-ceiling'|'frontmatter-unsafe' }>}
|
|
368
|
+
*/
|
|
369
|
+
export async function writeBaseline({ repoRoot, intent, ownerBoundary, plannedFiles } = {}) {
|
|
370
|
+
if (!Array.isArray(plannedFiles)) {
|
|
371
|
+
// #903 — the plain-number back-compat shape is REMOVED, not merely
|
|
372
|
+
// undocumented: it let a caller bypass `filterExcluded()` entirely by
|
|
373
|
+
// pre-counting (possibly unfiltered) and storing verbatim. Reject up
|
|
374
|
+
// front — no STATE.md read, no lock ever taken.
|
|
375
|
+
return { written: false, reason: 'invalid-planned-files' };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let skipReason;
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const result = await writeStateMd(repoRoot, (before) => {
|
|
382
|
+
// `writeStateMd()` passes an empty string when STATE.md does not
|
|
383
|
+
// exist on disk (frontmatter-mutators.mjs:304-305) — that IS the
|
|
384
|
+
// "no-state-md" signal at this layer.
|
|
385
|
+
if (before === '') {
|
|
386
|
+
skipReason = 'no-state-md';
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const parsed = parseStateMd(before);
|
|
391
|
+
if (parsed === null) {
|
|
392
|
+
skipReason = 'unreadable-state-md';
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const fm = parsed.frontmatter;
|
|
397
|
+
const currentSession = normalizeSessionValue(fm.session);
|
|
398
|
+
const hasBaseline = Object.prototype.hasOwnProperty.call(fm, 'scope-baseline-session');
|
|
399
|
+
|
|
400
|
+
if (hasBaseline) {
|
|
401
|
+
const baselineSession = normalizeSessionValue(fm['scope-baseline-session']);
|
|
402
|
+
if (baselineSession === currentSession) {
|
|
403
|
+
// Same session already froze a baseline (or both are absent,
|
|
404
|
+
// which counts as a match per rule 2 above) — reject, no mutation.
|
|
405
|
+
skipReason = 'already-frozen';
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
// Different session — the stored baseline is stale. Fall through
|
|
409
|
+
// and overwrite all five keys below.
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Resolve the denominator (#894 review finding F1, tightened by
|
|
413
|
+
// #903): `plannedFiles` is GUARANTEED to be an array here — the outer
|
|
414
|
+
// `writeBaseline()` already rejected anything else (see the
|
|
415
|
+
// `Array.isArray()` guard above) before this transformer ever ran.
|
|
416
|
+
// Filter it via `countPlannedFiles()` — the SAME `filterExcluded()`
|
|
417
|
+
// primitive `computeDrift()`'s numerator uses below, so both sides of
|
|
418
|
+
// the ratio are provably produced by one function.
|
|
419
|
+
const resolvedPlannedFiles = countPlannedFiles(plannedFiles);
|
|
420
|
+
|
|
421
|
+
fm['scope-baseline-intent'] = intent;
|
|
422
|
+
fm['scope-baseline-owner-boundary'] = ownerBoundary;
|
|
423
|
+
fm['scope-baseline-planned-files'] = resolvedPlannedFiles;
|
|
424
|
+
fm['scope-baseline-session'] = currentSession;
|
|
425
|
+
fm['scope-baseline-frozen-at'] = nowIso();
|
|
426
|
+
|
|
427
|
+
return serializeStateMd(parsed);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
if (result.written) {
|
|
431
|
+
return { written: true };
|
|
432
|
+
}
|
|
433
|
+
// `writeStateMd()` itself refused the write (size-ceiling or
|
|
434
|
+
// frontmatter-unsafe breach) — surface that reason rather than falling
|
|
435
|
+
// back to the transformer's closure-captured reason, which was never
|
|
436
|
+
// set on this path.
|
|
437
|
+
if (result.reason === 'size-ceiling' || result.reason === 'frontmatter-unsafe') {
|
|
438
|
+
return { written: false, reason: result.reason };
|
|
439
|
+
}
|
|
440
|
+
return { written: false, reason: skipReason ?? 'unreadable-state-md' };
|
|
441
|
+
} catch (err) {
|
|
442
|
+
// `writeStateMd()` throws a `TypeError` only if the transformer returns
|
|
443
|
+
// a non-string/non-null/non-undefined value (never happens here), and
|
|
444
|
+
// the `withStateMdLock()` it takes internally THROWS a labelled Error on
|
|
445
|
+
// acquire failure BY DESIGN (state-md-lock.mjs:265-270 / :328), tagging
|
|
446
|
+
// `err.code` as `STATE_LOCK_TIMEOUT` or `STATE_LOCK_FS_ERROR`. This
|
|
447
|
+
// module's contract is "never throw" — map the escaping error's
|
|
448
|
+
// `err.code` HONESTLY (#894 review finding F4) instead of collapsing
|
|
449
|
+
// every failure into `'lock-timeout'`: a disk-full / permission failure
|
|
450
|
+
// on the lock directory must not misdirect the operator toward "retry,
|
|
451
|
+
// it's contention". (The invalid-`plannedFiles` case that used to throw
|
|
452
|
+
// from inside this transformer is gone as of #903 — it is rejected by
|
|
453
|
+
// the `Array.isArray()` guard before `writeStateMd()` is ever called,
|
|
454
|
+
// so it never reaches this catch block.)
|
|
455
|
+
if (err?.code === 'STATE_LOCK_TIMEOUT') {
|
|
456
|
+
return { written: false, reason: 'lock-timeout' };
|
|
457
|
+
}
|
|
458
|
+
if (err?.code === 'STATE_LOCK_FS_ERROR') {
|
|
459
|
+
return { written: false, reason: 'lock-fs-error' };
|
|
460
|
+
}
|
|
461
|
+
return { written: false, reason: 'unexpected-error' };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Compute the S2 warn-only scope-drift ratio: how many files have actually
|
|
467
|
+
* changed since the session's frozen `session-start-ref`, filtered through
|
|
468
|
+
* `DRIFT_EXCLUDE_PATTERNS`, against the `plannedFiles` count frozen by
|
|
469
|
+
* `writeBaseline()`. Sync (`readFileSync` + `execFileSync('git', ...)`
|
|
470
|
+
* only). Never throws, never denies — this is a WARN-only tripwire; the
|
|
471
|
+
* CALLER decides whether/how to surface `breached`.
|
|
472
|
+
*
|
|
473
|
+
* Skip precedence (first match wins, so `reason` is deterministic):
|
|
474
|
+
* `no-state-md` → `unreadable-state-md` → `no-baseline` →
|
|
475
|
+
* `stale-baseline` → `unresolvable-ref`
|
|
476
|
+
*
|
|
477
|
+
* `session-start-ref` handling — MISSING vs UNRESOLVABLE are different:
|
|
478
|
+
* - Field absent from frontmatter → falls back to
|
|
479
|
+
* `git diff --name-only origin/main...HEAD` (the documented fallback,
|
|
480
|
+
* skills/session-end/plan-verification.md:37). This is NOT a skip —
|
|
481
|
+
* `refUsed` reports the fallback ref actually used.
|
|
482
|
+
* - Field present but the diff against it fails (rebase, force-push,
|
|
483
|
+
* deleted commit, …) → skip with `reason: 'unresolvable-ref'`.
|
|
484
|
+
*
|
|
485
|
+
* @param {object} args
|
|
486
|
+
* @param {string|undefined} args.repoRoot
|
|
487
|
+
* @param {number} [args.threshold] — breach threshold, `>=` counts as
|
|
488
|
+
* breached (default `2.0`).
|
|
489
|
+
* @returns {{ ok: true, skipped: true, reason: 'no-state-md'|'unreadable-state-md'|'no-baseline'|'stale-baseline'|'unresolvable-ref' }
|
|
490
|
+
* | { ok: true, skipped: false, filesRatio: number, plannedFiles: number,
|
|
491
|
+
* actualFiles: number, breached: boolean, threshold: number, refUsed: string }}
|
|
492
|
+
*/
|
|
493
|
+
export function computeDrift({ repoRoot, threshold = 2.0 } = {}) {
|
|
494
|
+
const parsedFm = readFrontmatterOrReason(repoRoot);
|
|
495
|
+
if (!parsedFm.ok) {
|
|
496
|
+
return { ok: true, skipped: true, reason: parsedFm.reason };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const classified = classifyBaselineFromFrontmatter(parsedFm.fm);
|
|
500
|
+
if (classified === null) {
|
|
501
|
+
// Distinguishable from readFrontmatterOrReason's failures — STATE.md
|
|
502
|
+
// parses fine, but `scope-baseline-session` was never written.
|
|
503
|
+
return { ok: true, skipped: true, reason: 'no-baseline' };
|
|
504
|
+
}
|
|
505
|
+
if (classified.stale === true) {
|
|
506
|
+
// A fresh diff against an old denominator is noise, not a measurement
|
|
507
|
+
// — no filesRatio is computed on this path.
|
|
508
|
+
return { ok: true, skipped: true, reason: 'stale-baseline' };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const baseline = classified;
|
|
512
|
+
const cwd = repoRoot ?? process.cwd();
|
|
513
|
+
const rawRef = typeof baseline.sessionStartRef === 'string' && baseline.sessionStartRef.length > 0
|
|
514
|
+
? baseline.sessionStartRef
|
|
515
|
+
: null;
|
|
516
|
+
|
|
517
|
+
const diffArgs = rawRef !== null
|
|
518
|
+
? ['diff', '--name-only', `${rawRef}..HEAD`]
|
|
519
|
+
: ['diff', '--name-only', 'origin/main...HEAD'];
|
|
520
|
+
const refUsed = rawRef ?? 'origin/main...HEAD';
|
|
521
|
+
|
|
522
|
+
let stdout;
|
|
523
|
+
try {
|
|
524
|
+
stdout = execFileSync('git', diffArgs, { cwd, encoding: 'utf8' });
|
|
525
|
+
} catch {
|
|
526
|
+
// Ref present-but-unresolvable (rebase, force-push, deleted commit) OR
|
|
527
|
+
// the fallback diff itself failed (e.g. no origin/main) — both land in
|
|
528
|
+
// the same skip bucket; neither can produce a trustworthy numerator.
|
|
529
|
+
return { ok: true, skipped: true, reason: 'unresolvable-ref' };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const changedFiles = stdout
|
|
533
|
+
.split('\n')
|
|
534
|
+
.map((line) => line.trim())
|
|
535
|
+
.filter((line) => line.length > 0);
|
|
536
|
+
|
|
537
|
+
// Same `filterExcluded()` primitive `writeBaseline()`'s denominator
|
|
538
|
+
// (`countPlannedFiles()`) calls (#894 review finding F1) — both sides of
|
|
539
|
+
// the ratio are provably produced by one function.
|
|
540
|
+
const actualFiles = filterExcluded(changedFiles).length;
|
|
541
|
+
|
|
542
|
+
const plannedFilesRaw = baseline.plannedFiles;
|
|
543
|
+
const plannedFiles = typeof plannedFilesRaw === 'number' && Number.isFinite(plannedFilesRaw)
|
|
544
|
+
? plannedFilesRaw
|
|
545
|
+
: 0;
|
|
546
|
+
|
|
547
|
+
// Mirrors the existing over_delivery_ratio div-by-zero guard in
|
|
548
|
+
// skills/wave-executor/wave-loop.md (`files_changed / max(planned_files_count, 1)`,
|
|
549
|
+
// in the per-wave metrics section). Deliberately un-line-pinned: that file is
|
|
550
|
+
// edited often enough that a pinned number goes stale, as this very comment did.
|
|
551
|
+
const filesRatio = round2(actualFiles / Math.max(plannedFiles, 1));
|
|
552
|
+
const breached = filesRatio >= threshold;
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
ok: true,
|
|
556
|
+
skipped: false,
|
|
557
|
+
filesRatio,
|
|
558
|
+
plannedFiles,
|
|
559
|
+
actualFiles,
|
|
560
|
+
breached,
|
|
561
|
+
threshold,
|
|
562
|
+
refUsed,
|
|
563
|
+
};
|
|
564
|
+
}
|