[object Object]

← back to Cli Printing Press

fix(cli): stage runstate manuscripts during lock promote (#1094)

17d1930735e5e9d5f26298640e869f978951b886 · 2026-05-11 11:15:41 -0700 · Trevin Chow

Phase 5 writes its acceptance marker into the runstate's proofs/ dir
($PRESS_HOME/.runstate/<scope>/runs/<run-id>/proofs/), but downstream
consumers like `publish validate` look for it inside the published
library copy at <lib>/.manuscripts/<run-id>/proofs/. Before this
change, `lock promote` did not bridge that gap, so every successful
generation tripped the phase5 leg unless the operator manually
`cp -r`'d the runstate dirs before validating.

`PromoteWorkingCLI` now stages the runstate's proofs/research/discovery
trees into the staging copy's .manuscripts/<run-id>/ before the atomic
swap. The helper is a no-op when state has no RunID (NewMinimalState
path for plan-driven CLIs), and a pre-existing subtree wins so artifacts
generate or polish dropped directly into the working dir survive.

Closes #889

Files touched

Diff

commit 17d1930735e5e9d5f26298640e869f978951b886
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 11:15:41 2026 -0700

    fix(cli): stage runstate manuscripts during lock promote (#1094)
    
    Phase 5 writes its acceptance marker into the runstate's proofs/ dir
    ($PRESS_HOME/.runstate/<scope>/runs/<run-id>/proofs/), but downstream
    consumers like `publish validate` look for it inside the published
    library copy at <lib>/.manuscripts/<run-id>/proofs/. Before this
    change, `lock promote` did not bridge that gap, so every successful
    generation tripped the phase5 leg unless the operator manually
    `cp -r`'d the runstate dirs before validating.
    
    `PromoteWorkingCLI` now stages the runstate's proofs/research/discovery
    trees into the staging copy's .manuscripts/<run-id>/ before the atomic
    swap. The helper is a no-op when state has no RunID (NewMinimalState
    path for plan-driven CLIs), and a pre-existing subtree wins so artifacts
    generate or polish dropped directly into the working dir survive.
    
    Closes #889
---
 docs/ARTIFACTS.md              |   1 +
 internal/pipeline/lock.go      |  51 +++++++++++++++
 internal/pipeline/lock_test.go | 144 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 196 insertions(+)

diff --git a/docs/ARTIFACTS.md b/docs/ARTIFACTS.md
index 27ee56a7..deaf67ae 100644
--- a/docs/ARTIFACTS.md
+++ b/docs/ARTIFACTS.md
@@ -5,6 +5,7 @@ Generated artifacts live under the user's home directory, not in this repo.
 ## Local artifacts
 
 - `~/printing-press/library/<api-slug>/` — local library: printed CLIs the generator has produced. Directory names are keyed by API slug, not CLI name. The binary inside is still `<api-slug>-pp-cli`.
+- `~/printing-press/library/<api-slug>/.manuscripts/<run-id>/` — per-run manuscripts (proofs, research, discovery) embedded inside the printed CLI by `printing-press lock promote`. Mirrors what `publish package` later copies into a packaged tarball, so `publish validate` can find the Phase 5 acceptance marker on a freshly promoted CLI without manual `cp -r` from the runstate.
 - `~/printing-press/manuscripts/<api-slug>/` — archived research and verification proofs, keyed by API slug. One API can have multiple runs.
 - `~/printing-press/.runstate/<scope>/` — mutable per-workspace state such as current run and sync cursors.
 
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index 31ae7e51..f5f3586b 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -266,6 +266,13 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
 		return fmt.Errorf("copying to staging directory: %w", err)
 	}
 
+	// Phase 5 writes acceptance markers to the runstate, but the published
+	// copy is the path downstream consumers see — embed them before the swap.
+	if err := stageRunstateManuscripts(stagingDir, state); err != nil {
+		_ = os.RemoveAll(stagingDir)
+		return fmt.Errorf("staging runstate manuscripts: %w", err)
+	}
+
 	// Update state to reflect promotion.
 	state.PublishedDir = libraryDir
 
@@ -335,6 +342,50 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
 	}
 }
 
+// A pre-existing subtree in the staging copy wins so artifacts that generate
+// or polish wrote directly into the working dir are never overwritten by an
+// older runstate snapshot. No-op when state has no RunID — the
+// NewMinimalState path for plan-driven CLIs has no runstate to stage from.
+func stageRunstateManuscripts(stagingDir string, state *PipelineState) error {
+	if state == nil || state.RunID == "" {
+		return nil
+	}
+	// The leaf component of each source path becomes the subdir name in
+	// staging (proofs/research/discovery). This pairs with the directory
+	// shape established by paths.go's RunProofsDir, RunResearchDir, and
+	// RunDiscoveryDir helpers; if those rename their leaf names, the
+	// destination layout follows automatically.
+	sources := []string{
+		state.ProofsDir(),
+		state.ResearchDir(),
+		state.DiscoveryDir(),
+	}
+	dstRoot := filepath.Join(stagingDir, ".manuscripts", state.RunID)
+	for _, src := range sources {
+		name := filepath.Base(src)
+		info, err := os.Stat(src)
+		if err != nil {
+			if os.IsNotExist(err) {
+				continue
+			}
+			return fmt.Errorf("inspecting runstate %s: %w", name, err)
+		}
+		if !info.IsDir() {
+			continue
+		}
+		target := filepath.Join(dstRoot, name)
+		if _, statErr := os.Stat(target); statErr == nil {
+			continue
+		} else if !os.IsNotExist(statErr) {
+			return fmt.Errorf("checking %s in staging: %w", target, statErr)
+		}
+		if err := CopyDir(src, target); err != nil {
+			return fmt.Errorf("copying runstate %s: %w", name, err)
+		}
+	}
+	return nil
+}
+
 func validatePhase5GateForPromote(workingDir string, state *PipelineState) error {
 	if state == nil || state.RunID == "" {
 		return nil
diff --git a/internal/pipeline/lock_test.go b/internal/pipeline/lock_test.go
index b9515b3f..028abe63 100644
--- a/internal/pipeline/lock_test.go
+++ b/internal/pipeline/lock_test.go
@@ -626,6 +626,150 @@ func TestPromoteWorkingCLI_MinimalStateNoRunstate(t *testing.T) {
 	assert.Equal(t, libDir, state.PublishedDir)
 }
 
+func TestPromoteWorkingCLI_StagesRunstateManuscripts(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-stage-001", "test-scope")
+	writePhase5PassForState(t, state, "none")
+
+	// Plant research and discovery alongside the phase5 marker so we can
+	// assert the whole runstate triplet gets staged into the published copy.
+	require.NoError(t, os.MkdirAll(state.ResearchDir(), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(state.ResearchDir(), "notes.md"), []byte("research notes\n"), 0o644))
+	require.NoError(t, os.MkdirAll(state.DiscoveryDir(), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(state.DiscoveryDir(), "endpoints.json"), []byte("{}\n"), 0o644))
+
+	err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+	require.NoError(t, err)
+
+	libDir := filepath.Join(PublishedLibraryRoot(), "test")
+	manuRoot := filepath.Join(libDir, ".manuscripts", state.RunID)
+
+	// phase5-acceptance.json must be reachable at the path publish validate
+	// looks at first: <lib>/.manuscripts/<run-id>/proofs/.
+	_, err = os.Stat(filepath.Join(manuRoot, "proofs", Phase5AcceptanceFilename))
+	assert.NoError(t, err, "phase5 acceptance marker should be staged into the published copy")
+
+	data, err := os.ReadFile(filepath.Join(manuRoot, "research", "notes.md"))
+	require.NoError(t, err)
+	assert.Equal(t, "research notes\n", string(data))
+
+	data, err = os.ReadFile(filepath.Join(manuRoot, "discovery", "endpoints.json"))
+	require.NoError(t, err)
+	assert.Equal(t, "{}\n", string(data))
+}
+
+func TestPromoteWorkingCLI_PreservesPreexistingManuscripts(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-stage-002", "test-scope")
+	writePhase5PassForState(t, state, "none")
+
+	// Working dir already carries a proofs subtree at the destination path
+	// with a sentinel file. We must not clobber it during staging.
+	preexistingProofs := filepath.Join(workDir, ".manuscripts", state.RunID, "proofs")
+	require.NoError(t, os.MkdirAll(preexistingProofs, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(preexistingProofs, "sentinel.txt"), []byte("keep-me\n"), 0o644))
+
+	// Also seed a different, non-overlapping research dir in runstate so
+	// non-conflicting subdirs still get staged on the same run.
+	require.NoError(t, os.MkdirAll(state.ResearchDir(), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(state.ResearchDir(), "notes.md"), []byte("from runstate\n"), 0o644))
+
+	err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+	require.NoError(t, err)
+
+	libDir := filepath.Join(PublishedLibraryRoot(), "test")
+	manuRoot := filepath.Join(libDir, ".manuscripts", state.RunID)
+
+	data, err := os.ReadFile(filepath.Join(manuRoot, "proofs", "sentinel.txt"))
+	require.NoError(t, err, "pre-existing proofs subtree should survive staging")
+	assert.Equal(t, "keep-me\n", string(data))
+
+	data, err = os.ReadFile(filepath.Join(manuRoot, "research", "notes.md"))
+	require.NoError(t, err)
+	assert.Equal(t, "from runstate\n", string(data))
+}
+
+func TestPromoteWorkingCLI_StagesPartialRunstate(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-stage-003", "test-scope")
+	// Only proofs is populated — research and discovery dirs do not exist
+	// in the runstate. The staging step must skip them via os.IsNotExist
+	// without aborting the promote.
+	writePhase5PassForState(t, state, "none")
+
+	err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+	require.NoError(t, err)
+
+	libDir := filepath.Join(PublishedLibraryRoot(), "test")
+	manuRoot := filepath.Join(libDir, ".manuscripts", state.RunID)
+
+	_, err = os.Stat(filepath.Join(manuRoot, "proofs", Phase5AcceptanceFilename))
+	assert.NoError(t, err)
+
+	_, statErr := os.Stat(filepath.Join(manuRoot, "research"))
+	assert.True(t, os.IsNotExist(statErr), "research subdir should not be created when runstate has none")
+	_, statErr = os.Stat(filepath.Join(manuRoot, "discovery"))
+	assert.True(t, os.IsNotExist(statErr), "discovery subdir should not be created when runstate has none")
+}
+
+func TestPromoteWorkingCLI_StagingNoopForMinimalState(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))
+
+	state := NewMinimalState("test-pp-cli", workDir)
+	require.Empty(t, state.RunID, "minimal state should have no RunID")
+
+	err := PromoteWorkingCLI("test-pp-cli", workDir, state)
+	require.NoError(t, err)
+
+	libDir := filepath.Join(PublishedLibraryRoot(), "test")
+	_, statErr := os.Stat(filepath.Join(libDir, ".manuscripts"))
+	assert.True(t, os.IsNotExist(statErr), "minimal-state promote should not create a .manuscripts dir")
+}
+
 func TestIsStale(t *testing.T) {
 	fresh := &LockState{UpdatedAt: time.Now()}
 	assert.False(t, IsStale(fresh))

← 3d515c45 fix(cli): delegate workflow archive to syncResource (#1090)  ·  back to Cli Printing Press  ·  fix(cli): unwrap single-key API envelopes before agent prove ff4b6528 →