diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index e268550b2..f609081e5 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -79,9 +79,7 @@ removed after current-main verification; it is not missing recommended work. | 27 | `#040` | Optional | High — visual QA/accessibility | When baseline owner/update workflow exists | 1–2 days | Establish a small stable desktop/mobile/accessibility baseline set. Do not make it blocking if flake or maintenance cost outweighs detection value. | | 28 | `#039` | Optional | High — frontend architecture | During a concrete catalogue-toolbar project | 0.5–1 day inventory; 1–3 days code | Converge only repeated toolbar behavior without flattening search semantics. Stop when there is no bounded implementation target. | | 29 | `#065` | A2 | High — document-viewer UI | Only when the user explicitly resumes the paused task | 0.5–1.5 days | Finish the compact source-text accordion, citation/search auto-open, print restoration, and 320/390/1280 px coverage. Keep the preserved branch untouched until explicit resume; no provider calls. | -| 30 | `#077` | A3 | High — workflow safety | Next workflow-hardening window | 1–2 hours | Add a cooperative primary-checkout write lease/check that prevents a second task from writing, switching, or synchronizing the canonical checkout while another owner or dirty state exists. Keep read-only work and independent feature worktrees unblocked. | -| 31 | `#078` | A3 | Standard — reconciliation tooling | Before another broad reconciliation | 2–4 hours | Generate one deterministic, secret-safe evidence pack from the reconciliation lifecycle: disposition rows, operation markers, archive refs, bundle verification, hashes, worktree counts, and local/base equality. Never fetch, call providers, or delete implicitly. | -| 32 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | +| 30 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | @@ -130,8 +128,6 @@ removed after current-main verification; it is not missing recommended work. | #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | | #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | | #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #077 | P2 | issue | Concurrent tasks can re-dirty the canonical primary checkout | **Outcome:** `C:\Dev\Apps\Database` remains a clean synchronization target while write work happens in task-owned worktrees. **Next:** add a cooperative owner/lease check to task-start and lifecycle transitions; before a primary write, branch switch, fast-forward, or cleanup, report owner, dirty state, and Git operation markers and fail closed on an active owner. Include stale-lease recovery. **Success:** a focused concurrency test refuses a second primary writer while read-only commands and separate worktrees continue normally. **Stop:** do not add an OS-wide lock, kill processes, discard existing dirty files, or serialize independent feature worktrees. | primary re-dirtied by another active task immediately after reconciliation proof; session 2026-07-24 | 2026-07-24 | -| #078 | P3 | task | Generate a deterministic reconciliation evidence pack | **Outcome:** one report-only command produces the final local evidence now assembled manually. **Next:** extend the reconciliation lifecycle with an explicit output path and include the frozen base/HEAD, per-worktree dispositions, operation markers resolved through Git, archive refs, bundle path/size/SHA-256/verify result, retained worktree count, and local/base tree equality. Accept remote PR state only as explicit approved input. **Success:** fixture tests prove deterministic output and redaction; an interrupted run leaves no false completion record. **Stop:** never fetch, call GitHub/providers, inspect secret values, mutate refs, or delete work implicitly. | `scripts/reconciliation-preflight.mjs`; `docs/reconciliation-playbook.md`; session 2026-07-24 | 2026-07-24 | | #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | | #080 | P3 | rec | Re-test the removed admission-to-discharge alias widening | **Outcome:** decide with measured evidence whether the user-approved 2026-07-21 widening (NMHS admission-to-discharge titles satisfying the admission expectation) belongs back on the wide-tier `AdmissionCommunityPts` list. #030 removed it so one document could not fill both comparison slots, but coverage now enforces distinct document identities by maximum matching, so the widening is no longer a false-pass route and its removal may instead cost legitimate admission credit. **Next:** with approval, run an eval-canary baseline/post pair over the admission-discharge comparison cases with and without those two titles. **Success:** restore only on measured non-regression (recall pinned 1.0, zero per-case reciprocal-rank regressions); otherwise record keep-removed and close. **Stop:** never restore on offline reasoning alone. | `src/lib/eval-document-matching.ts`; #030 archive row; session 2026-07-25 | 2026-07-25 | @@ -141,8 +137,10 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | ID | Type | Summary | Outcome | Resolved | | ---- | ----- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| #077 | issue | Concurrent tasks can re-dirty the canonical primary checkout | Added cooperative primary-checkout write lease with dirty/operation fail-closed checks, stale-owner recovery, and lifecycle start/cleanup wiring; focused concurrency tests refuse a second primary writer while read-only/feature worktrees stay unblocked. | 2026-07-25 | +| #078 | task | Generate a deterministic reconciliation evidence pack | Added report-only atomic evidence pack with dispositions, markers, archive refs, bundle verify/hash, worktree counts, and local/base equality; fixture tests prove determinism/redaction and no false completion record on interrupt. | 2026-07-25 | | #066 | task | Land and prove the streamlined six-item sidebar | Proven on `origin/main` via PR #1174 (`4dc76306 Land streamlined six-item sidebar`). Six-item rail shipped; open ledger row was stale post-merge. | 2026-07-25 | -| #067 | issue | Reconciliation preflight test times out under full-suite load | Fixed in PR #1191 (`e2488dbb`): JSON contract calls `collectReconciliationState()` in-process instead of spawning a second Node/Vite graph under full-suite load. No global timeout raise or heavy-test lock bypass. | 2026-07-25 | +| #067 | issue | Reconciliation preflight test times out under full-suite load | Fixed in PR #1191 (`e2488dbb`) by calling `collectReconciliationState()` in-process; PR #1203 further injects a fixture `repositoryRoot` so the contract no longer scales with the live worktree farm. No global timeout raise or heavy-test lock bypass. | 2026-07-25 | | #007 | rec | `/tools` vs `/?mode=tools` parallel Tools entry points | Resolved as `/tools` canonical (PT-11 already documented on `/applications` redirect). Sidebar, appModeHomeHref, universal-search, prefetch, sitemap, and reachability now use `/tools`; `/?mode=tools` remains a dashboard-mode alias. Reachability allowlist entry removed. | 2026-07-24 | | #030 | issue | Wide-tier alias lets one doc satisfy both comparison slots | Fixed on `cursor/search-correctness-030-075-6273`: removed dual-listed Admission-to-Discharge titles from AdmissionCommunityPts so one retrieved source cannot make allHit true for both comparison slots; fail-closed contracts in `tests/eval-document-matching.test.ts`. RAG impact: no retrieval behaviour change — eval matching only. Hardened after merge: coverage dedupes by document identity and assigns by maximum matching (#080). | 2026-07-24 | | #075 | issue | Search-scope label enumeration can truncate after 1,000 rows | Fixed on `cursor/search-correctness-030-075-6273`: `loadScopeLabels` pages document_labels with deterministic order/batching past the Supabase 1k cap; multi-page >1000 contracts in `tests/search-scope.test.ts`. Isolated from mixed PR #1132. RAG impact: no retrieval behaviour change — label pagination only. | 2026-07-24 | diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 5d3e29cb0..54fb49446 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -12,8 +12,14 @@ The reusable procedure is [`docs/reconciliation-playbook.md`](reconciliation-pla cleanup. It uses cached Git refs, never fetches, and reports primary/worktree dirty state, detached worktrees, ahead/behind counts, and operation markers. Add `--include-processes` only when ownership could block cleanup; that path emits metadata/counts and never raw command lines. -- `workflow:lifecycle -- --phase reconcile` selects the preflight locally and lists remote fetch as - a separate approval-required action. +- `node scripts/reconciliation-evidence-pack.mjs --output ` writes one deterministic secret-safe + local evidence pack (atomic) covering dispositions, markers, archive refs, bundle verification, + hashes, worktree counts, and local/base tree equality without fetching or deleting. +- `node scripts/primary-checkout-lease.mjs --check` is the cooperative primary-write gate: refuse a + second primary writer while another live owner or dirty/operation state exists; keep read-only + work and independent feature worktrees unblocked. +- `workflow:lifecycle -- --phase reconcile` selects the preflight and evidence pack locally and lists remote fetch as + a separate approval-required action. `start`/`cleanup` select the primary-checkout lease check. - Candidate filtering is cheap-first: owner/open-PR/review-ledger/ancestry before patch comparison; `merge-tree` remains a last resort. This avoids repeating the slow all-ref sweep that dominated the historical reconciliation. diff --git a/docs/productivity-workflows.md b/docs/productivity-workflows.md index bdd263559..8624e7bce 100644 --- a/docs/productivity-workflows.md +++ b/docs/productivity-workflows.md @@ -24,9 +24,13 @@ The repository exposes seven offline-first workflow planners. Each planner inspe - Use `-- --files pathA,pathB` to plan an explicit proposed change before editing. - Use `workflow:triage -- --log ` to classify a captured failure. - Use lifecycle phase `reconcile` for broad multi-worktree work. It selects the report-only - `node scripts/reconciliation-preflight.mjs` locally and keeps `git fetch --prune origin` approval-gated. Add + `node scripts/reconciliation-preflight.mjs` and + `node scripts/reconciliation-evidence-pack.mjs --output .local/reconciliation-evidence/pack.json` + locally and keeps `git fetch --prune origin` approval-gated. Add `--include-processes` to the preflight only when process ownership may block cleanup; it never - serializes raw command lines. + serializes raw command lines. Lifecycle `start`/`cleanup` select + `node scripts/primary-checkout-lease.mjs --check` so primary writes fail closed under another + owner or dirty/operation state without blocking read-only or feature worktrees. The existing shared `workflow:run`, `workflow:status`, `workflow:verify`, `workflow:deps`, `workflow:clean-state`, `workflow:export`, and `workflow:handoff` commands now resolve their shared implementation through the repository's Git common directory. This keeps them portable in linked and detached Codex worktrees. Set `CODEX_LOCAL_WORKFLOW_ROOT` only when the shared tools live somewhere non-standard. diff --git a/docs/reconciliation-playbook.md b/docs/reconciliation-playbook.md index 8d7c4abaf..100679891 100644 --- a/docs/reconciliation-playbook.md +++ b/docs/reconciliation-playbook.md @@ -9,12 +9,27 @@ focused verification path. ```powershell npm run workflow:lifecycle -- --phase reconcile --write-evidence node scripts/reconciliation-preflight.mjs +node scripts/reconciliation-evidence-pack.mjs --output .local/reconciliation-evidence/pack.json ``` The preflight is local, read-only, and cached-ref-only. It inventories the primary checkout, worktrees, dirty entries, detached state, ahead/behind counts, and Git operation markers. It never fetches, scans providers, deletes anything, or treats a preserved branch as approved. +The evidence pack writes one deterministic JSON report (atomic rename) with frozen base/HEAD, +per-worktree dispositions, operation markers, archive refs, optional bundle path/size/SHA-256/verify, +retained worktree counts, and local/base tree equality. Secrets are redacted; interrupted runs leave +only a `.partial` file, never a false completion record at the final path. + +Before any primary checkout write, branch switch, fast-forward, or cleanup: + +```powershell +node scripts/primary-checkout-lease.mjs --check +``` + +That cooperative lease refuses a second primary writer while another live owner or dirty/operation +state exists. Read-only commands and independent feature worktrees stay unblocked. + When process ownership may block cleanup, add the explicit process check: ```powershell diff --git a/package.json b/package.json index 52c72444b..dffdc9ebc 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,9 @@ "workflow:rag-lab": "node scripts/productivity-workflow.mjs rag-lab", "workflow:operator-closeout": "node scripts/productivity-workflow.mjs operator-closeout", "workflow:lifecycle": "node scripts/productivity-workflow.mjs lifecycle", + "reconciliation:preflight": "node scripts/reconciliation-preflight.mjs", + "reconciliation:evidence-pack": "node scripts/reconciliation-evidence-pack.mjs", + "check:primary-checkout-lease": "node scripts/primary-checkout-lease.mjs --check", "skill:create": "node scripts/skill-create.mjs", "branch:cleanup": "node scripts/sweep-merged-branches.mjs", "skills": "node scripts/list-database-skills.mjs", diff --git a/scripts/primary-checkout-lease.mjs b/scripts/primary-checkout-lease.mjs new file mode 100644 index 000000000..be2dee95a --- /dev/null +++ b/scripts/primary-checkout-lease.mjs @@ -0,0 +1,405 @@ +#!/usr/bin/env node + +/** + * Cooperative primary-checkout write lease (#077). + * + * Prevents a second task from writing, switching, or synchronizing the + * canonical primary checkout while another live owner holds the lease, or while + * the primary is dirty / mid-operation. Read-only inspection and independent + * feature worktrees do not acquire this lease and stay unblocked. + * + * This is intentionally not an OS-wide lock and never kills processes or discards work. + */ + +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; +import { redactSensitiveText } from "./sensitive-text.mjs"; +import { reconciliationPreflightInternals } from "./reconciliation-preflight.mjs"; + +const defaultRepositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const tokenEnvironmentKey = "CLINICAL_KB_PRIMARY_LEASE_TOKEN"; +const pathEnvironmentKey = "CLINICAL_KB_PRIMARY_LEASE_PATH"; +const { operationMarkers, normalizePath } = reconciliationPreflightInternals; + +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +function git(args, cwd, { allowFailure = false } = {}) { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch (error) { + if (allowFailure) return ""; + throw error; + } +} + +export function resolvePrimaryCheckoutPath(repositoryRoot = defaultRepositoryRoot) { + const porcelain = git(["worktree", "list", "--porcelain"], repositoryRoot, { allowFailure: true }); + const match = porcelain.match(/^worktree (.+)$/m); + if (!match) return path.resolve(repositoryRoot); + return path.resolve(match[1]); +} + +function resolveGitCommonDirectory(primaryPath) { + const common = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], primaryPath); + if (!common) throw new Error("Could not resolve the shared Git directory for the primary-checkout lease."); + return path.resolve(common); +} + +function leaseFilePath({ gitCommonDirectory, baseDirectory }) { + if (baseDirectory) { + mkdirSync(baseDirectory, { recursive: true }); + return path.join(baseDirectory, "primary-checkout-lease.json"); + } + return path.join(gitCommonDirectory, "clinical-kb-primary-checkout-lease.json"); +} + +function readLease(leasePath) { + try { + return JSON.parse(readFileSync(leasePath, "utf8")); + } catch { + return null; + } +} + +export function inspectPrimaryCheckoutState(primaryPath) { + const resolved = path.resolve(primaryPath); + const status = git(["status", "--porcelain=v1", "--untracked-files=normal"], resolved, { allowFailure: true }); + const gitDirectory = git(["rev-parse", "--path-format=absolute", "--git-dir"], resolved, { allowFailure: true }); + const head = git(["rev-parse", "HEAD"], resolved, { allowFailure: true }) || null; + const branch = git(["branch", "--show-current"], resolved, { allowFailure: true }) || null; + const statusEntries = status ? status.split(/\r?\n/).filter(Boolean).length : status === "" ? 0 : null; + const operations = + gitDirectory && existsSync(gitDirectory) + ? operationMarkers.filter((marker) => existsSync(path.join(gitDirectory, marker))) + : []; + return { + path: resolved, + head, + branch: branch || null, + statusEntries, + dirty: typeof statusEntries === "number" ? statusEntries > 0 : true, + operations, + inspectionFailed: statusEntries === null || !gitDirectory, + }; +} + +function ownerIsLive(owner) { + return Boolean(owner && processIsAlive(owner.pid)); +} + +/** + * @param {{ + * primaryPath?: string, + * repositoryRoot?: string, + * baseDirectory?: string, + * environment?: Record, + * processId?: number, + * }} [options] + */ +export function inspectPrimaryCheckoutLease({ + primaryPath, + repositoryRoot = defaultRepositoryRoot, + baseDirectory, + environment = process.env, + processId = process.pid, +} = {}) { + const resolvedPrimary = path.resolve(primaryPath ?? resolvePrimaryCheckoutPath(repositoryRoot)); + const gitCommonDirectory = resolveGitCommonDirectory(resolvedPrimary); + const leasePath = leaseFilePath({ gitCommonDirectory, baseDirectory }); + const primary = inspectPrimaryCheckoutState(resolvedPrimary); + const owner = readLease(leasePath); + const inheritedToken = environment[tokenEnvironmentKey]; + const inheritedPath = environment[pathEnvironmentKey]; + const sameOwner = + Boolean(inheritedToken) && + normalizePath(inheritedPath) === normalizePath(leasePath) && + owner?.token === inheritedToken && + ownerIsLive(owner); + const liveForeignOwner = Boolean(owner && ownerIsLive(owner) && !sameOwner); + const staleOwner = Boolean(owner && !ownerIsLive(owner)); + const blockers = []; + + if (primary.inspectionFailed) { + blockers.push({ + code: "primary-inspection-failed", + detail: "The primary checkout could not be inspected completely.", + }); + } + if (primary.dirty) { + blockers.push({ + code: "primary-dirty", + detail: "The primary checkout has tracked or untracked changes.", + }); + } + if (primary.operations.length > 0) { + blockers.push({ + code: "active-git-operation", + detail: `Active Git operation marker(s): ${primary.operations.join(", ")}.`, + }); + } + if (liveForeignOwner) { + blockers.push({ + code: "lease-held", + detail: `Primary write lease held by PID ${owner.pid} (${redactSensitiveText(owner.purpose ?? "unknown")}).`, + }); + } + + return { + primaryPath: resolvedPrimary, + leasePath, + primary, + owner: owner + ? { + ...owner, + purpose: redactSensitiveText(owner.purpose ?? ""), + alive: ownerIsLive(owner), + stale: staleOwner, + } + : null, + sameOwner, + writeAllowed: blockers.length === 0 || sameOwner, + readOnlyAllowed: true, + featureWorktreesUnaffected: true, + blockers, + inspectedByPid: processId, + }; +} + +/** + * @param {{ + * primaryPath?: string, + * repositoryRoot?: string, + * baseDirectory?: string, + * environment?: Record, + * processId?: number, + * ownerLabel?: string, + * purpose?: string, + * }} [options] + */ +export function acquirePrimaryCheckoutLease({ + primaryPath, + repositoryRoot = defaultRepositoryRoot, + baseDirectory, + environment = process.env, + processId = process.pid, + ownerLabel = "task", + purpose = "primary-write", +} = {}) { + const inspection = inspectPrimaryCheckoutLease({ + primaryPath, + repositoryRoot, + baseDirectory, + environment, + processId, + }); + + if (inspection.sameOwner && inspection.owner) { + return { + path: inspection.leasePath, + owner: inspection.owner, + reentrant: true, + environment: { ...environment }, + release() {}, + }; + } + + if (inspection.blockers.some((item) => item.code !== "lease-held")) { + const detail = inspection.blockers.map((item) => item.detail).join(" "); + throw new Error(`Primary checkout write refused: ${detail}`); + } + + if (inspection.owner && inspection.owner.alive) { + throw new Error( + `Primary checkout write refused: lease held by PID ${inspection.owner.pid} (${redactSensitiveText(inspection.owner.purpose ?? "unknown")}).`, + ); + } + + if (inspection.owner && !inspection.owner.alive && existsSync(inspection.leasePath)) { + rmSync(inspection.leasePath, { force: true }); + } + + const token = randomUUID(); + const owner = { + pid: processId, + token, + ownerLabel: redactSensitiveText(ownerLabel), + purpose: redactSensitiveText(purpose), + primaryPath: inspection.primaryPath, + startedAt: new Date().toISOString(), + }; + writeFileSync(inspection.leasePath, `${JSON.stringify(owner, null, 2)}\n`, "utf8"); + + let released = false; + return { + path: inspection.leasePath, + owner, + reentrant: false, + environment: { + ...environment, + [tokenEnvironmentKey]: token, + [pathEnvironmentKey]: inspection.leasePath, + }, + release() { + if (released) return; + released = true; + const current = readLease(inspection.leasePath); + if (current?.token === token) rmSync(inspection.leasePath, { force: true }); + }, + }; +} + +export function assertPrimaryWriteAllowed(options = {}) { + const inspection = inspectPrimaryCheckoutLease(options); + if (!inspection.writeAllowed) { + const detail = inspection.blockers.map((item) => `${item.code}: ${item.detail}`).join("; "); + throw new Error(`Primary checkout write refused (${detail})`); + } + return inspection; +} + +function releasePrimaryCheckoutLeaseByToken({ primaryPath, repositoryRoot, baseDirectory, token }) { + const inspection = inspectPrimaryCheckoutLease({ primaryPath, repositoryRoot, baseDirectory, environment: {} }); + const current = readLease(inspection.leasePath); + if (!current) return { released: false, detail: "no-lease" }; + if (current.token !== token) throw new Error("Primary checkout lease token does not match the active owner."); + rmSync(inspection.leasePath, { force: true }); + return { released: true, path: inspection.leasePath }; +} + +function parseArgs(argv) { + const options = { + check: false, + acquire: false, + release: false, + json: false, + ownerLabel: "task", + purpose: "primary-write", + primaryPath: undefined, + token: undefined, + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + const next = () => { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) throw new Error(`Missing value for ${token}`); + index += 1; + return value; + }; + if (token === "--check") options.check = true; + else if (token === "--acquire") options.acquire = true; + else if (token === "--release") options.release = true; + else if (token === "--json") options.json = true; + else if (token === "--owner") options.ownerLabel = next(); + else if (token === "--purpose") options.purpose = next(); + else if (token === "--primary") options.primaryPath = next(); + else if (token === "--token") options.token = next(); + else if (token === "--help" || token === "-h") options.help = true; + else throw new Error(`Unknown option: ${token}`); + } + const modes = [options.check, options.acquire, options.release].filter(Boolean).length; + if (!options.help && modes === 0) options.check = true; + if (!options.help && modes > 1) throw new Error("Use only one of --check, --acquire, or --release."); + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log( + "Usage: node scripts/primary-checkout-lease.mjs [--check|--acquire|--release] [--json] [--primary ] [--owner