@friedbotstudio/create-baseline 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  // --abbrev-ref HEAD`. Detached HEAD ("HEAD") → DENY explicitly.
11
11
  // - On a branch matched by project.json → git.protected_branches
12
12
  // (or when that key is null/absent → every branch protected),
13
- // commits require fresh commit_consent (300s) and pushes require
13
+ // commits require fresh commit_consent (900s) and pushes require
14
14
  // fresh push_consent (300s).
15
15
  // - When git.branch_pattern is set and the current branch does NOT
16
16
  // match the regex, commits are denied with the pattern surfaced.
@@ -179,7 +179,7 @@ function handleBash(cmd) {
179
179
 
180
180
  // Protected — require the matching consent token.
181
181
  if (isCommit) {
182
- validateConsentToken(`${STATE_DIR}/commit_consent`, '.consent.commit_ttl_seconds', 300, 'Git Commit Guard', '/grant-commit');
182
+ validateConsentToken(`${STATE_DIR}/commit_consent`, '.consent.commit_ttl_seconds', 900, 'Git Commit Guard', '/grant-commit');
183
183
  } else {
184
184
  validateConsentToken(`${STATE_DIR}/push_consent`, '.consent.push_ttl_seconds', 300, 'Git Commit Guard', '/grant-push');
185
185
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "manifest_version": 3,
3
- "generated_at": "2026-05-22T13:30:10.925Z",
3
+ "generated_at": "2026-05-22T21:02:04.729Z",
4
4
  "files": {
5
5
  ".claude/agents/swarm-worker.md": {
6
6
  "sha256": "1735a220f268c9765cb22e0567b728803f2edd7776cbde51dd017a9f062ae41f",
@@ -55,7 +55,7 @@
55
55
  "tier": "MECHANICAL"
56
56
  },
57
57
  ".claude/hooks/git_commit_guard.mjs": {
58
- "sha256": "95449912110be43635c8130f06ff87d851fc0ebec89fda17b3290671710dfbf6",
58
+ "sha256": "dbea846f2e11a68054a902ee37ac2f11383fdfc9abff33fa2bdec0f4c373f610",
59
59
  "tier": "MECHANICAL"
60
60
  },
61
61
  ".claude/hooks/harness_continuation.sh": {
@@ -168,11 +168,11 @@
168
168
  },
169
169
  ".claude/memory/_pending.md": {
170
170
  "sha256": "ae6407956aadd998c17ad5ad7150dbb21ccf49cc3375efe25b2d2ffb61c0a92a",
171
- "tier": "MECHANICAL"
171
+ "tier": "NEVER_TOUCH"
172
172
  },
173
173
  ".claude/memory/_resume.md": {
174
174
  "sha256": "7ef4da663edc2ea35d19167f776e94b3f925072e9d8388841c42c5c6b81fc2f5",
175
- "tier": "MECHANICAL"
175
+ "tier": "NEVER_TOUCH"
176
176
  },
177
177
  ".claude/memory/backlog.md": {
178
178
  "sha256": "4e124b7ecee5aa0918fd6d9a3bb232a68c7feff6fcf916b8562ed6513715db71",
@@ -203,7 +203,7 @@
203
203
  "tier": "MECHANICAL"
204
204
  },
205
205
  ".claude/project.json": {
206
- "sha256": "a81f9c1341a15831fe8dd558e376dfe38d5fd938aac6291a4c59529637a8d2c7",
206
+ "sha256": "752fe71917e2d0c38e756427af8ca0a01efe096cc633dced2f870edfc878c495",
207
207
  "tier": "NEVER_TOUCH"
208
208
  },
209
209
  ".claude/schemas/workflow-track.v1.json": {
@@ -227,7 +227,7 @@
227
227
  "tier": "MECHANICAL"
228
228
  },
229
229
  ".claude/skills/audit-baseline/audit.sh": {
230
- "sha256": "6d799ea8b567ecf91757fa142f72795b1da0b4d8443e54b4d233dad5b98b5dab",
230
+ "sha256": "77c0f083f108cba4b5d3b049936323e189ebe5f434f0559888647ed3df97995f",
231
231
  "tier": "MECHANICAL"
232
232
  },
233
233
  ".claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md": {
@@ -267,7 +267,7 @@
267
267
  "tier": "MECHANICAL"
268
268
  },
269
269
  ".claude/skills/changelog/changelog.mjs": {
270
- "sha256": "662ae8142ea55c70375e45d874f97b40bcb85b0d6e5378393be56aba1b254eee",
270
+ "sha256": "a00e9aae5d660424588a37774d13d44625012506e128cf51bb9198b9c9c518dd",
271
271
  "tier": "MECHANICAL"
272
272
  },
273
273
  ".claude/skills/changelog/classifier.mjs": {
@@ -279,7 +279,7 @@
279
279
  "tier": "MECHANICAL"
280
280
  },
281
281
  ".claude/skills/changelog/tests/consent-expired_test.sh": {
282
- "sha256": "60351d32cb544af33a6929f898411e43d20ec258f620ebc08179754a74811cf1",
282
+ "sha256": "da950772c5f6938c81b7c2667d1807b0da838e1f8ea00d64970d2251692b7b87",
283
283
  "tier": "MECHANICAL"
284
284
  },
285
285
  ".claude/skills/changelog/tests/golden-path_test.sh": {
@@ -823,7 +823,7 @@
823
823
  "tier": "MECHANICAL"
824
824
  },
825
825
  ".claude/skills/upgrade-project/SKILL.md": {
826
- "sha256": "a8d31370b626bb2c5fb733cf3b1e210f66ade1de84007ad0b3a46e31d115687a",
826
+ "sha256": "f444667925baa4e4f054c7e23dfea5a0578077192cd3b7bc0838627e95387293",
827
827
  "tier": "MECHANICAL"
828
828
  },
829
829
  ".claude/skills/verify/SKILL.md": {
@@ -843,7 +843,7 @@
843
843
  "tier": "SEMANTIC"
844
844
  },
845
845
  "docs/init/seed.md": {
846
- "sha256": "7c64057392b39289edf123e3395b67b8605c28b9ab35c102c9469f6974047c78",
846
+ "sha256": "d1c33da767f4311d1f1c5efc98157b5c09a0b80331d7992f0ca66ec48a6ee08d",
847
847
  "tier": "SEMANTIC"
848
848
  }
849
849
  },
@@ -889,5 +889,5 @@
889
889
  "verify": "baseline"
890
890
  }
891
891
  },
892
- "build_id": "gha-26290648827"
892
+ "build_id": "gha-26311755762"
893
893
  }
@@ -184,7 +184,7 @@
184
184
  }
185
185
  },
186
186
  "consent": {
187
- "commit_ttl_seconds": 300,
187
+ "commit_ttl_seconds": 900,
188
188
  "gate_marker_ttl_seconds": 120,
189
189
  "push_ttl_seconds": 300
190
190
  },
@@ -224,13 +224,17 @@ check_names("agents names match seed §4.2", EXPECTED_AGENTS, add_agents,
224
224
  # Skills canonical set comes from manifest.owners.skills (built by
225
225
  # scripts/build-manifest.mjs at release time). Falls back to disk_baseline_skills
226
226
  # when the manifest is missing (e.g., first audit before initial build).
227
+ # Unlike hooks/agents/commands, the skills `disk_*` is `disk_baseline_skills`
228
+ # (filtered to `owner: baseline`), so `add_skills` (project additions, which are
229
+ # user/third-party by design) cannot be passed in here — they'd false-FAIL.
230
+ # Per CLAUDE.md Article XI #5.
227
231
  _manifest_for_skills = load_manifest()
228
232
  if _manifest_for_skills is None:
229
233
  _canonical_skills = disk_baseline_skills
230
234
  else:
231
235
  _canonical_skills = set((_manifest_for_skills.get("owners") or {}).get("skills", {}).keys()) \
232
236
  or disk_baseline_skills
233
- check_names("skills names match seed §4.3", _canonical_skills, add_skills, disk_baseline_skills)
237
+ check_names("skills names match seed §4.3", _canonical_skills, set(), disk_baseline_skills)
234
238
  check_names("commands names match seed §4.4", EXPECTED_COMMANDS, set(), disk_commands)
235
239
 
236
240
  # ---------- skill ownership (per-file hash drift + frontmatter validation) ----------
@@ -349,11 +353,23 @@ else:
349
353
  # The src/ tree mirrors the canonical paths with a `.template` suffix so
350
354
  # `npx @friedbotstudio/create-baseline` can discover and overlay deterministically.
351
355
  src_dir = root / "src"
356
+ # Consumer-mode detection: the CLI overlay ships .claude/manifest.json into
357
+ # every consumer project; src/ exists only in the baseline-dev repo as the
358
+ # overlay source. Manifest present + src/ absent ⇒ legitimate consumer install
359
+ # ⇒ skip the src/ checks instead of false-FAIL'ing. Per CLAUDE.md Appendix A.
360
+ consumer_manifest = (root / ".claude" / "manifest.json").is_file()
352
361
  if not src_dir.is_dir():
353
- add("src templates: directory", "FAIL", "missing src/")
362
+ if consumer_manifest:
363
+ add("src templates: directory", "PASS",
364
+ "consumer install (manifest present, src/ absent) — src/ checks skipped")
365
+ else:
366
+ add("src templates: directory", "FAIL", "missing src/")
367
+ _SKIP_SRC = True
354
368
  else:
355
369
  add("src templates: directory", "PASS", "")
370
+ _SKIP_SRC = False
356
371
 
372
+ if not _SKIP_SRC:
357
373
  # CLAUDE.template.md — must exist and read as constitution-voice (or, in
358
374
  # the pre-Stage-2 transitional shape, at least the user-voice lede). The
359
375
  # test below tolerates either: dogfood-leak fails hard; constitutional
@@ -630,12 +646,13 @@ else:
630
646
  add("CLAUDE.md: Article X.2 present", "FAIL",
631
647
  "missing `### X.2 Design-task routing` heading — Article X.2 is the structural seam between design-ui and impeccable")
632
648
 
633
- template_claude = read_text("src/CLAUDE.template.md")
634
- if "### X.2 Design-task routing" in template_claude:
635
- add("src/CLAUDE.template.md: Article X.2 mirrors", "PASS", "")
636
- else:
637
- add("src/CLAUDE.template.md: Article X.2 mirrors", "FAIL",
638
- "src template does not contain Article X.2 template-drift will fail")
649
+ if not _SKIP_SRC:
650
+ template_claude = read_text("src/CLAUDE.template.md")
651
+ if "### X.2 Design-task routing" in template_claude:
652
+ add("src/CLAUDE.template.md: Article X.2 mirrors", "PASS", "")
653
+ else:
654
+ add("src/CLAUDE.template.md: Article X.2 mirrors", "FAIL",
655
+ "src template does not contain Article X.2 — template-drift will fail")
639
656
 
640
657
  design_ui_skill = read_text(".claude/skills/design-ui/SKILL.md")
641
658
  if re.search(r'^description:.*orchestrat', design_ui_skill, re.MULTILINE | re.IGNORECASE):
@@ -764,7 +781,13 @@ docs_to_check = [
764
781
  for doc in docs_to_check:
765
782
  text = read_text(doc)
766
783
  if not text:
767
- add(f"{doc} count claims", "WARN", "file not present")
784
+ # README.md is not shipped to consumer projects by the create-baseline
785
+ # CLI (only CLAUDE.md + docs/init/seed.md land at the consumer root).
786
+ # Its absence is the consumer-install case, not drift — silently skip.
787
+ # CLAUDE.md and seed.md are required baseline shipfiles; missing means
788
+ # real drift, so keep the WARN.
789
+ if doc != "README.md":
790
+ add(f"{doc} count claims", "WARN", "file not present")
768
791
  continue
769
792
 
770
793
  headline_drift = [] # confirmed stale headline claims
@@ -20,7 +20,7 @@ import { previewProjectedVersion } from './version-preview.mjs';
20
20
  import { writeState } from './state-writer.mjs';
21
21
  import { appendUnderUnreleased } from './unreleased-writer.mjs';
22
22
 
23
- const TTL_SECONDS = 300;
23
+ const TTL_SECONDS = 900;
24
24
 
25
25
  function parseCli() {
26
26
  const { values } = parseArgs({
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Fixture-based integration test for AC-010: when commit_consent token is
3
- # stale (older than consent.commit_ttl_seconds, default 300s), the changelog
3
+ # stale (older than consent.commit_ttl_seconds, default 900s), the changelog
4
4
  # skill exits non-zero with "consent expired" stderr, does NOT modify
5
5
  # CHANGELOG.md, and does NOT write the state file.
6
6
  #
@@ -16,7 +16,7 @@ PASS=0; FAIL=0; FAILED=()
16
16
 
17
17
  fail() { echo " FAIL: $*"; return 1; }
18
18
 
19
- # Seed a tempdir with a stale commit_consent (epoch = now - 310s).
19
+ # Seed a tempdir with a stale commit_consent (epoch = now - 910s).
20
20
  seed_stale_consent_project() {
21
21
  local proj="$1" slug="$2"
22
22
  mkdir -p "$proj/.claude/state"
@@ -29,8 +29,8 @@ seed_stale_consent_project() {
29
29
  echo "stale test" > thing.txt
30
30
  git add thing.txt
31
31
  git commit -q -m "feat: stale consent path"
32
- # Stale token: epoch in the past, beyond 300s default TTL.
33
- local stale_epoch; stale_epoch=$(( $(date +%s) - 310 ))
32
+ # Stale token: epoch in the past, beyond 900s default TTL.
33
+ local stale_epoch; stale_epoch=$(( $(date +%s) - 910 ))
34
34
  echo "$stale_epoch" > "$proj/.claude/state/commit_consent"
35
35
  echo "stale" >> "$proj/.claude/state/commit_consent"
36
36
  cat > "$proj/.claude/state/workflow.json" <<EOF
@@ -65,7 +65,20 @@ For each stage directory under `.claude/state/upgrade/`:
65
65
  - The **zero-drift renumbering rule does NOT apply** to two-way reconciliation — there is no BASE anchor to shift against, so "shift, never fold" cannot be evaluated. When LOCAL and INCOMING both add structural entries at the same anchor and you cannot determine which is user content vs baseline content without the BASE, apply the `NEEDS_USER_INPUT` fallback.
66
66
  - Write the reconciled bytes to the LOCAL path.
67
67
  - Update the stage manifest entry's `status` to `RECONCILED`.
68
- 5. **Finalize the stage.** When every entry's status is `RECONCILED`, delete the stage directory (`rm -rf .claude/state/upgrade/<ts>/`). Report per-file status to the user.
68
+ 5. **Record the reconciliation marker.** For every entry whose status just transitioned to `RECONCILED` (NOT `NEEDS_USER_INPUT`, NOT skipped under `--dry-run`), invoke the marker writer so the next `create-baseline upgrade` knows the user has already reviewed this file against the current template hash. From the skill terminal:
69
+
70
+ ```
71
+ node -e "import('./src/cli/reconciliation-marker.js').then(m => m.recordReconciliation('<target>', '<rel>', '<baseline_version_to>', '<incoming_sha256>'))"
72
+ ```
73
+
74
+ - `<target>` is the project root the skill is operating in (usually `.`).
75
+ - `<rel>` is the entry's `rel` field from the stage manifest.
76
+ - `<baseline_version_to>` is the stage manifest's top-level field of the same name.
77
+ - `<incoming_sha256>` is the entry's `incoming_sha256` field (the template hash this reconciliation was reviewed against).
78
+
79
+ The writer creates / updates `<target>/.claude/.baseline-reconciliations.json` atomically (write-then-rename). On filesystem error it throws `MarkerWriteError` — surface the error to the user but do NOT roll back the reconciled LOCAL bytes (LOCAL is already on disk and is the user-visible outcome). Marker is best-effort: the user can re-run `/upgrade-project` to re-record if the write was lost. See `docs/specs/upgrade-no-replay-prompts.md §Behavior #4` for the contract.
80
+
81
+ 6. **Finalize the stage.** When every entry's status is `RECONCILED`, delete the stage directory (`rm -rf .claude/state/upgrade/<ts>/`). Report per-file status to the user.
69
82
 
70
83
  ## The zero-drift renumbering rule (binding for three-way only)
71
84
 
@@ -93,6 +106,7 @@ When invoked with `args=dry-run` (e.g., `/upgrade-project dry-run`):
93
106
  - DO NOT modify any LOCAL file.
94
107
  - DO NOT update the stage manifest (statuses stay PENDING / NEEDS_USER_INPUT).
95
108
  - DO NOT delete the stage directory.
109
+ - DO NOT call `recordReconciliation` — the marker would record a reconciliation the user never actually applied, causing the next upgrade to silently skip a file that still has unreviewed upstream changes. Dry-run is for preview only; the marker write happens exclusively on the real apply path.
96
110
  - Tell the user: "Dry-run complete. Re-run without `dry-run` to apply."
97
111
 
98
112
  Dry-run mode is for building trust in early use. After the first few successful reconciliations, the user typically stops dry-running.
@@ -111,7 +125,7 @@ Use this fallback sparingly. The rework's whole point is that LLM judgment excee
111
125
  ## Constraints
112
126
 
113
127
  - **Validate `rel` before writing.** Before writing reconciled bytes to LOCAL, you SHALL verify that the resolved absolute path of `<target>/<rel>` is a descendant of `target`. A `rel` value that escapes the target tree (`../`, absolute path, symlink-resolved escape) SHALL be rejected as a `NEEDS_USER_INPUT` fallback with the reason `path-traversal-rejected`. The CLI's stage writer never produces escaping `rel` values, so this catches only tampered stage manifests from a local attacker with `.claude/state/` write access — defense in depth.
114
- - **No write outside the stage directory and the LOCAL path.** You SHALL NOT touch `.claude/.baseline-prior/`, the installed `.baseline-manifest.json`, or any other CLI state.
128
+ - **No write outside the stage directory, the LOCAL path, and the reconciliation marker.** You SHALL NOT touch `.claude/.baseline-prior/`, the installed `.baseline-manifest.json`, or any other CLI state. The single narrow exception is `.claude/.baseline-reconciliations.json`, written via the `recordReconciliation` foundation module per Procedure step 5 (post-RECONCILED, not in `--dry-run`). The marker write goes through that module's atomic write-then-rename so partial writes cannot corrupt the file.
115
129
  - **No partial writes per file.** The reconciled LOCAL must be the complete final content. If you cannot produce a complete reconciliation, use the NEEDS_USER_INPUT fallback and leave LOCAL unmodified.
116
130
  - **Honor Article XI of CLAUDE.md.** This skill only touches files explicitly staged by the CLI — which, by construction, are baseline-owned. User-added files at colliding paths are never staged.
117
131
  - **No commits.** Reconciled files land on the working tree; the user inspects via `git diff` and commits when satisfied.
@@ -531,7 +531,7 @@ Seed-level requirement: no stale workflow artifacts in the working tree after co
531
531
  - `artifacts.required_sections.{intake,brd,spec,rca}` — the canonical section lists.
532
532
  - `artifacts.required_diagrams.spec` — the six kinds (§4.7).
533
533
  - `swarm.max_parallel`, `swarm.isolation: "auto"`, `swarm.min_tasks_worth_swarming: 3`, `swarm.refuse_dirty_tree: true`, `swarm.exempt_path_prefixes`, `swarm.enforced_path_prefixes`.
534
- - `consent.commit_ttl_seconds: 300`.
534
+ - `consent.commit_ttl_seconds: 900`.
535
535
  - `additions.{agents,skills,hooks,mcp_servers,swarm_worker_skills}` — names of every project-adopted addition the recommender emitted (just identifiers, no `command`/`why`/`tokens` payload). `additions.agents` stays empty in this baseline — the recommender does not propose new subagent types. `additions.swarm_worker_skills` lists stack-specific skills the `swarm-worker` template should preload via the `{{SKILLS}}` token at re-render time. `audit.sh` reads this manifest and unions each set with the baseline `EXPECTED_*` sets when checking names; counts are reframed as `"<total> = <baseline> + <project>"` so legitimate additions don't fail drift detection. Default state is five empty arrays.
536
536
  - Flip `configured: true`.
537
537
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friedbotstudio/create-baseline",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Node CLI scaffolder that materializes the Claude Code baseline (hooks, skills, commands, MCP servers, governance docs) into a target project, with branded interactive install / upgrade / doctor flows. Run via `npx @friedbotstudio/create-baseline <target>`.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/doctor.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, readdir } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, relative, sep } from 'node:path';
4
4
  import { hashFile, loadManifest } from './manifest.js';
5
+ import { MARKER_PATH_REL } from './reconciliation-marker.js';
5
6
  import { pathExists } from './util.js';
6
7
 
7
8
  const MANIFEST_REL = '.claude/.baseline-manifest.json';
@@ -84,12 +85,15 @@ export async function runDoctor(target, options = {}) {
84
85
  }
85
86
 
86
87
  // ADDED — files under .claude/ that aren't in the manifest. Excludes the
87
- // manifest itself (it's written by the CLI post-install and is not self-referential).
88
+ // manifest itself (it's written by the CLI post-install and is not self-referential)
89
+ // and the reconciliation marker (per-target user state written by
90
+ // /upgrade-project; see docs/specs/upgrade-no-replay-prompts.md §Behavior #6).
88
91
  const added = [];
89
92
  const onDisk = await listFilesUnder(join(target, ADDED_SCAN_PREFIX));
90
93
  for (const rel of onDisk) {
91
94
  const full = `${ADDED_SCAN_PREFIX}/${rel}`;
92
95
  if (full === MANIFEST_REL) continue;
96
+ if (full === MARKER_PATH_REL) continue;
93
97
  if (!(full in manifest.files)) added.push(full);
94
98
  }
95
99
 
@@ -14,6 +14,13 @@ export const NEVER_TOUCH = Object.freeze([
14
14
  '.claude/project.json',
15
15
  '.claude/workflows.jsonl',
16
16
  '.claude/schemas/workflow-track.v1.json',
17
+ // Runtime-state files: bodies are gitignored and overwritten every
18
+ // conversation turn by memory_stop.sh / memory_pre_compact.sh / /memory-flush.
19
+ // Their on-disk hash will essentially never match the shipped template hash,
20
+ // so any merge-time prompt is a structural false positive. Preserve silently.
21
+ // See docs/specs/upgrade-no-replay-prompts.md §Behavior #1.
22
+ '.claude/memory/_pending.md',
23
+ '.claude/memory/_resume.md',
17
24
  ]);
18
25
  export const SPECIAL_MERGE = Object.freeze(['.mcp.json']);
19
26
  // The shipped manifest now lives at `.claude/manifest.json` (inside the
package/src/cli/merge.js CHANGED
@@ -5,11 +5,13 @@ import { deepMergeMcpServers } from './mcp.js';
5
5
  import { NEVER_TOUCH, SPECIAL_MERGE } from './install.js';
6
6
  import { pathExists } from './util.js';
7
7
  import { dispatchByTier, NoBaseError, canRecoverBase, writeStageBaseless } from './upgrade-tiers.js';
8
+ import { readMarker, matchesReconciledHash } from './reconciliation-marker.js';
8
9
 
9
10
  export const ACTION_KINDS = Object.freeze({
10
11
  ADD: 'ADD',
11
12
  OVERWRITE: 'OVERWRITE',
12
13
  NOOP: 'NOOP',
14
+ MARKER_MATCHED: 'MARKER_MATCHED',
13
15
  SKIP_CUSTOMIZED: 'SKIP_CUSTOMIZED',
14
16
  PRUNE: 'PRUNE',
15
17
  PRUNE_SKIPPED_CUSTOMIZED: 'PRUNE_SKIPPED_CUSTOMIZED',
@@ -28,6 +30,7 @@ export const ACTION_LABELS = Object.freeze({
28
30
  ADD: 'add',
29
31
  OVERWRITE: 'update',
30
32
  NOOP: 'unchanged',
33
+ MARKER_MATCHED: 'already reconciled',
31
34
  SKIP_CUSTOMIZED: 'kept yours',
32
35
  PRUNE: 'removed (upstream)',
33
36
  PRUNE_SKIPPED_CUSTOMIZED: 'kept yours (upstream removed)',
@@ -69,6 +72,7 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
69
72
  const newFiles = newManifest?.files ?? {};
70
73
  const baseline_version = oldManifest?.baseline_version;
71
74
  const allPaths = new Set([...Object.keys(oldFiles), ...Object.keys(newFiles)]);
75
+ const marker = await readMarker(target);
72
76
 
73
77
  const tierCtx = {
74
78
  target,
@@ -127,6 +131,14 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
127
131
  }
128
132
 
129
133
  if (newHash && tgtHash && tgtHash !== oldHash) {
134
+ if (matchesReconciledHash(marker, rel, newHash)) {
135
+ actions.push({
136
+ kind: ACTION_KINDS.MARKER_MATCHED,
137
+ path: rel,
138
+ reason: 'marker records reconciliation against this template hash; no re-stage',
139
+ });
140
+ continue;
141
+ }
130
142
  const action = await dispatchCustomized({
131
143
  rel, newEntry, tierCtx, dryRun, onSkipCustomized, tplPath, tgtPath,
132
144
  });
@@ -0,0 +1,108 @@
1
+ // Foundation — per-target reconciliation marker for create-baseline upgrade.
2
+ //
3
+ // Records which template hash each customized file was reconciled against by
4
+ // `/upgrade-project`, so subsequent `create-baseline upgrade` runs can skip
5
+ // files the user has already reviewed. The marker lives at
6
+ // <target>/.claude/.baseline-reconciliations.json (gitignore-by-default; see
7
+ // docs/specs/upgrade-no-replay-prompts.md non-goal on consumer .gitignore).
8
+ //
9
+ // Consumed by: src/cli/merge.js (marker-consult branch in threeWayMerge).
10
+ // Written by: /upgrade-project skill via the Bash interop the skill describes
11
+ // in its Procedure section (post-RECONCILED step).
12
+ // Doctor: src/cli/doctor.js excludes this path from its `added` scan.
13
+
14
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ const MARKER_REL = '.claude/.baseline-reconciliations.json';
19
+ const SCHEMA_VERSION = 1;
20
+
21
+ export class MarkerWriteError extends Error {
22
+ constructor(message, opts = {}) {
23
+ super(message);
24
+ this.name = 'MarkerWriteError';
25
+ if (opts.cause) this.cause = opts.cause;
26
+ }
27
+ }
28
+
29
+ export async function readMarker(target) {
30
+ const path = join(target, MARKER_REL);
31
+ let text;
32
+ try {
33
+ text = await readFile(path, 'utf8');
34
+ } catch (err) {
35
+ if (err.code === 'ENOENT') return null;
36
+ process.stderr.write(`reconciliation-marker: cannot read ${MARKER_REL}: ${err.message}\n`);
37
+ return null;
38
+ }
39
+ return parseMarker(text);
40
+ }
41
+
42
+ export async function recordReconciliation(target, rel, baseline_version, template_sha) {
43
+ const path = join(target, MARKER_REL);
44
+ const existing = (await readMarker(target)) ?? newMarker();
45
+ existing.reconciliations[rel] = {
46
+ baseline_version,
47
+ reconciled_against_template_sha: template_sha,
48
+ reconciled_at: new Date().toISOString(),
49
+ };
50
+ await atomicWriteJson(path, existing);
51
+ }
52
+
53
+ export function matchesReconciledHash(marker, rel, template_sha) {
54
+ if (!marker || !marker.reconciliations) return false;
55
+ const entry = marker.reconciliations[rel];
56
+ if (!entry) return false;
57
+ return entry.reconciled_against_template_sha === template_sha;
58
+ }
59
+
60
+ export const MARKER_PATH_REL = MARKER_REL;
61
+
62
+ function newMarker() {
63
+ return { schema_version: SCHEMA_VERSION, reconciliations: {} };
64
+ }
65
+
66
+ function parseMarker(text) {
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(text);
70
+ } catch (err) {
71
+ process.stderr.write(
72
+ `reconciliation-marker: malformed ${MARKER_REL} (invalid JSON): ${err.message}\n` +
73
+ ` To reset, delete the file: rm ${MARKER_REL}\n`,
74
+ );
75
+ return null;
76
+ }
77
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.reconciliations !== 'object') {
78
+ process.stderr.write(
79
+ `reconciliation-marker: malformed ${MARKER_REL} (missing reconciliations object)\n` +
80
+ ` To reset, delete the file: rm ${MARKER_REL}\n`,
81
+ );
82
+ return null;
83
+ }
84
+ if (parsed.schema_version !== SCHEMA_VERSION) {
85
+ process.stderr.write(
86
+ `reconciliation-marker: unsupported schema_version=${parsed.schema_version} in ${MARKER_REL} ` +
87
+ `(this CLI understands schema_version=${SCHEMA_VERSION}); ignoring marker.\n` +
88
+ ` Either upgrade create-baseline, or delete the file: rm ${MARKER_REL}\n`,
89
+ );
90
+ return null;
91
+ }
92
+ return parsed;
93
+ }
94
+
95
+ async function atomicWriteJson(path, obj) {
96
+ const tmp = `${path}.${randomUUID()}.tmp`;
97
+ const body = JSON.stringify(obj, null, 2) + '\n';
98
+ try {
99
+ await mkdir(dirname(path), { recursive: true });
100
+ await writeFile(tmp, body);
101
+ await rename(tmp, path);
102
+ } catch (err) {
103
+ throw new MarkerWriteError(
104
+ `cannot write ${MARKER_REL}: ${err.message}`,
105
+ { cause: err },
106
+ );
107
+ }
108
+ }
@@ -4,8 +4,9 @@
4
4
  // formatReport — this renderer is only invoked when stdout is a TTY.
5
5
 
6
6
  import { accent, muted, success, warn, error, accentLight } from './tokens.js';
7
+ import { renderHeader } from './splash.js';
7
8
 
8
- function brandHeader(target, manifestInfo) {
9
+ function targetAndManifestLines(target, manifestInfo) {
9
10
  const lines = [accent('Baseline doctor')];
10
11
  if (target) lines.push(muted(`target: ${target}`));
11
12
  if (manifestInfo) lines.push(muted(`manifest: ${manifestInfo}`));
@@ -13,13 +14,14 @@ function brandHeader(target, manifestInfo) {
13
14
  }
14
15
 
15
16
  export function render(report) {
17
+ process.stdout.write(renderHeader({ subtitle: 'doctor' }));
16
18
  if (report.error) {
17
- const headerLines = brandHeader(report.target);
19
+ const headerLines = targetAndManifestLines(report.target);
18
20
  process.stdout.write(headerLines.join('\n') + '\n\n');
19
21
  process.stdout.write(`${error('doctor:')} ${report.error}\n`);
20
22
  return;
21
23
  }
22
- const lines = brandHeader(report.target, `version ${report.manifestVersion}, installed ${report.generatedAt}`);
24
+ const lines = targetAndManifestLines(report.target, `version ${report.manifestVersion}, installed ${report.generatedAt}`);
23
25
  lines.push('');
24
26
  lines.push(` ${success('matched')}: ${report.matched.length}`);
25
27
  lines.push(` ${accentLight('customized')}: ${report.customized.length}`);
@@ -6,7 +6,7 @@ import * as clackModule from '@clack/prompts';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { freshInstall, forceInstall } from '../install.js';
8
8
  import { fetchPlantumlIfMissing, FETCH_OUTCOMES } from '../plantuml.js';
9
- import { renderBrandStrip } from './splash.js';
9
+ import { renderHeader } from './splash.js';
10
10
 
11
11
  const SUCCESS = 0;
12
12
  const ERR_INSTALL_FAILED = 1;
@@ -21,7 +21,7 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
21
21
  }
22
22
 
23
23
  const version = await readPackageVersion();
24
- process.stdout.write(renderBrandStrip({ version, subtitle: 'install' }));
24
+ process.stdout.write(renderHeader({ version, subtitle: 'install' }));
25
25
  prompts.intro('create-baseline');
26
26
 
27
27
  const spinner = prompts.spinner();
@@ -1,12 +1,16 @@
1
1
  // Domain — branded splash surfaces for the CLI. Renders a chunky pixel-art
2
2
  // "BASELINE" wordmark in three bands of FBS orange (bevel: shadow / mid /
3
- // highlight / mid / shadow) so the marquee surfaces (--help, --version,
4
- // no-arg landing) share a single visual identity. Slimmer brand strip is
5
- // reused by install / upgrade intros and inside the usage-error renderer.
3
+ // highlight / mid / shadow) so every command (--help, --version, no-arg
4
+ // landing, install, upgrade, doctor) shares a single visual identity.
5
+ // Install / upgrade / doctor use `renderHeader` (wordmark + tagline);
6
+ // --version uses `renderVersionMarquee`; --help and the no-arg landing use
7
+ // the full `renderSplash`; `renderBrandStrip` is the slim header reserved
8
+ // for the usage-error renderer and as `renderHeader`'s narrow-terminal
9
+ // fallback.
6
10
  //
7
11
  // All renderers degrade cleanly when stdout is not a TTY or NO_COLOR is set
8
12
  // (paintRGB short-circuits to plain text). When the terminal is narrower
9
- // than the wordmark, callers should fall through to the plain banner via
13
+ // than the wordmark, callers fall through to the slim brand strip via
10
14
  // `wordmarkFits(width)` instead of letting the glyphs wrap.
11
15
 
12
16
  import { paintRGB, PALETTE, accent, muted } from './tokens.js';
@@ -95,8 +99,32 @@ export function renderSplash({ tagline, tryLine, discoverUrl } = {}) {
95
99
  return lines.join('\n');
96
100
  }
97
101
 
98
- // Slim two-line brand strip. Used by install / upgrade intros, --version,
99
- // and the top of the usage-error renderer. Cheap and width-safe (~32 cols).
102
+ // Wordmark + tagline header. Used by install / upgrade / doctor command
103
+ // intros so every command shares the same branded surface as the no-arg
104
+ // landing. Width-gated: when the terminal is narrower than the wordmark,
105
+ // falls back to the slim brand strip so the header never wraps into
106
+ // unreadable glyphs.
107
+ export function renderHeader({ subtitle, version, columns } = {}) {
108
+ if (!wordmarkFits(columns)) return renderBrandStrip({ version, subtitle });
109
+ const lines = [
110
+ '',
111
+ `${muted('▲')} ${muted('~/')} ${muted('npx @friedbotstudio/create-baseline@latest')}`,
112
+ '',
113
+ renderWordmark(),
114
+ '',
115
+ muted('The Claude Code baseline — hooks, skills, MCP, governance.'),
116
+ ];
117
+ if (subtitle) {
118
+ lines.push('');
119
+ lines.push(muted(subtitle));
120
+ }
121
+ lines.push('');
122
+ return lines.join('\n');
123
+ }
124
+
125
+ // Slim two-line brand strip. Used by --version and the top of the
126
+ // usage-error renderer (and as renderHeader's narrow-terminal fallback).
127
+ // Cheap and width-safe (~32 cols).
100
128
  export function renderBrandStrip({ version, subtitle } = {}) {
101
129
  const left = `${accent('▲ BASELINE')}`;
102
130
  const right = version ? ` ${muted(`v${version}`)}` : '';
@@ -18,7 +18,7 @@ import { threeWayMerge, ACTION_KINDS, ACTION_LABELS, ACTION_LABEL_WIDTH } from '
18
18
  import { loadManifest, buildManifestFromDir } from '../manifest.js';
19
19
  import { COPY_EXCLUDE } from '../install.js';
20
20
  import { findPendingStage, formatStageTimestamp } from '../upgrade-tiers.js';
21
- import { renderBrandStrip } from './splash.js';
21
+ import { renderHeader } from './splash.js';
22
22
 
23
23
  const SUCCESS = 0;
24
24
  const ERR_ABORT = 1;
@@ -49,7 +49,7 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
49
49
  }
50
50
 
51
51
  const version = await readPackageVersion();
52
- process.stdout.write(renderBrandStrip({ version, subtitle: 'upgrade' }));
52
+ process.stdout.write(renderHeader({ version, subtitle: 'upgrade' }));
53
53
  prompts.intro('create-baseline upgrade');
54
54
 
55
55
  const pending = await findPendingStage(target);
@@ -57,7 +57,7 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
57
57
 
58
58
  const { oldManifest, newManifest } = await loadManifests(opts.templateDir, manifestPath);
59
59
  if (isLegacyManifest(oldManifest)) {
60
- prompts.log.warn("Your previous install predates version-tracked manifests, so this upgrade can't perform automatic three-way merges on customized files. You'll be prompted to keep your version or take the new baseline for each customized file. To enable three-way merges next time, re-install with the latest baseline.");
60
+ prompts.log.warn("Your previous install predates version-tracked manifests, so this upgrade can't perform automatic three-way merges on customized files. You'll be prompted to keep your version or take the new baseline for each customized file. After you finish, run `/upgrade-project` in Claude Code on any staged files — the reconciliations are recorded so future upgrades silently skip files you've already reviewed against the current baseline.");
61
61
  }
62
62
 
63
63
  const dryReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { dryRun: true });
@@ -184,7 +184,7 @@
184
184
  }
185
185
  },
186
186
  "consent": {
187
- "commit_ttl_seconds": 300,
187
+ "commit_ttl_seconds": 900,
188
188
  "gate_marker_ttl_seconds": 120,
189
189
  "push_ttl_seconds": 300
190
190
  },
@@ -531,7 +531,7 @@ Seed-level requirement: no stale workflow artifacts in the working tree after co
531
531
  - `artifacts.required_sections.{intake,brd,spec,rca}` — the canonical section lists.
532
532
  - `artifacts.required_diagrams.spec` — the six kinds (§4.7).
533
533
  - `swarm.max_parallel`, `swarm.isolation: "auto"`, `swarm.min_tasks_worth_swarming: 3`, `swarm.refuse_dirty_tree: true`, `swarm.exempt_path_prefixes`, `swarm.enforced_path_prefixes`.
534
- - `consent.commit_ttl_seconds: 300`.
534
+ - `consent.commit_ttl_seconds: 900`.
535
535
  - `additions.{agents,skills,hooks,mcp_servers,swarm_worker_skills}` — names of every project-adopted addition the recommender emitted (just identifiers, no `command`/`why`/`tokens` payload). `additions.agents` stays empty in this baseline — the recommender does not propose new subagent types. `additions.swarm_worker_skills` lists stack-specific skills the `swarm-worker` template should preload via the `{{SKILLS}}` token at re-render time. `audit.sh` reads this manifest and unions each set with the baseline `EXPECTED_*` sets when checking names; counts are reframed as `"<total> = <baseline> + <project>"` so legitimate additions don't fail drift detection. Default state is five empty arrays.
536
536
  - Flip `configured: true`.
537
537