session-orchestrator 3.19.0 → 3.21.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 +10 -8
- package/CHANGELOG.md +494 -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 +1 -1
- 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/release.md +60 -0
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- 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 +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +236 -12
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +2 -2
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +45 -40
- package/scripts/export-hw-learnings.mjs +61 -2
- 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 +207 -23
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +704 -0
- 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/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +134 -1
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +852 -72
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-banner-parity.mjs +376 -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-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
- package/scripts/lib/validate/check-rules.mjs +31 -5
- 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 +757 -0
- 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-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 +500 -19
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +501 -0
- package/scripts/release.mjs +616 -61
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +82 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- 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/evolve/SKILL.md +116 -18
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +54 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/soul.md +44 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/plan/soul.md +46 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +45 -26
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +6 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +15 -1
- package/skills/session-start/soul.md +41 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- 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/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +77 -82
- package/scripts/lib/mission-status-schema.mjs +0 -114
package/scripts/emit-session.mjs
CHANGED
|
@@ -25,8 +25,15 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
28
29
|
import { fileURLToPath } from 'node:url';
|
|
29
30
|
import { appendJsonl } from './lib/common.mjs';
|
|
31
|
+
import {
|
|
32
|
+
MEMORY_CLEANUP_EVENT,
|
|
33
|
+
deriveMemoryCleanupSignal,
|
|
34
|
+
stampMemoryCleanup,
|
|
35
|
+
} from './lib/memory-cleanup-stamp.mjs';
|
|
36
|
+
import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
|
|
30
37
|
import {
|
|
31
38
|
validateSession,
|
|
32
39
|
ValidationError,
|
|
@@ -35,45 +42,7 @@ import {
|
|
|
35
42
|
aliasLegacyEndedAt,
|
|
36
43
|
} from './lib/session-schema.mjs';
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
* Serialize `validated` to a single JSONL line and prove it round-trips:
|
|
40
|
-
* the line MUST be JSON-parseable AND the parsed-back object MUST still pass
|
|
41
|
-
* `validateSession`. This is the pre-write self-validation seam (#662) that
|
|
42
|
-
* mirrors the `appendLearning` guard — it catches non-serializable values
|
|
43
|
-
* (`undefined`, `NaN`, `Infinity`, `BigInt`, circular refs) that
|
|
44
|
-
* `JSON.stringify` silently drops or that produce a line which won't parse
|
|
45
|
-
* back to the same schema-valid shape.
|
|
46
|
-
*
|
|
47
|
-
* Throws ValidationError (consumed by the caller as a validation failure,
|
|
48
|
-
* exit 1). Does not write — the caller appends only after this returns.
|
|
49
|
-
*
|
|
50
|
-
* @param {object} validated — already validated session entry
|
|
51
|
-
* @returns {string} the verified JSONL line (newline-terminated)
|
|
52
|
-
* @throws {ValidationError} when the serialized line does not round-trip
|
|
53
|
-
*/
|
|
54
|
-
export function serializeSessionLineChecked(validated) {
|
|
55
|
-
let line;
|
|
56
|
-
try {
|
|
57
|
-
line = JSON.stringify(validated);
|
|
58
|
-
} catch (err) {
|
|
59
|
-
throw new ValidationError(`session is not JSON-serializable: ${err.message}`);
|
|
60
|
-
}
|
|
61
|
-
if (typeof line !== 'string' || line.length === 0) {
|
|
62
|
-
throw new ValidationError('session serialized to an empty line');
|
|
63
|
-
}
|
|
64
|
-
let reparsed;
|
|
65
|
-
try {
|
|
66
|
-
reparsed = JSON.parse(line);
|
|
67
|
-
} catch (err) {
|
|
68
|
-
throw new ValidationError(
|
|
69
|
-
`serialized session line does not parse back as JSON: ${err.message}`
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
// Re-validate the round-tripped shape — catches required fields that were
|
|
73
|
-
// present as `undefined`/`NaN` before stringify but vanished after.
|
|
74
|
-
validateSession(reparsed);
|
|
75
|
-
return line + '\n';
|
|
76
|
-
}
|
|
45
|
+
export { serializeSessionLineChecked };
|
|
77
46
|
|
|
78
47
|
function parseArgs(argv) {
|
|
79
48
|
const args = { file: '.orchestrator/metrics/sessions.jsonl', entry: null };
|
|
@@ -148,6 +117,42 @@ async function main() {
|
|
|
148
117
|
);
|
|
149
118
|
}
|
|
150
119
|
|
|
120
|
+
// `memory_cleanup_at` derivation (#699 follow-up — Disziplin statt Mechanik).
|
|
121
|
+
// The flag used to be a boolean the coordinator-LLM remembered to pass at
|
|
122
|
+
// session-end; it was forgotten on 2026-08-14 and the cadence marker stalled
|
|
123
|
+
// 29 days behind the operator's own notes. It is now READ from the session's
|
|
124
|
+
// own `orchestrator.memory.cleanup_completed` events, sitting in the sibling
|
|
125
|
+
// events.jsonl of the target sessions.jsonl (same `.orchestrator/metrics/`
|
|
126
|
+
// directory in production, same tmp dir under test — no env plumbing).
|
|
127
|
+
//
|
|
128
|
+
// Precedence: an EXPLICIT `memory_cleanup_at` on the incoming record WINS and
|
|
129
|
+
// is never overwritten — an explicit stamp is a caller's positive assertion,
|
|
130
|
+
// while derivation only fills the gap left by silence.
|
|
131
|
+
const alreadyStamped =
|
|
132
|
+
typeof repaired.memory_cleanup_at === 'string' && repaired.memory_cleanup_at.length > 0;
|
|
133
|
+
if (!alreadyStamped) {
|
|
134
|
+
const eventsFile = join(dirname(args.file), 'events.jsonl');
|
|
135
|
+
const signal = deriveMemoryCleanupSignal({
|
|
136
|
+
eventsFile,
|
|
137
|
+
sessionId: repaired.session_id,
|
|
138
|
+
startedAt: repaired.started_at,
|
|
139
|
+
completedAt: repaired.completed_at,
|
|
140
|
+
});
|
|
141
|
+
if (signal.ranCleanup) {
|
|
142
|
+
const before = repaired;
|
|
143
|
+
repaired = stampMemoryCleanup(repaired, {
|
|
144
|
+
ranCleanup: true,
|
|
145
|
+
completedAt: repaired.completed_at,
|
|
146
|
+
});
|
|
147
|
+
if (repaired !== before) {
|
|
148
|
+
process.stderr.write(
|
|
149
|
+
`emit-session: derived memory_cleanup_at=${repaired.memory_cleanup_at} from ` +
|
|
150
|
+
`${signal.matches} ${MEMORY_CLEANUP_EVENT} event(s) (latest ${signal.at})\n`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
151
156
|
let validated;
|
|
152
157
|
try {
|
|
153
158
|
validated = validateSession(repaired);
|
|
@@ -167,7 +172,7 @@ async function main() {
|
|
|
167
172
|
// sessions.jsonl and only surface on the NEXT session's read. Treated as a
|
|
168
173
|
// validation failure (exit 1); file is left untouched.
|
|
169
174
|
try {
|
|
170
|
-
serializeSessionLineChecked(
|
|
175
|
+
serializeSessionLineChecked(repaired);
|
|
171
176
|
} catch (err) {
|
|
172
177
|
if (err instanceof ValidationError) {
|
|
173
178
|
process.stderr.write(
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
*
|
|
23
23
|
* ## Anonymization pipeline (enforced; no opt-out)
|
|
24
24
|
*
|
|
25
|
+
* - Mask env-derived secret VALUES first (#1025, value-based — orthogonal to the
|
|
26
|
+
* form-based rules below; see the note above `maskSecretValues`)
|
|
25
27
|
* - Strip all absolute paths (macOS, Linux system paths, Windows)
|
|
26
28
|
* - Redact IPv4 addresses
|
|
27
29
|
* - Redact GitHub/GitLab URLs containing org/repo paths
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
} from './lib/learnings.mjs';
|
|
56
58
|
import { findProjectRoot, resolveInstructionFile, expandTilde } from './lib/common.mjs';
|
|
57
59
|
import { parseSessionConfig } from './lib/config.mjs';
|
|
60
|
+
import { createSecretValueMasker } from './lib/secret-masker.mjs';
|
|
58
61
|
|
|
59
62
|
// Vault-relative default write target (Epic #774 — docs Public-Split removed
|
|
60
63
|
// the prior in-repo generated telemetry doc in favor of the private Meta-Vault).
|
|
@@ -135,6 +138,58 @@ const SIGNED_OFF_RE = /Signed-off-by:[^\n]+/g;
|
|
|
135
138
|
// those never appear with a .local/.lan/.home suffix.
|
|
136
139
|
const HOSTNAME_RE = /\b[A-Za-z0-9]+(?:[-.][A-Za-z0-9]+)*\.(?:local|lan|home|internal|corp)\b/g;
|
|
137
140
|
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Value-based secret masking (#1025) — the SECOND half of the pipeline
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
//
|
|
145
|
+
// `anonymizeString` below is FORM-based: eight regexes over the SHAPE of a
|
|
146
|
+
// string. `createSecretValueMasker` is VALUE-based: it looks for the literal
|
|
147
|
+
// values of secret-NAMED env vars. The two are orthogonal and BOTH are needed —
|
|
148
|
+
// measured on four probe secrets:
|
|
149
|
+
//
|
|
150
|
+
// AWS secret key (with slashes) FORM: secret survives VALUE: [REDACTED]
|
|
151
|
+
// DB password (short, symbols) FORM: secret survives VALUE: [REDACTED]
|
|
152
|
+
// GitLab PAT (token shape) FORM: <redacted-token> VALUE: [REDACTED]
|
|
153
|
+
// all-letter passphrase FORM: secret survives VALUE: [REDACTED]
|
|
154
|
+
//
|
|
155
|
+
// The AWS key is the sharpest case: `/` is outside TOKEN_RE's character class,
|
|
156
|
+
// so the key breaks into runs of 13/7/18 characters — every one of them BELOW
|
|
157
|
+
// the 20-character floor, hence not a single match. Conversely the form catches
|
|
158
|
+
// tokens of FOREIGN hosts that were never in `process.env`, which the value
|
|
159
|
+
// masker cannot see. Neither subsumes the other.
|
|
160
|
+
//
|
|
161
|
+
// ORDER IS LOAD-BEARING: mask FIRST, anonymize SECOND. `[REDACTED]` is 8
|
|
162
|
+
// characters with no digit, so it passes through `anonymizeString` untouched
|
|
163
|
+
// (TOKEN_RE requires ≥20 chars AND a digit). The reverse order blinds the
|
|
164
|
+
// masker: a value that `anonymizeString` has already rewritten to
|
|
165
|
+
// `<redacted-token>` is no longer findable as a literal, so a secret that only
|
|
166
|
+
// the VALUE filter would catch — because the form leaves a residue rather than
|
|
167
|
+
// the whole span — can no longer be matched.
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Lazily-built, process-wide masker. `createSecretValueMasker` scans the whole
|
|
171
|
+
* env and compiles one RegExp per needle, so it is built ONCE per process and
|
|
172
|
+
* reused for every entry — never rebuilt per record.
|
|
173
|
+
*
|
|
174
|
+
* @type {{ mask: (text: string) => string, needleCount: number } | null}
|
|
175
|
+
*/
|
|
176
|
+
let _secretMasker = null;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Mask env-derived secret VALUES in a free-form string.
|
|
180
|
+
*
|
|
181
|
+
* Fail-soft: non-strings pass through by reference, and a zero-needle env makes
|
|
182
|
+
* `mask` the identity — the masker must never be the reason an export dies.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} s
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
function maskSecretValues(s) {
|
|
188
|
+
if (typeof s !== 'string') return s;
|
|
189
|
+
if (_secretMasker === null) _secretMasker = createSecretValueMasker(process.env);
|
|
190
|
+
return _secretMasker.mask(s);
|
|
191
|
+
}
|
|
192
|
+
|
|
138
193
|
/**
|
|
139
194
|
* Scrub a free-form string of PII / host-identifying content.
|
|
140
195
|
* Order matters: author/signoff patterns first (they contain emails), then
|
|
@@ -185,8 +240,12 @@ export function anonymizeLearning(entry) {
|
|
|
185
240
|
const e = normalizeLearning(entry);
|
|
186
241
|
const out = {
|
|
187
242
|
...e,
|
|
188
|
-
|
|
189
|
-
|
|
243
|
+
// #1025: value-mask BEFORE form-anonymize — see the ORDER IS LOAD-BEARING
|
|
244
|
+
// note above `maskSecretValues`. This is the choke-point for BOTH write
|
|
245
|
+
// paths (promoteHwLearnings and exportHwLearnings both route through here);
|
|
246
|
+
// wiring at exportHwLearnings alone would leave the promote path unhardened.
|
|
247
|
+
insight: anonymizeString(maskSecretValues(e.insight)),
|
|
248
|
+
evidence: anonymizeString(maskSecretValues(e.evidence)),
|
|
190
249
|
// Stamp after redaction so callers that write back through validateLearning
|
|
191
250
|
// do not hit the scope=public contract check.
|
|
192
251
|
anonymized: true,
|
|
@@ -315,14 +315,14 @@ export async function teardownWorktree(context, result, opts = {}) {
|
|
|
315
315
|
try {
|
|
316
316
|
// #987 defense-in-depth: pass the in-memory genesis proof (captured by
|
|
317
317
|
// runStoryPipeline right after acquire — same process, no file I/O)
|
|
318
|
-
// so the delete is double-gated at the fs layer.
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
318
|
+
// so the delete is double-gated at the fs layer. `_lockOwnerProof` is
|
|
319
|
+
// null/undefined when the lock could not yield a full proof; release()
|
|
320
|
+
// gates on `proof != null` (#989) and degrades to the session_id-only
|
|
321
|
+
// path for that case, so no call-site guard is needed.
|
|
322
322
|
releaseResult = release({
|
|
323
323
|
sessionId: result._lockSessionId,
|
|
324
324
|
repoRoot: result.worktreePath,
|
|
325
|
-
|
|
325
|
+
proof: result._lockOwnerProof,
|
|
326
326
|
});
|
|
327
327
|
} catch (lockErr) {
|
|
328
328
|
console.error(
|
|
@@ -10,39 +10,96 @@
|
|
|
10
10
|
* - Module-level cache keyed on (vcs, limit) — one VCS round-trip per session.
|
|
11
11
|
* - Graceful degradation: missing CLI / non-zero exit / parse failure → null.
|
|
12
12
|
* Never throws to the caller.
|
|
13
|
+
* - No silent caps: the scan reads at most `limit` issues, so every count is a
|
|
14
|
+
* LOWER BOUND when the window fills. `truncated` says so in the return value
|
|
15
|
+
* and a WARNING says so on stderr — a bound that is applied is announced.
|
|
13
16
|
*
|
|
14
17
|
* Stale threshold: 30 days since `updated_at`.
|
|
15
18
|
*
|
|
16
19
|
* Dependencies:
|
|
17
|
-
* - VCS detection
|
|
20
|
+
* - VCS detection delegates to `vcs-repo-spec.mjs::detectVcsFamily` (#1039):
|
|
21
|
+
* remote-host family classification over ALL remotes, not a hard-coded
|
|
22
|
+
* `origin` lookup with a `url.includes('github.com')` test.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { spawnSync } from 'node:child_process';
|
|
21
26
|
|
|
27
|
+
import { warn } from './common.mjs';
|
|
22
28
|
import { normalizeLabel } from './label-scope.mjs';
|
|
23
|
-
import { resolveRepoSpec } from './vcs-repo-spec.mjs';
|
|
29
|
+
import { detectVcsFamily, isQueryFailure, redactUrlCredentials, resolveRepoSpec } from './vcs-repo-spec.mjs';
|
|
24
30
|
|
|
25
31
|
export const STALE_THRESHOLD_DAYS = 30;
|
|
26
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Default scan window, and the SINGLE source for that number — every caller
|
|
35
|
+
* either omits `limit` or imports this constant. A second hand-written copy is
|
|
36
|
+
* what made the old default wrong in three places at once.
|
|
37
|
+
*
|
|
38
|
+
* Ceiling: 100 is the largest single-request page BOTH CLIs serve reliably
|
|
39
|
+
* (the GitLab API clamps `per_page` at 100), so it is the widest exact window
|
|
40
|
+
* available without paginating. Above it the scan truncates — `truncated: true`
|
|
41
|
+
* plus a stderr WARNING announce that, and every count becomes a lower bound.
|
|
42
|
+
* Revisit with a `--page` loop if a repo's OPEN backlog routinely exceeds 100.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_BACKLOG_LIMIT = 100;
|
|
45
|
+
|
|
27
46
|
/** Module-level cache. Keyed by JSON.stringify({vcs, limit}). */
|
|
28
47
|
const _cache = new Map();
|
|
29
48
|
|
|
30
49
|
/**
|
|
31
|
-
* Detect the VCS
|
|
50
|
+
* Detect the VCS family of a repo from its git remotes.
|
|
32
51
|
* Returns 'github' | 'gitlab' | null. Never throws.
|
|
33
52
|
*
|
|
53
|
+
* This probe cannot take `vcs` as a parameter and cannot use
|
|
54
|
+
* `resolveRepoSpec({vcs})`: it runs in order to DETERMINE `vcs`. So it uses the
|
|
55
|
+
* vcs-less projection {@link detectVcsFamily}, which classifies every remote by
|
|
56
|
+
* URL host (then by remote name) instead of pinning `origin`.
|
|
57
|
+
*
|
|
58
|
+
* Two #1039 defects this replaces:
|
|
59
|
+
* 1. `git remote get-url origin` returned non-zero on a repo whose remotes are
|
|
60
|
+
* named `gitlab`/`github` (no `origin`) — a perfectly scannable backlog
|
|
61
|
+
* read as "no VCS".
|
|
62
|
+
* 2. `url.includes('github.com')` classified GitHub Enterprise
|
|
63
|
+
* (`git@github.example.com:o/r.git`) as gitlab, pointing `glab` at a GitHub
|
|
64
|
+
* instance. `detectVcsFamily`'s host rule (`github.*`) covers it.
|
|
65
|
+
*
|
|
66
|
+
* `null` still means "no backlog signal" to the caller, but the two states it
|
|
67
|
+
* used to fold are now distinguishable on stderr: a QUERY FAILURE (git missing,
|
|
68
|
+
* not a git repo) emits exactly one WARNING, because a 40-issue backlog reading
|
|
69
|
+
* as empty is a degraded measurement the operator must see. An ABSENCE
|
|
70
|
+
* (`no-remotes`, `no-matching-remote`) stays SILENT — it is a legitimate repo
|
|
71
|
+
* state, and warning on it would train operators to ignore the warning that
|
|
72
|
+
* matters.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ repoRoot?: string, gitRun?: (args: string[]) => { ok: boolean, stdout?: string, stderr?: string, status?: number, code?: string } }} [opts]
|
|
75
|
+
* `gitRun` is the injectable git seam of `detectVcsFamily` (tests stub it
|
|
76
|
+
* instead of shelling out); `repoRoot` defaults to `process.cwd()` there.
|
|
34
77
|
* @returns {'github'|'gitlab'|null}
|
|
35
78
|
*/
|
|
36
|
-
export function detectVcs() {
|
|
79
|
+
export function detectVcs({ repoRoot, gitRun } = {}) {
|
|
80
|
+
let detected;
|
|
37
81
|
try {
|
|
38
|
-
|
|
39
|
-
if (r.status !== 0) return null;
|
|
40
|
-
const url = String(r.stdout || '').trim();
|
|
41
|
-
if (!url) return null;
|
|
42
|
-
return url.includes('github.com') ? 'github' : 'gitlab';
|
|
82
|
+
detected = detectVcsFamily({ repoRoot, gitRun });
|
|
43
83
|
} catch {
|
|
44
84
|
return null;
|
|
45
85
|
}
|
|
86
|
+
|
|
87
|
+
if (detected.ok) return detected.vcs;
|
|
88
|
+
|
|
89
|
+
if (isQueryFailure(detected.reason)) {
|
|
90
|
+
// Redact before logging: git stderr can echo a remote URL carrying
|
|
91
|
+
// userinfo credentials (#907, CWE-214). First line only — a git fatal is
|
|
92
|
+
// one line, and the rest is noise in a session banner.
|
|
93
|
+
const detail = redactUrlCredentials(String(detected.stderr || ''))
|
|
94
|
+
.split('\n')[0]
|
|
95
|
+
.trim();
|
|
96
|
+
warn(
|
|
97
|
+
`backlog scan could not determine the VCS family (${detected.reason})` +
|
|
98
|
+
`${detail ? `: ${detail}` : ''} — backlog signal degraded to null (contributes 0 delta to mode selection).`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
46
103
|
}
|
|
47
104
|
|
|
48
105
|
/**
|
|
@@ -91,6 +148,10 @@ function ageDays(iso, nowMs) {
|
|
|
91
148
|
* - `labels`: array of strings OR array of {name: string} objects
|
|
92
149
|
* - `updated_at` (glab) or `updatedAt` (gh): ISO-8601 timestamp
|
|
93
150
|
*
|
|
151
|
+
* `total` is the number of records AGGREGATED, never the number of records that
|
|
152
|
+
* exist in the tracker — the caller decides the window, so only the caller
|
|
153
|
+
* (or `scanBacklog`'s `truncated` flag) can tell the two apart.
|
|
154
|
+
*
|
|
94
155
|
* @param {Array<object>} issues
|
|
95
156
|
* @param {number} nowMs — injected for tests
|
|
96
157
|
* @returns {{criticalCount: number, highCount: number, staleCount: number, byLabel: Record<string, number>, total: number}}
|
|
@@ -140,12 +201,21 @@ export function summarizeIssues(issues, nowMs = Date.now()) {
|
|
|
140
201
|
* (vcs, limit) pair never collide on a shared cache entry.
|
|
141
202
|
*
|
|
142
203
|
* Returns null on any of:
|
|
143
|
-
* - VCS cannot be detected (no git
|
|
204
|
+
* - VCS family cannot be detected ({@link detectVcs} — no remotes, or git
|
|
205
|
+
* itself unavailable; the latter also emits one WARNING)
|
|
144
206
|
* - CLI binary missing (`glab` for gitlab, `gh` for github)
|
|
145
207
|
* - CLI exits non-zero or produces unparsable output
|
|
146
208
|
*
|
|
147
209
|
* Never throws.
|
|
148
210
|
*
|
|
211
|
+
* The scan reads at most `limit` records (default `DEFAULT_BACKLOG_LIMIT`), so
|
|
212
|
+
* every count is a LOWER BOUND once the window fills. `truncated` reports that:
|
|
213
|
+
* `true` means the CLI returned a full window, so records — and the critical /
|
|
214
|
+
* high / stale issues among them — may lie beyond it. It is deliberately
|
|
215
|
+
* conservative: a backlog of exactly `limit` issues reports `truncated: true`
|
|
216
|
+
* even though nothing was missed. Over-reporting "you may have missed some" is
|
|
217
|
+
* safe; under-reporting it is the bug this flag exists to prevent.
|
|
218
|
+
*
|
|
149
219
|
* @param {{
|
|
150
220
|
* limit?: number,
|
|
151
221
|
* vcs?: 'github'|'gitlab'|null,
|
|
@@ -160,19 +230,23 @@ export function summarizeIssues(issues, nowMs = Date.now()) {
|
|
|
160
230
|
* get-url`); tests inject a stub instead of shelling out. `runJsonFn` is
|
|
161
231
|
* the injectable seam for the CLI runner — defaults to the real `runJson`
|
|
162
232
|
* (shells out to `glab`/`gh`).
|
|
163
|
-
* @returns {Promise<null | {criticalCount: number, highCount: number, staleCount: number, byLabel: Record<string, number>, total: number, vcs: string, limit: number}>}
|
|
233
|
+
* @returns {Promise<null | {criticalCount: number, highCount: number, staleCount: number, byLabel: Record<string, number>, total: number, vcs: string, limit: number, truncated: boolean}>}
|
|
164
234
|
*/
|
|
165
235
|
export async function scanBacklog(opts = {}) {
|
|
166
|
-
const limit =
|
|
236
|
+
const limit =
|
|
237
|
+
Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : DEFAULT_BACKLOG_LIMIT;
|
|
238
|
+
const repoRoot = typeof opts.repoRoot === 'string' ? opts.repoRoot : process.cwd();
|
|
167
239
|
// Distinguish "user did not pass vcs" (auto-detect) from "user explicitly passed
|
|
168
240
|
// null" (degrade). 'vcs' in opts catches the explicit-null path so callers can
|
|
169
241
|
// force the no-vcs branch in tests without monkey-patching detectVcs.
|
|
170
|
-
|
|
242
|
+
// `repoRoot` is passed through so detection and `-R` spec resolution below
|
|
243
|
+
// answer about the SAME repo — they used to disagree whenever a caller passed
|
|
244
|
+
// `repoRoot` (detection silently read `process.cwd()` instead).
|
|
245
|
+
const vcs = 'vcs' in opts ? opts.vcs : detectVcs({ repoRoot });
|
|
171
246
|
const nowMs = typeof opts.nowMs === 'number' ? opts.nowMs : Date.now();
|
|
172
247
|
|
|
173
248
|
if (vcs !== 'github' && vcs !== 'gitlab') return null;
|
|
174
249
|
|
|
175
|
-
const repoRoot = typeof opts.repoRoot === 'string' ? opts.repoRoot : process.cwd();
|
|
176
250
|
const resolveRepoSpecFn =
|
|
177
251
|
typeof opts.resolveRepoSpecFn === 'function' ? opts.resolveRepoSpecFn : resolveRepoSpec;
|
|
178
252
|
const runJsonFn = typeof opts.runJsonFn === 'function' ? opts.runJsonFn : runJson;
|
|
@@ -197,8 +271,25 @@ export async function scanBacklog(opts = {}) {
|
|
|
197
271
|
return null;
|
|
198
272
|
}
|
|
199
273
|
|
|
274
|
+
// A full window means records may lie beyond it, so every count is a lower
|
|
275
|
+
// bound. `>=` (not `> `) because the CLIs cap silently: the GitLab API clamps
|
|
276
|
+
// `per_page` at 100, so an over-fetch of `limit + 1` would come back capped
|
|
277
|
+
// and read as "not truncated" — under-approximating exactly the way the
|
|
278
|
+
// window itself did. A full window is the only signal that survives clamping.
|
|
279
|
+
const truncated = issues.length >= limit;
|
|
280
|
+
|
|
200
281
|
const summary = summarizeIssues(issues, nowMs);
|
|
201
|
-
const result = { ...summary, vcs, limit };
|
|
282
|
+
const result = { ...summary, vcs, limit, truncated };
|
|
283
|
+
|
|
284
|
+
// Announce the bound (never a silent cap). Emitted once per cache key — a
|
|
285
|
+
// cache hit returns before this point, so a per-session scan warns once.
|
|
286
|
+
if (truncated) {
|
|
287
|
+
warn(
|
|
288
|
+
`backlog scan filled its ${limit}-issue window (${bin}): criticalCount/highCount/staleCount are LOWER BOUNDS. ` +
|
|
289
|
+
`Pass a larger limit for exact counts.`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
202
293
|
_cache.set(cacheKey, result);
|
|
203
294
|
return result;
|
|
204
295
|
}
|
|
@@ -18,7 +18,7 @@ import { resolve } from 'node:path';
|
|
|
18
18
|
import { parseStateMd, parseRecommendations } from './state-md.mjs';
|
|
19
19
|
import { normalizeSession, tailRealSessions } from './session-schema.mjs';
|
|
20
20
|
import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
|
|
21
|
-
import { scanBacklog } from './backlog-scan.mjs';
|
|
21
|
+
import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
|
|
22
22
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
// Public API
|
|
@@ -36,7 +36,11 @@ import { scanBacklog } from './backlog-scan.mjs';
|
|
|
36
36
|
* @param {string} [opts.sessionsPath] — defaults to '.orchestrator/metrics/sessions.jsonl'
|
|
37
37
|
* @param {string} [opts.lockPath] — defaults to '.orchestrator/bootstrap.lock'
|
|
38
38
|
* @param {Array} [opts.learnings] — pre-surfaced top-N learnings; defaults to []
|
|
39
|
-
* @param {number} [opts.backlogLimit] — passed to scanBacklog; defaults to
|
|
39
|
+
* @param {number} [opts.backlogLimit] — passed to scanBacklog; defaults to
|
|
40
|
+
* `DEFAULT_BACKLOG_LIMIT` from backlog-scan.mjs (never a local copy of that
|
|
41
|
+
* number). A window smaller than the repo's open backlog makes
|
|
42
|
+
* `backlog.criticalCount`/`highCount`/`staleCount` lower bounds —
|
|
43
|
+
* `backlog.truncated` is the flag that says so.
|
|
40
44
|
* @param {number} [opts.sessionTailN] — defaults to 10 (last N sessions)
|
|
41
45
|
* @param {Function} [opts._scanBacklog] — injectable seam for tests (defaults to scanBacklog)
|
|
42
46
|
* @returns {Promise<import('./mode-selector.mjs').Signals>}
|
|
@@ -56,7 +60,7 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
56
60
|
const learnings = Array.isArray(opts.learnings) ? opts.learnings : [];
|
|
57
61
|
const backlogLimit = typeof opts.backlogLimit === 'number' && opts.backlogLimit > 0
|
|
58
62
|
? opts.backlogLimit
|
|
59
|
-
:
|
|
63
|
+
: DEFAULT_BACKLOG_LIMIT;
|
|
60
64
|
const sessionTailN = typeof opts.sessionTailN === 'number' && opts.sessionTailN > 0
|
|
61
65
|
? opts.sessionTailN
|
|
62
66
|
: 10;
|