session-orchestrator 3.21.0 → 3.23.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 +11 -2
- 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/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +10 -10
- 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/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 +269 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/memory-proposal-collector.md +6 -4
- 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/eli5.md +33 -0
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- 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 +52 -5
- package/docs/session-config-reference.md +57 -12
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +26 -16
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -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 +12 -2
- package/hooks/on-session-end.mjs +237 -104
- package/hooks/on-session-start.mjs +404 -110
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +133 -21
- 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/monitors/monitors.json +6 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- 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/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +12 -8
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- 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/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +221 -68
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- 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/rank.mjs +4 -7
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +84 -11
- package/scripts/lib/gates/gate-helpers.mjs +93 -21
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +284 -31
- 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 +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +104 -4
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +165 -7
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- 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 +159 -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-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +111 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +467 -72
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -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 +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -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-rules.mjs +153 -9
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- 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-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +478 -0
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +6 -0
- package/scripts/validate-wave-scope.mjs +73 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -25
- package/skills/_shared/parallel-aware-preamble.md +35 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +61 -8
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +5 -4
- 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/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/discovery/SKILL.md +28 -24
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +18 -6
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +44 -4
- package/skills/session-end/SKILL.md +70 -24
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +48 -5
- 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 +96 -48
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +129 -24
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/templates/_shared/journey-manifest.md +110 -0
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -41,17 +41,28 @@ function isTestFile(filePath) {
|
|
|
41
41
|
/**
|
|
42
42
|
* Execute a shell command and return a structured result.
|
|
43
43
|
*
|
|
44
|
+
* `output` is a bounded TAIL for humans. `fullOutput` is the complete captured
|
|
45
|
+
* text and is what every COUNT parse must read.
|
|
46
|
+
*
|
|
47
|
+
* Why both: the tail was 5 lines and was also the parser's input. Vitest prints
|
|
48
|
+
* its `Test Files` / `Tests` summary and THEN the per-failure detail, so on any
|
|
49
|
+
* real failure the summary sits hundreds of lines above the tail — measured
|
|
50
|
+
* 2026-08-22: line 174 of 936. `extractTestCounts` then found nothing and the
|
|
51
|
+
* gate reported `test: fail, total 0, passed 0, failed 0`, which reads as
|
|
52
|
+
* "the runner never produced results" and hides WHICH test failed. An hour was
|
|
53
|
+
* spent chasing that phantom before the real cause (one red test) was found.
|
|
54
|
+
*
|
|
44
55
|
* @param {string} cmd - Shell command to run, or `"skip"` / empty to skip.
|
|
45
|
-
* @returns {{ status: 'pass'|'fail'|'skip', output: string, exitCode: number, stubbed?: { kind: 'echo'|'noop' } }}
|
|
56
|
+
* @returns {{ status: 'pass'|'fail'|'skip', output: string, fullOutput: string, exitCode: number, stubbed?: { kind: 'echo'|'noop' } }}
|
|
46
57
|
*/
|
|
47
58
|
export function runCheck(cmd) {
|
|
48
59
|
if (!cmd || cmd === 'skip') {
|
|
49
|
-
return { status: 'skip', output: '', exitCode: 0 };
|
|
60
|
+
return { status: 'skip', output: '', fullOutput: '', exitCode: 0 };
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
const stub = detectStubCommand(cmd);
|
|
53
64
|
if (stub.isStub) {
|
|
54
|
-
return { status: 'pass', output: `(stubbed: ${stub.kind})`, exitCode: 0, stubbed: { kind: stub.kind } };
|
|
65
|
+
return { status: 'pass', output: `(stubbed: ${stub.kind})`, fullOutput: `(stubbed: ${stub.kind})`, exitCode: 0, stubbed: { kind: stub.kind } };
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
try {
|
|
@@ -61,18 +72,18 @@ export function runCheck(cmd) {
|
|
|
61
72
|
maxBuffer: RUN_CHECK_MAX_BUFFER_BYTES,
|
|
62
73
|
});
|
|
63
74
|
const output = raw.split('\n').slice(-5).join('\n').trim();
|
|
64
|
-
return { status: 'pass', output, exitCode: 0 };
|
|
75
|
+
return { status: 'pass', output, fullOutput: raw, exitCode: 0 };
|
|
65
76
|
} catch (err) {
|
|
66
77
|
const exitCode = typeof err.status === 'number' ? err.status : 1;
|
|
67
78
|
|
|
68
79
|
// Exit code 127 means command not found — treat as skip.
|
|
69
80
|
if (exitCode === 127) {
|
|
70
|
-
return { status: 'skip', output: 'command not found', exitCode };
|
|
81
|
+
return { status: 'skip', output: 'command not found', fullOutput: '', exitCode };
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
const combined = [err.stdout ?? '', err.stderr ?? ''].join('\n');
|
|
74
85
|
const output = combined.split('\n').slice(-5).join('\n').trim();
|
|
75
|
-
return { status: 'fail', output, exitCode };
|
|
86
|
+
return { status: 'fail', output, fullOutput: combined, exitCode };
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
|
|
@@ -143,7 +154,35 @@ export function extractCount(output, pattern) {
|
|
|
143
154
|
const TEST_SUMMARY_LINE = /^\s*Tests\b/;
|
|
144
155
|
|
|
145
156
|
/**
|
|
146
|
-
*
|
|
157
|
+
* Matches vitest's FILE-level summary line (`Test Files 8 failed | 595 passed (603)`).
|
|
158
|
+
*
|
|
159
|
+
* Distinct from {@link TEST_SUMMARY_LINE} (the TEST-CASE line, `Tests ...`): a
|
|
160
|
+
* suite that dies at import time is counted HERE, and the `Tests` line then
|
|
161
|
+
* carries no `N failed` segment at all (#1149) — vitest omits it whenever zero
|
|
162
|
+
* individual test cases ran. Last matching line wins (the final summary after
|
|
163
|
+
* any rerun), mirroring `TEST_SUMMARY_LINE`'s own convention.
|
|
164
|
+
*/
|
|
165
|
+
const TEST_FILES_SUMMARY_LINE = /^\s*Test Files\b/;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse a `<N> passed` / `<N> failed` pair out of one summary line/scope.
|
|
169
|
+
* `total` is their sum (skipped excluded) — the same rule the top-level
|
|
170
|
+
* test-case triple in {@link extractTestCounts} publishes.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} scope
|
|
173
|
+
* @returns {{ passed: number, failed: number, total: number }}
|
|
174
|
+
*/
|
|
175
|
+
function parseCountTriple(scope) {
|
|
176
|
+
const passMatch = scope.match(/(\d+)\s+passed/);
|
|
177
|
+
const failMatch = scope.match(/(\d+)\s+failed/);
|
|
178
|
+
const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
|
|
179
|
+
const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
|
|
180
|
+
return { passed, failed, total: passed + failed };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parse test-runner output for pass/fail/total TEST-CASE counts, plus the
|
|
185
|
+
* vitest FILE-level triple when a `Test Files` summary line is present.
|
|
147
186
|
*
|
|
148
187
|
* ## Which line is parsed
|
|
149
188
|
*
|
|
@@ -157,10 +196,38 @@ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
|
|
|
157
196
|
* A naive whole-output scan hits the FILE count (550) and publishes it as the
|
|
158
197
|
* test count — the number then rides `gate-full.mjs`'s `test.passed` into the
|
|
159
198
|
* `orchestrator.quality_gate.*` event stream looking authoritative. So: when a
|
|
160
|
-
* `Tests`-anchored summary line exists, ONLY that line is parsed
|
|
161
|
-
* which is the final summary after any
|
|
162
|
-
* no such line falls back to scanning
|
|
163
|
-
* bare `"42 passed"` / `"10 passed, 5
|
|
199
|
+
* `Tests`-anchored summary line exists, ONLY that line is parsed for the
|
|
200
|
+
* test-case triple (the LAST one, which is the final summary after any
|
|
201
|
+
* rerun). Terse or non-vitest output with no such line falls back to scanning
|
|
202
|
+
* the whole string, which preserves the bare `"42 passed"` / `"10 passed, 5
|
|
203
|
+
* failed"` forms.
|
|
204
|
+
*
|
|
205
|
+
* The `Test Files` line is parsed the same way, into a SEPARATE nested `files`
|
|
206
|
+
* triple — never folded into `failed`/`total`. `total === passed + failed` is
|
|
207
|
+
* load-bearing for the test-case triple (`admitSuiteCounts`, and historically
|
|
208
|
+
* `failed = total - passed` in `run-quality-gate.mjs`); mixing file counts in
|
|
209
|
+
* would either break that invariant or invent phantom test-case failures. A
|
|
210
|
+
* suite that dies at import is invisible on the `Tests` line (vitest omits
|
|
211
|
+
* `N failed` there when zero test cases ran) and visible only on `Test
|
|
212
|
+
* Files` — #1149, measured as
|
|
213
|
+
* `{"test":{"status":"fail","total":14671,"passed":14671,"failed":0}}`, with
|
|
214
|
+
* no field anywhere naming the 8 files that actually died.
|
|
215
|
+
* `gate-full.mjs` derives a `suite_died` boolean from `files.failed` for
|
|
216
|
+
* exactly this shape.
|
|
217
|
+
*
|
|
218
|
+
* ## `files` is NULL when no `Test Files` line was seen — never a zero triple
|
|
219
|
+
*
|
|
220
|
+
* Only vitest prints that line. A non-vitest runner, a terse reporter, or a
|
|
221
|
+
* fail-fast crash produces output with no file-level summary at all, and the
|
|
222
|
+
* function used to publish `files_total/passed/failed: 0` there — an UNMEASURED
|
|
223
|
+
* zero, byte-identical in the envelope to a measured one, from which
|
|
224
|
+
* `gate-full.mjs` then derived `suite_died: false` as if it had checked. That is
|
|
225
|
+
* the same "absent is not zero" defect `admitSuiteCounts` exists to prevent one
|
|
226
|
+
* field over, so it uses the same channel: `null` means "no file-level
|
|
227
|
+
* measurement", and the caller OMITS the keys rather than zero-filling them.
|
|
228
|
+
* The test-case triple keeps its zeros — those are the caller's fallback, not a
|
|
229
|
+
* parse result, and `total: 0` is already the "no counts" signal
|
|
230
|
+
* `admitSuiteCounts` rejects.
|
|
164
231
|
*
|
|
165
232
|
* ## What `total` means — passed + failed, NOT vitest's parenthesised number
|
|
166
233
|
*
|
|
@@ -178,22 +245,27 @@ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
|
|
|
178
245
|
* see. Add it together with a consumer, never ahead of one.
|
|
179
246
|
*
|
|
180
247
|
* @param {string} output - Captured test-runner stdout/stderr (or a tail of it).
|
|
181
|
-
* @returns {{ passed: number, failed: number, total: number
|
|
248
|
+
* @returns {{ passed: number, failed: number, total: number, files: {passed: number, failed: number, total: number}|null }}
|
|
249
|
+
* Test-case `total === passed + failed`. `files` is `null` when the output
|
|
250
|
+
* carried no `Test Files` summary line; when present,
|
|
251
|
+
* `files.total === files.passed + files.failed`.
|
|
182
252
|
*/
|
|
183
253
|
export function extractTestCounts(output) {
|
|
184
|
-
|
|
254
|
+
const EMPTY = { passed: 0, failed: 0, total: 0, files: null };
|
|
255
|
+
if (!output) return EMPTY;
|
|
185
256
|
|
|
186
|
-
const
|
|
257
|
+
const lines = output.split('\n');
|
|
258
|
+
const summaryLines = lines.filter((line) => TEST_SUMMARY_LINE.test(line));
|
|
187
259
|
const scope = summaryLines.length > 0 ? summaryLines[summaryLines.length - 1] : output;
|
|
260
|
+
const { passed, failed, total } = parseCountTriple(scope);
|
|
188
261
|
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const total = passed + failed;
|
|
262
|
+
const fileSummaryLines = lines.filter((line) => TEST_FILES_SUMMARY_LINE.test(line));
|
|
263
|
+
const fileScope = fileSummaryLines.length > 0 ? fileSummaryLines[fileSummaryLines.length - 1] : '';
|
|
264
|
+
// No `Test Files` line → NOT MEASURED. See the docstring section above: a zero
|
|
265
|
+
// triple here would be indistinguishable from a real all-passed-zero-files run.
|
|
266
|
+
const files = fileScope ? parseCountTriple(fileScope) : null;
|
|
195
267
|
|
|
196
|
-
return { passed, failed, total };
|
|
268
|
+
return { passed, failed, total, files };
|
|
197
269
|
}
|
|
198
270
|
|
|
199
271
|
/**
|
|
@@ -47,11 +47,35 @@
|
|
|
47
47
|
* - a `.git/config` written AFTER this probe ran. It is a session-start
|
|
48
48
|
* snapshot, not a watcher.
|
|
49
49
|
*
|
|
50
|
-
* **Revisit trigger
|
|
51
|
-
* deliberate local override
|
|
50
|
+
* **Revisit trigger (identity / remote / gpgsign only):** if an operator
|
|
51
|
+
* reports a standing false positive on a deliberate local override of one of
|
|
52
|
+
* those three, add an explicit allow-list (a `[sessionOrchestrator]`
|
|
52
53
|
* local-config key naming the accepted keys) — do NOT widen or delete a rule,
|
|
53
54
|
* which would re-open the exact hole the probe was built to close.
|
|
54
55
|
*
|
|
56
|
+
* `hooks-path` is no longer in that bucket (#1158, 2026-08-28): a
|
|
57
|
+
* `core.hooksPath` outside `.husky/_` is accepted when the repo DECLARES it —
|
|
58
|
+
* `git ls-files -- <hooksPath>` returns at least one TRACKED file DIRECTLY
|
|
59
|
+
* under the hooksPath whose BASENAME is a real git hook name (see
|
|
60
|
+
* `GIT_HOOK_NAMES`, sourced from `git help hooks`), the same "declared beats
|
|
61
|
+
* guessed" shape `isFixtureHost` already applies to the remote finding. A
|
|
62
|
+
* tracked `.githooks/pre-commit` with `core.hooksPath=.githooks` is a
|
|
63
|
+
* deliberate setup, not drift, and stops flagging without any config key. An
|
|
64
|
+
* untracked or out-of-repo hooksPath still flags exactly as before — that is
|
|
65
|
+
* still the incident class this probe exists for (a fixture that rewrote
|
|
66
|
+
* hooksPath to somewhere nothing is tracked).
|
|
67
|
+
*
|
|
68
|
+
* **Narrowed 2026-08-28 (review, #1158/#1159 N1):** the first cut of this
|
|
69
|
+
* acceptance rule asked only "does `git ls-files` return anything at all
|
|
70
|
+
* under the hooksPath" — which accepted `core.hooksPath=scripts` the moment
|
|
71
|
+
* ANY file under `scripts/` was tracked, including an UNTRACKED executable
|
|
72
|
+
* literally named `pre-commit` planted alongside ordinary tracked source
|
|
73
|
+
* (measured exploit: `git ls-files -- scripts` returns 456 tracked files in
|
|
74
|
+
* this repo, none of them a hook, and the old check accepted it on count
|
|
75
|
+
* alone). `hooksPathIsTracked` now requires a tracked file DIRECTLY under the
|
|
76
|
+
* hooksPath whose basename git itself would invoke as a hook — a tracked
|
|
77
|
+
* directory of unrelated source no longer counts as a declaration.
|
|
78
|
+
*
|
|
55
79
|
* ## Fail-open is forbidden here
|
|
56
80
|
*
|
|
57
81
|
* A failed query is NOT a clean repo. That fold — `null` meaning both "asked
|
|
@@ -77,6 +101,7 @@
|
|
|
77
101
|
*/
|
|
78
102
|
|
|
79
103
|
import { spawnSync } from 'node:child_process';
|
|
104
|
+
import { isAbsolute, relative } from 'node:path';
|
|
80
105
|
|
|
81
106
|
/** Default timeout in ms for each `git config` invocation. */
|
|
82
107
|
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
@@ -112,7 +137,9 @@ export const DEGRADED_REASONS = Object.freeze([
|
|
|
112
137
|
* global identity. The costliest entry of the incident.
|
|
113
138
|
* - `local-gpgsign` — a local `commit.gpgsign` differing from the global.
|
|
114
139
|
* - `fixture-remote` — a remote URL on a reserved/fixture host.
|
|
115
|
-
* - `hooks-path` — a local `core.hooksPath` not pointing at `.husky/_
|
|
140
|
+
* - `hooks-path` — a local `core.hooksPath` not pointing at `.husky/_`
|
|
141
|
+
* and not DECLARED by the repo — no tracked file directly under it is a
|
|
142
|
+
* real git hook name; see `hooksPathIsTracked` / `GIT_HOOK_NAMES`.
|
|
116
143
|
*
|
|
117
144
|
* @type {readonly ['ambient-git-env','local-identity','local-gpgsign','fixture-remote','hooks-path']}
|
|
118
145
|
*/
|
|
@@ -181,6 +208,45 @@ const FIXTURE_HOSTS = Object.freeze([
|
|
|
181
208
|
/** The only `core.hooksPath` this repo expects, matched as a path tail. */
|
|
182
209
|
const EXPECTED_HOOKS_PATH_TAIL = '.husky/_';
|
|
183
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Every hook name git itself recognizes under a `core.hooksPath` directory.
|
|
213
|
+
* Source: `git help hooks`. A tracked file below a declared hooksPath whose
|
|
214
|
+
* BASENAME is not one of these is not a hook git will ever invoke — accepting
|
|
215
|
+
* it as a "declaration" would accept an arbitrary tracked file, which is the
|
|
216
|
+
* #1158 review finding this list closes (see the module header's Narrowed
|
|
217
|
+
* note).
|
|
218
|
+
*/
|
|
219
|
+
const GIT_HOOK_NAMES = Object.freeze([
|
|
220
|
+
'applypatch-msg',
|
|
221
|
+
'pre-applypatch',
|
|
222
|
+
'post-applypatch',
|
|
223
|
+
'pre-commit',
|
|
224
|
+
'pre-merge-commit',
|
|
225
|
+
'prepare-commit-msg',
|
|
226
|
+
'commit-msg',
|
|
227
|
+
'post-commit',
|
|
228
|
+
'pre-rebase',
|
|
229
|
+
'post-checkout',
|
|
230
|
+
'post-merge',
|
|
231
|
+
'pre-push',
|
|
232
|
+
'pre-receive',
|
|
233
|
+
'update',
|
|
234
|
+
'proc-receive',
|
|
235
|
+
'post-receive',
|
|
236
|
+
'post-update',
|
|
237
|
+
'reference-transaction',
|
|
238
|
+
'push-to-checkout',
|
|
239
|
+
'pre-auto-gc',
|
|
240
|
+
'post-rewrite',
|
|
241
|
+
'sendemail-validate',
|
|
242
|
+
'fsmonitor-watchman',
|
|
243
|
+
'p4-changelist',
|
|
244
|
+
'p4-prepare-changelist',
|
|
245
|
+
'p4-post-changelist',
|
|
246
|
+
'p4-pre-submit',
|
|
247
|
+
'post-index-change',
|
|
248
|
+
]);
|
|
249
|
+
|
|
184
250
|
/**
|
|
185
251
|
* Build the filtered child environment. See {@link GIT_ENV_ALLOWLIST}.
|
|
186
252
|
*
|
|
@@ -310,6 +376,67 @@ function hooksPathIsExpected(value) {
|
|
|
310
376
|
return normalized === EXPECTED_HOOKS_PATH_TAIL || normalized.endsWith(`/${EXPECTED_HOOKS_PATH_TAIL}`);
|
|
311
377
|
}
|
|
312
378
|
|
|
379
|
+
/**
|
|
380
|
+
* @param {string} pathspec repo-relative hooksPath (posix separators, no
|
|
381
|
+
* trailing slash) already resolved to sit inside `repoRoot`
|
|
382
|
+
* @param {string} trackedLine one line of `git ls-files` output (repo-relative;
|
|
383
|
+
* git prints `/`-separated paths even on Windows, but this normalizes `\`
|
|
384
|
+
* too in case a future caller feeds it something else)
|
|
385
|
+
* @returns {boolean} true when `trackedLine` sits DIRECTLY under `pathspec`
|
|
386
|
+
* (not in a nested subdirectory) and its basename is a recognized git hook
|
|
387
|
+
* name — see {@link GIT_HOOK_NAMES}. A tracked file two levels deep, or a
|
|
388
|
+
* tracked file with an unrelated name, is deliberately NOT a declaration.
|
|
389
|
+
*/
|
|
390
|
+
function isDeclaredHookFile(pathspec, trackedLine) {
|
|
391
|
+
const line = String(trackedLine ?? '').replace(/\\/g, '/');
|
|
392
|
+
const slash = line.lastIndexOf('/');
|
|
393
|
+
const dir = slash === -1 ? '' : line.slice(0, slash);
|
|
394
|
+
const base = slash === -1 ? line : line.slice(slash + 1);
|
|
395
|
+
return dir === pathspec && GIT_HOOK_NAMES.includes(base);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* @param {string} value a `core.hooksPath` value that already failed
|
|
400
|
+
* {@link hooksPathIsExpected}
|
|
401
|
+
* @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
|
|
402
|
+
* the same context {@link runGitConfig} uses — same filtered env, same `-C`
|
|
403
|
+
* seam, so this query cannot be redirected any more than the config read can
|
|
404
|
+
* @returns {boolean} true when the path is DECLARED as a hook by the repo:
|
|
405
|
+
* `git ls-files -- <path>` returns at least one TRACKED file DIRECTLY under
|
|
406
|
+
* it whose basename is a real git hook name (#1158 review — a tracked
|
|
407
|
+
* directory of ordinary source files is not itself a declaration; see
|
|
408
|
+
* {@link GIT_HOOK_NAMES} / {@link isDeclaredHookFile}). An absolute value is
|
|
409
|
+
* resolved relative to `repoRoot` first; a value outside the repo root can
|
|
410
|
+
* never be tracked and returns `false` without spawning git. Any spawn
|
|
411
|
+
* failure (missing git, timeout, non-zero exit) also returns `false` — this
|
|
412
|
+
* helper only ever WIDENS acceptance, so a query it cannot answer must fall
|
|
413
|
+
* back to "not declared", never the reverse.
|
|
414
|
+
*/
|
|
415
|
+
function hooksPathIsTracked(value, ctx) {
|
|
416
|
+
const raw = String(value ?? '').trim();
|
|
417
|
+
if (raw === '') return false;
|
|
418
|
+
|
|
419
|
+
let pathspec = raw.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
420
|
+
if (isAbsolute(raw)) {
|
|
421
|
+
const rel = relative(ctx.repoRoot, raw);
|
|
422
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return false; // outside repo root
|
|
423
|
+
pathspec = rel.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const res = ctx.spawn('git', ['-C', ctx.repoRoot, 'ls-files', '--', pathspec], {
|
|
427
|
+
encoding: 'utf8',
|
|
428
|
+
timeout: ctx.timeoutMs,
|
|
429
|
+
env: filteredGitEnv(ctx.env),
|
|
430
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
if (res?.error || res?.signal) return false;
|
|
434
|
+
if (typeof res?.status !== 'number' || res.status !== 0) return false;
|
|
435
|
+
|
|
436
|
+
const lines = String(res?.stdout ?? '').split('\n').filter((l) => l.length > 0);
|
|
437
|
+
return lines.some((line) => isDeclaredHookFile(pathspec, line));
|
|
438
|
+
}
|
|
439
|
+
|
|
313
440
|
/**
|
|
314
441
|
* Build the degraded result. Distinct from `null` on purpose — see the header.
|
|
315
442
|
*
|
|
@@ -439,12 +566,14 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
|
|
|
439
566
|
continue;
|
|
440
567
|
}
|
|
441
568
|
|
|
442
|
-
if (lower === 'core.hookspath' && !hooksPathIsExpected(value)) {
|
|
569
|
+
if (lower === 'core.hookspath' && !hooksPathIsExpected(value) && !hooksPathIsTracked(value, ctx)) {
|
|
443
570
|
findings.push({
|
|
444
571
|
kind: 'hooks-path',
|
|
445
572
|
key,
|
|
446
573
|
value,
|
|
447
|
-
detail:
|
|
574
|
+
detail:
|
|
575
|
+
`core.hooksPath=${value} zeigt nicht auf ${EXPECTED_HOOKS_PATH_TAIL} und ist im Repo ` +
|
|
576
|
+
`nicht als getrackter Pfad deklariert — Hooks laufen möglicherweise nicht.`,
|
|
448
577
|
});
|
|
449
578
|
}
|
|
450
579
|
}
|
|
@@ -30,11 +30,26 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
existsSync,
|
|
35
|
+
readFileSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
renameSync,
|
|
38
|
+
chmodSync,
|
|
39
|
+
mkdirSync,
|
|
40
|
+
unlinkSync,
|
|
41
|
+
} from 'node:fs';
|
|
34
42
|
import { digestSha256WithSalt } from './crypto-digest-utils.mjs';
|
|
35
43
|
import path from 'node:path';
|
|
36
44
|
import os from 'node:os';
|
|
37
|
-
|
|
45
|
+
// NOTE (#1072 gate fix): platform.mjs is deliberately NOT imported statically.
|
|
46
|
+
// Its module level computes SO_PLATFORM/SO_PLUGIN_ROOT via detectPlatform()/
|
|
47
|
+
// resolvePluginRoot(), which walk the filesystem (existsSync + readFileSync of
|
|
48
|
+
// package.json up the tree). session-lock.mjs now imports this module, which
|
|
49
|
+
// put those import-time reads into every graph that imports session-lock —
|
|
50
|
+
// including fs-mocked test graphs (tests/lib/vault-mirror/process.test.mjs went
|
|
51
|
+
// red: the walk consumed the mocks' sequenced return values). The only two
|
|
52
|
+
// consumers (SO_OS/SO_PLATFORM in collectFingerprint) are async — load lazily.
|
|
38
53
|
|
|
39
54
|
const FINGERPRINT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
40
55
|
const PLACEHOLDER_SALT = 'env-aware-v1-default-salt-replaced-by-owner-yaml';
|
|
@@ -97,6 +112,235 @@ export function hashHostname(hostname, salt) {
|
|
|
97
112
|
return digestSha256WithSalt(hostname, { salt: String(salt) });
|
|
98
113
|
}
|
|
99
114
|
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Stable host identity (GitLab #1072)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
//
|
|
119
|
+
// `os.hostname()` is NOT stable on a single machine. Measured on the reference
|
|
120
|
+
// host 2026-08-24: two readings ten minutes apart returned `Mac.home` and
|
|
121
|
+
// `Ferdinands-MacBook-Pro.local`, and `.orchestrator/metrics/events.jsonl`
|
|
122
|
+
// carries both spellings (106× / 27×). Every `lock.host === os.hostname()`
|
|
123
|
+
// comparison in the lock family therefore fails against the machine's OWN lock
|
|
124
|
+
// after a flip: stale detection reports `cross-host` (and cross-host locks are
|
|
125
|
+
// never reaped or overridden by design, PSA-003), the reaper refuses with
|
|
126
|
+
// `cross-host-requires-operator`, and release owner-match returns `not-owner`.
|
|
127
|
+
//
|
|
128
|
+
// Two independent layers, because one is not enough:
|
|
129
|
+
// 1. `stableHostname()` normalises the SUFFIX difference (`Mac.home` vs
|
|
130
|
+
// `Mac.local` → `mac`). It cannot bridge `mac` vs `ferdinands-macbook-pro`
|
|
131
|
+
// — those differ before the suffix.
|
|
132
|
+
// 2. The self-alias ledger records every spelling THIS machine has presented
|
|
133
|
+
// itself under, so `hostnamesMatch()` can bridge the remaining gap. Only
|
|
134
|
+
// names the local machine wrote about itself ever enter it, which is what
|
|
135
|
+
// keeps a genuinely foreign host from ever matching.
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Suffixes stripped by {@link stableHostname}. mDNS (`.local`), the common
|
|
139
|
+
* router-assigned search domains (`.home`, `.lan`), and the POSIX default
|
|
140
|
+
* (`.localdomain`). Deliberately NOT a generic "strip the last label" rule:
|
|
141
|
+
* `a.b.example.com` is a real FQDN whose labels carry meaning.
|
|
142
|
+
*/
|
|
143
|
+
const LOCAL_HOST_SUFFIXES = ['.local', '.home', '.lan', '.localdomain'];
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Normalise a hostname into a comparable form: trimmed, lowercased, with ONE
|
|
147
|
+
* trailing local-network suffix removed.
|
|
148
|
+
*
|
|
149
|
+
* Never throws. A non-string, empty, or whitespace-only input yields `''`
|
|
150
|
+
* (which {@link hostnamesMatch} treats as "no identity" — it never matches).
|
|
151
|
+
*
|
|
152
|
+
* NOTE on the default: `stableHostname()` and `stableHostname(undefined)` both
|
|
153
|
+
* normalise `os.hostname()`, because that is JavaScript default-parameter
|
|
154
|
+
* semantics. Pass `''` or `null` to get the empty result.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} [raw=os.hostname()]
|
|
157
|
+
* @returns {string} normalised hostname, or '' when there is nothing to normalise.
|
|
158
|
+
*/
|
|
159
|
+
export function stableHostname(raw = os.hostname()) {
|
|
160
|
+
if (typeof raw !== 'string') return '';
|
|
161
|
+
let name = raw.trim().toLowerCase();
|
|
162
|
+
if (name === '') return '';
|
|
163
|
+
for (const suffix of LOCAL_HOST_SUFFIXES) {
|
|
164
|
+
// `name.length > suffix.length` keeps a bare `.local` from normalising to ''.
|
|
165
|
+
if (name.endsWith(suffix) && name.length > suffix.length) {
|
|
166
|
+
name = name.slice(0, -suffix.length);
|
|
167
|
+
break; // ONE suffix only — `foo.lan.local` keeps its inner label.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return name;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Host-alias ledger path. `SO_HOST_ALIASES_FILE` overrides it — MANDATORY for
|
|
175
|
+
* tests, which must never write into the operator's real `~/.config`.
|
|
176
|
+
*
|
|
177
|
+
* The `.trim() || fallback` shape is deliberate: a whitespace-only env var is
|
|
178
|
+
* truthy and would otherwise short-circuit the `||` (see `.claude/rules/
|
|
179
|
+
* development.md` § Error Handling, env-var fallback whitespace trap).
|
|
180
|
+
*
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
function _hostAliasesFile() {
|
|
184
|
+
const override = (process.env.SO_HOST_ALIASES_FILE || '').trim();
|
|
185
|
+
if (override) return override;
|
|
186
|
+
return path.join(_privateDir(), 'host-aliases.json');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Named ceiling (BV-004): a machine realistically presents 2–4 spellings of
|
|
191
|
+
* itself (`os.hostname()`, the mDNS variant, a DHCP-assigned name). 16 bounds
|
|
192
|
+
* the pathological DHCP-churn case while leaving ample headroom. Revisit if a
|
|
193
|
+
* real host is ever observed writing more than a handful of distinct names —
|
|
194
|
+
* beyond that the ledger stops being an identity record and becomes a history.
|
|
195
|
+
*/
|
|
196
|
+
const HOST_ALIASES_MAX = 16;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Synchronous atomic JSON write with an explicit mode. The async twin
|
|
200
|
+
* `_writeJsonAtomic` below cannot serve the alias ledger: every caller of
|
|
201
|
+
* {@link recordHostAlias} sits on a SYNCHRONOUS lock path. `io.mjs`'s
|
|
202
|
+
* `writeJsonAtomicSync` is not reused because it offers no mode argument, and
|
|
203
|
+
* 0o600 is the whole point of a file that lives beside `host-private.json`.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} filePath
|
|
206
|
+
* @param {*} data
|
|
207
|
+
* @param {number} mode
|
|
208
|
+
*/
|
|
209
|
+
function _writeJsonAtomicSync(filePath, data, mode) {
|
|
210
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
211
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
212
|
+
try {
|
|
213
|
+
// `flag: 'wx'` + `mode` on the CREATE, never a chmod after the rename.
|
|
214
|
+
// Two distinct holes closed: `wx` fails with EEXIST rather than following a
|
|
215
|
+
// pre-planted symlink at the (predictable) tmp path, so an attacker cannot
|
|
216
|
+
// redirect the write; and the mode is applied at creation, so the file is
|
|
217
|
+
// never briefly world-readable between `writeFileSync` and `chmodSync` —
|
|
218
|
+
// the window in which the ledger beside `host-private.json` was readable.
|
|
219
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode, flag: 'wx' });
|
|
220
|
+
renameSync(tmp, filePath);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
try { unlinkSync(tmp); } catch { /* best effort */ }
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
// Belt-and-braces for the pre-existing-target case: `rename` keeps the
|
|
226
|
+
// SOURCE inode, so the mode above already governs — this only repairs a
|
|
227
|
+
// umask-widened mode on platforms that ignore the create mode.
|
|
228
|
+
try { chmodSync(filePath, mode); } catch { /* best effort */ }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Read the self-alias ledger. Returns normalised names.
|
|
233
|
+
*
|
|
234
|
+
* Fail-CLOSED by construction: any failure (absent file, unreadable, malformed
|
|
235
|
+
* JSON, wrong shape) yields `[]`, which reduces {@link hostnamesMatch} to plain
|
|
236
|
+
* normalised equality. A broken ledger can therefore only ever REFUSE a match
|
|
237
|
+
* that would otherwise have been made — it can never manufacture one.
|
|
238
|
+
*
|
|
239
|
+
* @returns {string[]} normalised alias names; `[]` on any failure.
|
|
240
|
+
*/
|
|
241
|
+
export function readHostAliases() {
|
|
242
|
+
try {
|
|
243
|
+
const parsed = JSON.parse(readFileSync(_hostAliasesFile(), 'utf8'));
|
|
244
|
+
if (!Array.isArray(parsed)) return [];
|
|
245
|
+
const seen = new Set();
|
|
246
|
+
for (const entry of parsed) {
|
|
247
|
+
const norm = typeof entry === 'string' ? stableHostname(entry) : '';
|
|
248
|
+
if (norm) seen.add(norm);
|
|
249
|
+
}
|
|
250
|
+
return [...seen];
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Record the name this machine currently presents itself under, so a later
|
|
258
|
+
* reading under a DIFFERENT spelling can still be recognised as the same host.
|
|
259
|
+
*
|
|
260
|
+
* Best-effort and idempotent: never throws, never duplicates, and writes
|
|
261
|
+
* nothing when the name is already present or normalises away to ''. Callers
|
|
262
|
+
* invoke it once per session-lock acquisition — the one moment we know for a
|
|
263
|
+
* fact that the current `os.hostname()` belongs to THIS machine.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} [name=stableHostname()] — raw or normalised hostname.
|
|
266
|
+
* @returns {string[]} the ledger contents after the call (`[]` on failure).
|
|
267
|
+
*/
|
|
268
|
+
export function recordHostAlias(name = stableHostname()) {
|
|
269
|
+
try {
|
|
270
|
+
const norm = stableHostname(typeof name === 'string' ? name : '');
|
|
271
|
+
if (!norm) return readHostAliases();
|
|
272
|
+
const current = readHostAliases();
|
|
273
|
+
if (current.includes(norm)) return current;
|
|
274
|
+
// Keep the MOST RECENT names when the cap bites — an old spelling this
|
|
275
|
+
// machine has not used in 16 renames is the safest one to forget.
|
|
276
|
+
const next = [...current, norm].slice(-HOST_ALIASES_MAX);
|
|
277
|
+
_writeJsonAtomicSync(_hostAliasesFile(), next, 0o600);
|
|
278
|
+
return next;
|
|
279
|
+
} catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Decide whether two hostnames name the SAME machine.
|
|
286
|
+
*
|
|
287
|
+
* Match when the normalised forms are equal, or when BOTH appear in the
|
|
288
|
+
* self-alias set (see {@link recordHostAlias}). Requiring both sides to be in
|
|
289
|
+
* the set is what preserves the cross-host invariants: a foreign host's name
|
|
290
|
+
* was never written by this machine, so it cannot be in the ledger, so it can
|
|
291
|
+
* never match — `cross-host` stays `cross-host` (PSA-003).
|
|
292
|
+
*
|
|
293
|
+
* The ledger is read ONLY when normalised equality already failed, so the
|
|
294
|
+
* common same-host path costs no filesystem access even inside a poll loop.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} a
|
|
297
|
+
* @param {string} b
|
|
298
|
+
* @param {{ aliases?: string[] }} [opts] — inject the alias set (tests, and
|
|
299
|
+
* callers that already hold it); omitted → read the ledger.
|
|
300
|
+
* @returns {boolean}
|
|
301
|
+
*/
|
|
302
|
+
export function hostnamesMatch(a, b, { aliases } = {}) {
|
|
303
|
+
const normA = stableHostname(typeof a === 'string' ? a : '');
|
|
304
|
+
const normB = stableHostname(typeof b === 'string' ? b : '');
|
|
305
|
+
if (!normA || !normB) return false;
|
|
306
|
+
if (normA === normB) return true;
|
|
307
|
+
|
|
308
|
+
const set = Array.isArray(aliases)
|
|
309
|
+
? aliases.map((x) => (typeof x === 'string' ? stableHostname(x) : '')).filter(Boolean)
|
|
310
|
+
: readHostAliases();
|
|
311
|
+
return set.includes(normA) && set.includes(normB);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Pick the host identity to compare a lock body against: the normalised
|
|
316
|
+
* `host_id` when it carries one, else the raw pre-#1072 `host`.
|
|
317
|
+
*
|
|
318
|
+
* `||`, not `??`, and that is the whole point. `??` falls back on `null` and
|
|
319
|
+
* `undefined` only, so a lock whose `host_id` is the EMPTY STRING — what a
|
|
320
|
+
* writer produces when `os.hostname()` momentarily returns '' or whitespace —
|
|
321
|
+
* short-circuits to `''`, and `hostnamesMatch('', host)` is false by contract
|
|
322
|
+
* ("no identity never matches"). The machine then reads its OWN lock as
|
|
323
|
+
* cross-host: stale detection is disabled, the reaper refuses with
|
|
324
|
+
* `cross-host-requires-operator`, and release returns `not-owner` — the exact
|
|
325
|
+
* #1072 failure the `host_id` field was added to prevent, re-entered through
|
|
326
|
+
* the fallback operator. Falling back on '' costs nothing: an empty `host` too
|
|
327
|
+
* yields '' here, and `hostnamesMatch` still refuses it.
|
|
328
|
+
*
|
|
329
|
+
* The `.trim()` extends the same fix one step: a whitespace-only `host_id` is
|
|
330
|
+
* TRUTHY, so a bare `||` would short-circuit on it exactly as `??` does on `''`
|
|
331
|
+
* (`.claude/rules/development.md` § Error Handling, env-var fallback whitespace
|
|
332
|
+
* trap — the same shape, one domain over).
|
|
333
|
+
*
|
|
334
|
+
* @param {{ host_id?: unknown, host?: unknown }|null|undefined} lock
|
|
335
|
+
* @returns {string} raw (un-normalised, but trimmed) candidate name; '' when
|
|
336
|
+
* neither field carries a usable value. Never throws.
|
|
337
|
+
*/
|
|
338
|
+
export function lockHostCandidate(lock) {
|
|
339
|
+
const id = typeof lock?.host_id === 'string' ? lock.host_id.trim() : '';
|
|
340
|
+
if (id) return id;
|
|
341
|
+
return typeof lock?.host === 'string' ? lock.host.trim() : '';
|
|
342
|
+
}
|
|
343
|
+
|
|
100
344
|
// ---------------------------------------------------------------------------
|
|
101
345
|
// Paths
|
|
102
346
|
// ---------------------------------------------------------------------------
|
|
@@ -149,6 +393,7 @@ export async function resolveSalt() {
|
|
|
149
393
|
* @param {string} [opts.salt] — override salt (tests)
|
|
150
394
|
*/
|
|
151
395
|
export async function collectFingerprint(opts = {}) {
|
|
396
|
+
const { SO_OS, SO_PLATFORM } = await import('./platform.mjs');
|
|
152
397
|
const osName = SO_OS;
|
|
153
398
|
const arch = process.arch;
|
|
154
399
|
const cpus = os.cpus() || [];
|
|
@@ -107,8 +107,38 @@ export const DEFAULT_CEILING = 480;
|
|
|
107
107
|
* the live repo sits at 471/480 directives (98.1% of ceiling) but 108589/114000
|
|
108
108
|
* bytes (95.3%). That asymmetry is inherited from the pre-existing directive
|
|
109
109
|
* ratchet, not introduced here.
|
|
110
|
+
*
|
|
111
|
+
* RE-BASELINED 2026-08-22: 114000 -> 121000. The ratchet fired for exactly the
|
|
112
|
+
* reason stated above — "a genuinely NEW always-on surface is added". Since the
|
|
113
|
+
* 2026-07-30 calibration this repo's corpus grew 108589 -> 115730 B, and the
|
|
114
|
+
* largest single cause is a rule file the operator adopted:
|
|
115
|
+
* .claude/rules/host-resources.md (7457 B, #1089, live 2026-08-21). Headroom
|
|
116
|
+
* unchanged at +5%: 115730 x 1.05 = 121516, rounded DOWN to 121000. Same
|
|
117
|
+
* relative slack, measured against the corpus that actually exists.
|
|
118
|
+
*
|
|
119
|
+
* Consumer impact was CHECKED, not assumed. This module ships inside the npm
|
|
120
|
+
* package, so the first instinct was to keep the shared default fixed and set a
|
|
121
|
+
* repo-local `instruction-budget.byte-ceiling` override instead. Two
|
|
122
|
+
* measurements killed that plan: (a) the package ships THREE always-on rules
|
|
123
|
+
* totalling 9261 B — both ceilings sit ~12x above any consumer's inherited
|
|
124
|
+
* corpus, so the delta is numerically inert downstream; and (b) the number was
|
|
125
|
+
* never a shipped-corpus figure in the first place — the calibration above says
|
|
126
|
+
* "measured against this repo's own always-on corpus". A repo-local override
|
|
127
|
+
* also could not have worked: tests/rules/receiving-review.test.mjs calls
|
|
128
|
+
* computeInstructionBudget({repoRoot}), which does not read Session Config, so
|
|
129
|
+
* it pins this constant by construction.
|
|
130
|
+
*
|
|
131
|
+
* Not a licence to raise the number whenever it is hit — a ratchet that yields
|
|
132
|
+
* on contact measures nothing, and the NEXT breach belongs to a diet. 4499 B of
|
|
133
|
+
* the always-on security.md still describes surfaces this repo does not have
|
|
134
|
+
* (SSRF via an http-client package, an OWASP table, RLS, bcrypt/JWT), vendored
|
|
135
|
+
* from the baseline and never adapted — the same class as the Semgrep claim
|
|
136
|
+
* fixed today. That cut is real and nameable and is tracked in #1126; it was
|
|
137
|
+
* deliberately NOT taken in the same pass that raised the ceiling, because
|
|
138
|
+
* deleting security prose to hit a number is the failure build-value.md BV-002
|
|
139
|
+
* names.
|
|
110
140
|
*/
|
|
111
|
-
export const DEFAULT_BYTE_CEILING =
|
|
141
|
+
export const DEFAULT_BYTE_CEILING = 121000;
|
|
112
142
|
|
|
113
143
|
/**
|
|
114
144
|
* Read the `instruction-budget:` nested block from the `## Session Config`
|