[object Object]

← back to Cli Printing Press

fix(cli): stamp run_id into manifest at generate time (#606)

37ee7e38b6b3b9f6b989fa8a52172cee8cb104c8 · 2026-05-05 00:26:44 -0700 · Trevin Chow

* fix(cli): stamp run_id into manifest at generate time

Belt-and-suspenders fix for the missing run_id failure mode that's been
hitting ~12% of v3.x library CLIs (allrecipes, yahoo-finance, trigger-dev).
Without run_id in .printing-press.json, printing-press dogfood --live
--write-acceptance refuses to write the gate marker and lock promote
refuses to promote — the canonical pipeline halts.

Two halves, one PR (different review surfaces but same root cause):

1. Generator (internal/pipeline/climanifest.go, internal/cli/root.go).
   GenerateManifestParams gains a RunID field; WriteManifestForGenerate
   stamps it into the emitted manifest. Both root.go call sites
   (:161, :321) derive the value via DeriveRunIDFromResearchDir, which
   extracts the basename of --research-dir when it matches ^\d{8}-\d{6}$
   (the canonical pipeline run_id shape). When derivation yields empty,
   the call site emits a one-line stderr warning that phase5 acceptance
   will refuse to write without it — deliberate signal, not a blocker.
   The empty fallback preserves the legacy --output-only path; the
   warning surfaces on every generate without --research-dir, which is
   why the four generate-* golden cases need re-baselining (separate
   commit).

2. Skill template (skills/printing-press/SKILL.md). The state.json schema
   now includes "run_id": "$RUN_ID", consistent with RUN_ID="$(date
   +%Y%m%d-%H%M%S)" already computed earlier in Run Initialization.
   Persisting run_id in state.json keeps /printing-press-score and any
   future state-loading consumer in sync with the value the manifest
   now carries — without depending on the --research-dir naming
   convention.

Tests cover the positive case (RunID stamped through), the omitempty
case (empty RunID produces no run_id field in the JSON), and the
DeriveRunIDFromResearchDir derivation across canonical, edge, and
non-matching shapes.

Closes #591 (which consolidated #588).

Plan: docs/plans/2026-05-04-005-fix-wave-1-retro-blockers-plan.md (U2 + U3)

* test(cli): re-baseline generate goldens for run_id warning

The generate command now emits a stderr warning when --research-dir is
absent or its basename does not match ^\d{8}-\d{6}$ (the canonical
run_id shape). All four generate-* golden cases run without
--research-dir, so they pick up the new warning line. Each golden
gains exactly one prefix line:

  warning: could not derive run_id from --research-dir; phase5 dogfood
  acceptance will refuse to write without it

Verified via scripts/golden.sh verify after update — all 11 cases pass.

Plan: docs/plans/2026-05-04-005-fix-wave-1-retro-blockers-plan.md (U2)

Files touched

Diff

commit 37ee7e38b6b3b9f6b989fa8a52172cee8cb104c8
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 5 00:26:44 2026 -0700

    fix(cli): stamp run_id into manifest at generate time (#606)
    
    * fix(cli): stamp run_id into manifest at generate time
    
    Belt-and-suspenders fix for the missing run_id failure mode that's been
    hitting ~12% of v3.x library CLIs (allrecipes, yahoo-finance, trigger-dev).
    Without run_id in .printing-press.json, printing-press dogfood --live
    --write-acceptance refuses to write the gate marker and lock promote
    refuses to promote — the canonical pipeline halts.
    
    Two halves, one PR (different review surfaces but same root cause):
    
    1. Generator (internal/pipeline/climanifest.go, internal/cli/root.go).
       GenerateManifestParams gains a RunID field; WriteManifestForGenerate
       stamps it into the emitted manifest. Both root.go call sites
       (:161, :321) derive the value via DeriveRunIDFromResearchDir, which
       extracts the basename of --research-dir when it matches ^\d{8}-\d{6}$
       (the canonical pipeline run_id shape). When derivation yields empty,
       the call site emits a one-line stderr warning that phase5 acceptance
       will refuse to write without it — deliberate signal, not a blocker.
       The empty fallback preserves the legacy --output-only path; the
       warning surfaces on every generate without --research-dir, which is
       why the four generate-* golden cases need re-baselining (separate
       commit).
    
    2. Skill template (skills/printing-press/SKILL.md). The state.json schema
       now includes "run_id": "$RUN_ID", consistent with RUN_ID="$(date
       +%Y%m%d-%H%M%S)" already computed earlier in Run Initialization.
       Persisting run_id in state.json keeps /printing-press-score and any
       future state-loading consumer in sync with the value the manifest
       now carries — without depending on the --research-dir naming
       convention.
    
    Tests cover the positive case (RunID stamped through), the omitempty
    case (empty RunID produces no run_id field in the JSON), and the
    DeriveRunIDFromResearchDir derivation across canonical, edge, and
    non-matching shapes.
    
    Closes #591 (which consolidated #588).
    
    Plan: docs/plans/2026-05-04-005-fix-wave-1-retro-blockers-plan.md (U2 + U3)
    
    * test(cli): re-baseline generate goldens for run_id warning
    
    The generate command now emits a stderr warning when --research-dir is
    absent or its basename does not match ^\d{8}-\d{6}$ (the canonical
    run_id shape). All four generate-* golden cases run without
    --research-dir, so they pick up the new warning line. Each golden
    gains exactly one prefix line:
    
      warning: could not derive run_id from --research-dir; phase5 dogfood
      acceptance will refuse to write without it
    
    Verified via scripts/golden.sh verify after update — all 11 cases pass.
    
    Plan: docs/plans/2026-05-04-005-fix-wave-1-retro-blockers-plan.md (U2)
---
 internal/cli/root.go                               | 10 ++++
 internal/pipeline/climanifest.go                   | 24 ++++++++++
 internal/pipeline/climanifest_test.go              | 55 ++++++++++++++++++++++
 skills/printing-press/SKILL.md                     |  3 ++
 .../generate-golden-api-oauth2-cc/stderr.txt       |  1 +
 .../generate-golden-api-unicode/stderr.txt         |  1 +
 .../golden/expected/generate-golden-api/stderr.txt |  1 +
 .../expected/generate-tier-routing-api/stderr.txt  |  1 +
 8 files changed, 96 insertions(+)

diff --git a/internal/cli/root.go b/internal/cli/root.go
index 50d66bd8..8e634157 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -158,11 +158,16 @@ func newGenerateCmd() *cobra.Command {
 					return err
 				}
 
+				runID := pipeline.DeriveRunIDFromResearchDir(researchDir)
+				if runID == "" {
+					fmt.Fprintln(os.Stderr, "warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it")
+				}
 				if err := pipeline.WriteManifestForGenerate(pipeline.GenerateManifestParams{
 					APIName:       parsed.Name,
 					DocsURL:       docsURL,
 					OutputDir:     absOut,
 					Owner:         parsed.Owner,
+					RunID:         runID,
 					Spec:          parsed,
 					NovelFeatures: novelFeatures,
 				}); err != nil {
@@ -318,12 +323,17 @@ func newGenerateCmd() *cobra.Command {
 				}
 			}
 
+			runID := pipeline.DeriveRunIDFromResearchDir(researchDir)
+			if runID == "" {
+				fmt.Fprintln(os.Stderr, "warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it")
+			}
 			if err := pipeline.WriteManifestForGenerate(pipeline.GenerateManifestParams{
 				APIName:       apiSpec.Name,
 				SpecSrcs:      specFiles,
 				SpecURL:       specURL,
 				OutputDir:     absOut,
 				Owner:         apiSpec.Owner,
+				RunID:         runID,
 				Spec:          apiSpec,
 				NovelFeatures: novelFeatures,
 			}); err != nil {
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 264a49f3..dc20f28a 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -9,6 +9,7 @@ import (
 	"os"
 	"path/filepath"
 	"reflect"
+	"regexp"
 	"sort"
 	"strings"
 	"time"
@@ -330,10 +331,32 @@ type GenerateManifestParams struct {
 	DocsURL       string   // --docs URL, if used
 	OutputDir     string
 	Owner         string                 // resolved owner attribution (manifest preserve > copyright parse > git config)
+	RunID         string                 // YYYYMMDD-HHMMSS, derived from --research-dir basename when empty
 	Spec          *spec.APISpec          // parsed spec for MCP metadata (nil if unavailable)
 	NovelFeatures []NovelFeatureManifest // transcendence features from research (nil if unavailable)
 }
 
+// runIDPattern matches the canonical pipeline run_id shape: YYYYMMDD-HHMMSS.
+// When an arbitrary path basename happens to match this pattern, treat it as
+// a real run_id; otherwise fall back to empty (and warn at the call site).
+var runIDPattern = regexp.MustCompile(`^\d{8}-\d{6}$`)
+
+// DeriveRunIDFromResearchDir extracts a canonical run_id from a research-dir
+// path, or returns "" when no valid run_id can be derived. The standalone
+// generate command does not load a PipelineState, so it cannot reach
+// state.RunID directly; the basename of --research-dir is the only structured
+// signal available without a state-loading refactor.
+func DeriveRunIDFromResearchDir(researchDir string) string {
+	if researchDir == "" {
+		return ""
+	}
+	base := filepath.Base(researchDir)
+	if runIDPattern.MatchString(base) {
+		return base
+	}
+	return ""
+}
+
 // WriteManifestForGenerate writes a .printing-press.json manifest into the
 // generated CLI directory. This is the generate-command counterpart of
 // writeCLIManifestForPublish (which operates on PipelineState).
@@ -344,6 +367,7 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 		PrintingPressVersion: version.Version,
 		APIName:              p.APIName,
 		CLIName:              naming.CLI(p.APIName),
+		RunID:                p.RunID,
 		Owner:                p.Owner,
 	}
 
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index 199b68d2..60f09fa3 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -474,6 +474,61 @@ func TestWriteManifestForGenerateNoSpec(t *testing.T) {
 	assert.Empty(t, got.SpecChecksum)
 }
 
+func TestWriteManifestForGenerateStampsRunID(t *testing.T) {
+	dir := t.TempDir()
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "runid-test",
+		RunID:     "20260504-190931",
+		OutputDir: dir,
+	})
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+	assert.Equal(t, "20260504-190931", got.RunID)
+}
+
+func TestWriteManifestForGenerateOmitsEmptyRunID(t *testing.T) {
+	dir := t.TempDir()
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "norunid-test",
+		OutputDir: dir,
+	})
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	require.NoError(t, err)
+	// run_id has the omitempty tag; empty value must not appear in serialized JSON.
+	assert.NotContains(t, string(data), `"run_id"`)
+}
+
+func TestDeriveRunIDFromResearchDir(t *testing.T) {
+	cases := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{"canonical run_id basename", "/tmp/runs/20260504-190931", "20260504-190931"},
+		{"trailing slash", "/tmp/runs/20260504-190931/", "20260504-190931"},
+		{"basename only", "20260101-000000", "20260101-000000"},
+		{"empty input", "", ""},
+		{"non-matching basename", "/tmp/runs/research", ""},
+		{"partial match (date only)", "/tmp/runs/20260504", ""},
+		{"longer suffix", "/tmp/runs/20260504-190931-x", ""},
+		{"wrong shape (T separator)", "/tmp/runs/20260504T190931", ""},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.expected, DeriveRunIDFromResearchDir(tc.input))
+		})
+	}
+}
+
 func TestArchiveRunArtifactsCopiesDiscovery(t *testing.T) {
 	home := setPressTestEnv(t)
 
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index e3053976..d484faff 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -406,12 +406,15 @@ Maintain a lightweight state file at `$STATE_FILE` so `/printing-press-score` ca
 ```json
 {
   "api_name": "<api>",
+  "run_id": "$RUN_ID",
   "working_dir": "$CLI_WORK_DIR",
   "output_dir": "$CLI_WORK_DIR",
   "spec_path": "<absolute spec path if known>"
 }
 ```
 
+`run_id` is the same `YYYYMMDD-HHMMSS` value computed earlier as `RUN_ID="$(date +%Y%m%d-%H%M%S)"`. The generator's manifest writer derives the same value from the `--research-dir` basename when generate is invoked through the canonical `$API_RUN_DIR` (whose basename equals `$RUN_ID`); persisting it in `state.json` here keeps `/printing-press-score` and any future state-loading consumer in sync. Without `run_id` in either path, `printing-press dogfood --live --write-acceptance` refuses to write the gate marker.
+
 Do not create a `go.work` file in `$CLI_WORK_DIR`. Generated modules must build and test as standalone modules; a mismatched workspace `go` directive can break Go 1.25+ toolchains and lefthook checks. Editor/gopls workspace noise is cosmetic and must not be traded for broken `go build` or `go test`.
 
 There are exactly three durable writable locations. Every generated artifact this
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt b/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt
index 7bcea578..29ff4136 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/stderr.txt
@@ -1 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
 Generated printing-press-oauth2 at <ARTIFACT_DIR>/generate-golden-api-oauth2-cc/printing-press-oauth2-cc
diff --git a/testdata/golden/expected/generate-golden-api-unicode/stderr.txt b/testdata/golden/expected/generate-golden-api-unicode/stderr.txt
index 45c603f2..fd2178ca 100644
--- a/testdata/golden/expected/generate-golden-api-unicode/stderr.txt
+++ b/testdata/golden/expected/generate-golden-api-unicode/stderr.txt
@@ -1 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
 Generated cafe-bistro at <ARTIFACT_DIR>/generate-golden-api-unicode/cafe-bistro
diff --git a/testdata/golden/expected/generate-golden-api/stderr.txt b/testdata/golden/expected/generate-golden-api/stderr.txt
index 5c2fa18c..60a9fb00 100644
--- a/testdata/golden/expected/generate-golden-api/stderr.txt
+++ b/testdata/golden/expected/generate-golden-api/stderr.txt
@@ -1 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
 Generated printing-press-golden at <ARTIFACT_DIR>/generate-golden-api/printing-press-golden
diff --git a/testdata/golden/expected/generate-tier-routing-api/stderr.txt b/testdata/golden/expected/generate-tier-routing-api/stderr.txt
index 1d686931..0b18592d 100644
--- a/testdata/golden/expected/generate-tier-routing-api/stderr.txt
+++ b/testdata/golden/expected/generate-tier-routing-api/stderr.txt
@@ -1 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
 Generated tier-routing-golden at <ARTIFACT_DIR>/generate-tier-routing-api/tier-routing-golden

← 7359a18f fix(cli): align phase5 quick gate with runner verdict (#605)  ·  back to Cli Printing Press  ·  fix(cli): route sync --json events to stdout (#608) 6281f2be →