session-orchestrator 3.22.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -32,6 +32,28 @@
|
|
|
32
32
|
* - Lock takes precedence over registry (more detail per session); registry
|
|
33
33
|
* supplements missing per-worktree entries (e.g., when the hook ran but
|
|
34
34
|
* the prose Phase 1.2 acquire was skipped).
|
|
35
|
+
* - A live local session.lock does NOT supersede same-repo registry entries
|
|
36
|
+
* with a different raw session_id (GH#67 candidate fix, measured 2026-09-02
|
|
37
|
+
* and NOT adopted): the session lock is advisory, so a second session can
|
|
38
|
+
* and demonstrably does run in the same working copy without holding it.
|
|
39
|
+
* Filtering those entries out hides genuine same-working-copy peers — the
|
|
40
|
+
* #1085 semantic-alias boundary (tests/integration/session-identity-
|
|
41
|
+
* boundaries.test.mjs) and 7 peer-discovery cases pin exactly that
|
|
42
|
+
* visibility. Any GH#67 fix must discriminate finished-but-fresh entries
|
|
43
|
+
* some other way; do not re-attempt lock-ownership supersession here.
|
|
44
|
+
* See ADR-0014 (docs/adr/0014-peer-visibility-under-advisory-lock.md) for
|
|
45
|
+
* the annotate-never-filter decision and the 9-test refutation of the
|
|
46
|
+
* filtering alternative.
|
|
47
|
+
* - The adopted GH#67 shape is therefore ADDITIVE ANNOTATION, never a filter.
|
|
48
|
+
* Registry-sourced sessions carry `registryOnly: true` plus
|
|
49
|
+
* `lockSuperseded` / `lockOwnerId`. `lockSuperseded: true` means: a LIVE
|
|
50
|
+
* lock at this repoRoot is owned by a different raw session_id than this
|
|
51
|
+
* registry entry. It is a HINT, not a verdict — the lock is advisory, so
|
|
52
|
+
* the entry may still be a live session that lost the acquire race (#1085
|
|
53
|
+
* contract). Consumers deciding a worktree-PROMOTION_OFFER downgrade such
|
|
54
|
+
* a peer to an advisory line (GH#67); consumers counting or displaying
|
|
55
|
+
* peers keep it. Lock-sourced sessions carry none of the three fields, so
|
|
56
|
+
* their objects stay byte-identical to the pre-GH#67 shape.
|
|
35
57
|
*
|
|
36
58
|
* Timeout + A1 fallback:
|
|
37
59
|
* - listWorktrees() is raced against DEFAULT_DISCOVERY_TIMEOUT_MS (2 s).
|
|
@@ -46,6 +68,7 @@ import os from 'node:os';
|
|
|
46
68
|
import { listWorktrees } from './worktree/listing.mjs';
|
|
47
69
|
import { readLock, isLockLive } from './session-lock.mjs';
|
|
48
70
|
import { readRegistry, repoPathHash, isRegistryEntryFresh } from './session-registry.mjs';
|
|
71
|
+
import { stableHostname } from './host-identity.mjs';
|
|
49
72
|
|
|
50
73
|
// ---------------------------------------------------------------------------
|
|
51
74
|
// Constants
|
|
@@ -66,7 +89,7 @@ export const DEFAULT_DISCOVERY_TIMEOUT_MS = 2000;
|
|
|
66
89
|
* @param {object} lock Parsed lock body (schema v2).
|
|
67
90
|
* @param {string} worktreePath Absolute worktree path.
|
|
68
91
|
* @param {string} [branch] Branch from worktree object (falsy → '').
|
|
69
|
-
* @returns {{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}}
|
|
92
|
+
* @returns {{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string}}
|
|
70
93
|
*/
|
|
71
94
|
function sessionFromLock(lock, worktreePath, branch = '') {
|
|
72
95
|
return {
|
|
@@ -76,6 +99,12 @@ function sessionFromLock(lock, worktreePath, branch = '') {
|
|
|
76
99
|
startedAt: lock.started_at,
|
|
77
100
|
pid: lock.pid,
|
|
78
101
|
host: lock.host,
|
|
102
|
+
// Additive normalised twin (#1072), same field the registry path emits —
|
|
103
|
+
// without it the two discovery sources returned DIFFERENT shapes, and a
|
|
104
|
+
// consumer comparing hosts silently fell back to the raw `host` for
|
|
105
|
+
// lock-sourced sessions only. Locks written before #1072 carry no
|
|
106
|
+
// `host_id`, so derive it from the raw name rather than emitting undefined.
|
|
107
|
+
host_id: lock.host_id ?? stableHostname(lock.host),
|
|
79
108
|
branch: typeof branch === 'string' ? branch : '',
|
|
80
109
|
};
|
|
81
110
|
}
|
|
@@ -86,10 +115,24 @@ function sessionFromLock(lock, worktreePath, branch = '') {
|
|
|
86
115
|
* does not record per-worktree paths — only repo-path-hashes. Branch is
|
|
87
116
|
* passed through from the registry entry when available.
|
|
88
117
|
*
|
|
118
|
+
* GH#67 annotation (additive, never a filter — see the module header): every
|
|
119
|
+
* registry-sourced session carries `registryOnly: true` and the pair
|
|
120
|
+
* `lockSuperseded` / `lockOwnerId`. `lockSuperseded: true` says a LIVE lock at
|
|
121
|
+
* this repoRoot is owned by a DIFFERENT raw session_id than this entry — a
|
|
122
|
+
* HINT that the entry may be a finished-but-still-fresh task (the GH#67 case:
|
|
123
|
+
* Codex emits no SessionEnd, so the registry entry outlives the task by up to
|
|
124
|
+
* `freshnessMin`). It is not a verdict: the lock is advisory, so the entry may
|
|
125
|
+
* equally be a live session that never acquired it (#1085). Consumers deciding
|
|
126
|
+
* a worktree PROMOTION_OFFER downgrade such a peer to an advisory line;
|
|
127
|
+
* consumers counting or displaying peers keep it unchanged.
|
|
128
|
+
*
|
|
89
129
|
* @param {object} entry Registry entry.
|
|
90
130
|
* @param {string} repoRoot Discovery repoRoot (used as worktreePath fallback).
|
|
131
|
+
* @param {object} [ctx]
|
|
132
|
+
* @param {string|null} [ctx.lockOwnerId] Raw session_id owning a LIVE lock at
|
|
133
|
+
* repoRoot, or null when there is no live local lock.
|
|
91
134
|
*/
|
|
92
|
-
function sessionFromRegistryEntry(entry, repoRoot) {
|
|
135
|
+
function sessionFromRegistryEntry(entry, repoRoot, { lockOwnerId = null } = {}) {
|
|
93
136
|
return {
|
|
94
137
|
worktreePath: repoRoot,
|
|
95
138
|
sessionId: entry.session_id,
|
|
@@ -100,7 +143,15 @@ function sessionFromRegistryEntry(entry, repoRoot) {
|
|
|
100
143
|
// local hostname as the default since registry entries are host-scoped
|
|
101
144
|
// by design (see ~/.config/session-orchestrator/sessions/).
|
|
102
145
|
host: os.hostname(),
|
|
146
|
+
// Additive normalised twin (#1072) — `os.hostname()` is not stable on a
|
|
147
|
+
// single machine, so consumers comparing hosts need the normalised form.
|
|
148
|
+
host_id: stableHostname(),
|
|
103
149
|
branch: typeof entry.branch === 'string' ? entry.branch : '',
|
|
150
|
+
// GH#67 additive annotation (see the JSDoc above). Lock-sourced sessions
|
|
151
|
+
// deliberately carry none of these three fields.
|
|
152
|
+
registryOnly: true,
|
|
153
|
+
lockSuperseded: lockOwnerId !== null && entry.session_id !== lockOwnerId,
|
|
154
|
+
lockOwnerId,
|
|
104
155
|
};
|
|
105
156
|
}
|
|
106
157
|
|
|
@@ -111,7 +162,7 @@ function sessionFromRegistryEntry(entry, repoRoot) {
|
|
|
111
162
|
* Liveness rule: heartbeat freshness via isLockLive (Epic #583, W2-I3).
|
|
112
163
|
*
|
|
113
164
|
* @param {string} repoRoot
|
|
114
|
-
* @returns {Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}>}
|
|
165
|
+
* @returns {Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string}>}
|
|
115
166
|
*/
|
|
116
167
|
function readLocalSession(repoRoot) {
|
|
117
168
|
const lock = readLock({ repoRoot });
|
|
@@ -167,7 +218,10 @@ function dedupeBySessionId(sessions) {
|
|
|
167
218
|
* @param {Function} [opts.registryReader] DI hook replacing readRegistry() for tests.
|
|
168
219
|
* @param {number} [opts.freshnessMin=15] Registry-entry freshness threshold in minutes.
|
|
169
220
|
* @param {number} [opts.now] ms-since-epoch (test seam for heartbeat freshness).
|
|
170
|
-
* @returns {Promise<Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}>>}
|
|
221
|
+
* @returns {Promise<Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string,registryOnly?:boolean,lockSuperseded?:boolean,lockOwnerId?:string|null}>>}
|
|
222
|
+
* The last three fields appear ONLY on registry-sourced sessions (GH#67
|
|
223
|
+
* annotation — see `sessionFromRegistryEntry`); lock-sourced sessions omit
|
|
224
|
+
* them entirely.
|
|
171
225
|
*/
|
|
172
226
|
export async function discoverActiveSessions(repoRoot, opts = {}) {
|
|
173
227
|
const listWorktreesFn = opts.listWorktreesImpl ?? listWorktrees;
|
|
@@ -222,10 +276,20 @@ export async function discoverActiveSessions(repoRoot, opts = {}) {
|
|
|
222
276
|
try {
|
|
223
277
|
const allEntries = await registryReaderFn();
|
|
224
278
|
const myRepoHash = repoPathHash(repoRoot);
|
|
279
|
+
// GH#67: who (if anyone) holds a LIVE lock at this repoRoot right now.
|
|
280
|
+
// Read once, outside the map — it is a property of the repo, not of an
|
|
281
|
+
// entry. A stale lock proves nothing, so it yields null.
|
|
282
|
+
const localLock = readLock({ repoRoot });
|
|
283
|
+
const lockOwnerId = (
|
|
284
|
+
localLock
|
|
285
|
+
&& isLockLive(localLock, nowMs)
|
|
286
|
+
&& typeof localLock.session_id === 'string'
|
|
287
|
+
&& localLock.session_id
|
|
288
|
+
) ? localLock.session_id : null;
|
|
225
289
|
registrySessions = allEntries
|
|
226
290
|
.filter((e) => e && e.repo_path_hash === myRepoHash)
|
|
227
291
|
.filter((e) => isRegistryEntryFresh(e, { freshnessMin, now: nowMs }))
|
|
228
|
-
.map((e) => sessionFromRegistryEntry(e, repoRoot));
|
|
292
|
+
.map((e) => sessionFromRegistryEntry(e, repoRoot, { lockOwnerId }));
|
|
229
293
|
} catch {
|
|
230
294
|
// Registry read failure → keep going with lock-only results.
|
|
231
295
|
registrySessions = [];
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
* ('config-gate' | 'proposals.jsonl' | 'sweep-dry-run' | 'auto-dream-signal' |
|
|
38
38
|
* 'skill-invocations.jsonl' | 'auto-dialectic-signal' | 'reconcile-dry-run' |
|
|
39
39
|
* 'learnings.jsonl' | 'probe-error').
|
|
40
|
+
* @property {string[]} [targets] - 3.6.8 only, RUN decisions only: the effective
|
|
41
|
+
* reconcile write-targets (issue #1099). Additive — absent on every other phase.
|
|
42
|
+
* @property {string|null} [baselineRoot] - 3.6.8 only, RUN decisions only: the
|
|
43
|
+
* resolved projects-baseline root, or null when `baseline` is not in effect.
|
|
40
44
|
*
|
|
41
45
|
* @typedef {Object} TailPlan
|
|
42
46
|
* @property {PhaseDecision[]} plan
|
|
@@ -51,7 +55,7 @@ import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
|
|
|
51
55
|
import { shouldDispatchAutoDream } from '../auto-dream.mjs';
|
|
52
56
|
import { shouldDispatchAutoDialectic } from '../auto-dialectic.mjs';
|
|
53
57
|
import { readSkillInvocations } from '../skill-invocations-schema.mjs';
|
|
54
|
-
import {
|
|
58
|
+
import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile/engine.mjs';
|
|
55
59
|
import { resolveMemoryDir } from '../memory-paths.mjs';
|
|
56
60
|
|
|
57
61
|
// ---------------------------------------------------------------------------
|
|
@@ -178,7 +182,9 @@ async function decideAutoDream({ repoRoot, cfg, platform, memoryDir }) {
|
|
|
178
182
|
if (!isClaudePlatform(platform)) {
|
|
179
183
|
return mkSkip(phase, 'non-Claude-Code platform (memory dir unavailable)', 'config-gate');
|
|
180
184
|
}
|
|
181
|
-
|
|
185
|
+
// #1071: same root as the `repoRoot` handed to shouldDispatchAutoDream below —
|
|
186
|
+
// a cwd-derived memory dir made the two halves of this decision disagree.
|
|
187
|
+
const dir = memoryDir ?? resolveMemoryDir(repoRoot);
|
|
182
188
|
const dec = await shouldDispatchAutoDream({
|
|
183
189
|
repoRoot,
|
|
184
190
|
memoryDir: dir,
|
|
@@ -259,6 +265,14 @@ async function decideAutoDialectic({ repoRoot, cfg }) {
|
|
|
259
265
|
* with `dryRun: true` (SKIPs the candidate-sidecar merge — no write) and the
|
|
260
266
|
* operator confidence-floor delivery gate. 0 proposals above floor → skip.
|
|
261
267
|
* runReconcile never throws; an engine `error` is treated fail-open (run).
|
|
268
|
+
*
|
|
269
|
+
* Target resolution (#1099) happens HERE — the layer where config → host-path
|
|
270
|
+
* resolution already lives — and deliberately only on the RUN branch: a WARN
|
|
271
|
+
* about an unusable baseline root in a session that had nothing to propose
|
|
272
|
+
* anyway would fire on almost every session and teach the operator to ignore it
|
|
273
|
+
* (`.claude/rules/host-resources.md` § HR-101). A `baseline` target that cannot
|
|
274
|
+
* be written is dropped BEFORE the coordinator surfaces the approval AUQ, so the
|
|
275
|
+
* operator is never asked to approve a write to a destination that cannot exist.
|
|
262
276
|
*/
|
|
263
277
|
async function decideReconcile({ repoRoot, cfg }) {
|
|
264
278
|
const phase = '3.6.8';
|
|
@@ -271,7 +285,7 @@ async function decideReconcile({ repoRoot, cfg }) {
|
|
|
271
285
|
if (!existsSync(learningsPath)) {
|
|
272
286
|
return mkSkip(phase, 'learnings.jsonl absent', 'learnings.jsonl');
|
|
273
287
|
}
|
|
274
|
-
const { proposals, summary, error } = await
|
|
288
|
+
const { proposals, summary, error } = await runReconcileFromPhaseSkip({
|
|
275
289
|
repoRoot,
|
|
276
290
|
ruleExpiryDays: cfg?.reconcile?.['rule-expiry-days'] ?? undefined,
|
|
277
291
|
minRuleDays: cfg?.reconcile?.['min-rule-days'] ?? undefined,
|
|
@@ -292,7 +306,26 @@ async function decideReconcile({ repoRoot, cfg }) {
|
|
|
292
306
|
'reconcile-dry-run',
|
|
293
307
|
);
|
|
294
308
|
}
|
|
295
|
-
|
|
309
|
+
const { targets, baselineRoot } = resolveEffectiveTargets({
|
|
310
|
+
targets: cfg?.reconcile?.targets,
|
|
311
|
+
baselineRoot: cfg?.['plan-baseline-path'],
|
|
312
|
+
});
|
|
313
|
+
if (targets.length === 0) {
|
|
314
|
+
return mkSkip(
|
|
315
|
+
phase,
|
|
316
|
+
`${surfaced.length} proposal(s) above floor but no writable target remains`,
|
|
317
|
+
'config-gate',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
...mkRun(
|
|
322
|
+
phase,
|
|
323
|
+
`${surfaced.length} proposal(s) above confidence floor → targets: ${targets.join(', ')}`,
|
|
324
|
+
'reconcile-dry-run',
|
|
325
|
+
),
|
|
326
|
+
targets,
|
|
327
|
+
baselineRoot,
|
|
328
|
+
};
|
|
296
329
|
} catch (err) {
|
|
297
330
|
return mkProbeError(phase, err);
|
|
298
331
|
}
|
|
@@ -332,7 +365,7 @@ export function buildSkippedReport(plan) {
|
|
|
332
365
|
* @param {object} args.config Parsed Session Config object (from parse-config.mjs).
|
|
333
366
|
* @param {string|null} [args.sessionId] Current session id (for the 3.6.6 judged-set filter).
|
|
334
367
|
* @param {string} [args.platform] Platform id ('claude' | 'codex' | 'cursor' | …).
|
|
335
|
-
* @param {string} [args.memoryDir] Optional Auto-Dream memory dir override (default resolveMemoryDir()).
|
|
368
|
+
* @param {string} [args.memoryDir] Optional Auto-Dream memory dir override (default resolveMemoryDir(repoRoot)).
|
|
336
369
|
* @returns {Promise<TailPlan>}
|
|
337
370
|
*/
|
|
338
371
|
export async function planTailPhases({ repoRoot, config, sessionId, platform, memoryDir } = {}) {
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* worktree-cleanup.mjs — Phase 4a Auto-Promoted Worktree Cleanup helpers (#575 P3.2).
|
|
3
3
|
*
|
|
4
4
|
* Public API:
|
|
5
|
-
* - detectAutoPromotedWorktree(repoRoot, sessionId, opts): { wtPath, sessionId, branch } | null
|
|
5
|
+
* - detectAutoPromotedWorktree(repoRoot, sessionId, opts): { wtPath, sessionId, branch, source } | null
|
|
6
6
|
* - isWorktreeClean(wtPath, opts): boolean
|
|
7
7
|
* (opts.execFileFn — injectable execFileSync seam for tests; #577 HARDEN-001)
|
|
8
|
+
* - PROMOTION_MARKER_RELPATH — repo-relative path of the promotion marker
|
|
9
|
+
* written by `enterWorktree()` (SSOT for the file location; the WRITER
|
|
10
|
+
* imports this constant from here, so writer and reader can never drift).
|
|
8
11
|
*
|
|
9
12
|
* Closes #575 — Epic #568 Phase 3.2 (Parallel-Aware Sessions Auto-Promoted Worktree Cleanup)
|
|
10
13
|
* PRD: "Parallel-aware sessions" (#568; archived in the private Meta-Vault) §3 P3 Gherkin rows 2-3
|
|
@@ -21,27 +24,162 @@
|
|
|
21
24
|
* kept divergent on purpose — unifying them would break the sync/async boundary.
|
|
22
25
|
*/
|
|
23
26
|
import path from 'node:path';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
24
28
|
import { execFileSync } from 'node:child_process';
|
|
25
29
|
import { parseSessionId } from '../session-id.mjs';
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Repo-relative location of the promotion marker `enterWorktree()` drops into
|
|
33
|
+
* every worktree it creates. Deliberately inside `.orchestrator/` (the session
|
|
34
|
+
* state dir) and deliberately WITHOUT any absolute path in its payload — the
|
|
35
|
+
* source checkout is recorded as `repoPathHash()` so the file can be committed
|
|
36
|
+
* or shipped without leaking the operator's filesystem layout.
|
|
37
|
+
*
|
|
38
|
+
* @type {string}
|
|
39
|
+
*/
|
|
40
|
+
export const PROMOTION_MARKER_RELPATH = path.join('.orchestrator', 'promoted-from.json');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read + shape-validate the promotion marker of a candidate worktree.
|
|
44
|
+
*
|
|
45
|
+
* Never throws: a missing file, a directory, unreadable permissions, invalid
|
|
46
|
+
* JSON, or a payload of the wrong shape all mean "no marker" (→ legacy path).
|
|
47
|
+
*
|
|
48
|
+
* @param {string} repoRoot
|
|
49
|
+
* @returns {{branch: string, source_session_id: string} & Record<string, unknown> | null}
|
|
50
|
+
*/
|
|
51
|
+
function readPromotionMarker(repoRoot) {
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(readFileSync(path.join(repoRoot, PROMOTION_MARKER_RELPATH), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return null; // absent / unreadable / corrupt JSON — fall back to legacy detection
|
|
57
|
+
}
|
|
58
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
59
|
+
if (typeof parsed.branch !== 'string' || parsed.branch.length === 0) return null;
|
|
60
|
+
if (typeof parsed.source_session_id !== 'string' || parsed.source_session_id.length === 0) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The marker path as git reports it in `status --porcelain` — always
|
|
68
|
+
* forward-slashed, regardless of the host separator.
|
|
69
|
+
* @type {string}
|
|
70
|
+
*/
|
|
71
|
+
const PROMOTION_MARKER_GIT_PATH = PROMOTION_MARKER_RELPATH.split(path.sep).join('/');
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True for the ONE porcelain line the promotion marker itself produces:
|
|
75
|
+
* `?? .orchestrator/promoted-from.json`.
|
|
76
|
+
*
|
|
77
|
+
* Why this exists: in THIS repo `.gitignore` already lists
|
|
78
|
+
* `.orchestrator/promoted-from.json` explicitly (`git check-ignore
|
|
79
|
+
* .orchestrator/promoted-from.json` exits 0), so the marker never reaches
|
|
80
|
+
* `git status --porcelain` here at all — this exemption is redundant
|
|
81
|
+
* belt-and-braces for the repo that ships it. It earns its keep in CONSUMER
|
|
82
|
+
* repos: `.orchestrator/` is commonly only PARTLY gitignored there
|
|
83
|
+
* (`.orchestrator/metrics/*.jsonl`, `session.lock`, … but not the bare
|
|
84
|
+
* directory, and not necessarily this file), and on a worktree whose branch
|
|
85
|
+
* predates that ignore line being added, the marker would show up as an
|
|
86
|
+
* untracked file and make every promoted worktree read "dirty" — turning the
|
|
87
|
+
* Phase 4a clean path (auto-remove) into a permanent operator AUQ. Repos that
|
|
88
|
+
* gitignore `.orchestrator/` wholesale never see the line at all. A third shape exists —
|
|
89
|
+
* the directory untracked AND un-ignored, where git collapses everything into
|
|
90
|
+
* one `?? .orchestrator/` line — and is deliberately NOT matched here:
|
|
91
|
+
* discounting a whole directory could hide operator work, and such a worktree
|
|
92
|
+
* is already dirty from the session's own `session.lock` / `STATE.md` writes
|
|
93
|
+
* regardless of this marker.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately narrow: ONLY the untracked (`??`) status of exactly this path is
|
|
96
|
+
* ignored. A modified, staged, renamed or conflicted marker still counts as
|
|
97
|
+
* dirty, and no other file under `.orchestrator/` is affected — this is our own
|
|
98
|
+
* bookkeeping artefact, not operator work (PSA-003: we created it, so it is
|
|
99
|
+
* ours to discount).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} line - One `git status --porcelain` line.
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function isUntrackedPromotionMarker(line) {
|
|
105
|
+
if (!line.startsWith('?? ')) return false;
|
|
106
|
+
const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1');
|
|
107
|
+
return filePath === PROMOTION_MARKER_GIT_PATH;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Current branch of a worktree, or `null` when it cannot be determined
|
|
112
|
+
* (git error, or a detached HEAD — `git branch --show-current` prints nothing).
|
|
113
|
+
*
|
|
114
|
+
* @param {Function} execFileFn
|
|
115
|
+
* @param {string} repoRoot
|
|
116
|
+
* @returns {string|null}
|
|
117
|
+
*/
|
|
118
|
+
function currentBranchOf(execFileFn, repoRoot) {
|
|
119
|
+
try {
|
|
120
|
+
const out = execFileFn('git', ['-C', repoRoot, 'branch', '--show-current'], {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
});
|
|
123
|
+
const branch = String(out ?? '').trim();
|
|
124
|
+
return branch.length > 0 ? branch : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
27
130
|
/**
|
|
28
131
|
* Detect whether the given repoRoot is an auto-promoted sibling worktree
|
|
29
132
|
* created by `enterWorktree()` during the Phase 0.5 PROMOTION_OFFER path.
|
|
30
133
|
*
|
|
31
|
-
*
|
|
134
|
+
* Two keys, tried in this order:
|
|
135
|
+
*
|
|
136
|
+
* 1. **Marker (primary).** `<repoRoot>/.orchestrator/promoted-from.json`,
|
|
137
|
+
* written by `enterWorktree()` at creation time. Accepted when the file
|
|
138
|
+
* parses, carries `branch` + `source_session_id`, and the worktree's
|
|
139
|
+
* current branch either MATCHES the recorded one or cannot be read at all.
|
|
140
|
+
* This is the only key that survives the #1069 process boundary: since
|
|
141
|
+
* #1069 the session that RUNS in the promoted worktree is a NEW session
|
|
142
|
+
* with its OWN id, and since #1067 the worktree sits on `so/<sourceId>` —
|
|
143
|
+
* so the current session's id appears in neither the directory name nor
|
|
144
|
+
* the branch, and key 2 below can never match. Recording the fact at
|
|
145
|
+
* creation time is what makes it re-derivable later.
|
|
146
|
+
* 2. **Basename (legacy fallback).** `<basePath>/<main-repo-name>-<sessionId>/`
|
|
147
|
+
* against the CURRENT session id — still correct for worktrees created
|
|
148
|
+
* before the marker existed, and for the same-session case.
|
|
32
149
|
*
|
|
33
150
|
* Returns:
|
|
34
|
-
* { wtPath, sessionId, branch } on match
|
|
151
|
+
* { wtPath, sessionId, branch, source: 'marker'|'basename' } on match
|
|
35
152
|
* null on non-match (UUID session, non-promoted path, or git error)
|
|
36
153
|
*
|
|
37
154
|
* @param {string} repoRoot - Absolute path to the candidate worktree
|
|
38
155
|
* @param {string} sessionId - Session ID (semantic or UUID)
|
|
39
|
-
* @returns {{wtPath: string, sessionId: string, branch: string} | null}
|
|
156
|
+
* @returns {{wtPath: string, sessionId: string, branch: string, source: 'marker'|'basename'} | null}
|
|
40
157
|
*/
|
|
41
158
|
export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
|
|
42
159
|
// #577 HARDEN-001: execFileSync + args ARRAY (no shell) is structurally
|
|
43
160
|
// injection-proof — repoRoot can never be interpreted as shell metacharacters.
|
|
44
161
|
const execFileFn = opts.execFileFn ?? execFileSync;
|
|
162
|
+
|
|
163
|
+
// --- Key 1: the marker written at creation time (session-id independent) ---
|
|
164
|
+
const marker = readPromotionMarker(repoRoot);
|
|
165
|
+
if (marker) {
|
|
166
|
+
const current = currentBranchOf(execFileFn, repoRoot);
|
|
167
|
+
// `current === null` (git unavailable / detached HEAD) is accepted: the
|
|
168
|
+
// marker is written by exactly one code path, and the destructive step in
|
|
169
|
+
// Phase 4a is gated separately by `isWorktreeClean()`, which fails CLOSED
|
|
170
|
+
// on any git error. So an unverifiable branch can only ever route the
|
|
171
|
+
// operator into the AUQ, never into an automatic removal.
|
|
172
|
+
if (current === null || current === marker.branch) {
|
|
173
|
+
return {
|
|
174
|
+
wtPath: repoRoot,
|
|
175
|
+
sessionId: marker.source_session_id,
|
|
176
|
+
branch: marker.branch,
|
|
177
|
+
source: 'marker',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- Key 2: legacy basename match against the CURRENT session id ---
|
|
45
183
|
const parsed = parseSessionId(sessionId);
|
|
46
184
|
if (!parsed || parsed.format !== 'semantic') return null; // UUID-format sessions are never auto-promoted
|
|
47
185
|
|
|
@@ -72,7 +210,7 @@ export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
|
|
|
72
210
|
const isPromotedPath = path.basename(repoRoot) === expectedBasename;
|
|
73
211
|
|
|
74
212
|
if (isPromotedPath) {
|
|
75
|
-
return { wtPath: repoRoot, sessionId, branch: parsed.branch };
|
|
213
|
+
return { wtPath: repoRoot, sessionId, branch: parsed.branch, source: 'basename' };
|
|
76
214
|
}
|
|
77
215
|
return null;
|
|
78
216
|
}
|
|
@@ -82,7 +220,9 @@ export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
|
|
|
82
220
|
*
|
|
83
221
|
* A worktree is clean iff ALL three conditions hold:
|
|
84
222
|
* 1. No uncommitted changes (`git status --porcelain` is empty)
|
|
85
|
-
* 2. No untracked files (implicit in #1 — porcelain includes `??` entries)
|
|
223
|
+
* 2. No untracked files (implicit in #1 — porcelain includes `??` entries),
|
|
224
|
+
* with ONE exception: the untracked promotion marker this module's own
|
|
225
|
+
* writer drops into the worktree (see isUntrackedPromotionMarker)
|
|
86
226
|
* 3. No unpushed commits (`git status --short --branch` lacks `ahead`)
|
|
87
227
|
*
|
|
88
228
|
* On any git error, returns `false` (safer per PSA-003 — conservative default
|
|
@@ -98,7 +238,14 @@ export function isWorktreeClean(wtPath, opts = {}) {
|
|
|
98
238
|
const status = execFileFn('git', ['-C', wtPath, 'status', '--porcelain'], {
|
|
99
239
|
encoding: 'utf8',
|
|
100
240
|
});
|
|
101
|
-
|
|
241
|
+
const significant = String(status ?? '')
|
|
242
|
+
.split('\n')
|
|
243
|
+
.map((l) => l.trimEnd())
|
|
244
|
+
.filter((l) => l.length > 0)
|
|
245
|
+
// Our own promotion marker is not operator work — see
|
|
246
|
+
// isUntrackedPromotionMarker() for why it must not count as dirty.
|
|
247
|
+
.filter((l) => !isUntrackedPromotionMarker(l));
|
|
248
|
+
if (significant.length > 0) return false; // dirty (modified, untracked, or staged)
|
|
102
249
|
|
|
103
250
|
const branchStatus = execFileFn('git', ['-C', wtPath, 'status', '--short', '--branch'], {
|
|
104
251
|
encoding: 'utf8',
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - parseSessionId(id): { format: 'semantic'|'uuid', ...fields, raw } | null
|
|
7
7
|
* - DEFAULT_SESSION_ID_SOURCES — the default `sources` array (see below)
|
|
8
8
|
* - SEMANTIC_ID_RE — source-of-truth regex for semantic session IDs
|
|
9
|
-
* -
|
|
9
|
+
* - UUID_RE — regex for RFC 9562 UUID session IDs (any version 1–8)
|
|
10
10
|
*
|
|
11
11
|
* Closes #572 — Epic #568 Phase 2.1 (Parallel-Aware Sessions Semantic ID)
|
|
12
12
|
* Closes #585 — Epic #583 W2-I2 (history-aware n-increment) per audit
|
|
@@ -67,14 +67,25 @@ import { parseStateMd } from './state-md/yaml-parser.mjs';
|
|
|
67
67
|
export const SEMANTIC_ID_RE = /^([a-z0-9._/-]+)-(\d{4}-\d{2}-\d{2})-([a-z-]+)-(\d+)$/;
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Regex for UUID
|
|
70
|
+
* Regex for RFC 9562 UUID session IDs — ANY version 1–8, variant `10xx`.
|
|
71
71
|
*
|
|
72
|
-
* Matches: 8-4-4-4-12 hex digits, version nibble
|
|
73
|
-
* Case-insensitive to accept both uppercase and lowercase hex.
|
|
72
|
+
* Matches: 8-4-4-4-12 hex digits, version nibble in [1-8], variant nibble in
|
|
73
|
+
* {8,9,a,b}. Case-insensitive to accept both uppercase and lowercase hex.
|
|
74
|
+
*
|
|
75
|
+
* Why the version nibble is a RANGE and not the literal `4` (Kanevry#66 / #1091):
|
|
76
|
+
* Claude Code mints UUIDv4 session ids, but Codex CLI mints UUIDv7. Pinning `4`
|
|
77
|
+
* made `parseSessionId()` return `null` for every Codex session, so
|
|
78
|
+
* `hooks/on-session-start.mjs` fell through to a freshly generated
|
|
79
|
+
* `randomUUID()` and every later hook in that session missed the lock.
|
|
80
|
+
*
|
|
81
|
+
* The structure stays strict on purpose: the dash/length layout and the
|
|
82
|
+
* variant nibble are what discriminate a real UUID from a 36-char lookalike,
|
|
83
|
+
* so only the version nibble is widened. Version `0` (nil UUID) and `9`..`f`
|
|
84
|
+
* (unassigned / max UUID) remain rejected — RFC 9562 defines 1–8.
|
|
74
85
|
*
|
|
75
86
|
* @type {RegExp}
|
|
76
87
|
*/
|
|
77
|
-
export const
|
|
88
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
78
89
|
|
|
79
90
|
// ---------------------------------------------------------------------------
|
|
80
91
|
// Internal helpers
|
|
@@ -344,8 +355,12 @@ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
|
|
|
344
355
|
* 1. Semantic: `<branch>-<YYYY-MM-DD>-<mode>-<n>`
|
|
345
356
|
* Returns `{ format: 'semantic', branch, date, mode, n, raw }`.
|
|
346
357
|
*
|
|
347
|
-
* 2. UUID
|
|
348
|
-
*
|
|
358
|
+
* 2. RFC 9562 UUID, any version 1–8, variant `10xx`:
|
|
359
|
+
* `xxxxxxxx-xxxx-[1-8]xxx-[89ab]xxx-xxxxxxxxxxxx`
|
|
360
|
+
* Returns `{ format: 'uuid', uuid, version, raw }`, where `version` is the
|
|
361
|
+
* version nibble as an integer (4 for Claude Code's v4 ids, 7 for Codex
|
|
362
|
+
* CLI's v7 ids). Callers that only branch on `format` are unaffected —
|
|
363
|
+
* `version` is additive (Kanevry#66 / #1091).
|
|
349
364
|
*
|
|
350
365
|
* Returns `null` for any input that is not a non-empty string or does not
|
|
351
366
|
* match either known format. Never throws.
|
|
@@ -356,7 +371,7 @@ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
|
|
|
356
371
|
*
|
|
357
372
|
* @param {unknown} id - The session ID to parse.
|
|
358
373
|
* @returns {{ format: 'semantic', branch: string, date: string, mode: string, n: number, raw: string }
|
|
359
|
-
* | { format: 'uuid', uuid: string, raw: string }
|
|
374
|
+
* | { format: 'uuid', uuid: string, version: number, raw: string }
|
|
360
375
|
* | null}
|
|
361
376
|
*/
|
|
362
377
|
export function parseSessionId(id) {
|
|
@@ -375,9 +390,10 @@ export function parseSessionId(id) {
|
|
|
375
390
|
};
|
|
376
391
|
}
|
|
377
392
|
|
|
378
|
-
// Try UUID
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
// Try RFC 9562 UUID (any version 1–8). Index 14 is the version nibble, and
|
|
394
|
+
// UUID_RE has already constrained it to [1-8], so Number() cannot be NaN.
|
|
395
|
+
if (UUID_RE.test(id)) {
|
|
396
|
+
return { format: 'uuid', uuid: id, version: Number(id[14]), raw: id };
|
|
381
397
|
}
|
|
382
398
|
|
|
383
399
|
return null;
|
|
@@ -418,9 +434,9 @@ export function parseSessionId(id) {
|
|
|
418
434
|
* cannot assign the same n. The #952 collision was NOT a concurrency defect —
|
|
419
435
|
* the lock held; the candidate set was incomplete.
|
|
420
436
|
*
|
|
421
|
-
* UUID
|
|
422
|
-
* format:'uuid' which the filter excludes). Malformed
|
|
423
|
-
* also dropped (SEMANTIC_ID_RE rejects them).
|
|
437
|
+
* UUID entries of ANY version (in any source) are silently dropped
|
|
438
|
+
* (parseSessionId returns format:'uuid' which the filter excludes). Malformed
|
|
439
|
+
* semantic-looking IDs are also dropped (SEMANTIC_ID_RE rejects them).
|
|
424
440
|
*
|
|
425
441
|
* @param {object} opts
|
|
426
442
|
* @param {string} opts.branch - Current git branch (e.g. "main", "feature/foo").
|