@friedbotstudio/create-baseline 0.7.0 → 0.8.1

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.
package/README.md CHANGED
@@ -95,7 +95,7 @@ npx @friedbotstudio/create-baseline ./your-project
95
95
  npx @friedbotstudio/create-baseline ./your-project --force
96
96
 
97
97
  # Upgrade an existing install against a newer baseline version.
98
- # In a TTY, each tier-1 customised file becomes a keep-mine / take-theirs / abort
98
+ # In a TTY, each tier-1 customised file prompts: keep-mine / take-theirs / merge / abort
99
99
  # prompt; tier-2 files auto-merge via `git merge-file --diff3`; tier-3 files
100
100
  # stage for the /upgrade-project Claude Code skill to reconcile. In CI / piped
101
101
  # stdout, every per-file action is reported with a user-facing label:
package/bin/cli.js CHANGED
@@ -33,7 +33,9 @@ Upgrade:
33
33
  Replaces the prior --merge flag. Reads <target>/.claude/.baseline-manifest.json
34
34
  and runs a three-tier merge against the shipped template:
35
35
  - tier 1 (binary prompt): customized files prompt "Keep your version / Use
36
- new baseline / Show diff" in TTY mode (exit 3 on any skipped).
36
+ new baseline / Merge / Abort" in TTY mode. "Merge" stages incoming bytes
37
+ under .claude/state/upgrade/<ts>/ for /upgrade-project to reconcile in
38
+ Claude Code (exit 5); "Keep your version" exits 3 on any skipped.
37
39
  - tier 2 (mechanical): files routed through git merge-file --diff3 with
38
40
  BASE recovered from .claude/.baseline-prior/ cache or npm fallback;
39
41
  clean merges land silently, conflicts surface with markers (exit 4).
@@ -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
  }
@@ -189,6 +189,30 @@ if pending_count > 0 and not active_workflow:
189
189
  'run `/memory-flush` to clear before starting new work.'
190
190
  )
191
191
 
192
+ # Pending upgrade stages (tier1-merge-option AC-004 + AC-008). Scans
193
+ # .claude/state/upgrade/*/manifest.json for entries with status: PENDING.
194
+ # Fires regardless of active_workflow (design pick 2C): stages are stable
195
+ # infrastructure debt, distinct from memory-candidate debt above.
196
+ upgrade_pending = 0
197
+ upgrade_root = root / '.claude/state/upgrade'
198
+ if upgrade_root.is_dir():
199
+ for stage_manifest in upgrade_root.glob('*/manifest.json'):
200
+ try:
201
+ with open(stage_manifest) as f:
202
+ stage = json.load(f)
203
+ except Exception:
204
+ continue
205
+ for entry in stage.get('files', []):
206
+ if entry.get('status') == 'PENDING':
207
+ upgrade_pending += 1
208
+
209
+ if upgrade_pending > 0:
210
+ noun = 'file' if upgrade_pending == 1 else 'files'
211
+ lines.append(
212
+ f'**{upgrade_pending} {noun} staged for /upgrade-project to reconcile** — '
213
+ 'run `/upgrade-project` when ready.'
214
+ )
215
+
192
216
  lines.append('')
193
217
  lines.append(
194
218
  'Files are read on demand by the relevant skill (scout reads landmarks, research reads libraries, etc.). '
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "manifest_version": 3,
3
- "generated_at": "2026-05-21T16:49:56.519Z",
3
+ "generated_at": "2026-05-22T15:13:25.707Z",
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": {
@@ -83,7 +83,7 @@
83
83
  "tier": "MECHANICAL"
84
84
  },
85
85
  ".claude/hooks/memory_session_start.sh": {
86
- "sha256": "76625781d6cec1dc0d069926adad65c30010b07b1c0e24a2ac441c2981d2ecb5",
86
+ "sha256": "ec03026919c3cfeb0bdb8e46d8ace19b23d93b5b21f1ebdb4f579e9c52293ac9",
87
87
  "tier": "MECHANICAL"
88
88
  },
89
89
  ".claude/hooks/memory_stop.sh": {
@@ -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": "68011b46a05473237b9d1ce3d6c9c6d760b460e3416a749b2738504fbde6ab4f",
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": "15e7cc2bd2ad8a271854352a7aa6ce8afc864747657af0be1ec90d392a8ee648",
826
+ "sha256": "a8d31370b626bb2c5fb733cf3b1e210f66ade1de84007ad0b3a46e31d115687a",
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-26240106828"
892
+ "build_id": "gha-26295896640"
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):
@@ -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
@@ -31,7 +31,7 @@ For each stage directory under `.claude/state/upgrade/`:
31
31
  "files": [
32
32
  {
33
33
  "rel": "docs/init/seed.md",
34
- "base_sha256": "<hex>",
34
+ "base_sha256": "<hex>" | null,
35
35
  "incoming_sha256": "<hex>",
36
36
  "local_sha256": "<hex>",
37
37
  "status": "PENDING"
@@ -39,24 +39,35 @@ For each stage directory under `.claude/state/upgrade/`:
39
39
  ]
40
40
  }
41
41
  ```
42
- - For each entry in `files`, three artifacts are present:
43
- - `<rel>.baseline-base` the **BASE** content (the file as it was when the user last installed the baseline).
44
- - `<rel>.baseline-incoming` — the **INCOMING** content (the file as it ships in the new baseline; INCOMING and REMOTE are the same thing).
42
+ `base_sha256` is the **per-entry classification discriminator**: a 64-hex string means the CLI staged a recoverable BASE (three-way reconciliation); the JSON value `null` means BASE was unrecoverable when the user picked Merge on the tier-1 prompt (two-way reconciliation). See [tier1-merge-option spec](../../../docs/specs/tier1-merge-option.md) §Design pick 1A.
43
+ - For each entry, the staged artifacts are:
44
+ - `<rel>.baseline-incoming` — the **INCOMING** content. Always present.
45
+ - `<rel>.baseline-base` — the **BASE** content. Present iff `base_sha256` is a string; **absent** for BASE-less entries.
45
46
  - The LOCAL file remains at its real path inside the target tree (untouched by the CLI).
46
47
 
47
48
  ## Procedure
48
49
 
49
50
  1. **Discover the stage.** Read `.claude/state/upgrade/` and pick the most-recent stage directory whose manifest has at least one file with `status: PENDING` or `status: NEEDS_USER_INPUT`. If no such stage exists, tell the user "No pending stage to reconcile" and exit.
50
- 2. **Per file**, in the order they appear in the stage manifest:
51
+ 2. **Per-entry classification** (binding). For each entry in the stage manifest, in declared order:
52
+ - If `entry.base_sha256` is a 64-hex string → **three-way reconciliation** (existing path; BASE was recoverable).
53
+ - If `entry.base_sha256` is `null` → **two-way reconciliation** (new path; BASE was unrecoverable when the user picked Merge on tier-1; the zero-drift renumbering rule does not apply because there is no BASE anchor to shift against).
54
+ - Any other value → apply the `NEEDS_USER_INPUT` fallback with reason `malformed-base-sha256`.
55
+ 3. **Three-way reconciliation** (BASE recoverable):
51
56
  - Read BASE, INCOMING, and LOCAL.
52
57
  - Reason about the three-way delta. Identify what changed between BASE → INCOMING (the upstream edit), what changed between BASE → LOCAL (the user edit), and where they conflict.
53
58
  - If both edits are textually non-overlapping, the CLI would have routed the file to tier 2 (mechanical merge). The fact that the file is in tier 3 means structural reconciliation is needed — most commonly: both sides inserted content at the same structural anchor (a new section, a new numbered article, a new TOC entry).
54
59
  - Apply the **zero-drift renumbering rule** below.
55
60
  - Write the reconciled bytes to the LOCAL path.
56
61
  - Update the stage manifest entry's `status` to `RECONCILED`.
57
- 3. **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.
62
+ 4. **Two-way reconciliation** (BASE-less; tier-1 Merge):
63
+ - Read INCOMING and LOCAL. Do NOT attempt to read `<rel>.baseline-base` — it is absent by construction.
64
+ - Reason about the two-way diff: which lines/sections in INCOMING are new bytes that should land in LOCAL, and which lines/sections in LOCAL are user-authored content that should be preserved.
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
+ - Write the reconciled bytes to the LOCAL path.
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.
58
69
 
59
- ## The zero-drift renumbering rule (binding)
70
+ ## The zero-drift renumbering rule (binding for three-way only)
60
71
 
61
72
  When BASE → INCOMING adds a new structural entry (a new Article, a new section, a new numbered item) at position N, and BASE → LOCAL added the user's own entry at the same position N, you SHALL renumber the user's entry to the **next available** slot (N+1) — you SHALL **never fold** the user's entry into an existing baseline section.
62
73
 
@@ -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.7.0",
3
+ "version": "0.8.1",
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/merge.js CHANGED
@@ -1,10 +1,10 @@
1
- import { cp, mkdir, unlink } from 'node:fs/promises';
1
+ import { cp, mkdir, readFile, unlink } from 'node:fs/promises';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { hashFile, saveManifest } from './manifest.js';
4
4
  import { deepMergeMcpServers } from './mcp.js';
5
5
  import { NEVER_TOUCH, SPECIAL_MERGE } from './install.js';
6
6
  import { pathExists } from './util.js';
7
- import { dispatchByTier, NoBaseError, canRecoverBase } from './upgrade-tiers.js';
7
+ import { dispatchByTier, NoBaseError, canRecoverBase, writeStageBaseless } from './upgrade-tiers.js';
8
8
 
9
9
  export const ACTION_KINDS = Object.freeze({
10
10
  ADD: 'ADD',
@@ -176,23 +176,33 @@ async function dispatchCustomized({ rel, newEntry, tierCtx, dryRun, onSkipCustom
176
176
  return await dispatchByTier(rel, tier, tierCtx);
177
177
  } catch (err) {
178
178
  if (err instanceof NoBaseError) {
179
- return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, err });
179
+ return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, tierCtx, err });
180
180
  }
181
181
  throw err;
182
182
  }
183
183
  }
184
- return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath });
184
+ return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, tierCtx });
185
185
  }
186
186
 
187
- async function fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, err = null }) {
187
+ async function fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, tierCtx, err = null }) {
188
188
  const choice = onSkipCustomized ? await onSkipCustomized(rel) : 'keep-mine';
189
189
  if (choice === 'take-theirs') {
190
190
  if (!dryRun) await copyFile(tplPath, tgtPath);
191
191
  return { kind: ACTION_KINDS.OVERWRITE, path: rel, reason: err ? `BASE recovery failed (${err.kind}); user chose take-theirs` : 'customized file; user chose take-theirs' };
192
192
  }
193
+ if (choice === 'merge') {
194
+ if (!dryRun) await stageBaselessMerge({ rel, tplPath, tgtPath, tierCtx });
195
+ return { kind: ACTION_KINDS.SEMANTIC_MERGE_STAGED, path: rel, reason: err ? `BASE recovery failed (${err.kind}); staged for two-way merge` : 'tier-1 customized; staged for two-way merge' };
196
+ }
193
197
  return { kind: ACTION_KINDS.SKIP_CUSTOMIZED, path: rel, reason: err ? `BASE recovery failed (${err.kind}); preserved` : 'target customized since last install' };
194
198
  }
195
199
 
200
+ async function stageBaselessMerge({ rel, tplPath, tgtPath, tierCtx }) {
201
+ const incomingBuf = await readFile(tplPath);
202
+ const localBuf = await readFile(tgtPath);
203
+ await writeStageBaseless(tierCtx, rel, incomingBuf, localBuf);
204
+ }
205
+
196
206
  function computeExitCode(actions) {
197
207
  let code = 0;
198
208
  for (const a of actions) {
@@ -2,7 +2,9 @@
2
2
  // Plan/apply split:
3
3
  // 1. detect pending semantic-merge stage (idempotency short-circuit, AC-007)
4
4
  // 2. dry-run threeWayMerge → enumerate SKIP_CUSTOMIZED conflicts (tier-1 only)
5
- // 3. prompt the user once per tier-1 conflict (with Show-diff loop, cap-at-2)
5
+ // 3. prompt the user once per tier-1 conflict: Keep your version / Use new
6
+ // baseline / Merge / Abort. The Merge pick stages incoming bytes for
7
+ // /upgrade-project to reconcile (tier1-merge-option spec).
6
8
  // 4. on cancel/abort: bail before any write
7
9
  // 5. on resolve: real threeWayMerge with onSkipCustomized backed by the Map.
8
10
  // Tier-2 MECHANICAL and tier-3 SEMANTIC files are NOT prompted — they're
@@ -16,7 +18,6 @@ import { threeWayMerge, ACTION_KINDS, ACTION_LABELS, ACTION_LABEL_WIDTH } from '
16
18
  import { loadManifest, buildManifestFromDir } from '../manifest.js';
17
19
  import { COPY_EXCLUDE } from '../install.js';
18
20
  import { findPendingStage, formatStageTimestamp } from '../upgrade-tiers.js';
19
- import { renderUnifiedDiff } from '../diff-render.js';
20
21
  import { renderBrandStrip } from './splash.js';
21
22
 
22
23
  const SUCCESS = 0;
@@ -29,12 +30,10 @@ const ERR_SEMANTIC_STAGED = 5;
29
30
  const CHOICE_OPTIONS = [
30
31
  { value: 'keep-mine', label: 'Keep your version', hint: 'preserve target file as-is' },
31
32
  { value: 'take-theirs', label: 'Use new baseline', hint: 'overwrite with new template' },
32
- { value: 'show-diff', label: 'Show diff', hint: 'render local vs incoming and re-prompt' },
33
+ { value: 'merge', label: 'Merge', hint: 'stage incoming bytes for /upgrade-project to reconcile' },
33
34
  { value: 'abort', label: 'Abort', hint: 'exit without changes' },
34
35
  ];
35
36
 
36
- const SHOW_DIFF_CONSECUTIVE_CAP = 2;
37
-
38
37
  export async function run({ target, opts = {}, prompts = clackModule } = {}) {
39
38
  if (!target || typeof target !== 'string') {
40
39
  throw new Error('tui.upgrade.run requires a non-empty string target');
@@ -65,7 +64,7 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
65
64
  const conflicts = dryReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED);
66
65
 
67
66
  const choices = new Map();
68
- const aborted = await collectUserChoices(prompts, conflicts, opts.templateDir, target, choices);
67
+ const aborted = await collectUserChoices(prompts, conflicts, choices);
69
68
  if (aborted) {
70
69
  prompts.cancel('Upgrade aborted; tree unchanged.');
71
70
  return ERR_ABORT;
@@ -95,7 +94,7 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
95
94
 
96
95
  const stagedCount = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED).length;
97
96
  if (stagedCount > 0) {
98
- prompts.log.info(`${stagedCount} file(s) need semantic merge. Open Claude Code and run /upgrade-project to reconcile.`);
97
+ prompts.log.info(`${stagedCount} file(s) staged. Open Claude Code and run /upgrade-project to reconcile.`);
99
98
  }
100
99
 
101
100
  const applied = finalReport.actions.filter((a) => isApplied(a.kind)).length;
@@ -121,38 +120,22 @@ function isLegacyManifest(m) {
121
120
  return typeof m.baseline_version !== 'string';
122
121
  }
123
122
 
124
- async function collectUserChoices(prompts, conflicts, templateDir, target, choices) {
123
+ async function collectUserChoices(prompts, conflicts, choices) {
125
124
  for (const conflict of conflicts) {
126
- const choice = await pickForFile(prompts, conflict.path, templateDir, target);
125
+ const choice = await pickForFile(prompts, conflict.path);
127
126
  if (choice === 'abort') return true;
128
- if (choice !== null) choices.set(conflict.path, choice);
127
+ choices.set(conflict.path, choice);
129
128
  }
130
129
  return false;
131
130
  }
132
131
 
133
- async function pickForFile(prompts, rel, templateDir, target) {
134
- let consecutiveShowDiff = 0;
135
- while (true) {
136
- const choice = await prompts.select({
137
- message: `${rel} has been customized — choose:`,
138
- options: CHOICE_OPTIONS,
139
- });
140
- if (prompts.isCancel(choice)) return 'abort';
141
- if (choice !== 'show-diff') return choice;
142
- await renderConflictDiff(prompts, rel, templateDir, target);
143
- consecutiveShowDiff++;
144
- if (consecutiveShowDiff >= SHOW_DIFF_CONSECUTIVE_CAP) {
145
- prompts.log.info(`Show-diff picked ${SHOW_DIFF_CONSECUTIVE_CAP} times for ${rel}; falling through (keeping your version). Re-run if you want to choose differently.`);
146
- return null;
147
- }
148
- }
149
- }
150
-
151
- async function renderConflictDiff(prompts, rel, templateDir, target) {
152
- const localBytes = await readFile(join(target, rel), 'utf8');
153
- const incomingBytes = await readFile(join(templateDir, rel), 'utf8');
154
- const diff = renderUnifiedDiff(localBytes, incomingBytes, { colorize: process.stdout.isTTY === true });
155
- prompts.log.info(`Diff for ${rel} (local → incoming):\n${diff}`);
132
+ async function pickForFile(prompts, rel) {
133
+ const choice = await prompts.select({
134
+ message: `${rel} has been customized — choose:`,
135
+ options: CHOICE_OPTIONS,
136
+ });
137
+ if (prompts.isCancel(choice)) return 'abort';
138
+ return choice;
156
139
  }
157
140
 
158
141
  function isReportableAction(kind) {
@@ -86,14 +86,30 @@ export async function dispatchByTier(rel, tier, ctx) {
86
86
  }
87
87
 
88
88
  export async function writeStage(ctx, rel, baseBuf, incomingBuf, localBuf) {
89
- if (!ctx.stageRunTs) ctx.stageRunTs = stageTimestamp();
90
- const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
91
- await mkdir(stageDir, { recursive: true });
89
+ const stageDir = await ensureStageDir(ctx);
92
90
  await writeStageArtifact(stageDir, `${rel}.baseline-base`, baseBuf);
93
91
  await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
94
92
  await appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, localBuf);
95
93
  }
96
94
 
95
+ // BASE-less stage writer used by the tier-1 Merge pick (see
96
+ // docs/specs/tier1-merge-option.md §Behavior #2 + design pick 1A). Unlike
97
+ // writeStage, no BASE artifact is written and the manifest entry carries
98
+ // base_sha256: null — the discriminator /upgrade-project reads to route to
99
+ // two-way reconciliation.
100
+ export async function writeStageBaseless(ctx, rel, incomingBuf, localBuf) {
101
+ const stageDir = await ensureStageDir(ctx);
102
+ await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
103
+ await appendToStageManifest(stageDir, ctx, rel, null, incomingBuf, localBuf);
104
+ }
105
+
106
+ async function ensureStageDir(ctx) {
107
+ if (!ctx.stageRunTs) ctx.stageRunTs = stageTimestamp();
108
+ const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
109
+ await mkdir(stageDir, { recursive: true });
110
+ return stageDir;
111
+ }
112
+
97
113
  // --- foundation helpers ---
98
114
 
99
115
  function sha256(buf) {
@@ -232,7 +248,7 @@ async function appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, l
232
248
  : newStageManifest(ctx);
233
249
  manifest.files.push({
234
250
  rel,
235
- base_sha256: sha256(baseBuf),
251
+ base_sha256: baseBuf === null ? null : sha256(baseBuf),
236
252
  incoming_sha256: sha256(incomingBuf),
237
253
  local_sha256: sha256(localBuf),
238
254
  status: 'PENDING',
@@ -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
 
@@ -1,54 +0,0 @@
1
- // Foundation — line-level unified-diff renderer used by the upgrade TUI's
2
- // "Show diff" prompt. Pure function; no IO, no side effects.
3
-
4
- const ANSI_RED = '\x1b[31m';
5
- const ANSI_GREEN = '\x1b[32m';
6
- const ANSI_RESET = '\x1b[0m';
7
-
8
- export function renderUnifiedDiff(localText, incomingText, opts = {}) {
9
- const colorize = opts.colorize === true;
10
- const ops = diffLines(splitLines(localText), splitLines(incomingText));
11
- return ops.map((op) => renderOp(op, colorize)).join('\n');
12
- }
13
-
14
- function splitLines(text) {
15
- return String(text).split('\n');
16
- }
17
-
18
- function renderOp(op, colorize) {
19
- if (op.kind === 'context') return ' ' + op.line;
20
- const marker = op.kind === 'remove' ? '-' : '+';
21
- if (!colorize) return marker + op.line;
22
- const color = op.kind === 'remove' ? ANSI_RED : ANSI_GREEN;
23
- return color + marker + op.line + ANSI_RESET;
24
- }
25
-
26
- function diffLines(a, b) {
27
- const m = a.length;
28
- const n = b.length;
29
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
30
- for (let i = 1; i <= m; i++) {
31
- for (let j = 1; j <= n; j++) {
32
- if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
33
- else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
34
- }
35
- }
36
- const ops = [];
37
- let i = m;
38
- let j = n;
39
- while (i > 0 && j > 0) {
40
- if (a[i - 1] === b[j - 1]) {
41
- ops.push({ kind: 'context', line: a[i - 1] });
42
- i--; j--;
43
- } else if (dp[i - 1][j] >= dp[i][j - 1]) {
44
- ops.push({ kind: 'remove', line: a[i - 1] });
45
- i--;
46
- } else {
47
- ops.push({ kind: 'add', line: b[j - 1] });
48
- j--;
49
- }
50
- }
51
- while (i > 0) { ops.push({ kind: 'remove', line: a[i - 1] }); i--; }
52
- while (j > 0) { ops.push({ kind: 'add', line: b[j - 1] }); j--; }
53
- return ops.reverse();
54
- }