session-orchestrator 3.22.0 → 3.24.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/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -227,6 +227,37 @@ function formatBytes(bytes) {
|
|
|
227
227
|
return `${bytes} B`;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Untracked is not the same as disposable. Three classes of untracked file
|
|
232
|
+
* under .orchestrator/ are load-bearing, so they are removed from the candidate
|
|
233
|
+
* set ENTIRELY rather than merely downgraded: a finding an operator must not
|
|
234
|
+
* act on is noise, and `fixable: true` routes it to the batch-work lane where
|
|
235
|
+
* acting on it is precisely what happens.
|
|
236
|
+
*
|
|
237
|
+
* `.backfilled-*.marker` — the TOCTOU idempotency guards written by
|
|
238
|
+
* scripts/lib/session-close-backfill.mjs (`markerName()`). The file's
|
|
239
|
+
* EXISTENCE is the whole state: delete one and the next backfill pass
|
|
240
|
+
* re-runs for that session id and duplicates its record. Age is the
|
|
241
|
+
* NORMAL condition of a marker whose session closed months ago.
|
|
242
|
+
* `*.jsonl` — append-only telemetry ledgers (audit, autopilot,
|
|
243
|
+
* vault-staleness, repair-candidates). They ARE the history every metric
|
|
244
|
+
* is computed from; mtime says when the ledger last grew, never whether
|
|
245
|
+
* its contents are still needed.
|
|
246
|
+
* `*.log` — append-only diagnostic logs (reconcile.rejected.log and
|
|
247
|
+
* siblings). Same argument as the ledgers.
|
|
248
|
+
*
|
|
249
|
+
* Everything else stays reportable: stray `*.json` scratch, `research/*.md`,
|
|
250
|
+
* abandoned run directories, and the 147 MB of test-run captures this check was
|
|
251
|
+
* built for.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} name basename of the candidate file
|
|
254
|
+
* @returns {boolean}
|
|
255
|
+
*/
|
|
256
|
+
function isRetainedArtifact(name) {
|
|
257
|
+
if (name.startsWith('.backfilled-') && name.endsWith('.marker')) return true;
|
|
258
|
+
return name.endsWith('.jsonl') || name.endsWith('.log');
|
|
259
|
+
}
|
|
260
|
+
|
|
230
261
|
/**
|
|
231
262
|
* H3 — Aged orchestrator artifacts.
|
|
232
263
|
*
|
|
@@ -245,6 +276,12 @@ function formatBytes(bytes) {
|
|
|
245
276
|
* their age is a sign of stability, not decay. Proposing them for
|
|
246
277
|
* "pruning" is proposing to delete source.
|
|
247
278
|
*
|
|
279
|
+
* 3. Untracked is not the same as disposable. Three classes are untracked BY
|
|
280
|
+
* DESIGN and load-bearing, and are excluded outright (see
|
|
281
|
+
* `isRetainedArtifact`). Measured in this repo 2026-08-24: 24 of the 25
|
|
282
|
+
* reported candidates were one of those three, so the finding was 96%
|
|
283
|
+
* unactionable while carrying `fixable: true`.
|
|
284
|
+
*
|
|
248
285
|
* @param {string} repoRoot
|
|
249
286
|
* @param {number} ageDays
|
|
250
287
|
* @param {number} now
|
|
@@ -298,6 +335,8 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
|
|
|
298
335
|
// walk, not the reportable subset.
|
|
299
336
|
scanned++;
|
|
300
337
|
if (tracked.has(full)) continue;
|
|
338
|
+
// Untracked BY DESIGN and load-bearing — never a pruning candidate.
|
|
339
|
+
if (isRetainedArtifact(e.name)) continue;
|
|
301
340
|
try {
|
|
302
341
|
const st = statSync(full);
|
|
303
342
|
if (st.mtimeMs < cutoff) {
|
|
@@ -318,14 +357,15 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
|
|
|
318
357
|
// (Phase 4), which routes fixable findings to "safe batch work" instead of
|
|
319
358
|
// the operator Q&A — prose guidance to the coordinator, not an automatic
|
|
320
359
|
// deletion run; `grep -rn "fixable" scripts/ skills/ hooks/` finds no other
|
|
321
|
-
// reader than the `mechanical` count below. With tracked paths
|
|
322
|
-
// claim is now true: every reported path is an
|
|
360
|
+
// reader than the `mechanical` count below. With tracked paths AND the
|
|
361
|
+
// retained classes excluded the claim is now true: every reported path is an
|
|
362
|
+
// untracked artifact whose deletion loses nothing but disk.
|
|
323
363
|
return {
|
|
324
364
|
check: 'stale-artifacts',
|
|
325
365
|
fixable: true,
|
|
326
366
|
agedFiles: aged,
|
|
327
367
|
agedBytes,
|
|
328
|
-
message: `${aged} untracked file(s) under .orchestrator/ older than ${ageDays}d${agedBytes > 0 ? ` (${formatBytes(agedBytes)} total)` : ''} — candidates for pruning`,
|
|
368
|
+
message: `${aged} untracked file(s) under .orchestrator/ older than ${ageDays}d${agedBytes > 0 ? ` (${formatBytes(agedBytes)} total)` : ''} — candidates for pruning (idempotency markers + ledgers excluded)`,
|
|
329
369
|
};
|
|
330
370
|
}
|
|
331
371
|
|
|
@@ -29,15 +29,19 @@
|
|
|
29
29
|
* failureContext.
|
|
30
30
|
*
|
|
31
31
|
* - `last-green-sha.txt` lives at `.orchestrator/runtime/last-green-sha.txt`
|
|
32
|
-
* and is updated atomically after every successful gate. `changedFiles`
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* and is updated atomically after every successful gate. `changedFiles` is
|
|
33
|
+
* the UNION of the diff against this file (falling back to `HEAD~1`) and
|
|
34
|
+
* the UNCOMMITTED working tree (#1058) — wave agents never commit, so the
|
|
35
|
+
* committed half alone reported nothing they did. Both halves are
|
|
36
|
+
* best-effort: a git failure in either degrades to an empty contribution
|
|
35
37
|
* rather than blocking the gate.
|
|
36
38
|
*
|
|
37
39
|
* - `corrective_context` is read from `.orchestrator/current-session.json`
|
|
38
40
|
* (written by `hooks/post-tool-failure-corrective-context.mjs`). Missing
|
|
39
|
-
* file / parse failure → empty array
|
|
40
|
-
*
|
|
41
|
+
* file / parse failure → empty array; a file that provably belongs to a
|
|
42
|
+
* PEER session in the same working copy is treated as absent, with a stderr
|
|
43
|
+
* WARN (#1058). The most recent 5 entries are forwarded to the fixer (older
|
|
44
|
+
* noise is dropped to keep prompts lean).
|
|
41
45
|
*
|
|
42
46
|
* - Diagnostics bundle path: `.orchestrator/metrics/verification-failures/<ts>.json`.
|
|
43
47
|
* Timestamp colons are replaced with `-` for filesystem portability.
|
|
@@ -63,6 +67,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
63
67
|
import { emitEvent, sessionAttribution } from './events.mjs';
|
|
64
68
|
import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
|
|
65
69
|
import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
70
|
+
import { readLock } from './session-lock.mjs';
|
|
66
71
|
|
|
67
72
|
export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
68
73
|
|
|
@@ -254,15 +259,79 @@ function writeLastGreenSha(repoRoot) {
|
|
|
254
259
|
}
|
|
255
260
|
|
|
256
261
|
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
262
|
+
* Parse `git status --porcelain -z` stdout into repo-root-relative paths.
|
|
263
|
+
*
|
|
264
|
+
* `-z` is not a convenience flag here — it is the only shape of this command
|
|
265
|
+
* whose paths are unambiguous. Measured 2026-08-23 (git 2.53.0) on a fixture
|
|
266
|
+
* carrying a space, a non-ASCII name, a literal `"` and a rename:
|
|
267
|
+
*
|
|
268
|
+
* ```
|
|
269
|
+
* git status --porcelain git status --porcelain -z
|
|
270
|
+
* ------------------------------ ---------------------------------
|
|
271
|
+
* M "scripts/lib/old name.mjs" M scripts/lib/old name.mjs
|
|
272
|
+
* M "scripts/lib/\303\274ml.mjs" M scripts/lib/üml.mjs
|
|
273
|
+
* ?? "scripts/lib/quo\"te.mjs" ?? scripts/lib/quo"te.mjs
|
|
274
|
+
* R old.mjs -> new.mjs R new.mjs \0 old.mjs
|
|
275
|
+
* ```
|
|
276
|
+
*
|
|
277
|
+
* The non-`-z` form C-quotes any path containing a space, a `"` or — under the
|
|
278
|
+
* default `core.quotePath=true` — a non-ASCII byte. `-c core.quotePath=false`
|
|
279
|
+
* repairs only the non-ASCII third of that (measured: the space and the `"`
|
|
280
|
+
* stayed quoted). A field-splitting parser over the non-`-z` form fails three
|
|
281
|
+
* separate ways on one input — measured `awk '{print $2}'` output for the four
|
|
282
|
+
* lines above: `"scripts/lib/old` (truncated at the space), the undecoded
|
|
283
|
+
* `\303\274` octal escape, and `old.mjs` (the PRE-rename path) for the `R`
|
|
284
|
+
* line. `-z` emits every path verbatim, so there is no unquoting step to get
|
|
285
|
+
* wrong.
|
|
286
|
+
*
|
|
287
|
+
* Rename/copy entries carry their ORIGINAL path as the NEXT NUL field, with NO
|
|
288
|
+
* `XY ` prefix. Consuming that extra field is mandatory, not optional: a naive
|
|
289
|
+
* per-field `slice(3)` would emit `.mjs`-suffixed garbage (`d.mjs` for
|
|
290
|
+
* `old.mjs`) as if it were a real path. Both paths are kept — a file moved OUT
|
|
291
|
+
* of `scripts/lib/` is as much a shared-lib touch as one moved in, and a fixer
|
|
292
|
+
* needs the old path to make sense of the new one. `R`/`C` are checked in BOTH
|
|
293
|
+
* status columns because git-status(1) documents `R `/`C ` (renamed/copied in
|
|
294
|
+
* index) as well as ` R`/` C` (renamed/copied in work tree).
|
|
295
|
+
*
|
|
296
|
+
* Untracked DIRECTORIES are not a case this parser has to handle: the caller
|
|
297
|
+
* passes `-uall`, which expands them to individual files (measured: `?? nd/`
|
|
298
|
+
* became `?? nd/a.mjs` + `?? nd/b.mjs`).
|
|
299
|
+
*
|
|
300
|
+
* @param {string} raw — raw stdout of `git status --porcelain -z …`.
|
|
301
|
+
* @returns {string[]} repo-root-relative paths, in git's emission order.
|
|
302
|
+
*/
|
|
303
|
+
function parsePorcelainZ(raw) {
|
|
304
|
+
const fields = String(raw ?? '').split('\0');
|
|
305
|
+
const paths = [];
|
|
306
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
307
|
+
const entry = fields[i];
|
|
308
|
+
// `XY P` is the shortest well-formed entry. Anything shorter — including
|
|
309
|
+
// the empty trailing field `split` always produces — is not an entry
|
|
310
|
+
// header, and the `[2] === ' '` check rejects a stray original-path field
|
|
311
|
+
// that a malformed stream could leave unconsumed.
|
|
312
|
+
if (typeof entry !== 'string' || entry.length < 4 || entry[2] !== ' ') continue;
|
|
313
|
+
const filePath = entry.slice(3);
|
|
314
|
+
if (filePath) paths.push(filePath);
|
|
315
|
+
const x = entry[0];
|
|
316
|
+
const y = entry[1];
|
|
317
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
|
|
318
|
+
i += 1;
|
|
319
|
+
const original = fields[i];
|
|
320
|
+
if (typeof original === 'string' && original) paths.push(original);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return paths;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Files that differ between `baseRef` and `HEAD` — the COMMITTED half of the
|
|
328
|
+
* change set. Best-effort: `[]` on any git failure.
|
|
259
329
|
*
|
|
260
330
|
* @param {string} repoRoot
|
|
261
|
-
* @param {string
|
|
331
|
+
* @param {string} baseRef
|
|
262
332
|
* @returns {string[]}
|
|
263
333
|
*/
|
|
264
|
-
function
|
|
265
|
-
const baseRef = ref ?? 'HEAD~1';
|
|
334
|
+
function listCommittedChangedFiles(repoRoot, baseRef) {
|
|
266
335
|
try {
|
|
267
336
|
const result = spawnSync('git', ['diff', '--name-only', baseRef, 'HEAD'], {
|
|
268
337
|
cwd: repoRoot,
|
|
@@ -279,9 +348,182 @@ function listChangedFiles(repoRoot, ref) {
|
|
|
279
348
|
}
|
|
280
349
|
}
|
|
281
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Files modified, staged, or untracked in the WORKING TREE — the uncommitted
|
|
353
|
+
* half of the change set. Best-effort: `[]` on any git failure.
|
|
354
|
+
*
|
|
355
|
+
* `-uall` lists untracked files individually instead of collapsing a new
|
|
356
|
+
* directory to `dir/`; measured 2026-08-23 on this repo it costs nothing
|
|
357
|
+
* (`-uall` 30-33 ms vs `-unormal` 30-32 ms over 3 runs each), because ignored
|
|
358
|
+
* trees — `node_modules/`, `.orchestrator/runtime/` — are never walked.
|
|
359
|
+
*
|
|
360
|
+
* Ceiling (BV-004): the union below relies on `.gitignore` to keep the
|
|
361
|
+
* orchestrator's OWN runtime artifacts out of the result. In this repo
|
|
362
|
+
* `.orchestrator/runtime/`, `current-session.json` and `session.lock` are all
|
|
363
|
+
* ignored (`git check-ignore -v`, measured 2026-08-23), so the gate's
|
|
364
|
+
* `last-green-sha.txt` write cannot show up as a "changed file". A consumer
|
|
365
|
+
* repo that does NOT ignore `.orchestrator/` will see those artifacts here.
|
|
366
|
+
* Revisit if a caller needs a hard exclusion rather than a gitignore-derived
|
|
367
|
+
* one.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} repoRoot
|
|
370
|
+
* @returns {string[]}
|
|
371
|
+
*/
|
|
372
|
+
function listWorkingTreeChangedFiles(repoRoot) {
|
|
373
|
+
try {
|
|
374
|
+
const result = spawnSync('git', ['status', '--porcelain', '-z', '-uall'], {
|
|
375
|
+
cwd: repoRoot,
|
|
376
|
+
encoding: 'utf8',
|
|
377
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
378
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
379
|
+
});
|
|
380
|
+
if (result.status !== 0 || !result.stdout) return [];
|
|
381
|
+
return parsePorcelainZ(result.stdout);
|
|
382
|
+
} catch {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* List files this wave touched since `ref` (or HEAD~1 if no ref) — the UNION of
|
|
389
|
+
* the committed diff and the working tree.
|
|
390
|
+
*
|
|
391
|
+
* The union is the whole point (#1058). The committed half alone answered a
|
|
392
|
+
* question nobody asks: wave agents never commit — `.claude/rules/parallel-sessions.md`
|
|
393
|
+
* § PSA-007 forbids every git-write operation to a dispatched subagent, and the
|
|
394
|
+
* auto-commit that would have closed the gap does not exist (`skills/wave-executor/wave-loop.md`
|
|
395
|
+
* marks it "not yet implemented as of v3.10.0"; `scripts/lib/auto-commit.mjs` is
|
|
396
|
+
* absent, verified 2026-08-23). So at the moment {@link detectSharedLibTouch}
|
|
397
|
+
* runs, every file the wave produced is UNCOMMITTED, and a committed-only diff
|
|
398
|
+
* reports the previous coordinator commit's files or nothing at all. The FL-3
|
|
399
|
+
* auto-promotion to the Full Gate could therefore never fire on the wave's own
|
|
400
|
+
* work — structurally, not occasionally.
|
|
401
|
+
*
|
|
402
|
+
* Each half is independently best-effort: a failure in one contributes nothing
|
|
403
|
+
* and does not suppress the other. Both fail (no repo at all, unreadable index)
|
|
404
|
+
* → `[]`, preserving the documented never-throw / never-block contract.
|
|
405
|
+
*
|
|
406
|
+
* @param {string} repoRoot
|
|
407
|
+
* @param {string|null} ref
|
|
408
|
+
* @returns {string[]} de-duplicated, lexicographically sorted.
|
|
409
|
+
*/
|
|
410
|
+
function listChangedFiles(repoRoot, ref) {
|
|
411
|
+
const baseRef = ref ?? 'HEAD~1';
|
|
412
|
+
const union = new Set([
|
|
413
|
+
...listCommittedChangedFiles(repoRoot, baseRef),
|
|
414
|
+
...listWorkingTreeChangedFiles(repoRoot),
|
|
415
|
+
]);
|
|
416
|
+
return [...union].sort();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The id-space THIS process belongs to, for comparison against a repo-global
|
|
421
|
+
* file that any session in the working copy may have written.
|
|
422
|
+
*
|
|
423
|
+
* Two sources, both read rather than invented — this deliberately adds no third
|
|
424
|
+
* way of answering "who am I" (`scripts/lib/lock-reaper.mjs` takes the id from
|
|
425
|
+
* its caller; `scripts/lib/peer-discovery.mjs` reads `readLock()`):
|
|
426
|
+
*
|
|
427
|
+
* 1. `CLAUDE_CODE_SESSION_ID` — the only PER-PROCESS source, and therefore
|
|
428
|
+
* the only one a foreign session cannot spoof by writing a file. Measured
|
|
429
|
+
* 2026-08-23 inside a dispatched wave agent: present, and equal to the
|
|
430
|
+
* coordinator's `current-session.json` `session_id` (a child session
|
|
431
|
+
* inherits the parent's id, which is what makes it usable here).
|
|
432
|
+
* `scripts/lib/spiral-carryover.mjs` reads the same variable, under the
|
|
433
|
+
* same measured premise.
|
|
434
|
+
* 2. `session.lock` `session_id` / `semantic_session_id` — a repo-global
|
|
435
|
+
* FALLBACK for harnesses that export no session env var. Weaker on
|
|
436
|
+
* purpose: the lock is one more shared file in the same working copy, so
|
|
437
|
+
* it can name a peer rather than us. It is used only when (1) is absent,
|
|
438
|
+
* where the alternative is no check at all.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} repoRoot
|
|
441
|
+
* @returns {Set<string>} possibly empty — an empty set means "identity
|
|
442
|
+
* unresolvable", which the classifier below treats as `unknown`, never as a
|
|
443
|
+
* mismatch.
|
|
444
|
+
*/
|
|
445
|
+
function readOwnSessionIds(repoRoot) {
|
|
446
|
+
const ids = new Set();
|
|
447
|
+
// `.trim()` first: a whitespace-only env var is truthy and would otherwise
|
|
448
|
+
// enter the set as a phantom id (`development.md` § env-var whitespace trap).
|
|
449
|
+
const fromEnv = (process.env.CLAUDE_CODE_SESSION_ID || '').trim();
|
|
450
|
+
if (fromEnv) ids.add(fromEnv);
|
|
451
|
+
if (ids.size === 0) {
|
|
452
|
+
try {
|
|
453
|
+
const lock = readLock({ repoRoot });
|
|
454
|
+
for (const key of ['session_id', 'semantic_session_id']) {
|
|
455
|
+
const value = typeof lock?.[key] === 'string' ? lock[key].trim() : '';
|
|
456
|
+
if (value) ids.add(value);
|
|
457
|
+
}
|
|
458
|
+
} catch { /* readLock never throws, but the contract is not ours to trust */ }
|
|
459
|
+
}
|
|
460
|
+
return ids;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Decide whether a parsed `current-session.json` belongs to THIS session.
|
|
465
|
+
*
|
|
466
|
+
* Three outcomes, and the middle one is load-bearing:
|
|
467
|
+
*
|
|
468
|
+
* - `'foreign'` — the file names at least one session id, we know at least
|
|
469
|
+
* one of our own, and NONE of them match. This is the only verdict that
|
|
470
|
+
* discards data.
|
|
471
|
+
* - `'unknown'` — the file names no id, or we could not resolve our own.
|
|
472
|
+
* Ownership is unproven in BOTH directions, so the content is kept. A file
|
|
473
|
+
* without an id cannot be attributed to a peer either; discarding it would
|
|
474
|
+
* turn "cannot tell" into a silent feature-off on every harness that
|
|
475
|
+
* exports no session id.
|
|
476
|
+
* - `'own'` — an id matched.
|
|
477
|
+
*
|
|
478
|
+
* Rejecting only what is PROVABLY foreign is the point. The hazard being closed
|
|
479
|
+
* (#1058 second finding) is a peer session's corrective hints briefing this
|
|
480
|
+
* session's fixer agent, and that hazard always carries a concrete, mismatching
|
|
481
|
+
* id — the live file in this repo carried `conflict_with_session_id` from a
|
|
482
|
+
* genuine second session while it was measured.
|
|
483
|
+
*
|
|
484
|
+
* Residual gap this classifier CANNOT close, because it is a property of the
|
|
485
|
+
* writer: `hooks/post-tool-failure-corrective-context.mjs` appends to whatever
|
|
486
|
+
* `current-session.json` it finds without checking or stamping ownership, and
|
|
487
|
+
* `hooks/on-session-start.mjs` rewrites the identity block on session start.
|
|
488
|
+
* Two live sessions therefore interleave entries into ONE array under the
|
|
489
|
+
* NEWER session's id. This check catches the older session reading the newer
|
|
490
|
+
* session's file (verdict `foreign`); it cannot un-mix entries inside a file
|
|
491
|
+
* that legitimately carries our own id. Per-entry attribution belongs to the
|
|
492
|
+
* writer hook, not here.
|
|
493
|
+
*
|
|
494
|
+
* @param {unknown} parsed — parsed `current-session.json` content.
|
|
495
|
+
* @param {Set<string>} ownIds
|
|
496
|
+
* @returns {{ verdict: 'own'|'foreign'|'unknown', fileIds: string[] }}
|
|
497
|
+
*/
|
|
498
|
+
function classifyCurrentSessionOwnership(parsed, ownIds) {
|
|
499
|
+
const fileIds = [];
|
|
500
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
501
|
+
for (const key of ['session_id', 'semantic_session_id']) {
|
|
502
|
+
const value = typeof parsed[key] === 'string' ? parsed[key].trim() : '';
|
|
503
|
+
if (value) fileIds.push(value);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (fileIds.length === 0 || ownIds.size === 0) return { verdict: 'unknown', fileIds };
|
|
507
|
+
const matched = fileIds.some((id) => ownIds.has(id));
|
|
508
|
+
return { verdict: matched ? 'own' : 'foreign', fileIds };
|
|
509
|
+
}
|
|
510
|
+
|
|
282
511
|
/**
|
|
283
512
|
* Read `corrective_context` array from `.orchestrator/current-session.json`.
|
|
284
|
-
* Returns the most-recent N entries. Empty array on missing file / parse failure
|
|
513
|
+
* Returns the most-recent N entries. Empty array on missing file / parse failure
|
|
514
|
+
* — and, since #1058, on a file that provably belongs to ANOTHER session.
|
|
515
|
+
*
|
|
516
|
+
* `current-session.json` is repo-global: every session sharing this working
|
|
517
|
+
* copy writes to the same path. Without the ownership check below, a peer
|
|
518
|
+
* session's tool-failure hints were forwarded verbatim into THIS session's
|
|
519
|
+
* fixer-agent prompt, which is how a fixer gets briefed on a failure that never
|
|
520
|
+
* happened in the tree it is editing.
|
|
521
|
+
*
|
|
522
|
+
* A foreign file is treated as ABSENT — and said out loud on stderr. A silent
|
|
523
|
+
* discard and a silent misuse are the same error class: both leave the operator
|
|
524
|
+
* unable to tell that two sessions are contending for this working copy. The
|
|
525
|
+
* WARN is not de-duplicated; this function runs at most once per fixer dispatch
|
|
526
|
+
* plus once at abort, and each line marks a distinct decision point.
|
|
285
527
|
*
|
|
286
528
|
* @param {string} repoRoot
|
|
287
529
|
* @returns {Array<object>}
|
|
@@ -292,6 +534,15 @@ function readCorrectiveContext(repoRoot) {
|
|
|
292
534
|
if (!existsSync(p)) return [];
|
|
293
535
|
const raw = readFileSync(p, 'utf8');
|
|
294
536
|
const parsed = JSON.parse(raw);
|
|
537
|
+
const { verdict, fileIds } = classifyCurrentSessionOwnership(parsed, readOwnSessionIds(repoRoot));
|
|
538
|
+
if (verdict === 'foreign') {
|
|
539
|
+
process.stderr.write(
|
|
540
|
+
`⚠️ quality-gate: .orchestrator/current-session.json belongs to another session ` +
|
|
541
|
+
`(${fileIds.join(', ')}) — corrective context ignored. ` +
|
|
542
|
+
'Another session is active in this working copy (PSA-001).\n',
|
|
543
|
+
);
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
295
546
|
const arr = Array.isArray(parsed?.corrective_context) ? parsed.corrective_context : [];
|
|
296
547
|
return arr.slice(-CORRECTIVE_CONTEXT_TAIL);
|
|
297
548
|
} catch {
|
|
@@ -334,8 +585,15 @@ function writeDiagnosticsBundle(repoRoot, bundle) {
|
|
|
334
585
|
* `scripts/lib/*`, `hooks/*`, or `.husky/*`, the blast radius is wider than the
|
|
335
586
|
* agent could predict — auto-promote to Full Gate.
|
|
336
587
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
588
|
+
* Since #1058 the underlying change set is the UNION of the committed diff and
|
|
589
|
+
* the UNCOMMITTED working tree. Without the second half this detector could
|
|
590
|
+
* never fire on the wave's own work: PSA-007 forbids a dispatched subagent
|
|
591
|
+
* every git-write, so at the moment this runs the wave's edits are uncommitted
|
|
592
|
+
* by construction — see {@link listChangedFiles}.
|
|
593
|
+
*
|
|
594
|
+
* Safe-default: a git failure on one half (missing sinceRef, detached HEAD, no
|
|
595
|
+
* commits) simply contributes nothing; both halves failing returns
|
|
596
|
+
* `{ touched: false, paths: [] }` so the gate never blocks a session.
|
|
339
597
|
*
|
|
340
598
|
* @param {object} opts
|
|
341
599
|
* @param {string} opts.repoRoot — repo to diff against.
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { dirname } from 'node:path';
|
|
32
32
|
|
|
33
33
|
import { learningKeyOf } from '../learnings/kebab.mjs';
|
|
34
|
-
import { deriveExpiresAt } from '../learnings/schema.mjs';
|
|
34
|
+
import { deriveExpiresAt, LEARNING_TYPE_REGISTRY } from '../learnings/schema.mjs';
|
|
35
35
|
|
|
36
36
|
const DAY_MS = 86400 * 1000;
|
|
37
37
|
const DESCRIPTION_MAX = 120;
|
|
@@ -81,6 +81,43 @@ const MIN_RULE_DAYS_DEFAULT = 7;
|
|
|
81
81
|
*/
|
|
82
82
|
const HOST_CLASS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Learning `type`s whose `host_class` is actually chip/OS-specific and may be
|
|
86
|
+
* copied through into the emitted rule's activation axis (#1090).
|
|
87
|
+
*
|
|
88
|
+
* DERIVED from `scripts/lib/learnings/schema.mjs`'s `LEARNING_TYPE_REGISTRY`:
|
|
89
|
+
* every type whose `hostScoped` flag is `true`. It was a hand-kept literal
|
|
90
|
+
* `new Set(['hardware-pattern'])` until #1151 — a SECOND type register beside
|
|
91
|
+
* the registry, and the two could only drift silently: a type made host-scoped
|
|
92
|
+
* in the registry would keep having its `host_class` dropped here, and a type
|
|
93
|
+
* removed there would keep gating rules by host. Same derivation pattern (and
|
|
94
|
+
* the same reason) as `CONVERT_TYPES` in `reconcile/eligibility.mjs` and
|
|
95
|
+
* `PROPOSAL_TYPES` in `memory-proposals/schema.mjs`.
|
|
96
|
+
*
|
|
97
|
+
* Before this allowlist, EVERY type's `host_class` was copied through
|
|
98
|
+
* unconditionally the moment it passed {@link HOST_CLASS_RE}. Measured against
|
|
99
|
+
* the live corpus (12/146 learnings carrying `host_class`, all
|
|
100
|
+
* `macos-arm64-m4pro`, on `anti-pattern`/`proven-pattern`/`recurring-issue` —
|
|
101
|
+
* NONE of them chip-specific content): a rule proposal could ship with
|
|
102
|
+
* `host-class` as its SOLE activation axis on a learning that has nothing to
|
|
103
|
+
* do with hardware, silently restricting an otherwise-general finding (a
|
|
104
|
+
* PNG-size anti-pattern, say) to one operator's one machine.
|
|
105
|
+
*
|
|
106
|
+
* `hardware-pattern` — the registry's only `hostScoped: true` entry today — is
|
|
107
|
+
* `ruleConvertible: false` there, so it can never reach `eligibility.mjs`'s
|
|
108
|
+
* CONVERT_TYPES allow-list and this set is currently INERT (no live record can
|
|
109
|
+
* exercise the "copied through" branch). It stays as forward-compat: the day a
|
|
110
|
+
* type is made BOTH host-scoped AND rule-convertible, the registry alone
|
|
111
|
+
* decides it is allowed to gate a rule by host — no edit to this file.
|
|
112
|
+
*
|
|
113
|
+
* @type {ReadonlySet<string>}
|
|
114
|
+
*/
|
|
115
|
+
export const HOST_SPECIFIC_TYPES = new Set(
|
|
116
|
+
Object.entries(LEARNING_TYPE_REGISTRY)
|
|
117
|
+
.filter(([, meta]) => meta.hostScoped)
|
|
118
|
+
.map(([type]) => type)
|
|
119
|
+
);
|
|
120
|
+
|
|
84
121
|
// Characters that make a `file_paths[]` entry unsafe to serialise as a glob.
|
|
85
122
|
// The renderer emits each glob as ` - "<value>"`, so a control char (a newline
|
|
86
123
|
// above all) breaks out of the block sequence into new top-level frontmatter
|
|
@@ -104,16 +141,22 @@ const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F]/g;
|
|
|
104
141
|
// .claude/rules/proven-pattern-nul-byte-corruption...).
|
|
105
142
|
const CONTROL_CHARS_TEST_RE = new RegExp(CONTROL_CHARS_RE.source);
|
|
106
143
|
|
|
107
|
-
// Glob metacharacters (issue #900-follow-up, Q3-MED). A
|
|
108
|
-
// (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF
|
|
109
|
-
// stray '**'
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
144
|
+
// Glob metacharacters (issue #900-follow-up, Q3-MED; extended #1089). A
|
|
145
|
+
// top-level (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF
|
|
146
|
+
// below — so a stray '**', '[ab]', or a path containing a picomatch group
|
|
147
|
+
// (parens) must never reach the renderer verbatim, or it would either produce
|
|
148
|
+
// an effectively always-on rule glob OR — the #1089 defect — a glob that
|
|
149
|
+
// LOOKS scoped but can never match anything at all: `(` / `)` are not
|
|
150
|
+
// filtered here even though a directory segment like `app/(marketing)/page`
|
|
151
|
+
// (a Next.js route group) contains them, so `app/(marketing)/**` is emitted
|
|
152
|
+
// and picomatch's own group syntax then reads `(marketing)` as a capture
|
|
153
|
+
// group rather than a literal path segment — `picomatch.isMatch('app/
|
|
154
|
+
// (marketing)/page.tsx', 'app/(marketing)/**')` is `false`. A rule minted from
|
|
155
|
+
// such an entry loads, looks scoped, and never fires on the very path it was
|
|
156
|
+
// derived from. `!`, `@`, `+` are ALSO picomatch extglob prefixes, but only
|
|
157
|
+
// when immediately followed by `(` (`!(...)`, `@(...)`, `+(...)`) — bare `!`
|
|
158
|
+
// (e.g. a filename) is not itself unsafe and is left alone.
|
|
159
|
+
const GLOB_METACHAR_RE = /[*?[\]{}()]|[!@+](?=\()/;
|
|
117
160
|
|
|
118
161
|
/**
|
|
119
162
|
* Derive non-empty directory globs from a learning's `file_paths`.
|
|
@@ -121,12 +164,13 @@ const GLOB_METACHAR_RE = /[*?[\]{}]/;
|
|
|
121
164
|
* For each path: take its directory (`path.dirname`) and emit `<dir>/**`. When
|
|
122
165
|
* the file sits at the repo top level (`dirname` === '.'), emit the bare
|
|
123
166
|
* basename pattern instead of `./**` (e.g. `"foo.mjs"`). Entries containing a
|
|
124
|
-
* glob metacharacter (`* ? [ ] { }
|
|
125
|
-
* {@link GLOB_METACHAR_RE} — as are
|
|
126
|
-
* character, which would break out
|
|
127
|
-
* item into new top-level
|
|
128
|
-
*
|
|
129
|
-
* deduped, order-preserving on
|
|
167
|
+
* glob metacharacter (`* ? [ ] { } ( )`, or an extglob prefix `! @ +` directly
|
|
168
|
+
* before `(`) are skipped entirely — see {@link GLOB_METACHAR_RE} — as are
|
|
169
|
+
* entries carrying a control char or a quote character, which would break out
|
|
170
|
+
* of the renderer's ` - "<glob>"` sequence item into new top-level
|
|
171
|
+
* frontmatter keys (#1015; see {@link CONTROL_CHARS_TEST_RE} /
|
|
172
|
+
* {@link UNSAFE_PATH_QUOTE_RE}). Results are deduped, order-preserving on
|
|
173
|
+
* first occurrence.
|
|
130
174
|
*
|
|
131
175
|
* @param {string[]} filePaths
|
|
132
176
|
* @returns {string[]}
|
|
@@ -262,7 +306,10 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
|
|
|
262
306
|
* hostClass?: string,
|
|
263
307
|
* }}
|
|
264
308
|
* @throws {Error} when no activation axis can be produced (empty globs AND no
|
|
265
|
-
* hostClass) — the never-always-on invariant.
|
|
309
|
+
* hostClass) — the never-always-on invariant. When `host_class` WAS present
|
|
310
|
+
* and valid but its `type` is not in {@link HOST_SPECIFIC_TYPES} (#1090), the
|
|
311
|
+
* message names that specifically rather than reporting the generic
|
|
312
|
+
* "no activation axis" reason.
|
|
266
313
|
* @throws {Error} when the learning has no derivable `learning_key` (no usable
|
|
267
314
|
* `type`, or no `title`/`subject` that slugs to a non-empty token) — a rule
|
|
268
315
|
* whose key resolves to no learning is worse than no rule.
|
|
@@ -282,7 +329,16 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
|
|
|
282
329
|
// silently emitting the rest of the rule from the same record would hide it.
|
|
283
330
|
// The engine wraps each learning in its own try/catch, so this throw degrades
|
|
284
331
|
// to ONE recorded rejection with an auditable reason, never a crashed run.
|
|
332
|
+
// Shape validation runs UNCONDITIONALLY, regardless of `type` — a malformed
|
|
333
|
+
// value is a corruption signal on its own and is rejected whether or not the
|
|
334
|
+
// type would ever be allowed to use it as an activation axis (see below).
|
|
285
335
|
let hostClass;
|
|
336
|
+
// #1090: `host_class` is only meaningful as an activation axis for a type
|
|
337
|
+
// that is actually chip/OS-specific (see {@link HOST_SPECIFIC_TYPES}) — set
|
|
338
|
+
// when a VALID host_class exists but its type is not in that allow-list, so
|
|
339
|
+
// the never-always-on throw below can name the reason instead of reporting
|
|
340
|
+
// the generic "no activation axis" message.
|
|
341
|
+
let hostClassDroppedByType = false;
|
|
286
342
|
if (typeof learning.host_class === 'string' && learning.host_class !== '') {
|
|
287
343
|
if (!HOST_CLASS_RE.test(learning.host_class)) {
|
|
288
344
|
throw new Error(
|
|
@@ -292,12 +348,24 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
|
|
|
292
348
|
`defeating the never-always-on brandmauer and the expiry sweep. Expected ${HOST_CLASS_RE.source}.`,
|
|
293
349
|
);
|
|
294
350
|
}
|
|
295
|
-
|
|
351
|
+
if (HOST_SPECIFIC_TYPES.has(learning.type)) {
|
|
352
|
+
hostClass = learning.host_class;
|
|
353
|
+
} else {
|
|
354
|
+
hostClassDroppedByType = true;
|
|
355
|
+
}
|
|
296
356
|
}
|
|
297
357
|
|
|
298
358
|
// The brandmauer: an auto-generated rule must carry ≥1 activation axis. If we
|
|
299
359
|
// could derive neither a glob nor a host-class, refuse — never emit always-on.
|
|
300
360
|
if (globs.length === 0 && hostClass === undefined) {
|
|
361
|
+
if (hostClassDroppedByType) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`emitter: host_class ${JSON.stringify(learning.host_class)} is present and well-formed, but type ` +
|
|
364
|
+
`${JSON.stringify(learning.type)} is not host-specific (#1090 — host-class is only copied through for ` +
|
|
365
|
+
`${[...HOST_SPECIFIC_TYPES].join(', ')}) — refusing to emit always-on auto-generated rule ` +
|
|
366
|
+
'(never-always-on invariant); no other activation axis (globs) is available either.',
|
|
367
|
+
);
|
|
368
|
+
}
|
|
301
369
|
throw new Error(
|
|
302
370
|
'emitter: no activation axis (globs/host-class) — refusing to emit always-on auto-generated rule (never-always-on invariant)',
|
|
303
371
|
);
|