session-orchestrator 5.12.0 → 5.13.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 +2 -2
- package/.claude-plugin/plugin.json +4 -1
- package/.codex-plugin/agents/explorer.toml +1 -1
- package/.codex-plugin/agents/session-reviewer.toml +2 -2
- package/.codex-plugin/agents/wave-worker.toml +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +8 -0
- package/.orchestrator/policy/public-export.json +30 -0
- package/CHANGELOG.md +162 -4
- package/README.md +126 -127
- package/agents/analyst.md +7 -2
- package/agents/architect-reviewer.md +7 -2
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +9 -2
- package/agents/dialectic-deriver.md +9 -3
- package/agents/docs-writer.md +7 -2
- package/agents/eval-judge.md +7 -1
- package/agents/qa-strategist.md +7 -2
- package/agents/security-reviewer.md +9 -2
- package/agents/session-reviewer.md +18 -2
- package/agents/skill-applied-judge.md +7 -1
- package/agents/test-writer.md +9 -2
- package/agents/ui-developer.md +9 -2
- package/agents/ux-evaluator.md +6 -1
- package/commands/session.md +1 -1
- package/commands/templates-ack.md +89 -18
- package/docs/agent-authoring.md +30 -11
- package/docs/ci-setup.md +38 -2
- package/docs/codex-setup.md +33 -9
- package/docs/components.md +13 -2
- package/docs/cursor-setup.md +3 -3
- package/docs/events-schema.md +5 -4
- package/docs/evidence-candidates.md +63 -0
- package/docs/fleet-checkin-audit.md +7 -4
- package/docs/github-mirror-protection.md +150 -3
- package/docs/install.md +90 -17
- package/docs/instruction-delivery.md +1 -1
- package/docs/migration-v4.md +2 -2
- package/docs/mods.md +118 -0
- package/docs/owner-config-schema.md +1 -0
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/pm-skills-marketplace.md +4 -3
- package/docs/rule-authoring.md +24 -13
- package/docs/scope-collision-guard.md +2 -1
- package/docs/session-config-reference.md +49 -15
- package/docs/session-config-template.md +30 -22
- package/docs/sessions-ledger-repair.md +137 -0
- package/docs/vault-docs-architecture.md +1 -1
- package/hooks/_lib/hook-import-set.json +406 -73
- package/hooks/_lib/hook-root.mjs +301 -0
- package/hooks/_lib/lock-bootstrap.mjs +434 -4
- package/hooks/_lib/orchestrator-ignore.mjs +3 -0
- package/hooks/_lib/subagent-transcript.mjs +2 -2
- package/hooks/_lib/vcs-create-matcher.mjs +13 -17
- package/hooks/cwd-change-restore.mjs +5 -1
- package/hooks/enforce-commands.mjs +4 -3
- package/hooks/enforce-scope.mjs +11 -1
- package/hooks/hooks-codex.json +48 -2
- package/hooks/hooks.json +31 -31
- package/hooks/on-session-end.mjs +38 -15
- package/hooks/on-session-start.mjs +139 -49
- package/hooks/on-stop.mjs +11 -6
- package/hooks/post-bash-issue-budget-refund.mjs +6 -1
- package/hooks/post-bash-write-verify.mjs +8 -4
- package/hooks/post-edit-validate.mjs +45 -52
- package/hooks/post-subagent-discovery-validator.mjs +2 -2
- package/hooks/post-tool-batch-wave-signal.mjs +8 -4
- package/hooks/post-tool-failure-corrective-context.mjs +5 -1
- package/hooks/pre-bash-destructive-guard.mjs +220 -8
- package/hooks/pre-bash-issue-budget.mjs +6 -1
- package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
- package/hooks/pre-bash-staging-fence.mjs +258 -14
- package/hooks/pre-bash-templates-first.mjs +28 -27
- package/hooks/run-node.sh +1 -1
- package/hooks/skill-invocation-telemetry.mjs +15 -8
- package/hooks/subagent-telemetry.mjs +77 -14
- package/hooks/wave-scope-commit-guard.mjs +143 -14
- package/mods/so-hud/.claude-plugin/plugin.json +11 -0
- package/mods/so-hud/README.md +57 -0
- package/mods/so-hud/hooks/beacon-io.mjs +101 -0
- package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
- package/mods/so-hud/hooks/hooks.json +1 -0
- package/mods/so-hud/hooks/logic.ts +182 -0
- package/mods/so-hud/hooks/register.tsx +670 -0
- package/mods/so-hud/hooks/rules.ts +2998 -0
- package/mods/so-hud/tsconfig.json +8 -0
- package/mods/so-hud/types/index.d.ts +36 -0
- package/package.json +4 -2
- package/rules/_index.md +1 -1
- package/rules/always-on/ask-via-tool.md +1 -1
- package/rules/always-on/loop-and-monitor.md +17 -194
- package/rules/always-on/parallel-sessions.md +3 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +1 -1
- package/rules/opt-in-stack/security-web.md +5 -1
- package/scripts/agent-usage-census.mjs +79 -0
- package/scripts/apply-session-learnings.mjs +34 -6
- package/scripts/archive-closed-prds.mjs +21 -5
- package/scripts/autopilot.mjs +11 -5
- package/scripts/backfill-abandoned-sessions.mjs +13 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +61 -6
- package/scripts/backfill-sessions.mjs +16 -2
- package/scripts/check-package-manager.mjs +26 -8
- package/scripts/check-sessions-integrity.mjs +4 -1
- package/scripts/ci/assert-vitest-green.mjs +3 -0
- package/scripts/ci/windows-smoke.mjs +201 -0
- package/scripts/close-precheck.mjs +55 -0
- package/scripts/dialectic-deriver.mjs +20 -12
- package/scripts/emit-event.mjs +2 -2
- package/scripts/emit-session.mjs +239 -20
- package/scripts/eval-session.mjs +17 -3
- package/scripts/export-hw-learnings.mjs +29 -2
- package/scripts/fleet-checkin-audit.mjs +8 -4
- package/scripts/fleet-checkin.mjs +39 -27
- package/scripts/gc-stale-worktrees.mjs +6 -1
- package/scripts/github-export.mjs +128 -0
- package/scripts/github-protection-audit.mjs +3 -1
- package/scripts/handover-queue-check.mjs +209 -0
- package/scripts/harness-audit.mjs +6 -4
- package/scripts/lib/agent-frontmatter.mjs +57 -10
- package/scripts/lib/agent-status.mjs +125 -7
- package/scripts/lib/agent-usage-census.mjs +394 -0
- package/scripts/lib/auto-dialectic.mjs +17 -10
- package/scripts/lib/auto-dream.mjs +22 -14
- package/scripts/lib/autopilot/loop.mjs +3 -1
- package/scripts/lib/autopilot/recent-runs.mjs +4 -2
- package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
- package/scripts/lib/autopilot/telemetry.mjs +5 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/baseline-archetypes.mjs +10 -2
- package/scripts/lib/blocked-commands-policy.mjs +4 -3
- package/scripts/lib/build-live-signals.mjs +13 -9
- package/scripts/lib/ci-status-banner.mjs +329 -24
- package/scripts/lib/codex/plugin-contract.mjs +8 -0
- package/scripts/lib/cold-start-detector.mjs +5 -2
- package/scripts/lib/command-blocker.mjs +603 -43
- package/scripts/lib/common.mjs +3 -2
- package/scripts/lib/config/cross-repo.mjs +74 -7
- package/scripts/lib/config/host-paths.mjs +6 -2
- package/scripts/lib/config/section-extractor.mjs +65 -11
- package/scripts/lib/config/template-keys.mjs +153 -0
- package/scripts/lib/config/vault-integration.mjs +1 -0
- package/scripts/lib/config.mjs +2 -2
- package/scripts/lib/coordinator-snapshot.mjs +1 -1
- package/scripts/lib/discovery/triage-state.mjs +2 -1
- package/scripts/lib/dispatcher/rank.mjs +5 -7
- package/scripts/lib/ecosystem-health.mjs +2 -2
- package/scripts/lib/eval/engine.mjs +3 -2
- package/scripts/lib/eval/session-resolve.mjs +6 -2
- package/scripts/lib/eval/sink.mjs +4 -3
- package/scripts/lib/events-retention-banner.mjs +3 -2
- package/scripts/lib/events-schema.mjs +10 -0
- package/scripts/lib/events.mjs +23 -12
- package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
- package/scripts/lib/fleet-protocol.mjs +4 -0
- package/scripts/lib/gates/gate-full.mjs +172 -11
- package/scripts/lib/gates/gate-helpers.mjs +236 -23
- package/scripts/lib/git-config-drift.mjs +141 -7
- package/scripts/lib/github-export.mjs +447 -0
- package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
- package/scripts/lib/handover-queue-marks.mjs +159 -0
- package/scripts/lib/hardware-pattern-detector.mjs +103 -13
- package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
- package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
- package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
- package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
- package/scripts/lib/host-identity.mjs +9 -5
- package/scripts/lib/http-request.mjs +64 -0
- package/scripts/lib/instruction-budget-guard.mjs +30 -22
- package/scripts/lib/io.mjs +6 -3
- package/scripts/lib/issue-budget-reconcile.mjs +3 -2
- package/scripts/lib/issue-budget.mjs +36 -7
- package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
- package/scripts/lib/learnings/io.mjs +289 -15
- package/scripts/lib/learnings/select.mjs +53 -4
- package/scripts/lib/learnings/store-role.mjs +209 -0
- package/scripts/lib/learnings/surface.mjs +46 -7
- package/scripts/lib/ledger-path.mjs +113 -0
- package/scripts/lib/maintenance-due-banner.mjs +63 -12
- package/scripts/lib/memory-banner.mjs +5 -6
- package/scripts/lib/memory-proposals/collector.mjs +5 -2
- package/scripts/lib/memory-proposals/sink.mjs +7 -4
- package/scripts/lib/memory-proposals/store.mjs +22 -5
- package/scripts/lib/mode-selector-accuracy.mjs +2 -1
- package/scripts/lib/no-follow-open.mjs +57 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
- package/scripts/lib/own-lock-root.mjs +95 -0
- package/scripts/lib/owner-interview.mjs +2 -1
- package/scripts/lib/owner-yaml.mjs +70 -7
- package/scripts/lib/platform.mjs +161 -9
- package/scripts/lib/plugin-update-banner.mjs +15 -11
- package/scripts/lib/pre-push-local-partial.mjs +117 -0
- package/scripts/lib/pre-push-remote-gate.mjs +151 -25
- package/scripts/lib/process-group.mjs +270 -34
- package/scripts/lib/project-hygiene.mjs +28 -0
- package/scripts/lib/quality-gate.mjs +33 -9
- package/scripts/lib/reconcile/backlog.mjs +29 -15
- package/scripts/lib/reconcile/engine.mjs +26 -2
- package/scripts/lib/reconcile/idempotency.mjs +65 -3
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
- package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
- package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
- package/scripts/lib/resource-probe/evaluate.mjs +32 -9
- package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
- package/scripts/lib/rule-loader.mjs +20 -10
- package/scripts/lib/scope-baseline.mjs +123 -8
- package/scripts/lib/scope-echo.mjs +5 -2
- package/scripts/lib/scope-gate.mjs +55 -20
- package/scripts/lib/session-close-backfill.mjs +75 -7
- package/scripts/lib/session-end/close-precheck.mjs +151 -0
- package/scripts/lib/session-end/phase-skip.mjs +8 -7
- package/scripts/lib/session-end/tail-runner.mjs +7 -2
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +72 -9
- package/scripts/lib/session-schema/constants.mjs +9 -0
- package/scripts/lib/session-schema/filters.mjs +56 -1
- package/scripts/lib/session-schema/validator.mjs +15 -5
- package/scripts/lib/session-start-probes.mjs +149 -22
- package/scripts/lib/session-token-rollup.mjs +2 -1
- package/scripts/lib/session-work-trees.mjs +166 -0
- package/scripts/lib/sessions-canonical.mjs +61 -3
- package/scripts/lib/sessions-integrity-banner.mjs +17 -39
- package/scripts/lib/sessions-staleness-banner.mjs +8 -5
- package/scripts/lib/sh-quote.mjs +1 -0
- package/scripts/lib/skill-evidence-window.mjs +5 -1
- package/scripts/lib/skill-health/join.mjs +10 -9
- package/scripts/lib/skill-invocations-schema.mjs +13 -0
- package/scripts/lib/skill-judgments-schema.mjs +6 -2
- package/scripts/lib/snapshot-probe.mjs +414 -0
- package/scripts/lib/so-hud/collect.mjs +49 -5
- package/scripts/lib/so-hud/runtime.mjs +15 -4
- package/scripts/lib/spiral-carryover.mjs +8 -1
- package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
- package/scripts/lib/state-md/idle-reset.mjs +14 -5
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md-peer-guard.mjs +2 -0
- package/scripts/lib/state-md.mjs +31 -8
- package/scripts/lib/sunset/walker.mjs +4 -3
- package/scripts/lib/telemetry/consent.mjs +4 -2
- package/scripts/lib/telemetry/queue.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +45 -9
- package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
- package/scripts/lib/tmux-layout/layouts.mjs +20 -3
- package/scripts/lib/ux-grill/paths.mjs +5 -1
- package/scripts/lib/validate/check-agents.mjs +37 -6
- package/scripts/lib/validate/check-component-paths.mjs +25 -1
- package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
- package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
- package/scripts/lib/validate/check-json-files.mjs +3 -1
- package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
- package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
- package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
- package/scripts/lib/validate/check-unwired-features.mjs +4 -2
- package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
- package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
- package/scripts/lib/validate/confidential-names.mjs +3 -1
- package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
- package/scripts/lib/vault-mirror/namespace.mjs +19 -7
- package/scripts/lib/vault-mirror/process.mjs +56 -27
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
- package/scripts/lib/vault-mirror/retention.mjs +2 -2
- package/scripts/lib/vault-mirror/utils.mjs +79 -0
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
- package/scripts/lib/win-process-tree.mjs +137 -0
- package/scripts/lib/workspace.mjs +1 -1
- package/scripts/lib/worktree/constants.mjs +1 -1
- package/scripts/lib/worktree/lifecycle.mjs +1 -1
- package/scripts/lib/worktree/listing.mjs +1 -1
- package/scripts/lib/zx.mjs +50 -0
- package/scripts/mcp-server.mjs +16 -7
- package/scripts/mcp-server.sh +13 -83
- package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
- package/scripts/memory-propose.mjs +10 -3
- package/scripts/migrate-cold-start-seed.mjs +7 -0
- package/scripts/migrate-legacy-learnings.sh +22 -0
- package/scripts/migrate-sessions-jsonl.mjs +16 -2
- package/scripts/migrate-vault-paths.mjs +29 -13
- package/scripts/parse-config.mjs +71 -14
- package/scripts/print-learnings-index.mjs +25 -2
- package/scripts/release-session-lock.mjs +94 -7
- package/scripts/release.mjs +451 -24
- package/scripts/relocate-vault-corpus.mjs +11 -7
- package/scripts/repair-invalid-sessions.mjs +68 -3
- package/scripts/resolve-session-invocation.mjs +10 -17
- package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
- package/scripts/run-quality-gate.mjs +33 -7
- package/scripts/self-update.mjs +56 -7
- package/scripts/session-commit-files.mjs +133 -0
- package/scripts/site-numbers.mjs +52 -9
- package/scripts/snapshot-probe.mjs +156 -0
- package/scripts/so-hud.mjs +29 -6
- package/scripts/state-md-idle-reset.mjs +2 -1
- package/scripts/sweep-expired-learnings.mjs +54 -6
- package/scripts/sweep-expired-rules.mjs +4 -1
- package/scripts/validate-plugin.mjs +22 -0
- package/scripts/vault-integration-watcher.mjs +54 -6
- package/scripts/vault-mirror.mjs +9 -0
- package/skills/_shared/fleet-protocol.md +142 -1
- package/skills/_shared/instruction-file-resolution.md +1 -1
- package/skills/_shared/monitor-patterns.md +44 -25
- package/skills/_shared/platform-tools.md +7 -0
- package/skills/_shared/state-ownership.md +3 -2
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/bootstrap/SKILL.md +3 -1
- package/skills/bootstrap/_shared-template.md +4 -4
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +1 -1
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
- package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
- package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
- package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
- package/skills/claude-md-drift-check/SKILL.md +3 -3
- package/skills/claude-md-drift-check/checker.mjs +61 -101
- package/skills/close/SKILL.md +20 -7
- package/skills/discovery/probes/docs-staleness.mjs +7 -5
- package/skills/discovery/probes/frontend-slop.mjs +7 -3
- package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
- package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
- package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
- package/skills/discovery/probes/vault-staleness.mjs +5 -3
- package/skills/discovery/probes-session.md +3 -0
- package/skills/discovery/probes-vault.md +2 -0
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eval/SKILL.md +3 -1
- package/skills/evolve/SKILL.md +2 -0
- package/skills/evolve/references/evolve-analyze-mode.md +3 -3
- package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
- package/skills/gitlab-ops/SKILL.md +7 -6
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/go/SKILL.md +13 -1
- package/skills/npm-publish/SKILL.md +5 -2
- package/skills/plan/SKILL.md +23 -10
- package/skills/plan/mode-feature.md +13 -8
- package/skills/plan/mode-new.md +6 -14
- package/skills/plan/prd-feature-template.md +19 -0
- package/skills/plan/prd-full-template.md +12 -5
- package/skills/plan/prd-premortem-panel.md +48 -0
- package/skills/plan/prd-reviewer-prompt.md +11 -3
- package/skills/plan/soul.md +1 -1
- package/skills/portfolio/SKILL.md +5 -6
- package/skills/quality-gates/SKILL.md +2 -4
- package/skills/reconcile/SKILL.md +27 -4
- package/skills/release/SKILL.md +16 -8
- package/skills/session-end/SKILL.md +94 -11
- package/skills/session-end/phase-3-2-docs-verification.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +11 -7
- package/skills/session-end/phase-3-7a-recommendations.md +9 -2
- package/skills/session-end/plan-verification.md +16 -1
- package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +7 -4
- package/skills/session-plan/SKILL.md +5 -3
- package/skills/session-start/SKILL.md +9 -3
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-8-5-express-path.md +4 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
- package/skills/tmux-layout/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +3 -2
- package/skills/wave-executor/SKILL.md +5 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
- package/skills/wave-executor/references/wave-loop-review.md +13 -5
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* migrate-ids.mjs — one-time repair of the frontmatter `id` of notes the vault
|
|
4
|
+
* mirror wrote before #1601/#1602.
|
|
5
|
+
*
|
|
6
|
+
* Why a separate pass: the mirror's hot path (`process.mjs`) never rewrites a
|
|
7
|
+
* note because only its `id` is wrong. Its only rewrite is a full re-render
|
|
8
|
+
* from the ledger record, and that re-render brought back plaintext that an
|
|
9
|
+
* earlier run had masked to `[REDACTED]` (#1025) whenever the secret was
|
|
10
|
+
* missing from the current env. That was the release-review finding HOCH 1 on
|
|
11
|
+
* MR !148. This pass changes ONE line and nothing else.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
* - Only notes with the vault-mirror `_generator` marker are considered, in
|
|
15
|
+
* `40-learnings/`, `50-sessions/` and their `90-archive/mirror/` copies,
|
|
16
|
+
* flat (`<zone>/<file>`) or namespaced (`<zone>/<ns>/<file>`). Month
|
|
17
|
+
* rollups are skipped (their id is not derived from the filename).
|
|
18
|
+
* - The new id is `noteIdFor(<ns>, <filename slug>)`: the same id the
|
|
19
|
+
* generator renders today. The namespace is the folder; a flat note uses
|
|
20
|
+
* its own `source-repo` (as `fmNamesSlug` in process.mjs does).
|
|
21
|
+
* - `schema` changes: the old id breaks the vault-sync schema (missing, over
|
|
22
|
+
* 128 chars, all digits, not a kebab slug). Always planned.
|
|
23
|
+
* - `namespace` changes: the old id is the bare pre-#1602 slug. Planned only
|
|
24
|
+
* with `includeBare`, because it touches every older generated note once.
|
|
25
|
+
* - Two notes of the same layer (live, or `90-archive/`) that would end up
|
|
26
|
+
* with the same id are both left alone and reported as `conflicts`. A live
|
|
27
|
+
* note and its archive copy never conflict: the vault-sync validator does
|
|
28
|
+
* not check `90-archive/`, so blocking the live repair on its copy would
|
|
29
|
+
* leave the schema error in place.
|
|
30
|
+
* - A note whose frontmatter has no `id:` line is not planned (the one-line
|
|
31
|
+
* rewrite has no line to change); it is reported as `unrepairable`.
|
|
32
|
+
* - Apply replaces only the `id:` line inside the frontmatter
|
|
33
|
+
* (`withNoteId`), checks that nothing else changed, re-reads the file and
|
|
34
|
+
* skips it if it changed since the plan, and writes atomically: a tmp
|
|
35
|
+
* sibling in the same folder, then rename (`atomicWriteWithBackup`).
|
|
36
|
+
* - Apply refuses to write anything when the plan has more changes than
|
|
37
|
+
* `maxChanges`. The CLI requires `--max-changes` with `--apply`, so the
|
|
38
|
+
* count comes from a dry run first.
|
|
39
|
+
*
|
|
40
|
+
* Usage (dry run is the default and writes nothing):
|
|
41
|
+
* node scripts/lib/vault-mirror/migrate-ids.mjs --vault-dir PATH [--include-bare]
|
|
42
|
+
* node scripts/lib/vault-mirror/migrate-ids.mjs --vault-dir PATH [--include-bare] --apply --max-changes N
|
|
43
|
+
*
|
|
44
|
+
* Output: one JSON line per planned change ({path, from, to, reason}), then
|
|
45
|
+
* one summary line. Paths are vault-relative. The `from` values are the
|
|
46
|
+
* record needed to undo a run.
|
|
47
|
+
*
|
|
48
|
+
* Exit codes: 0 success · 1 usage error · 2 refused (over the cap), per-file
|
|
49
|
+
* errors, or `unrepairable` notes left after --apply
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
53
|
+
import { join, resolve } from 'node:path';
|
|
54
|
+
import yaml from 'js-yaml';
|
|
55
|
+
|
|
56
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
57
|
+
import { expandTilde } from '../common.mjs';
|
|
58
|
+
import { isMainModule } from '../is-main-module.mjs';
|
|
59
|
+
import { NOTE_ID_MAX_LENGTH, isValidSlug, noteIdFor, noteIdMatches, parseFrontmatter, slugifyIdSafe, withNoteId } from './utils.mjs';
|
|
60
|
+
import { ARCHIVE_ROOT, GENERATOR_MARKER, ROLLUP_PREFIX } from './retention.mjs';
|
|
61
|
+
|
|
62
|
+
const ZONES = ['40-learnings', '50-sessions'];
|
|
63
|
+
/** The vault-sync schema's id pattern (`skills/vault-sync/validator.mjs` slugRegex). */
|
|
64
|
+
const SCHEMA_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
65
|
+
const ID_LINE = /^id:(.*)$/m;
|
|
66
|
+
|
|
67
|
+
/** Every `.md` of a zone root: flat files and one folder level below. */
|
|
68
|
+
function listZoneFiles(vaultDir, zoneRel) {
|
|
69
|
+
const out = [];
|
|
70
|
+
let top;
|
|
71
|
+
try {
|
|
72
|
+
top = readdirSync(join(vaultDir, zoneRel), { withFileTypes: true });
|
|
73
|
+
} catch {
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
for (const ent of top) {
|
|
77
|
+
if (ent.isFile() && ent.name.endsWith('.md')) out.push({ rel: `${zoneRel}/${ent.name}`, dirNs: '', name: ent.name });
|
|
78
|
+
else if (ent.isDirectory()) {
|
|
79
|
+
let inner;
|
|
80
|
+
try {
|
|
81
|
+
inner = readdirSync(join(vaultDir, zoneRel, ent.name), { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const f of inner) {
|
|
86
|
+
if (f.isFile() && f.name.endsWith('.md')) out.push({ rel: `${zoneRel}/${ent.name}/${f.name}`, dirNs: ent.name, name: f.name });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Does the on-disk id break the vault-sync schema (`z.string().regex(slug).max(128)`)?
|
|
95
|
+
* `cur` is the parseFrontmatter value (quotes stripped, always a string);
|
|
96
|
+
* `raw` is the literal after `id:`. An unquoted `433` is a YAML NUMBER even
|
|
97
|
+
* though parseFrontmatter hands it back as a string — the #1601 shape — so
|
|
98
|
+
* the raw literal goes through a real YAML parse too.
|
|
99
|
+
*/
|
|
100
|
+
function idViolatesSchema(cur, raw) {
|
|
101
|
+
if (typeof cur !== 'string' || cur.length > NOTE_ID_MAX_LENGTH || !SCHEMA_SLUG.test(cur)) return true;
|
|
102
|
+
try {
|
|
103
|
+
return typeof yaml.load(`id: ${raw}`)?.id !== 'string';
|
|
104
|
+
} catch {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The leading frontmatter block, or null. */
|
|
110
|
+
function frontmatterHead(content) {
|
|
111
|
+
if (!content.startsWith('---')) return null;
|
|
112
|
+
const end = content.indexOf('\n---', 3);
|
|
113
|
+
return end === -1 ? null : content.slice(0, end);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Plan the id migration. Read-only.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ vaultDir: string, includeBare?: boolean }} opts
|
|
120
|
+
* @returns {{ changes: Array<{path: string, from: string, to: string, reason: 'schema'|'namespace'}>,
|
|
121
|
+
* conflicts: Array<{path: string, to: string, reason: 'schema'|'namespace'}>, unrepairable: Array<{path: string, reason: 'no-id-line'}>,
|
|
122
|
+
* scanned: number, unrecognised: number, contents: Map<string, string> }}
|
|
123
|
+
* `contents` holds the bytes each change was planned against (apply compares them).
|
|
124
|
+
*/
|
|
125
|
+
export function planIdMigration({ vaultDir, includeBare = false }) {
|
|
126
|
+
const candidates = [];
|
|
127
|
+
// `<layer>:<id>` → count of notes that carry or would carry it. Counted per
|
|
128
|
+
// layer: a live note and its archive copy share an id by design.
|
|
129
|
+
const targets = new Map();
|
|
130
|
+
const unrepairable = [];
|
|
131
|
+
let scanned = 0;
|
|
132
|
+
let unrecognised = 0;
|
|
133
|
+
const zoneRoots = [...ZONES, ...ZONES.map((z) => `${ARCHIVE_ROOT}/${z}`)];
|
|
134
|
+
for (const zoneRel of zoneRoots) {
|
|
135
|
+
const layer = zoneRel.startsWith(`${ARCHIVE_ROOT}/`) ? 'archive' : 'live';
|
|
136
|
+
for (const { rel, dirNs, name } of listZoneFiles(vaultDir, zoneRel)) {
|
|
137
|
+
if (name.startsWith(ROLLUP_PREFIX)) continue;
|
|
138
|
+
let content;
|
|
139
|
+
try {
|
|
140
|
+
content = readFileSync(join(vaultDir, rel), 'utf8');
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const fm = parseFrontmatter(content);
|
|
145
|
+
if (!fm || fm['_generator'] !== GENERATOR_MARKER) continue;
|
|
146
|
+
const head = frontmatterHead(content);
|
|
147
|
+
if (head === null) continue;
|
|
148
|
+
scanned += 1;
|
|
149
|
+
const stem = name.slice(0, -'.md'.length);
|
|
150
|
+
// The generator's id base: a learning's filename IS its slug; a session
|
|
151
|
+
// file is named by the raw session id, which the renderer slugifies.
|
|
152
|
+
const base = isValidSlug(stem) ? stem : (slugifyIdSafe(stem) ?? stem);
|
|
153
|
+
const ns = dirNs || (typeof fm['source-repo'] === 'string' ? fm['source-repo'] : null);
|
|
154
|
+
const to = noteIdFor(ns, base);
|
|
155
|
+
const cur = typeof fm['id'] === 'string' ? fm['id'] : undefined;
|
|
156
|
+
const rawLine = head.match(ID_LINE);
|
|
157
|
+
if (!rawLine) {
|
|
158
|
+
unrepairable.push({ path: rel, reason: 'no-id-line' });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const from = rawLine[1].trim();
|
|
162
|
+
const violates = idViolatesSchema(cur, from);
|
|
163
|
+
let reason = null;
|
|
164
|
+
if (violates) reason = 'schema';
|
|
165
|
+
else if (cur !== to) {
|
|
166
|
+
const bare = cur === base || cur === stem || noteIdMatches(cur, base, fm['source-repo']);
|
|
167
|
+
if (bare && includeBare) reason = 'namespace';
|
|
168
|
+
else if (!bare) unrecognised += 1;
|
|
169
|
+
}
|
|
170
|
+
const finalId = reason === null ? cur : to;
|
|
171
|
+
if (typeof finalId === 'string') targets.set(`${layer}:${finalId}`, (targets.get(`${layer}:${finalId}`) ?? 0) + 1);
|
|
172
|
+
if (reason !== null) candidates.push({ path: rel, from, to, reason, content, layer });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const changes = [];
|
|
176
|
+
const conflicts = [];
|
|
177
|
+
const contents = new Map();
|
|
178
|
+
for (const c of candidates) {
|
|
179
|
+
if ((targets.get(`${c.layer}:${c.to}`) ?? 0) > 1) {
|
|
180
|
+
conflicts.push({ path: c.path, to: c.to, reason: c.reason });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
changes.push({ path: c.path, from: c.from, to: c.to, reason: c.reason });
|
|
184
|
+
contents.set(c.path, c.content);
|
|
185
|
+
}
|
|
186
|
+
return { changes, conflicts, unrepairable, scanned, unrecognised, contents };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* True when `next` differs from `content` in the frontmatter `id:` line only.
|
|
191
|
+
* A belt against a future change to `withNoteId`, not an independent check:
|
|
192
|
+
* it strips the line with the same `id:` pattern `withNoteId` rewrites, so it
|
|
193
|
+
* only catches a `withNoteId` that starts touching anything else
|
|
194
|
+
* (BV-002: data-loss prevention).
|
|
195
|
+
*/
|
|
196
|
+
function onlyIdLineChanged(content, next) {
|
|
197
|
+
const strip = (s) => {
|
|
198
|
+
const head = frontmatterHead(s);
|
|
199
|
+
return head === null ? s : head.replace(ID_LINE, 'id:') + s.slice(head.length);
|
|
200
|
+
};
|
|
201
|
+
return strip(content) === strip(next);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Apply a plan from {@link planIdMigration}.
|
|
206
|
+
*
|
|
207
|
+
* @param {ReturnType<typeof planIdMigration>} plan
|
|
208
|
+
* @param {{ vaultDir: string, maxChanges: number, fs?: object }} opts
|
|
209
|
+
* `fs` is passed to `atomicWriteWithBackup` (test seam).
|
|
210
|
+
* @returns {{ ok: boolean, refused?: 'over-limit', written: string[],
|
|
211
|
+
* skipped: Array<{path: string, reason: string}>, errors: Array<{path: string, error: string}> }}
|
|
212
|
+
*/
|
|
213
|
+
export function applyIdMigration(plan, { vaultDir, maxChanges, fs: injectedFs } = {}) {
|
|
214
|
+
const written = [];
|
|
215
|
+
const skipped = [];
|
|
216
|
+
const errors = [];
|
|
217
|
+
if (!Number.isInteger(maxChanges) || maxChanges < 0 || plan.changes.length > maxChanges) {
|
|
218
|
+
return { ok: false, refused: 'over-limit', written, skipped, errors };
|
|
219
|
+
}
|
|
220
|
+
for (const change of plan.changes) {
|
|
221
|
+
const abs = join(vaultDir, change.path);
|
|
222
|
+
let content;
|
|
223
|
+
try {
|
|
224
|
+
content = readFileSync(abs, 'utf8');
|
|
225
|
+
} catch (err) {
|
|
226
|
+
errors.push({ path: change.path, error: err?.code ?? 'read-failed' });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (content !== plan.contents.get(change.path)) {
|
|
230
|
+
skipped.push({ path: change.path, reason: 'changed-since-plan' });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const next = withNoteId(content, change.to);
|
|
234
|
+
if (next === content || !onlyIdLineChanged(content, next)) {
|
|
235
|
+
skipped.push({ path: change.path, reason: 'not-a-single-line-change' });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const res = atomicWriteWithBackup(abs, next, { tmpPrefix: '.vault-mirror-migrate-ids.tmp', ...(injectedFs ? { fs: injectedFs } : {}) });
|
|
239
|
+
if (res.ok) written.push(change.path);
|
|
240
|
+
else errors.push({ path: change.path, error: res.code ?? 'write-failed' });
|
|
241
|
+
}
|
|
242
|
+
return { ok: errors.length === 0, written, skipped, errors };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const HELP = `Usage: node scripts/lib/vault-mirror/migrate-ids.mjs --vault-dir PATH [--include-bare]
|
|
246
|
+
[--apply --max-changes N]
|
|
247
|
+
|
|
248
|
+
Repairs the frontmatter id of vault-mirror notes (#1601/#1602). Dry run is the
|
|
249
|
+
default: it prints the planned changes and writes nothing. --apply needs
|
|
250
|
+
--max-changes N (take N from the dry run); a plan with more changes is refused.
|
|
251
|
+
|
|
252
|
+
--include-bare Also namespace ids that are valid but carry no repo prefix
|
|
253
|
+
`;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string[]} argv
|
|
257
|
+
* @returns {{ vaultDir: string|null, apply: boolean, includeBare: boolean, maxChanges: number|null, help: boolean }}
|
|
258
|
+
*/
|
|
259
|
+
export function parseArgs(argv) {
|
|
260
|
+
const args = { vaultDir: null, apply: false, includeBare: false, maxChanges: null, help: false };
|
|
261
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
262
|
+
const a = argv[i];
|
|
263
|
+
if (a === '--help' || a === '-h') args.help = true;
|
|
264
|
+
else if (a === '--apply') args.apply = true;
|
|
265
|
+
else if (a === '--include-bare') args.includeBare = true;
|
|
266
|
+
else if (a === '--vault-dir') args.vaultDir = argv[++i] ?? null;
|
|
267
|
+
else if (a === '--max-changes') {
|
|
268
|
+
const raw = argv[++i];
|
|
269
|
+
const n = Number(raw);
|
|
270
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`invalid --max-changes "${raw}"`);
|
|
271
|
+
args.maxChanges = n;
|
|
272
|
+
} else throw new Error(`unknown argument "${a}"`);
|
|
273
|
+
}
|
|
274
|
+
return args;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (isMainModule(import.meta.url)) {
|
|
278
|
+
let args;
|
|
279
|
+
try {
|
|
280
|
+
args = parseArgs(process.argv.slice(2));
|
|
281
|
+
} catch (err) {
|
|
282
|
+
process.stderr.write(`migrate-ids: ${err.message}\n`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
if (args.help) {
|
|
286
|
+
process.stdout.write(HELP);
|
|
287
|
+
process.exit(0);
|
|
288
|
+
}
|
|
289
|
+
if (!args.vaultDir) {
|
|
290
|
+
process.stderr.write('migrate-ids: --vault-dir is required\n');
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
if (args.apply && args.maxChanges === null) {
|
|
294
|
+
process.stderr.write('migrate-ids: --apply needs --max-changes N (run without --apply first to see N)\n');
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
const vaultDir = resolve(expandTilde(args.vaultDir));
|
|
298
|
+
const plan = planIdMigration({ vaultDir, includeBare: args.includeBare });
|
|
299
|
+
for (const c of plan.changes) process.stdout.write(`${JSON.stringify(c)}\n`);
|
|
300
|
+
const summary = {
|
|
301
|
+
mode: args.apply ? 'apply' : 'dry-run',
|
|
302
|
+
scanned: plan.scanned,
|
|
303
|
+
planned: plan.changes.length,
|
|
304
|
+
conflicts: plan.conflicts,
|
|
305
|
+
unrepairable: plan.unrepairable,
|
|
306
|
+
unrecognised: plan.unrecognised,
|
|
307
|
+
};
|
|
308
|
+
if (plan.unrepairable.length > 0) {
|
|
309
|
+
process.stderr.write(`migrate-ids: ${plan.unrepairable.length} note(s) have no id line and are not repaired; see "unrepairable"\n`);
|
|
310
|
+
}
|
|
311
|
+
if (!args.apply) {
|
|
312
|
+
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
const res = applyIdMigration(plan, { vaultDir, maxChanges: args.maxChanges });
|
|
316
|
+
process.stdout.write(`${JSON.stringify({ ...summary, ...res, written: res.written.length })}\n`);
|
|
317
|
+
// A run that leaves schema violations behind (no id line, or a schema repair
|
|
318
|
+
// blocked by a conflict) is not a success, even if every planned write landed.
|
|
319
|
+
const schemaLeft = plan.unrepairable.length + plan.conflicts.filter((c) => c.reason === 'schema').length;
|
|
320
|
+
process.exit(res.ok && schemaLeft === 0 ? 0 : 2);
|
|
321
|
+
}
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
import { basename, resolve } from 'node:path';
|
|
57
57
|
|
|
58
58
|
import { legacySubjectToSlug } from './utils.mjs';
|
|
59
|
-
import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
|
|
59
|
+
import { inspectOwnerPatterns, isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
|
|
60
60
|
import { loadPseudonymMap } from './pseudonym-map.mjs';
|
|
61
61
|
import { loadHostPaths, resolveHostPath } from '../config/host-paths.mjs';
|
|
62
62
|
import { isQueryFailure, resolvePreferredRemote } from '../vcs-repo-spec.mjs';
|
|
@@ -261,6 +261,22 @@ function lookupPseudonym(base, seg) {
|
|
|
261
261
|
return null;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* WARN line for a redaction. An unsafe private config dir (#1629, e.g. 0777 under
|
|
266
|
+
* umask 000) makes isOwnerLeakySegment() fail closed with CP1 for EVERY segment —
|
|
267
|
+
* the redaction stays, but calling that a leak hid the real cause.
|
|
268
|
+
*
|
|
269
|
+
* @param {string} where
|
|
270
|
+
* @param {string} pattern
|
|
271
|
+
* @returns {string}
|
|
272
|
+
*/
|
|
273
|
+
function redactionWarning(where, pattern) {
|
|
274
|
+
if (inspectOwnerPatterns().reason === 'unsafe-config-dir') {
|
|
275
|
+
return `WARN vault-mirror/namespace: owner policy unavailable (unsafe config dir); redacting ${where} to 'redacted-repo'\n`;
|
|
276
|
+
}
|
|
277
|
+
return `WARN vault-mirror/namespace: owner-privacy leak detected in ${where} (pattern: ${pattern}); redacting to 'redacted-repo'\n`;
|
|
278
|
+
}
|
|
279
|
+
|
|
264
280
|
/**
|
|
265
281
|
* Resolve the sanitised repository namespace segment for vault path scoping.
|
|
266
282
|
*
|
|
@@ -332,9 +348,7 @@ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {})
|
|
|
332
348
|
if (rawMatch !== null) {
|
|
333
349
|
const pseudonym = lookupPseudonym(base, seg);
|
|
334
350
|
if (pseudonym) return pseudonym;
|
|
335
|
-
process.stderr.write(
|
|
336
|
-
`WARN vault-mirror/namespace: owner-privacy leak detected in repo identifier (pattern: ${rawMatch}); redacting to 'redacted-repo'\n`,
|
|
337
|
-
);
|
|
351
|
+
process.stderr.write(redactionWarning('repo identifier', rawMatch));
|
|
338
352
|
return 'redacted-repo';
|
|
339
353
|
}
|
|
340
354
|
|
|
@@ -342,9 +356,7 @@ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {})
|
|
|
342
356
|
if (segMatch !== null) {
|
|
343
357
|
const pseudonym = lookupPseudonym(base, seg);
|
|
344
358
|
if (pseudonym) return pseudonym;
|
|
345
|
-
process.stderr.write(
|
|
346
|
-
`WARN vault-mirror/namespace: owner-privacy leak detected in sanitised namespace segment (pattern: ${segMatch}); redacting to 'redacted-repo'\n`,
|
|
347
|
-
);
|
|
359
|
+
process.stderr.write(redactionWarning('sanitised namespace segment', segMatch));
|
|
348
360
|
return 'redacted-repo';
|
|
349
361
|
}
|
|
350
362
|
|
|
@@ -10,7 +10,7 @@ import { basename, join, resolve, sep } from 'node:path';
|
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
import { createSecretValueMasker } from '../secret-masker.mjs';
|
|
13
|
-
import { subjectToSlug, legacySubjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter } from './utils.mjs';
|
|
13
|
+
import { subjectToSlug, legacySubjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter, slugifyIdSafe, noteIdFor, noteIdMatches, withNoteId } from './utils.mjs';
|
|
14
14
|
import { isRealSession } from '../session-schema/filters.mjs';
|
|
15
15
|
import { resolveRepoNamespace } from './namespace.mjs';
|
|
16
16
|
import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2, learningSourceRecord } from './render-learnings.mjs';
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
renderRollup,
|
|
26
26
|
rollupRelPath,
|
|
27
27
|
rollupRowFromNote,
|
|
28
|
+
section,
|
|
28
29
|
sessionNarrativeChars,
|
|
29
30
|
} from './retention.mjs';
|
|
30
31
|
|
|
@@ -268,6 +269,23 @@ export function matchesModuloRedaction(existingVal, renderedVal) {
|
|
|
268
269
|
return new RegExp(`^${pattern}$`).test(renderedVal);
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Does a session note on disk carry LESS free text than the record (#1513
|
|
274
|
+
* review H2), so it must be re-rendered? Not when the only difference is a
|
|
275
|
+
* `[REDACTED]` span (#1025): `[REDACTED]` and the raw value differ in length,
|
|
276
|
+
* so the bare char-count compare reported "stale" whenever the secret was
|
|
277
|
+
* missing from the current env and re-rendered the note WITH the raw value.
|
|
278
|
+
* Found while pinning #1601 (release-review HOCH 1, MR !148).
|
|
279
|
+
*
|
|
280
|
+
* @param {string} existingContent
|
|
281
|
+
* @param {string} renderedBody
|
|
282
|
+
* @returns {boolean}
|
|
283
|
+
*/
|
|
284
|
+
function sessionNotesStale(existingContent, renderedBody) {
|
|
285
|
+
if (noteNarrativeChars(existingContent) === noteNarrativeChars(renderedBody)) return false;
|
|
286
|
+
return !matchesModuloRedaction(section(existingContent, 'Notes') ?? '', section(renderedBody, 'Notes') ?? '');
|
|
287
|
+
}
|
|
288
|
+
|
|
271
289
|
/**
|
|
272
290
|
* Return true when the existing vault note content and the freshly-rendered
|
|
273
291
|
* candidate share identical canonical fields (i.e. no meaningful update needed).
|
|
@@ -449,17 +467,6 @@ function disambigSlugFor(dir, slug, { legacyId, recordKey }) {
|
|
|
449
467
|
return { slug: hashed, exists: null };
|
|
450
468
|
}
|
|
451
469
|
|
|
452
|
-
/**
|
|
453
|
-
* Replace the frontmatter `id:` line of a note — used when a displaced note
|
|
454
|
-
* moves to its disambiguated name (#1506.2), whose `id` must match the file.
|
|
455
|
-
* @param {string} content
|
|
456
|
-
* @param {string} id
|
|
457
|
-
* @returns {string}
|
|
458
|
-
*/
|
|
459
|
-
function withNoteId(content, id) {
|
|
460
|
-
return content.replace(/^id: .*$/m, `id: ${id}`);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
470
|
/**
|
|
464
471
|
* Emit `skipped-foreign-owner` for a note the writer must not touch: one stderr
|
|
465
472
|
* `SKIP` line (vault-relative path, like every other SKIP line) and the stdout
|
|
@@ -974,10 +981,20 @@ function learningSlugVariants(entry, schema) {
|
|
|
974
981
|
return [...names];
|
|
975
982
|
}
|
|
976
983
|
|
|
984
|
+
/**
|
|
985
|
+
* Does the note `fm` name `slug`? #1601/#1602: its `id` is the bare slug (a note
|
|
986
|
+
* written before the namespaced id) or the slug namespaced under the writer's
|
|
987
|
+
* folder or under the note's OWN `source-repo` — a moved or relabelled note keeps
|
|
988
|
+
* its old prefix, and the folder, not the label, decides ownership (#1503 F1).
|
|
989
|
+
*/
|
|
990
|
+
function fmNamesSlug(fm, slug, repoNs) {
|
|
991
|
+
return Boolean(fm) && (noteIdMatches(fm['id'], slug, repoNs) || noteIdMatches(fm['id'], slug, fm['source-repo']));
|
|
992
|
+
}
|
|
993
|
+
|
|
977
994
|
/** Only explicit, exact provenance permits assigning an old name to a record. */
|
|
978
995
|
function positiveLearningOwner(fm, name, repoNs, recordKey) {
|
|
979
996
|
return recordKey !== null && notOurGenerator(fm) === null &&
|
|
980
|
-
fm
|
|
997
|
+
fmNamesSlug(fm, name, repoNs) && fm['source-record'] === recordKey && fm['source-repo'] === repoNs;
|
|
981
998
|
}
|
|
982
999
|
|
|
983
1000
|
|
|
@@ -1181,7 +1198,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1181
1198
|
const legacyFm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
|
|
1182
1199
|
if (
|
|
1183
1200
|
notOurGenerator(legacyFm) === null &&
|
|
1184
|
-
legacyFm
|
|
1201
|
+
fmNamesSlug(legacyFm, legacySlug, repoNs) &&
|
|
1185
1202
|
foreignOwnerReason(legacyFm, writer) === null
|
|
1186
1203
|
) {
|
|
1187
1204
|
slug = legacySlug;
|
|
@@ -1220,7 +1237,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1220
1237
|
// neither a duplicate to skip on nor a file to rewrite with THIS record's
|
|
1221
1238
|
// content; this entry proceeds to its own namespaced path (a different
|
|
1222
1239
|
// file). A leak in it is masked in place, keeping its owner and content.
|
|
1223
|
-
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm
|
|
1240
|
+
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && fmNamesSlug(legacyFm, slug, repoNs);
|
|
1224
1241
|
const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
|
|
1225
1242
|
if (legacyForeign && legacyStillLeaks) {
|
|
1226
1243
|
healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
|
|
@@ -1239,6 +1256,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1239
1256
|
// #1028 residue 1: the five-field compare cannot see a raw secret
|
|
1240
1257
|
// sitting in a non-canonical field (e.g. `evidence`) — the
|
|
1241
1258
|
// `legacyStillLeaks` probe above is what makes this match trustworthy.
|
|
1259
|
+
// #1601: `id` is not a compared field, and a schema-violating old id
|
|
1260
|
+
// is NOT a reason to rewrite here — re-rendering would bring back
|
|
1261
|
+
// plaintext a past run masked (#1025). `migrate-ids.mjs` repairs it.
|
|
1242
1262
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: slug });
|
|
1243
1263
|
}
|
|
1244
1264
|
// Content differs, or the on-disk note still leaks under the current
|
|
@@ -1307,7 +1327,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1307
1327
|
const entryDay = toDate(dateSource);
|
|
1308
1328
|
if (
|
|
1309
1329
|
routedByRecord &&
|
|
1310
|
-
fm
|
|
1330
|
+
fmNamesSlug(fm, slug, repoNs) &&
|
|
1311
1331
|
!expired &&
|
|
1312
1332
|
/^\d{4}-\d{2}-\d{2}$/.test(entryDay) &&
|
|
1313
1333
|
typeof fm['created'] === 'string' &&
|
|
@@ -1320,7 +1340,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1320
1340
|
if (takenOver !== null) return takenOver;
|
|
1321
1341
|
}
|
|
1322
1342
|
|
|
1323
|
-
if (fm
|
|
1343
|
+
if (!fmNamesSlug(fm, slug, repoNs) || routedByRecord) {
|
|
1324
1344
|
// Different id (or different record) → collision: disambiguate
|
|
1325
1345
|
const picked = disambigSlugFor(targetDir, slug, {
|
|
1326
1346
|
legacyId: String(entryId),
|
|
@@ -1401,7 +1421,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1401
1421
|
// #1506.2: the main note is this record's — a live disambiguation copy of
|
|
1402
1422
|
// the same record (left by an interrupted takeover) is superseded.
|
|
1403
1423
|
const stale = !slugTransition && fm['source-record'] === writer.recordKey
|
|
1404
|
-
? staleOwnDisambig(targetDir, slug, entryId, writer)
|
|
1424
|
+
? staleOwnDisambig(targetDir, slug, entryId, writer, repoNs)
|
|
1405
1425
|
: null;
|
|
1406
1426
|
|
|
1407
1427
|
// Same id: check if updated would advance (unless --force overrides).
|
|
@@ -1414,6 +1434,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1414
1434
|
if (learningContentMatches(existingContent, candidateContent)) {
|
|
1415
1435
|
// #1028 residue 1: probe the on-disk content against the CURRENT masker
|
|
1416
1436
|
// before trusting the five-field match — see maskerWouldChange above.
|
|
1437
|
+
// #1601: `id` is deliberately NOT part of this decision — an id-only
|
|
1438
|
+
// difference must never re-render the note (that would bring back
|
|
1439
|
+
// plaintext a past run masked, #1025); `migrate-ids.mjs` repairs it.
|
|
1417
1440
|
mainUnchanged = !maskerWouldChange(existingContent);
|
|
1418
1441
|
}
|
|
1419
1442
|
// Content differs, or the on-disk note still leaks under the current env —
|
|
@@ -1480,14 +1503,14 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1480
1503
|
*
|
|
1481
1504
|
* @returns {{ path: string, next: string }|null} the rewrite, or null.
|
|
1482
1505
|
*/
|
|
1483
|
-
function staleOwnDisambig(targetDir, slug, entryId, writer) {
|
|
1506
|
+
function staleOwnDisambig(targetDir, slug, entryId, writer, repoNs) {
|
|
1484
1507
|
if (typeof writer.recordKey !== 'string') return null;
|
|
1485
1508
|
const own = disambigSlugFor(targetDir, slug, { legacyId: String(entryId), recordKey: writer.recordKey });
|
|
1486
1509
|
const path = join(targetDir, `${own.slug}.md`);
|
|
1487
1510
|
if (!(own.exists ?? existsSync(path))) return null;
|
|
1488
1511
|
const content = readFileSync(path, 'utf8');
|
|
1489
1512
|
const fm = parseFrontmatter(content);
|
|
1490
|
-
if (notOurGenerator(fm) !== null || fm['source-record'] !== writer.recordKey || fm
|
|
1513
|
+
if (notOurGenerator(fm) !== null || fm['source-record'] !== writer.recordKey || !fmNamesSlug(fm, own.slug, repoNs)) return null;
|
|
1491
1514
|
const next = markArchived(content, 'superseded');
|
|
1492
1515
|
return next === content ? null : { path, next };
|
|
1493
1516
|
}
|
|
@@ -1517,14 +1540,14 @@ function takeOverMainNote({
|
|
|
1517
1540
|
if (notOurGenerator(dfm) !== null || dfm['source-record'] !== displacedKey) return null;
|
|
1518
1541
|
}
|
|
1519
1542
|
|
|
1520
|
-
const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer);
|
|
1543
|
+
const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer, generatorOpts.repoNs);
|
|
1521
1544
|
|
|
1522
1545
|
if (!dryRun) {
|
|
1523
1546
|
// Re-read: an in-place leak heal above may have rewritten the note.
|
|
1524
1547
|
const current = readFileSync(targetPath, 'utf8');
|
|
1525
1548
|
// Rewritten even when it exists (and is ours, checked above): a stale copy
|
|
1526
1549
|
// there may carry `status: archived` from an earlier archive pass.
|
|
1527
|
-
writeFileSync(displacedPath, withNoteId(current, displaced.slug), 'utf8');
|
|
1550
|
+
writeFileSync(displacedPath, withNoteId(current, noteIdFor(generatorOpts.repoNs, displaced.slug)), 'utf8');
|
|
1528
1551
|
// Before the main note: a crash between the two writes must leave the
|
|
1529
1552
|
// takeover re-runnable (the main note still foreign), never a main note of
|
|
1530
1553
|
// ours beside a stale live disambiguation note of the same record.
|
|
@@ -1633,7 +1656,7 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
|
|
|
1633
1656
|
if (!existsSync(path)) continue;
|
|
1634
1657
|
const content = readFileSync(path, 'utf8');
|
|
1635
1658
|
const fm = parseFrontmatter(content);
|
|
1636
|
-
if (notOurGenerator(fm) !== null || fm
|
|
1659
|
+
if (notOurGenerator(fm) !== null || !fmNamesSlug(fm, name, repoNs)) continue;
|
|
1637
1660
|
if (foreignOwnerReason(fm, writer) !== null) continue;
|
|
1638
1661
|
const next = markArchived(content, reason);
|
|
1639
1662
|
if (next === content) {
|
|
@@ -1817,6 +1840,12 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1817
1840
|
// (`subjectToSlug`), caps at 240 chars, or falls back to a uuid slug. A note
|
|
1818
1841
|
// carrying either id is this record's own note.
|
|
1819
1842
|
const renderedId = parseFrontmatter(renderedBody)?.id;
|
|
1843
|
+
// #1601/#1602: a note written before the namespaced id carries the bare slug
|
|
1844
|
+
// (`session_id`, or the renderer's whitespace-hyphenated slug); either form,
|
|
1845
|
+
// with or without the repo prefix, names this session.
|
|
1846
|
+
const legacyRenderedId = slugifyIdSafe(rawSessionId) ?? session_id;
|
|
1847
|
+
const isOwnSessionId = (fm) =>
|
|
1848
|
+
fm['id'] === renderedId || fmNamesSlug(fm, session_id, repoNs) || fmNamesSlug(fm, legacyRenderedId, repoNs);
|
|
1820
1849
|
|
|
1821
1850
|
// Canonical filename pattern (issue #343): `<session_id>.md` where session_id
|
|
1822
1851
|
// follows `<branch>-<YYYY-MM-DD>-<HHmm>-<slug>` per the session-id schema.
|
|
@@ -1843,14 +1872,14 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1843
1872
|
const legacyStillLeaks = legacyFm !== null && maskerWouldChange(legacyContent);
|
|
1844
1873
|
// #1503: a flat note from a foreign record is not ours — see processLearning's
|
|
1845
1874
|
// dual-probe. A leak in it is masked in place, keeping its owner and content.
|
|
1846
|
-
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm
|
|
1875
|
+
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && isOwnSessionId(legacyFm);
|
|
1847
1876
|
const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
|
|
1848
1877
|
if (legacyForeign && legacyStillLeaks) {
|
|
1849
1878
|
healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
|
|
1850
1879
|
}
|
|
1851
1880
|
if (legacyMarked && !legacyForeign) {
|
|
1852
1881
|
const entryUpdated = toDate(entry.completed_at);
|
|
1853
|
-
const legacyNarrativeStale =
|
|
1882
|
+
const legacyNarrativeStale = sessionNotesStale(legacyContent, renderedBody);
|
|
1854
1883
|
if (!force && !legacyStillLeaks && !legacyNarrativeStale && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
|
|
1855
1884
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
|
|
1856
1885
|
}
|
|
@@ -1889,7 +1918,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1889
1918
|
// no disambiguation scheme. Checked before the date comparison; `--force`
|
|
1890
1919
|
// does not bypass it. A leak in a foreign note is masked in place, never
|
|
1891
1920
|
// rewritten from this record.
|
|
1892
|
-
const sameSession = fm
|
|
1921
|
+
const sameSession = isOwnSessionId(fm);
|
|
1893
1922
|
const foreign = foreignOwnerReason(fm, writer) ?? (sameSession ? null : 'id mismatch');
|
|
1894
1923
|
if (foreign !== null) {
|
|
1895
1924
|
const healed = existingLeaks && maskForeignNoteInPlace(targetPath, existingContent, ctx);
|
|
@@ -1900,7 +1929,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1900
1929
|
// #1513 review H2: a note rendered before narrative/summary were printed
|
|
1901
1930
|
// carries less free text than the record — heal it once, or the pruner
|
|
1902
1931
|
// archives it as metrics-only and the mirror re-creates it every run.
|
|
1903
|
-
const narrativeStale =
|
|
1932
|
+
const narrativeStale = sessionNotesStale(existingContent, renderedBody);
|
|
1904
1933
|
if (!force && !existingLeaks && !narrativeStale && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
1905
1934
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
|
|
1906
1935
|
}
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
import { createHash } from 'node:crypto';
|
|
55
55
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
56
56
|
|
|
57
|
-
import { parseFrontmatter, subjectToSlug } from './utils.mjs';
|
|
57
|
+
import { parseFrontmatter, subjectToSlug, noteIdMatches } from './utils.mjs';
|
|
58
58
|
import {
|
|
59
59
|
GENERATOR_MARKER,
|
|
60
60
|
ROLLUP_PREFIX,
|
|
@@ -287,7 +287,10 @@ export function planPrune({ vaultDir, today, minNarrativeChars = 400, aliases =
|
|
|
287
287
|
const target = `${n.zone}/${n.effNs}/${n.name}`;
|
|
288
288
|
if (claimed.has(target) || existingRel.has(target)) {
|
|
289
289
|
const twin = notes.find((m) => m.rel === target);
|
|
290
|
-
|
|
290
|
+
// #1601/#1602: the twin may already carry the namespaced id while the flat
|
|
291
|
+
// note keeps the bare pre-#1601 one — same note, either form.
|
|
292
|
+
const sameId = twin && (twin.fm.id === n.fm.id || noteIdMatches(twin.fm.id, n.fm.id, n.effNs) || noteIdMatches(n.fm.id, twin.fm.id, n.effNs));
|
|
293
|
+
if (sameId && !decided.has(twin.rel)) {
|
|
291
294
|
actions.push({ path: n.rel, reason: 'duplicate', action: 'archive', target: archivePathFor(n.rel), keep: target });
|
|
292
295
|
} else {
|
|
293
296
|
actions.push({ path: n.rel, reason, action: 'keep', note: `relocation target taken: ${target}` });
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* sourceRecordValue, learningSourceRecord, sourceRecordLine (#1503)
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { toDate, truncateAtWord, yamlQuoteIfNeeded, legacySubjectToSlug, isValidSlug, buildTag, resolveSourceSessionLink } from './utils.mjs';
|
|
8
|
+
import { toDate, truncateAtWord, yamlQuoteIfNeeded, legacySubjectToSlug, isValidSlug, buildTag, resolveSourceSessionLink, noteIdFor, yamlNoteId } from './utils.mjs';
|
|
9
9
|
|
|
10
10
|
const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
|
|
11
11
|
|
|
@@ -186,7 +186,7 @@ export function generateLearningNote(entry, slug, opts = {}) {
|
|
|
186
186
|
const sourceSessionBodyLink = _srcIsLink ? `[[${_srcTarget}]]` : _srcTarget;
|
|
187
187
|
|
|
188
188
|
return `---
|
|
189
|
-
id: ${slug}
|
|
189
|
+
id: ${yamlNoteId(noteIdFor(opts.repoNs, slug))}
|
|
190
190
|
type: learning
|
|
191
191
|
title: ${title}
|
|
192
192
|
status: ${status}
|
|
@@ -242,7 +242,7 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
|
|
|
242
242
|
const recordLine = sourceRecordLine(learningSourceRecord(entry));
|
|
243
243
|
|
|
244
244
|
return `---
|
|
245
|
-
id: ${slug}
|
|
245
|
+
id: ${yamlNoteId(noteIdFor(opts.repoNs, slug))}
|
|
246
246
|
type: learning
|
|
247
247
|
title: ${title}
|
|
248
248
|
status: ${status}
|