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
|
@@ -23,6 +23,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
|
|
25
25
|
import { filterRealSessions } from './session-schema.mjs';
|
|
26
|
+
import { emitEvent, sessionAttribution } from './events.mjs';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Constants
|
|
@@ -245,6 +246,73 @@ export async function shouldDispatchAutoDialectic({
|
|
|
245
246
|
};
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Decision + mechanical telemetry (#1200 part c)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `shouldDispatchAutoDialectic()` plus a MECHANICAL `orchestrator.dialectic.nudge_decided`
|
|
255
|
+
* record, so the nudge decision is observable without depending on the
|
|
256
|
+
* session-end skill prose actually reaching the emit step (#1200: 0 records of
|
|
257
|
+
* this class across 164k fleet events despite the nudge firing every close).
|
|
258
|
+
*
|
|
259
|
+
* Contract-preserving wrapper: calls `shouldDispatchAutoDialectic()` unchanged
|
|
260
|
+
* and returns its decision object verbatim — the emit is a side effect bolted
|
|
261
|
+
* on, never a change to the decision logic or its return shape.
|
|
262
|
+
*
|
|
263
|
+
* Emits on ALL FOUR return paths (kill-switch, no-new-input,
|
|
264
|
+
* cadence-threshold-met, under-threshold) — best-effort, try/catch-wrapped,
|
|
265
|
+
* because `emitEvent()` throws `EventValidationError` on a malformed record
|
|
266
|
+
* and a telemetry failure must never change what the caller decides to do
|
|
267
|
+
* (same posture as `scripts/lib/reconcile/engine.mjs`'s `emitReconcileCompleted`
|
|
268
|
+
* wrapper).
|
|
269
|
+
*
|
|
270
|
+
* @param {object} args
|
|
271
|
+
* @param {string} args.repoRoot
|
|
272
|
+
* @param {number} [args.cadence=DEFAULT_CADENCE] `dialectic.cadence` from config.
|
|
273
|
+
* @param {object} [args.signals] Pre-computed signals (skips disk reads) — forwarded verbatim.
|
|
274
|
+
* @param {Function|null} [args.emitFn=emitEvent] DI hook for testing / disabling
|
|
275
|
+
* emission; defaults to `emitEvent` from `./events.mjs`. Any error it throws
|
|
276
|
+
* is swallowed — it never changes the returned decision.
|
|
277
|
+
* @param {boolean} [args.record=true] When `false`, no event is emitted at all —
|
|
278
|
+
* for read-only PROBE callers (e.g. the session-end Phase 3.6.x tail-skip
|
|
279
|
+
* aggregator, `scripts/lib/session-end/phase-skip.mjs`, whose documented
|
|
280
|
+
* contract is side-effect-free) where the decision is computed for internal
|
|
281
|
+
* branching only and must never itself be recorded as a nudge decision.
|
|
282
|
+
* @returns {Promise<{trigger:boolean, reason:string, signals:object}>}
|
|
283
|
+
*/
|
|
284
|
+
export async function decideAndRecordAutoDialectic({
|
|
285
|
+
repoRoot,
|
|
286
|
+
cadence = DEFAULT_CADENCE,
|
|
287
|
+
signals,
|
|
288
|
+
emitFn = emitEvent,
|
|
289
|
+
record = true,
|
|
290
|
+
} = {}) {
|
|
291
|
+
const decision = await shouldDispatchAutoDialectic({ repoRoot, cadence, signals });
|
|
292
|
+
|
|
293
|
+
if (record && typeof emitFn === 'function') {
|
|
294
|
+
try {
|
|
295
|
+
await emitFn(
|
|
296
|
+
'orchestrator.dialectic.nudge_decided',
|
|
297
|
+
{
|
|
298
|
+
...sessionAttribution(repoRoot),
|
|
299
|
+
decided: decision.trigger,
|
|
300
|
+
reason: decision.reason,
|
|
301
|
+
cadence,
|
|
302
|
+
sessions_since: decision.signals?.sessionsSinceLast,
|
|
303
|
+
learnings_since: decision.signals?.learningsSinceLast,
|
|
304
|
+
},
|
|
305
|
+
{ repoRoot },
|
|
306
|
+
);
|
|
307
|
+
} catch {
|
|
308
|
+
// best-effort — a telemetry failure must never block or change the
|
|
309
|
+
// decision the caller already has in hand.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return decision;
|
|
314
|
+
}
|
|
315
|
+
|
|
248
316
|
// ---------------------------------------------------------------------------
|
|
249
317
|
// last-run — atomic write
|
|
250
318
|
// ---------------------------------------------------------------------------
|
|
@@ -36,6 +36,12 @@ import { acquire, release, buildLockOwnerProof } from '../session-lock.mjs';
|
|
|
36
36
|
import { emitEvent } from '../events.mjs';
|
|
37
37
|
import { main as gcMain } from '../../gc-stale-worktrees.mjs';
|
|
38
38
|
import { SEMANTIC_ID_RE } from '../session-id.mjs';
|
|
39
|
+
import { repoPathHash } from '../session-registry.mjs';
|
|
40
|
+
import { leaveSourceRoot } from '../session-transition.mjs';
|
|
41
|
+
// Marker location is owned by its READER (session-end Phase 4a) — importing the
|
|
42
|
+
// constant from there keeps writer and reader on one string. The dependency runs
|
|
43
|
+
// heavy→lean (this pipeline → the dependency-free cleanup helper), never back.
|
|
44
|
+
import { PROMOTION_MARKER_RELPATH } from '../session-end/worktree-cleanup.mjs';
|
|
39
45
|
|
|
40
46
|
// ---------------------------------------------------------------------------
|
|
41
47
|
// Type definitions
|
|
@@ -547,6 +553,134 @@ export async function runStoryPipeline(context, opts = {}) {
|
|
|
547
553
|
/** Valid git branch character set — mirrors isValidBranch from session-id.mjs. */
|
|
548
554
|
const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
|
|
549
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Raised when the promotion branch `so/<sessionId>` is already checked out by
|
|
558
|
+
* another worktree. Refusing is the point: adopting a foreign target worktree
|
|
559
|
+
* would put two sessions on one branch (PSA-002 territory), so the caller gets
|
|
560
|
+
* the conflicting path by name instead of a silent takeover.
|
|
561
|
+
*/
|
|
562
|
+
export class WorktreePromotionBranchError extends Error {
|
|
563
|
+
/**
|
|
564
|
+
* @param {string} message
|
|
565
|
+
* @param {{branch: string, checkedOutAt: string}} detail
|
|
566
|
+
*/
|
|
567
|
+
constructor(message, { branch, checkedOutAt } = {}) {
|
|
568
|
+
super(message);
|
|
569
|
+
this.name = 'WorktreePromotionBranchError';
|
|
570
|
+
this.branch = branch;
|
|
571
|
+
this.checkedOutAt = checkedOutAt;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Map of branch name → worktree path for every branch currently checked out by
|
|
577
|
+
* ANY worktree of `repoRoot` (#1067).
|
|
578
|
+
*
|
|
579
|
+
* Deliberately NOT `listWorktrees()` from `../worktree/listing.mjs`: that helper
|
|
580
|
+
* runs `git worktree list --porcelain` in `process.cwd()` (it has no repo
|
|
581
|
+
* parameter), and `enterWorktree` takes `repoRoot` explicitly precisely to avoid
|
|
582
|
+
* CWD drift (#219) — so reusing it would query whichever repo the process
|
|
583
|
+
* happens to sit in. Its `$` seam also has a different shape (`$({cwd})` returns
|
|
584
|
+
* the tag) than `enterWorktree`'s (`exec` IS the tag), so the two cannot share
|
|
585
|
+
* one injected executor. Only the `branch` lines are parsed here; `HEAD`,
|
|
586
|
+
* `detached`, `bare` and `locked` records carry nothing this decision needs.
|
|
587
|
+
*
|
|
588
|
+
* @param {Function} exec zx-like template-tag executor (the DI seam).
|
|
589
|
+
* @param {string} repoRoot Absolute path to the source git repository.
|
|
590
|
+
* @returns {Promise<Map<string, string>>} Branch names (without the
|
|
591
|
+
* `refs/heads/` prefix) → the worktree path that has them checked out.
|
|
592
|
+
* Empty on git failure — the caller then behaves exactly as it did before this
|
|
593
|
+
* check existed, so a broken listing degrades to git's own loud
|
|
594
|
+
* `already used by worktree` error rather than to a silent wrong branch.
|
|
595
|
+
*/
|
|
596
|
+
async function listCheckedOutBranches(exec, repoRoot) {
|
|
597
|
+
let stdout;
|
|
598
|
+
try {
|
|
599
|
+
const result = await exec`git -C ${repoRoot} worktree list --porcelain`;
|
|
600
|
+
stdout = String(result?.stdout ?? '');
|
|
601
|
+
} catch {
|
|
602
|
+
return new Map();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const branches = new Map();
|
|
606
|
+
let currentPath = null;
|
|
607
|
+
for (const rawLine of stdout.split('\n')) {
|
|
608
|
+
const line = rawLine.trimEnd();
|
|
609
|
+
const wtMatch = /^worktree (.+)$/.exec(line);
|
|
610
|
+
if (wtMatch) {
|
|
611
|
+
currentPath = wtMatch[1];
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const branchMatch = /^branch refs\/heads\/(.+)$/.exec(line);
|
|
615
|
+
if (branchMatch) branches.set(branchMatch[1], currentPath ?? '');
|
|
616
|
+
}
|
|
617
|
+
return branches;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Does `<ref>` resolve in `repoRoot`? Wraps `git rev-parse --verify --quiet`.
|
|
622
|
+
*
|
|
623
|
+
* @param {Function} exec
|
|
624
|
+
* @param {string} repoRoot
|
|
625
|
+
* @param {string} ref
|
|
626
|
+
* @returns {Promise<boolean>} false on any git failure (the ref is absent, or
|
|
627
|
+
* git could not be asked — both lead to the create-new path, whose failure
|
|
628
|
+
* mode is git's own loud error rather than a silent wrong checkout).
|
|
629
|
+
*/
|
|
630
|
+
async function refExists(exec, repoRoot, ref) {
|
|
631
|
+
try {
|
|
632
|
+
await exec`git -C ${repoRoot} rev-parse --verify --quiet ${ref}`;
|
|
633
|
+
return true;
|
|
634
|
+
} catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Record, inside the freshly created worktree, that it was auto-promoted —
|
|
641
|
+
* and from where. Best-effort: a failure to write the marker degrades Phase 4a
|
|
642
|
+
* detection to the legacy basename key, it never fails the promotion itself.
|
|
643
|
+
*
|
|
644
|
+
* No absolute source path is stored: `source_root_hash` is `repoPathHash()`, the
|
|
645
|
+
* same stable SHA-256 the session registry uses to correlate repos without
|
|
646
|
+
* exposing the operator's filesystem layout.
|
|
647
|
+
*
|
|
648
|
+
* @param {object} params
|
|
649
|
+
* @param {string} params.wtPath Freshly created worktree.
|
|
650
|
+
* @param {string} params.sourceRoot Resolved source checkout.
|
|
651
|
+
* @param {string} params.sessionId Session label the worktree was created for.
|
|
652
|
+
* @param {string} params.branch Branch the worktree actually landed on.
|
|
653
|
+
* @returns {boolean} true when the marker was written.
|
|
654
|
+
*/
|
|
655
|
+
function writePromotionMarker({ wtPath, sourceRoot, sessionId, branch }) {
|
|
656
|
+
try {
|
|
657
|
+
const markerPath = path.join(wtPath, PROMOTION_MARKER_RELPATH);
|
|
658
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
659
|
+
fs.writeFileSync(
|
|
660
|
+
markerPath,
|
|
661
|
+
JSON.stringify(
|
|
662
|
+
{
|
|
663
|
+
source_root_hash: repoPathHash(sourceRoot),
|
|
664
|
+
source_root_basename: path.basename(sourceRoot),
|
|
665
|
+
source_session_id: sessionId,
|
|
666
|
+
branch,
|
|
667
|
+
promoted_at: new Date().toISOString(),
|
|
668
|
+
},
|
|
669
|
+
null,
|
|
670
|
+
2,
|
|
671
|
+
) + '\n',
|
|
672
|
+
'utf8',
|
|
673
|
+
);
|
|
674
|
+
return true;
|
|
675
|
+
} catch (err) {
|
|
676
|
+
console.warn(
|
|
677
|
+
`enterWorktree: could not write promotion marker in ${wtPath} (${err.message}) — ` +
|
|
678
|
+
'session-end Phase 4a falls back to basename detection',
|
|
679
|
+
);
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
550
684
|
/**
|
|
551
685
|
* Create a sibling git worktree for Worktree-Auto-Promotion (#574, Epic #568 P3.1).
|
|
552
686
|
*
|
|
@@ -573,23 +707,75 @@ const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
|
|
|
573
707
|
* check as `setupWorktree` (CWE-23 / SEC-013 defence-in-depth). Throws
|
|
574
708
|
* `WorktreeBoundaryError` if the computed path escapes `basePath`.
|
|
575
709
|
*
|
|
576
|
-
* Branch handling
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
710
|
+
* Branch handling — three cases, decided in this order (#1067):
|
|
711
|
+
* 1. `<branch>` is already checked out by ANY worktree of `repoRoot` (the
|
|
712
|
+
* normal session-start Phase 0.5 case, where `branch` is the CURRENT HEAD):
|
|
713
|
+
* git refuses a second checkout of the same branch
|
|
714
|
+
* (`fatal: '<branch>' is already used by worktree at '<repoRoot>'`). Treat
|
|
715
|
+
* `<branch>` as a START POINT only and create a fresh promotion branch
|
|
716
|
+
* `so/<sessionId>` at it: `git worktree add -b so/<sessionId> <wtPath> <branch>`.
|
|
717
|
+
* The source branch and the source worktree are left untouched.
|
|
718
|
+
* If `so/<sessionId>` ALREADY exists (a worktree directory was removed but
|
|
719
|
+
* its branch survived), it is REUSED — `git worktree add <wtPath>
|
|
720
|
+
* so/<sessionId>`, `reusedBranch: true` in the return — because `-b` would
|
|
721
|
+
* abort with `fatal: a branch named 'so/<sessionId>' already exists`.
|
|
722
|
+
* If it exists AND is checked out by some worktree, this throws
|
|
723
|
+
* `WorktreePromotionBranchError` naming that path: adopting a foreign
|
|
724
|
+
* target worktree is refused, never silently taken over.
|
|
725
|
+
* 2. `<branch>` exists but is not checked out (verified via
|
|
726
|
+
* `git rev-parse --verify <branch>`): `git worktree add <wtPath> <branch>`.
|
|
727
|
+
* 3. `<branch>` does not exist: `git worktree add -b <branch> <wtPath>`.
|
|
728
|
+
* Cases 2/3 differ from `setupWorktree`, which always passes `-b`.
|
|
580
729
|
*
|
|
581
730
|
* @param {object} params
|
|
582
731
|
* @param {string} params.basePath - Parent directory where the new worktree goes (absolute).
|
|
583
732
|
* @param {string} params.sessionId - Semantic session-ID matching SEMANTIC_ID_RE.
|
|
584
733
|
* @param {string} params.branch - Branch name (existing or new) matching ENTER_WORKTREE_BRANCH_RE.
|
|
585
734
|
* @param {string} params.repoRoot - Path to the source git repository (passed explicitly to avoid CWD drift per #219).
|
|
735
|
+
* @param {string} [params.rawSessionId] - The RAW (physical) session id that owns
|
|
736
|
+
* the SOURCE root's `session.lock` and registry entry. Optional. When it is a
|
|
737
|
+
* non-empty string, the source root is LEFT mechanically (#1170): after the
|
|
738
|
+
* destination worktree provably exists, `leaveSourceRoot()` runs and its
|
|
739
|
+
* result is reported as `left` in the return value. OMITTING it (strictly
|
|
740
|
+
* `undefined`) preserves the pre-#1170 behaviour exactly — no teardown, no
|
|
741
|
+
* `left` key, no WARN. Passing something UNUSABLE (`null`, `''`, a non-string —
|
|
742
|
+
* `readLock({ repoRoot }).session_id` returns `null` on a missing/unreadable
|
|
743
|
+
* lock, which is how this happens in practice) is NOT the same case: it WARNs
|
|
744
|
+
* and reports `left: { ok: false, reason: 'raw-session-id-unusable' }`, because
|
|
745
|
+
* a silent skip there is the phantom-owner state of #1069. NOT the semantic
|
|
746
|
+
* `sessionId` above, which is a naming key, never an ownership key.
|
|
747
|
+
* @param {string} [params.reason='worktree-promotion'] - Departure reason
|
|
748
|
+
* recorded verbatim in the `orchestrator.session.root_left` payload. Only used
|
|
749
|
+
* when `rawSessionId` is given.
|
|
586
750
|
* @param {object} [opts]
|
|
587
751
|
* @param {Function} [opts.$] - zx-like template-tag executor (DI seam); falls back to lazy `await import('zx')`.
|
|
588
|
-
*
|
|
752
|
+
* Every freshly created worktree also gets a `.orchestrator/promoted-from.json`
|
|
753
|
+
* marker (see {@link PROMOTION_MARKER_RELPATH}) so session-end Phase 4a can
|
|
754
|
+
* still recognise it after the #1069 process boundary hands the worktree to a
|
|
755
|
+
* session with a different id. Marker writing is best-effort and never fails
|
|
756
|
+
* the promotion.
|
|
757
|
+
*
|
|
758
|
+
* @returns {Promise<{ wtPath: string, reused: boolean, branch?: string, promotedFrom?: string, reusedBranch?: true, left?: { ok: boolean, steps: object, reason?: string } }>}
|
|
759
|
+
* `branch` is the branch the new worktree actually landed on — equal to the
|
|
760
|
+
* `branch` param except in case 1 above, where it is `so/<sessionId>` and
|
|
761
|
+
* `promotedFrom` carries the requested source branch. `reusedBranch` is
|
|
762
|
+
* present (and `true`) only when an existing `so/<sessionId>` was checked out
|
|
763
|
+
* rather than created. All three are absent on the `reused: true` path (no
|
|
764
|
+
* branch was chosen — the worktree pre-existed). `left` is present on BOTH
|
|
765
|
+
* success exits — and ONLY — when `rawSessionId` was supplied (including when
|
|
766
|
+
* it was supplied unusable, where it reports the refusal).
|
|
589
767
|
* @throws {TypeError} when any required param is missing or fails validation.
|
|
590
768
|
* @throws {WorktreeBoundaryError} when the computed worktree path escapes `basePath`.
|
|
769
|
+
* @throws {WorktreePromotionBranchError} when `so/<sessionId>` is checked out elsewhere.
|
|
591
770
|
*/
|
|
592
|
-
export async function enterWorktree({
|
|
771
|
+
export async function enterWorktree({
|
|
772
|
+
basePath,
|
|
773
|
+
sessionId,
|
|
774
|
+
branch,
|
|
775
|
+
repoRoot,
|
|
776
|
+
rawSessionId,
|
|
777
|
+
reason = 'worktree-promotion',
|
|
778
|
+
} = {}, opts = {}) {
|
|
593
779
|
// -------------------------------------------------------------------------
|
|
594
780
|
// Step 1: Input validation (TypeError on any malformed param).
|
|
595
781
|
// -------------------------------------------------------------------------
|
|
@@ -678,36 +864,150 @@ export async function enterWorktree({ basePath, sessionId, branch, repoRoot } =
|
|
|
678
864
|
);
|
|
679
865
|
}
|
|
680
866
|
|
|
867
|
+
// -------------------------------------------------------------------------
|
|
868
|
+
// Step 3b: The source-root departure (#1170), as a MECHANICAL step.
|
|
869
|
+
//
|
|
870
|
+
// Before #1170 this was skill PROSE at four sites: `enterWorktree()` created
|
|
871
|
+
// the destination and the coordinator was trusted to call `leaveSourceRoot()`
|
|
872
|
+
// afterwards. A step that only exists in prose is a step that is sometimes
|
|
873
|
+
// skipped — and skipping it is exactly the phantom-peer / double-live-lock
|
|
874
|
+
// state #1069 was filed about.
|
|
875
|
+
//
|
|
876
|
+
// Ordering is load-bearing and only runs on a proven-existing destination:
|
|
877
|
+
// every throw above leaves the source root untouched, because a departure
|
|
878
|
+
// from a root whose successor does not exist strands the session with no
|
|
879
|
+
// live root at all. `leaveSourceRoot()` never throws, so a failed teardown
|
|
880
|
+
// WARNs and is reported in `left` — it never fails the promotion.
|
|
881
|
+
// -------------------------------------------------------------------------
|
|
882
|
+
const departSourceRoot = async (result) => {
|
|
883
|
+
// NOT PASSED and PASSED-BUT-UNUSABLE are different callers with different
|
|
884
|
+
// bugs, and collapsing them re-opened #1069 silently. `undefined` is the
|
|
885
|
+
// documented opt-out (the autopilot callers, which own no source session):
|
|
886
|
+
// byte-identical pre-#1170 behaviour, no `left`, no WARN.
|
|
887
|
+
if (rawSessionId === undefined) return result;
|
|
888
|
+
// Anything else falsy or non-string means a caller INTENDED to depart and
|
|
889
|
+
// handed us something unusable. The documented derivation is
|
|
890
|
+
// `readLock({ repoRoot }).session_id`, which is `null` whenever the lock is
|
|
891
|
+
// missing or unreadable — so the realistic failure lands here, and before
|
|
892
|
+
// this branch it returned early with nothing on stderr: promotion succeeds,
|
|
893
|
+
// source root never deregistered, phantom owner (#1069).
|
|
894
|
+
if (typeof rawSessionId !== 'string' || rawSessionId.length === 0) {
|
|
895
|
+
const shown = rawSessionId === null
|
|
896
|
+
? 'null'
|
|
897
|
+
: typeof rawSessionId === 'string' ? 'empty string' : typeof rawSessionId;
|
|
898
|
+
console.warn(
|
|
899
|
+
`enterWorktree: rawSessionId unusable (${shown}) — source root NOT departed; ` +
|
|
900
|
+
'read it via readLock({ repoRoot }).session_id',
|
|
901
|
+
);
|
|
902
|
+
result.left = { ok: false, reason: 'raw-session-id-unusable' };
|
|
903
|
+
return result;
|
|
904
|
+
}
|
|
905
|
+
const left = await leaveSourceRoot({
|
|
906
|
+
repoRoot,
|
|
907
|
+
sessionId: rawSessionId,
|
|
908
|
+
semanticSessionId: sessionId,
|
|
909
|
+
reason,
|
|
910
|
+
});
|
|
911
|
+
if (left.ok !== true) {
|
|
912
|
+
console.warn(`enterWorktree: leaveSourceRoot: ${left.reason ?? 'unknown'}`);
|
|
913
|
+
}
|
|
914
|
+
result.left = left;
|
|
915
|
+
return result;
|
|
916
|
+
};
|
|
917
|
+
|
|
681
918
|
// -------------------------------------------------------------------------
|
|
682
919
|
// Step 4: Idempotency — reuse if worktree already exists with .git.
|
|
683
920
|
// -------------------------------------------------------------------------
|
|
684
921
|
if (fs.existsSync(wtPath) && fs.existsSync(path.join(wtPath, '.git'))) {
|
|
685
|
-
return { wtPath, reused: true };
|
|
922
|
+
return await departSourceRoot({ wtPath, reused: true });
|
|
686
923
|
}
|
|
687
924
|
|
|
688
925
|
// -------------------------------------------------------------------------
|
|
689
|
-
// Step 5:
|
|
926
|
+
// Step 5: Is `branch` already checked out elsewhere? (#1067)
|
|
927
|
+
//
|
|
928
|
+
// Phase 0.5 passes the CURRENT HEAD as `branch`, so in the normal promotion
|
|
929
|
+
// case it is checked out by `repoRoot` itself and git would refuse a second
|
|
930
|
+
// checkout. Then `branch` is only a start point and the worktree lands on a
|
|
931
|
+
// fresh `so/<sessionId>`; the source branch/worktree stay untouched.
|
|
690
932
|
// -------------------------------------------------------------------------
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
933
|
+
const checkedOutBranches = await listCheckedOutBranches(exec, repoRoot);
|
|
934
|
+
const promoted = checkedOutBranches.has(branch);
|
|
935
|
+
const targetBranch = promoted ? `so/${sessionId}` : branch;
|
|
936
|
+
|
|
937
|
+
// -------------------------------------------------------------------------
|
|
938
|
+
// Step 6: Decide the `git worktree add` argv.
|
|
939
|
+
//
|
|
940
|
+
// On the promotion path the target is `so/<sessionId>`, which can be in any
|
|
941
|
+
// of THREE states — and only the first was handled before:
|
|
942
|
+
// (a) absent → create it at `<branch>`: `add -b so/<id> <wt> <branch>`
|
|
943
|
+
// (b) exists, free → REUSE it: `add <wt> so/<id>`. This is the survivor
|
|
944
|
+
// of a worktree whose directory was removed while its branch stayed
|
|
945
|
+
// behind (`git worktree remove` does not delete the branch); `-b` would
|
|
946
|
+
// abort Phase 0.5 with `fatal: a branch named 'so/<id>' already exists`.
|
|
947
|
+
// (c) exists, checked out elsewhere → REFUSE. Adopting a foreign target
|
|
948
|
+
// worktree is exactly what Phase 0.5 must not do.
|
|
949
|
+
// -------------------------------------------------------------------------
|
|
950
|
+
let reusedBranch = false;
|
|
951
|
+
if (promoted) {
|
|
952
|
+
const checkedOutAt = checkedOutBranches.get(targetBranch);
|
|
953
|
+
if (checkedOutAt !== undefined) {
|
|
954
|
+
throw new WorktreePromotionBranchError(
|
|
955
|
+
`enterWorktree: promotion branch '${targetBranch}' is already checked out at '${checkedOutAt}' — refusing to adopt a foreign worktree`,
|
|
956
|
+
{ branch: targetBranch, checkedOutAt },
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
reusedBranch = await refExists(exec, repoRoot, `refs/heads/${targetBranch}`);
|
|
697
960
|
}
|
|
698
961
|
|
|
699
|
-
|
|
962
|
+
// A branch that a worktree has checked out exists by construction, so the
|
|
963
|
+
// source-branch rev-parse probe is skipped on the promotion path.
|
|
964
|
+
let branchExists = promoted;
|
|
965
|
+
if (!promoted) {
|
|
966
|
+
try {
|
|
967
|
+
await exec`git -C ${repoRoot} rev-parse --verify ${branch}`;
|
|
968
|
+
branchExists = true;
|
|
969
|
+
} catch {
|
|
970
|
+
// Branch does not exist — fall through to create-new path with `-b`.
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (promoted && reusedBranch) {
|
|
975
|
+
await exec`git -C ${repoRoot} worktree add ${wtPath} ${targetBranch}`;
|
|
976
|
+
} else if (promoted) {
|
|
977
|
+
await exec`git -C ${repoRoot} worktree add -b ${targetBranch} ${wtPath} ${branch}`;
|
|
978
|
+
} else if (branchExists) {
|
|
700
979
|
await exec`git -C ${repoRoot} worktree add ${wtPath} ${branch}`;
|
|
701
980
|
} else {
|
|
702
981
|
await exec`git -C ${repoRoot} worktree add -b ${branch} ${wtPath}`;
|
|
703
982
|
}
|
|
704
983
|
|
|
705
984
|
// -------------------------------------------------------------------------
|
|
706
|
-
// Step
|
|
985
|
+
// Step 6b: Record the promotion FACT inside the new worktree (#1069 boundary).
|
|
986
|
+
// Session-end Phase 4a runs in a session whose id is NOT this one, so nothing
|
|
987
|
+
// in the path or the branch identifies the worktree to it — the marker does.
|
|
988
|
+
// -------------------------------------------------------------------------
|
|
989
|
+
writePromotionMarker({
|
|
990
|
+
wtPath,
|
|
991
|
+
sourceRoot: resolvedRepoRoot,
|
|
992
|
+
sessionId,
|
|
993
|
+
branch: targetBranch,
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// -------------------------------------------------------------------------
|
|
997
|
+
// Step 7: WARN to stderr (PRD §3 P3 Gherkin row-1 + #574 DoD).
|
|
707
998
|
// -------------------------------------------------------------------------
|
|
708
999
|
console.warn(
|
|
709
|
-
|
|
1000
|
+
promoted
|
|
1001
|
+
? `enterWorktree: created sibling worktree at ${wtPath} (branch=${targetBranch}, promoted from ${branch}, sessionId=${sessionId})`
|
|
1002
|
+
: `enterWorktree: created sibling worktree at ${wtPath} (branch=${branch}, sessionId=${sessionId})`,
|
|
710
1003
|
);
|
|
711
1004
|
|
|
712
|
-
|
|
1005
|
+
if (promoted) {
|
|
1006
|
+
const result = { wtPath, reused: false, branch: targetBranch, promotedFrom: branch };
|
|
1007
|
+
// Present only when it happened, so the common shape stays byte-identical
|
|
1008
|
+
// for every existing consumer and strict-equality pin.
|
|
1009
|
+
if (reusedBranch) result.reusedBranch = true;
|
|
1010
|
+
return await departSourceRoot(result);
|
|
1011
|
+
}
|
|
1012
|
+
return await departSourceRoot({ wtPath, reused: false, branch });
|
|
713
1013
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - Synchronous file I/O for STATE.md / sessions.jsonl / bootstrap.lock.
|
|
11
11
|
* The async wrapper exists only because `scanBacklog` is async.
|
|
12
12
|
* - Logging is NOT this helper's job — silent graceful-null on every error.
|
|
13
|
-
* -
|
|
13
|
+
* - Relative paths resolve against `opts.repoRoot` (which itself defaults to
|
|
14
|
+
* process.cwd()); an explicit ABSOLUTE path always wins over the root.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -19,6 +20,7 @@ import { parseStateMd, parseRecommendations } from './state-md.mjs';
|
|
|
19
20
|
import { normalizeSession, tailRealSessions } from './session-schema.mjs';
|
|
20
21
|
import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
|
|
21
22
|
import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
|
|
23
|
+
import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
22
24
|
|
|
23
25
|
// ---------------------------------------------------------------------------
|
|
24
26
|
// Public API
|
|
@@ -32,9 +34,16 @@ import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
|
|
|
32
34
|
* null/[] value to the Signals object.
|
|
33
35
|
*
|
|
34
36
|
* @param {object} [opts]
|
|
35
|
-
* @param {string} [opts.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
37
|
+
* @param {string} [opts.repoRoot] — absolute project root every relative
|
|
38
|
+
* path below resolves against, and the root forwarded to `scanBacklog`.
|
|
39
|
+
* Defaults to `process.cwd()`. Without it (#1071) a caller running from a
|
|
40
|
+
* worktree or a subdirectory silently read a DIFFERENT repo's STATE.md and
|
|
41
|
+
* sessions.jsonl than the one it was reporting on — measured as
|
|
42
|
+
* `recentSessions: []` against a checkout holding 245 session records.
|
|
43
|
+
* An explicit absolute `statePath`/`sessionsPath`/`lockPath` still wins.
|
|
44
|
+
* @param {string} [opts.statePath] — defaults to '<repoRoot>/.claude/STATE.md'
|
|
45
|
+
* @param {string} [opts.sessionsPath] — defaults to '<repoRoot>/.orchestrator/metrics/sessions.jsonl'
|
|
46
|
+
* @param {string} [opts.lockPath] — defaults to '<repoRoot>/.orchestrator/bootstrap.lock'
|
|
38
47
|
* @param {Array} [opts.learnings] — pre-surfaced top-N learnings; defaults to []
|
|
39
48
|
* @param {number} [opts.backlogLimit] — passed to scanBacklog; defaults to
|
|
40
49
|
* `DEFAULT_BACKLOG_LIMIT` from backlog-scan.mjs (never a local copy of that
|
|
@@ -46,15 +55,24 @@ import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
|
|
|
46
55
|
* @returns {Promise<import('./mode-selector.mjs').Signals>}
|
|
47
56
|
*/
|
|
48
57
|
export async function buildLiveSignals(opts = {}) {
|
|
58
|
+
const repoRoot =
|
|
59
|
+
typeof opts.repoRoot === 'string' && opts.repoRoot.length > 0
|
|
60
|
+
? opts.repoRoot
|
|
61
|
+
: process.cwd();
|
|
62
|
+
// `resolve(root, p)` returns `p` unchanged when `p` is absolute — explicit
|
|
63
|
+
// per-file overrides therefore keep precedence over repoRoot.
|
|
49
64
|
const statePath = resolve(
|
|
65
|
+
repoRoot,
|
|
50
66
|
typeof opts.statePath === 'string' ? opts.statePath : '.claude/STATE.md'
|
|
51
67
|
);
|
|
52
68
|
const sessionsPath = resolve(
|
|
69
|
+
repoRoot,
|
|
53
70
|
typeof opts.sessionsPath === 'string'
|
|
54
71
|
? opts.sessionsPath
|
|
55
72
|
: '.orchestrator/metrics/sessions.jsonl'
|
|
56
73
|
);
|
|
57
74
|
const lockPath = resolve(
|
|
75
|
+
repoRoot,
|
|
58
76
|
typeof opts.lockPath === 'string' ? opts.lockPath : '.orchestrator/bootstrap.lock'
|
|
59
77
|
);
|
|
60
78
|
const learnings = Array.isArray(opts.learnings) ? opts.learnings : [];
|
|
@@ -96,29 +114,31 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
96
114
|
let recentSessions = [];
|
|
97
115
|
|
|
98
116
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
// #1186: readCanonicalSessions applies the #1167 newest-wins-per-`session_id`
|
|
118
|
+
// / attestable-`supersedes` collapse BEFORE the tail is taken. The raw read
|
|
119
|
+
// this replaced parsed every line unconditionally (malformed lines skipped
|
|
120
|
+
// on JSON.parse failure only), so a duplicated `session_id` — a
|
|
121
|
+
// crash-recovery re-append, or the #1068 abandoned-stub/supersede pair —
|
|
122
|
+
// counted as TWO entries toward `sessionTailN`, silently narrowing the
|
|
123
|
+
// REAL window by however many duplicates sat in the file's tail.
|
|
124
|
+
//
|
|
125
|
+
// Behavioural change: a well-formed record with NO `session_id` (never
|
|
126
|
+
// legitimate per REQUIRED_FIELDS, but possible on a hand-edited or
|
|
127
|
+
// pre-schema legacy line) is now DROPPED rather than counted — it cannot
|
|
128
|
+
// be deduplicated by identity, so `canonicalizeSessions` excludes it (see
|
|
129
|
+
// sessions-canonical.mjs's own contract). Missing-file / unreadable-file
|
|
130
|
+
// handling (ENOENT vs EACCES/EISDIR, #1188) is delegated to the shared
|
|
131
|
+
// reader; both still degrade to `[]` here, matching Branch 3's contract.
|
|
132
|
+
const canonical = readCanonicalSessions({ filePath: sessionsPath });
|
|
133
|
+
const parsed = canonical.map((obj) => normalizeSession(obj));
|
|
134
|
+
// #834: `status: 'abandoned'` phantom stubs must still be filtered out
|
|
135
|
+
// BEFORE the tail is taken, or sessionTailN silently means "last N LINES"
|
|
136
|
+
// instead of "last N REAL sessions". tailRealSessions() does the filter +
|
|
137
|
+
// tail.
|
|
138
|
+
recentSessions = tailRealSessions(parsed, sessionTailN);
|
|
120
139
|
} catch {
|
|
121
|
-
// Branch 3: file unreadable — recentSessions stays []
|
|
140
|
+
// Branch 3: file unreadable — recentSessions stays [] (readCanonicalSessions
|
|
141
|
+
// itself never throws, but the graceful-null contract is kept as a backstop).
|
|
122
142
|
}
|
|
123
143
|
|
|
124
144
|
// --- Branch 5: bootstrap.lock ---
|
|
@@ -137,7 +157,9 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
137
157
|
let backlog = null;
|
|
138
158
|
|
|
139
159
|
try {
|
|
140
|
-
|
|
160
|
+
// `repoRoot` is forwarded so VCS detection and the `-R` host-pinning spec
|
|
161
|
+
// inside scanBacklog answer about the SAME repo the signals describe.
|
|
162
|
+
backlog = await _scan({ limit: backlogLimit, repoRoot });
|
|
141
163
|
} catch {
|
|
142
164
|
// Branch 6: scanBacklog threw — backlog stays null
|
|
143
165
|
}
|