session-orchestrator 5.2.0 → 5.3.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/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vault-yaml.mjs — Reader for a repo's `.vault.yaml` registration slug (#1131).
|
|
3
|
+
*
|
|
4
|
+
* A repo registered in the Meta-Vault carries `.vault.yaml` at its root, whose
|
|
5
|
+
* `metadata.slug` is the CANONICAL vault slug for that project — the name of
|
|
6
|
+
* its `01-projects/<slug>/` folder. Until #1131 nothing READ that key: every
|
|
7
|
+
* vault writer derived the slug from the checkout DIRECTORY name instead
|
|
8
|
+
* (`scripts/lib/vault-status/narrative-mirror.mjs` via `subjectToSlug`,
|
|
9
|
+
* `scripts/lib/vault-mirror/namespace.mjs` via `basename(process.cwd())`), so a
|
|
10
|
+
* repo whose directory name differs lexically from its registered slug had its
|
|
11
|
+
* narrative and its learnings/sessions filed under a second, wrong folder — and
|
|
12
|
+
* the loose-slug healer in narrative-mirror (#829) only repairs case and
|
|
13
|
+
* punctuation drift, never a genuinely different name.
|
|
14
|
+
*
|
|
15
|
+
* Exports:
|
|
16
|
+
* VAULT_YAML_FILE — the basename this module reads
|
|
17
|
+
* readVaultSlug — repoRoot → validated `metadata.slug` string, or null
|
|
18
|
+
*
|
|
19
|
+
* Contract: NEVER throws. Absent file, unreadable file, malformed YAML, missing
|
|
20
|
+
* key, non-string value, or a value that fails the repo's slug predicate all
|
|
21
|
+
* return `null`, leaving every caller on its pre-#1131 derivation chain.
|
|
22
|
+
*
|
|
23
|
+
* Import closure is deliberately tiny (`node:fs`, `node:path`, `node:module`,
|
|
24
|
+
* `./vault-mirror/utils.mjs`) — `namespace.mjs` is documented as a leaf-ward
|
|
25
|
+
* module and must stay one.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
|
|
32
|
+
import { isValidSlug } from './vault-mirror/utils.mjs';
|
|
33
|
+
|
|
34
|
+
/** Basename of the per-repo vault registration file. */
|
|
35
|
+
export const VAULT_YAML_FILE = '.vault.yaml';
|
|
36
|
+
|
|
37
|
+
// ── Lazy js-yaml resolution ──────────────────────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// Same shape (and same reason) as `getYaml()` in scripts/lib/owner-yaml.mjs:
|
|
40
|
+
// a static `import yaml from 'js-yaml'` crashes any consumer running without
|
|
41
|
+
// `node_modules`, at MODULE LOAD, where the failure is not catchable. Both
|
|
42
|
+
// consumers of this reader are synchronous, so `createRequire` — not
|
|
43
|
+
// `await import()` — is the resolution mechanism.
|
|
44
|
+
|
|
45
|
+
/** @type {null | false | { load: Function }} */
|
|
46
|
+
let _yaml = null;
|
|
47
|
+
let _yamlWarned = false;
|
|
48
|
+
|
|
49
|
+
function getYaml() {
|
|
50
|
+
if (_yaml !== null) return _yaml === false ? null : _yaml;
|
|
51
|
+
try {
|
|
52
|
+
_yaml = createRequire(import.meta.url)('js-yaml');
|
|
53
|
+
} catch {
|
|
54
|
+
_yaml = false;
|
|
55
|
+
}
|
|
56
|
+
return _yaml === false ? null : _yaml;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One-per-process actionable WARN, emitted ONLY when a `.vault.yaml` actually
|
|
61
|
+
* exists and could therefore have changed the resolved slug. A repo without the
|
|
62
|
+
* file loses nothing by the parser being absent, so it stays silent.
|
|
63
|
+
*/
|
|
64
|
+
function warnYamlMissing() {
|
|
65
|
+
if (_yamlWarned) return;
|
|
66
|
+
_yamlWarned = true;
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
"WARN vault-yaml: 'js-yaml' is not installed — .vault.yaml cannot be parsed; " +
|
|
69
|
+
'the vault slug falls back to the directory-derived name (run \'npm install\' in the plugin directory)\n',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the canonical vault slug declared at `metadata.slug` in
|
|
75
|
+
* `<repoRoot>/.vault.yaml`.
|
|
76
|
+
*
|
|
77
|
+
* The value becomes a FILESYSTEM PATH SEGMENT under the operator's vault, so it
|
|
78
|
+
* is validated with the repo's existing slug predicate ({@link isValidSlug},
|
|
79
|
+
* `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`) rather than trusted. That rejects `/`, `..`,
|
|
80
|
+
* leading dots, whitespace and uppercase — this validation is a trust-boundary
|
|
81
|
+
* control, not hygiene: `.vault.yaml` is repo-supplied data, and a slug such as
|
|
82
|
+
* `../../etc` would otherwise escape the vault root.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} repoRoot - absolute or relative path to the repository root.
|
|
85
|
+
* @returns {string|null} the validated slug, or `null` when it cannot be read.
|
|
86
|
+
*/
|
|
87
|
+
export function readVaultSlug(repoRoot) {
|
|
88
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') return null;
|
|
89
|
+
|
|
90
|
+
let text;
|
|
91
|
+
try {
|
|
92
|
+
text = readFileSync(path.join(repoRoot, VAULT_YAML_FILE), 'utf8');
|
|
93
|
+
} catch {
|
|
94
|
+
// Absent, unreadable, a directory, … — all "no declared slug".
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const yaml = getYaml();
|
|
99
|
+
if (!yaml) {
|
|
100
|
+
warnYamlMissing();
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let doc;
|
|
105
|
+
try {
|
|
106
|
+
doc = yaml.load(text);
|
|
107
|
+
} catch {
|
|
108
|
+
// Malformed YAML must never break a vault write.
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!doc || typeof doc !== 'object') return null;
|
|
113
|
+
const metadata = doc.metadata;
|
|
114
|
+
if (!metadata || typeof metadata !== 'object') return null;
|
|
115
|
+
|
|
116
|
+
const slug = metadata.slug;
|
|
117
|
+
return isValidSlug(slug) ? slug : null;
|
|
118
|
+
}
|
|
@@ -46,6 +46,15 @@ async function _exists(p) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The one directory session-orchestrator worktrees may live in.
|
|
51
|
+
* Host-shared: several sessions run concurrently against the same base.
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function _worktreeBaseDir() {
|
|
55
|
+
return path.join(os.tmpdir(), 'so-worktrees');
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
/**
|
|
50
59
|
* Compute the standard branch name and tmp path for a given suffix.
|
|
51
60
|
* @param {string} suffix
|
|
@@ -53,10 +62,146 @@ async function _exists(p) {
|
|
|
53
62
|
*/
|
|
54
63
|
function _worktreeInfo(suffix) {
|
|
55
64
|
const branch = `so-worktree-${suffix}`;
|
|
56
|
-
const wtPath = path.join(
|
|
65
|
+
const wtPath = path.join(_worktreeBaseDir(), branch);
|
|
57
66
|
return { branch, wtPath };
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Resolve symlinks, returning null when the path does not exist.
|
|
71
|
+
* @param {string} p
|
|
72
|
+
* @returns {Promise<string|null>}
|
|
73
|
+
*/
|
|
74
|
+
async function _realpathOrNull(p) {
|
|
75
|
+
try {
|
|
76
|
+
return await fs.realpath(p);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when `wtPath` is NOT a live working tree of any repository — i.e. either
|
|
84
|
+
* it carries no `.git` entry at all, or its `.git` file points at an
|
|
85
|
+
* administrative directory that no longer exists (the registration was dropped).
|
|
86
|
+
*
|
|
87
|
+
* A directory whose registration is intact is never an orphan: `git worktree
|
|
88
|
+
* remove --force` handles that case, so this predicate is what keeps a live
|
|
89
|
+
* worktree — including one owned by a PEER session, or by another repository —
|
|
90
|
+
* out of the delete path below.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} wtPath
|
|
93
|
+
* @returns {Promise<boolean>}
|
|
94
|
+
*/
|
|
95
|
+
async function _isUnregisteredWorktreeDir(wtPath) {
|
|
96
|
+
const dotGit = path.join(wtPath, '.git');
|
|
97
|
+
let st;
|
|
98
|
+
try {
|
|
99
|
+
st = await fs.lstat(dotGit);
|
|
100
|
+
} catch {
|
|
101
|
+
return true; // No .git entry — not a working tree of anything.
|
|
102
|
+
}
|
|
103
|
+
// A real .git DIRECTORY means a full repository lives here; never ours to delete.
|
|
104
|
+
if (!st.isFile()) return false;
|
|
105
|
+
|
|
106
|
+
const content = await fs.readFile(dotGit, 'utf8').catch(() => '');
|
|
107
|
+
const match = /^gitdir:\s*(.+)$/m.exec(content);
|
|
108
|
+
if (!match) return false; // Unparseable — treat as live, refuse.
|
|
109
|
+
|
|
110
|
+
const adminDir = path.resolve(wtPath, match[1].trim());
|
|
111
|
+
// Admin dir still present → git knows this worktree → not an orphan.
|
|
112
|
+
return !(await _exists(adminDir));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Remove an ORPHAN worktree directory (issue #984).
|
|
117
|
+
*
|
|
118
|
+
* An interrupted `removeWorktree` drops the git registration first and can die
|
|
119
|
+
* before the directory is gone. `git worktree add` then fails with
|
|
120
|
+
* "'…' already exists" while `git worktree remove --force` fails with
|
|
121
|
+
* "'…' is not a working tree" (swallowed by nothrow) — the retry loops on the
|
|
122
|
+
* same error until someone deletes the directory by hand.
|
|
123
|
+
*
|
|
124
|
+
* SAFETY — this is a recursive delete inside library code, so every condition
|
|
125
|
+
* below is asserted in code before `fs.rm` runs. Any failed condition returns
|
|
126
|
+
* false and leaves the directory alone (the retry then fails with git's own
|
|
127
|
+
* error, which is the pre-#984 behaviour):
|
|
128
|
+
* 1. `wtPath` itself is a real directory and NOT a symlink — the LEAF is never
|
|
129
|
+
* followed. (A symlink one level up is covered by condition 2.)
|
|
130
|
+
* 2. The literal base `<os.tmpdir()>/so-worktrees` is itself a real directory
|
|
131
|
+
* and NOT a symlink. The base lives on a host-shared /tmp where a co-tenant
|
|
132
|
+
* can pre-create it; without this check the realpath of a planted base
|
|
133
|
+
* symlink BECOMES the delete root, so a `so-worktree-*` directory anywhere
|
|
134
|
+
* on the filesystem would qualify.
|
|
135
|
+
* 3. The REALPATH of `wtPath` is a DIRECT child of the REALPATH of that base
|
|
136
|
+
* — both sides resolved, because macOS resolves /tmp → /private/tmp and
|
|
137
|
+
* os.tmpdir() itself may be a symlinked path.
|
|
138
|
+
* 4. The resolved basename starts with `so-worktree-`.
|
|
139
|
+
* 5. It is not a live working tree of ANY repository (see above).
|
|
140
|
+
* Note what is NOT asserted: an intermediate component of `wtPath` other than
|
|
141
|
+
* the base cannot exist — the path is `<base>/so-worktree-<suffix>`, computed by
|
|
142
|
+
* `_worktreeInfo`, never caller-supplied.
|
|
143
|
+
* `git worktree prune` is deliberately NOT used: it is repo-global and would
|
|
144
|
+
* drop a peer session's stale registration.
|
|
145
|
+
*
|
|
146
|
+
* CEILING (BV-004): the delete is bounded to one directory directly under the
|
|
147
|
+
* fixed base `<os.tmpdir()>/so-worktrees` whose name carries the `so-worktree-`
|
|
148
|
+
* prefix. Revisit when worktree paths become caller-supplied (the
|
|
149
|
+
* `validateWorkspacePath` follow-up noted above) or move out of os.tmpdir() —
|
|
150
|
+
* at that point the containment base is no longer a constant and must be passed
|
|
151
|
+
* in and validated by the caller.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} wtPath
|
|
154
|
+
* @returns {Promise<boolean>} true when a directory was actually removed.
|
|
155
|
+
*/
|
|
156
|
+
async function _removeOrphanWorktreeDir(wtPath) {
|
|
157
|
+
let lst;
|
|
158
|
+
try {
|
|
159
|
+
lst = await fs.lstat(wtPath);
|
|
160
|
+
} catch {
|
|
161
|
+
return false; // Nothing there.
|
|
162
|
+
}
|
|
163
|
+
if (lst.isSymbolicLink() || !lst.isDirectory()) return false;
|
|
164
|
+
|
|
165
|
+
// The containment BASE must be a real directory too: on a host-shared /tmp a
|
|
166
|
+
// co-tenant can pre-create `<os.tmpdir()>/so-worktrees` as a symlink, and a
|
|
167
|
+
// realpath-resolved base would then relocate the delete root off-tmpdir.
|
|
168
|
+
const baseDir = _worktreeBaseDir();
|
|
169
|
+
let baseLst;
|
|
170
|
+
try {
|
|
171
|
+
baseLst = await fs.lstat(baseDir);
|
|
172
|
+
} catch {
|
|
173
|
+
return false; // No base directory — nothing under it to remove.
|
|
174
|
+
}
|
|
175
|
+
if (baseLst.isSymbolicLink() || !baseLst.isDirectory()) return false;
|
|
176
|
+
|
|
177
|
+
const realWt = await _realpathOrNull(wtPath);
|
|
178
|
+
const realBase = await _realpathOrNull(baseDir);
|
|
179
|
+
if (!realWt || !realBase) return false;
|
|
180
|
+
if (!isPathInside(realWt, realBase)) return false;
|
|
181
|
+
// Direct child only — matches the CEILING below; no nested path qualifies.
|
|
182
|
+
if (path.dirname(realWt) !== realBase) return false;
|
|
183
|
+
if (!path.basename(realWt).startsWith('so-worktree-')) return false;
|
|
184
|
+
|
|
185
|
+
if (!(await _isUnregisteredWorktreeDir(realWt))) return false;
|
|
186
|
+
|
|
187
|
+
// RACE WINDOW (accepted, #1390 P7): `_isUnregisteredWorktreeDir` above and
|
|
188
|
+
// `fs.rm` below are two separate syscalls, not one atomic check-then-act.
|
|
189
|
+
// Both plausible racers here are session-orchestrator sessions on the SAME
|
|
190
|
+
// host, contending over the same `<os.tmpdir()>/so-worktrees` base: one
|
|
191
|
+
// could register (`git worktree add`) or delete `realWt` in the gap between
|
|
192
|
+
// the two calls. No lock primitive guards this sub-millisecond window
|
|
193
|
+
// between our own sessions — the worst case is a benign retry (the caller's
|
|
194
|
+
// `git worktree add` above already retries once on failure), never data
|
|
195
|
+
// loss, because neither racer ever touches a path outside its own
|
|
196
|
+
// `so-worktree-*` name.
|
|
197
|
+
// REVISIT TRIGGER (BV-004): an UNRELATED process gains the ability to
|
|
198
|
+
// create or delete `so-worktree-*` directories under this base — at that
|
|
199
|
+
// point the race is no longer bounded to our own retry semantics and this
|
|
200
|
+
// window needs an actual lock, not an accepted gap.
|
|
201
|
+
await fs.rm(realWt, { recursive: true, force: true });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
60
205
|
// ---------------------------------------------------------------------------
|
|
61
206
|
// validateWorkspacePath
|
|
62
207
|
// ---------------------------------------------------------------------------
|
|
@@ -136,6 +281,13 @@ export async function createWorktree(suffix, baseRef = 'HEAD', options = {}) {
|
|
|
136
281
|
await nothrow(git`git worktree remove ${wtPath} --force`);
|
|
137
282
|
await nothrow(git`git branch -D ${branch}`);
|
|
138
283
|
|
|
284
|
+
// Still on disk after the remove attempt → git has no registration for it
|
|
285
|
+
// (issue #984). Delete the orphan directory under the guards documented on
|
|
286
|
+
// _removeOrphanWorktreeDir; a live/registered worktree never reaches here.
|
|
287
|
+
if (await _exists(wtPath)) {
|
|
288
|
+
await _removeOrphanWorktreeDir(wtPath);
|
|
289
|
+
}
|
|
290
|
+
|
|
139
291
|
try {
|
|
140
292
|
await git`git worktree add -b ${branch} ${wtPath} ${baseRef}`;
|
|
141
293
|
} catch (secondErr) {
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* release-session-lock.mjs — CLI: release THIS session's `session.lock`, emit the
|
|
3
|
+
* terminal `orchestrator.session.lock.released` breadcrumb, and PROVE the lock is
|
|
4
|
+
* gone before exiting 0.
|
|
5
|
+
*
|
|
6
|
+
* ## Why a CLI and not one more `release()` call site (#1395)
|
|
7
|
+
*
|
|
8
|
+
* Measured 2026-09-19: three of the four `release()` call sites already emit
|
|
9
|
+
* `orchestrator.session.lock.released` — `hooks/on-session-end.mjs`,
|
|
10
|
+
* `scripts/lib/autopilot/worktree-pipeline.mjs` and
|
|
11
|
+
* `scripts/lib/session-transition.mjs`. The silent one was never code at all: it
|
|
12
|
+
* is the PROSE step `skills/session-end/SKILL.md` § Phase 3.8, executed by hand
|
|
13
|
+
* by the coordinator LLM. It deletes the lock, so the SessionEnd hook later reads
|
|
14
|
+
* `status: 'absent'` and emits nothing — this repo recorded 5 `lock.acquired`
|
|
15
|
+
* against 0 `lock.released`. A prose "call X, then check Y" step is the shape
|
|
16
|
+
* that gets skipped (`.claude/rules/process-contracts.md`: 339 of 351 issues
|
|
17
|
+
* closed without the prose-mandated pre-step); the repair is ONE command that
|
|
18
|
+
* verifies its own effect and exits non-zero when it cannot.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the emit is not inside `release()`
|
|
21
|
+
*
|
|
22
|
+
* Three structural reasons, none of them fixable here:
|
|
23
|
+
* 1. Import cycle — `scripts/lib/events.mjs` imports `session-lock.mjs`
|
|
24
|
+
* (`sessionAttribution()` reads the lock), so `session-lock.mjs` cannot
|
|
25
|
+
* import `events.mjs`.
|
|
26
|
+
* 2. `release()` is synchronous; `emitEvent()` is async.
|
|
27
|
+
* 3. After the unlink the event envelope can no longer read the lock for
|
|
28
|
+
* correlation — which is exactly why this CLI captures `session_id` and
|
|
29
|
+
* `semantic_session_id` from the lock BEFORE releasing and passes both
|
|
30
|
+
* EXPLICITLY in the payload.
|
|
31
|
+
*
|
|
32
|
+
* ## Reuse note (BV-001.2)
|
|
33
|
+
*
|
|
34
|
+
* `leaveSourceRoot()` in `scripts/lib/session-transition.mjs` is the closest
|
|
35
|
+
* existing helper and is deliberately NOT reused: it also deregisters the
|
|
36
|
+
* host-wide registry entry, which a session closing in place must NOT do at
|
|
37
|
+
* Phase 3.8 (the SessionEnd hook owns deregistration, at the real end). Its
|
|
38
|
+
* `LOCK_RELEASED_EVENT` constant IS reused, so the event name has one writer.
|
|
39
|
+
*
|
|
40
|
+
* Exit codes: 0 verified · 1 user/input or ownership error · 2 system error.
|
|
41
|
+
*
|
|
42
|
+
* Exports (for tests): main.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { parseArgs } from 'node:util';
|
|
46
|
+
|
|
47
|
+
import { readLockDetailed, loadOwnerProof, release } from './lib/session-lock.mjs';
|
|
48
|
+
import { LOCK_RELEASED_EVENT } from './lib/session-transition.mjs';
|
|
49
|
+
import { emitEvent } from './lib/events.mjs';
|
|
50
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `caller` value stamped into the breadcrumb, so this fourth release site stays
|
|
54
|
+
* distinguishable from `on-session-end`, `session-transition` and the autopilot
|
|
55
|
+
* worktree pipeline in the single `orchestrator.session.lock.*` stream.
|
|
56
|
+
*/
|
|
57
|
+
export const PHASE_CALLER = 'session-end-phase-3-8';
|
|
58
|
+
|
|
59
|
+
const HELP = `release-session-lock.mjs — release this session's lock and prove it is gone
|
|
60
|
+
|
|
61
|
+
USAGE
|
|
62
|
+
node scripts/release-session-lock.mjs --session-id <raw-id> [--repo-root <path>] [--json]
|
|
63
|
+
|
|
64
|
+
WHAT IT DOES
|
|
65
|
+
1. Reads <repo-root>/.orchestrator/session.lock.
|
|
66
|
+
2. Releases it IFF its raw session_id equals --session-id (owner-proof gated,
|
|
67
|
+
exactly as hooks/on-session-end.mjs gates it).
|
|
68
|
+
3. Emits orchestrator.session.lock.released with the ids read BEFORE the
|
|
69
|
+
delete, plus caller="${PHASE_CALLER}".
|
|
70
|
+
4. RE-READS the lock path and exits 0 only when our lock is provably gone.
|
|
71
|
+
|
|
72
|
+
FLAGS
|
|
73
|
+
--session-id <id> REQUIRED. The RAW (physical) session id that owns the lock
|
|
74
|
+
— .orchestrator/session.lock "session_id", never STATE.md
|
|
75
|
+
"session" and never the semantic id. A lock owned by a
|
|
76
|
+
different id is NEVER released.
|
|
77
|
+
--repo-root <path> Repo whose lock is released (default: cwd).
|
|
78
|
+
--json Machine-readable result on stdout.
|
|
79
|
+
-h, --help Show this help and exit 0.
|
|
80
|
+
|
|
81
|
+
OUTCOMES
|
|
82
|
+
absent no lock file — nothing released, NO event, exit 0.
|
|
83
|
+
deleted our lock was removed and re-read as gone.
|
|
84
|
+
already-gone ownership matched but the lock had already vanished; the event
|
|
85
|
+
is still written, because a lock removed by a third party while
|
|
86
|
+
we held it is the forensically interesting case.
|
|
87
|
+
|
|
88
|
+
EXIT CODES
|
|
89
|
+
0 — the lock is provably absent (released now, or nothing to release)
|
|
90
|
+
1 — user/input or ownership error: missing/empty --session-id, unknown flag,
|
|
91
|
+
or a lock owned by a different session (nothing is touched)
|
|
92
|
+
2 — system error: unreadable/corrupt lock, filesystem error, owner-proof
|
|
93
|
+
mismatch, the lock still present after release, or the breadcrumb could
|
|
94
|
+
not be written
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Print the one-line human summary (stdout = data, stderr = diagnostics).
|
|
99
|
+
*
|
|
100
|
+
* @param {{outcome: string, verified: boolean, session_id: string, event_emitted: boolean}} result
|
|
101
|
+
*/
|
|
102
|
+
function printHuman(result) {
|
|
103
|
+
if (result.outcome === 'absent') {
|
|
104
|
+
process.stdout.write('session-lock: nothing to release — no lock file (no event written).\n');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const verified = result.verified ? 'verified gone' : 'NOT verified — lock still present';
|
|
108
|
+
const event = result.event_emitted ? 'breadcrumb written' : 'breadcrumb FAILED';
|
|
109
|
+
process.stdout.write(
|
|
110
|
+
`session-lock ${result.outcome} for session ${result.session_id} — ${verified}, ${event}.\n`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Did OUR lock really vanish?
|
|
116
|
+
*
|
|
117
|
+
* Computed from a FRESH read after `release()` returned, deliberately not from
|
|
118
|
+
* `release()`'s own `verified` field: that field is absent on the benign
|
|
119
|
+
* `no-lock` branch (so a caller copying it reports `verified: false` for a lock
|
|
120
|
+
* that IS gone), and it was computed before this process did anything else. The
|
|
121
|
+
* CLI's whole contract is "exit 0 proves absence", so it re-reads.
|
|
122
|
+
*
|
|
123
|
+
* A FOREIGN lock present at the re-read counts as verified — ours is gone and a
|
|
124
|
+
* sibling legitimately acquired the path in the race window (same rule as
|
|
125
|
+
* `release()`'s post-delete verify; PSA-005: never touch a lock we do not own).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} repoRoot
|
|
128
|
+
* @param {string} sessionId
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
function ourLockIsGone(repoRoot, sessionId) {
|
|
132
|
+
const after = readLockDetailed({ repoRoot });
|
|
133
|
+
if (after.status === 'absent') return true;
|
|
134
|
+
if (after.status === 'ok') return after.lock.session_id !== sessionId;
|
|
135
|
+
// unreadable / corrupt — absence cannot be confirmed, so it is not claimed.
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* CLI entry point.
|
|
141
|
+
*
|
|
142
|
+
* @param {{argv?: string[]}} [opts]
|
|
143
|
+
* @returns {Promise<number>} process exit code
|
|
144
|
+
*/
|
|
145
|
+
export async function main({ argv = process.argv.slice(2) } = {}) {
|
|
146
|
+
let values;
|
|
147
|
+
try {
|
|
148
|
+
({ values } = parseArgs({
|
|
149
|
+
args: argv,
|
|
150
|
+
options: {
|
|
151
|
+
'repo-root': { type: 'string' },
|
|
152
|
+
'session-id': { type: 'string' },
|
|
153
|
+
json: { type: 'boolean', default: false },
|
|
154
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
155
|
+
},
|
|
156
|
+
strict: true,
|
|
157
|
+
allowPositionals: false,
|
|
158
|
+
}));
|
|
159
|
+
} catch (err) {
|
|
160
|
+
process.stderr.write(`release-session-lock: ${err?.message ?? 'bad arguments'}\n`);
|
|
161
|
+
process.stderr.write('Run with --help for usage.\n');
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (values.help) {
|
|
166
|
+
process.stdout.write(HELP);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const sessionId = values['session-id'];
|
|
171
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
172
|
+
process.stderr.write('release-session-lock: --session-id <raw-id> is required.\n');
|
|
173
|
+
process.stderr.write('Run with --help for usage.\n');
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const repoRoot = values['repo-root'] ?? process.cwd();
|
|
178
|
+
|
|
179
|
+
const emitResult = async (result) => {
|
|
180
|
+
if (values.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
181
|
+
else printHuman(result);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// -- Step 1: read the lock, and capture the ids BEFORE anything is deleted --
|
|
185
|
+
const lockDetail = readLockDetailed({ repoRoot });
|
|
186
|
+
|
|
187
|
+
if (lockDetail.status === 'absent') {
|
|
188
|
+
// Nothing was released, so nothing terminal happened: NO event. Writing one
|
|
189
|
+
// here would put a fictional lifecycle end into the stream — the idempotent
|
|
190
|
+
// re-run case is not a release.
|
|
191
|
+
const result = { ok: true, outcome: 'absent', verified: true, session_id: sessionId, event_emitted: false };
|
|
192
|
+
await emitResult(result);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (lockDetail.status !== 'ok') {
|
|
197
|
+
process.stderr.write(`release-session-lock: lock is ${lockDetail.status} — ownership cannot be established; nothing touched.\n`);
|
|
198
|
+
await emitResult({
|
|
199
|
+
ok: false, outcome: `lock-${lockDetail.status}`, verified: false, session_id: sessionId, event_emitted: false,
|
|
200
|
+
});
|
|
201
|
+
return 2;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const lock = lockDetail.lock;
|
|
205
|
+
if (lock.session_id !== sessionId) {
|
|
206
|
+
// A raw-id mismatch never grants release ownership (same gate as
|
|
207
|
+
// hooks/on-session-end.mjs). Reconciliation of a foreign lease is the
|
|
208
|
+
// reaper's job, not this command's.
|
|
209
|
+
process.stderr.write(`release-session-lock: lock is owned by a different session — not released.\n`);
|
|
210
|
+
await emitResult({
|
|
211
|
+
ok: false, outcome: 'session-mismatch', verified: false, session_id: sessionId, event_emitted: false,
|
|
212
|
+
});
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const semanticSessionId = typeof lock.semantic_session_id === 'string' && lock.semantic_session_id.length > 0
|
|
217
|
+
? lock.semantic_session_id
|
|
218
|
+
: null;
|
|
219
|
+
|
|
220
|
+
// -- Step 2: release, owner-proof gated (#987/#989) ------------------------
|
|
221
|
+
// `proof` is null whenever ownership cannot be proven; release() treats null
|
|
222
|
+
// as absent and degrades to the session_id-only path, so it is passed
|
|
223
|
+
// through unguarded.
|
|
224
|
+
const proof = loadOwnerProof({ repoRoot });
|
|
225
|
+
const releaseResult = release({ sessionId, repoRoot, proof });
|
|
226
|
+
|
|
227
|
+
if (releaseResult.ok !== true) {
|
|
228
|
+
process.stderr.write(`release-session-lock: release failed — ${releaseResult.reason ?? 'fs-error'}\n`);
|
|
229
|
+
await emitResult({
|
|
230
|
+
ok: false, outcome: `release-${releaseResult.reason ?? 'fs-error'}`, verified: false, session_id: sessionId, event_emitted: false,
|
|
231
|
+
});
|
|
232
|
+
return 2;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const alreadyGone = releaseResult.deleted !== true && releaseResult.reason === 'no-lock';
|
|
236
|
+
if (releaseResult.deleted !== true && !alreadyGone) {
|
|
237
|
+
// 'proof-mismatch' (and any future non-delete reason): ownership matched on
|
|
238
|
+
// the raw id but the second factor contradicted it. Contradictory on-disk
|
|
239
|
+
// state, not a bad argument — exit 2.
|
|
240
|
+
process.stderr.write(`release-session-lock: lock not released — ${releaseResult.reason ?? 'not-deleted'}\n`);
|
|
241
|
+
await emitResult({
|
|
242
|
+
ok: false, outcome: `release-${releaseResult.reason ?? 'not-deleted'}`, verified: false, session_id: sessionId, event_emitted: false,
|
|
243
|
+
});
|
|
244
|
+
return 2;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// -- Step 3: prove it ------------------------------------------------------
|
|
248
|
+
const verified = ourLockIsGone(repoRoot, sessionId);
|
|
249
|
+
const outcome = alreadyGone ? 'already-gone' : 'deleted';
|
|
250
|
+
|
|
251
|
+
// -- Step 4: the terminal breadcrumb --------------------------------------
|
|
252
|
+
// The ids are passed EXPLICITLY: the lock is gone by now, so emitEvent's
|
|
253
|
+
// lock-derived correlation would find nothing to attribute this record to.
|
|
254
|
+
let eventEmitted = true;
|
|
255
|
+
let emitError = null;
|
|
256
|
+
try {
|
|
257
|
+
await emitEvent(LOCK_RELEASED_EVENT, {
|
|
258
|
+
session_id: sessionId,
|
|
259
|
+
...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
|
|
260
|
+
caller: PHASE_CALLER,
|
|
261
|
+
outcome,
|
|
262
|
+
verified,
|
|
263
|
+
}, { repoRoot });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
eventEmitted = false;
|
|
266
|
+
emitError = err?.message ?? String(err);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const result = {
|
|
270
|
+
ok: verified && eventEmitted,
|
|
271
|
+
outcome,
|
|
272
|
+
verified,
|
|
273
|
+
session_id: sessionId,
|
|
274
|
+
...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
|
|
275
|
+
event_emitted: eventEmitted,
|
|
276
|
+
...(emitError !== null ? { event_error: emitError } : {}),
|
|
277
|
+
};
|
|
278
|
+
await emitResult(result);
|
|
279
|
+
|
|
280
|
+
if (!verified) {
|
|
281
|
+
process.stderr.write('release-session-lock: the lock is STILL present after release — not verified.\n');
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
284
|
+
if (!eventEmitted) {
|
|
285
|
+
// The release itself succeeded; what failed is the one thing this command
|
|
286
|
+
// exists to guarantee, so it is reported as a system error rather than
|
|
287
|
+
// swallowed. Phase 3.8 treats a non-zero exit as a WARN and continues.
|
|
288
|
+
process.stderr.write(`release-session-lock: breadcrumb could not be written — ${emitError}\n`);
|
|
289
|
+
return 2;
|
|
290
|
+
}
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// CLI guard — prevents execution during test-time imports.
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
if (isMainModule(import.meta.url)) {
|
|
299
|
+
main()
|
|
300
|
+
.then((code) => process.exit(code))
|
|
301
|
+
.catch((err) => {
|
|
302
|
+
process.stderr.write(`release-session-lock: unexpected error: ${err?.stack ?? err}\n`);
|
|
303
|
+
process.exit(2);
|
|
304
|
+
});
|
|
305
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -384,7 +384,10 @@ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
|
|
|
384
384
|
// September 10 campaign snapshot: inputs and receipt reproduce the dated 4.3-planned
|
|
385
385
|
// render made while 4.2.0 was current. Exact files only; neighboring marketing
|
|
386
386
|
// remains checked. Do not bump receipt props without a newly reviewed render.
|
|
387
|
-
|
|
387
|
+
// `scripts/lib/locks/index.mjs` is the one-cycle deprecation shim (5.2.0 CHANGELOG, Changed): its
|
|
388
|
+
// runtime warning names the version it was deprecated IN, so it legitimately carries the previous
|
|
389
|
+
// release literal until its 6.0.0 removal — version history by construction, not drift.
|
|
390
|
+
export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|scripts\/lib\/locks\/index\.mjs|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|skills\/release\/SKILL\.md|marketing\/remotion\/(?:README\.md|campaign\.json|render-receipt\.json|src\/ReleaseFilm\.tsx)$)/;
|
|
388
391
|
|
|
389
392
|
/** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
|
|
390
393
|
export function checkLeakage(lines) {
|
|
@@ -881,14 +884,36 @@ export function evaluateNpmAuth(whoami) {
|
|
|
881
884
|
* never found out". Only an actual `status: 'green'` reading passes; a release
|
|
882
885
|
* must never proceed on an unknown CI state.
|
|
883
886
|
*
|
|
884
|
-
*
|
|
887
|
+
* The row also carries the CAUSE the probe already knows, because the three
|
|
888
|
+
* ways this row goes red are indistinguishable without it (#1384 P5): a short
|
|
889
|
+
* `sha`, a commit the mirror has never seen, and a HEAD with no pipeline all
|
|
890
|
+
* printed `CI status unknown (query-failed)` / `status: unknown`. The probe's
|
|
891
|
+
* `detail` (degraded branch, `degradedResult`) and `details.reason` /
|
|
892
|
+
* `failingJobName` (read branch, `sanitizeApiText`) are clamped and
|
|
893
|
+
* control-byte-escaped AT THEIR GENERATION in `ci-status-banner.mjs` —
|
|
894
|
+
* appended here, never re-formatted. That escaping is load-bearing, not
|
|
895
|
+
* hygiene: the read branch interpolates a RAW API `status` value into
|
|
896
|
+
* `reason`, and until #1384 f-4 a status carrying `\r` + an ANSI sequence
|
|
897
|
+
* could repaint THIS row green while its verdict stayed `ok:false`. Do not
|
|
898
|
+
* append a further probe field here without checking it goes through one of
|
|
899
|
+
* those two. With neither present the text is byte-identical to
|
|
900
|
+
* before, so a plain green or a named failing job reads exactly as it did.
|
|
901
|
+
*
|
|
902
|
+
* @param {null | {status?: string, failingJobName?: string, degraded?: string,
|
|
903
|
+
* detail?: string, details?: {reason?: string}}} ci
|
|
885
904
|
* @returns {{ok: boolean, detail: string}}
|
|
886
905
|
*/
|
|
887
906
|
export function evaluateCiRow(ci) {
|
|
888
907
|
if (ci === null || ci === undefined) return { ok: false, detail: 'CI status unavailable' };
|
|
889
|
-
if (ci.degraded)
|
|
890
|
-
|
|
891
|
-
|
|
908
|
+
if (ci.degraded) {
|
|
909
|
+
const why = ci.detail ? `: ${ci.detail}` : '';
|
|
910
|
+
return { ok: false, detail: `CI status unknown (${ci.degraded}${why})` };
|
|
911
|
+
}
|
|
912
|
+
// A failing job NAMES the failure, so it wins over the generic reason;
|
|
913
|
+
// `reason` is what fills the gap when there is no job to name (`unknown`).
|
|
914
|
+
const reason = ci.failingJobName || ci.details?.reason || '';
|
|
915
|
+
const why = reason ? ` (${reason})` : '';
|
|
916
|
+
return { ok: ci.status === 'green', detail: `status: ${ci.status}${why}` };
|
|
892
917
|
}
|
|
893
918
|
|
|
894
919
|
/**
|