session-orchestrator 3.20.0 → 3.22.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/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -52,6 +52,35 @@
|
|
|
52
52
|
* #1015 hardened for `.claude/rules/`. The primitives are imported, never
|
|
53
53
|
* re-implemented: a second copy is how this channel shipped raw beside the
|
|
54
54
|
* hardened one in the first place.
|
|
55
|
+
* - `extractProvenance()` from `../validate/check-learning-provenance.mjs` and
|
|
56
|
+
* `learningKeyOf()` from `./kebab.mjs` — the already-delivered filter below.
|
|
57
|
+
* Both readers already exist; a second provenance parser here would be the
|
|
58
|
+
* "same fact in two copies" defect this repo keeps paying for.
|
|
59
|
+
*
|
|
60
|
+
* ## Already-delivered filter (#1019)
|
|
61
|
+
*
|
|
62
|
+
* `/reconcile` converts a learning into a `.claude/rules/*.md` file, and Claude
|
|
63
|
+
* Code delivers every such file to every dispatched agent NATIVELY, in full.
|
|
64
|
+
* Measured first-person 2026-08-15 @fd73548+wave-2: a wave subagent's context
|
|
65
|
+
* carried all 29 of `.claude/rules/*.md` — `alwaysApply: false` and `globs:`
|
|
66
|
+
* notwithstanding, because `rule-loader.mjs` (the only code that understands
|
|
67
|
+
* that frontmatter) does not run on the delivery path
|
|
68
|
+
* (`docs/instruction-delivery.md` §1/§1.1). So a rule-derived learning that also
|
|
69
|
+
* enters this index arrives TWICE, and the second copy costs a slot in a
|
|
70
|
+
* 2000-char budget — displacing a learning the agent would otherwise never see.
|
|
71
|
+
*
|
|
72
|
+
* The filter therefore runs BEFORE the split caps, not after: dropping a
|
|
73
|
+
* duplicate after the Top-N cut would remove the line without freeing its slot,
|
|
74
|
+
* which is the whole harm. Two axes, mirroring the provenance checker's own
|
|
75
|
+
* `dangling` / `superseded` split: the rule's `learning-id` (exact record) and
|
|
76
|
+
* its `learning-key` (logical identity, stable across a re-minted UUID — the
|
|
77
|
+
* state any id backfill lands in). 13 of 29 rules carry provenance and all 13
|
|
78
|
+
* resolve by id today, so the key axis is currently inert by measurement, not
|
|
79
|
+
* by design.
|
|
80
|
+
*
|
|
81
|
+
* SILENT NO-OP is a hard requirement: no rules directory, no `.md` files, or no
|
|
82
|
+
* provenance block anywhere ⇒ empty sets ⇒ byte-identical output. A repo without
|
|
83
|
+
* `/reconcile` must never see FEWER learnings because this filter exists.
|
|
55
84
|
*
|
|
56
85
|
* NOT used: `filterByScope()` from `./filters.mjs`. Despite the name it filters
|
|
57
86
|
* the PRIVACY enum `['local','private','public']` (schema.mjs), not file scope.
|
|
@@ -96,12 +125,17 @@
|
|
|
96
125
|
* 5. Expired and sub-floor entries are never selected.
|
|
97
126
|
*/
|
|
98
127
|
|
|
128
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
129
|
+
import { join } from 'node:path';
|
|
130
|
+
|
|
99
131
|
import { affinity } from './affinity.mjs';
|
|
100
132
|
import {
|
|
101
133
|
INSIGHT_MAX_BYTES,
|
|
102
134
|
TITLE_MAX_BYTES,
|
|
103
135
|
sanitizeProse,
|
|
104
136
|
} from '../reconcile/sanitize.mjs';
|
|
137
|
+
import { learningKeyOf } from './kebab.mjs';
|
|
138
|
+
import { extractProvenance } from '../validate/check-learning-provenance.mjs';
|
|
105
139
|
import { DECAY_DEFAULTS, effectiveScore, surfaceTopN } from './surface.mjs';
|
|
106
140
|
|
|
107
141
|
// ---------------------------------------------------------------------------
|
|
@@ -178,9 +212,15 @@ export const DEFAULT_CONFIDENCE_FLOOR = 0.3;
|
|
|
178
212
|
* @typedef {{entries: object[], selected: SelectedLearning[], lines: string[],
|
|
179
213
|
* text: string, chars: number, scopeMatched: number,
|
|
180
214
|
* globalCount: number, candidates: number, truncated: boolean,
|
|
181
|
-
* rejected: number}} Selection
|
|
215
|
+
* rejected: number, deliveredFiltered: number}} Selection
|
|
182
216
|
* `rejected` counts records dropped by the untrusted-text guard — surfaced so
|
|
183
217
|
* a drop is observable in the injection event rather than silent.
|
|
218
|
+
* `deliveredFiltered` counts records dropped because a `.claude/rules/*.md`
|
|
219
|
+
* file already delivers them natively (#1019) — same reason, and it is the
|
|
220
|
+
* only way to tell "the filter bit" from "the corpus has no such learning".
|
|
221
|
+
*
|
|
222
|
+
* @typedef {{ids: Set<string>, keys: Set<string>}} DeliveredProvenance
|
|
223
|
+
* Learning ids and logical keys already delivered as `.claude/rules/*.md`.
|
|
184
224
|
*/
|
|
185
225
|
|
|
186
226
|
// ---------------------------------------------------------------------------
|
|
@@ -214,6 +254,43 @@ function _isActive(entry, nowMs, confidenceFloor) {
|
|
|
214
254
|
return true;
|
|
215
255
|
}
|
|
216
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Normalize a caller-supplied delivered-provenance option into two Sets, or
|
|
259
|
+
* `null` when there is nothing to filter against.
|
|
260
|
+
*
|
|
261
|
+
* `null` (not empty Sets) is the no-filter signal so the hot loop can skip the
|
|
262
|
+
* per-entry `learningKeyOf()` call entirely — and so the SILENT NO-OP guarantee
|
|
263
|
+
* is one explicit branch rather than an emergent property of empty membership.
|
|
264
|
+
*
|
|
265
|
+
* @param {unknown} v `{ids, keys}` with Sets or arrays; anything else ⇒ `null`
|
|
266
|
+
* @returns {DeliveredProvenance|null}
|
|
267
|
+
*/
|
|
268
|
+
function _resolveDelivered(v) {
|
|
269
|
+
if (!_isRecord(v)) return null;
|
|
270
|
+
const toSet = (x) => (x instanceof Set ? x : Array.isArray(x) ? new Set(x) : new Set());
|
|
271
|
+
const ids = toSet(v.ids);
|
|
272
|
+
const keys = toSet(v.keys);
|
|
273
|
+
return ids.size === 0 && keys.size === 0 ? null : { ids, keys };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* True when this learning already reaches the agent as a natively-delivered
|
|
278
|
+
* `.claude/rules/*.md` file.
|
|
279
|
+
*
|
|
280
|
+
* Id first (exact record), then the logical key — a rule whose `learning-id`
|
|
281
|
+
* was re-minted by a backfill still delivers the same content, which is the
|
|
282
|
+
* `superseded-learning-id` state `check-learning-provenance.mjs` names.
|
|
283
|
+
*
|
|
284
|
+
* @param {object} entry
|
|
285
|
+
* @param {DeliveredProvenance} delivered
|
|
286
|
+
* @returns {boolean}
|
|
287
|
+
*/
|
|
288
|
+
function _isDelivered(entry, delivered) {
|
|
289
|
+
if (typeof entry.id === 'string' && entry.id !== '' && delivered.ids.has(entry.id)) return true;
|
|
290
|
+
const key = learningKeyOf(entry); // total: a shape-foreign entry yields null
|
|
291
|
+
return key !== null && delivered.keys.has(key);
|
|
292
|
+
}
|
|
293
|
+
|
|
217
294
|
/** Epoch ms from a Date | number | undefined clock option. */
|
|
218
295
|
function _resolveNowMs(now) {
|
|
219
296
|
if (now instanceof Date) return now.getTime();
|
|
@@ -274,9 +351,60 @@ export function emptySelection() {
|
|
|
274
351
|
candidates: 0,
|
|
275
352
|
truncated: false,
|
|
276
353
|
rejected: 0,
|
|
354
|
+
deliveredFiltered: 0,
|
|
277
355
|
};
|
|
278
356
|
}
|
|
279
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Read the provenance pointers of every `.claude/rules/*.md` — the set of
|
|
360
|
+
* learnings the agent already receives natively (#1019).
|
|
361
|
+
*
|
|
362
|
+
* Reuses `extractProvenance()` rather than re-deriving the block format. Total
|
|
363
|
+
* by construction: an absent, unreadable or provenance-free directory yields
|
|
364
|
+
* empty sets, which {@link selectLearnings} reads as "no filter" (SILENT NO-OP).
|
|
365
|
+
*
|
|
366
|
+
* Read discipline mirrors `check-learning-provenance.mjs`: `readFileSync`, never
|
|
367
|
+
* a `grep` spawn — one NUL byte makes a text file invisible to a grep-based
|
|
368
|
+
* audit, and a silently-skipped rule file reads exactly like a rule with no
|
|
369
|
+
* provenance.
|
|
370
|
+
*
|
|
371
|
+
* Ceiling: one synchronous read per rule file, linear in the corpus (29 files /
|
|
372
|
+
* ~170 KB today). Revisit — cache per process or read async — if
|
|
373
|
+
* `.claude/rules/` passes a few hundred files.
|
|
374
|
+
*
|
|
375
|
+
* @param {string} rulesDir absolute path to the `.claude/rules` directory
|
|
376
|
+
* @returns {DeliveredProvenance}
|
|
377
|
+
*/
|
|
378
|
+
export function readDeliveredProvenance(rulesDir) {
|
|
379
|
+
/** @type {Set<string>} */
|
|
380
|
+
const ids = new Set();
|
|
381
|
+
/** @type {Set<string>} */
|
|
382
|
+
const keys = new Set();
|
|
383
|
+
if (typeof rulesDir !== 'string' || rulesDir.trim() === '') return { ids, keys };
|
|
384
|
+
|
|
385
|
+
/** @type {string[]} */
|
|
386
|
+
let names;
|
|
387
|
+
try {
|
|
388
|
+
names = readdirSync(rulesDir);
|
|
389
|
+
} catch {
|
|
390
|
+
return { ids, keys }; // no rules directory ⇒ nothing is natively delivered
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const name of names) {
|
|
394
|
+
if (!name.endsWith('.md')) continue;
|
|
395
|
+
let body;
|
|
396
|
+
try {
|
|
397
|
+
body = readFileSync(join(rulesDir, name), 'utf8');
|
|
398
|
+
} catch {
|
|
399
|
+
continue; // one unreadable rule must not cost the whole census
|
|
400
|
+
}
|
|
401
|
+
const { id, key } = extractProvenance(body);
|
|
402
|
+
if (id) ids.add(id);
|
|
403
|
+
if (key) keys.add(key);
|
|
404
|
+
}
|
|
405
|
+
return { ids, keys };
|
|
406
|
+
}
|
|
407
|
+
|
|
280
408
|
/**
|
|
281
409
|
* Truncate to at most `maxUnits` UTF-16 code units, cutting on a CODE-POINT
|
|
282
410
|
* boundary.
|
|
@@ -413,6 +541,9 @@ export function scoreLearning(entry, scope, opts = {}) {
|
|
|
413
541
|
* @param {Date|number} [opts.now] — injectable clock
|
|
414
542
|
* @param {object} [opts.decay] — #670 decay tuning, forwarded to effectiveScore
|
|
415
543
|
* @param {object} [opts.affinityOpts] — forwarded to affinity()
|
|
544
|
+
* @param {DeliveredProvenance} [opts.delivered] — learnings already delivered
|
|
545
|
+
* natively as `.claude/rules/*.md` (#1019); omitted/empty ⇒ no filtering, and
|
|
546
|
+
* the selection is byte-identical to the pre-filter one
|
|
416
547
|
* @returns {Selection}
|
|
417
548
|
*/
|
|
418
549
|
export function selectLearnings(entries, scope, opts = {}) {
|
|
@@ -433,6 +564,7 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
433
564
|
? o.confidenceFloor
|
|
434
565
|
: DEFAULT_CONFIDENCE_FLOOR;
|
|
435
566
|
const nowMs = _resolveNowMs(o.now);
|
|
567
|
+
const delivered = _resolveDelivered(o.delivered);
|
|
436
568
|
const scoreOpts = { now: nowMs, decay: o.decay, affinityOpts: o.affinityOpts };
|
|
437
569
|
|
|
438
570
|
/** @type {SelectedLearning[]} */
|
|
@@ -441,12 +573,23 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
441
573
|
const global = [];
|
|
442
574
|
let candidates = 0;
|
|
443
575
|
let rejected = 0;
|
|
576
|
+
let deliveredFiltered = 0;
|
|
444
577
|
|
|
445
578
|
for (const entry of entries) {
|
|
446
579
|
if (!_isRecord(entry)) continue;
|
|
447
580
|
if (!_isActive(entry, nowMs, confidenceFloor)) continue;
|
|
448
581
|
candidates++;
|
|
449
582
|
|
|
583
|
+
// #1019 — BEFORE the split caps below, never after. This entry already
|
|
584
|
+
// reaches the agent in full as a `.claude/rules/*.md` file; dropping it
|
|
585
|
+
// here frees its slot for a learning the agent would otherwise never see,
|
|
586
|
+
// whereas dropping it after the Top-N cut would only shorten the index.
|
|
587
|
+
// `candidates` still counts it: the pool it was drawn from is unchanged.
|
|
588
|
+
if (delivered !== null && _isDelivered(entry, delivered)) {
|
|
589
|
+
deliveredFiltered++;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
450
593
|
const s = scoreLearning(entry, scope, scoreOpts);
|
|
451
594
|
// Fail CLOSED per entry: a record whose text forges the delivery wrapper
|
|
452
595
|
// is dropped, not neutralised in place — with 100 candidates competing for
|
|
@@ -510,6 +653,7 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
510
653
|
candidates,
|
|
511
654
|
truncated,
|
|
512
655
|
rejected,
|
|
656
|
+
deliveredFiltered,
|
|
513
657
|
};
|
|
514
658
|
} catch {
|
|
515
659
|
// Contract point 1 — a ranking primitive on the dispatch hot path must
|
|
@@ -525,7 +669,11 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
525
669
|
* @param {string} filePath — absolute path to learnings.jsonl
|
|
526
670
|
* @param {AgentScope} scope
|
|
527
671
|
* @param {object} [opts] — everything {@link selectLearnings} accepts, plus
|
|
528
|
-
* `poolSize` (how many active entries to pull before ranking)
|
|
672
|
+
* `poolSize` (how many active entries to pull before ranking) and `rulesDir`
|
|
673
|
+
* (absolute `.claude/rules` path; read via {@link readDeliveredProvenance} into
|
|
674
|
+
* the `delivered` filter). `rulesDir` is EXPLICIT rather than derived from
|
|
675
|
+
* `filePath`: guessing a repo root from a metrics path is the hand-maintained
|
|
676
|
+
* fact this repo keeps getting wrong. Omit it and nothing is filtered.
|
|
529
677
|
* @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
|
|
530
678
|
*/
|
|
531
679
|
export async function selectLearningsFromFile(filePath, scope, opts = {}) {
|
|
@@ -538,12 +686,18 @@ export async function selectLearningsFromFile(filePath, scope, opts = {}) {
|
|
|
538
686
|
? o.confidenceFloor
|
|
539
687
|
: DEFAULT_CONFIDENCE_FLOOR;
|
|
540
688
|
|
|
689
|
+
const delivered = _isRecord(o.delivered)
|
|
690
|
+
? o.delivered
|
|
691
|
+
: typeof o.rulesDir === 'string' && o.rulesDir.trim() !== ''
|
|
692
|
+
? readDeliveredProvenance(o.rulesDir)
|
|
693
|
+
: undefined;
|
|
694
|
+
|
|
541
695
|
const entries = await surfaceTopN(filePath, poolSize, {
|
|
542
696
|
now: nowMs,
|
|
543
697
|
confidenceFloor,
|
|
544
698
|
decay: o.decay,
|
|
545
699
|
});
|
|
546
|
-
return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor });
|
|
700
|
+
return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor, delivered });
|
|
547
701
|
} catch {
|
|
548
702
|
return emptySelection();
|
|
549
703
|
}
|
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory-cleanup-stamp.mjs —
|
|
3
|
-
*
|
|
2
|
+
* memory-cleanup-stamp.mjs — stamp `memory_cleanup_at` on a session record when
|
|
3
|
+
* `/memory-cleanup` ran this session, and DERIVE that fact from the event log
|
|
4
|
+
* instead of from coordinator recall.
|
|
4
5
|
*
|
|
5
6
|
* Issue #699 fix: a healthy no-op run of `/memory-cleanup` previously left
|
|
6
7
|
* `memory_cleanup_at` unstamped, so `auto-dream.mjs` `readDreamSignals`
|
|
7
8
|
* never advanced `lastCleanupAt` and `shouldDispatchAutoDream` kept firing a
|
|
8
|
-
* false nudge.
|
|
9
|
-
* cleanup ran — including a healthy no-op where no memory files were mutated.
|
|
9
|
+
* false nudge. `stampMemoryCleanup()` stamps the field unconditionally whenever
|
|
10
|
+
* the cleanup ran — including a healthy no-op where no memory files were mutated.
|
|
11
|
+
*
|
|
12
|
+
* #699 follow-up (Disziplin statt Mechanik): the `ranCleanup` boolean itself used
|
|
13
|
+
* to come from the coordinator-LLM remembering a prose step at session-end. It
|
|
14
|
+
* measurably failed — a `/memory-cleanup` ran on 2026-08-14 and all three session
|
|
15
|
+
* records of that day read `memory_cleanup_at: null`, so the session-start banner
|
|
16
|
+
* reported "29 days ago" while the operator's own notes said 3. Since then
|
|
17
|
+
* `/memory-cleanup` emits `orchestrator.memory.cleanup_completed` and
|
|
18
|
+
* `deriveMemoryCleanupSignal()` reads that event back out of
|
|
19
|
+
* `.orchestrator/metrics/events.jsonl`, which is what `scripts/emit-session.mjs`
|
|
20
|
+
* calls at write time. Nothing depends on recall any more.
|
|
10
21
|
*
|
|
11
22
|
* Design constraints:
|
|
12
|
-
* -
|
|
13
|
-
* - No-throw — invalid inputs
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
23
|
+
* - `stampMemoryCleanup()` stays pure — no I/O, no side-effects.
|
|
24
|
+
* - No-throw — invalid inputs / unreadable-or-corrupt event log return the
|
|
25
|
+
* "no cleanup" answer rather than blocking a session close.
|
|
26
|
+
* - Testable seam — both functions are unit-tested directly.
|
|
27
|
+
* - No external deps — Node stdlib only.
|
|
16
28
|
*/
|
|
17
29
|
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Event `/memory-cleanup` emits when a run completes (any mode, any outcome —
|
|
34
|
+
* including a healthy no-op). Emitted via `scripts/emit-event.mjs` → the
|
|
35
|
+
* canonical `emitEvent()` path; see `skills/memory-cleanup/SKILL.md` § Output.
|
|
36
|
+
*/
|
|
37
|
+
export const MEMORY_CLEANUP_EVENT = 'orchestrator.memory.cleanup_completed';
|
|
38
|
+
|
|
18
39
|
/**
|
|
19
40
|
* Stamp `memory_cleanup_at` on a session record when `/memory-cleanup` ran.
|
|
20
41
|
*
|
|
@@ -54,3 +75,106 @@ export function stampMemoryCleanup(record, { ranCleanup, completedAt } = {}) {
|
|
|
54
75
|
// Return a shallow clone with the stamp applied — never mutate the input.
|
|
55
76
|
return { ...record, memory_cleanup_at: completedAt };
|
|
56
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive "did `/memory-cleanup` run in THIS session?" from the event log.
|
|
81
|
+
*
|
|
82
|
+
* Reads `eventsFile` and looks for `orchestrator.memory.cleanup_completed`
|
|
83
|
+
* records that fall inside the session's own `[startedAt, completedAt]` window.
|
|
84
|
+
* This is the mechanical replacement for the coordinator-supplied boolean.
|
|
85
|
+
*
|
|
86
|
+
* Matching rule (two clauses, both mechanical):
|
|
87
|
+
* 1. the event's `timestamp` lies within `[startedAt, completedAt]` inclusive;
|
|
88
|
+
* 2. IF the event carries a non-empty `semantic_session_id`, it must equal
|
|
89
|
+
* `sessionId` (sessions.jsonl `session_id` is the SEMANTIC id — the UUID
|
|
90
|
+
* `session_id` some events carry lives in a different id space and is
|
|
91
|
+
* deliberately NOT used for matching).
|
|
92
|
+
*
|
|
93
|
+
* Named ceiling (deliberate simplification): an event with NO attribution is
|
|
94
|
+
* claimed on the window alone, so two parallel sessions in one repo with
|
|
95
|
+
* overlapping windows both derive `ranCleanup: true`. The consequence is a
|
|
96
|
+
* marginally generous cadence marker on the peer's record — never a MISSED
|
|
97
|
+
* cleanup, which is the failure this whole path exists to prevent. Revisit
|
|
98
|
+
* trigger: if per-session cleanup accounting is ever needed, attach
|
|
99
|
+
* `semantic_session_id` to the emitted event and clause 2 makes it exact.
|
|
100
|
+
* Scan cost is a single linear pass over `events.jsonl` (~23k lines,
|
|
101
|
+
* size-rotated at SessionStart by `events-rotation.mjs`) — revisit if rotation
|
|
102
|
+
* is ever removed.
|
|
103
|
+
*
|
|
104
|
+
* No-throw: a missing, unreadable, or partly-corrupt event log yields
|
|
105
|
+
* `{ ranCleanup: false, ... }`. A session close must never fail because
|
|
106
|
+
* telemetry is damaged.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} opts
|
|
109
|
+
* @param {string} opts.eventsFile Absolute or CWD-relative path to events.jsonl.
|
|
110
|
+
* @param {string} [opts.sessionId] The record's `session_id` (semantic form).
|
|
111
|
+
* @param {string} opts.startedAt Session `started_at` (ISO-8601).
|
|
112
|
+
* @param {string} opts.completedAt Session `completed_at` (ISO-8601).
|
|
113
|
+
* @returns {{ranCleanup: boolean, at: string|null, matches: number}}
|
|
114
|
+
* `at` is the LATEST matching event timestamp (null when none matched);
|
|
115
|
+
* `matches` is how many events matched (a run can emit more than one).
|
|
116
|
+
*/
|
|
117
|
+
export function deriveMemoryCleanupSignal({
|
|
118
|
+
eventsFile,
|
|
119
|
+
sessionId,
|
|
120
|
+
startedAt,
|
|
121
|
+
completedAt,
|
|
122
|
+
} = {}) {
|
|
123
|
+
const none = { ranCleanup: false, at: null, matches: 0 };
|
|
124
|
+
|
|
125
|
+
if (typeof eventsFile !== 'string' || eventsFile.length === 0) return none;
|
|
126
|
+
|
|
127
|
+
const startMs = Date.parse(startedAt);
|
|
128
|
+
const endMs = Date.parse(completedAt);
|
|
129
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) {
|
|
130
|
+
return none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let raw;
|
|
134
|
+
try {
|
|
135
|
+
raw = readFileSync(eventsFile, 'utf8');
|
|
136
|
+
} catch {
|
|
137
|
+
// Missing / unreadable event log — no signal, never an error.
|
|
138
|
+
return none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const wantSession = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
142
|
+
let matches = 0;
|
|
143
|
+
let latestMs = -Infinity;
|
|
144
|
+
let latestTs = null;
|
|
145
|
+
|
|
146
|
+
for (const line of raw.split('\n')) {
|
|
147
|
+
// Cheap substring prefilter before the JSON.parse cost.
|
|
148
|
+
if (line.length === 0 || !line.includes(MEMORY_CLEANUP_EVENT)) continue;
|
|
149
|
+
|
|
150
|
+
let rec;
|
|
151
|
+
try {
|
|
152
|
+
rec = JSON.parse(line);
|
|
153
|
+
} catch {
|
|
154
|
+
continue; // corrupt line — skip, best-effort reader
|
|
155
|
+
}
|
|
156
|
+
if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) continue;
|
|
157
|
+
if (rec.event !== MEMORY_CLEANUP_EVENT) continue;
|
|
158
|
+
|
|
159
|
+
const tsMs = Date.parse(rec.timestamp);
|
|
160
|
+
if (!Number.isFinite(tsMs) || tsMs < startMs || tsMs > endMs) continue;
|
|
161
|
+
|
|
162
|
+
const semantic = rec.semantic_session_id;
|
|
163
|
+
if (
|
|
164
|
+
wantSession !== null &&
|
|
165
|
+
typeof semantic === 'string' &&
|
|
166
|
+
semantic.length > 0 &&
|
|
167
|
+
semantic !== wantSession
|
|
168
|
+
) {
|
|
169
|
+
continue; // attributed to a DIFFERENT session — not ours
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
matches += 1;
|
|
173
|
+
if (tsMs > latestMs) {
|
|
174
|
+
latestMs = tsMs;
|
|
175
|
+
latestTs = rec.timestamp;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { ranCleanup: matches > 0, at: latestTs, matches };
|
|
180
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirror-issues-banner.mjs — the mirror blind spot (#1022 follow-up)
|
|
3
|
+
*
|
|
4
|
+
* `skills/gitlab-ops/SKILL.md` § VCS Auto-Detection picks EXACTLY ONE platform
|
|
5
|
+
* via if/else. In a repo whose `origin` is GitLab and whose `github` remote is
|
|
6
|
+
* a public mirror, no code path ever reads issues from the mirror — so issues
|
|
7
|
+
* filed by external reporters against the mirror are structurally invisible to
|
|
8
|
+
* every session. This probe is the counter-measure: it asks the OTHER side.
|
|
9
|
+
*
|
|
10
|
+
* The VCS family is therefore HARD-PINNED to `'github'`, never auto-detected.
|
|
11
|
+
* Auto-detection is the defect this module exists to compensate for; deriving
|
|
12
|
+
* the family here would reproduce it.
|
|
13
|
+
*
|
|
14
|
+
* No new Session Config key by design. `resolveRepoSpec({ vcs:'github' })`
|
|
15
|
+
* derives the `gh -R` spec straight from `git remote` (its `REMOTE_PREFERENCE`
|
|
16
|
+
* tries the remote literally named `github` first, and its `WRONG_FAMILY_HOST`
|
|
17
|
+
* guard discards a cross-family match). That makes the probe SELF-DISABLING: a
|
|
18
|
+
* repo without a GitHub mirror resolves to `undefined` → `null` → no spawn, no
|
|
19
|
+
* network call. A config key would be a second SSOT drifting against
|
|
20
|
+
* `git remote`.
|
|
21
|
+
*
|
|
22
|
+
* Plain-JS — no Zod dependency. Never throws.
|
|
23
|
+
*
|
|
24
|
+
* Mirrors the Phase 4 banner contract documented in
|
|
25
|
+
* `scripts/lib/loop-readiness-banner.mjs:23-26`: a single `checkXxx()` entry
|
|
26
|
+
* point returning `null` or `{ severity, message, ... }`.
|
|
27
|
+
*
|
|
28
|
+
* Cross-references:
|
|
29
|
+
* - `scripts/lib/ci-status-banner.mjs` — the sibling project-facing probe
|
|
30
|
+
* (whose `null`-collapsing this module deliberately does NOT copy, see below).
|
|
31
|
+
* - `scripts/lib/reconcile-nudge-banner.mjs:183-186` — the same
|
|
32
|
+
* absence-preserving discipline applied to a count.
|
|
33
|
+
* - `skills/session-start/SKILL.md` Phase 4 — banner render site.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { execFile as _execFile } from 'node:child_process';
|
|
37
|
+
import { promisify } from 'node:util';
|
|
38
|
+
import { resolveRepoSpec as _resolveRepoSpec } from './vcs-repo-spec.mjs';
|
|
39
|
+
|
|
40
|
+
const execFileAsync = promisify(_execFile);
|
|
41
|
+
|
|
42
|
+
/** Default timeout in ms for the `gh` invocation. Mirrors `ci-status-banner.mjs:20`. */
|
|
43
|
+
export const DEFAULT_TIMEOUT_MS = 8000;
|
|
44
|
+
|
|
45
|
+
/** Default `--limit` passed to `gh issue list`. */
|
|
46
|
+
export const DEFAULT_LIMIT = 20;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The VCS family this probe queries. Deliberately a module constant, not a
|
|
50
|
+
* parameter: querying the auto-detected platform would make this probe a no-op
|
|
51
|
+
* in exactly the repos it exists for.
|
|
52
|
+
*/
|
|
53
|
+
const MIRROR_VCS = 'github';
|
|
54
|
+
|
|
55
|
+
/** How many issue numbers the banner message names before summarising. */
|
|
56
|
+
const MESSAGE_ISSUE_CAP = 5;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Closed set of `degraded` reasons. A degraded result means "the mirror was
|
|
60
|
+
* NOT successfully read" — never "the mirror is clean".
|
|
61
|
+
*
|
|
62
|
+
* `query-failed` is the residual bucket for a `gh` that ran and exited
|
|
63
|
+
* non-zero for a reason that is neither missing-CLI, timeout, nor auth
|
|
64
|
+
* (network down, repo renamed, rate limit). It is a deliberate fifth member
|
|
65
|
+
* beyond the four originally specified: folding those into `parse-error`
|
|
66
|
+
* would mislabel a network failure as malformed output, reintroducing the
|
|
67
|
+
* dishonest-state class this module was built to remove.
|
|
68
|
+
*
|
|
69
|
+
* @type {readonly ['cli-missing','timeout','parse-error','auth-error','query-failed']}
|
|
70
|
+
*/
|
|
71
|
+
export const DEGRADED_REASONS = Object.freeze([
|
|
72
|
+
'cli-missing',
|
|
73
|
+
'timeout',
|
|
74
|
+
'parse-error',
|
|
75
|
+
'auth-error',
|
|
76
|
+
'query-failed',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run `cmd` with a timeout race. Mirrors `ci-status-banner.mjs:30-38`.
|
|
81
|
+
*
|
|
82
|
+
* The timer is cleared and `unref`ed so a fast success does not hold the event
|
|
83
|
+
* loop open for the full budget (`.claude/rules/testing.md` § Async & Timeout
|
|
84
|
+
* Patterns). KNOWN LIMIT, inherited from the shared pattern and NOT fixed
|
|
85
|
+
* here: losing the race abandons the `gh` child process rather than killing
|
|
86
|
+
* it — a hung `gh` is left orphaned.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} cmd
|
|
89
|
+
* @param {string[]} args
|
|
90
|
+
* @param {{ cwd?: string, timeoutMs?: number, execFile?: Function }} [opts]
|
|
91
|
+
* @returns {Promise<{ stdout?: string, stderr?: string }>}
|
|
92
|
+
*/
|
|
93
|
+
async function execWithTimeout(cmd, args, opts = {}) {
|
|
94
|
+
const { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, execFile = execFileAsync } = opts;
|
|
95
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
96
|
+
let timer;
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([
|
|
99
|
+
execFile(cmd, args, { cwd, env: process.env }),
|
|
100
|
+
new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
|
|
102
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
103
|
+
}),
|
|
104
|
+
]);
|
|
105
|
+
} finally {
|
|
106
|
+
if (timer) clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Map a failed `gh` invocation onto a {@link DEGRADED_REASONS} member.
|
|
112
|
+
*
|
|
113
|
+
* @param {unknown} err
|
|
114
|
+
* @returns {'cli-missing'|'timeout'|'auth-error'|'query-failed'}
|
|
115
|
+
*/
|
|
116
|
+
function classifyFailure(err) {
|
|
117
|
+
const code = err && typeof err === 'object' ? /** @type {any} */ (err).code : undefined;
|
|
118
|
+
if (code === 'ENOENT') return 'cli-missing';
|
|
119
|
+
|
|
120
|
+
const message = err instanceof Error ? err.message : String(err ?? '');
|
|
121
|
+
if (message === 'timeout') return 'timeout';
|
|
122
|
+
|
|
123
|
+
const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
|
|
124
|
+
const haystack = `${message}\n${stderr}`.toLowerCase();
|
|
125
|
+
if (
|
|
126
|
+
haystack.includes('gh auth login') ||
|
|
127
|
+
haystack.includes('not logged in') ||
|
|
128
|
+
haystack.includes('authentication') ||
|
|
129
|
+
haystack.includes('http 401') ||
|
|
130
|
+
haystack.includes('http 403')
|
|
131
|
+
) {
|
|
132
|
+
return 'auth-error';
|
|
133
|
+
}
|
|
134
|
+
return 'query-failed';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the degraded result. Distinct from `null` on purpose: `null` in the
|
|
139
|
+
* banner contract reads as "all clear", which a failed query has NOT
|
|
140
|
+
* established.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} repoSpec
|
|
143
|
+
* @param {string} reason
|
|
144
|
+
* @returns {{ severity: 'warn', message: string, repoSpec: string, degraded: string }}
|
|
145
|
+
*/
|
|
146
|
+
function degradedResult(repoSpec, reason) {
|
|
147
|
+
return {
|
|
148
|
+
severity: 'warn',
|
|
149
|
+
message:
|
|
150
|
+
`⚠ mirror-issues: Mirror ${repoSpec} konnte nicht abgefragt werden (${reason}) ` +
|
|
151
|
+
`— Zustand unbekannt, nicht "sauber".`,
|
|
152
|
+
repoSpec,
|
|
153
|
+
degraded: reason,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render the found-issues banner message, naming issue numbers up to
|
|
159
|
+
* {@link MESSAGE_ISSUE_CAP} and summarising any remainder.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} repoSpec
|
|
162
|
+
* @param {Array<{ number: number, title: string }>} issues
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
function formatMessage(repoSpec, issues) {
|
|
166
|
+
const named = issues.slice(0, MESSAGE_ISSUE_CAP).map((i) => `#${i.number}`).join(', ');
|
|
167
|
+
const rest = issues.length - Math.min(issues.length, MESSAGE_ISSUE_CAP);
|
|
168
|
+
const tail = rest > 0 ? ` (+${rest} weitere)` : '';
|
|
169
|
+
const plural = issues.length === 1 ? 'offenes Issue' : 'offene Issues';
|
|
170
|
+
return (
|
|
171
|
+
`⚠ mirror-issues: ${issues.length} ${plural} im Mirror ${repoSpec}: ${named}${tail} ` +
|
|
172
|
+
`— kein anderer Codepfad dieser Session liest sie.`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check the GitHub mirror for open issues and produce a session-start banner.
|
|
178
|
+
*
|
|
179
|
+
* Return contract — three states, not two:
|
|
180
|
+
* - `null` when the mirror remote does not resolve (repo genuinely has no
|
|
181
|
+
* mirror; NO subprocess is spawned) or when the query SUCCEEDED and found
|
|
182
|
+
* zero open issues (measured and clean).
|
|
183
|
+
* - `{ severity:'warn', message, count, repoSpec, issues }` when N > 0.
|
|
184
|
+
* - `{ severity:'warn', message, repoSpec, degraded }` when the query did not
|
|
185
|
+
* succeed. `degraded` is present ONLY in this case, so its absence proves
|
|
186
|
+
* the mirror was actually read — the distinction `ci-status-banner.mjs`
|
|
187
|
+
* collapses (CLI-missing, bad JSON and no-remote all return `null` there,
|
|
188
|
+
* which is why the mirror gap went unseen for months).
|
|
189
|
+
*
|
|
190
|
+
* Never throws.
|
|
191
|
+
*
|
|
192
|
+
* @param {{ repoRoot?: string, timeoutMs?: number, limit?: number }} [opts]
|
|
193
|
+
* @param {{ execFile?: Function, resolveRepoSpec?: Function }} [deps] DI for tests only.
|
|
194
|
+
* @returns {Promise<null | { severity: 'warn', message: string, repoSpec: string, count?: number, issues?: Array<{number:number,title:string}>, degraded?: string }>}
|
|
195
|
+
*/
|
|
196
|
+
export async function checkMirrorIssues(opts = {}, deps = {}) {
|
|
197
|
+
try {
|
|
198
|
+
const { repoRoot, timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT } = opts ?? {};
|
|
199
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
200
|
+
|
|
201
|
+
const { execFile = execFileAsync, resolveRepoSpec = _resolveRepoSpec } = deps ?? {};
|
|
202
|
+
|
|
203
|
+
// Step 1 — resolve the mirror spec from `git remote`. `undefined` here is
|
|
204
|
+
// the self-disabling path: no mirror, no spawn, no network cost.
|
|
205
|
+
let repoSpec;
|
|
206
|
+
try {
|
|
207
|
+
repoSpec = resolveRepoSpec({ repoRoot, vcs: MIRROR_VCS });
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (!repoSpec || typeof repoSpec !== 'string') return null;
|
|
212
|
+
|
|
213
|
+
// Step 2 — query. Args array, never a shell string.
|
|
214
|
+
let stdout;
|
|
215
|
+
try {
|
|
216
|
+
const result = await execWithTimeout(
|
|
217
|
+
'gh',
|
|
218
|
+
[
|
|
219
|
+
'issue',
|
|
220
|
+
'list',
|
|
221
|
+
'-R',
|
|
222
|
+
repoSpec,
|
|
223
|
+
'--state',
|
|
224
|
+
'open',
|
|
225
|
+
'--limit',
|
|
226
|
+
String(limit),
|
|
227
|
+
'--json',
|
|
228
|
+
'number,title',
|
|
229
|
+
],
|
|
230
|
+
{ cwd: repoRoot, timeoutMs, execFile },
|
|
231
|
+
);
|
|
232
|
+
stdout = String(result?.stdout ?? '');
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return degradedResult(repoSpec, classifyFailure(err));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Step 3 — parse. A CLI that exited 0 with unusable output is degraded,
|
|
238
|
+
// NOT clean.
|
|
239
|
+
let issues;
|
|
240
|
+
try {
|
|
241
|
+
const parsed = JSON.parse(stdout);
|
|
242
|
+
if (!Array.isArray(parsed)) return degradedResult(repoSpec, 'parse-error');
|
|
243
|
+
issues = parsed
|
|
244
|
+
.filter((entry) => entry && typeof entry === 'object' && Number.isFinite(Number(entry.number)))
|
|
245
|
+
.map((entry) => ({ number: Number(entry.number), title: String(entry.title ?? '') }));
|
|
246
|
+
} catch {
|
|
247
|
+
return degradedResult(repoSpec, 'parse-error');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Step 4 — measured and clean.
|
|
251
|
+
if (issues.length === 0) return null;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
severity: 'warn',
|
|
255
|
+
message: formatMessage(repoSpec, issues),
|
|
256
|
+
count: issues.length,
|
|
257
|
+
repoSpec,
|
|
258
|
+
issues,
|
|
259
|
+
};
|
|
260
|
+
} catch {
|
|
261
|
+
// Defensive catch-all: a banner must never break session-start.
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export default checkMirrorIssues;
|