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
|
@@ -205,20 +205,132 @@ export function selectPiHooks(manifest, piEventName, event = {}) {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
208
|
+
* Substrings that identify a line as an *attempted* decision envelope even when
|
|
209
|
+
* it no longer parses — the tell-tale of a truncated `emitDeny` payload.
|
|
210
210
|
*/
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
const DECISION_MARKERS = Object.freeze(['permissionDecision', 'hookSpecificOutput']);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Decide whether an unparseable stdout line is corrupt hook PROTOCOL output
|
|
215
|
+
* (fail closed) or merely human-facing chatter (fail open).
|
|
216
|
+
*
|
|
217
|
+
* The discriminator is deliberately narrow. "stdout is non-empty but nothing
|
|
218
|
+
* parsed ⇒ block" — the obvious fail-closed rule — would turn every third-party
|
|
219
|
+
* hook that prints a progress line or a debug trace into a permanent blocker,
|
|
220
|
+
* because the Pi bridge spawns whatever the manifest points at. A line that
|
|
221
|
+
* OPENS A JSON OBJECT, or that name-drops a decision field, is by contrast
|
|
222
|
+
* malformed by its own protocol's standard; no legitimate log line looks like
|
|
223
|
+
* that.
|
|
224
|
+
*
|
|
225
|
+
* `[` is deliberately NOT an opener here even though it opens a JSON array: a
|
|
226
|
+
* decision envelope is always an object, so an array line can never be a
|
|
227
|
+
* truncated one — while `[####----] 50%` is a perfectly ordinary progress bar.
|
|
228
|
+
* Treating `[` as protocol output blocked that hook in an earlier cut of this
|
|
229
|
+
* function; `tests/lib/pi-hook-bridge.test.mjs` pins the carve-out.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} line Trimmed, non-empty stdout line that failed JSON.parse.
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
function looksLikeBrokenEnvelope(line) {
|
|
235
|
+
if (line.startsWith('{')) return true;
|
|
236
|
+
return DECISION_MARKERS.some((marker) => line.includes(marker));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Pull `{decision, reason}` out of ONE parsed envelope, accepting both
|
|
241
|
+
* generations of the deny protocol.
|
|
242
|
+
*
|
|
243
|
+
* @param {unknown} parsed
|
|
244
|
+
* @returns {{ decision: string|null, reason: string|null }}
|
|
245
|
+
*/
|
|
246
|
+
function decisionFromEnvelope(parsed) {
|
|
247
|
+
const envelope = objectOrEmpty(parsed);
|
|
248
|
+
const hookSpecific = objectOrEmpty(envelope.hookSpecificOutput);
|
|
249
|
+
|
|
250
|
+
const decision = typeof hookSpecific.permissionDecision === 'string'
|
|
251
|
+
? hookSpecific.permissionDecision
|
|
252
|
+
: (typeof envelope.permissionDecision === 'string' ? envelope.permissionDecision : null);
|
|
253
|
+
|
|
254
|
+
const reason = typeof hookSpecific.permissionDecisionReason === 'string'
|
|
255
|
+
? hookSpecific.permissionDecisionReason
|
|
256
|
+
: (typeof envelope.reason === 'string' ? envelope.reason : null);
|
|
257
|
+
|
|
258
|
+
return { decision, reason };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Extract the permission decision + reason from a hook's stdout, accepting BOTH
|
|
263
|
+
* generations of the deny protocol.
|
|
264
|
+
*
|
|
265
|
+
* - **Current** (`scripts/lib/io.mjs#emitDeny`, exit 0):
|
|
266
|
+
* `{ hookSpecificOutput: { hookEventName, permissionDecision, permissionDecisionReason } }`
|
|
267
|
+
* - **Legacy** (pre-#906 in-repo hooks, and third-party hooks outside this repo
|
|
268
|
+
* that still emit the deprecated flat shape): `{ permissionDecision, reason }`
|
|
269
|
+
*
|
|
270
|
+
* Legacy support is NOT optional: the bridge spawns whatever commands the Pi
|
|
271
|
+
* manifest points at, including hooks this repo does not own. Dropping the flat
|
|
272
|
+
* form would silently stop honouring their denies.
|
|
273
|
+
*
|
|
274
|
+
* Reading the reason out of the envelope also avoids the failure mode where the
|
|
275
|
+
* caller falls through to `stdout.trim()` and surfaces the raw JSON string as
|
|
276
|
+
* the human-readable block reason.
|
|
277
|
+
*
|
|
278
|
+
* ## Why this reads raw stdout, and why the FIRST envelope wins
|
|
279
|
+
*
|
|
280
|
+
* Since #906 every in-repo PreToolUse hook denies with **exit 0** plus this
|
|
281
|
+
* envelope, so on the Pi lane `status === 2` no longer fires and the stdout read
|
|
282
|
+
* is the ONLY surviving block signal. Two ways the previous last-parseable-line
|
|
283
|
+
* scan dropped that signal:
|
|
284
|
+
*
|
|
285
|
+
* 1. **Anything printed after the envelope shadowed it.** `emitSystemMessage`,
|
|
286
|
+
* or any third-party hook's trailing JSON, became "the" decision line and the
|
|
287
|
+
* deny read as "no decision" ⇒ allow. Scanning forward and keeping the first
|
|
288
|
+
* line that actually carries a `permissionDecision` removes that shadowing:
|
|
289
|
+
* non-decision JSON lines are now skipped rather than mistaken for a verdict.
|
|
290
|
+
* 2. **A truncated envelope parsed as nothing at all** ⇒ allow. `malformed`
|
|
291
|
+
* reports that case up to {@link runPiHookCommand}, which fails closed for
|
|
292
|
+
* PreToolUse. It stays set even when a later line does carry a decision:
|
|
293
|
+
* garbage interleaved with a verdict is a protocol violation, and a mangled
|
|
294
|
+
* line is exactly as likely to have been a deny as an allow.
|
|
295
|
+
*
|
|
296
|
+
* @param {unknown} input Raw hook stdout (preferred), or an already-parsed
|
|
297
|
+
* envelope object. Non-string input skips the line scan and can never
|
|
298
|
+
* report `malformed`.
|
|
299
|
+
* @returns {{ decision: string|null, reason: string|null, deny: boolean, malformed: boolean }}
|
|
300
|
+
*/
|
|
301
|
+
export function readHookDecision(input) {
|
|
302
|
+
if (typeof input !== 'string') {
|
|
303
|
+
const { decision, reason } = decisionFromEnvelope(input);
|
|
304
|
+
return { decision, reason, deny: decision === 'deny', malformed: false };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let found = null;
|
|
308
|
+
let malformed = false;
|
|
309
|
+
|
|
310
|
+
for (const rawLine of input.split('\n')) {
|
|
311
|
+
const line = rawLine.trim();
|
|
312
|
+
if (line === '') continue;
|
|
313
|
+
|
|
314
|
+
let parsed;
|
|
214
315
|
try {
|
|
215
|
-
|
|
216
|
-
if (parsed && typeof parsed === 'object') return parsed;
|
|
316
|
+
parsed = JSON.parse(line);
|
|
217
317
|
} catch {
|
|
218
|
-
|
|
318
|
+
if (looksLikeBrokenEnvelope(line)) malformed = true;
|
|
319
|
+
continue;
|
|
219
320
|
}
|
|
321
|
+
|
|
322
|
+
if (found !== null) continue;
|
|
323
|
+
const candidate = decisionFromEnvelope(parsed);
|
|
324
|
+
if (candidate.decision !== null) found = candidate;
|
|
220
325
|
}
|
|
221
|
-
|
|
326
|
+
|
|
327
|
+
const decision = found?.decision ?? null;
|
|
328
|
+
return {
|
|
329
|
+
decision,
|
|
330
|
+
reason: found?.reason ?? null,
|
|
331
|
+
deny: decision === 'deny',
|
|
332
|
+
malformed,
|
|
333
|
+
};
|
|
222
334
|
}
|
|
223
335
|
|
|
224
336
|
/**
|
|
@@ -261,19 +373,36 @@ export function runPiHookCommand(hook, payload, options) {
|
|
|
261
373
|
|
|
262
374
|
const stdout = result.stdout ?? '';
|
|
263
375
|
const stderr = result.stderr ?? '';
|
|
264
|
-
const
|
|
376
|
+
const decision = readHookDecision(stdout);
|
|
265
377
|
const isPreToolUse = payload.hook_event_name === 'PreToolUse';
|
|
378
|
+
// Since #906 in-repo hooks deny with exit 0, so `status` alone carries no block
|
|
379
|
+
// signal any more and `decision` is load-bearing on its own. `decision.malformed`
|
|
380
|
+
// is therefore counted as an infrastructure failure: a hook whose decision
|
|
381
|
+
// channel is corrupt has told us nothing, and for a PreToolUse guard "nothing"
|
|
382
|
+
// must not read as "allow".
|
|
266
383
|
const infraFailure = Boolean(result.error) ||
|
|
267
384
|
result.status === null ||
|
|
268
|
-
(result.status !== 0 && result.status !== 2)
|
|
385
|
+
(result.status !== 0 && result.status !== 2) ||
|
|
386
|
+
decision.malformed;
|
|
387
|
+
// `status === 2` stays: legacy/third-party hooks still signal a block that way.
|
|
388
|
+
// `decision.deny` covers both the current exit-0 envelope and the legacy flat form.
|
|
269
389
|
const blocked = result.status === 2 ||
|
|
270
|
-
|
|
390
|
+
decision.deny ||
|
|
271
391
|
(isPreToolUse && infraFailure);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
392
|
+
|
|
393
|
+
let reason = decision.reason;
|
|
394
|
+
if (reason === null && blocked) {
|
|
395
|
+
// Prefer a diagnosis over echoing the corrupt payload: `stdout.trim()` on a
|
|
396
|
+
// truncated envelope would surface the whole mangled JSON blob as the
|
|
397
|
+
// operator-facing block reason.
|
|
398
|
+
reason = result.error?.message
|
|
399
|
+
|| (decision.malformed
|
|
400
|
+
? `Hook stdout is not a parseable decision envelope (possible truncated output) — failing closed: ${command}`
|
|
401
|
+
: '')
|
|
402
|
+
|| stdout.trim()
|
|
403
|
+
|| stderr.trim()
|
|
404
|
+
|| `Hook command blocked: ${command}`;
|
|
405
|
+
}
|
|
277
406
|
|
|
278
407
|
return {
|
|
279
408
|
command,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
+
import { findSessionConfigBlock } from './config/section-extractor.mjs';
|
|
10
11
|
|
|
11
12
|
const FRAMEWORK_DEPS = [
|
|
12
13
|
'next',
|
|
@@ -110,12 +111,12 @@ export function hasVaultConfig(configFilePath) {
|
|
|
110
111
|
return false;
|
|
111
112
|
}
|
|
112
113
|
// Scan only within the Session Config section (bounded by the next ## heading).
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return /^\s*vault\s*:/m.test(
|
|
114
|
+
// Heading + span both come from the SSOT (#968): the previous local regex
|
|
115
|
+
// `/^## Session Config[ \t]*(?:\r?\n|$)/m` accepted a heading with trailing
|
|
116
|
+
// spaces/tab that `_extractConfigSection` rejects, so a `vault:` key under
|
|
117
|
+
// `## Session Config␠` was reported as configured while the runtime read
|
|
118
|
+
// nothing at all from that block.
|
|
119
|
+
const block = findSessionConfigBlock(text);
|
|
120
|
+
if (!block) return false;
|
|
121
|
+
return /^\s*vault\s*:/m.test(block.body);
|
|
121
122
|
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-hygiene.mjs — Project-hygiene probe family (housekeeping canon).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* session-start Phase 4 runs 13 probes. Exactly one of them (`ci-status`)
|
|
7
|
+
* inspects the PROJECT; the rest inspect the orchestrator's own substrate
|
|
8
|
+
* (vault, peer-cards, loop readiness, instruction budget, its own ledger).
|
|
9
|
+
* A read-only diagnostic run of the housekeeping flow across six real
|
|
10
|
+
* consumer repos (9 to 2099 tracked files) found the checks the flow does
|
|
11
|
+
* NOT perform outscored the ones it does in every single repo — by up to 7x.
|
|
12
|
+
*
|
|
13
|
+
* This module adds the highest-yield missing checks. Each one was selected by
|
|
14
|
+
* measured hit rate across those six repos, not by intuition:
|
|
15
|
+
*
|
|
16
|
+
* releaseHygiene 6/6 repos tags/CHANGELOG vs. commits since
|
|
17
|
+
* ignoredBallast 6/6 repos ignored-and-untracked working-tree mass
|
|
18
|
+
* staleArtifacts 5/6 repos aged .orchestrator/ artifacts
|
|
19
|
+
* ciConfig 5/6 repos pipeline present, audit job, image pinning
|
|
20
|
+
*
|
|
21
|
+
* DELIBERATE OMISSIONS (documented so nobody "completes" the set later):
|
|
22
|
+
*
|
|
23
|
+
* docs-drift (6/6 hit rate) is NOT reimplemented here. `claude-md-drift-check`
|
|
24
|
+
* already covers it with ten checks; it merely runs at session-END, i.e.
|
|
25
|
+
* after the work. The fix is scheduling, not a second implementation.
|
|
26
|
+
*
|
|
27
|
+
* env-hygiene (5/6 hit rate) is NOT implemented as a dead-variable list.
|
|
28
|
+
* The naive approach — diff `process.env.X` reads against `.env.example` —
|
|
29
|
+
* produced a 100% false-positive rate in the largest repo tested, because
|
|
30
|
+
* variables were read centrally through a Zod schema rather than at their
|
|
31
|
+
* use sites. This module reports only the falsifiable part: whether an
|
|
32
|
+
* `.env.example` exists at all when env vars are read. Counting or naming
|
|
33
|
+
* "dead" variables requires resolving indirection and is left to the
|
|
34
|
+
* per-repo parity tests that some repos already implement correctly.
|
|
35
|
+
*
|
|
36
|
+
* CONTRACT
|
|
37
|
+
* --------
|
|
38
|
+
* Mirrors the established banner-probe shape (see ci-status-banner.mjs):
|
|
39
|
+
* returns `null` for a silent no-op, or `{ severity, message, findings }`.
|
|
40
|
+
* Never throws, never writes, never blocks. Every finding carries `fixable`
|
|
41
|
+
* so a caller can split mechanical work from work needing human judgement.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { execFileSync } from 'node:child_process';
|
|
45
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
|
|
48
|
+
/** Commits past the newest tag before release hygiene is worth mentioning. */
|
|
49
|
+
export const DEFAULT_RELEASE_DRIFT_COMMITS = 50;
|
|
50
|
+
/** Megabytes of ignored working-tree mass before it is worth mentioning. */
|
|
51
|
+
export const DEFAULT_BALLAST_MB = 500;
|
|
52
|
+
/** Days before an .orchestrator/ artifact counts as aged. */
|
|
53
|
+
export const DEFAULT_ARTIFACT_AGE_DAYS = 30;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run a git command, returning trimmed stdout or null on any failure.
|
|
57
|
+
* Never throws — a missing repo, missing ref, or absent git all yield null.
|
|
58
|
+
* @param {string[]} args
|
|
59
|
+
* @param {string} cwd
|
|
60
|
+
* @returns {string|null}
|
|
61
|
+
*/
|
|
62
|
+
function git(args, cwd) {
|
|
63
|
+
try {
|
|
64
|
+
return execFileSync('git', args, {
|
|
65
|
+
cwd,
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
68
|
+
timeout: 10_000,
|
|
69
|
+
}).trim();
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* H1 — Release hygiene.
|
|
77
|
+
*
|
|
78
|
+
* Measured 6/6. The severe real-world case: 980 commits, live in production,
|
|
79
|
+
* zero release tags, CHANGELOG frozen 460 commits back — no rollback anchor.
|
|
80
|
+
* Distinguishes "no tags at all" from "tags exist but HEAD has run far past
|
|
81
|
+
* the newest one", because the remedies differ.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} repoRoot
|
|
84
|
+
* @param {number} driftCommits
|
|
85
|
+
* @returns {object|null}
|
|
86
|
+
*/
|
|
87
|
+
export function checkReleaseHygiene(repoRoot, driftCommits = DEFAULT_RELEASE_DRIFT_COMMITS) {
|
|
88
|
+
const totalCommits = Number(git(['rev-list', '--count', 'HEAD'], repoRoot));
|
|
89
|
+
if (!Number.isFinite(totalCommits) || totalCommits === 0) return null;
|
|
90
|
+
|
|
91
|
+
const tags = git(['tag', '--list'], repoRoot);
|
|
92
|
+
if (tags === null) return null;
|
|
93
|
+
const tagList = tags.split('\n').filter(Boolean);
|
|
94
|
+
|
|
95
|
+
// A young repo without tags is normal, not a finding.
|
|
96
|
+
if (tagList.length === 0) {
|
|
97
|
+
if (totalCommits < driftCommits) return null;
|
|
98
|
+
return {
|
|
99
|
+
check: 'release-hygiene',
|
|
100
|
+
fixable: false,
|
|
101
|
+
message: `no release tags across ${totalCommits} commits — no rollback anchor and no deploy identity`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `describe` fails when no ANNOTATED tag is reachable; fall back to any tag.
|
|
106
|
+
const described = git(['describe', '--tags', '--abbrev=0'], repoRoot);
|
|
107
|
+
if (!described) {
|
|
108
|
+
return {
|
|
109
|
+
check: 'release-hygiene',
|
|
110
|
+
fixable: false,
|
|
111
|
+
message: `${tagList.length} tag(s) exist but none is reachable from HEAD (git describe fails) — releases are not anchored to this history`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const since = Number(git(['rev-list', '--count', `${described}..HEAD`], repoRoot));
|
|
116
|
+
if (!Number.isFinite(since) || since < driftCommits) return null;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
check: 'release-hygiene',
|
|
120
|
+
fixable: false,
|
|
121
|
+
message: `${since} commits since the newest tag (${described}) — release identity is ${since} commits stale`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* H2 — Ignored working-tree ballast.
|
|
127
|
+
*
|
|
128
|
+
* Measured 6/6. Largest observed: a 7.4 GB working tree carrying 30.8 MB of
|
|
129
|
+
* tracked content (factor 240), entirely invisible to `git status`, which
|
|
130
|
+
* reported clean, and to `git count-objects`, which reported a healthy pack.
|
|
131
|
+
*
|
|
132
|
+
* Also reports files that are NEITHER tracked NOR ignored — the state where a
|
|
133
|
+
* `.gitignore` intends to version something that was never committed. In one
|
|
134
|
+
* repo this hid 23 rule files the `.gitignore` explicitly un-ignored.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} repoRoot
|
|
137
|
+
* @param {number} ballastMb
|
|
138
|
+
* @returns {object[]}
|
|
139
|
+
*/
|
|
140
|
+
export function checkIgnoredBallast(repoRoot, ballastMb = DEFAULT_BALLAST_MB) {
|
|
141
|
+
const findings = [];
|
|
142
|
+
|
|
143
|
+
const ignored = git(
|
|
144
|
+
['status', '--ignored=matching', '--porcelain', '--untracked-files=all'],
|
|
145
|
+
repoRoot,
|
|
146
|
+
);
|
|
147
|
+
if (ignored === null) return findings;
|
|
148
|
+
|
|
149
|
+
const ignoredPaths = [];
|
|
150
|
+
let untrackedUnignored = 0;
|
|
151
|
+
for (const line of ignored.split('\n').filter(Boolean)) {
|
|
152
|
+
if (line.startsWith('!! ')) ignoredPaths.push(line.slice(3));
|
|
153
|
+
else if (line.startsWith('?? ')) untrackedUnignored++;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Size only the top-level ignored entries — recursing every path would cost
|
|
157
|
+
// more than the finding is worth on a large tree.
|
|
158
|
+
let totalBytes = 0;
|
|
159
|
+
const heaviest = [];
|
|
160
|
+
for (const p of ignoredPaths) {
|
|
161
|
+
const abs = join(repoRoot, p);
|
|
162
|
+
const bytes = duBytes(abs);
|
|
163
|
+
if (bytes === null) continue;
|
|
164
|
+
totalBytes += bytes;
|
|
165
|
+
heaviest.push({ path: p, bytes });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const totalMb = Math.round(totalBytes / (1024 * 1024));
|
|
169
|
+
if (totalMb >= ballastMb) {
|
|
170
|
+
heaviest.sort((a, b) => b.bytes - a.bytes);
|
|
171
|
+
const top = heaviest
|
|
172
|
+
.slice(0, 3)
|
|
173
|
+
.map((h) => `${h.path} ${Math.round(h.bytes / (1024 * 1024))}MB`)
|
|
174
|
+
.join(', ');
|
|
175
|
+
findings.push({
|
|
176
|
+
check: 'ignored-ballast',
|
|
177
|
+
fixable: true,
|
|
178
|
+
message: `${totalMb} MB of ignored files in the working tree (largest: ${top}) — invisible to git status`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (untrackedUnignored > 0) {
|
|
183
|
+
findings.push({
|
|
184
|
+
check: 'untracked-unignored',
|
|
185
|
+
fixable: false,
|
|
186
|
+
message: `${untrackedUnignored} file(s) are neither tracked nor ignored — either commit them or add them to .gitignore, since nothing currently decides`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return findings;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Directory/file size in bytes via `du -sk`, or null when unavailable.
|
|
195
|
+
* `du` is POSIX and present on macOS and Linux; Windows yields null, which
|
|
196
|
+
* degrades the ballast check to a silent skip rather than a crash.
|
|
197
|
+
* @param {string} absPath
|
|
198
|
+
* @returns {number|null}
|
|
199
|
+
*/
|
|
200
|
+
function duBytes(absPath) {
|
|
201
|
+
try {
|
|
202
|
+
if (!existsSync(absPath)) return null;
|
|
203
|
+
const out = execFileSync('du', ['-sk', absPath], {
|
|
204
|
+
encoding: 'utf8',
|
|
205
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
206
|
+
timeout: 10_000,
|
|
207
|
+
});
|
|
208
|
+
const kb = Number(out.trim().split(/\s+/)[0]);
|
|
209
|
+
return Number.isFinite(kb) ? kb * 1024 : null;
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* H3 — Aged orchestrator artifacts.
|
|
217
|
+
*
|
|
218
|
+
* Measured 5/6. Largest observed: 184 MB under .orchestrator/, of which
|
|
219
|
+
* 147 MB were Playwright test-run captures dating back seven weeks, plus
|
|
220
|
+
* 592 files older than 30 days.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} repoRoot
|
|
223
|
+
* @param {number} ageDays
|
|
224
|
+
* @param {number} now
|
|
225
|
+
* @returns {object|null}
|
|
226
|
+
*/
|
|
227
|
+
export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAYS, now = Date.now()) {
|
|
228
|
+
const dir = join(repoRoot, '.orchestrator');
|
|
229
|
+
if (!existsSync(dir)) return null;
|
|
230
|
+
|
|
231
|
+
const cutoff = now - ageDays * 24 * 60 * 60 * 1000;
|
|
232
|
+
let aged = 0;
|
|
233
|
+
let scanned = 0;
|
|
234
|
+
|
|
235
|
+
/** @param {string} d @param {number} depth */
|
|
236
|
+
const walk = (d, depth) => {
|
|
237
|
+
// Bounded: a runaway scan on a huge artifact tree would cost more than
|
|
238
|
+
// the finding. 20k entries is far above any healthy .orchestrator/.
|
|
239
|
+
if (depth > 6 || scanned > 20_000) return;
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
243
|
+
} catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const e of entries) {
|
|
247
|
+
const full = join(d, e.name);
|
|
248
|
+
if (e.isDirectory()) {
|
|
249
|
+
walk(full, depth + 1);
|
|
250
|
+
} else if (e.isFile()) {
|
|
251
|
+
scanned++;
|
|
252
|
+
try {
|
|
253
|
+
if (statSync(full).mtimeMs < cutoff) aged++;
|
|
254
|
+
} catch {
|
|
255
|
+
/* vanished mid-scan — ignore */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
walk(dir, 0);
|
|
261
|
+
|
|
262
|
+
if (aged === 0) return null;
|
|
263
|
+
|
|
264
|
+
const mb = Math.round((duBytes(dir) ?? 0) / (1024 * 1024));
|
|
265
|
+
return {
|
|
266
|
+
check: 'stale-artifacts',
|
|
267
|
+
fixable: true,
|
|
268
|
+
message: `${aged} file(s) under .orchestrator/ older than ${ageDays}d${mb > 0 ? ` (${mb} MB total)` : ''} — candidates for pruning`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* H4 — CI configuration hygiene.
|
|
274
|
+
*
|
|
275
|
+
* Measured 5/6. Reports only what is decidable by presence, never by parsing
|
|
276
|
+
* pipeline semantics. The dependency-audit gap is the load-bearing one: three
|
|
277
|
+
* of the tested repos carried known-vulnerable dependencies that no pipeline
|
|
278
|
+
* would ever surface.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} repoRoot
|
|
281
|
+
* @returns {object[]}
|
|
282
|
+
*/
|
|
283
|
+
export function checkCiConfig(repoRoot) {
|
|
284
|
+
const findings = [];
|
|
285
|
+
const gitlabCi = join(repoRoot, '.gitlab-ci.yml');
|
|
286
|
+
const ghWorkflows = join(repoRoot, '.github', 'workflows');
|
|
287
|
+
const hasGitlab = existsSync(gitlabCi);
|
|
288
|
+
const hasGithub = existsSync(ghWorkflows);
|
|
289
|
+
|
|
290
|
+
// Only meaningful for repos that actually build something.
|
|
291
|
+
const hasManifest = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod'].some((m) =>
|
|
292
|
+
existsSync(join(repoRoot, m)),
|
|
293
|
+
);
|
|
294
|
+
if (!hasManifest) return findings;
|
|
295
|
+
|
|
296
|
+
if (!hasGitlab && !hasGithub) {
|
|
297
|
+
findings.push({
|
|
298
|
+
check: 'ci-config',
|
|
299
|
+
fixable: false,
|
|
300
|
+
message: 'no CI pipeline configured (.gitlab-ci.yml / .github/workflows) — nothing verifies commits',
|
|
301
|
+
});
|
|
302
|
+
return findings;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let ciText = '';
|
|
306
|
+
if (hasGitlab) ciText += safeRead(gitlabCi);
|
|
307
|
+
if (hasGithub) {
|
|
308
|
+
try {
|
|
309
|
+
for (const f of readdirSync(ghWorkflows)) {
|
|
310
|
+
if (f.endsWith('.yml') || f.endsWith('.yaml')) ciText += safeRead(join(ghWorkflows, f));
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
/* unreadable workflows dir — fall through with what we have */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (ciText && !/\b(npm|pnpm|yarn) audit\b|pip-audit|cargo audit|osv-scanner|dependency.?check/i.test(ciText)) {
|
|
318
|
+
findings.push({
|
|
319
|
+
check: 'ci-audit-job',
|
|
320
|
+
fixable: true,
|
|
321
|
+
message: 'CI runs no dependency-audit step — known-vulnerable dependencies cannot surface in the pipeline',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return findings;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** @param {string} p @returns {string} */
|
|
329
|
+
function safeRead(p) {
|
|
330
|
+
try {
|
|
331
|
+
return readFileSync(p, 'utf8');
|
|
332
|
+
} catch {
|
|
333
|
+
return '';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* H5 — Env documentation presence (deliberately narrow; see module header).
|
|
339
|
+
*
|
|
340
|
+
* Reports ONLY the falsifiable case: code reads environment variables and no
|
|
341
|
+
* `.env.example` exists anywhere. Does NOT attempt to name dead or missing
|
|
342
|
+
* variables — that requires resolving indirection (central schema modules,
|
|
343
|
+
* injected config objects) and produced a 100% false-positive rate when
|
|
344
|
+
* attempted naively during the diagnostic runs.
|
|
345
|
+
*
|
|
346
|
+
* @param {string} repoRoot
|
|
347
|
+
* @returns {object|null}
|
|
348
|
+
*/
|
|
349
|
+
export function checkEnvDocumentation(repoRoot) {
|
|
350
|
+
const tracked = git(['ls-files'], repoRoot);
|
|
351
|
+
if (!tracked) return null;
|
|
352
|
+
|
|
353
|
+
const files = tracked.split('\n').filter((f) => /\.(mjs|cjs|js|ts|tsx|py|go|rs)$/.test(f));
|
|
354
|
+
if (files.length === 0) return null;
|
|
355
|
+
|
|
356
|
+
const hasExample = ['.env.example', '.env.sample', '.env.template'].some((n) =>
|
|
357
|
+
existsSync(join(repoRoot, n)),
|
|
358
|
+
);
|
|
359
|
+
if (hasExample) return null;
|
|
360
|
+
|
|
361
|
+
// Sample rather than read every file — one hit is enough to decide.
|
|
362
|
+
let readsEnv = false;
|
|
363
|
+
for (const f of files.slice(0, 400)) {
|
|
364
|
+
const text = safeRead(join(repoRoot, f));
|
|
365
|
+
if (/process\.env\.\w|os\.environ|std::env::var|os\.Getenv/.test(text)) {
|
|
366
|
+
readsEnv = true;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!readsEnv) return null;
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
check: 'env-documentation',
|
|
374
|
+
fixable: false,
|
|
375
|
+
message: 'code reads environment variables but no .env.example/.env.sample exists — required configuration is undiscoverable',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Aggregate the project-hygiene family into one banner result.
|
|
381
|
+
*
|
|
382
|
+
* Returns a SINGLE result rather than one banner per check: Phase 4 already
|
|
383
|
+
* renders 13 banners, and the diagnostic runs showed a flat list stops being
|
|
384
|
+
* read past roughly 25 findings. The caller renders the summary plus the top
|
|
385
|
+
* findings and can use `mechanical` to route the fixable subset separately.
|
|
386
|
+
*
|
|
387
|
+
* @param {{ repoRoot: string, now?: number, thresholds?: object }} opts
|
|
388
|
+
* @returns {{ severity: 'warn', message: string, findings: object[], mechanical: number } | null}
|
|
389
|
+
*/
|
|
390
|
+
export function checkProjectHygiene({ repoRoot, now = Date.now(), thresholds = {} } = {}) {
|
|
391
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
392
|
+
if (!existsSync(join(repoRoot, '.git'))) return null;
|
|
393
|
+
|
|
394
|
+
const {
|
|
395
|
+
releaseDriftCommits = DEFAULT_RELEASE_DRIFT_COMMITS,
|
|
396
|
+
ballastMb = DEFAULT_BALLAST_MB,
|
|
397
|
+
artifactAgeDays = DEFAULT_ARTIFACT_AGE_DAYS,
|
|
398
|
+
} = thresholds;
|
|
399
|
+
|
|
400
|
+
const findings = [];
|
|
401
|
+
try {
|
|
402
|
+
const release = checkReleaseHygiene(repoRoot, releaseDriftCommits);
|
|
403
|
+
if (release) findings.push(release);
|
|
404
|
+
|
|
405
|
+
findings.push(...checkIgnoredBallast(repoRoot, ballastMb));
|
|
406
|
+
|
|
407
|
+
const stale = checkStaleArtifacts(repoRoot, artifactAgeDays, now);
|
|
408
|
+
if (stale) findings.push(stale);
|
|
409
|
+
|
|
410
|
+
findings.push(...checkCiConfig(repoRoot));
|
|
411
|
+
|
|
412
|
+
const env = checkEnvDocumentation(repoRoot);
|
|
413
|
+
if (env) findings.push(env);
|
|
414
|
+
} catch {
|
|
415
|
+
// Probe family is advisory — a partial result beats a crashed session-start.
|
|
416
|
+
if (findings.length === 0) return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (findings.length === 0) return null;
|
|
420
|
+
|
|
421
|
+
const mechanical = findings.filter((f) => f.fixable).length;
|
|
422
|
+
const head = findings.slice(0, 3).map((f) => `• ${f.message}`).join('\n');
|
|
423
|
+
const rest = findings.length > 3 ? `\n …and ${findings.length - 3} more.` : '';
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
severity: 'warn',
|
|
427
|
+
message:
|
|
428
|
+
`⚠ project-hygiene: ${findings.length} finding(s), ${mechanical} mechanically fixable:\n${head}${rest}`,
|
|
429
|
+
findings,
|
|
430
|
+
mechanical,
|
|
431
|
+
};
|
|
432
|
+
}
|