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
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
* lines — an append would leave stale duplicates.
|
|
11
11
|
*
|
|
12
12
|
* Store: `.orchestrator/runtime/reconcile-candidates.jsonl` — a mutable
|
|
13
|
-
* work-queue in JSON-Lines format (one ReconcileCandidate per line).
|
|
13
|
+
* work-queue in JSON-Lines format (one ReconcileCandidate per line). The store
|
|
14
|
+
* is OWNED by `mergeCandidates`: it is the only sanctioned writer. Nothing else
|
|
15
|
+
* — no report, no analysis run, no agent — may append to it; a read-side shape
|
|
16
|
+
* guard drops any record that is not a ReconcileCandidate and COUNTS the drop.
|
|
17
|
+
* The count reaches readers through the ONE reader ({@link loadCandidates}
|
|
18
|
+
* returns `{records, skipped}`) and writers through `mergeCandidates`'s
|
|
19
|
+
* `skipped` — there is deliberately no lossy array-only variant beside them.
|
|
14
20
|
*
|
|
15
21
|
* Two responsibilities differ from the repair store:
|
|
16
22
|
* 1. The IDEMPOTENCY KEY is the LOGICAL `learning_key` (issue #695), not the
|
|
@@ -66,12 +72,40 @@ function resolveStorePath(repoRoot, storePath) {
|
|
|
66
72
|
return join(root, rel);
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Minimal shape guard for a persisted store line. A record is accepted only
|
|
77
|
+
* when it carries the two fields every consumer of this store depends on:
|
|
78
|
+
* - `learning_key` — THE logical dedupe key (`mergeCandidates`, `isProcessed`).
|
|
79
|
+
* - `created_at` — the recency axis (`reconcile-nudge-banner.mjs` `_lastRunAt`).
|
|
80
|
+
*
|
|
81
|
+
* This is deliberately NOT a full schema check: the store is a mutable
|
|
82
|
+
* work-queue whose records may gain fields across schema versions, so
|
|
83
|
+
* over-strict validation would silently drop legitimate future records. It
|
|
84
|
+
* rejects only records that no writer in this repo produces — the concrete
|
|
85
|
+
* incident being a hand-written report artefact using `candidate_id` /
|
|
86
|
+
* `generated_at` / `status:"candidate"` (2026-07-31, see
|
|
87
|
+
* `docs/reconcile/2026-07-31-reconcile-candidates.md`).
|
|
88
|
+
* @param {unknown} rec
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
function isCandidateShape(rec) {
|
|
92
|
+
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return false;
|
|
93
|
+
const r = /** @type {Record<string, unknown>} */ (rec);
|
|
94
|
+
if (typeof r.learning_key !== 'string' || r.learning_key.length === 0) return false;
|
|
95
|
+
if (typeof r.created_at !== 'string') return false;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
/**
|
|
70
100
|
* Read + defensively parse the store's JSONL lines into ReconcileCandidate
|
|
71
|
-
* records. Malformed lines (bad JSON, non-object)
|
|
72
|
-
*
|
|
101
|
+
* records. Malformed lines (bad JSON, non-object) and shape-foreign records
|
|
102
|
+
* (see {@link isCandidateShape}) are skipped — the latter are COUNTED, because
|
|
103
|
+
* the store is a mutable work-queue that `mergeCandidates` rewrites in full, so
|
|
104
|
+
* a skipped line is dropped from disk on the next merge and a silent drop would
|
|
105
|
+
* be unattributable data loss. A missing file yields `{ records: [], skipped: 0 }`.
|
|
106
|
+
* Never throws.
|
|
73
107
|
* @param {string} absPath
|
|
74
|
-
* @returns {ReconcileCandidate[]}
|
|
108
|
+
* @returns {{ records: ReconcileCandidate[], skipped: number }}
|
|
75
109
|
*/
|
|
76
110
|
function readStore(absPath) {
|
|
77
111
|
let raw;
|
|
@@ -79,11 +113,12 @@ function readStore(absPath) {
|
|
|
79
113
|
raw = readFileSync(absPath, 'utf8');
|
|
80
114
|
} catch {
|
|
81
115
|
// ENOENT or any read error → empty store.
|
|
82
|
-
return [];
|
|
116
|
+
return { records: [], skipped: 0 };
|
|
83
117
|
}
|
|
84
118
|
|
|
85
119
|
/** @type {ReconcileCandidate[]} */
|
|
86
120
|
const records = [];
|
|
121
|
+
let skipped = 0;
|
|
87
122
|
for (const line of raw.split('\n')) {
|
|
88
123
|
const trimmed = line.trim();
|
|
89
124
|
if (trimmed.length === 0) continue;
|
|
@@ -91,13 +126,16 @@ function readStore(absPath) {
|
|
|
91
126
|
try {
|
|
92
127
|
parsed = JSON.parse(trimmed);
|
|
93
128
|
} catch {
|
|
129
|
+
skipped += 1;
|
|
94
130
|
continue; // skip malformed line
|
|
95
131
|
}
|
|
96
|
-
if (
|
|
132
|
+
if (isCandidateShape(parsed)) {
|
|
97
133
|
records.push(/** @type {ReconcileCandidate} */ (parsed));
|
|
134
|
+
} else {
|
|
135
|
+
skipped += 1;
|
|
98
136
|
}
|
|
99
137
|
}
|
|
100
|
-
return records;
|
|
138
|
+
return { records, skipped };
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
/**
|
|
@@ -153,13 +191,70 @@ export function makeCandidateId(learningKey, slug) {
|
|
|
153
191
|
}
|
|
154
192
|
|
|
155
193
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
194
|
+
* Build a ReconcileCandidate line-record for a proposed or rejected learning.
|
|
195
|
+
*
|
|
196
|
+
* Lives here — beside the {@link ReconcileCandidate} typedef it instantiates and
|
|
197
|
+
* the read-side `isCandidateShape` guard that judges it — so ONE file decides
|
|
198
|
+
* which fields a persisted record carries. It deliberately does NOT mint the
|
|
199
|
+
* `id` (see {@link makeCandidateId}): rejections currently derive their id from
|
|
200
|
+
* `(learningKey, 'rejected-<type>')` while storing `slug: ''`, so folding the
|
|
201
|
+
* mint in here using the record's own slug would change every rejection
|
|
202
|
+
* candidate's id and orphan every rejection row already on disk. Caller-supplied
|
|
203
|
+
* `id` keeps that decision at the call site.
|
|
204
|
+
*
|
|
205
|
+
* `created_at` is caller-supplied (from the engine's injectable clock) so output
|
|
206
|
+
* stays deterministic under test. `processed_at`/`superseded_by` always start
|
|
207
|
+
* null — only the merge/approval path stamps them. Never throws.
|
|
208
|
+
*
|
|
209
|
+
* @param {Object} params
|
|
210
|
+
* @param {string} params.id - deterministic physical id, see {@link makeCandidateId}.
|
|
211
|
+
* @param {string|null} params.learningKey - logical dedupe key; a non-string coerces to `''`.
|
|
212
|
+
* @param {string} params.slug - `.claude/rules/<slug>.md` slug (`''` for rejections).
|
|
213
|
+
* @param {'proposed'|'rejected'} params.status
|
|
214
|
+
* @param {string} params.reason
|
|
215
|
+
* @param {number} params.confidence
|
|
216
|
+
* @param {string} params.createdAt - ISO timestamp.
|
|
217
|
+
* @returns {ReconcileCandidate}
|
|
218
|
+
*/
|
|
219
|
+
export function buildCandidate({ id, learningKey, slug, status, reason, confidence, createdAt }) {
|
|
220
|
+
return {
|
|
221
|
+
id,
|
|
222
|
+
schema_version: 1,
|
|
223
|
+
learning_key: typeof learningKey === 'string' ? learningKey : '',
|
|
224
|
+
slug,
|
|
225
|
+
status,
|
|
226
|
+
reason,
|
|
227
|
+
confidence,
|
|
228
|
+
created_at: createdAt,
|
|
229
|
+
processed_at: null,
|
|
230
|
+
superseded_by: null,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Load the persisted store: every ReconcileCandidate that survives the read-side
|
|
236
|
+
* shape guard, PLUS the count of lines it rejected. Reads JSONL, skips malformed
|
|
237
|
+
* lines and shape-foreign records (missing `learning_key` / `created_at`),
|
|
238
|
+
* yields `{ records: [], skipped: 0 }` for a missing file. Does NOT create the
|
|
158
239
|
* runtime dir (mkdir -p happens only on write). Never throws.
|
|
240
|
+
*
|
|
241
|
+
* `skipped` is part of the return value rather than a second, "diagnostics"
|
|
242
|
+
* reader beside this one, because `records.length === 0` is AMBIGUOUS on its
|
|
243
|
+
* own: a missing store and a store whose every line is shape-foreign both yield
|
|
244
|
+
* `[]`, and a consumer that sees only the array reports "no reconcile run on
|
|
245
|
+
* record" for a store that in fact holds quarantined evidence of one (the
|
|
246
|
+
* concrete defect in `scripts/lib/reconcile-nudge-banner.mjs`, GitLab #955
|
|
247
|
+
* finding 2). Splitting the honest reader off under a longer name left the
|
|
248
|
+
* OBVIOUS name as the lossy one — the next consumer would reach for
|
|
249
|
+
* `loadCandidates`, get `[]`, and re-derive the same wrong conclusion. One
|
|
250
|
+
* reader, one answer.
|
|
251
|
+
*
|
|
252
|
+
* Read-only: unlike {@link mergeCandidates} this does NOT rewrite the store, so
|
|
253
|
+
* the skipped lines are still on disk after this call.
|
|
159
254
|
* @param {Object} [params]
|
|
160
255
|
* @param {string} [params.repoRoot] - repo root; relative `storePath` is resolved against it (defaults to `process.cwd()`).
|
|
161
256
|
* @param {string} [params.storePath] - store path (relative ⇒ joined to repoRoot). Defaults to {@link DEFAULT_STORE_PATH}.
|
|
162
|
-
* @returns {ReconcileCandidate[]}
|
|
257
|
+
* @returns {{ records: ReconcileCandidate[], skipped: number }}
|
|
163
258
|
*/
|
|
164
259
|
export function loadCandidates({ repoRoot, storePath } = {}) {
|
|
165
260
|
const absPath = resolveStorePath(repoRoot, storePath);
|
|
@@ -198,16 +293,21 @@ export function isProcessed(candidate, existing) {
|
|
|
198
293
|
* The runtime dir is created with mkdir -p semantics. The store is rewritten in
|
|
199
294
|
* full (read-all → merge → atomic tmp+rename), never appended. Output lines are
|
|
200
295
|
* sorted by `learning_key` for deterministic output. Never throws; on write
|
|
201
|
-
* failure returns `
|
|
296
|
+
* failure returns `written: false`.
|
|
297
|
+
*
|
|
298
|
+
* `skipped` reports how many persisted lines the read-side shape guard rejected
|
|
299
|
+
* (malformed JSON, or a record missing `learning_key`/`created_at`). Because the
|
|
300
|
+
* store is rewritten in full, those lines are DROPPED from disk by this call —
|
|
301
|
+
* the count is what makes that loss attributable instead of silent.
|
|
202
302
|
* @param {Object} [params]
|
|
203
303
|
* @param {ReconcileCandidate[]} [params.candidates] - newly minted candidates to merge.
|
|
204
304
|
* @param {string} [params.repoRoot]
|
|
205
305
|
* @param {string} [params.storePath]
|
|
206
|
-
* @returns {{ merged: ReconcileCandidate[], written: boolean }}
|
|
306
|
+
* @returns {{ merged: ReconcileCandidate[], written: boolean, skipped: number }}
|
|
207
307
|
*/
|
|
208
308
|
export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
|
|
209
309
|
const absPath = resolveStorePath(repoRoot, storePath);
|
|
210
|
-
const store = readStore(absPath);
|
|
310
|
+
const { records: store, skipped } = readStore(absPath);
|
|
211
311
|
|
|
212
312
|
// Index existing records by learning_key for O(1) lookup. Last write wins for
|
|
213
313
|
// any pre-existing duplicates in the file (defensive — store should be unique).
|
|
@@ -235,5 +335,5 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
|
|
|
235
335
|
);
|
|
236
336
|
|
|
237
337
|
const result = writeStore(absPath, merged);
|
|
238
|
-
return { merged, written: result.ok === true };
|
|
338
|
+
return { merged, written: result.ok === true, skipped };
|
|
239
339
|
}
|
|
@@ -23,9 +23,13 @@
|
|
|
23
23
|
* `.orchestrator/runtime/reconcile-candidates.jsonl` (owned by
|
|
24
24
|
* `scripts/lib/reconcile/idempotency.mjs`). Every learning present at a given
|
|
25
25
|
* run (eligible OR rejected) gets a candidate row, so:
|
|
26
|
-
* - the MAX `created_at` across `loadCandidates({repoRoot})` is the
|
|
27
|
-
* recent reconcile run's timestamp (or `null` when the store is
|
|
28
|
-
* i.e. no run on record, matching the fleet finding above).
|
|
26
|
+
* - the MAX `created_at` across `loadCandidates({repoRoot}).records` is the
|
|
27
|
+
* most recent reconcile run's timestamp (or `null` when the store is
|
|
28
|
+
* empty — i.e. no run on record, matching the fleet finding above).
|
|
29
|
+
* `.skipped` is read alongside it because `records.length === 0` alone is
|
|
30
|
+
* AMBIGUOUS: a missing store and a store whose every line failed the
|
|
31
|
+
* candidate shape guard both yield `[]`. Reporting "never" for the latter
|
|
32
|
+
* denies a run whose record was merely quarantined (GitLab #955 finding 2).
|
|
29
33
|
* - the candidate COUNT is a reasonable proxy for "how many learnings had
|
|
30
34
|
* been seen as of the last run" (dedup is by `learning_key`, so it is a
|
|
31
35
|
* high-water mark across all runs to date), used for the "new learnings
|
|
@@ -116,7 +120,11 @@ function _lastRunAt(candidates) {
|
|
|
116
120
|
* delta: number,
|
|
117
121
|
* nudge: boolean,
|
|
118
122
|
* reasons: string[],
|
|
123
|
+
* skippedCandidates?: number,
|
|
119
124
|
* }>}
|
|
125
|
+
* `skippedCandidates` is ABSENT when the candidate store was never inspected
|
|
126
|
+
* (early empty-corpus return, or an unreadable store); `0` means inspected and
|
|
127
|
+
* clean, `> 0` means that many persisted lines failed the candidate shape guard.
|
|
120
128
|
*/
|
|
121
129
|
export async function computeReconcileNudge(opts = {}) {
|
|
122
130
|
const empty = {
|
|
@@ -167,20 +175,39 @@ export async function computeReconcileNudge(opts = {}) {
|
|
|
167
175
|
|
|
168
176
|
/** @type {Array<Record<string, unknown>>} */
|
|
169
177
|
let candidates;
|
|
178
|
+
/** @type {number|undefined} */
|
|
179
|
+
let skippedCandidates;
|
|
170
180
|
try {
|
|
171
|
-
|
|
181
|
+
const diag = loadCandidates({ repoRoot });
|
|
182
|
+
candidates = Array.isArray(diag?.records) ? diag.records : [];
|
|
183
|
+
// Absence-preserving, mirroring `engine.mjs` `summary.skipped`: only a
|
|
184
|
+
// finite count means "the store was inspected". Absent ⇒ never checked,
|
|
185
|
+
// 0 ⇒ checked and clean.
|
|
186
|
+
if (Number.isFinite(diag?.skipped)) skippedCandidates = Number(diag.skipped);
|
|
172
187
|
} catch {
|
|
173
188
|
candidates = [];
|
|
189
|
+
// Store never inspected → leave `skippedCandidates` absent rather than
|
|
190
|
+
// fabricating a clean 0.
|
|
174
191
|
}
|
|
175
192
|
|
|
176
193
|
const lastRunAt = _lastRunAt(candidates);
|
|
177
194
|
const lastRunCandidateCount = Array.isArray(candidates) ? candidates.length : 0;
|
|
178
195
|
const delta = entries.length - lastRunCandidateCount;
|
|
196
|
+
const quarantined = typeof skippedCandidates === 'number' ? skippedCandidates : 0;
|
|
179
197
|
|
|
180
198
|
const reasons = [];
|
|
181
|
-
// (a) — plenty of active learnings
|
|
199
|
+
// (a) — plenty of active learnings and no DATEABLE run on record. When the
|
|
200
|
+
// store holds quarantined lines the honest claim is "undeterminable", not
|
|
201
|
+
// "never": the evidence exists, it is merely unreadable. /reconcile is still
|
|
202
|
+
// the right action either way — mergeCandidates rewrites the store in full and
|
|
203
|
+
// purges the bad lines — so the nudge fires in both cases, only the wording
|
|
204
|
+
// differs.
|
|
182
205
|
if (active.length >= NUDGE_MIN_LEARNINGS && lastRunAt === null) {
|
|
183
|
-
reasons.push(
|
|
206
|
+
reasons.push(
|
|
207
|
+
quarantined > 0
|
|
208
|
+
? `${active.length} active learnings; last reconcile run undeterminable — ${quarantined} unreadable record(s) in the candidate store`
|
|
209
|
+
: `${active.length} active learnings with no reconcile run on record`,
|
|
210
|
+
);
|
|
184
211
|
}
|
|
185
212
|
// (b) — a determinable prior run exists, and the corpus has grown meaningfully since.
|
|
186
213
|
if (lastRunAt !== null && delta > NUDGE_MIN_DELTA) {
|
|
@@ -191,7 +218,7 @@ export async function computeReconcileNudge(opts = {}) {
|
|
|
191
218
|
reasons.push(`${eligibleCount} rule-eligible learnings`);
|
|
192
219
|
}
|
|
193
220
|
|
|
194
|
-
|
|
221
|
+
const computed = {
|
|
195
222
|
totalLearnings: entries.length,
|
|
196
223
|
activeLearnings: active.length,
|
|
197
224
|
eligibleCount,
|
|
@@ -201,6 +228,12 @@ export async function computeReconcileNudge(opts = {}) {
|
|
|
201
228
|
nudge: reasons.length > 0,
|
|
202
229
|
reasons,
|
|
203
230
|
};
|
|
231
|
+
// Additive + absence-preserving: the key exists ONLY when the candidate store
|
|
232
|
+
// was actually inspected, so no consumer can read a false `skippedCandidates: 0`.
|
|
233
|
+
if (typeof skippedCandidates === 'number') {
|
|
234
|
+
/** @type {any} */ (computed).skippedCandidates = skippedCandidates;
|
|
235
|
+
}
|
|
236
|
+
return computed;
|
|
204
237
|
}
|
|
205
238
|
|
|
206
239
|
/**
|
|
@@ -261,10 +294,33 @@ export async function checkReconcileNudge(opts = {}) {
|
|
|
261
294
|
}
|
|
262
295
|
}
|
|
263
296
|
|
|
264
|
-
|
|
297
|
+
// Three-state last-run label. `never` is a claim about history and must only
|
|
298
|
+
// be made when the store was inspected and held nothing: a store whose lines
|
|
299
|
+
// were quarantined by the shape guard is EVIDENCE OF A RUN that can no longer
|
|
300
|
+
// be dated, so it reads `undeterminable` — saying "never" there would assert
|
|
301
|
+
// an absence the file on disk contradicts (GitLab #955 finding 2).
|
|
302
|
+
const quarantined =
|
|
303
|
+
Number.isFinite(computed.skippedCandidates) && computed.skippedCandidates > 0
|
|
304
|
+
? Number(computed.skippedCandidates)
|
|
305
|
+
: 0;
|
|
306
|
+
const dated =
|
|
265
307
|
typeof computed.lastRunAt === 'string' && computed.lastRunAt.length >= 10
|
|
266
308
|
? computed.lastRunAt.slice(0, 10)
|
|
267
|
-
:
|
|
309
|
+
: null;
|
|
310
|
+
let lastRunLabel;
|
|
311
|
+
if (dated !== null) {
|
|
312
|
+
// Partially contaminated: the date is real but derived only from the
|
|
313
|
+
// surviving records, so flag that it may under-report.
|
|
314
|
+
lastRunLabel =
|
|
315
|
+
quarantined > 0
|
|
316
|
+
? `${dated} (+${quarantined} unreadable record(s) — date may be stale)`
|
|
317
|
+
: dated;
|
|
318
|
+
} else {
|
|
319
|
+
lastRunLabel =
|
|
320
|
+
quarantined > 0
|
|
321
|
+
? `undeterminable (${quarantined} unreadable record(s) in the candidate store)`
|
|
322
|
+
: 'never';
|
|
323
|
+
}
|
|
268
324
|
|
|
269
325
|
const lines = [
|
|
270
326
|
`⚠ reconcile-nudge: ${computed.activeLearnings} active learnings, ${computed.eligibleCount} rule-eligible, ` +
|
|
@@ -47,14 +47,54 @@ const MACOS_HEALTHY_PRESSURE_PCT = 30;
|
|
|
47
47
|
* and return a verdict used by session-start Phase 4.5.
|
|
48
48
|
* @param {object} snapshot — output of probe()
|
|
49
49
|
* @param {object} thresholds — resource-thresholds block from parseSessionConfig
|
|
50
|
+
* @param {{heavyRepo?: boolean, agentsPerWave?: number|{default: number, [mode: string]: number}}} [options] — HR-003/HR-004
|
|
51
|
+
* preflight ceiling (baseline #60). When `heavyRepo` is true and `agentsPerWave`
|
|
52
|
+
* resolves to a number (see {@link resolveAgentsPerWaveCap}), `recommended_agents_per_wave_cap`
|
|
53
|
+
* is forced to at most that number REGARDLESS of the live-probe verdict — a
|
|
54
|
+
* static preflight ceiling, not a runtime signal. More-restrictive-wins:
|
|
55
|
+
* effective = min(existing cap ?? Infinity, agentsPerWave). Omitted entirely
|
|
56
|
+
* = today's behaviour (back-compat).
|
|
57
|
+
*
|
|
58
|
+
* `agentsPerWave` accepts either a plain number OR the `{default, <mode>: N}`
|
|
59
|
+
* object `_coerceInteger()` (scripts/lib/config/coercers.mjs) produces for
|
|
60
|
+
* the parenthetical override syntax (e.g. `agents-per-wave: 4 (deep: 18)`) —
|
|
61
|
+
* `skills/session-start/phase-4-5-resource-health.md` documents wiring
|
|
62
|
+
* `config['agents-per-wave']` straight into this option, so the object shape
|
|
63
|
+
* is a real input here, not a hypothetical one.
|
|
50
64
|
* @returns {{verdict: 'green'|'warn'|'degraded'|'critical', reasons: string[], recommended_agents_per_wave_cap: number|null}}
|
|
51
65
|
*/
|
|
52
|
-
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve an `agentsPerWave` option value into a plain numeric cap, or `null`
|
|
69
|
+
* when no cap should apply. Mirrors `resolveApwCap()` in
|
|
70
|
+
* `../wave-resource-gate.mjs` — kept as a local pure helper here rather than
|
|
71
|
+
* a cross-module import since both sites are ≤10 lines and evolve
|
|
72
|
+
* independently per their own gate's options shape.
|
|
73
|
+
*
|
|
74
|
+
* `evaluate()` has no session-mode input in scope, so the object shape
|
|
75
|
+
* resolves to `cap.default` — the documented HR-003 convention writes the
|
|
76
|
+
* override as `<default> (mode: <higher-ceiling>)`, i.e. `default` is the
|
|
77
|
+
* MORE restrictive of the pair, so this can only under-apply a looser
|
|
78
|
+
* mode-specific ceiling, never let a heavy repo exceed its base cap.
|
|
79
|
+
*
|
|
80
|
+
* @param {number|{default: number, [mode: string]: number}|*} cap
|
|
81
|
+
* @returns {number|null}
|
|
82
|
+
*/
|
|
83
|
+
function resolveAgentsPerWaveCap(cap) {
|
|
84
|
+
if (typeof cap === 'number') return Number.isFinite(cap) ? cap : null;
|
|
85
|
+
if (cap !== null && typeof cap === 'object' && !Array.isArray(cap)) {
|
|
86
|
+
const def = cap.default;
|
|
87
|
+
return typeof def === 'number' && Number.isFinite(def) ? def : null;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function evaluate(snapshot, thresholds, options = {}) {
|
|
53
93
|
const reasons = [];
|
|
54
94
|
let verdict = 'green';
|
|
55
95
|
let cap = null;
|
|
56
96
|
|
|
57
|
-
const { ram_free_gb, cpu_load_pct, claude_processes_count, memory_pressure_pct_free, ram_available_gb } = snapshot;
|
|
97
|
+
const { ram_free_gb, cpu_load_pct, cpu_load_5m_pct, claude_processes_count, memory_pressure_pct_free, ram_available_gb } = snapshot;
|
|
58
98
|
const {
|
|
59
99
|
'ram-free-min-gb': ramMin,
|
|
60
100
|
'ram-free-critical-gb': ramCrit,
|
|
@@ -93,10 +133,24 @@ export function evaluate(snapshot, thresholds) {
|
|
|
93
133
|
reasons.push(`macOS memory_pressure healthy (${memory_pressure_pct_free}% free ≥ ${MACOS_HEALTHY_PRESSURE_PCT}%) — free-RAM signal suppressed (Pages-free underreports on Darwin).`);
|
|
94
134
|
}
|
|
95
135
|
|
|
96
|
-
|
|
136
|
+
// CPU axis (#943): the 1m load average systematically carries the decaying
|
|
137
|
+
// tail of the coordinator's own just-finished gate run (the caller sits right
|
|
138
|
+
// after the inter-wave Quality Gate by construction). When the probe supplied
|
|
139
|
+
// a numeric `cpu_load_5m_pct`, judge CPU on min(1m, 5m) — only-1m-high is a
|
|
140
|
+
// decaying transient (informational, no cap), both-high is genuine sustained
|
|
141
|
+
// load. Mirrors the memory_pressure healthy-suppression pattern above.
|
|
142
|
+
// `cpu_load_5m_pct` absent/null (legacy snapshots, Windows) → legacy 1m-only.
|
|
143
|
+
const has5mCpu = typeof cpu_load_5m_pct === 'number' && Number.isFinite(cpu_load_5m_pct);
|
|
144
|
+
const effectiveCpuPct = has5mCpu ? Math.min(cpu_load_pct, cpu_load_5m_pct) : cpu_load_pct;
|
|
145
|
+
if (effectiveCpuPct > cpuMax) {
|
|
97
146
|
if (verdict === 'green') verdict = 'warn';
|
|
98
147
|
cap = cap === null ? 2 : Math.min(cap, 2);
|
|
99
|
-
|
|
148
|
+
const detail = has5mCpu ? ` (min of 1m ${cpu_load_pct}% / 5m ${cpu_load_5m_pct}%)` : '';
|
|
149
|
+
reasons.push(`CPU load ${effectiveCpuPct}%${detail} above threshold ${cpuMax}% — capping agents-per-wave at 2.`);
|
|
150
|
+
} else if (has5mCpu && cpu_load_pct > cpuMax) {
|
|
151
|
+
// Informational only: 1m spike with a calm 5m average = decaying transient
|
|
152
|
+
// (typically the coordinator's own just-finished gate run). No cap.
|
|
153
|
+
reasons.push(`CPU 1m load ${cpu_load_pct}% above threshold ${cpuMax}% but 5m load ${cpu_load_5m_pct}% is below — decaying transient (likely the coordinator's own gate run); no cap (#943).`);
|
|
100
154
|
}
|
|
101
155
|
|
|
102
156
|
if (claude_processes_count !== null && claude_processes_count !== undefined && claude_processes_count >= concWarn) {
|
|
@@ -186,5 +240,17 @@ export function evaluate(snapshot, thresholds) {
|
|
|
186
240
|
cap = 0;
|
|
187
241
|
}
|
|
188
242
|
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// HR-003/HR-004 heavy-repo preflight ceiling (#60): a STATIC cap independent
|
|
245
|
+
// of the live-probe verdict. Applies only when heavyRepo is true and
|
|
246
|
+
// agentsPerWave is a finite number — more-restrictive-wins against whatever
|
|
247
|
+
// the live-probe signals already computed.
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
const { heavyRepo, agentsPerWave } = options;
|
|
250
|
+
const resolvedApwCap = resolveAgentsPerWaveCap(agentsPerWave);
|
|
251
|
+
if (heavyRepo === true && resolvedApwCap !== null) {
|
|
252
|
+
cap = cap === null ? resolvedApwCap : Math.min(cap, resolvedApwCap);
|
|
253
|
+
}
|
|
254
|
+
|
|
189
255
|
return { verdict, reasons, recommended_agents_per_wave_cap: cap };
|
|
190
256
|
}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* ram_used_pct: 78,
|
|
16
16
|
* cpu_load_1m: 2.4,
|
|
17
17
|
* cpu_load_pct: 65,
|
|
18
|
+
* cpu_load_5m: 1.8,
|
|
19
|
+
* cpu_load_5m_pct: 45 | null, // 5m load-average as pct-of-cores; null on Windows/zero-load (#943)
|
|
18
20
|
* claude_processes_count: 3 | null,
|
|
19
21
|
* codex_processes_count: 0 | null,
|
|
20
22
|
* other_node_processes: 12 | null,
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
* - scripts/lib/resource-probe/evaluate.mjs — verdict + threshold logic
|
|
38
40
|
*/
|
|
39
41
|
|
|
42
|
+
import os from 'node:os';
|
|
40
43
|
import { ramSnapshot, cpuSnapshot, processCounts, swapUsedMb, memoryPressurePctFree, ramAvailableGb } from './resource-probe/probe-platform.mjs';
|
|
41
44
|
|
|
42
45
|
// ---------------------------------------------------------------------------
|
|
@@ -63,6 +66,20 @@ export async function probe(opts = {}) {
|
|
|
63
66
|
const start = Date.now();
|
|
64
67
|
const ram = ramSnapshot();
|
|
65
68
|
const cpu = cpuSnapshot();
|
|
69
|
+
|
|
70
|
+
// #943: additionally sample the 5-minute load average. The wave-resource-gate
|
|
71
|
+
// runs, by construction, right after the coordinator's own CPU-saturating
|
|
72
|
+
// quality-gate run — the 1m average still carries that decaying tail
|
|
73
|
+
// (observed 2026-07-30: 96% → 91% → 78% → 75% within 36s after a Full Gate at
|
|
74
|
+
// 813% CPU), while the 5m average smooths it. Consumers (evaluate(),
|
|
75
|
+
// wave-resource-gate) judge CPU on min(1m, 5m) when the 5m signal exists.
|
|
76
|
+
// Windows reports loadavg [0,0,0] → cpu_load_5m_pct stays null there and
|
|
77
|
+
// consumers fall back to cpu_load_pct alone (which cpuSnapshot() derives from
|
|
78
|
+
// per-core times on Windows).
|
|
79
|
+
const load5m = os.loadavg()[1];
|
|
80
|
+
const cpuCores = (os.cpus() || []).length || 1;
|
|
81
|
+
const cpu_load_5m = Math.round(load5m * 10) / 10;
|
|
82
|
+
const cpu_load_5m_pct = load5m > 0 ? Math.min(100, Math.round((load5m / cpuCores) * 100)) : null;
|
|
66
83
|
const zombieThresholdMin = opts.zombieThresholdMin ?? null;
|
|
67
84
|
const procs = opts.skipProcessCounts
|
|
68
85
|
? { claude_processes_count: null, codex_processes_count: null, other_node_processes: null, zombie_processes_count: null }
|
|
@@ -84,6 +101,8 @@ export async function probe(opts = {}) {
|
|
|
84
101
|
timestamp: new Date().toISOString(),
|
|
85
102
|
...ram,
|
|
86
103
|
...cpu,
|
|
104
|
+
cpu_load_5m,
|
|
105
|
+
cpu_load_5m_pct,
|
|
87
106
|
...procs,
|
|
88
107
|
swap_used_mb,
|
|
89
108
|
memory_pressure_pct_free,
|
|
@@ -88,6 +88,12 @@ function getPicomatch() {
|
|
|
88
88
|
* Minimal glob-to-RegExp fallback used only when picomatch is absent.
|
|
89
89
|
* Handles `**`, `*`, and literal character matching.
|
|
90
90
|
*
|
|
91
|
+
* Drift contract (W4 B2): a semantically FROZEN twin of this function lives in
|
|
92
|
+
* scripts/lib/command-blocker.mjs (`redirectGlobToRegExp`) — duplicated there
|
|
93
|
+
* because that module is hook-hot-path pure and must not import this one.
|
|
94
|
+
* Syntax extensions made here do NOT automatically apply there; mirror them
|
|
95
|
+
* deliberately or document the divergence.
|
|
96
|
+
*
|
|
91
97
|
* @param {string} pattern
|
|
92
98
|
* @returns {RegExp}
|
|
93
99
|
*/
|