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
|
@@ -10,8 +10,16 @@
|
|
|
10
10
|
* liveness via {@link isLockLive}) plus the host-wide session registry (the
|
|
11
11
|
* registry entry is the ONLY source of `branch` — the lock lacks that field).
|
|
12
12
|
*
|
|
13
|
+
* Telemetry: every {@link mirrorBoard} call — and therefore every
|
|
14
|
+
* {@link sweepBoard} call — emits exactly ONE {@link BOARD_EVENT} record,
|
|
15
|
+
* including the no-op paths (`skipped-vault-disabled`, `skipped-handwritten`,
|
|
16
|
+
* `skipped-noop`, `skipped-write-failed`). Those are the states that previously
|
|
17
|
+
* looked identical to a healthy write from outside the process. Emission is
|
|
18
|
+
* best-effort and can never fail a board write.
|
|
19
|
+
*
|
|
13
20
|
* Exports:
|
|
14
21
|
* GENERATOR_MARKER — frontmatter sentinel that identifies generator-owned files
|
|
22
|
+
* BOARD_EVENT — canonical event name for a board-write attempt
|
|
15
23
|
* boardKey — repoRoot → stable path-derived row identity (issue #871)
|
|
16
24
|
* resolveBoardPath — vaultDir → `<vaultDir>/01-projects/_active-sessions.md`
|
|
17
25
|
* collectRows — per-repo status derivation (readLock + readRegistry)
|
|
@@ -42,6 +50,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
|
42
50
|
import os from 'node:os';
|
|
43
51
|
import path from 'node:path';
|
|
44
52
|
|
|
53
|
+
import { emitEvent, sessionAttribution } from '../events.mjs';
|
|
45
54
|
import { isLockLive, readLock, DEFAULT_TTL_HOURS } from '../session-lock.mjs';
|
|
46
55
|
import { readRegistry, repoPathHash, isRegistryEntryFresh } from '../session-registry.mjs';
|
|
47
56
|
import { parseFrontmatter } from '../vault-mirror/utils.mjs';
|
|
@@ -49,6 +58,8 @@ import { readConfigFile, parseSessionConfig } from '../config.mjs';
|
|
|
49
58
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
50
59
|
import { enumerateCandidates } from '../dispatcher/enumerate.mjs';
|
|
51
60
|
import { atomicWriteWithBackup } from '../io.mjs';
|
|
61
|
+
import { withBoardLock } from './board-lock.mjs';
|
|
62
|
+
import { expandTilde } from '../common.mjs';
|
|
52
63
|
|
|
53
64
|
/** Frontmatter sentinel that identifies generator-owned board files. */
|
|
54
65
|
export const GENERATOR_MARKER = 'session-orchestrator-active-sessions@1';
|
|
@@ -171,22 +182,6 @@ const nameSlot = (repo) => `n:${foldKey(repo)}`;
|
|
|
171
182
|
|
|
172
183
|
// ── Path helpers ────────────────────────────────────────────────────────────────
|
|
173
184
|
|
|
174
|
-
/**
|
|
175
|
-
* Expand a leading `~` to the current user's home directory. Inlined here on
|
|
176
|
-
* purpose — the shared helper is private elsewhere, and a shared
|
|
177
|
-
* `vault-write-guard.mjs` extraction is deferred to a later epic (W2 forbids a
|
|
178
|
-
* new shared file in this slice).
|
|
179
|
-
*
|
|
180
|
-
* @param {string} p
|
|
181
|
-
* @returns {string}
|
|
182
|
-
*/
|
|
183
|
-
function expandHome(p) {
|
|
184
|
-
if (typeof p !== 'string' || p.length === 0) return p;
|
|
185
|
-
if (p === '~') return os.homedir();
|
|
186
|
-
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
187
|
-
return p;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
185
|
/**
|
|
191
186
|
* Resolve the board file path from a vault directory.
|
|
192
187
|
*
|
|
@@ -194,7 +189,7 @@ function expandHome(p) {
|
|
|
194
189
|
* @returns {string} `<vaultDir>/01-projects/_active-sessions.md`
|
|
195
190
|
*/
|
|
196
191
|
export function resolveBoardPath(vaultDir) {
|
|
197
|
-
return path.join(
|
|
192
|
+
return path.join(expandTilde(vaultDir), '01-projects', '_active-sessions.md');
|
|
198
193
|
}
|
|
199
194
|
|
|
200
195
|
// ── Formatting helpers ───────────────────────────────────────────────────────────
|
|
@@ -681,6 +676,141 @@ export function writeBoard(opts) {
|
|
|
681
676
|
return { action: 'written', path: outputPath };
|
|
682
677
|
}
|
|
683
678
|
|
|
679
|
+
// ── Telemetry ────────────────────────────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Canonical event name for a board-write attempt.
|
|
683
|
+
*
|
|
684
|
+
* ONE event per {@link mirrorBoard} call — including every no-op path. The
|
|
685
|
+
* no-op paths are the point: a vault-disabled config, a hand-edited board, or a
|
|
686
|
+
* failed write returned silently before this existed, so an outage of this
|
|
687
|
+
* writer was indistinguishable from a healthy skip. Measured 2026-08-23 over
|
|
688
|
+
* 28 387 ledger records: ZERO board/mirror events, because this module did not
|
|
689
|
+
* import {@link emitEvent} at all.
|
|
690
|
+
*/
|
|
691
|
+
export const BOARD_EVENT = 'orchestrator.vault.board_written';
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Emit the board-write telemetry record. Best-effort: never throws, never
|
|
695
|
+
* alters the board result.
|
|
696
|
+
*
|
|
697
|
+
* ABSENT IS NOT ZERO (`docs/events-schema.md`): every optional field is spread
|
|
698
|
+
* conditionally, so an UNMEASURED field is missing from the record rather than
|
|
699
|
+
* written as `0`. A present `repos_swept: 0` therefore means "enumeration ran
|
|
700
|
+
* and surfaced nothing" (the documented silent-enumeration failure mode), while
|
|
701
|
+
* an absent `repos_swept` means "not a sweep, or enumeration threw" — reading
|
|
702
|
+
* the missing key as `0` would conflate the two in both directions.
|
|
703
|
+
*
|
|
704
|
+
* @param {object} opts
|
|
705
|
+
* @param {string} [opts.repoRoot] — pins the ledger to THIS repo's
|
|
706
|
+
* `.orchestrator/metrics/events.jsonl` (#941). Omitted only when the caller
|
|
707
|
+
* supplied no usable root, where `SO_PROJECT_DIR` is the sole destination left.
|
|
708
|
+
* @param {'sweepBoard'|'mirrorBoard'} opts.caller — which entry point ran.
|
|
709
|
+
* @param {string} opts.action — the `action` the board write returned.
|
|
710
|
+
* @param {string} [opts.path] — resolved board path, when one was resolved.
|
|
711
|
+
* @param {number} [opts.rows] — rows in the board content THIS call rendered.
|
|
712
|
+
* Present whenever the render was reached (so also on `skipped-noop` /
|
|
713
|
+
* `dry-run`, where the content was built but not written — `action` is what
|
|
714
|
+
* says whether it landed); absent on the early no-op paths that never render.
|
|
715
|
+
* @param {number} [opts.reposSwept] — candidates {@link enumerateCandidates}
|
|
716
|
+
* returned, on the {@link sweepBoard} path only.
|
|
717
|
+
* @param {number} [opts.durationMs]
|
|
718
|
+
* @param {{ locked: boolean, reason?: string, stale_override?: string, waited_ms: number }} [opts.lock]
|
|
719
|
+
* — board-lock outcome, present only when the lock was actually attempted
|
|
720
|
+
* (i.e. not on the early no-op guards, not on dry-run). Never carries the lock
|
|
721
|
+
* PATH — that is a `$HOME`-rooted string, the CP1 shape this payload keeps out.
|
|
722
|
+
* @returns {Promise<void>}
|
|
723
|
+
*/
|
|
724
|
+
/**
|
|
725
|
+
* Reduce an absolute vault path to its LAST TWO segments for telemetry.
|
|
726
|
+
*
|
|
727
|
+
* The full path is the module's public return contract and stays untouched.
|
|
728
|
+
* What must not travel is the path in the EMITTED payload: on a real host it
|
|
729
|
+
* reads `/Users/<name>/Projects/<vault>/01-projects/<private-slug>/…`, i.e. an
|
|
730
|
+
* OS username plus a private project slug. Those are exactly the two shapes
|
|
731
|
+
* `scripts/lib/validate/check-owner-leakage.mjs` blocks as CP1 and CP6 — and
|
|
732
|
+
* that scanner structurally cannot see this one, because it walks `git ls-files`
|
|
733
|
+
* and `.orchestrator/metrics/*.jsonl` is gitignored (`.gitignore:40`).
|
|
734
|
+
* The record is invisible to the pre-commit guard and visible to the optional
|
|
735
|
+
* Clank webhook (`scripts/lib/events.mjs`, `CLANK_EVENT_URL`), which posts the
|
|
736
|
+
* payload verbatim with no redaction.
|
|
737
|
+
*
|
|
738
|
+
* The BASENAME is the deliberate ceiling — one segment, not two. Two segments
|
|
739
|
+
* would keep the parent directory, and under `01-projects/` that directory IS
|
|
740
|
+
* the private project slug, i.e. exactly the CP6 shape this is meant to drop.
|
|
741
|
+
* The diagnostic value lives in the filename alone: it says WHICH writer ran
|
|
742
|
+
* (`_session-narrative.md` vs `_active-sessions.md`), which is the question the
|
|
743
|
+
* event exists to answer. Which project it was is already answerable from the
|
|
744
|
+
* record's own `session_id` / repo-scoped ledger location.
|
|
745
|
+
* Revisit trigger: a consumer that needs more than the filename — then it
|
|
746
|
+
* belongs in the RETURN value, which already carries the absolute path, never
|
|
747
|
+
* in the event.
|
|
748
|
+
*
|
|
749
|
+
* @param {unknown} outputPath
|
|
750
|
+
* @returns {string|undefined} `undefined` when there is nothing measured to report.
|
|
751
|
+
*/
|
|
752
|
+
function telemetrySafePath(outputPath) {
|
|
753
|
+
if (typeof outputPath !== 'string' || outputPath.length === 0) return undefined;
|
|
754
|
+
const base = path.basename(outputPath);
|
|
755
|
+
return base.length > 0 ? base : undefined;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs, lock }) {
|
|
759
|
+
// Refuse the SO_PROJECT_DIR fallback instead of guessing a destination.
|
|
760
|
+
// Without an explicit repoRoot, `emitEvent` resolves `eventsFilePath(undefined)`
|
|
761
|
+
// and writes into whatever tree the ambient env points at — so `mirrorBoard()`
|
|
762
|
+
// called with no argument used to append a record to an UNRELATED repo's ledger.
|
|
763
|
+
// Measured 2026-08-23: a review agent reproduced it and put a second, byte-identical
|
|
764
|
+
// record into this repo's live events.jsonl doing so. Two sibling emitters in the
|
|
765
|
+
// same commit arc already refuse it (`express-path.mjs` with a WARN,
|
|
766
|
+
// `narrative-mirror.mjs` silently); this one was the odd one out, and it was the
|
|
767
|
+
// unsafe one. A stderr WARN, not silence: a telemetry record that goes missing
|
|
768
|
+
// should say so, or it becomes the very blind spot this event was added to close.
|
|
769
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
770
|
+
process.stderr.write(
|
|
771
|
+
`[board-writer] ${BOARD_EVENT} not emitted: no repoRoot given, and the ambient ` +
|
|
772
|
+
`SO_PROJECT_DIR fallback would write to an unrelated repo's ledger.\n`,
|
|
773
|
+
);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
await emitEvent(
|
|
778
|
+
BOARD_EVENT,
|
|
779
|
+
{
|
|
780
|
+
action,
|
|
781
|
+
caller,
|
|
782
|
+
...(telemetrySafePath(outputPath) !== undefined ? { path_tail: telemetrySafePath(outputPath) } : {}),
|
|
783
|
+
// Number.isFinite — NOT truthiness — is what keeps a MEASURED zero in
|
|
784
|
+
// the record (`repos_swept: 0` = "enumeration ran, found nothing") while
|
|
785
|
+
// still omitting an unmeasured field. `x || undefined` would silently
|
|
786
|
+
// delete exactly the zero the field exists to report. (`!= null` is the
|
|
787
|
+
// idiom elsewhere but this repo's eqeqeq rule forbids it.)
|
|
788
|
+
...(Number.isFinite(rows) ? { rows } : {}),
|
|
789
|
+
...(Number.isFinite(reposSwept) ? { repos_swept: reposSwept } : {}),
|
|
790
|
+
...(Number.isFinite(durationMs) ? { duration_ms: durationMs } : {}),
|
|
791
|
+
// Lock diagnostics (absent on every path that never took the lock: the
|
|
792
|
+
// early no-op guards and dry-run). `lock.locked === false` marks a
|
|
793
|
+
// fail-open unlocked write; `lock.stale_override` marks an acquire that
|
|
794
|
+
// aged out someone else's lock — the observable behind board-lock's
|
|
795
|
+
// DEFAULT_STALE_MS revisit trigger.
|
|
796
|
+
...(lock && typeof lock === 'object' ? { lock } : {}),
|
|
797
|
+
// #1147: join key parity with the sibling `narrative_mirrored` event,
|
|
798
|
+
// which has carried attribution since #1073. Without it a board record
|
|
799
|
+
// cannot be joined to the session that wrote it. Both keys are OMITTED
|
|
800
|
+
// (never fabricated) when no session.lock is readable at `repoRoot` —
|
|
801
|
+
// and `repoRoot` is the SAME root the ledger line is pinned to below,
|
|
802
|
+
// so the attribution can never name a different tree than the record.
|
|
803
|
+
...sessionAttribution(repoRoot),
|
|
804
|
+
},
|
|
805
|
+
{ repoRoot },
|
|
806
|
+
);
|
|
807
|
+
} catch {
|
|
808
|
+
/* Best-effort telemetry. emitEvent does real file I/O (mkdir + append), so a
|
|
809
|
+
read-only or occupied ledger path WILL throw — and a broken ledger must
|
|
810
|
+
never fail a board write. The board result is authoritative. */
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
684
814
|
// ── Convenience: config-read + resolve + write ───────────────────────────────────
|
|
685
815
|
|
|
686
816
|
/**
|
|
@@ -717,11 +847,17 @@ export function writeBoard(opts) {
|
|
|
717
847
|
* `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the fixture value
|
|
718
848
|
* and bleeds into the assertion (issue #783). Production callers omit this — the
|
|
719
849
|
* default (real owner.yaml resolution) is the correct host-local behavior there.
|
|
720
|
-
* @returns {Promise<{ action: string, path?: string }
|
|
850
|
+
* @returns {Promise<{ result: { action: string, path?: string }, rows?: number,
|
|
851
|
+
* lock?: { locked: boolean, reason?: string, stale_override?: string, waited_ms: number } }>}
|
|
852
|
+
* `lock` is present only on the locked path (absent on the early no-op guards
|
|
853
|
+
* and on dry-run, which deliberately takes no lock).
|
|
854
|
+
* `rows` is present only once the render was reached — see
|
|
855
|
+
* {@link emitBoardEvent}'s `rows` contract. The public {@link mirrorBoard}
|
|
856
|
+
* wrapper unwraps `result` so the caller-visible return shape is unchanged.
|
|
721
857
|
*/
|
|
722
|
-
|
|
858
|
+
async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Date(), dryRun = false, fs, hostPaths } = {}) {
|
|
723
859
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
724
|
-
return { action: 'skipped-vault-disabled' };
|
|
860
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
725
861
|
}
|
|
726
862
|
|
|
727
863
|
// Read + parse Session Config. Any failure → silent no-op.
|
|
@@ -730,24 +866,24 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
|
|
|
730
866
|
const text = await readConfigFile(repoRoot);
|
|
731
867
|
config = parseSessionConfig(text, { hostPaths });
|
|
732
868
|
} catch {
|
|
733
|
-
return { action: 'skipped-vault-disabled' };
|
|
869
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
734
870
|
}
|
|
735
871
|
|
|
736
872
|
const vault = config?.['vault-integration'];
|
|
737
873
|
if (!vault || vault.enabled !== true) {
|
|
738
|
-
return { action: 'skipped-vault-disabled' };
|
|
874
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
739
875
|
}
|
|
740
876
|
const vaultDir = vault['vault-dir'];
|
|
741
877
|
if (typeof vaultDir !== 'string' || vaultDir.length === 0) {
|
|
742
|
-
return { action: 'skipped-vault-disabled' };
|
|
878
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
743
879
|
}
|
|
744
880
|
|
|
745
881
|
// Safety: the resolved vault dir must live under $HOME.
|
|
746
|
-
const expandedVault =
|
|
882
|
+
const expandedVault = expandTilde(vaultDir);
|
|
747
883
|
const home = os.homedir();
|
|
748
884
|
const inHome = validatePathInsideProject(expandedVault, home);
|
|
749
885
|
if (!inHome.ok) {
|
|
750
|
-
return { action: 'skipped-vault-disabled' };
|
|
886
|
+
return { result: { action: 'skipped-vault-disabled' } };
|
|
751
887
|
}
|
|
752
888
|
|
|
753
889
|
// `vault-name` (#660) overrides the git-derived repo slug for per-project
|
|
@@ -797,132 +933,220 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
|
|
|
797
933
|
|
|
798
934
|
const outputPath = resolveBoardPath(vaultDir);
|
|
799
935
|
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if (
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
//
|
|
839
|
-
// the
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
|
|
936
|
+
// Everything below — the two reads of the existing board, the merge, and the
|
|
937
|
+
// write — is ONE read-modify-write over a file shared by every repo on the
|
|
938
|
+
// host (issue #1180). Serialise it on the vault-scoped board lock so a
|
|
939
|
+
// concurrent sweepBoard() from another repo cannot compute its merge from a
|
|
940
|
+
// base we are about to replace. Fail-open: withBoardLock runs the closure
|
|
941
|
+
// unlocked (with a WARN) rather than let a contended lock abort the phase.
|
|
942
|
+
const mergeAndWrite = async () => {
|
|
943
|
+
// Read the EXISTING generator-owned board (if any) to:
|
|
944
|
+
// 1. preserve its `created:` — otherwise every render differs on `created:`
|
|
945
|
+
// and the noop-skip in writeBoard would never fire.
|
|
946
|
+
// 2. recover the prior per-repo status — drives the `closed` derivation for
|
|
947
|
+
// repos NOT in this update (idempotent merge: their rows are re-derived).
|
|
948
|
+
// Both maps are keyed by {@link foldKey}(repo) — case-insensitively folded
|
|
949
|
+
// (issue #719) — so two prior rows differing only by case (e.g.
|
|
950
|
+
// `some-repo` vs `Some-Repo`, the same physical directory on a
|
|
951
|
+
// case-insensitive-preserving filesystem like APFS) collapse to ONE entry
|
|
952
|
+
// instead of coexisting as duplicates. The row OBJECTS keep their original
|
|
953
|
+
// `repo` string untouched, so `renderBoard` still displays true casing.
|
|
954
|
+
const fsReadFile = fs?.readFileSync ?? readFileSync;
|
|
955
|
+
const fsExists = fs?.existsSync ?? existsSync;
|
|
956
|
+
let createdIso;
|
|
957
|
+
const priorStatusByRepo = new Map(); // LEGACY rows only — see collectRows contract
|
|
958
|
+
const priorStatusByKey = new Map(); // boardKey → status (authoritative since #871)
|
|
959
|
+
const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) → prior row
|
|
960
|
+
if (fsExists(outputPath)) {
|
|
961
|
+
let existing;
|
|
962
|
+
try {
|
|
963
|
+
existing = fsReadFile(outputPath, 'utf8');
|
|
964
|
+
} catch {
|
|
965
|
+
existing = null;
|
|
966
|
+
}
|
|
967
|
+
if (existing) {
|
|
968
|
+
const fm = parseFrontmatter(existing);
|
|
969
|
+
if (fm && fm['_generator'] === GENERATOR_MARKER) {
|
|
970
|
+
if (fm['created']) createdIso = fm['created'];
|
|
971
|
+
for (const prior of parseBoardRows(existing)) {
|
|
972
|
+
// Dual-key slotting (#871): a keyed row owns its own hash slot; a
|
|
973
|
+
// legacy (6-column) row falls back to its folded display name. Two
|
|
974
|
+
// keyed rows can only collide when they resolve to the SAME path, so
|
|
975
|
+
// the heartbeat-preference resolution below is now reached almost
|
|
976
|
+
// exclusively by legacy rows — which is precisely the case it was
|
|
977
|
+
// written for (#719).
|
|
978
|
+
const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
|
|
979
|
+
const collidingPrior = preservedRows.get(key);
|
|
980
|
+
if (collidingPrior) {
|
|
981
|
+
// Collision WITHIN parseBoardRows output — two prior rows fold to
|
|
982
|
+
// the same key with no fresh row in play yet (that upsert happens
|
|
983
|
+
// below). Prefer the row with the most-recent `heartbeat` rather
|
|
984
|
+
// than silently last-in-file-order. Guard: if either heartbeat is
|
|
985
|
+
// unparsable, fall through to last-written-wins (the pre-#719
|
|
986
|
+
// default) by NOT skipping the overwrite below.
|
|
987
|
+
const collidingTs = Date.parse(collidingPrior.heartbeat ?? '');
|
|
988
|
+
const priorTs = Date.parse(prior.heartbeat ?? '');
|
|
989
|
+
if (Number.isFinite(collidingTs) && Number.isFinite(priorTs) && collidingTs > priorTs) {
|
|
990
|
+
// The already-preserved row is strictly newer — keep it, skip
|
|
991
|
+
// this older colliding row entirely.
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
850
994
|
}
|
|
995
|
+
if (prior.key) {
|
|
996
|
+
priorStatusByKey.set(prior.key, prior.status);
|
|
997
|
+
} else {
|
|
998
|
+
// LEGACY rows only. Seeding this map from keyed rows too would let
|
|
999
|
+
// repo B (never seen, same basename) inherit repo A's terminal
|
|
1000
|
+
// status through the name fallback in collectRows — reintroducing
|
|
1001
|
+
// the identity collision #871 exists to remove, one layer down.
|
|
1002
|
+
priorStatusByRepo.set(foldKey(prior.repo), prior.status);
|
|
1003
|
+
}
|
|
1004
|
+
preservedRows.set(key, prior);
|
|
851
1005
|
}
|
|
852
|
-
if (prior.key) {
|
|
853
|
-
priorStatusByKey.set(prior.key, prior.status);
|
|
854
|
-
} else {
|
|
855
|
-
// LEGACY rows only. Seeding this map from keyed rows too would let
|
|
856
|
-
// repo B (never seen, same basename) inherit repo A's terminal
|
|
857
|
-
// status through the name fallback in collectRows — reintroducing
|
|
858
|
-
// the identity collision #871 exists to remove, one layer down.
|
|
859
|
-
priorStatusByRepo.set(foldKey(prior.repo), prior.status);
|
|
860
|
-
}
|
|
861
|
-
preservedRows.set(key, prior);
|
|
862
1006
|
}
|
|
863
1007
|
}
|
|
864
1008
|
}
|
|
865
|
-
}
|
|
866
1009
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1010
|
+
const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
|
|
1011
|
+
|
|
1012
|
+
// TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
|
|
1013
|
+
// Without this pass, a preserved `in-progress` row (a repo NOT in this
|
|
1014
|
+
// update) is copied forward FOREVER — a crashed/never-closed session's row
|
|
1015
|
+
// never flips even after its heartbeat has aged well past the lock's TTL,
|
|
1016
|
+
// because `collectRows` only re-derives status for repos actually IN
|
|
1017
|
+
// `repoList`. Re-derive staleness for every preserved row here, BEFORE the
|
|
1018
|
+
// freshly-derived `rows` are upserted over it below (fresh data always
|
|
1019
|
+
// wins regardless of this pass — a live lock or an explicit-closed update
|
|
1020
|
+
// always takes precedence over the TTL flip).
|
|
1021
|
+
//
|
|
1022
|
+
// Board rows carry only a raw `heartbeat` string, never the lock's own
|
|
1023
|
+
// `ttl_hours` (that field is not part of the rendered board) — so this
|
|
1024
|
+
// reuses the shared {@link DEFAULT_TTL_HOURS} constant rather than the
|
|
1025
|
+
// per-lock TTL {@link isLockLive} uses when a live lock object is in hand.
|
|
1026
|
+
// Rows with an unparseable/absent heartbeat are left UNCHANGED (fail-open,
|
|
1027
|
+
// never crash on a malformed prior board).
|
|
1028
|
+
const nowMs = now instanceof Date ? now.getTime() : Date.now();
|
|
1029
|
+
const ttlMs = DEFAULT_TTL_HOURS * 3600 * 1000;
|
|
1030
|
+
const staleRederivedRows = new Map();
|
|
1031
|
+
for (const [key, row] of preservedRows) {
|
|
1032
|
+
if (row.status === STATUS_IN_PROGRESS) {
|
|
1033
|
+
const heartbeatMs = Date.parse(row.heartbeat ?? '');
|
|
1034
|
+
if (Number.isFinite(heartbeatMs) && (nowMs - heartbeatMs) >= ttlMs) {
|
|
1035
|
+
staleRederivedRows.set(key, { ...row, status: STATUS_FORCE_CLOSED });
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
894
1038
|
}
|
|
1039
|
+
staleRederivedRows.set(key, row);
|
|
895
1040
|
}
|
|
896
|
-
staleRederivedRows.set(key, row);
|
|
897
|
-
}
|
|
898
1041
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1042
|
+
// Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
|
|
1043
|
+
// update, then upsert the freshly-derived rows over them so repeated writes
|
|
1044
|
+
// stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
|
|
1045
|
+
// same slot — that is what collapses a live row over a stale preserved one.
|
|
1046
|
+
//
|
|
1047
|
+
// Dual-key upsert (#871). A naive switch from the folded name to the path
|
|
1048
|
+
// key would make the two key spaces DISJOINT: the fresh row would never
|
|
1049
|
+
// overwrite the legacy row, the legacy row would become immortal (the sweep
|
|
1050
|
+
// skips `frei` candidates and the TTL pass only rewrites `status`, never
|
|
1051
|
+
// removes a row), and the board would grow a permanent duplicate per repo.
|
|
1052
|
+
// So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
|
|
1053
|
+
// the legacy name slot for the same folded name — one board write converts
|
|
1054
|
+
// the row, and the migration is complete for that repo.
|
|
1055
|
+
const merged = new Map(staleRederivedRows);
|
|
1056
|
+
for (const row of rows) {
|
|
1057
|
+
const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
|
|
1058
|
+
if (row.key && !merged.has(slot)) {
|
|
1059
|
+
// First keyed write for this repo — take over its legacy row rather than
|
|
1060
|
+
// rendering a second one beside it.
|
|
1061
|
+
merged.delete(nameSlot(row.repo));
|
|
1062
|
+
}
|
|
1063
|
+
merged.set(slot, row);
|
|
919
1064
|
}
|
|
920
|
-
merged.set(slot, row);
|
|
921
|
-
}
|
|
922
1065
|
|
|
923
|
-
|
|
1066
|
+
const content = renderBoard([...merged.values()], { now, createdIso });
|
|
1067
|
+
|
|
1068
|
+
return { result: writeBoard({ outputPath, content, dryRun, fs }), rows: merged.size };
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// dry-run never touches disk (writeBoard guard 1) — so it must not create a
|
|
1072
|
+
// lock file in the operator's vault either. Nothing to serialise.
|
|
1073
|
+
if (dryRun) return await mergeAndWrite();
|
|
1074
|
+
|
|
1075
|
+
// The lock outcome is diagnostic, and until now it went nowhere: `withBoardLock`
|
|
1076
|
+
// has exposed `onLockOutcome` since #1180, and NO production caller passed one
|
|
1077
|
+
// (measured 2026-09-02: `grep -rn onLockOutcome scripts hooks` → board-lock.mjs
|
|
1078
|
+
// and its test, nothing else). So the two states that silently weaken the mutex —
|
|
1079
|
+
// a fail-open unlocked write, and a stale-override that can override a LIVE
|
|
1080
|
+
// writer (see board-lock's DEFAULT_STALE_MS § CEILING) — were unobservable in
|
|
1081
|
+
// aggregate, which is exactly what the revisit trigger needs. Capture it here and
|
|
1082
|
+
// ride it out on the ONE board_written event rather than adding a second event.
|
|
1083
|
+
let lockOutcome;
|
|
1084
|
+
const acquireStartedAt = Date.now();
|
|
1085
|
+
const inner = await withBoardLock(expandedVault, mergeAndWrite, {
|
|
1086
|
+
onLockOutcome: (o) => {
|
|
1087
|
+
// Called exactly once, strictly BEFORE `fn` — so the elapsed time is the
|
|
1088
|
+
// acquire wait, not the merge. `lockPath` is deliberately DROPPED: it is
|
|
1089
|
+
// `<vault>/.orchestrator/board.lock` under $HOME, i.e. the CP1 (OS username)
|
|
1090
|
+
// shape `telemetrySafePath` exists to keep out of the payload.
|
|
1091
|
+
lockOutcome = {
|
|
1092
|
+
locked: o?.locked === true,
|
|
1093
|
+
...(typeof o?.reason === 'string' ? { reason: o.reason } : {}),
|
|
1094
|
+
...(typeof o?.staleOverride === 'string' ? { stale_override: o.staleOverride } : {}),
|
|
1095
|
+
waited_ms: Date.now() - acquireStartedAt,
|
|
1096
|
+
};
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
924
1099
|
|
|
925
|
-
return
|
|
1100
|
+
return lockOutcome === undefined ? inner : { ...inner, lock: lockOutcome };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Public {@link mirrorBoardInner} wrapper that emits exactly ONE
|
|
1105
|
+
* {@link BOARD_EVENT} per call — on EVERY path, no-ops included.
|
|
1106
|
+
*
|
|
1107
|
+
* The wrapper exists so the emit cannot be forgotten: the six return points
|
|
1108
|
+
* inside {@link mirrorBoardInner} (five `skipped-vault-disabled` guards plus
|
|
1109
|
+
* whatever {@link writeBoard} decides) all funnel through here, and so does the
|
|
1110
|
+
* seventh someone adds next. Emitting per-return instead would leave each new
|
|
1111
|
+
* early return silent by default — which is the exact defect being fixed.
|
|
1112
|
+
*
|
|
1113
|
+
* A THROW from the inner function is deliberately NOT converted into an event:
|
|
1114
|
+
* `action` is mandatory in the payload and a throw has no action the code knows,
|
|
1115
|
+
* so inventing one would put a fictional state in the ledger. The throw is not
|
|
1116
|
+
* silent either — it propagates to the caller, and on the {@link sweepBoard}
|
|
1117
|
+
* path the fallback write emits its own record.
|
|
1118
|
+
*
|
|
1119
|
+
* Return shape is byte-identical to the pre-telemetry contract.
|
|
1120
|
+
*
|
|
1121
|
+
* @param {Parameters<typeof mirrorBoardInner>[0] & {
|
|
1122
|
+
* caller?: 'sweepBoard'|'mirrorBoard', reposSwept?: number }} [opts]
|
|
1123
|
+
* — full parameter contract (`repoRoot`, `repos`, `explicitStatus`, `now`,
|
|
1124
|
+
* `dryRun`, `fs`, `hostPaths`) is documented on {@link mirrorBoardInner}.
|
|
1125
|
+
* `caller` / `reposSwept` are telemetry attribution only and never reach the
|
|
1126
|
+
* board content. `caller` defaults to `'mirrorBoard'`; {@link sweepBoard}
|
|
1127
|
+
* overrides it so the two entry points stay separable in the ledger.
|
|
1128
|
+
* @returns {Promise<{ action: string, path?: string }>}
|
|
1129
|
+
*/
|
|
1130
|
+
export async function mirrorBoard(opts = {}) {
|
|
1131
|
+
const startedAt = Date.now();
|
|
1132
|
+
// Destructured directly (not via `opts ?? {}`) so a `null` argument still
|
|
1133
|
+
// throws exactly as it did before this wrapper existed.
|
|
1134
|
+
const { repoRoot, caller = 'mirrorBoard', reposSwept } = opts;
|
|
1135
|
+
|
|
1136
|
+
const { result, rows, lock } = await mirrorBoardInner(opts);
|
|
1137
|
+
|
|
1138
|
+
await emitBoardEvent({
|
|
1139
|
+
repoRoot,
|
|
1140
|
+
caller,
|
|
1141
|
+
action: result?.action,
|
|
1142
|
+
path: result?.path,
|
|
1143
|
+
rows,
|
|
1144
|
+
reposSwept,
|
|
1145
|
+
durationMs: Date.now() - startedAt,
|
|
1146
|
+
lock,
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
return result;
|
|
926
1150
|
}
|
|
927
1151
|
|
|
928
1152
|
// ── Host-wide sweep (issue #716) ─────────────────────────────────────────────────
|
|
@@ -1022,6 +1246,12 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
|
|
|
1022
1246
|
* write still happens. `mirrorBoard`'s own internal guards (vault disabled,
|
|
1023
1247
|
* `_overview.md` refusal, noop-skip, …) are untouched and still apply.
|
|
1024
1248
|
*
|
|
1249
|
+
* Telemetry: BOTH paths emit one {@link BOARD_EVENT} with `caller: 'sweepBoard'`
|
|
1250
|
+
* (the wrapping {@link mirrorBoard} call does the emitting, so a sweep never
|
|
1251
|
+
* produces two records). The happy path carries `repos_swept`; the fallback
|
|
1252
|
+
* omits it, which is what distinguishes "enumeration ran and found nothing"
|
|
1253
|
+
* (`repos_swept: 0`) from "enumeration threw" (key absent).
|
|
1254
|
+
*
|
|
1025
1255
|
* @param {object} [opts]
|
|
1026
1256
|
* @param {string} opts.repoRoot — the calling repo (always included in the sweep).
|
|
1027
1257
|
* @param {string} [opts.startDir] — enumeration root; omitted in production so
|
|
@@ -1061,11 +1291,21 @@ export async function sweepBoard({ repoRoot, startDir, now = new Date(), dryRun
|
|
|
1061
1291
|
try {
|
|
1062
1292
|
const candidates = await enumerateCandidates({ startDir, now: nowMs, deps });
|
|
1063
1293
|
const repos = buildSweepRepos(candidates, { thisRepoRoot: repoRoot });
|
|
1064
|
-
|
|
1294
|
+
// Telemetry counts what ENUMERATION surfaced, not what we sweep: `repos`
|
|
1295
|
+
// always contains at least `thisRepoRoot` (buildSweepRepos unions it in),
|
|
1296
|
+
// so a silently-empty enumeration — the documented macOS realpath-hop
|
|
1297
|
+
// failure, 0 candidates and no error — would be invisible in `repos.length`
|
|
1298
|
+
// and plainly visible as `repos_swept: 0`. Guarded with Array.isArray so a
|
|
1299
|
+
// non-array return keeps flowing into buildSweepRepos exactly as before
|
|
1300
|
+
// rather than throwing us into the fallback branch below.
|
|
1301
|
+
const reposSwept = Array.isArray(candidates) ? candidates.length : undefined;
|
|
1302
|
+
return await mirrorBoard({ repoRoot, repos, now: nowForMirror, dryRun, fs, hostPaths, caller: 'sweepBoard', reposSwept });
|
|
1065
1303
|
} catch (err) {
|
|
1066
1304
|
console.warn('[sweepBoard] host-wide enumeration failed — degraded to single-repo board write:', err?.message ?? err);
|
|
1067
1305
|
// Best-effort fallback: enumeration failed for any reason — degrade to the
|
|
1068
1306
|
// pre-#716 single-repo write so the board is still updated for THIS repo.
|
|
1069
|
-
|
|
1307
|
+
// `reposSwept` is deliberately NOT passed: nothing was enumerated, so the
|
|
1308
|
+
// field is omitted rather than reported as 0 (absent is not zero).
|
|
1309
|
+
return mirrorBoard({ repoRoot, explicitStatus: 'in-progress', now: nowForMirror, dryRun, fs, hostPaths, caller: 'sweepBoard' });
|
|
1070
1310
|
}
|
|
1071
1311
|
}
|