← back to Cli Printing Press
feat(cli): runstate isolation and lock lifecycle for parallel build safety (#114)
10150ad3ae217601e0ea2c64662ac7937693db7c · 2026-04-03 01:39:40 -0700 · Trevin Chow
* feat(cli): add lock state management for parallel build safety
Adds LockState struct and operations (acquire, update, status, release,
promote) to the pipeline package. Uses O_CREATE|O_EXCL for atomic lock
acquisition, 30-minute staleness threshold for auto-reclaim, and
heartbeat-based updates at phase transitions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add lock CLI subcommands for build coordination
Exposes lock operations as `printing-press lock {acquire,update,status,
release,promote}` subcommands. All output JSON to stdout for
deterministic parsing by skills. Non-zero exit on blocked acquire.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(skills): add runstate isolation and lock lifecycle to main skill
Replaces all Phase 2-5 $PRESS_LIBRARY/<api>-pp-cli references with
run-scoped $CLI_WORK_DIR. Adds lock acquire/update/release/promote at
phase boundaries and failure paths. Updates Phase 0 Library Check to
detect active builds via lock status. Bumps min-binary-version to 0.3.0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): update consuming skills for lock compatibility
Bumps min-binary-version in score skill to 0.3.0. Adds active build
detection to polish skill when CLI not yet in library.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(cli): update contract tests for runstate isolation
Updates TestPrintingPressSkillUsesRunRootStateFile to check for
$CLI_WORK_DIR instead of <absolute cli dir>. Adds
TestPrintingPressSkillUsesRunstateForBuilds validating that Phase 2-5
uses $CLI_WORK_DIR for --output, lock acquire appears before generation,
and lock promote appears in Phase 5.5.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): mark runstate isolation plan as completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add atomic promote and lock robustness for crash safety
Replace destructive RemoveAll+CopyDir in PromoteWorkingCLI with
staging-dir + atomic-rename swap so the previous library copy survives
if any step fails. Add retry logic for concurrent lock reads, atomic
lock file writes via temp+rename, and stale-lock awareness in the
polish skill's guard check. Includes 3 new failure-scenario tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): silence errcheck lint in workflow_verify.go
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-03-31-001-fix-skill-runstate-isolation-plan.mdA internal/cli/lock.goA internal/cli/lock_test.goM internal/cli/root.goM internal/pipeline/contracts_test.goA internal/pipeline/lock.goA internal/pipeline/lock_test.goM internal/pipeline/workflow_verify.goM skills/printing-press-polish/SKILL.mdM skills/printing-press-score/SKILL.mdM skills/printing-press/SKILL.md
Diff
commit 10150ad3ae217601e0ea2c64662ac7937693db7c
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri Apr 3 01:39:40 2026 -0700
feat(cli): runstate isolation and lock lifecycle for parallel build safety (#114)
* feat(cli): add lock state management for parallel build safety
Adds LockState struct and operations (acquire, update, status, release,
promote) to the pipeline package. Uses O_CREATE|O_EXCL for atomic lock
acquisition, 30-minute staleness threshold for auto-reclaim, and
heartbeat-based updates at phase transitions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add lock CLI subcommands for build coordination
Exposes lock operations as `printing-press lock {acquire,update,status,
release,promote}` subcommands. All output JSON to stdout for
deterministic parsing by skills. Non-zero exit on blocked acquire.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(skills): add runstate isolation and lock lifecycle to main skill
Replaces all Phase 2-5 $PRESS_LIBRARY/<api>-pp-cli references with
run-scoped $CLI_WORK_DIR. Adds lock acquire/update/release/promote at
phase boundaries and failure paths. Updates Phase 0 Library Check to
detect active builds via lock status. Bumps min-binary-version to 0.3.0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(skills): update consuming skills for lock compatibility
Bumps min-binary-version in score skill to 0.3.0. Adds active build
detection to polish skill when CLI not yet in library.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(cli): update contract tests for runstate isolation
Updates TestPrintingPressSkillUsesRunRootStateFile to check for
$CLI_WORK_DIR instead of <absolute cli dir>. Adds
TestPrintingPressSkillUsesRunstateForBuilds validating that Phase 2-5
uses $CLI_WORK_DIR for --output, lock acquire appears before generation,
and lock promote appears in Phase 5.5.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): mark runstate isolation plan as completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add atomic promote and lock robustness for crash safety
Replace destructive RemoveAll+CopyDir in PromoteWorkingCLI with
staging-dir + atomic-rename swap so the previous library copy survives
if any step fails. Add retry logic for concurrent lock reads, atomic
lock file writes via temp+rename, and stale-lock awareness in the
polish skill's guard check. Includes 3 new failure-scenario tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): silence errcheck lint in workflow_verify.go
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...-03-31-001-fix-skill-runstate-isolation-plan.md | 441 +++++++++++++++++++++
internal/cli/lock.go | 258 ++++++++++++
internal/cli/lock_test.go | 202 ++++++++++
internal/cli/root.go | 1 +
internal/pipeline/contracts_test.go | 20 +-
internal/pipeline/lock.go | 320 +++++++++++++++
internal/pipeline/lock_test.go | 438 ++++++++++++++++++++
internal/pipeline/workflow_verify.go | 4 +-
skills/printing-press-polish/SKILL.md | 15 +
skills/printing-press-score/SKILL.md | 4 +-
skills/printing-press/SKILL.md | 158 ++++++--
11 files changed, 1832 insertions(+), 29 deletions(-)
diff --git a/docs/plans/2026-03-31-001-fix-skill-runstate-isolation-plan.md b/docs/plans/2026-03-31-001-fix-skill-runstate-isolation-plan.md
new file mode 100644
index 00000000..385dcdc3
--- /dev/null
+++ b/docs/plans/2026-03-31-001-fix-skill-runstate-isolation-plan.md
@@ -0,0 +1,441 @@
+---
+title: "fix: Use runstate for active builds, add heartbeat lock for parallel safety"
+type: fix
+status: completed
+date: 2026-03-31
+deepened: 2026-03-31
+---
+
+# fix: Use runstate for active builds, add heartbeat lock for parallel safety
+
+## Overview
+
+The main printing-press skill writes generated CLIs directly to `$PRESS_LIBRARY/<api>-pp-cli` during Phases 2-5, contradicting its own stated architecture ("active mutable work lives under `$PRESS_RUNSTATE/`"). This breaks parallel build safety because `$PRESS_LIBRARY` is global while `$PRESS_RUNSTATE` is scoped per workspace. Interrupted sessions also leave partial CLIs in the library that look complete to subsequent runs.
+
+The fix moves active builds to the run-scoped working directory (`$API_RUN_DIR/working/<api>-pp-cli`), adds a heartbeat lock mechanism via `printing-press lock` CLI commands, and copies to library only after shipcheck passes.
+
+## Problem Frame
+
+Two bugs discovered:
+
+1. **Architectural contradiction**: `PRESS_CURRENT` is defined and `mkdir -p`'d in the setup contract but never referenced in Phases 2-5. All generation, build, and verification commands write directly to the global `$PRESS_LIBRARY`.
+
+2. **No interrupted-build detection**: If a session dies mid-build, the next run's Phase 0 Library Check sees a partial CLI in `$PRESS_LIBRARY` and treats it as a complete, published CLI.
+
+The Go binary's automated `print` pipeline already does this correctly — `WorkingCLIDir(apiName, runID)` writes to `.runstate/<scope>/runs/<run-id>/working/<api>-pp-cli`, and `PublishWorkingCLI()` copies to library as a separate step. The interactive skill flow just never adopted the same pattern.
+
+## Requirements Trace
+
+- R1. Active CLI builds must live under `$PRESS_RUNSTATE`, not `$PRESS_LIBRARY`
+- R2. Two parallel agents building the same API from different worktrees must not collide during build
+- R3. A heartbeat lock in a global locks directory must signal build-in-progress to other sessions
+- R4. Interrupted builds must be detectable (stale heartbeat) and reclaimable
+- R5. CLI code moves to `$PRESS_LIBRARY` only after shipcheck passes
+- R6. The lock mechanism must be a deterministic CLI command, not model-improvised bash
+- R7. Existing consuming skills (polish, score, publish) must continue to work
+- R8. The setup contract validation tests must pass after changes
+
+## Scope Boundaries
+
+- The `DefaultOutputDir()` Go function is NOT changed — it's only used when `--output` isn't specified (direct CLI usage), which is a separate concern
+- The `printing-press-catalog` skill is deprecated and gets minimal changes (no lock integration)
+- The polish skill continues to operate on library copies (published CLIs), not working copies
+- No distributed locking or file locking (`flock`) — the heartbeat/staleness approach is sufficient for the use case
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/paths.go`: `WorkingCLIDir(apiName, runID)` — the correct runstate-scoped path the skill should use
+- `internal/pipeline/publish.go`: `PublishWorkingCLI()` — copies from working dir to library via `CopyDir()` + `ClaimOutputDir()`
+- `internal/pipeline/state.go`: `CurrentRunPointer` struct with `UpdatedAt` — existing timestamp pattern
+- `internal/pipeline/pipeline.go`: `ClaimOutputDir()` — atomic `os.Mkdir` for concurrent directory claiming
+- `internal/pipeline/contracts_test.go`: Setup contract validation tests that must pass
+- Setup contract delimiters: `<!-- PRESS_SETUP_CONTRACT_START -->` / `<!-- PRESS_SETUP_CONTRACT_END -->`
+
+### Key Insight: Go Binary Already Has the Right Pattern
+
+`pipeline.Init()` in `pipeline.go:63` uses `WorkingCLIDir(apiName, runID)` as the default output dir. `PublishWorkingCLI()` in `publish.go:78` copies from working to library. The skill just needs to follow this same two-phase pattern.
+
+## Key Technical Decisions
+
+- **Lock file location: `$PRESS_HOME/.locks/<api>-pp-cli.lock`**: The lock lives in a dedicated global locks directory, separate from both library and runstate. This avoids creating anything in the library during build (the original bug) while keeping lock checks simple — one file path, no multi-scope scanning. Phase 0 checks the library for existing CLIs and the locks directory for active builds as two separate concerns.
+
+- **Lock atomicity via `O_CREATE|O_EXCL` on the lock file**: Atomicity is achieved by `os.OpenFile("<api>-pp-cli.lock", O_CREATE|O_EXCL, 0644)` — first writer wins. The `.locks/` directory is created with `os.MkdirAll` (idempotent). For stale lock reclaim, read-then-replace is acceptable given the heartbeat-based design.
+
+- **`CLI_WORK_DIR` variable set after `<api>` is known, not in setup contract**: The setup contract doesn't know `<api>` yet. The new variable is set in the "After you know `<api>`" section alongside `RUN_ID`, `API_RUN_DIR`, etc. This avoids changing the shared setup contract and its validation tests.
+
+- **Staleness threshold: 30 minutes**: Phase 3 (Build) can involve long stretches within a single priority level — a complex P0 implementation with Codex delegation can run 20-30 minutes without a phase-boundary update. 30 minutes avoids false-positive staleness while still detecting genuinely dead sessions. Heartbeat updates happen at phase transitions AND after each priority level in Phase 3, giving 7-10 updates per typical run.
+
+- **Promotion to library via `printing-press lock promote` CLI command, not raw `cp -r`**: The copy-to-library step must be a deterministic CLI command (R6), handle clean replacement of existing library contents (not additive merge), write the CLI manifest, update the `CurrentRunPointer` to reflect the library path, and release the lock — all in one step. Raw `cp -r` would violate R6, leave orphaned files from previous builds, and not update state. The Go binary already has `PublishWorkingCLI()` and `CopyDir()` — the `promote` subcommand wraps this existing logic.
+
+- **Promotion happens immediately after shipcheck, before archiving**: The CLI being in library is the primary deliverable. Archiving manuscripts is supplementary. Promoting first minimizes the window of "verified but not in library" state if the session dies between shipcheck and archive.
+
+- **`lock acquire` auto-reclaims stale locks; `--force` needed only for fresh locks held by a different scope**: This is the common case — a stale lock means the previous session died. Auto-reclaim simplifies the skill's Phase 0 flow (just call acquire, check the result). `--force` is a safety valve for the rare case where a user wants to override a fresh lock they know is theirs from a different worktree.
+
+- **Explicit lock release on all failure/abort paths**: The skill must call `printing-press lock release` whenever a build fails and the user chooses not to retry (generation failure, shipcheck hold, user cancels). This prevents the 30-minute staleness window from blocking other agents after a known failure.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should the setup contract change?** No. `CLI_WORK_DIR` is set after `<api>` is known, outside the contract block. The contract test validates the contract block contents, so avoiding changes there means no test breakage from contract changes alone.
+
+- **Should `DefaultOutputDir()` change?** No. It's used by the `generate` subcommand's default `--output` for direct CLI usage. The skill always passes `--output` explicitly.
+
+- **What if two agents build different APIs from the same worktree?** Safe — each gets a unique `RUN_ID` and thus a unique `API_RUN_DIR/working/<api>-pp-cli` path. The locks in `.locks/` are per-CLI-name, so different APIs don't collide.
+
+- **How does `lock acquire` handle rebuild case (library dir exists, no lock, user wants rebuild)?** Acquire writes the lock file to `.locks/<api>-pp-cli.lock` with `O_CREATE|O_EXCL`. The existing library dir is untouched until `lock promote` replaces its contents.
+
+- **How does Phase 0 distinguish debris directories from completed CLIs?** Check for `go.mod` or `.printing-press.json` presence alongside directory existence. A library directory with no `go.mod` and no active lock is debris from a previous promote that was interrupted — offer cleanup rather than treating as complete CLI.
+
+- **Should `printing-press library list` filter out incomplete directories?** Yes. Library directories that have no `go.mod` or `.printing-press.json` should be excluded from library list results to avoid confusing the publish skill.
+
+### Deferred to Implementation
+
+- **Exact JSON schema for lock file (`.locks/<cli-name>.lock`)**: The lock struct fields and JSON format will be finalized during implementation. Conceptual shape: `{scope, phase, pid, acquired_at, updated_at}`.
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```
+Build lifecycle (before fix):
+
+ Phase 2 ──write──> $PRESS_LIBRARY/<api>-pp-cli
+ Phase 3 ──modify─> $PRESS_LIBRARY/<api>-pp-cli
+ Phase 4 ──verify─> $PRESS_LIBRARY/<api>-pp-cli
+ Phase 6 ──publish from─> $PRESS_LIBRARY/<api>-pp-cli
+
+Build lifecycle (after fix):
+
+ Directory layout:
+ ~/printing-press/.locks/<api>-pp-cli.lock (global lock file)
+ ~/printing-press/.runstate/<scope>/runs/<id>/working/<api>-pp-cli (build here)
+ ~/printing-press/library/<api>-pp-cli (only after promote)
+
+ Phase 2 ──lock acquire──> .locks/<api>-pp-cli.lock (claim name)
+ ──write────────> $API_RUN_DIR/working/<api>-pp-cli (build here)
+ Phase 3 ──lock update───> (heartbeat in .locks/)
+ ──modify───────> $API_RUN_DIR/working/<api>-pp-cli
+ Phase 4 ──lock update───> (heartbeat in .locks/)
+ ──verify───────> $API_RUN_DIR/working/<api>-pp-cli
+ Phase 5.5 ──promote─────> $PRESS_LIBRARY/<api>-pp-cli (copy + release lock)
+ Phase 6 ──publish from──> $PRESS_LIBRARY/<api>-pp-cli
+```
+
+Phase 0 checks two things independently:
+1. Does `$PRESS_LIBRARY/<api>-pp-cli` exist? (completed CLI)
+2. Does `printing-press lock status --cli <api>-pp-cli` report an active lock? (build in progress)
+
+```
+Library dir? | Lock? | Stale? | Has go.mod? | Action
+-------------|-------|--------|-------------|-------
+No | No | N/A | N/A | Proceed normally, acquire lock in Phase 2
+No | Yes | No | N/A | "Actively being built (phase X, Ys ago). Wait, use a different name, or pick different API."
+No | Yes | Yes | N/A | "Interrupted build (stale since X). Reclaim and start fresh?"
+Yes | No | N/A | Yes | Completed CLI — existing "Found existing" flow
+Yes | No | N/A | No | Debris — offer cleanup
+Yes | Yes | No | Any | "Actively being rebuilt. Wait, use a different name, or pick different API."
+Yes | Yes | Yes | Any | "Interrupted rebuild (stale since X). Reclaim?"
+```
+
+Rebuild case: When user chooses "Generate a fresh CLI" from the existing
+"Found existing" flow, Phase 2 acquires the lock (writes to .locks/),
+builds in runstate, and the promote step replaces library contents cleanly.
+
+## Implementation Units
+
+- [ ] **Unit 1: Go — Lock state management**
+
+ **Goal:** Add lock file operations (acquire, update, status, release) to the pipeline package.
+
+ **Requirements:** R3, R4, R6
+
+ **Dependencies:** None
+
+ **Files:**
+ - Create: `internal/pipeline/lock.go`
+ - Test: `internal/pipeline/lock_test.go`
+
+ **Approach:**
+ - Define `LockState` struct (scope, phase, PID, acquired_at, updated_at)
+ - Locks directory: `PressPressHome()/.locks/` (global, not scoped — visible to all agents)
+ - Lock file path: `PressPressHome()/.locks/<cli-name>.lock`
+ - `AcquireLock(cliName, scope string)` — creates `.locks/` with `os.MkdirAll` (idempotent), then writes lock file with `os.OpenFile("<cli-name>.lock", O_CREATE|O_EXCL, 0644)` for atomicity. Auto-reclaims stale locks. Returns error if fresh lock held by different scope. Returns success if no lock, stale lock, or same-scope lock. With `force=true`, overrides even fresh locks from other scopes.
+ - `UpdateLock(cliName, phase string)` — refreshes `updated_at` and `phase`
+ - `LockStatus(cliName string)` — returns current lock state including staleness, and separately checks the library dir for a completed CLI (`go.mod` or `.printing-press.json` present → `has_cli=true`)
+ - `ReleaseLock(cliName string)` — removes lock file (idempotent)
+ - `PromoteWorkingCLI(cliName, workingDir string, state *PipelineState)` — the promotion sequence: if library dir exists, clear its contents; copy working dir contents to library via `CopyDir`; write CLI manifest; update `CurrentRunPointer` so `working_dir` reflects the library path; then release the lock. This wraps the existing `PublishWorkingCLI` + `CopyDir` pattern.
+ - `IsStale(lock LockState)` — checks `updated_at` against 30-minute threshold
+ - Stale lock reclaim: read the existing lock, verify staleness, delete it, then create new lock with `O_CREATE|O_EXCL`. There is a TOCTOU window between delete and create, but this is acceptable — two agents reclaiming the same stale lock is an extremely unlikely race, and the loser gets a clear "lock held" error.
+
+ **Patterns to follow:**
+ - `internal/pipeline/state.go` — JSON struct marshaling pattern (`CurrentRunPointer`)
+ - `internal/pipeline/publish.go` — `PublishWorkingCLI()`, `CopyDir()`, `writeCLIManifestForPublish()` for the promotion logic
+ - `internal/pipeline/pipeline.go` — `ClaimOutputDir()` for directory operations
+
+ **Test scenarios:**
+ - Happy path: Acquire lock when no lock exists — .locks/ dir created, lock file written with correct fields
+ - Happy path: Acquire lock when library dir exists but no lock (rebuild) — lock file created in .locks/
+ - Happy path: Update lock phase, verify updated_at changes and phase changes
+ - Happy path: Release lock, verify lock file removed from .locks/
+ - Happy path: Status on active lock returns held=true, stale=false
+ - Happy path: Promote copies working dir to library, writes manifest, updates run pointer, removes lock
+ - Edge case: Acquire when stale lock exists — auto-reclaims, acquires successfully
+ - Edge case: Acquire when fresh lock exists from different scope — returns error/blocked status
+ - Edge case: Acquire when fresh lock exists from same scope — succeeds (re-entrant for retries)
+ - Edge case: Acquire with force=true when fresh lock exists from different scope — succeeds
+ - Edge case: Status on non-existent lock — returns held=false
+ - Edge case: Status when no lock but library dir has go.mod — returns held=false, has_cli=true
+ - Edge case: Status when no lock and library dir has no go.mod — returns held=false, has_cli=false
+ - Edge case: Status when no lock and no library dir — returns held=false, has_cli=false
+ - Edge case: Promote when library dir has files from previous build — old files replaced, not merged
+ - Error path: Release on non-existent lock file — no error (idempotent)
+ - Error path: Promote when working dir is empty — returns error
+ - Integration: Concurrent acquire from two goroutines — exactly one succeeds via O_CREATE|O_EXCL
+
+ **Verification:**
+ - All tests pass
+ - Lock file is valid JSON readable by `LockStatus`
+ - Promote produces a library dir identical to what `PublishWorkingCLI` would produce
+
+- [ ] **Unit 2: Go — Lock CLI subcommands**
+
+ **Goal:** Expose lock operations as `printing-press lock {acquire,update,status,release}` subcommands.
+
+ **Requirements:** R6
+
+ **Dependencies:** Unit 1
+
+ **Files:**
+ - Create: `internal/cli/lock.go`
+ - Modify: `internal/cli/root.go` (add `rootCmd.AddCommand(newLockCmd())`)
+ - Test: `internal/cli/lock_test.go`
+
+ **Approach:**
+ - Parent command `lock` with subcommands: `acquire`, `update`, `status`, `release`, `promote`
+ - Common flags: `--cli <name>` (required for all)
+ - `acquire` flags: `--scope <scope>` (required), `--force` (override fresh locks from other scopes)
+ - `update` flags: `--phase <phase>` (required)
+ - `status` flags: `--json` (structured output, includes `held`, `stale`, `phase`, `has_cli`, `scope`, `age_seconds`)
+ - `release` flags: none beyond `--cli`
+ - `promote` flags: `--dir <working-dir>` (required — path to the working CLI to promote)
+ - All subcommands output JSON to stdout for deterministic parsing by the skill
+ - Non-zero exit code on blocked acquire (fresh lock held by another scope)
+ - `promote` handles the full sequence: clear old library dir contents (if exists), copy working dir to library, write CLI manifest, update CurrentRunPointer, release lock from `.locks/`
+
+ **Patterns to follow:**
+ - `internal/cli/library.go` — subcommand registration pattern
+ - `internal/cli/publish.go` — nested subcommand pattern (`publish validate`, `publish package`)
+
+ **Test scenarios:**
+ - Happy path: `lock acquire --cli test-pp-cli --scope scope-1` writes lock file, exits 0
+ - Happy path: `lock status --cli test-pp-cli --json` returns JSON with held/stale/phase/has_cli fields
+ - Happy path: `lock update --cli test-pp-cli --phase build` refreshes heartbeat
+ - Happy path: `lock release --cli test-pp-cli` removes lock, exits 0
+ - Happy path: `lock promote --cli test-pp-cli --dir /path/to/working` copies, writes manifest, exits 0
+ - Error path: `lock acquire` without `--cli` flag — exits non-zero with usage
+ - Error path: `lock acquire --cli x --scope s` when fresh lock held by different scope — exits non-zero, JSON indicates blocked
+ - Error path: `lock promote --cli test-pp-cli --dir /nonexistent` — exits non-zero
+
+ **Verification:**
+ - `go build ./...` and `go vet ./...` pass
+ - Subcommands appear in `printing-press lock --help`
+
+- [ ] **Unit 3: Skill — Add `CLI_WORK_DIR` and lock lifecycle to main skill**
+
+ **Goal:** Replace all Phase 2-5 references to `$PRESS_LIBRARY/<api>-pp-cli` with run-scoped `$CLI_WORK_DIR`, and add lock commands at phase boundaries.
+
+ **Requirements:** R1, R2, R3, R5
+
+ **Dependencies:** Unit 2
+
+ **Files:**
+ - Modify: `skills/printing-press/SKILL.md`
+
+ **Approach:**
+
+ This is the largest unit. Changes span multiple sections of the 1662-line skill file. All changes are to SKILL.md content (markdown + bash blocks), not Go code.
+
+ **A. Add `CLI_WORK_DIR` to "After you know `<api>`" setup block (around line 168-179):**
+ - Add `CLI_WORK_DIR="$API_RUN_DIR/working/<api>-pp-cli"` after the existing variable definitions
+ - Add `mkdir -p "$CLI_WORK_DIR"` to the existing `mkdir -p` call
+
+ **B. Update state file schema documentation (around line 183-189):**
+ - `working_dir` should point to `$CLI_WORK_DIR`
+ - `output_dir` should point to `$CLI_WORK_DIR` during build
+
+ **C. Update Phase 0 Library Check (around line 254-286):**
+ - Two independent checks: (1) does `$PRESS_LIBRARY/<api>-pp-cli` exist with `go.mod`? (2) is there an active lock?
+ ```bash
+ printing-press lock status --cli <api>-pp-cli --json
+ ```
+ - Route based on combined result per the decision matrix: library + no lock → existing "Found existing" flow; no library + active lock → warn user; stale lock → offer reclaim; neither → proceed
+
+ **D. Replace `$PRESS_LIBRARY/<api>-pp-cli` in Phase 2 (around lines 1078-1168):**
+ - All 7 `--output "$PRESS_LIBRARY/<api>-pp-cli"` variants → `--output "$CLI_WORK_DIR"`
+ - The description rewrite path → `$CLI_WORK_DIR/internal/cli/root.go`
+ - Add `printing-press lock acquire --cli <api>-pp-cli --scope "$PRESS_SCOPE"` before generation
+ - Add `printing-press lock update --cli <api>-pp-cli --phase generate` after generation
+
+ **E. Replace `$PRESS_LIBRARY/<api>-pp-cli` in Phase 3 (around lines 1175-1400):**
+ - All `cd "$PRESS_LIBRARY/<api>-pp-cli"` → `cd "$CLI_WORK_DIR"`
+ - All codex delegation references to the library path → `$CLI_WORK_DIR`
+ - Add `printing-press lock update --cli <api>-pp-cli --phase build-p0` after Priority 0
+ - Add `printing-press lock update --cli <api>-pp-cli --phase build-p1` after Priority 1
+ - Add `printing-press lock update --cli <api>-pp-cli --phase build-p2` after Priority 2
+
+ **F. Replace `$PRESS_LIBRARY/<api>-pp-cli` in Phase 4 (around lines 1406-1510):**
+ - All `--dir "$PRESS_LIBRARY/<api>-pp-cli"` → `--dir "$CLI_WORK_DIR"`
+ - All codex fix delegation references → `$CLI_WORK_DIR`
+ - Add `printing-press lock update --cli <api>-pp-cli --phase shipcheck` before shipcheck
+
+ **G. Replace `$PRESS_LIBRARY/<api>-pp-cli` in Phase 5 (around lines 1512-1527):**
+ - Smoke test references → `$CLI_WORK_DIR`
+
+ **H. Add promotion to library in Phase 5.5 — BEFORE archiving manuscripts (around line 1529-1557):**
+ - Reorder Phase 5.5 so promotion happens first, then archiving:
+ ```bash
+ # Promote verified CLI to library (before archiving — CLI is the primary deliverable)
+ printing-press lock promote --cli <api>-pp-cli --dir "$CLI_WORK_DIR"
+ ```
+ - Then archive manuscripts as before (using `$CLI_WORK_DIR` references for source paths)
+ - The `promote` command handles: clearing old library files, copying working dir, writing CLI manifest, updating CurrentRunPointer, and releasing the lock — all in one deterministic step
+
+ **I. Add lock release on all failure/abort paths:**
+ - Whenever the skill's flow terminates early (generation fails, shipcheck fails with "hold" verdict, user cancels, API reachability gate fails after lock acquire), add:
+ ```bash
+ printing-press lock release --cli <api>-pp-cli
+ ```
+ - This prevents the 30-minute staleness window from unnecessarily blocking other agents after a known failure
+ - The skill already has well-defined failure points — add release at each one
+
+ **J. Bump `min-binary-version` in the main skill's setup contract:**
+ - Update the `# min-binary-version:` comment to the version that ships the `lock` subcommands
+ - This must also be bumped in all 4 skills' setup contracts for consistency
+
+ **K. Update Phase 6 references (around lines 1559-1620):**
+ - Phase 6 reads from `$PRESS_LIBRARY/<api>-pp-cli` — this is CORRECT after the promote step
+ - No changes needed for Phase 6 publish flow itself
+
+ **L. Handle shipcheck "hold" verdict:**
+ - When shipcheck verdict is "hold" and the user chooses not to retry: release the lock, do NOT promote to library. The working copy remains in runstate for potential future retry. Archive manuscripts as normal.
+
+ **Critical: Variable reference contexts.** The skill uses `$PRESS_LIBRARY/<api>-pp-cli` in three contexts:
+ 1. Inside ` ```bash ``` ` blocks — these are executed by the model as shell commands. Replace with `$CLI_WORK_DIR`.
+ 2. Inside inline backtick references like `` `$PRESS_LIBRARY/<api>-pp-cli/internal/cli/root.go` `` — these guide the model to file paths. Replace with `$CLI_WORK_DIR/internal/cli/root.go`.
+ 3. Inside prose descriptions like "the CLI in $PRESS_LIBRARY/<api>-pp-cli" — replace with appropriate new path reference.
+
+ Do NOT replace `$PRESS_LIBRARY` references in Phase 0's Library Check (it correctly checks the library) or Phase 6 (it correctly reads from the library after promotion).
+
+ **Test scenarios:**
+ - Test expectation: none — this is a skill markdown file, not code. Verification is behavioral during skill execution.
+
+ **Verification:**
+ - All `$PRESS_LIBRARY/<api>-pp-cli` references in Phases 2-5 are replaced with `$CLI_WORK_DIR`
+ - Lock acquire appears before Phase 2 generation
+ - Lock update appears at each phase boundary and priority level
+ - `printing-press lock promote` appears in Phase 5.5 (before manuscript archiving)
+ - Lock release appears at every failure/abort path
+ - Phase 0 Library Check uses `lock status --json` to check for active builds separately from library dir existence
+ - Phase 0 handles all decision matrix cases: library+lock, library+no lock, no library+lock, debris
+ - Shipcheck "hold" verdict releases lock without promoting
+ - Phase 6 still references `$PRESS_LIBRARY` (correct — reads from promoted location)
+ - `min-binary-version` bumped in setup contract
+ - No broken backtick/quote pairing in the edited markdown
+
+- [ ] **Unit 4: Skill — Update consuming skills for new flow**
+
+ **Goal:** Ensure polish, score, and publish skills work with the new build/library separation.
+
+ **Requirements:** R7
+
+ **Dependencies:** Unit 3
+
+ **Files:**
+ - Modify: `skills/printing-press-publish/SKILL.md`
+ - Modify: `skills/printing-press-score/SKILL.md`
+ - Modify: `skills/printing-press-polish/SKILL.md`
+
+ **Approach:**
+
+ **Publish skill:** Minimal changes. It uses `printing-press library list --json` (which scans `$PRESS_LIBRARY`) and `printing-press publish validate --dir <cli-dir>`. After the fix, CLIs in library are always promoted (shipcheck-passed), so publish reads complete CLIs. No path changes needed. The only change is to bump `min-binary-version` to match the version that includes `lock` subcommands (so the setup contract stays compatible).
+
+ **Score skill:** Already uses `$PRESS_CURRENT/*.json` correctly to find current-run pointers. The `working_dir` in those pointers will initially point to `$API_RUN_DIR/working/<api>-pp-cli`. After promotion, `lock promote` updates the pointer so `working_dir` reflects the library path. If a user runs `/printing-press-score` mid-build (before promotion), it reads from runstate — this is correct. If they run it after promotion, it reads from library — also correct. Verify that the score skill's path resolution does not assume the working dir is under the current session's runstate, since runstate directories persist across sessions.
+
+ **Polish skill:** Operates on CLIs in `$PRESS_LIBRARY` for "second-pass improvements to an existing CLI." After the fix, library only contains promoted (verified) CLIs, which is exactly what polish expects. The only consideration: if a user runs `/printing-press-polish` during an active build (before promotion), the CLI won't be in library yet. Add a note to the polish skill that if a CLI is not found in library, check if there's an active build in progress (`printing-press lock status --cli <name> --json`) and advise the user to wait or run polish after the build completes.
+
+ **Patterns to follow:**
+ - Each skill's existing path resolution logic
+
+ **Test scenarios:**
+ - Test expectation: none — skill markdown files. Verification is behavioral.
+
+ **Verification:**
+ - Publish skill's `min-binary-version` bumped
+ - Score skill path resolution still works (uses pointer's `working_dir`, not hardcoded library path)
+ - Polish skill has fallback guidance when CLI not yet in library
+
+- [ ] **Unit 5: Go — Update contract tests**
+
+ **Goal:** Ensure contract tests pass with any setup contract changes, and add contract coverage for lock behavior.
+
+ **Requirements:** R8
+
+ **Dependencies:** Units 1-4
+
+ **Files:**
+ - Modify: `internal/pipeline/contracts_test.go`
+
+ **Approach:**
+ - The setup contract block itself is unlikely to change (CLI_WORK_DIR is set outside the contract). But the contract tests also validate:
+ - `TestGenerateHelpMentionsPublishedLibraryDefault` — may need to acknowledge that the skill now uses `$CLI_WORK_DIR` for `--output`
+ - `TestREADMEOutputContract` — may need updating if README references change
+ - `TestPrintingPressSkillExamplesUseCurrentCLINaming` — may need updating if naming patterns change
+ - Run existing tests after Units 1-4 to see what breaks, then fix specifically
+
+ **Patterns to follow:**
+ - Existing test patterns in `contracts_test.go`
+
+ **Test scenarios:**
+ - Happy path: All existing contract tests pass after changes
+ - Happy path: New test validates that SKILL.md Phase 2-5 no longer reference `$PRESS_LIBRARY/<api>-pp-cli` as `--output` target
+ - Happy path: New test validates that SKILL.md Phase 2 includes `printing-press lock acquire` before generation
+
+ **Verification:**
+ - `go test ./internal/pipeline/...` passes
+ - No regressions in contract validation
+
+## System-Wide Impact
+
+- **Interaction graph:** The main skill's Phase 2-5 output path changes from library to runstate. The publish skill, polish skill, and score skill all consume library paths — publish and polish are unaffected (they read promoted CLIs). Score reads from current-run pointers — these point to runstate during build and are updated to library after promotion via `lock promote`.
+- **Error propagation:** If lock acquire fails (another session holds it), the skill should present the user with the lock status and let them decide (wait, use a different CLI name, force-reclaim, or pick a different API). Not a silent failure. Lock release is called on all known failure paths so other agents aren't blocked.
+- **State lifecycle risks:** Interrupted sessions leave a stale lock in `.locks/` and a partial build in runstate. Library is untouched. Next run detects stale lock via `lock status` and offers reclaim. After successful promote, the lock is removed as part of the promote sequence.
+- **API surface parity:** The `printing-press lock` subcommands are new public CLI surface. They should follow the existing JSON output convention used by other subcommands. `printing-press library list` should skip directories without `go.mod` or manifest.
+- **CurrentRunPointer lifecycle:** During build, `working_dir` points to runstate. After `lock promote`, it's updated to point to library. This means the score skill gets the right path regardless of when it's invoked. If runstate is later cleaned up, the pointer still works because it reflects the library location.
+- **Unchanged invariants:** `printing-press generate --output` still accepts any path. `DefaultOutputDir()` still returns the library path for direct CLI usage. The `print` pipeline's working-dir pattern is unchanged.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Skill changes span many sections of a 1662-line file — easy to miss a reference | Grep for all `PRESS_LIBRARY.*-pp-cli` in the skill and verify each is addressed. Contract test validates no stale references. |
+| Lock file left behind after session killed during promote | The `promote` command handles copy-to-library + release-lock as one sequence. If killed mid-copy, the library dir may have partial new files. The lock in `.locks/` goes stale and next run detects it. |
+| Lock file left behind after deliberate build failure/abort | Skill calls `lock release` on all failure paths. If the model skips this instruction, staleness fallback still works. |
+| The `min-binary-version` bump means older binaries won't have `lock` subcommands | The skill already handles version mismatches with a warning. Lock commands will fail with "unknown command" on old binaries — the skill should catch this and fall back to no-lock behavior. |
+| Promote overwrites library dir that publish skill is currently reading | Narrow window. The publish skill is interactive and human-driven. Concurrent promote + publish for the same CLI is a user error. |
+| TOCTOU window on stale lock reclaim (two agents both detect stale, both try to reclaim) | Acceptable. The `O_CREATE|O_EXCL` on the new lock file means one wins and one gets a clear error. Second agent retries and sees a fresh lock. |
+| 30-minute staleness threshold may be too long for fast-failing builds | The explicit lock-release-on-failure mitigates this. The 30-minute threshold is only the fallback for truly killed sessions. |
+
+## Sources & References
+
+- Go binary path functions: `internal/pipeline/paths.go`
+- Existing publish-to-library pattern: `internal/pipeline/publish.go:PublishWorkingCLI()`
+- Existing atomic claiming: `internal/pipeline/pipeline.go:ClaimOutputDir()`
+- Current run pointer: `internal/pipeline/state.go:CurrentRunPointer`
+- Setup contract test: `internal/pipeline/contracts_test.go`
+- Main skill: `skills/printing-press/SKILL.md`
+- Publish skill: `skills/printing-press-publish/SKILL.md`
+- Polish skill: `skills/printing-press-polish/SKILL.md`
+- Score skill: `skills/printing-press-score/SKILL.md`
diff --git a/internal/cli/lock.go b/internal/cli/lock.go
new file mode 100644
index 00000000..44702e42
--- /dev/null
+++ b/internal/cli/lock.go
@@ -0,0 +1,258 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+ "github.com/spf13/cobra"
+)
+
+func newLockCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "lock",
+ Short: "Manage build locks for parallel safety",
+ Example: ` # Acquire a lock for a CLI build
+ printing-press lock acquire --cli notion-pp-cli --scope my-workspace
+
+ # Check lock status
+ printing-press lock status --cli notion-pp-cli --json
+
+ # Update heartbeat
+ printing-press lock update --cli notion-pp-cli --phase build
+
+ # Release a lock
+ printing-press lock release --cli notion-pp-cli
+
+ # Promote working dir to library
+ printing-press lock promote --cli notion-pp-cli --dir /path/to/working`,
+ }
+
+ cmd.AddCommand(newLockAcquireCmd())
+ cmd.AddCommand(newLockUpdateCmd())
+ cmd.AddCommand(newLockStatusCmd())
+ cmd.AddCommand(newLockReleaseCmd())
+ cmd.AddCommand(newLockPromoteCmd())
+
+ return cmd
+}
+
+func newLockAcquireCmd() *cobra.Command {
+ var cliName string
+ var scope string
+ var force bool
+
+ cmd := &cobra.Command{
+ Use: "acquire",
+ Short: "Acquire a build lock for a CLI",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if cliName == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--cli is required")}
+ }
+ if scope == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--scope is required")}
+ }
+
+ lock, err := pipeline.AcquireLock(cliName, scope, force)
+ if err != nil {
+ // Check if it's a "lock held" error — return structured JSON + non-zero exit.
+ status := pipeline.LockStatus(cliName)
+ result := map[string]interface{}{
+ "acquired": false,
+ "blocked": true,
+ "error": err.Error(),
+ "status": status,
+ }
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ _ = enc.Encode(result)
+ return &ExitError{Code: ExitInputError, Err: err, Silent: true}
+ }
+
+ result := map[string]interface{}{
+ "acquired": true,
+ "blocked": false,
+ "cli": cliName,
+ "scope": lock.Scope,
+ "lock_file": pipeline.LockFilePath(cliName),
+ "acquired_at": lock.AcquiredAt,
+ }
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(result)
+ },
+ }
+
+ cmd.Flags().StringVar(&cliName, "cli", "", "CLI name (required)")
+ cmd.Flags().StringVar(&scope, "scope", "", "Workspace scope (required)")
+ cmd.Flags().BoolVar(&force, "force", false, "Override fresh locks from other scopes")
+
+ return cmd
+}
+
+func newLockUpdateCmd() *cobra.Command {
+ var cliName string
+ var phase string
+
+ cmd := &cobra.Command{
+ Use: "update",
+ Short: "Update lock heartbeat and phase",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if cliName == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--cli is required")}
+ }
+ if phase == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--phase is required")}
+ }
+
+ if err := pipeline.UpdateLock(cliName, phase); err != nil {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("updating lock: %w", err)}
+ }
+
+ result := map[string]interface{}{
+ "updated": true,
+ "cli": cliName,
+ "phase": phase,
+ }
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(result)
+ },
+ }
+
+ cmd.Flags().StringVar(&cliName, "cli", "", "CLI name (required)")
+ cmd.Flags().StringVar(&phase, "phase", "", "Current build phase (required)")
+
+ return cmd
+}
+
+func newLockStatusCmd() *cobra.Command {
+ var cliName string
+ var asJSON bool
+
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Check lock status for a CLI",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if cliName == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--cli is required")}
+ }
+
+ status := pipeline.LockStatus(cliName)
+
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(status)
+ }
+
+ if !status.Held {
+ if status.HasCLI {
+ fmt.Fprintf(os.Stderr, "No active lock for %s (completed CLI exists in library)\n", cliName)
+ } else {
+ fmt.Fprintf(os.Stderr, "No active lock for %s\n", cliName)
+ }
+ return nil
+ }
+
+ staleStr := ""
+ if status.Stale {
+ staleStr = " (STALE)"
+ }
+ fmt.Fprintf(os.Stderr, "Lock held for %s%s\n", cliName, staleStr)
+ fmt.Fprintf(os.Stderr, " Scope: %s\n", status.Scope)
+ fmt.Fprintf(os.Stderr, " Phase: %s\n", status.Phase)
+ fmt.Fprintf(os.Stderr, " Age: %.0fs\n", status.AgeSeconds)
+
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&cliName, "cli", "", "CLI name (required)")
+ cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+ return cmd
+}
+
+func newLockReleaseCmd() *cobra.Command {
+ var cliName string
+
+ cmd := &cobra.Command{
+ Use: "release",
+ Short: "Release a build lock",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if cliName == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--cli is required")}
+ }
+
+ if err := pipeline.ReleaseLock(cliName); err != nil {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("releasing lock: %w", err)}
+ }
+
+ result := map[string]interface{}{
+ "released": true,
+ "cli": cliName,
+ }
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(result)
+ },
+ }
+
+ cmd.Flags().StringVar(&cliName, "cli", "", "CLI name (required)")
+
+ return cmd
+}
+
+func newLockPromoteCmd() *cobra.Command {
+ var cliName string
+ var dir string
+
+ cmd := &cobra.Command{
+ Use: "promote",
+ Short: "Promote a working CLI to the library",
+ Long: "Copies the working CLI to the library, writes the CLI manifest, updates the run pointer, and releases the lock.",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if cliName == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--cli is required")}
+ }
+ if dir == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
+ }
+
+ // Verify the working directory exists.
+ info, err := os.Stat(dir)
+ if err != nil {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("working directory not found: %w", err)}
+ }
+ if !info.IsDir() {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir must be a directory")}
+ }
+
+ // Try to find state by working dir.
+ state, err := pipeline.FindStateByWorkingDir(dir)
+ if err != nil {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("finding pipeline state: %w", err)}
+ }
+
+ if err := pipeline.PromoteWorkingCLI(cliName, dir, state); err != nil {
+ return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("promoting CLI: %w", err)}
+ }
+
+ result := map[string]interface{}{
+ "promoted": true,
+ "cli": cliName,
+ "library_dir": state.PublishedDir,
+ }
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(result)
+ },
+ }
+
+ cmd.Flags().StringVar(&cliName, "cli", "", "CLI name (required)")
+ cmd.Flags().StringVar(&dir, "dir", "", "Working CLI directory to promote (required)")
+
+ return cmd
+}
diff --git a/internal/cli/lock_test.go b/internal/cli/lock_test.go
new file mode 100644
index 00000000..2598b33e
--- /dev/null
+++ b/internal/cli/lock_test.go
@@ -0,0 +1,202 @@
+package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupLockCLITest(t *testing.T) {
+ t.Helper()
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+}
+
+func runLockCmd(args ...string) (stdout string, exitCode int) {
+ cmd := newLockCmd()
+ buf := new(bytes.Buffer)
+ cmd.SetOut(buf)
+ cmd.SetErr(new(bytes.Buffer))
+ cmd.SetArgs(args)
+
+ // Redirect stdout to capture JSON output.
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ err := cmd.Execute()
+
+ _ = w.Close()
+ os.Stdout = oldStdout
+
+ var captured bytes.Buffer
+ _, _ = captured.ReadFrom(r)
+
+ code := 0
+ if err != nil {
+ code = 1
+ if exitErr, ok := err.(*ExitError); ok {
+ code = exitErr.Code
+ }
+ }
+ return captured.String(), code
+}
+
+func TestLockAcquire_Success(t *testing.T) {
+ setupLockCLITest(t)
+
+ stdout, code := runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "scope-1")
+ assert.Equal(t, 0, code)
+
+ var result map[string]interface{}
+ require.NoError(t, json.Unmarshal([]byte(stdout), &result))
+ assert.Equal(t, true, result["acquired"])
+ assert.Equal(t, false, result["blocked"])
+ assert.Equal(t, "test-pp-cli", result["cli"])
+}
+
+func TestLockAcquire_MissingCLI(t *testing.T) {
+ setupLockCLITest(t)
+
+ _, code := runLockCmd("acquire", "--scope", "scope-1")
+ assert.Equal(t, ExitInputError, code)
+}
+
+func TestLockAcquire_MissingScope(t *testing.T) {
+ setupLockCLITest(t)
+
+ _, code := runLockCmd("acquire", "--cli", "test-pp-cli")
+ assert.Equal(t, ExitInputError, code)
+}
+
+func TestLockAcquire_Blocked(t *testing.T) {
+ setupLockCLITest(t)
+
+ // Acquire first.
+ _, code := runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "scope-1")
+ require.Equal(t, 0, code)
+
+ // Try from different scope.
+ stdout, code := runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "scope-2")
+ assert.Equal(t, ExitInputError, code)
+
+ var result map[string]interface{}
+ require.NoError(t, json.Unmarshal([]byte(stdout), &result))
+ assert.Equal(t, false, result["acquired"])
+ assert.Equal(t, true, result["blocked"])
+}
+
+func TestLockStatus_JSON(t *testing.T) {
+ setupLockCLITest(t)
+
+ _, _ = runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "scope-1")
+
+ stdout, code := runLockCmd("status", "--cli", "test-pp-cli", "--json")
+ assert.Equal(t, 0, code)
+
+ var result pipeline.LockStatusResult
+ require.NoError(t, json.Unmarshal([]byte(stdout), &result))
+ assert.True(t, result.Held)
+ assert.False(t, result.Stale)
+ assert.Equal(t, "acquire", result.Phase)
+ assert.Equal(t, "scope-1", result.Scope)
+}
+
+func TestLockUpdate(t *testing.T) {
+ setupLockCLITest(t)
+
+ _, _ = runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "scope-1")
+
+ stdout, code := runLockCmd("update", "--cli", "test-pp-cli", "--phase", "build")
+ assert.Equal(t, 0, code)
+
+ var result map[string]interface{}
+ require.NoError(t, json.Unmarshal([]byte(stdout), &result))
+ assert.Equal(t, true, result["updated"])
+ assert.Equal(t, "build", result["phase"])
+}
+
+func TestLockRelease(t *testing.T) {
+ setupLockCLITest(t)
+
+ _, _ = runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "scope-1")
+
+ stdout, code := runLockCmd("release", "--cli", "test-pp-cli")
+ assert.Equal(t, 0, code)
+
+ var result map[string]interface{}
+ require.NoError(t, json.Unmarshal([]byte(stdout), &result))
+ assert.Equal(t, true, result["released"])
+
+ // Verify lock is gone.
+ _, err := os.Stat(pipeline.LockFilePath("test-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+}
+
+func TestLockPromote_MissingDir(t *testing.T) {
+ setupLockCLITest(t)
+
+ _, code := runLockCmd("promote", "--cli", "test-pp-cli", "--dir", "/nonexistent/path")
+ assert.NotEqual(t, 0, code)
+}
+
+func TestLockPromote_Success(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ // Create working dir with content.
+ runID := "20260331T120000Z-abcd1234"
+ workDir := filepath.Join(tmp, ".runstate", "test-scope", "runs", runID, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+
+ // Create state file for the run.
+ state := pipeline.NewStateWithRun("test", workDir, runID, "test-scope")
+ require.NoError(t, state.Save())
+
+ // Acquire lock.
+ _, code := runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "test-scope")
+ require.Equal(t, 0, code)
+
+ // Promote.
+ stdout, code := runLockCmd("promote", "--cli", "test-pp-cli", "--dir", workDir)
+ assert.Equal(t, 0, code)
+
+ var result map[string]interface{}
+ require.NoError(t, json.Unmarshal([]byte(stdout), &result))
+ assert.Equal(t, true, result["promoted"])
+
+ // Verify library dir exists.
+ libDir := filepath.Join(pipeline.PublishedLibraryRoot(), "test-pp-cli")
+ _, err := os.Stat(filepath.Join(libDir, "go.mod"))
+ assert.NoError(t, err)
+}
+
+func TestLockHelpOutput(t *testing.T) {
+ cmd := newLockCmd()
+ buf := new(bytes.Buffer)
+ cmd.SetOut(buf)
+ cmd.SetErr(buf)
+ cmd.SetArgs([]string{"--help"})
+
+ err := cmd.Execute()
+ assert.NoError(t, err)
+
+ output := buf.String()
+ assert.Contains(t, output, "acquire")
+ assert.Contains(t, output, "update")
+ assert.Contains(t, output, "status")
+ assert.Contains(t, output, "release")
+ assert.Contains(t, output, "promote")
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 31e8a9b6..96c98ff6 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -52,6 +52,7 @@ func Execute() error {
rootCmd.AddCommand(newPublishCmd())
rootCmd.AddCommand(newPolishCmd())
rootCmd.AddCommand(newWorkflowVerifyCmd())
+ rootCmd.AddCommand(newLockCmd())
return rootCmd.Execute()
}
diff --git a/internal/pipeline/contracts_test.go b/internal/pipeline/contracts_test.go
index 1ebcf832..832109c5 100644
--- a/internal/pipeline/contracts_test.go
+++ b/internal/pipeline/contracts_test.go
@@ -93,7 +93,25 @@ func TestPrintingPressSkillUsesRunRootStateFile(t *testing.T) {
assert.Contains(t, skill, `STATE_FILE="$API_RUN_DIR/state.json"`)
assert.NotContains(t, skill, `STATE_FILE="$PIPELINE_DIR/state.json"`)
- assert.Contains(t, skill, `"working_dir": "<absolute cli dir>"`)
+ assert.Contains(t, skill, `"working_dir": "$CLI_WORK_DIR"`)
+}
+
+func TestPrintingPressSkillUsesRunstateForBuilds(t *testing.T) {
+ skill := readContractFile(t, filepath.Join("..", "..", "skills", "printing-press", "SKILL.md"))
+
+ // Phase 2-5 should use $CLI_WORK_DIR, not $PRESS_LIBRARY/<api>-pp-cli for --output.
+ assert.Contains(t, skill, `CLI_WORK_DIR="$API_RUN_DIR/working/<api>-pp-cli"`)
+ assert.Contains(t, skill, `--output "$CLI_WORK_DIR"`)
+ assert.NotContains(t, skill, `--output "$PRESS_LIBRARY/<api>-pp-cli"`)
+
+ // Lock acquire should appear before generation.
+ assert.Contains(t, skill, `printing-press lock acquire --cli <api>-pp-cli --scope "$PRESS_SCOPE"`)
+
+ // Lock promote should appear in Phase 5.5.
+ assert.Contains(t, skill, `printing-press lock promote --cli <api>-pp-cli --dir "$CLI_WORK_DIR"`)
+
+ // Phase 6 should still reference $PRESS_LIBRARY (reads from promoted location).
+ assert.Contains(t, skill, `$PRESS_LIBRARY/<api>-pp-cli`)
}
func TestPrintingPressSkillExamplesUseCurrentCLINaming(t *testing.T) {
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
new file mode 100644
index 00000000..8ed602e2
--- /dev/null
+++ b/internal/pipeline/lock.go
@@ -0,0 +1,320 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ // StaleLockThreshold is the duration after which a lock is considered stale.
+ StaleLockThreshold = 30 * time.Minute
+
+ locksDir = ".locks"
+)
+
+// LockState represents the state of a build lock for a CLI.
+type LockState struct {
+ Scope string `json:"scope"`
+ Phase string `json:"phase"`
+ PID int `json:"pid"`
+ AcquiredAt time.Time `json:"acquired_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// LockStatusResult is the combined status returned by LockStatus.
+type LockStatusResult struct {
+ Held bool `json:"held"`
+ Stale bool `json:"stale"`
+ Phase string `json:"phase,omitempty"`
+ Scope string `json:"scope,omitempty"`
+ AgeSeconds float64 `json:"age_seconds,omitempty"`
+ HasCLI bool `json:"has_cli"`
+ Lock *LockState `json:"lock,omitempty"`
+}
+
+// LocksDir returns the global locks directory path.
+func LocksDir() string {
+ return filepath.Join(PressHome(), locksDir)
+}
+
+// LockFilePath returns the lock file path for a given CLI name.
+func LockFilePath(cliName string) string {
+ return filepath.Join(LocksDir(), cliName+".lock")
+}
+
+// AcquireLock attempts to acquire a build lock for the given CLI.
+// It auto-reclaims stale locks. If force is true, it overrides even fresh
+// locks held by a different scope.
+func AcquireLock(cliName, scope string, force bool) (*LockState, error) {
+ lockPath := LockFilePath(cliName)
+
+ if err := os.MkdirAll(LocksDir(), 0o755); err != nil {
+ return nil, fmt.Errorf("creating locks directory: %w", err)
+ }
+
+ lock := &LockState{
+ Scope: scope,
+ Phase: "acquire",
+ PID: os.Getpid(),
+ AcquiredAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ // Try atomic creation first.
+ err := writeLockExclusive(lockPath, lock)
+ if err == nil {
+ return lock, nil
+ }
+ if !os.IsExist(err) {
+ return nil, fmt.Errorf("acquiring lock: %w", err)
+ }
+
+ // Lock file exists — check if we can reclaim it.
+ // Retry read once to tolerate a concurrent atomic rename in writeLock.
+ existing, readErr := readLock(lockPath)
+ if readErr != nil {
+ time.Sleep(50 * time.Millisecond)
+ existing, readErr = readLock(lockPath)
+ }
+ if readErr != nil {
+ // Still can't read — file is genuinely corrupt. Remove and re-create.
+ _ = os.Remove(lockPath)
+ if err := writeLockExclusive(lockPath, lock); err != nil {
+ return nil, fmt.Errorf("acquiring lock after removing unreadable lock: %w", err)
+ }
+ return lock, nil
+ }
+
+ // Same scope — re-entrant, just overwrite.
+ if existing.Scope == scope {
+ if err := writeLock(lockPath, lock); err != nil {
+ return nil, fmt.Errorf("re-acquiring lock for same scope: %w", err)
+ }
+ return lock, nil
+ }
+
+ // Different scope — check staleness or force.
+ if IsStale(existing) || force {
+ _ = os.Remove(lockPath)
+ if err := writeLockExclusive(lockPath, lock); err != nil {
+ return nil, fmt.Errorf("acquiring lock after reclaim: %w", err)
+ }
+ return lock, nil
+ }
+
+ return nil, fmt.Errorf("lock held by scope %q (phase: %s, updated: %s ago)", existing.Scope, existing.Phase, time.Since(existing.UpdatedAt).Truncate(time.Second))
+}
+
+// UpdateLock refreshes the heartbeat and phase of an existing lock.
+func UpdateLock(cliName, phase string) error {
+ lockPath := LockFilePath(cliName)
+
+ existing, err := readLock(lockPath)
+ if err != nil {
+ return fmt.Errorf("reading lock for update: %w", err)
+ }
+
+ existing.Phase = phase
+ existing.UpdatedAt = time.Now()
+ existing.PID = os.Getpid()
+
+ return writeLock(lockPath, existing)
+}
+
+// LockStatus returns the current lock state for a CLI, including whether
+// a completed CLI exists in the library.
+func LockStatus(cliName string) LockStatusResult {
+ result := LockStatusResult{}
+
+ // Check library for completed CLI.
+ libDir := filepath.Join(PublishedLibraryRoot(), cliName)
+ if info, err := os.Stat(libDir); err == nil && info.IsDir() {
+ goModPath := filepath.Join(libDir, "go.mod")
+ manifestPath := filepath.Join(libDir, CLIManifestFilename)
+ _, goModErr := os.Stat(goModPath)
+ _, manifestErr := os.Stat(manifestPath)
+ result.HasCLI = goModErr == nil || manifestErr == nil
+ }
+
+ // Check lock file.
+ lockPath := LockFilePath(cliName)
+ lock, err := readLock(lockPath)
+ if err != nil {
+ return result
+ }
+
+ result.Held = true
+ result.Stale = IsStale(lock)
+ result.Phase = lock.Phase
+ result.Scope = lock.Scope
+ result.AgeSeconds = time.Since(lock.UpdatedAt).Seconds()
+ result.Lock = lock
+
+ return result
+}
+
+// ReleaseLock removes the lock file for a CLI. It is idempotent.
+func ReleaseLock(cliName string) error {
+ lockPath := LockFilePath(cliName)
+ err := os.Remove(lockPath)
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("releasing lock: %w", err)
+ }
+ return nil
+}
+
+// PromoteWorkingCLI copies a working CLI directory to the library, writes
+// the CLI manifest, updates the CurrentRunPointer, and releases the lock.
+// Uses a staging directory with atomic swap so the previous library copy
+// survives if any step fails.
+func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
+ if workingDir == "" {
+ return fmt.Errorf("working directory is empty")
+ }
+
+ // Verify working dir has content.
+ entries, err := os.ReadDir(workingDir)
+ if err != nil {
+ return fmt.Errorf("reading working directory: %w", err)
+ }
+ if len(entries) == 0 {
+ return fmt.Errorf("working directory is empty: %s", workingDir)
+ }
+
+ libraryDir := filepath.Join(PublishedLibraryRoot(), cliName)
+ stagingDir := libraryDir + ".promoting"
+ backupDir := libraryDir + ".old"
+
+ // Ensure parent exists.
+ if err := os.MkdirAll(filepath.Dir(libraryDir), 0o755); err != nil {
+ return fmt.Errorf("creating library parent directory: %w", err)
+ }
+
+ // If a previous promote died after moving the live library to backup but
+ // before swapping in staging, restore that backup before attempting a retry.
+ if _, err := os.Stat(backupDir); err == nil {
+ if _, libErr := os.Stat(libraryDir); os.IsNotExist(libErr) {
+ if err := os.Rename(backupDir, libraryDir); err != nil {
+ return fmt.Errorf("restoring library from backup: %w", err)
+ }
+ } else if libErr != nil {
+ return fmt.Errorf("checking existing library directory: %w", libErr)
+ }
+ }
+
+ // Clean up any leftover staging dir from a previous failed promote.
+ _ = os.RemoveAll(stagingDir)
+
+ // Copy working dir to staging.
+ if err := CopyDir(workingDir, stagingDir); err != nil {
+ _ = os.RemoveAll(stagingDir)
+ return fmt.Errorf("copying to staging directory: %w", err)
+ }
+
+ // Update state to reflect promotion.
+ state.PublishedDir = libraryDir
+
+ // Write CLI manifest into the staging copy.
+ if err := writeCLIManifestForPublish(state, stagingDir); err != nil {
+ _ = os.RemoveAll(stagingDir)
+ return fmt.Errorf("writing CLI manifest: %w", err)
+ }
+
+ // Remove any stale backup from a prior successful swap before we create a
+ // fresh backup for the current library contents.
+ if _, err := os.Stat(backupDir); err == nil {
+ if err := os.RemoveAll(backupDir); err != nil {
+ _ = os.RemoveAll(stagingDir)
+ return fmt.Errorf("removing stale backup directory: %w", err)
+ }
+ }
+
+ // Atomic swap: move old library aside, move staging into place.
+ if _, err := os.Stat(libraryDir); err == nil {
+ if err := os.Rename(libraryDir, backupDir); err != nil {
+ _ = os.RemoveAll(stagingDir)
+ return fmt.Errorf("backing up existing library directory: %w", err)
+ }
+ } else if !os.IsNotExist(err) {
+ _ = os.RemoveAll(stagingDir)
+ return fmt.Errorf("checking library directory before promote: %w", err)
+ }
+
+ if err := os.Rename(stagingDir, libraryDir); err != nil {
+ // Restore backup if the swap failed.
+ if _, statErr := os.Stat(backupDir); statErr == nil {
+ _ = os.Rename(backupDir, libraryDir)
+ }
+ return fmt.Errorf("promoting staging to library: %w", err)
+ }
+
+ // Swap succeeded — remove the backup.
+ _ = os.RemoveAll(backupDir)
+
+ // Update current run pointer so working_dir reflects library path.
+ state.WorkingDir = libraryDir
+ saveErr := state.Save()
+ releaseErr := ReleaseLock(cliName)
+
+ switch {
+ case saveErr != nil && releaseErr != nil:
+ return fmt.Errorf("cli promoted to %s, but state update failed: %v; lock release also failed: %w", libraryDir, saveErr, releaseErr)
+ case saveErr != nil:
+ return fmt.Errorf("cli promoted to %s, but state update failed: %w", libraryDir, saveErr)
+ case releaseErr != nil:
+ return fmt.Errorf("cli promoted to %s, but lock release failed: %w", libraryDir, releaseErr)
+ default:
+ return nil
+ }
+}
+
+// IsStale returns true if the lock's UpdatedAt is older than StaleLockThreshold.
+func IsStale(lock *LockState) bool {
+ return time.Since(lock.UpdatedAt) > StaleLockThreshold
+}
+
+func readLock(path string) (*LockState, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var lock LockState
+ if err := json.Unmarshal(data, &lock); err != nil {
+ return nil, err
+ }
+ return &lock, nil
+}
+
+func writeLock(path string, lock *LockState) error {
+ data, err := json.MarshalIndent(lock, "", " ")
+ if err != nil {
+ return err
+ }
+ // Write to a temp file in the same directory and rename for atomicity.
+ // This prevents concurrent readers from seeing truncated JSON.
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, path)
+}
+
+func writeLockExclusive(path string, lock *LockState) error {
+ data, err := json.MarshalIndent(lock, "", " ")
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
+ if err != nil {
+ return err
+ }
+ _, writeErr := f.Write(data)
+ closeErr := f.Close()
+ if writeErr != nil {
+ return writeErr
+ }
+ return closeErr
+}
diff --git a/internal/pipeline/lock_test.go b/internal/pipeline/lock_test.go
new file mode 100644
index 00000000..be9cfb0f
--- /dev/null
+++ b/internal/pipeline/lock_test.go
@@ -0,0 +1,438 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setupLockTest(t *testing.T) (cleanup func()) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmpDir)
+ return func() {}
+}
+
+func TestAcquireLock_NoExistingLock(t *testing.T) {
+ setupLockTest(t)
+
+ lock, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+ assert.Equal(t, "scope-1", lock.Scope)
+ assert.Equal(t, "acquire", lock.Phase)
+ assert.NotZero(t, lock.PID)
+ assert.WithinDuration(t, time.Now(), lock.AcquiredAt, 2*time.Second)
+ assert.WithinDuration(t, time.Now(), lock.UpdatedAt, 2*time.Second)
+
+ // Verify the lock file exists and is valid JSON.
+ data, err := os.ReadFile(LockFilePath("test-pp-cli"))
+ require.NoError(t, err)
+ var readBack LockState
+ require.NoError(t, json.Unmarshal(data, &readBack))
+ assert.Equal(t, "scope-1", readBack.Scope)
+}
+
+func TestAcquireLock_LocksDirectoryCreated(t *testing.T) {
+ setupLockTest(t)
+
+ _, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+
+ info, err := os.Stat(LocksDir())
+ require.NoError(t, err)
+ assert.True(t, info.IsDir())
+}
+
+func TestAcquireLock_RebuildCase(t *testing.T) {
+ setupLockTest(t)
+
+ // Create a library directory to simulate rebuild scenario.
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ require.NoError(t, os.MkdirAll(libDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(libDir, "go.mod"), []byte("module test"), 0o644))
+
+ lock, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+ assert.Equal(t, "scope-1", lock.Scope)
+}
+
+func TestAcquireLock_StaleLockAutoReclaim(t *testing.T) {
+ setupLockTest(t)
+
+ // Create a stale lock.
+ require.NoError(t, os.MkdirAll(LocksDir(), 0o755))
+ staleLock := &LockState{
+ Scope: "old-scope",
+ Phase: "build",
+ PID: 99999,
+ AcquiredAt: time.Now().Add(-2 * time.Hour),
+ UpdatedAt: time.Now().Add(-2 * time.Hour),
+ }
+ require.NoError(t, writeLock(LockFilePath("test-pp-cli"), staleLock))
+
+ lock, err := AcquireLock("test-pp-cli", "new-scope", false)
+ require.NoError(t, err)
+ assert.Equal(t, "new-scope", lock.Scope)
+}
+
+func TestAcquireLock_FreshLockDifferentScope_Blocked(t *testing.T) {
+ setupLockTest(t)
+
+ _, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+
+ _, err = AcquireLock("test-pp-cli", "scope-2", false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "lock held by scope")
+}
+
+func TestAcquireLock_FreshLockSameScope_Succeeds(t *testing.T) {
+ setupLockTest(t)
+
+ _, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+
+ lock, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+ assert.Equal(t, "scope-1", lock.Scope)
+}
+
+func TestAcquireLock_ForceOverridesFreshLock(t *testing.T) {
+ setupLockTest(t)
+
+ _, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+
+ lock, err := AcquireLock("test-pp-cli", "scope-2", true)
+ require.NoError(t, err)
+ assert.Equal(t, "scope-2", lock.Scope)
+}
+
+func TestUpdateLock(t *testing.T) {
+ setupLockTest(t)
+
+ _, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+
+ time.Sleep(10 * time.Millisecond) // Ensure time difference.
+
+ err = UpdateLock("test-pp-cli", "build-p0")
+ require.NoError(t, err)
+
+ lock, err := readLock(LockFilePath("test-pp-cli"))
+ require.NoError(t, err)
+ assert.Equal(t, "build-p0", lock.Phase)
+ assert.True(t, lock.UpdatedAt.After(lock.AcquiredAt))
+}
+
+func TestLockStatus_ActiveLock(t *testing.T) {
+ setupLockTest(t)
+
+ _, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+
+ status := LockStatus("test-pp-cli")
+ assert.True(t, status.Held)
+ assert.False(t, status.Stale)
+ assert.Equal(t, "acquire", status.Phase)
+ assert.Equal(t, "scope-1", status.Scope)
+ assert.NotNil(t, status.Lock)
+}
+
+func TestLockStatus_NoLock(t *testing.T) {
+ setupLockTest(t)
+
+ status := LockStatus("nonexistent-pp-cli")
+ assert.False(t, status.Held)
+ assert.False(t, status.HasCLI)
+}
+
+func TestLockStatus_NoLockWithLibraryCLI(t *testing.T) {
+ setupLockTest(t)
+
+ // Create library dir with go.mod.
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ require.NoError(t, os.MkdirAll(libDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(libDir, "go.mod"), []byte("module test"), 0o644))
+
+ status := LockStatus("test-pp-cli")
+ assert.False(t, status.Held)
+ assert.True(t, status.HasCLI)
+}
+
+func TestLockStatus_NoLockLibraryDirNoGoMod(t *testing.T) {
+ setupLockTest(t)
+
+ // Create library dir without go.mod (debris).
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ require.NoError(t, os.MkdirAll(libDir, 0o755))
+
+ status := LockStatus("test-pp-cli")
+ assert.False(t, status.Held)
+ assert.False(t, status.HasCLI)
+}
+
+func TestLockStatus_NoLockLibraryDirWithManifest(t *testing.T) {
+ setupLockTest(t)
+
+ // Create library dir with manifest but no go.mod.
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ require.NoError(t, os.MkdirAll(libDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(libDir, CLIManifestFilename), []byte("{}"), 0o644))
+
+ status := LockStatus("test-pp-cli")
+ assert.False(t, status.Held)
+ assert.True(t, status.HasCLI)
+}
+
+func TestReleaseLock(t *testing.T) {
+ setupLockTest(t)
+
+ _, err := AcquireLock("test-pp-cli", "scope-1", false)
+ require.NoError(t, err)
+
+ err = ReleaseLock("test-pp-cli")
+ require.NoError(t, err)
+
+ _, err = os.Stat(LockFilePath("test-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+}
+
+func TestReleaseLock_Idempotent(t *testing.T) {
+ setupLockTest(t)
+
+ err := ReleaseLock("nonexistent-pp-cli")
+ assert.NoError(t, err)
+}
+
+func TestPromoteWorkingCLI(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ // Create a working directory with content.
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+
+ // Create a lock.
+ _, err := AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ // Create minimal state.
+ state := NewStateWithRun("test", workDir, "run-001", "test-scope")
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.NoError(t, err)
+
+ // Verify library dir exists with copied content.
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ _, err = os.Stat(filepath.Join(libDir, "go.mod"))
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(libDir, "main.go"))
+ assert.NoError(t, err)
+
+ // Verify lock was released.
+ _, err = os.Stat(LockFilePath("test-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+
+ // Verify state was updated.
+ assert.Equal(t, libDir, state.PublishedDir)
+ assert.Equal(t, libDir, state.WorkingDir)
+}
+
+func TestPromoteWorkingCLI_ReplacesExistingLibrary(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ // Create existing library dir with old content.
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ require.NoError(t, os.MkdirAll(libDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(libDir, "old-file.txt"), []byte("old"), 0o644))
+
+ // Create working dir with new content.
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "new-file.txt"), []byte("new"), 0o644))
+
+ _, err := AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-002", "test-scope")
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.NoError(t, err)
+
+ // Old file should be gone.
+ _, err = os.Stat(filepath.Join(libDir, "old-file.txt"))
+ assert.True(t, os.IsNotExist(err))
+
+ // New file should exist.
+ _, err = os.Stat(filepath.Join(libDir, "new-file.txt"))
+ assert.NoError(t, err)
+}
+
+func TestPromoteWorkingCLI_EmptyWorkingDir(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+
+ state := NewStateWithRun("test", workDir, "run-003", "test-scope")
+
+ err := PromoteWorkingCLI("test-pp-cli", workDir, state)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "empty")
+}
+
+func TestPromoteWorkingCLI_PreservesOldOnFailure(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ // Create existing library dir with old content.
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ require.NoError(t, os.MkdirAll(libDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(libDir, "go.mod"), []byte("module old\n\ngo 1.21\n"), 0o644))
+
+ state := NewStateWithRun("test", "/nonexistent/path", "run-004", "test-scope")
+
+ // Promote with a nonexistent working dir should fail.
+ err := PromoteWorkingCLI("test-pp-cli", "/nonexistent/path", state)
+ assert.Error(t, err)
+
+ // Old library should still be intact.
+ data, readErr := os.ReadFile(filepath.Join(libDir, "go.mod"))
+ require.NoError(t, readErr)
+ assert.Contains(t, string(data), "module old")
+}
+
+func TestPromoteWorkingCLI_RetryRestoresBackupBeforeFailure(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ backupDir := libDir + ".old"
+ stagingDir := libDir + ".promoting"
+
+ // Simulate a crashed promote: backup survived, live library is missing,
+ // and stale staging debris is still present.
+ require.NoError(t, os.MkdirAll(backupDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(backupDir, "go.mod"), []byte("module old\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(stagingDir, "partial.txt"), []byte("partial"), 0o644))
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ outside := filepath.Join(tmp, "outside.txt")
+ require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644))
+ require.NoError(t, os.Symlink(outside, filepath.Join(workDir, "bad-link.txt")))
+
+ state := NewStateWithRun("test", workDir, "run-004", "test-scope")
+
+ err := PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "copying to staging directory")
+
+ // The previous published CLI should be restored before the retry fails.
+ data, readErr := os.ReadFile(filepath.Join(libDir, "go.mod"))
+ require.NoError(t, readErr)
+ assert.Contains(t, string(data), "module old")
+ _, statErr := os.Stat(backupDir)
+ assert.True(t, os.IsNotExist(statErr))
+}
+
+func TestPromoteWorkingCLI_ReleasesLockWhenStateSaveFails(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+
+ _, err := AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-005", "test-scope")
+
+ // Force state.Save() to fail after the library swap succeeds.
+ require.NoError(t, os.MkdirAll(filepath.Dir(state.PipelineDir()), 0o755))
+ require.NoError(t, os.WriteFile(state.PipelineDir(), []byte("not a directory"), 0o644))
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "cli promoted to")
+ assert.Contains(t, err.Error(), "state update failed")
+
+ libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ _, err = os.Stat(filepath.Join(libDir, "go.mod"))
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(libDir, "main.go"))
+ assert.NoError(t, err)
+
+ _, err = os.Stat(LockFilePath("test-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+}
+
+func TestIsStale(t *testing.T) {
+ fresh := &LockState{UpdatedAt: time.Now()}
+ assert.False(t, IsStale(fresh))
+
+ stale := &LockState{UpdatedAt: time.Now().Add(-31 * time.Minute)}
+ assert.True(t, IsStale(stale))
+
+ boundary := &LockState{UpdatedAt: time.Now().Add(-30*time.Minute - time.Second)}
+ assert.True(t, IsStale(boundary))
+}
+
+func TestConcurrentAcquire(t *testing.T) {
+ setupLockTest(t)
+
+ const goroutines = 10
+ var wg sync.WaitGroup
+ successes := make(chan string, goroutines)
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ scope := "scope-" + string(rune('A'+i))
+ go func(s string) {
+ defer wg.Done()
+ _, err := AcquireLock("test-pp-cli", s, false)
+ if err == nil {
+ successes <- s
+ }
+ }(scope)
+ }
+
+ wg.Wait()
+ close(successes)
+
+ // Exactly one goroutine should have succeeded at initial acquire.
+ // Others may succeed if they happen to be the same scope (unlikely)
+ // or fail. At minimum one should succeed.
+ winners := 0
+ for range successes {
+ winners++
+ }
+ assert.GreaterOrEqual(t, winners, 1, "at least one goroutine should acquire the lock")
+}
diff --git a/internal/pipeline/workflow_verify.go b/internal/pipeline/workflow_verify.go
index 60fb8e6f..4ce8a3d4 100644
--- a/internal/pipeline/workflow_verify.go
+++ b/internal/pipeline/workflow_verify.go
@@ -49,7 +49,7 @@ func RunWorkflowVerification(dir string) (*WorkflowVerifyReport, error) {
if err != nil {
return nil, fmt.Errorf("building CLI binary: %w", err)
}
- defer os.Remove(binary)
+ defer func() { _ = os.Remove(binary) }()
report := &WorkflowVerifyReport{
Dir: dir,
@@ -335,7 +335,7 @@ func parseSegment(seg string) (name string, idx int, hasIdx bool) {
m := arrayIndexRe.FindStringSubmatch(seg)
if m != nil {
idx := 0
- fmt.Sscanf(m[2], "%d", &idx)
+ _, _ = fmt.Sscanf(m[2], "%d", &idx)
return m[1], idx, true
}
return seg, 0, false
diff --git a/skills/printing-press-polish/SKILL.md b/skills/printing-press-polish/SKILL.md
index 37297ebe..c2e1edba 100644
--- a/skills/printing-press-polish/SKILL.md
+++ b/skills/printing-press-polish/SKILL.md
@@ -71,6 +71,21 @@ relative timestamps (e.g., "generated 2 hours ago").
CLI_DIR="<resolved path>"
CLI_NAME="$(basename "$CLI_DIR")"
+# Check if there's an active build lock — polish edits would be overwritten
+# when the running build promotes to library.
+_lock_json=$(printing-press lock status --cli "$CLI_NAME" --json 2>/dev/null)
+if echo "$_lock_json" | grep -q '"held".*true'; then
+ if echo "$_lock_json" | grep -q '"stale".*true'; then
+ echo "Warning: stale lock exists for $CLI_NAME (build may have crashed)."
+ echo "Proceeding with polish. Run 'printing-press lock release --cli $CLI_NAME' to clear."
+ else
+ echo "An active build is in progress for $CLI_NAME."
+ echo "Polish edits would be overwritten when the build promotes."
+ echo "Wait for the build to finish, then run polish."
+ exit 1
+ fi
+fi
+
# Verify it's a valid Go CLI
if [ ! -f "$CLI_DIR/go.mod" ]; then
echo "Not a valid CLI directory: $CLI_DIR"
diff --git a/skills/printing-press-score/SKILL.md b/skills/printing-press-score/SKILL.md
index dcf120ae..23f9e46d 100644
--- a/skills/printing-press-score/SKILL.md
+++ b/skills/printing-press-score/SKILL.md
@@ -2,7 +2,7 @@
name: printing-press-score
description: Score a generated CLI against the Steinberger bar, compare two CLIs side-by-side
version: 0.1.0
-min-binary-version: "0.2.0"
+min-binary-version: "0.3.0"
allowed-tools:
- Bash
- Read
@@ -35,7 +35,7 @@ Before any other commands, run the setup contract to verify the printing-press b
<!-- PRESS_SETUP_CONTRACT_START -->
```bash
-# min-binary-version: 0.2.0
+# min-binary-version: 0.3.0
if ! command -v printing-press >/dev/null 2>&1; then
if [ -x "$HOME/go/bin/printing-press" ]; then
echo "printing-press found at ~/go/bin/printing-press but not on PATH."
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index a96f4e93..01fcb7c1 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -2,7 +2,7 @@
name: printing-press
description: Generate a ship-ready CLI for an API with a lean research -> generate -> build -> shipcheck loop.
version: 2.0.0
-min-binary-version: "0.2.0"
+min-binary-version: "0.3.0"
allowed-tools:
- Bash
- Read
@@ -193,7 +193,7 @@ Before doing anything else:
<!-- PRESS_SETUP_CONTRACT_START -->
```bash
-# min-binary-version: 0.2.0
+# min-binary-version: 0.3.0
if ! command -v printing-press >/dev/null 2>&1; then
if [ -x "$HOME/go/bin/printing-press" ]; then
export PATH="$HOME/go/bin:$PATH"
@@ -269,9 +269,10 @@ RESEARCH_DIR="$API_RUN_DIR/research"
PROOFS_DIR="$API_RUN_DIR/proofs"
PIPELINE_DIR="$API_RUN_DIR/pipeline"
DISCOVERY_DIR="$API_RUN_DIR/discovery"
+CLI_WORK_DIR="$API_RUN_DIR/working/<api>-pp-cli"
STAMP="$(date +%Y-%m-%d-%H%M%S)"
-mkdir -p "$RESEARCH_DIR" "$PROOFS_DIR" "$PIPELINE_DIR"
+mkdir -p "$RESEARCH_DIR" "$PROOFS_DIR" "$PIPELINE_DIR" "$CLI_WORK_DIR"
STATE_FILE="$API_RUN_DIR/state.json"
```
@@ -280,8 +281,8 @@ Maintain a lightweight state file at `$STATE_FILE` so `/printing-press-score` ca
```json
{
"api_name": "<api>",
- "working_dir": "<absolute cli dir>",
- "output_dir": "<absolute cli dir>",
+ "working_dir": "$CLI_WORK_DIR",
+ "output_dir": "$CLI_WORK_DIR",
"spec_path": "<absolute spec path if known>"
}
```
@@ -289,6 +290,7 @@ Maintain a lightweight state file at `$STATE_FILE` so `/printing-press-score` ca
Active mutable work lives under `$PRESS_RUNSTATE/`. Published CLIs live under `$PRESS_LIBRARY/`. Archived research and verification evidence live under `$PRESS_MANUSCRIPTS/<cli-name>/<run-id>/` (keyed by CLI name, e.g., `steam-web-pp-cli`, not the API slug). Do not write mutable run artifacts into the repo checkout.
Examples of the current naming/layout to preserve:
+- `/printing-press emboss notion-pp-cli`
- `discord-pp-cli/internal/store/store.go`
- `linear-pp-cli stale --days 30 --team ENG`
- `github.com/mvanhorn/discord-pp-cli`
@@ -347,11 +349,29 @@ Before new research:
- `$PRESS_MANUSCRIPTS/<cli-name>/*/research/*` (also check `$PRESS_MANUSCRIPTS/<api>/*/research/*` for backwards compatibility)
- `$REPO_ROOT/docs/plans/*<api>*` (legacy fallback)
3. Reuse good prior work instead of redoing it.
-4. **Library Check** — Check if a CLI for this API already exists in the library and present the user with context and options.
+4. **Library Check** — Check if a CLI for this API already exists in the library or is actively being built, and present the user with context and options.
+
+ First, check lock status to detect active builds:
+
+ ```bash
+ LOCK_STATUS=$(printing-press lock status --cli <api>-pp-cli --json 2>/dev/null)
+ LOCK_HELD=$(echo "$LOCK_STATUS" | grep -o '"held"[[:space:]]*:[[:space:]]*[a-z]*' | head -1 | sed 's/.*: *//')
+ LOCK_STALE=$(echo "$LOCK_STATUS" | grep -o '"stale"[[:space:]]*:[[:space:]]*[a-z]*' | head -1 | sed 's/.*: *//')
+ LOCK_PHASE=$(echo "$LOCK_STATUS" | grep -o '"phase"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"phase"[[:space:]]*:[[:space:]]*"//;s/"//')
+ LOCK_AGE=$(echo "$LOCK_STATUS" | grep -o '"age_seconds"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | sed 's/.*: *//')
+ ```
+
+ Then check the library directory:
```bash
CLI_DIR="$PRESS_LIBRARY/<api>-pp-cli"
+ HAS_LIBRARY=false
+ HAS_GOMOD=false
if [ -d "$CLI_DIR" ]; then
+ HAS_LIBRARY=true
+ if [ -f "$CLI_DIR/go.mod" ]; then
+ HAS_GOMOD=true
+ fi
# Read manifest if available
MANIFEST="$CLI_DIR/.printing-press.json"
if [ -f "$MANIFEST" ]; then
@@ -363,7 +383,23 @@ Before new research:
fi
```
- If the directory exists, display context and present options using `AskUserQuestion`:
+ **Decision matrix:**
+
+ | Library dir? | Lock? | Stale? | Has go.mod? | Action |
+ |-------------|-------|--------|-------------|--------|
+ | No | No | N/A | N/A | Proceed normally |
+ | No | Yes | No | N/A | Warn: "Actively being built (phase: `<phase>`, `<age>` seconds ago). Wait, use a different name, or pick a different API." |
+ | No | Yes | Yes | N/A | Offer reclaim: "Interrupted build detected (stale since `<age>`s ago). Reclaim and start fresh?" |
+ | Yes | No | N/A | Yes | Existing "Found existing" flow (see below) |
+ | Yes | No | N/A | No | Debris: "Found `<cli-name>` directory in library but it appears incomplete (no go.mod). Clean up and start fresh?" If user approves, `rm -rf "$CLI_DIR"` and proceed normally. |
+ | Yes | Yes | No | Any | Warn: "Actively being rebuilt (phase: `<phase>`, `<age>` seconds ago). Wait, use a different name, or pick a different API." |
+ | Yes | Yes | Yes | Any | Offer reclaim: "Interrupted rebuild detected (stale since `<age>`s ago). Reclaim and start fresh?" |
+
+ **If actively locked (not stale):** Present via `AskUserQuestion` with options to wait, pick a different API, or force-reclaim (`printing-press lock acquire --cli <api>-pp-cli --scope "$PRESS_SCOPE" --force`).
+
+ **If stale lock:** Reclaiming is automatic on `lock acquire` in Phase 2. If user approves, proceed normally — the lock acquire in Phase 2 will auto-reclaim the stale lock.
+
+ **If library exists with go.mod and no lock (completed CLI):** Display context and present options using `AskUserQuestion`:
> Found existing `<cli-name>` in library (last modified `<date>`).
@@ -372,7 +408,7 @@ Before new research:
If prior research was also found (step 2), include the research summary alongside the library info.
Then ask:
- 1. **"Generate a fresh CLI"** — Re-runs the generator into the same directory (`--force`), overwrites generated code, then rebuilds transcendence features. Prior research is reused if recent. ~15-20 min.
+ 1. **"Generate a fresh CLI"** — Re-runs the generator into a working directory, overwrites generated code, then rebuilds transcendence features. Prior research is reused if recent. ~15-20 min.
2. **"Improve existing CLI"** — Keeps all current code, audits for quality gaps, implements top improvements. The generator is not re-run. ~10 min.
3. **"Review prior research first"** — Show the full research brief and absorb manifest before deciding.
@@ -380,7 +416,7 @@ Before new research:
If the user picks option 2, invoke `/printing-press-polish <cli-name>` to improve the existing CLI.
If the user picks option 3, display the prior research, then re-present options 1 and 2.
- If no CLI exists in the library, skip this step and proceed normally.
+ If no CLI exists in the library and no lock is active, skip this step and proceed normally.
5. **API Key Gate** — Check whether this API requires authentication, then handle accordingly.
@@ -905,12 +941,20 @@ Proceed silently to Phase 2.
Use the resolved spec source and generate immediately.
+Before running any generate command, acquire the build lock:
+
+```bash
+printing-press lock acquire --cli <api>-pp-cli --scope "$PRESS_SCOPE"
+```
+
+If acquire fails (another session holds a fresh lock), present the lock status to the user and let them decide: wait, use a different CLI name, force-reclaim, or pick a different API.
+
OpenAPI / internal YAML:
```bash
printing-press generate \
--spec <spec-path-or-url> \
- --output "$PRESS_LIBRARY/<api>-pp-cli" \
+ --output "$CLI_WORK_DIR" \
--research-dir "$API_RUN_DIR" \
--force --lenient --validate
```
@@ -922,7 +966,7 @@ printing-press generate \
--spec <original-spec-path-or-url> \
--spec "$RESEARCH_DIR/<api>-sniff-spec.yaml" \
--name <api> \
- --output "$PRESS_LIBRARY/<api>-pp-cli" \
+ --output "$CLI_WORK_DIR" \
--research-dir "$API_RUN_DIR" \
--spec-source sniffed \
--force --lenient --validate
@@ -935,7 +979,7 @@ Sniff-only (no original spec, sniff was the primary source):
```bash
printing-press generate \
--spec "$RESEARCH_DIR/<api>-sniff-spec.yaml" \
- --output "$PRESS_LIBRARY/<api>-pp-cli" \
+ --output "$CLI_WORK_DIR" \
--research-dir "$API_RUN_DIR" \
--spec-source sniffed \
--force --lenient --validate
@@ -950,7 +994,7 @@ printing-press generate \
--spec <original-spec-path-or-url> \
--spec "$RESEARCH_DIR/<api>-crowd-spec.yaml" \
--name <api> \
- --output "$PRESS_LIBRARY/<api>-pp-cli" \
+ --output "$CLI_WORK_DIR" \
--research-dir "$API_RUN_DIR" \
--force --lenient --validate
```
@@ -960,7 +1004,7 @@ Crowd-sniff-only (no original spec, crowd sniff was the primary source):
```bash
printing-press generate \
--spec "$RESEARCH_DIR/<api>-crowd-spec.yaml" \
- --output "$PRESS_LIBRARY/<api>-pp-cli" \
+ --output "$CLI_WORK_DIR" \
--research-dir "$API_RUN_DIR" \
--force --lenient --validate
```
@@ -973,7 +1017,7 @@ printing-press generate \
--spec "$RESEARCH_DIR/<api>-sniff-spec.yaml" \
--spec "$RESEARCH_DIR/<api>-crowd-spec.yaml" \
--name <api> \
- --output "$PRESS_LIBRARY/<api>-pp-cli" \
+ --output "$CLI_WORK_DIR" \
--research-dir "$API_RUN_DIR" \
--force --lenient --validate
```
@@ -984,7 +1028,7 @@ Docs-only:
printing-press generate \
--docs <docs-url> \
--name <api> \
- --output "$PRESS_LIBRARY/<api>-pp-cli" \
+ --output "$CLI_WORK_DIR" \
--research-dir "$API_RUN_DIR" \
--force --validate
```
@@ -998,7 +1042,7 @@ After generation:
**REQUIRED: Rewrite the CLI description.** The generator copies the spec's `description` field
as the CLI's `Short` help text. Spec descriptions describe the *API* ("Payment processing API")
but CLI help should describe what the *CLI does* ("Manage payments, subscriptions, and invoices
-via the Stripe API"). Open `$PRESS_LIBRARY/<api>-pp-cli/internal/cli/root.go`, find the
+via the Stripe API"). Open `$CLI_WORK_DIR/internal/cli/root.go`, find the
`Short:` field on the root cobra command, and rewrite it as a concise, user-facing description
of the CLI's purpose. Use the product thesis from the Phase 1 brief to inform the rewrite.
@@ -1016,6 +1060,12 @@ or auth method that the spec didn't declare, add the appropriate env var support
and `os.Getenv("<API>_API_KEY")` in the Load function. The research brief is the
authoritative source when the spec is silent on auth.
+After the description rewrite, update the lock heartbeat:
+
+```bash
+printing-press lock update --cli <api>-pp-cli --phase generate
+```
+
Then:
- note skipped complex body fields
- fix only blocking generation failures here
@@ -1025,6 +1075,10 @@ If generation fails:
- fix the specific blocker
- retry at most 2 times
- prefer generator fixes over manual generated-code surgery when the failure is systemic
+- if retries are exhausted, release the lock and stop:
+ ```bash
+ printing-press lock release --cli <api>-pp-cli
+ ```
## Phase 3: Build The GOAT
@@ -1041,16 +1095,36 @@ Priority 0 (foundation):
- data layer for ALL primary entities from the manifest
- sync/search/SQL path - this is what makes transcendence possible
+After completing Priority 0, update the lock heartbeat:
+```bash
+printing-press lock update --cli <api>-pp-cli --phase build-p0
+```
+
Priority 1 (absorb - match everything):
- ALL absorbed features from the Phase 1.5 manifest
- Every feature from every competing tool, matched and beaten with agent-native output
- This is NOT "top 3-5" - it is the FULL manifest
+**Lock heartbeat rule for long priority levels:** If Priority 1 has more than 5 features, update the lock heartbeat after every 3-5 features to prevent the 30-minute staleness threshold from triggering mid-build:
+```bash
+printing-press lock update --cli <api>-pp-cli --phase build-p1-progress
+```
+
Priority 2 (transcend - build what nobody else has):
- ALL transcendence features from Phase 1.5
- The NOI commands that only work because everything is in SQLite
- These are the commands that make someone say "I need this"
+**Lock heartbeat rule for Priority 2:** Same rule as Priority 1 — if Priority 2 has more than 3 transcendence features, update the heartbeat after every 2-3 features:
+```bash
+printing-press lock update --cli <api>-pp-cli --phase build-p2-progress
+```
+
+After completing Priority 2, update the lock heartbeat:
+```bash
+printing-press lock update --cli <api>-pp-cli --phase build-p2
+```
+
Priority 3 (polish):
- skipped complex request bodies that block important commands
- naming cleanup for ugly operationId-derived commands
@@ -1086,6 +1160,11 @@ Pick 3 random commands from Priority 1. Run each with:
If any of the 3 fail, there's a systemic issue. Fix it across all commands before proceeding. This catches problems like "--dry-run not wired" or "--json outputs table instead of JSON" early, when they're cheap to fix.
+After passing the Priority 1 Review Gate, update the lock heartbeat:
+```bash
+printing-press lock update --cli <api>-pp-cli --phase build-p1
+```
+
Get Priority 0 and 1 working first (the foundation and absorbed features), pass the review gate, then build Priority 2 (transcendence), then verify.
Write:
@@ -1102,11 +1181,16 @@ Include:
Run one combined verification block.
+Before running shipcheck, update the lock heartbeat:
+```bash
+printing-press lock update --cli <api>-pp-cli --phase shipcheck
+```
+
```bash
-printing-press dogfood --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec>
-printing-press verify --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec> --fix
-printing-press workflow-verify --dir "$PRESS_LIBRARY/<api>-pp-cli"
-printing-press scorecard --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec>
+printing-press dogfood --dir "$CLI_WORK_DIR" --spec <same-spec>
+printing-press verify --dir "$CLI_WORK_DIR" --spec <same-spec> --fix
+printing-press workflow-verify --dir "$CLI_WORK_DIR"
+printing-press scorecard --dir "$CLI_WORK_DIR" --spec <same-spec>
```
Interpretation:
@@ -1115,13 +1199,18 @@ Interpretation:
- `workflow-verify` tests the primary workflow end-to-end using the verification manifest (workflow_verify.yaml). Three verdicts: workflow-pass, workflow-fail, unverified-needs-auth
- `scorecard` is the structural quality snapshot, not the source of truth by itself
-Fix order:
+Fix order (update heartbeat between each fix category to prevent stale lock during long fix loops):
1. generation blockers or build breaks
2. invalid paths and auth mismatches
3. dead flags / dead functions / ghost tables
4. broken dry-run and runtime command failures
5. scorecard-only polish gaps
+After fixing each category, update the heartbeat:
+```bash
+printing-press lock update --cli <api>-pp-cli --phase shipcheck-fixing
+```
+
<!-- CODEX_PHASE4_START -->
When `CODEX_MODE` is true, read [references/codex-delegation.md](references/codex-delegation.md)
for the Phase 4 fix delegation pattern.
@@ -1150,6 +1239,12 @@ Include:
- before/after scorecard total
- final ship recommendation: `ship`, `ship-with-gaps`, or `hold`
+If the final verdict is `hold`, release the lock without promoting to library:
+```bash
+printing-press lock release --cli <api>-pp-cli
+```
+The working copy remains in `$CLI_WORK_DIR` for potential future retry. Proceed to Phase 5.5 to archive manuscripts (archiving still happens on hold).
+
## Phase 5: Optional Live Smoke
Only run this if a token is available and the user agreed.
@@ -1167,10 +1262,25 @@ Write:
`$PROOFS_DIR/<stamp>-fix-<api>-pp-cli-live-smoke.md`
-## Phase 5.5: Archive Manuscripts
+## Phase 5.5: Promote and Archive
+
+### Promote to Library
+
+If the shipcheck verdict is `ship` or `ship-with-gaps`, promote the verified CLI from the working directory to the library. This must happen BEFORE archiving — the CLI in the library is the primary deliverable.
+
+```bash
+# Promote verified CLI to library (copies working dir, writes manifest, releases lock)
+printing-press lock promote --cli <api>-pp-cli --dir "$CLI_WORK_DIR"
+```
+
+The `promote` command handles the full sequence: stages the working directory, atomically swaps it into `$PRESS_LIBRARY/<api>-pp-cli`, writes the `.printing-press.json` manifest, updates the `CurrentRunPointer`, and releases the lock — all in one step.
+
+If the shipcheck verdict is `hold`, the lock was already released in Phase 4. Do NOT promote. The working copy stays in `$CLI_WORK_DIR` and is not copied to the library.
+
+### Archive Manuscripts
Archive the run's research, proofs, and discovery artifacts to `$PRESS_MANUSCRIPTS/`
-**unconditionally** after shipcheck completes (or after live smoke if it ran). This
+**unconditionally** after promotion (or after lock release for `hold` verdicts). This
happens regardless of the shipcheck verdict — even a `hold` run produces research
and proofs that future runs should be able to reuse.
← a28d1b48 fix(cli): add primary workflow verification to printing pres
·
back to Cli Printing Press
·
feat(cli): add Chrome cookie auth for sniff-discovered APIs b0a38151 →