session-orchestrator 5.11.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 +12 -1
- 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 +180 -3
- package/README.md +126 -117
- 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 +4 -2
- 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 +102 -25
- 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 +55 -21
- package/docs/session-config-template.md +32 -24
- 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 +158 -59
- 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 -3
- 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 +73 -3
- 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/remote-hosts.mjs +2 -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 +3 -3
- 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 +21 -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-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +85 -12
- 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 +174 -0
- package/scripts/lib/so-hud/runtime.mjs +87 -0
- 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 +248 -0
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- 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 +22 -9
- package/scripts/lib/vault-mirror/process.mjs +197 -65
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
- 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 +106 -13
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +34 -5
- 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 +57 -8
- 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 +68 -0
- package/scripts/state-md-idle-reset.mjs +82 -0
- 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/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +166 -3
- 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 +5 -4
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/autopilot/SKILL.md +3 -2
- 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 +97 -13
- 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-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +8 -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 +10 -4
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- 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 +13 -5
- 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/AGENTS.md +0 -207
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -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, 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';
|
|
@@ -19,13 +19,13 @@ import { emitMirrorEvent } from './telemetry.mjs';
|
|
|
19
19
|
import {
|
|
20
20
|
ARCHIVE_ROOT,
|
|
21
21
|
canonicalNamespace,
|
|
22
|
-
legacyConcatSlug,
|
|
23
22
|
noteNarrativeChars,
|
|
24
23
|
markArchived,
|
|
25
24
|
noteInsightKey,
|
|
26
25
|
renderRollup,
|
|
27
26
|
rollupRelPath,
|
|
28
27
|
rollupRowFromNote,
|
|
28
|
+
section,
|
|
29
29
|
sessionNarrativeChars,
|
|
30
30
|
} from './retention.mjs';
|
|
31
31
|
|
|
@@ -269,6 +269,23 @@ export function matchesModuloRedaction(existingVal, renderedVal) {
|
|
|
269
269
|
return new RegExp(`^${pattern}$`).test(renderedVal);
|
|
270
270
|
}
|
|
271
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
|
+
|
|
272
289
|
/**
|
|
273
290
|
* Return true when the existing vault note content and the freshly-rendered
|
|
274
291
|
* candidate share identical canonical fields (i.e. no meaningful update needed).
|
|
@@ -450,17 +467,6 @@ function disambigSlugFor(dir, slug, { legacyId, recordKey }) {
|
|
|
450
467
|
return { slug: hashed, exists: null };
|
|
451
468
|
}
|
|
452
469
|
|
|
453
|
-
/**
|
|
454
|
-
* Replace the frontmatter `id:` line of a note — used when a displaced note
|
|
455
|
-
* moves to its disambiguated name (#1506.2), whose `id` must match the file.
|
|
456
|
-
* @param {string} content
|
|
457
|
-
* @param {string} id
|
|
458
|
-
* @returns {string}
|
|
459
|
-
*/
|
|
460
|
-
function withNoteId(content, id) {
|
|
461
|
-
return content.replace(/^id: .*$/m, `id: ${id}`);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
470
|
/**
|
|
465
471
|
* Emit `skipped-foreign-owner` for a note the writer must not touch: one stderr
|
|
466
472
|
* `SKIP` line (vault-relative path, like every other SKIP line) and the stdout
|
|
@@ -904,7 +910,7 @@ function maskEntrySecrets(entry) {
|
|
|
904
910
|
* @param {'v1'|'v2'} schema
|
|
905
911
|
* @returns {string}
|
|
906
912
|
*/
|
|
907
|
-
function learningSlug(entry, schema) {
|
|
913
|
+
function learningSlug(entry, schema, slugger = learningNameSlugger(entry)) {
|
|
908
914
|
const entryId = entry.id;
|
|
909
915
|
let slugSource;
|
|
910
916
|
if (schema === 'v2') {
|
|
@@ -916,7 +922,7 @@ function learningSlug(entry, schema) {
|
|
|
916
922
|
}
|
|
917
923
|
let slug;
|
|
918
924
|
if (typeof slugSource === 'string' && slugSource.length > 0) {
|
|
919
|
-
slug =
|
|
925
|
+
slug = slugger(slugSource);
|
|
920
926
|
} else {
|
|
921
927
|
slug = '';
|
|
922
928
|
}
|
|
@@ -936,6 +942,62 @@ function learningSlug(entry, schema) {
|
|
|
936
942
|
}
|
|
937
943
|
|
|
938
944
|
|
|
945
|
+
/** A record without representable provenance (v1 or v2) keeps its historical name. */
|
|
946
|
+
function learningNameSlugger(entry) {
|
|
947
|
+
return learningSourceRecord(entry) === null ? legacySubjectToSlug : subjectToSlug;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Compare BEFORE filename capping so a changed concatenated name is protected. */
|
|
951
|
+
function hasLearningSlugTransition(entry, schema) {
|
|
952
|
+
if (learningNameSlugger(entry) === legacySubjectToSlug) return false;
|
|
953
|
+
const source = schema === 'v2' ? entry.id : entry.subject;
|
|
954
|
+
if (typeof source !== 'string' || source.length === 0) return false;
|
|
955
|
+
const normalized = schema === 'v1' ? source.trim().replace(/\s+/g, '-') : source;
|
|
956
|
+
return subjectToSlug(normalized) !== legacySubjectToSlug(normalized) ||
|
|
957
|
+
(schema === 'v1' && subjectToSlug(source) !== legacySubjectToSlug(source));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function historicalConcatSlug(subject) {
|
|
961
|
+
if (typeof subject !== 'string') return '';
|
|
962
|
+
return legacySubjectToSlug(subject);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** All historical names, including both generations of disambiguation suffix. */
|
|
966
|
+
function learningSlugVariants(entry, schema) {
|
|
967
|
+
const bases = new Set([learningSlug(entry, schema), learningSlug(entry, schema, legacySubjectToSlug)]);
|
|
968
|
+
if (schema === 'v1') {
|
|
969
|
+
const concat = historicalConcatSlug(entry.subject);
|
|
970
|
+
if (isValidSlug(concat)) {
|
|
971
|
+
bases.add(concat);
|
|
972
|
+
if (concat.length > 240) bases.add(concat.slice(0, 240).replace(/-+$/, ''));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const names = new Set(bases);
|
|
976
|
+
const id = String(entry.id);
|
|
977
|
+
for (const base of bases) {
|
|
978
|
+
names.add(`${base}-${uuidPrefix8(id)}`);
|
|
979
|
+
names.add(`${base}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`);
|
|
980
|
+
}
|
|
981
|
+
return [...names];
|
|
982
|
+
}
|
|
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
|
+
|
|
994
|
+
/** Only explicit, exact provenance permits assigning an old name to a record. */
|
|
995
|
+
function positiveLearningOwner(fm, name, repoNs, recordKey) {
|
|
996
|
+
return recordKey !== null && notOurGenerator(fm) === null &&
|
|
997
|
+
fmNamesSlug(fm, name, repoNs) && fm['source-record'] === recordKey && fm['source-repo'] === repoNs;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
|
|
939
1001
|
export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
940
1002
|
const {
|
|
941
1003
|
vaultDir,
|
|
@@ -1023,10 +1085,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1023
1085
|
// #1513 review M2: a hyphen alias (`gotzendorferv2`) resolves to the
|
|
1024
1086
|
// canonical folder already in the vault (`gotzendorfer-v2`), so the mirror
|
|
1025
1087
|
// and the pruner agree on where a repo's notes live.
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
1029
|
-
);
|
|
1088
|
+
const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
|
|
1089
|
+
const repoNs = canonicalNamespace(vaultDir, stableNs);
|
|
1030
1090
|
// #725 D2: thread the resolved repo namespace into the learning frontmatter as
|
|
1031
1091
|
// `source-repo` for cross-repo attribution. repoNs is already sanitised +
|
|
1032
1092
|
// leak-guarded by resolveRepoNamespace, so it is safe to interpolate as-is. The
|
|
@@ -1075,18 +1135,56 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1075
1135
|
return null;
|
|
1076
1136
|
};
|
|
1077
1137
|
const targetDir = join(resolve(vaultDir), '40-learnings', repoNs);
|
|
1078
|
-
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
1079
1138
|
|
|
1080
1139
|
let targetPath = join(targetDir, `${slug}.md`);
|
|
1081
1140
|
|
|
1141
|
+
// #1548: inspect EVERY candidate before mutation when normalization changes
|
|
1142
|
+
// a name. Missing/foreign/ambiguous provenance is not permission to create a
|
|
1143
|
+
// second note, including under --force. Keep the actual flat or namespace path.
|
|
1144
|
+
const slugTransition = hasLearningSlugTransition(entry, schema);
|
|
1145
|
+
if (slugTransition) {
|
|
1146
|
+
const matches = [];
|
|
1147
|
+
for (const dir of [targetDir, join(resolve(vaultDir), '40-learnings')]) {
|
|
1148
|
+
for (const name of learningSlugVariants(entry, schema)) {
|
|
1149
|
+
const path = join(dir, `${name}.md`);
|
|
1150
|
+
if (!existsSync(path)) continue;
|
|
1151
|
+
const fm = parseFrontmatter(readFileSync(path, 'utf8'));
|
|
1152
|
+
matches.push({ path, name, valid: positiveLearningOwner(fm, name, repoNs, writer.recordKey) });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (matches.length > 1 || matches.some((match) => !match.valid)) {
|
|
1156
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1157
|
+
action: 'skipped-foreign-owner', path: null, id: entryId,
|
|
1158
|
+
meta: { reason: 'slug transition: ambiguous or missing/contradictory provenance' },
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
if (matches.length === 1) {
|
|
1162
|
+
slug = matches[0].name;
|
|
1163
|
+
targetPath = matches[0].path;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// A provenance-less flat v2 note cannot be assigned to this namespace.
|
|
1168
|
+
// Keep it untouched rather than creating a second copy on an advanced date.
|
|
1169
|
+
if (schema === 'v2' && writer.recordKey === null &&
|
|
1170
|
+
learningSlugVariants(entry, schema).some((name) =>
|
|
1171
|
+
existsSync(join(resolve(vaultDir), '40-learnings', name + '.md')))) {
|
|
1172
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1173
|
+
action: 'skipped-foreign-owner', path: null, id: entryId,
|
|
1174
|
+
meta: { reason: 'flat note: missing representable source provenance' },
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
1179
|
+
|
|
1082
1180
|
// #1513 LEGACY SLUG: until #725 D1 a v1 subject was slugged WITHOUT the
|
|
1083
1181
|
// whitespace → hyphen pre-map ("3parallelimplagents…"); the canonical form
|
|
1084
1182
|
// hyphenates ("3-parallel-impl-agents…"). When only the legacy variant of
|
|
1085
1183
|
// THIS record's note exists, it IS the note: it is updated in place under
|
|
1086
1184
|
// its own name instead of a second file being created beside it (the vault
|
|
1087
1185
|
// carried 364 such twins). New notes always get the canonical name.
|
|
1088
|
-
if (schema === 'v1') {
|
|
1089
|
-
const legacySlug =
|
|
1186
|
+
if (!slugTransition && schema === 'v1') {
|
|
1187
|
+
const legacySlug = historicalConcatSlug(entry.subject);
|
|
1090
1188
|
const flatDir = join(resolve(vaultDir), '40-learnings');
|
|
1091
1189
|
if (
|
|
1092
1190
|
isValidSlug(legacySlug) &&
|
|
@@ -1100,11 +1198,13 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1100
1198
|
const legacyFm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
|
|
1101
1199
|
if (
|
|
1102
1200
|
notOurGenerator(legacyFm) === null &&
|
|
1103
|
-
legacyFm
|
|
1201
|
+
fmNamesSlug(legacyFm, legacySlug, repoNs) &&
|
|
1104
1202
|
foreignOwnerReason(legacyFm, writer) === null
|
|
1105
1203
|
) {
|
|
1106
1204
|
slug = legacySlug;
|
|
1107
|
-
targetPath =
|
|
1205
|
+
targetPath = positiveLearningOwner(legacyFm, legacySlug, repoNs, writer.recordKey)
|
|
1206
|
+
? legacyPath
|
|
1207
|
+
: join(targetDir, `${legacySlug}.md`);
|
|
1108
1208
|
break;
|
|
1109
1209
|
}
|
|
1110
1210
|
}
|
|
@@ -1114,8 +1214,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1114
1214
|
// #660 IDEMPOTENCY DUAL-PROBE: before treating the namespaced path as absent,
|
|
1115
1215
|
// also check the legacy flat path. If a note with the same slug already exists
|
|
1116
1216
|
// in the flat layout (pre-namespace migration), treat it as existing to avoid
|
|
1117
|
-
// duplicating the note.
|
|
1118
|
-
//
|
|
1217
|
+
// duplicating the note. Positive provenance also permits an update at the
|
|
1218
|
+
// actual flat path; notes without that evidence keep their historical rules.
|
|
1119
1219
|
const legacyFlatPath = join(resolve(vaultDir), '40-learnings', `${slug}.md`);
|
|
1120
1220
|
// Set when a still-leaking legacy flat note was healed in place below, so the
|
|
1121
1221
|
// action emitted for THIS entry can say so (see the write sites at the end).
|
|
@@ -1137,12 +1237,16 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1137
1237
|
// neither a duplicate to skip on nor a file to rewrite with THIS record's
|
|
1138
1238
|
// content; this entry proceeds to its own namespaced path (a different
|
|
1139
1239
|
// file). A leak in it is masked in place, keeping its owner and content.
|
|
1140
|
-
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm
|
|
1240
|
+
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && fmNamesSlug(legacyFm, slug, repoNs);
|
|
1141
1241
|
const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
|
|
1142
1242
|
if (legacyForeign && legacyStillLeaks) {
|
|
1143
1243
|
healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
|
|
1144
1244
|
}
|
|
1145
|
-
if (
|
|
1245
|
+
if (positiveLearningOwner(legacyFm, slug, repoNs, writer.recordKey)) {
|
|
1246
|
+
// A proven flat note is the update target. Let the common existing-note
|
|
1247
|
+
// branch below compare/render/heal it ONCE; never pre-write a healed copy.
|
|
1248
|
+
targetPath = legacyFlatPath;
|
|
1249
|
+
} else if (legacyMarked && !legacyForeign) {
|
|
1146
1250
|
const entryUpdated = toDate(dateSource);
|
|
1147
1251
|
if (!force && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
|
|
1148
1252
|
// Date has not advanced — but content may have changed (confidence, insight, etc.).
|
|
@@ -1152,6 +1256,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1152
1256
|
// #1028 residue 1: the five-field compare cannot see a raw secret
|
|
1153
1257
|
// sitting in a non-canonical field (e.g. `evidence`) — the
|
|
1154
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.
|
|
1155
1262
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: slug });
|
|
1156
1263
|
}
|
|
1157
1264
|
// Content differs, or the on-disk note still leaks under the current
|
|
@@ -1220,7 +1327,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1220
1327
|
const entryDay = toDate(dateSource);
|
|
1221
1328
|
if (
|
|
1222
1329
|
routedByRecord &&
|
|
1223
|
-
fm
|
|
1330
|
+
fmNamesSlug(fm, slug, repoNs) &&
|
|
1224
1331
|
!expired &&
|
|
1225
1332
|
/^\d{4}-\d{2}-\d{2}$/.test(entryDay) &&
|
|
1226
1333
|
typeof fm['created'] === 'string' &&
|
|
@@ -1233,7 +1340,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1233
1340
|
if (takenOver !== null) return takenOver;
|
|
1234
1341
|
}
|
|
1235
1342
|
|
|
1236
|
-
if (fm
|
|
1343
|
+
if (!fmNamesSlug(fm, slug, repoNs) || routedByRecord) {
|
|
1237
1344
|
// Different id (or different record) → collision: disambiguate
|
|
1238
1345
|
const picked = disambigSlugFor(targetDir, slug, {
|
|
1239
1346
|
legacyId: String(entryId),
|
|
@@ -1313,8 +1420,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1313
1420
|
|
|
1314
1421
|
// #1506.2: the main note is this record's — a live disambiguation copy of
|
|
1315
1422
|
// the same record (left by an interrupted takeover) is superseded.
|
|
1316
|
-
const stale = fm['source-record'] === writer.recordKey
|
|
1317
|
-
? staleOwnDisambig(targetDir, slug, entryId, writer)
|
|
1423
|
+
const stale = !slugTransition && fm['source-record'] === writer.recordKey
|
|
1424
|
+
? staleOwnDisambig(targetDir, slug, entryId, writer, repoNs)
|
|
1318
1425
|
: null;
|
|
1319
1426
|
|
|
1320
1427
|
// Same id: check if updated would advance (unless --force overrides).
|
|
@@ -1327,6 +1434,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1327
1434
|
if (learningContentMatches(existingContent, candidateContent)) {
|
|
1328
1435
|
// #1028 residue 1: probe the on-disk content against the CURRENT masker
|
|
1329
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.
|
|
1330
1440
|
mainUnchanged = !maskerWouldChange(existingContent);
|
|
1331
1441
|
}
|
|
1332
1442
|
// Content differs, or the on-disk note still leaks under the current env —
|
|
@@ -1351,14 +1461,19 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1351
1461
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
|
|
1352
1462
|
}
|
|
1353
1463
|
|
|
1354
|
-
// Overwrite with advanced updated date (or forced re-render)
|
|
1464
|
+
// Overwrite with advanced updated date (or forced re-render). A retained
|
|
1465
|
+
// flat note, including an old Unicode slug, is healed by this single write.
|
|
1355
1466
|
const content = generator(entry, slug, generatorOpts);
|
|
1467
|
+
healedLegacyFlat = !dryRun && targetPath === legacyFlatPath && maskerWouldChange(existingContent);
|
|
1356
1468
|
if (!dryRun) writeFileSync(targetPath, content, 'utf8');
|
|
1357
1469
|
return emitEntryAction(_lineNum, ctx, {
|
|
1358
1470
|
action: 'updated',
|
|
1359
1471
|
path: targetPath,
|
|
1360
1472
|
id: slug,
|
|
1361
|
-
...(stale !== null ? { meta: {
|
|
1473
|
+
...((stale !== null || healedLegacyFlat) ? { meta: {
|
|
1474
|
+
...(stale !== null ? { superseded_id: basename(stale.path, '.md') } : {}),
|
|
1475
|
+
...(healedLegacyFlat ? { healed_legacy_flat: true } : {}),
|
|
1476
|
+
} } : {}),
|
|
1362
1477
|
});
|
|
1363
1478
|
}
|
|
1364
1479
|
|
|
@@ -1388,14 +1503,14 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
|
|
|
1388
1503
|
*
|
|
1389
1504
|
* @returns {{ path: string, next: string }|null} the rewrite, or null.
|
|
1390
1505
|
*/
|
|
1391
|
-
function staleOwnDisambig(targetDir, slug, entryId, writer) {
|
|
1506
|
+
function staleOwnDisambig(targetDir, slug, entryId, writer, repoNs) {
|
|
1392
1507
|
if (typeof writer.recordKey !== 'string') return null;
|
|
1393
1508
|
const own = disambigSlugFor(targetDir, slug, { legacyId: String(entryId), recordKey: writer.recordKey });
|
|
1394
1509
|
const path = join(targetDir, `${own.slug}.md`);
|
|
1395
1510
|
if (!(own.exists ?? existsSync(path))) return null;
|
|
1396
1511
|
const content = readFileSync(path, 'utf8');
|
|
1397
1512
|
const fm = parseFrontmatter(content);
|
|
1398
|
-
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;
|
|
1399
1514
|
const next = markArchived(content, 'superseded');
|
|
1400
1515
|
return next === content ? null : { path, next };
|
|
1401
1516
|
}
|
|
@@ -1425,14 +1540,14 @@ function takeOverMainNote({
|
|
|
1425
1540
|
if (notOurGenerator(dfm) !== null || dfm['source-record'] !== displacedKey) return null;
|
|
1426
1541
|
}
|
|
1427
1542
|
|
|
1428
|
-
const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer);
|
|
1543
|
+
const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer, generatorOpts.repoNs);
|
|
1429
1544
|
|
|
1430
1545
|
if (!dryRun) {
|
|
1431
1546
|
// Re-read: an in-place leak heal above may have rewritten the note.
|
|
1432
1547
|
const current = readFileSync(targetPath, 'utf8');
|
|
1433
1548
|
// Rewritten even when it exists (and is ours, checked above): a stale copy
|
|
1434
1549
|
// there may carry `status: archived` from an earlier archive pass.
|
|
1435
|
-
writeFileSync(displacedPath, withNoteId(current, displaced.slug), 'utf8');
|
|
1550
|
+
writeFileSync(displacedPath, withNoteId(current, noteIdFor(generatorOpts.repoNs, displaced.slug)), 'utf8');
|
|
1436
1551
|
// Before the main note: a crash between the two writes must leave the
|
|
1437
1552
|
// takeover re-runnable (the main note still foreign), never a main note of
|
|
1438
1553
|
// ours beside a stale live disambiguation note of the same record.
|
|
@@ -1465,17 +1580,7 @@ function takeOverMainNote({
|
|
|
1465
1580
|
export function learningNoteSlugs(rawEntry) {
|
|
1466
1581
|
const entry = normalizeLearningEntry(rawEntry);
|
|
1467
1582
|
if (!entry || entry.id === null || entry.id === undefined) return [];
|
|
1468
|
-
|
|
1469
|
-
const slugs = [learningSlug(entry, schema)];
|
|
1470
|
-
if (schema === 'v1') {
|
|
1471
|
-
const legacy = legacyConcatSlug(entry.subject);
|
|
1472
|
-
if (isValidSlug(legacy) && !slugs.includes(legacy)) slugs.push(legacy);
|
|
1473
|
-
}
|
|
1474
|
-
const id = String(entry.id);
|
|
1475
|
-
for (const name of [`${slugs[0]}-${uuidPrefix8(id)}`, `${slugs[0]}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`]) {
|
|
1476
|
-
if (!slugs.includes(name)) slugs.push(name);
|
|
1477
|
-
}
|
|
1478
|
-
return slugs;
|
|
1583
|
+
return learningSlugVariants(entry, detectLearningSchema(entry));
|
|
1479
1584
|
}
|
|
1480
1585
|
|
|
1481
1586
|
/**
|
|
@@ -1515,20 +1620,43 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
|
|
|
1515
1620
|
meta: { reason: 'archived record: a live record owns this note' },
|
|
1516
1621
|
});
|
|
1517
1622
|
if (names.length === 0) return liveOwned();
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
1521
|
-
);
|
|
1623
|
+
const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
|
|
1624
|
+
const repoNs = canonicalNamespace(vaultDir, stableNs);
|
|
1522
1625
|
const nsDir = join(resolve(vaultDir), '40-learnings', repoNs);
|
|
1523
1626
|
const flatDir = join(resolve(vaultDir), '40-learnings');
|
|
1524
1627
|
const writer = { recordKey: learningSourceRecord(entry) };
|
|
1628
|
+
const schema = detectLearningSchema(entry);
|
|
1629
|
+
if (schema === 'v2' && writer.recordKey === null &&
|
|
1630
|
+
allNames.some((name) => existsSync(join(flatDir, name + '.md')))) {
|
|
1631
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1632
|
+
action: 'skipped-foreign-owner', path: null, id: entry.id,
|
|
1633
|
+
meta: { reason: 'flat note: missing representable source provenance' },
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
if (hasLearningSlugTransition(entry, schema)) {
|
|
1637
|
+
const matches = [];
|
|
1638
|
+
for (const dir of [nsDir, flatDir]) {
|
|
1639
|
+
for (const name of allNames) {
|
|
1640
|
+
const path = join(dir, `${name}.md`);
|
|
1641
|
+
if (!existsSync(path)) continue;
|
|
1642
|
+
const fm = parseFrontmatter(readFileSync(path, 'utf8'));
|
|
1643
|
+
matches.push(positiveLearningOwner(fm, name, repoNs, writer.recordKey));
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
if (matches.length > 1 || matches.some((valid) => !valid)) {
|
|
1647
|
+
return emitEntryAction(_lineNum, ctx, {
|
|
1648
|
+
action: 'skipped-foreign-owner', path: null, id: entry.id,
|
|
1649
|
+
meta: { reason: 'slug transition: ambiguous or missing/contradictory provenance' },
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1525
1653
|
for (const dir of [nsDir, flatDir]) {
|
|
1526
1654
|
for (const name of names) {
|
|
1527
1655
|
const path = join(dir, `${name}.md`);
|
|
1528
1656
|
if (!existsSync(path)) continue;
|
|
1529
1657
|
const content = readFileSync(path, 'utf8');
|
|
1530
1658
|
const fm = parseFrontmatter(content);
|
|
1531
|
-
if (notOurGenerator(fm) !== null || fm
|
|
1659
|
+
if (notOurGenerator(fm) !== null || !fmNamesSlug(fm, name, repoNs)) continue;
|
|
1532
1660
|
if (foreignOwnerReason(fm, writer) !== null) continue;
|
|
1533
1661
|
const next = markArchived(content, reason);
|
|
1534
1662
|
if (next === content) {
|
|
@@ -1615,8 +1743,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1615
1743
|
const generator =
|
|
1616
1744
|
schema === 'v3' ? generateSessionNoteV3 : schema === 'v2' ? generateSessionNoteV2 : generateSessionNote;
|
|
1617
1745
|
|
|
1618
|
-
// Validate session_id as a filesystem-safe slug;
|
|
1619
|
-
//
|
|
1746
|
+
// Validate session_id as a filesystem-safe slug; preserve old Session identity
|
|
1747
|
+
// with the historical sanitizer (collapse slash paths + strip invalid chars), before
|
|
1620
1748
|
// resorting to a uuid-derived slug. Without subjectToSlug, raw slashes in
|
|
1621
1749
|
// rawSessionId (e.g. "feat/opus-4-7-...") would survive uuidPrefix8 and
|
|
1622
1750
|
// produce a path with directory separators in the basename.
|
|
@@ -1624,7 +1752,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1624
1752
|
if (isValidSlug(rawSessionId)) {
|
|
1625
1753
|
session_id = rawSessionId;
|
|
1626
1754
|
} else if (typeof rawSessionId === 'string' && rawSessionId.length > 0) {
|
|
1627
|
-
const sanitised =
|
|
1755
|
+
const sanitised = legacySubjectToSlug(rawSessionId);
|
|
1628
1756
|
session_id = isValidSlug(sanitised) ? sanitised : `session-${uuidPrefix8(rawSessionId)}`;
|
|
1629
1757
|
} else {
|
|
1630
1758
|
session_id = `session-${uuidPrefix8(randomUUID())}`;
|
|
@@ -1671,10 +1799,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1671
1799
|
// owner-leaky repo's real name reached the vault through the session-note
|
|
1672
1800
|
// frontmatter even though the directory AND the learning `source-repo` field
|
|
1673
1801
|
// were already pseudonym-mapped/redacted (#732 leak-guard bypass).
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
|
|
1677
|
-
);
|
|
1802
|
+
const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
|
|
1803
|
+
const repoNs = canonicalNamespace(vaultDir, stableNs);
|
|
1678
1804
|
|
|
1679
1805
|
// Narrative gate (#1513, replaces the PRD F1.2 rendered-length gate). A
|
|
1680
1806
|
// session gets its own note only when its FREE-TEXT fields (`notes`,
|
|
@@ -1714,6 +1840,12 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1714
1840
|
// (`subjectToSlug`), caps at 240 chars, or falls back to a uuid slug. A note
|
|
1715
1841
|
// carrying either id is this record's own note.
|
|
1716
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);
|
|
1717
1849
|
|
|
1718
1850
|
// Canonical filename pattern (issue #343): `<session_id>.md` where session_id
|
|
1719
1851
|
// follows `<branch>-<YYYY-MM-DD>-<HHmm>-<slug>` per the session-id schema.
|
|
@@ -1740,14 +1872,14 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1740
1872
|
const legacyStillLeaks = legacyFm !== null && maskerWouldChange(legacyContent);
|
|
1741
1873
|
// #1503: a flat note from a foreign record is not ours — see processLearning's
|
|
1742
1874
|
// dual-probe. A leak in it is masked in place, keeping its owner and content.
|
|
1743
|
-
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm
|
|
1875
|
+
const legacyMarked = Boolean(legacyFm) && legacyFm['_generator'] === GENERATOR_MARKER && isOwnSessionId(legacyFm);
|
|
1744
1876
|
const legacyForeign = legacyMarked && foreignOwnerReason(legacyFm, writer) !== null;
|
|
1745
1877
|
if (legacyForeign && legacyStillLeaks) {
|
|
1746
1878
|
healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
|
|
1747
1879
|
}
|
|
1748
1880
|
if (legacyMarked && !legacyForeign) {
|
|
1749
1881
|
const entryUpdated = toDate(entry.completed_at);
|
|
1750
|
-
const legacyNarrativeStale =
|
|
1882
|
+
const legacyNarrativeStale = sessionNotesStale(legacyContent, renderedBody);
|
|
1751
1883
|
if (!force && !legacyStillLeaks && !legacyNarrativeStale && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
|
|
1752
1884
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
|
|
1753
1885
|
}
|
|
@@ -1786,7 +1918,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1786
1918
|
// no disambiguation scheme. Checked before the date comparison; `--force`
|
|
1787
1919
|
// does not bypass it. A leak in a foreign note is masked in place, never
|
|
1788
1920
|
// rewritten from this record.
|
|
1789
|
-
const sameSession = fm
|
|
1921
|
+
const sameSession = isOwnSessionId(fm);
|
|
1790
1922
|
const foreign = foreignOwnerReason(fm, writer) ?? (sameSession ? null : 'id mismatch');
|
|
1791
1923
|
if (foreign !== null) {
|
|
1792
1924
|
const healed = existingLeaks && maskForeignNoteInPlace(targetPath, existingContent, ctx);
|
|
@@ -1797,7 +1929,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
|
|
|
1797
1929
|
// #1513 review H2: a note rendered before narrative/summary were printed
|
|
1798
1930
|
// carries less free text than the record — heal it once, or the pruner
|
|
1799
1931
|
// archives it as metrics-only and the mirror re-creates it every run.
|
|
1800
|
-
const narrativeStale =
|
|
1932
|
+
const narrativeStale = sessionNotesStale(existingContent, renderedBody);
|
|
1801
1933
|
if (!force && !existingLeaks && !narrativeStale && fm['updated'] && fm['updated'] >= entryUpdated) {
|
|
1802
1934
|
return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
|
|
1803
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,
|
|
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
|
|
|
@@ -118,9 +118,10 @@ export function normalizeLearningEntry(entry) {
|
|
|
118
118
|
e.evidence = joinList(e.evidence_sessions) ?? joinList(e.files) ?? joinList(e.sessions) ?? '(none recorded)';
|
|
119
119
|
}
|
|
120
120
|
if (e.id === null || e.id === undefined) {
|
|
121
|
-
//
|
|
121
|
+
// Keep historical ASCII deletion for record identity; only note names
|
|
122
|
+
// transliterate. The old slugger strips whitespace (designed for
|
|
122
123
|
// kebab-ish inputs) — pre-map whitespace to hyphens for prose subjects.
|
|
123
|
-
const derived = firstString(e.subject) ?
|
|
124
|
+
const derived = firstString(e.subject) ? legacySubjectToSlug(e.subject.trim().replace(/\s+/g, '-')) : '';
|
|
124
125
|
if (isValidSlug(derived)) e.id = derived;
|
|
125
126
|
}
|
|
126
127
|
if (e.source_session === null || e.source_session === undefined) {
|
|
@@ -157,7 +158,7 @@ export function generateLearningNote(entry, slug, opts = {}) {
|
|
|
157
158
|
// through buildTag so each is kebab-slugified and capped at 64 chars. The
|
|
158
159
|
// existing source/<session> slug is preserved (buildTag is idempotent for an
|
|
159
160
|
// already-kebab value and adds the length cap).
|
|
160
|
-
const sourceTag =
|
|
161
|
+
const sourceTag = legacySubjectToSlug(String(source_session)) || 'unknown';
|
|
161
162
|
const tags = `[${buildTag(['learning', type])}, ${buildTag(['status', status])}, ${buildTag(['source', sourceTag])}]`;
|
|
162
163
|
|
|
163
164
|
// Check if expires has a value; it's optional in schema
|
|
@@ -185,7 +186,7 @@ export function generateLearningNote(entry, slug, opts = {}) {
|
|
|
185
186
|
const sourceSessionBodyLink = _srcIsLink ? `[[${_srcTarget}]]` : _srcTarget;
|
|
186
187
|
|
|
187
188
|
return `---
|
|
188
|
-
id: ${slug}
|
|
189
|
+
id: ${yamlNoteId(noteIdFor(opts.repoNs, slug))}
|
|
189
190
|
type: learning
|
|
190
191
|
title: ${title}
|
|
191
192
|
status: ${status}
|
|
@@ -230,7 +231,7 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
|
|
|
230
231
|
const title = yamlQuoteIfNeeded(titleRaw);
|
|
231
232
|
|
|
232
233
|
// #602: type/status interpolated raw upstream — slugify + cap each segment.
|
|
233
|
-
const scopeTag =
|
|
234
|
+
const scopeTag = legacySubjectToSlug(scope) || 'unscoped';
|
|
234
235
|
const tags = `[${buildTag(['learning', type])}, ${buildTag(['status', status])}, ${buildTag(['scope', scopeTag])}]`;
|
|
235
236
|
|
|
236
237
|
// #725 D2: emit `source-repo` for cross-repo attribution when the caller threads
|
|
@@ -241,7 +242,7 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
|
|
|
241
242
|
const recordLine = sourceRecordLine(learningSourceRecord(entry));
|
|
242
243
|
|
|
243
244
|
return `---
|
|
244
|
-
id: ${slug}
|
|
245
|
+
id: ${yamlNoteId(noteIdFor(opts.repoNs, slug))}
|
|
245
246
|
type: learning
|
|
246
247
|
title: ${title}
|
|
247
248
|
status: ${status}
|