@friedbotstudio/create-baseline 0.2.1 → 0.4.0
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 +17 -7
- package/bin/cli.js +197 -119
- package/obj/template/.claude/commands/grant-push.md +19 -0
- package/obj/template/.claude/commands/init-project.md +26 -4
- package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
- package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
- package/obj/template/.claude/hooks/lib/common.mjs +283 -0
- package/obj/template/.claude/hooks/lib/common.sh +1 -1
- package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
- package/obj/template/.claude/hooks/memory_stop.sh +161 -2
- package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
- package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
- package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
- package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
- package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
- package/obj/template/.claude/memory/README.md +8 -3
- package/obj/template/.claude/memory/backlog.md +12 -0
- package/obj/template/.claude/project.json +6 -1
- package/obj/template/.claude/settings.json +3 -4
- package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
- package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
- package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
- package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
- package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
- package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
- package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
- package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
- package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
- package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
- package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
- package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
- package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
- package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
- package/obj/template/.claude/skills/chore/SKILL.md +5 -3
- package/obj/template/.claude/skills/commit/SKILL.md +5 -4
- package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
- package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
- package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
- package/obj/template/.claude/skills/documentation/LICENSE +202 -0
- package/obj/template/.claude/skills/documentation/NOTICE +22 -0
- package/obj/template/.claude/skills/harness/SKILL.md +5 -1
- package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
- package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
- package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
- package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
- package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
- package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
- package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
- package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
- package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
- package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
- package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
- package/obj/template/.claude/skills/triage/SKILL.md +11 -5
- package/obj/template/CLAUDE.md +36 -25
- package/obj/template/docs/init/seed.md +39 -24
- package/obj/template/manifest.json +73 -33
- package/package.json +5 -2
- package/src/CLAUDE.template.md +36 -25
- package/src/cli/merge.js +15 -10
- package/src/cli/tui/doctor.js +56 -0
- package/src/cli/tui/install.js +79 -0
- package/src/cli/tui/meta.js +30 -0
- package/src/cli/tui/tokens.js +38 -0
- package/src/cli/tui/upgrade.js +100 -0
- package/src/memory/backlog.template.md +12 -0
- package/src/project.template.json +6 -1
- package/src/seed.template.md +39 -24
- package/src/settings.template.json +3 -4
- package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
- package/obj/template/.claude/hooks/git_commit_guard.sh +0 -93
package/src/CLAUDE.template.md
CHANGED
|
@@ -40,11 +40,11 @@ On every new session, before any work, you SHALL:
|
|
|
40
40
|
|
|
41
41
|
1. **Read** `.claude/project.json` and check the `configured` field.
|
|
42
42
|
2. **If `configured: false`** — `/init-project` has not run. The repository is in a sanctioned operating state called **project-agnostic mode**: hooks are active but `test_runner` and `lint_runner` run in guide mode and nothing is tailored to the user's stack. You SHALL greet the user with this exact framing:
|
|
43
|
-
> "This repo has the Claude Code baseline installed (22 hooks, 1 subagent,
|
|
43
|
+
> "This repo has the Claude Code baseline installed (22 hooks, 1 subagent, 37 skills). It's in **project-agnostic mode** — `test_runner` and `lint_runner` are in guide mode and nothing is tailored to your stack. Run **`/init-project`** to scout the codebase, run the recommender, and generate a config. Skip it if you want baseline-only behavior, but you'll miss stack-specific tailoring."
|
|
44
44
|
You SHALL then proceed with whatever the user asks. Project-agnostic mode is **allowed** — the user is not required to run `/init-project` to use the baseline. The `setup_guard` hook surfaces a one-shot reminder on Write/Edit/MultiEdit (rate-limited to 10 minutes); it does **not** block writes. Other guards (commit, env, spec-approval, verify-pass, track, swarm-boundary) remain hard regardless of `configured` state.
|
|
45
45
|
3. **If `configured: true`** — read `docs/init/seed.md` §16 if present so you know what was added. Tell the user:
|
|
46
46
|
> "Configured for `<stack>`. Run `/triage \"<request>\"` to start a workflow, or `/harness` for the full pipeline."
|
|
47
|
-
4. **Memory check.** The `memory_session_start` hook injects a memory index into your additional context.
|
|
47
|
+
4. **Memory check.** The `memory_session_start` hook injects a memory index into your additional context. The hook emits a **debt-mode nag** only when `_pending.md` has unflushed candidates AND no active workflow on disk (i.e., `.claude/state/workflow.json` is absent) — those candidates are debt from a prior workflow that didn't end-flush. During an active workflow, **Phase 10.6** (memory-flush, between archive and grant-commit) handles flushing automatically; the session-start nag stays silent. You SHALL run `/memory-flush` when the debt-mode nag fires, before starting new work.
|
|
48
48
|
5. **Git-repo check.** Run `git rev-parse --is-inside-work-tree 2>/dev/null` at the project root. If non-zero (not a git repo), surface this once per session and tell the user that gate C and the `commit` phase will be auto-excepted; the workflow ends after `/archive`. This is a sanctioned operating mode — Article IV phase 11 and Article VII are git-conditional.
|
|
49
49
|
6. Once per session is sufficient. You SHALL NOT repeat the greeting on every prompt.
|
|
50
50
|
|
|
@@ -68,6 +68,7 @@ The 11-phase workflow is the only sanctioned path from request to commit. Phase
|
|
|
68
68
|
| 9 | Integrate | `/integrate` | binding verify verdict |
|
|
69
69
|
| 10 | Document | `/document` | docs |
|
|
70
70
|
| 10.5 | Archive | `/archive` | bundle at `docs/archive/<date>/<slug>/` |
|
|
71
|
+
| 10.6 | Memory flush | `/memory-flush` | curated canonical memory + reset `_pending.md` |
|
|
71
72
|
| 11 | **Grant commit** (gate C) + commit | user runs **`/grant-commit`**, then `/commit` (skill) | commit |
|
|
72
73
|
|
|
73
74
|
**Mandatory rules:**
|
|
@@ -77,7 +78,7 @@ The 11-phase workflow is the only sanctioned path from request to commit. Phase
|
|
|
77
78
|
- The only mechanism to bypass a phase is the `exceptions` array in `.claude/state/workflow.json`, written by `/triage`.
|
|
78
79
|
- **Phase 6c and Phase 11 are git-conditional.** On a project where `git rev-parse --is-inside-work-tree` exits non-zero (no `.git/`, not inside a work tree), `/triage` SHALL auto-add `swarm-plan`, `approve-swarm`, `swarm-dispatch`, `grant-commit`, and `commit` to `exceptions`. Phase 6 routes to solo `/tdd` unconditionally; the workflow ends after `/archive`. Worktree isolation (the swarm contract's physical safety mechanism) requires git; `swarm.isolation: "shared"` is a sanctioned configuration knob for git projects that opt out of worktrees but does NOT restore the cross-task write isolation the swarm-worker assumes — it is unsafe as a non-git fallback, especially when `swarm.exempt_path_prefixes` covers baseline-internal paths (e.g. `.claude/`). Persistence outside git is the user's responsibility. See Article VII for the matching rule on git operations.
|
|
79
80
|
- The three consent gates (A, B, C) are **commands**, not skills. They are structurally un-invokable by Claude. You SHALL NOT self-approve.
|
|
80
|
-
- **How the gates are structurally enforced.** Each consent command (`/approve-spec`, `/approve-swarm`, `/grant-commit`) is a slash command typed by the user. The `consent_gate_grant` UserPromptSubmit hook (Art. VIII) parses the user's prompt **before Claude is invoked** and writes a short-lived consent marker at `.claude/state/.<gate>_grant`. The corresponding PreToolUse approval guard (`spec_approval_guard`, `swarm_approval_guard`, `git_commit_guard`) then allows Claude's slash-command-body write of the approval token only when the marker is present, fresh (≤ `consent.gate_marker_ttl_seconds`, default 120), and slug-matched; the marker is single-use and deleted on the allowed write. Slug derivation is centralized in `lib/common.sh → canonical_slug` (strip directory prefix + trailing `.md`) so the marker and the expected slug always agree, whether the user typed a bare slug, a filename, or a full path. The same guards block Claude from writing the marker file itself via Write/Edit/MultiEdit. Claude cannot reach the UserPromptSubmit code path, so it cannot forge consent.
|
|
81
|
+
- **How the gates are structurally enforced.** Each consent command (`/approve-spec`, `/approve-swarm`, `/grant-commit`, `/grant-push`) is a slash command typed by the user. The `consent_gate_grant` UserPromptSubmit hook (Art. VIII) parses the user's prompt **before Claude is invoked** and writes a short-lived consent marker at `.claude/state/.<gate>_grant`. The corresponding PreToolUse approval guard (`spec_approval_guard`, `swarm_approval_guard`, `git_commit_guard`) then allows Claude's slash-command-body write of the approval token only when the marker is present, fresh (≤ `consent.gate_marker_ttl_seconds`, default 120), and slug-matched; the marker is single-use and deleted on the allowed write. `/grant-push` is **not** a workflow-phase gate — it is a Bash-time consent for push to a protected branch (see Article VII). Slug derivation is centralized in `lib/common.sh → canonical_slug` (strip directory prefix + trailing `.md`) so the marker and the expected slug always agree, whether the user typed a bare slug, a filename, or a full path. The same guards block Claude from writing the marker file itself via Write/Edit/MultiEdit. Claude cannot reach the UserPromptSubmit code path, so it cannot forge consent.
|
|
81
82
|
- **Out-of-band**: `/rca` produces an incident postmortem at `docs/rca/<slug>.md`. It is not a workflow phase and often precedes a bugfix intake.
|
|
82
83
|
|
|
83
84
|
**Entry points** (`/triage` writes `workflow.json` with `entry_phase` and `exceptions`):
|
|
@@ -105,7 +106,7 @@ When `/harness` is invoked, you SHALL:
|
|
|
105
106
|
|
|
106
107
|
**The safety net.** The `harness_continuation` Stop hook (Article VIII) re-fires `Skill(harness)` only when the loop exited mid-flow — i.e., on-disk state is `{state: "continue"}` with the marker present, and `stop_hook_active` is absent on the Stop payload. In normal operation (loop runs to gate/failure/done), the hook sees `state != continue` or marker absent and stays silent. The hook is a defense-in-depth signal, not the primary driver: a healthy `Skill(harness)` invocation never depends on it. The hook is also bounded to one block per turn by Claude Code's `stop_hook_active` semantics, so it cannot itself drive multi-phase chaining.
|
|
107
108
|
|
|
108
|
-
**Resume after yield.** The user runs the consent command
|
|
109
|
+
**Resume after yield (auto).** After yielding at a consent gate, the harness skill writes `state: yielded` and removes `.harness_active`. The user runs the consent slash command in their next prompt; `consent_gate_grant` writes the gate marker (outside Claude's tool boundary), the command body writes the consent token, and the `harness_continuation` Stop hook detects fresh consent (rung 4: `workflow.json` present + `state=yielded` + a consent-token mtime newer than `harness_state`). The hook emits `{decision:"block"}`, and `Skill(harness)` is re-invoked in the same turn. The next invocation re-enters preflight, finds the gate token on disk, marks the gate task `completed`, and re-enters the loop. The user does not type `/harness` to resume.
|
|
109
110
|
|
|
110
111
|
**Task discipline (autonomous progression).** `/triage` seeds a `TaskCreate`-backed checklist covering every non-excepted phase plus consent-gate placeholders (the placeholders carry `metadata.needs_user: true`). Inside the loop you SHALL: (a) call `TaskList` first; if empty, re-seed from `workflow.json → completed + exceptions + entry_phase` using the canonical templates in `triage`'s SKILL.md; (b) `TaskUpdate` the next pending non-blocked task to `in_progress` before invoking its phase skill; (c) `TaskUpdate` to `completed` only after the phase skill returns success; (d) when the next pending task carries `needs_user: true`, EXIT LOOP with YIELD — never invoke a skill for that task. The TaskList is session-bound; `workflow.json → completed` is the durable truth, and re-seeding always reconciles to it.
|
|
111
112
|
|
|
@@ -157,21 +158,25 @@ The following bind every code change.
|
|
|
157
158
|
|
|
158
159
|
**Applicability.** Article VII applies only when the project is a git repository (`git rev-parse --is-inside-work-tree` exits 0 at the project root). On a non-git project, this Article is vacuously satisfied: you SHALL NOT attempt any git operation, gate C and the `commit` phase are auto-excepted at triage time (Art. IV), and the workflow ends after `/archive`. The rules below bind only inside the git-repository case.
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
**Branch-aware consent policy.** Consent enforcement for `git commit` and `git push` is driven by two `project.json` knobs:
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
- `git.protected_branches` — glob list. `null` (default) means every branch is protected. Set e.g. `["main", "release/*"]` to limit consent enforcement to those branches.
|
|
164
|
+
- `git.branch_pattern` — regex. `null` (default) means no naming check. Set e.g. `"^(feat|fix|chore|docs)/[a-z0-9-]+$"` to require conformant branch names on commit.
|
|
164
165
|
|
|
165
|
-
`git_commit_guard` (Art. VIII)
|
|
166
|
+
On a **protected branch**, commits require a fresh `commit_consent` token (written by `/grant-commit`, 5-min TTL) and pushes require a fresh `push_consent` token (written by `/grant-push`, 5-min TTL) — both gated by the user having explicitly asked for the operation in their current request. On a non-protected branch, commits and pushes proceed without consent. `git_commit_guard` (Art. VIII) is the enforcer.
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
**Detached HEAD.** When the current branch resolves to the literal string `HEAD` (detached state), the guard denies both commit and push with an explicit message. Check out a named branch before attempting either — branch-aware policy needs a named branch to evaluate `git.protected_branches` and `git.branch_pattern`.
|
|
168
169
|
|
|
169
|
-
-
|
|
170
|
-
|
|
171
|
-
-
|
|
172
|
-
-
|
|
173
|
-
- `git
|
|
174
|
-
- `git
|
|
170
|
+
**Hard-blocks regardless of consent, branch, or user request.** These operations rewrite history, skip safety, or sweep paths; `git_commit_guard`'s `FORBIDDEN_RE` blocks them flat-out:
|
|
171
|
+
|
|
172
|
+
- `git commit --amend` — always create a new commit.
|
|
173
|
+
- `--no-verify`, `--no-gpg-sign`, or any flag that skips hooks/signing.
|
|
174
|
+
- `git reset --hard`, `git clean -f`, `git checkout --`, `git branch -D`.
|
|
175
|
+
- `git config` changes.
|
|
176
|
+
- `git rebase -i`, `git add -i` (interactive).
|
|
177
|
+
- `git add -A`, `git add .` — name the paths.
|
|
178
|
+
|
|
179
|
+
`git push` is no longer in this set — it is governed by the branch-aware policy above. `git push --force` and `--force-with-lease` are still forbidden unless the user names the exact operation in their current request, AND additionally subject to the branch-aware policy (force-push to a protected branch requires fresh `push_consent` plus the user-named carve-out).
|
|
175
180
|
|
|
176
181
|
## Article VIII — Hooks (the enforcement layer)
|
|
177
182
|
|
|
@@ -181,7 +186,7 @@ The 22 hooks in `.claude/hooks/` are the structural enforcement of this constitu
|
|
|
181
186
|
|---|---|---|---|
|
|
182
187
|
| `setup_guard` | PreToolUse / Edit\|Write\|MultiEdit | Art. III | Advisory reminder when `configured: false` (rate-limited 10 min). Does **not** block. |
|
|
183
188
|
| `destructive_cmd_guard` | PreToolUse / Bash | Art. VII | Hard-block catastrophic commands; ask risky |
|
|
184
|
-
| `git_commit_guard` | PreToolUse / Bash + Edit\|Write\|MultiEdit | Art. IV gate C, Art. VII | Bash:
|
|
189
|
+
| `git_commit_guard` | PreToolUse / Bash + Edit\|Write\|MultiEdit | Art. IV gate C, Art. VII | Bash: enforce branch-aware policy — `git commit` on a protected branch requires fresh `commit_consent`; `git push` on a protected branch requires fresh `push_consent`; both proceed without consent on non-protected branches; off-`branch_pattern` branches deny commits; detached HEAD denies both. Hard-block remaining forbidden flags (--amend, --no-verify, reset --hard, etc.). Write: gate writes to `.claude/state/{commit,push}_consent` and the matching `.{commit,push}_consent_grant` markers. |
|
|
185
190
|
| `env_guard` | PreToolUse / Edit\|Write\|MultiEdit\|NotebookEdit | Art. VII | Block writes to `.env*` (allows `.env.example`) |
|
|
186
191
|
| `spec_approval_guard` | PreToolUse / Edit\|Write\|MultiEdit | Art. IV gate A | Validate fresh `.spec_approval_grant` marker before allowing approval-token writes; block self-approval inside spec markdown; block direct writes to the marker |
|
|
187
192
|
| `swarm_approval_guard` | PreToolUse / Edit\|Write\|MultiEdit | Art. IV gate B | Validate fresh `.swarm_approval_grant` marker before allowing swarm-approval writes; block direct writes to the marker |
|
|
@@ -200,13 +205,13 @@ The 22 hooks in `.claude/hooks/` are the structural enforcement of this constitu
|
|
|
200
205
|
| `memory_stop` | Stop | Art. IX | Auto-extract memory candidates each turn-end |
|
|
201
206
|
| `harness_continuation` | Stop | Art. V | Three-rung gate: (1) `stop_hook_active` absent on payload; (2) `.claude/state/.harness_active` exists (session-scoped marker created by the harness skill on `continue`, deleted on `yielded`/`done`, cleaned by `memory_session_start.sh` on session boundary); (3) `harness_state.state == "continue"`. When all three pass, emits `{"decision":"block","reason":"…invoke Skill(harness)…"}`. Sanity rail: marker-slug-vs-`workflow.json`-slug mismatch logs WARN to `harness_continuation.log` without changing the decision. Silent on any rung fail. Never writes consent markers. |
|
|
202
207
|
| `memory_pre_compact` | PreCompact | Art. IX | Capture resume snapshot before context compaction |
|
|
203
|
-
| `consent_gate_grant` | UserPromptSubmit | Art. IV gates A/B/C | Detect `/approve-spec`/`/approve-swarm`/`/grant-commit` in user input and write the gate-specific consent marker — runs OUTSIDE Claude's tool boundary so Claude cannot forge it |
|
|
208
|
+
| `consent_gate_grant` | UserPromptSubmit | Art. IV gates A/B/C, Art. VII | Detect `/approve-spec`/`/approve-swarm`/`/grant-commit`/`/grant-push` in user input and write the gate-specific consent marker — runs OUTSIDE Claude's tool boundary so Claude cannot forge it |
|
|
204
209
|
|
|
205
210
|
## Article IX — Project memory
|
|
206
211
|
|
|
207
212
|
The memory system at `.claude/memory/` accumulates project facts across sessions. You SHALL:
|
|
208
213
|
|
|
209
|
-
1. Treat the
|
|
214
|
+
1. Treat the seven canonical files (`landmarks.md`, `libraries.md`, `decisions.md`, `landmines.md`, `conventions.md`, `pending-questions.md`, `backlog.md`) as long-term project memory. Each entry has a stable key per the schema in `.claude/memory/README.md`.
|
|
210
215
|
2. **Re-verify before citing.** Every skill that cites a memory entry SHALL re-verify it (file exists, symbol still at named line, library version still pinned). Failed verification → you SHALL correct or delete the entry in the same run before proceeding.
|
|
211
216
|
3. Treat `_pending.md` as the auto-extraction inbox (written by `memory_stop`). Promote candidates to canonical files only via `/memory-flush`. You SHALL NOT write directly into canonical memory files outside the natural byproduct of phase skills.
|
|
212
217
|
4. Treat `_resume.md` as the cross-session continuity snapshot (refreshed every turn-end and before compaction). It is **session memory**, not project memory.
|
|
@@ -257,7 +262,7 @@ Every UI design task that originates inside a workflow phase SHALL route through
|
|
|
257
262
|
| A spec whose `write_set` intersects `project.json → tdd.ui_globs` SHALL declare a populated `## Design calls` section, one row per design surface. | `spec_design_calls_guard` (Art. VIII) at the Write boundary; `/spec-lint` at preflight. |
|
|
258
263
|
| `/tdd` Step 6 SHALL invoke `Skill(design-ui, task_brief)` once per `## Design calls` row before Step 7 (verify). | `tdd` skill SOP. |
|
|
259
264
|
| `design-ui` SHALL NOT write product code. Its only writes are the state file at `.claude/state/design/<slug>.json`, snapshots under `docs/design/<slug>.*.md`, and memory candidates. The product-code writes happen inside `impeccable` invocations. | `design-ui` SKILL.md. |
|
|
260
|
-
| `design-ui` SHALL classify incoming intents at Stage 0 (design / development / copy). A misrouted intent returns `final_state: "not_a_design_task"` with
|
|
265
|
+
| `design-ui` SHALL classify incoming intents at Stage 0 (design / development / copy). A misrouted intent returns one of two terminal states: `final_state: "not_a_design_task"` (single-lane misroute) with `correct_lane`, OR `final_state: "mixed_brief"` (multi-lane misroute) with a structured `lane_split` array. Neither writes code. | `design-ui` Stage 0 + `references/design-vs-development.md`. |
|
|
261
266
|
| Iteration cap: `audit → polish` loops SHALL terminate after 3 iterations with `final_state: "needs_human"` if P0 ≥ 1 or P1 > 0 persist. P0 issues block (do not loop). | `design-ui` SKILL.md + `references/orchestration.md`. |
|
|
262
267
|
| Multi-step impeccable recipes SHALL ask the user before proceeding. Single-step recipes SHALL auto-execute. | `references/intent-table.md` `mode` column. |
|
|
263
268
|
|
|
@@ -287,12 +292,12 @@ Cryptographic supply-chain attestation, signed lock files, and per-skill aggrega
|
|
|
287
292
|
|---|---|
|
|
288
293
|
| `.claude/hooks/` | 22 hook scripts (17 write/run-boundary + 4 lifecycle + 1 input-boundary). Bash + python3, no jq. |
|
|
289
294
|
| `.claude/agents/` | 1 baseline subagent: `swarm-worker` (rendered from `src/agents/swarm-worker.template.md`) |
|
|
290
|
-
| `.claude/skills/` |
|
|
291
|
-
| `.claude/commands/` |
|
|
292
|
-
| `.claude/memory/` |
|
|
295
|
+
| `.claude/skills/` | 37 skills: artifact (4) + phases (11) + workers (5) + spec helpers (4) + orchestration (3) + memory (1) + shared globals (7) + audit (1) + alt tracks (1) |
|
|
296
|
+
| `.claude/commands/` | 5 consent/bootstrap gates: `approve-spec`, `approve-swarm`, `grant-commit`, `grant-push`, `init-project` |
|
|
297
|
+
| `.claude/memory/` | 7 canonical knowledge files + `_pending.md` (staging) + `_resume.md` (continuity snapshot) + `README.md` |
|
|
293
298
|
| `.claude/project.json` | per-project config (test/lint cmd, TDD globs, destructive patterns, swarm config, additions). Populated by `/init-project`. |
|
|
294
299
|
| `.claude/settings.json` | hook wiring + permissions |
|
|
295
|
-
| `.claude/state/` | runtime: `workflow.json`, `commit_consent`, `spec_approvals/`, `swarm_approvals/`, `swarm/`, `harness/<slug>.log`, `last_test_result` |
|
|
300
|
+
| `.claude/state/` | runtime: `workflow.json`, `commit_consent`, `push_consent`, `spec_approvals/`, `swarm_approvals/`, `swarm/`, `harness/<slug>.log`, `last_test_result` |
|
|
296
301
|
| `.mcp.json` | three baseline MCP servers: `context7`, `plantuml`, `playwright` |
|
|
297
302
|
| `src/` | pristine ship-time templates for every file `/init-project` modifies (overlay source for `npx @friedbotstudio/create-baseline`) |
|
|
298
303
|
| `docs/init/seed.md` | genesis prompt — governing specification of the baseline |
|
|
@@ -317,8 +322,14 @@ Cryptographic supply-chain attestation, signed lock files, and per-skill aggrega
|
|
|
317
322
|
**Memory (1)**:
|
|
318
323
|
- `memory-flush`
|
|
319
324
|
|
|
320
|
-
**Shared globals (7)** — vendored
|
|
321
|
-
- `claude-automation-recommender`
|
|
325
|
+
**Shared globals (7)** — one written for this baseline, six vendored from external sources with their upstream licenses preserved in `LICENSE` + `NOTICE` alongside each skill:
|
|
326
|
+
- `claude-automation-recommender` — vendored from Anthropic's `claude-code-setup` plugin, Apache 2.0.
|
|
327
|
+
- `code-structure` — written for this baseline (Friedbot Studio). Mandatory on every code-generation step.
|
|
328
|
+
- `humanizer` — vendored from [`blader/humanizer`](https://github.com/blader/humanizer), MIT.
|
|
329
|
+
- `documentation` — vendored from Anthropic's `claude-code-setup` plugin, Apache 2.0.
|
|
330
|
+
- `technical-tutorials` — vendored from [`jonathimer/devmarketing-skills`](https://github.com/jonathimer/devmarketing-skills), MIT.
|
|
331
|
+
- `copywriting` — vendored from [`coreyhaines31/marketingskills`](https://github.com/coreyhaines31/marketingskills), MIT.
|
|
332
|
+
- `impeccable` — vendored from [`pbakaus/impeccable`](https://github.com/pbakaus/impeccable), Apache 2.0.
|
|
322
333
|
|
|
323
334
|
**Audit (1)**:
|
|
324
335
|
- `audit-baseline` — drift check between this constitution + seed.md and the implementation
|
package/src/cli/merge.js
CHANGED
|
@@ -22,7 +22,8 @@ async function copyFile(src, dst) {
|
|
|
22
22
|
await cp(src, dst, { force: true });
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export async function threeWayMerge(templateDir, target, oldManifest, newManifest) {
|
|
25
|
+
export async function threeWayMerge(templateDir, target, oldManifest, newManifest, opts = {}) {
|
|
26
|
+
const { dryRun = false, onSkipCustomized = null } = opts;
|
|
26
27
|
const actions = [];
|
|
27
28
|
const oldFiles = oldManifest?.files ?? {};
|
|
28
29
|
const newFiles = newManifest?.files ?? {};
|
|
@@ -36,7 +37,7 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
|
|
|
36
37
|
if (await pathExists(tgtPath)) {
|
|
37
38
|
actions.push({ kind: ACTION_KINDS.NEVER_TOUCH_PRESERVE, path: rel, reason: 'NEVER_TOUCH path present in target' });
|
|
38
39
|
} else if (rel in newFiles) {
|
|
39
|
-
await copyFile(tplPath, tgtPath);
|
|
40
|
+
if (!dryRun) await copyFile(tplPath, tgtPath);
|
|
40
41
|
actions.push({ kind: ACTION_KINDS.NEVER_TOUCH_ADD, path: rel, reason: 'NEVER_TOUCH path absent; written from template' });
|
|
41
42
|
}
|
|
42
43
|
continue;
|
|
@@ -44,7 +45,7 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
|
|
|
44
45
|
|
|
45
46
|
if (SPECIAL_MERGE.includes(rel)) {
|
|
46
47
|
if (rel in newFiles && await pathExists(tplPath)) {
|
|
47
|
-
await deepMergeMcpServers(tplPath, tgtPath);
|
|
48
|
+
if (!dryRun) await deepMergeMcpServers(tplPath, tgtPath);
|
|
48
49
|
actions.push({ kind: ACTION_KINDS.SPECIAL_MERGE, path: rel, reason: 'additive deep-merge applied' });
|
|
49
50
|
}
|
|
50
51
|
continue;
|
|
@@ -56,7 +57,7 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
|
|
|
56
57
|
const tgtHash = targetExists ? await hashFile(tgtPath) : null;
|
|
57
58
|
|
|
58
59
|
if (!targetExists && newHash) {
|
|
59
|
-
await copyFile(tplPath, tgtPath);
|
|
60
|
+
if (!dryRun) await copyFile(tplPath, tgtPath);
|
|
60
61
|
actions.push({ kind: ACTION_KINDS.ADD, path: rel, reason: 'new in template; not present in target' });
|
|
61
62
|
continue;
|
|
62
63
|
}
|
|
@@ -67,13 +68,19 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
if (newHash && oldHash && tgtHash === oldHash) {
|
|
70
|
-
await copyFile(tplPath, tgtPath);
|
|
71
|
+
if (!dryRun) await copyFile(tplPath, tgtPath);
|
|
71
72
|
actions.push({ kind: ACTION_KINDS.OVERWRITE, path: rel, reason: 'target untouched since last install; updated' });
|
|
72
73
|
continue;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
if (newHash && tgtHash && tgtHash !== oldHash) {
|
|
76
|
-
|
|
77
|
+
const choice = onSkipCustomized ? await onSkipCustomized(rel) : 'keep-mine';
|
|
78
|
+
if (choice === 'take-theirs') {
|
|
79
|
+
if (!dryRun) await copyFile(tplPath, tgtPath);
|
|
80
|
+
actions.push({ kind: ACTION_KINDS.OVERWRITE, path: rel, reason: 'customized file; user chose take-theirs' });
|
|
81
|
+
} else {
|
|
82
|
+
actions.push({ kind: ACTION_KINDS.SKIP_CUSTOMIZED, path: rel, reason: 'target customized since last install' });
|
|
83
|
+
}
|
|
77
84
|
continue;
|
|
78
85
|
}
|
|
79
86
|
|
|
@@ -84,10 +91,8 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
|
|
|
84
91
|
// prune. Otherwise the user accumulates stale baseline files forever.
|
|
85
92
|
// - target customized (tgtHash != oldHash) → preserve to avoid
|
|
86
93
|
// destroying user work; report drift via exit 3.
|
|
87
|
-
// Pruning only runs when --merge already applies; there is no separate
|
|
88
|
-
// flag (decision recorded in README).
|
|
89
94
|
if (targetExists && tgtHash === oldHash) {
|
|
90
|
-
await unlink(tgtPath);
|
|
95
|
+
if (!dryRun) await unlink(tgtPath);
|
|
91
96
|
actions.push({ kind: ACTION_KINDS.PRUNE, path: rel, reason: 'removed from new template; target was untouched — deleted' });
|
|
92
97
|
} else if (targetExists) {
|
|
93
98
|
actions.push({ kind: ACTION_KINDS.PRUNE_SKIPPED_CUSTOMIZED, path: rel, reason: 'removed from new template; target customized — preserved' });
|
|
@@ -96,7 +101,7 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
|
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
if (newManifest) {
|
|
104
|
+
if (newManifest && !dryRun) {
|
|
100
105
|
await mkdir(join(target, '.claude'), { recursive: true });
|
|
101
106
|
await saveManifest(join(target, '.claude/.baseline-manifest.json'), newManifest);
|
|
102
107
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Domain — branded sectioned doctor report. Consumes the structured
|
|
2
|
+
// DoctorReport from src/cli/doctor.js (unchanged) and writes a colorized,
|
|
3
|
+
// sectioned rendering to stdout. The non-TTY plain path stays on doctor.js's
|
|
4
|
+
// formatReport — this renderer is only invoked when stdout is a TTY.
|
|
5
|
+
|
|
6
|
+
import { accent, muted, success, warn, error, accentLight } from './tokens.js';
|
|
7
|
+
|
|
8
|
+
function brandHeader(target, manifestInfo) {
|
|
9
|
+
const lines = [accent('Baseline doctor')];
|
|
10
|
+
if (target) lines.push(muted(`target: ${target}`));
|
|
11
|
+
if (manifestInfo) lines.push(muted(`manifest: ${manifestInfo}`));
|
|
12
|
+
return lines;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function render(report) {
|
|
16
|
+
if (report.error) {
|
|
17
|
+
const headerLines = brandHeader(report.target);
|
|
18
|
+
process.stdout.write(headerLines.join('\n') + '\n\n');
|
|
19
|
+
process.stdout.write(`${error('doctor:')} ${report.error}\n`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const lines = brandHeader(report.target, `version ${report.manifestVersion}, installed ${report.generatedAt}`);
|
|
23
|
+
lines.push('');
|
|
24
|
+
lines.push(` ${success('matched')}: ${report.matched.length}`);
|
|
25
|
+
lines.push(` ${accentLight('customized')}: ${report.customized.length}`);
|
|
26
|
+
lines.push(` ${error('missing')}: ${report.missing.length}`);
|
|
27
|
+
lines.push(` ${warn('added')}: ${report.added.length}`);
|
|
28
|
+
|
|
29
|
+
if (report.missing.length > 0) {
|
|
30
|
+
lines.push('');
|
|
31
|
+
lines.push(error('Missing (deleted from disk; exit 1):'));
|
|
32
|
+
for (const p of report.missing) lines.push(` - ${p}`);
|
|
33
|
+
}
|
|
34
|
+
if (report.customized.length > 0) {
|
|
35
|
+
lines.push('');
|
|
36
|
+
const header = report.strict
|
|
37
|
+
? accentLight('Customized (strict mode → exit 1):')
|
|
38
|
+
: accentLight('Customized (informational):');
|
|
39
|
+
lines.push(header);
|
|
40
|
+
if (Array.isArray(report.tampered) && report.tampered.length > 0) {
|
|
41
|
+
for (const entry of report.tampered) {
|
|
42
|
+
lines.push(` ${warn('TAMPERED')}: ${entry.path}`);
|
|
43
|
+
lines.push(` shipped=${muted(entry.shipped)} observed=${muted(entry.observed)}`);
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
for (const p of report.customized) lines.push(` - ${p}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (report.added.length > 0) {
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push(warn('Added under .claude/ since install (likely /init-project; informational):'));
|
|
52
|
+
for (const p of report.added) lines.push(` - ${p}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
56
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Domain — branded install flow. Composes the pure-data install + plantuml
|
|
2
|
+
// foundations behind a clack-style presentation seam. The `prompts` parameter
|
|
3
|
+
// defaults to @clack/prompts but is injected in tests.
|
|
4
|
+
|
|
5
|
+
import * as clackModule from '@clack/prompts';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { freshInstall, forceInstall } from '../install.js';
|
|
8
|
+
import { fetchPlantumlIfMissing, FETCH_OUTCOMES } from '../plantuml.js';
|
|
9
|
+
|
|
10
|
+
const SUCCESS = 0;
|
|
11
|
+
const ERR_INSTALL_FAILED = 1;
|
|
12
|
+
const ERR_PLANTUML_REQUIRED = 4;
|
|
13
|
+
|
|
14
|
+
export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
15
|
+
if (!target || typeof target !== 'string') {
|
|
16
|
+
throw new Error('tui.install.run requires a non-empty string target');
|
|
17
|
+
}
|
|
18
|
+
if (!opts.templateDir) {
|
|
19
|
+
throw new Error('tui.install.run requires opts.templateDir');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const version = await readPackageVersion();
|
|
23
|
+
prompts.intro(`create-baseline v${version}`);
|
|
24
|
+
|
|
25
|
+
const spinner = prompts.spinner();
|
|
26
|
+
spinner.start('Copying baseline files');
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await copyTemplate(target, opts);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
spinner.error('Install failed');
|
|
32
|
+
prompts.outro(err.message);
|
|
33
|
+
return ERR_INSTALL_FAILED;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const plantumlExit = await fetchPlantumlBranded(target, opts, prompts, spinner);
|
|
37
|
+
if (plantumlExit !== SUCCESS) return plantumlExit;
|
|
38
|
+
|
|
39
|
+
spinner.stop('Baseline installed');
|
|
40
|
+
prompts.outro(`Installed at ${target}`);
|
|
41
|
+
return SUCCESS;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function copyTemplate(target, opts) {
|
|
45
|
+
const installOpts = { withNpmrc: !!opts.withNpmrc };
|
|
46
|
+
if (opts.force) await forceInstall(opts.templateDir, target, installOpts);
|
|
47
|
+
else await freshInstall(opts.templateDir, target, installOpts);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchPlantumlBranded(target, opts, prompts, spinner) {
|
|
51
|
+
if (opts.noPlantuml) return SUCCESS;
|
|
52
|
+
spinner.message('Fetching PlantUML jar');
|
|
53
|
+
const result = await fetchPlantumlIfMissing(target, {
|
|
54
|
+
noPlantuml: opts.noPlantuml,
|
|
55
|
+
requirePlantuml: opts.requirePlantuml,
|
|
56
|
+
});
|
|
57
|
+
if (result.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
|
|
58
|
+
spinner.error('PlantUML required but unavailable');
|
|
59
|
+
prompts.outro(result.reason);
|
|
60
|
+
return ERR_PLANTUML_REQUIRED;
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
result.outcome === FETCH_OUTCOMES.WARNED_NETWORK_FAILURE ||
|
|
64
|
+
result.outcome === FETCH_OUTCOMES.WARNED_HASH_MISMATCH
|
|
65
|
+
) {
|
|
66
|
+
prompts.log.warn(`PlantUML jar: ${result.reason} — install continued`);
|
|
67
|
+
}
|
|
68
|
+
return SUCCESS;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readPackageVersion() {
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL('../../../package.json', import.meta.url);
|
|
74
|
+
const pkg = JSON.parse(await readFile(url, 'utf8'));
|
|
75
|
+
return pkg.version || '0.0.0';
|
|
76
|
+
} catch {
|
|
77
|
+
return '0.0.0';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Domain — branded renderers for the meta commands (--help, --version).
|
|
2
|
+
// In a TTY, a brand banner frames the canonical body; in non-TTY the body is
|
|
3
|
+
// emitted unchanged so that piped consumers (`$(cli --version)`, `cli --help |
|
|
4
|
+
// grep ...`) keep working byte-clean.
|
|
5
|
+
|
|
6
|
+
import { accent, muted, rule } from './tokens.js';
|
|
7
|
+
|
|
8
|
+
export function renderHelp(helpText, version) {
|
|
9
|
+
if (!process.stdout.isTTY) {
|
|
10
|
+
process.stdout.write(helpText.endsWith('\n') ? helpText : helpText + '\n');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const banner = [
|
|
14
|
+
'',
|
|
15
|
+
` ${accent('Baseline CLI')} ${muted(`v${version}`)}`,
|
|
16
|
+
` ${muted('@friedbotstudio/create-baseline')}`,
|
|
17
|
+
` ${rule('─'.repeat(48))}`,
|
|
18
|
+
'',
|
|
19
|
+
].join('\n');
|
|
20
|
+
process.stdout.write(banner + '\n');
|
|
21
|
+
process.stdout.write(helpText.endsWith('\n') ? helpText : helpText + '\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderVersion(version) {
|
|
25
|
+
if (!process.stdout.isTTY) {
|
|
26
|
+
process.stdout.write(`${version}\n`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
process.stdout.write(`${accent('baseline')} ${muted('v')}${version}\n`);
|
|
30
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Foundation — ANSI brand-color helpers for the TUI presentation layer.
|
|
2
|
+
// Translates Friedbot Studio's oklch tokens (site-src/assets/site.css :root) to
|
|
3
|
+
// 24-bit truecolor escape sequences. Respects NO_COLOR (https://no-color.org)
|
|
4
|
+
// and skips emission when stdout is not a TTY.
|
|
5
|
+
|
|
6
|
+
const NO_COLOR = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '';
|
|
7
|
+
|
|
8
|
+
// oklch -> approximate sRGB hex used in the rendered docs site:
|
|
9
|
+
// --accent oklch(55.8% 0.187 41.5) ~ #c2410c (orange-700)
|
|
10
|
+
// --accent-light oklch(70.3% 0.187 41.5) ~ #ea6a25 (orange-500)
|
|
11
|
+
// --muted oklch(45% 0.026 257) ~ #6b7280
|
|
12
|
+
// --cli-success oklch(70% 0.15 145) ~ #4ade80
|
|
13
|
+
// --warn oklch(58% 0.13 60) ~ #d97706
|
|
14
|
+
// --mac-red oklch(70% 0.21 24) ~ #ef4444
|
|
15
|
+
// --rule oklch(89% 0.013 257) ~ #d1d5db
|
|
16
|
+
const RGB = {
|
|
17
|
+
accent: [194, 65, 12],
|
|
18
|
+
accentLight: [234, 106, 37],
|
|
19
|
+
muted: [107, 114, 128],
|
|
20
|
+
success: [74, 222, 128],
|
|
21
|
+
warn: [217, 119, 6],
|
|
22
|
+
error: [239, 68, 68],
|
|
23
|
+
rule: [209, 213, 219],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function paint(rgb, text) {
|
|
27
|
+
if (NO_COLOR || !process.stdout.isTTY) return text;
|
|
28
|
+
const [r, g, b] = rgb;
|
|
29
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const accent = (text) => paint(RGB.accent, text);
|
|
33
|
+
export const accentLight = (text) => paint(RGB.accentLight, text);
|
|
34
|
+
export const muted = (text) => paint(RGB.muted, text);
|
|
35
|
+
export const success = (text) => paint(RGB.success, text);
|
|
36
|
+
export const warn = (text) => paint(RGB.warn, text);
|
|
37
|
+
export const error = (text) => paint(RGB.error, text);
|
|
38
|
+
export const rule = (text) => paint(RGB.rule, text);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Domain — branded upgrade flow with interactive per-file conflict resolution.
|
|
2
|
+
// Plan/apply split:
|
|
3
|
+
// 1. dry-run threeWayMerge → enumerate SKIP_CUSTOMIZED conflicts
|
|
4
|
+
// 2. prompt the user once per conflict
|
|
5
|
+
// 3. on cancel/abort: bail before any write
|
|
6
|
+
// 4. on resolve: real threeWayMerge with onSkipCustomized backed by the Map
|
|
7
|
+
|
|
8
|
+
import * as clackModule from '@clack/prompts';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { readdir } from 'node:fs/promises';
|
|
11
|
+
import { join, relative, sep } from 'node:path';
|
|
12
|
+
import { threeWayMerge, ACTION_KINDS } from '../merge.js';
|
|
13
|
+
import { loadManifest, buildManifestFromDir } from '../manifest.js';
|
|
14
|
+
|
|
15
|
+
const SUCCESS = 0;
|
|
16
|
+
const ERR_ABORT = 1;
|
|
17
|
+
const ERR_NO_MANIFEST = 2;
|
|
18
|
+
const ERR_DIVERGENCE = 3;
|
|
19
|
+
|
|
20
|
+
const CHOICE_OPTIONS = [
|
|
21
|
+
{ value: 'keep-mine', label: 'Keep mine', hint: 'preserve target file as-is' },
|
|
22
|
+
{ value: 'take-theirs', label: 'Take theirs', hint: 'overwrite with new baseline' },
|
|
23
|
+
{ value: 'abort', label: 'Abort', hint: 'exit without changes' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
27
|
+
if (!target || typeof target !== 'string') {
|
|
28
|
+
throw new Error('tui.upgrade.run requires a non-empty string target');
|
|
29
|
+
}
|
|
30
|
+
if (!opts.templateDir) {
|
|
31
|
+
throw new Error('tui.upgrade.run requires opts.templateDir');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const manifestPath = join(target, '.claude/.baseline-manifest.json');
|
|
35
|
+
if (!existsSync(manifestPath)) {
|
|
36
|
+
prompts.log.error(`No baseline manifest at ${manifestPath}. Run a fresh install first.`);
|
|
37
|
+
return ERR_NO_MANIFEST;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
prompts.intro('create-baseline upgrade');
|
|
41
|
+
|
|
42
|
+
const { oldManifest, newManifest } = await loadManifests(opts.templateDir, manifestPath);
|
|
43
|
+
const dryReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { dryRun: true });
|
|
44
|
+
const conflicts = dryReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED);
|
|
45
|
+
|
|
46
|
+
const choices = new Map();
|
|
47
|
+
for (const conflict of conflicts) {
|
|
48
|
+
const choice = await prompts.select({
|
|
49
|
+
message: `${conflict.path} has been customized — choose:`,
|
|
50
|
+
options: CHOICE_OPTIONS,
|
|
51
|
+
});
|
|
52
|
+
if (prompts.isCancel(choice) || choice === 'abort') {
|
|
53
|
+
prompts.cancel('Upgrade aborted; tree unchanged.');
|
|
54
|
+
return ERR_ABORT;
|
|
55
|
+
}
|
|
56
|
+
choices.set(conflict.path, choice);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (opts.dryRun) {
|
|
60
|
+
for (const action of dryReport.actions) {
|
|
61
|
+
prompts.log.info(`${action.kind.padEnd(24)} ${action.path}`);
|
|
62
|
+
}
|
|
63
|
+
prompts.outro('Dry run complete; no changes written.');
|
|
64
|
+
return SUCCESS;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const onSkipCustomized = (rel) => choices.get(rel) ?? 'keep-mine';
|
|
68
|
+
const finalReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { onSkipCustomized });
|
|
69
|
+
|
|
70
|
+
const applied = finalReport.actions.filter((a) => isApplied(a.kind)).length;
|
|
71
|
+
const skipped = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED).length;
|
|
72
|
+
prompts.outro(`Applied ${applied}; ${skipped} skipped.`);
|
|
73
|
+
return finalReport.exitCode === 3 ? ERR_DIVERGENCE : SUCCESS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isApplied(kind) {
|
|
77
|
+
return (
|
|
78
|
+
kind === ACTION_KINDS.ADD ||
|
|
79
|
+
kind === ACTION_KINDS.OVERWRITE ||
|
|
80
|
+
kind === ACTION_KINDS.PRUNE ||
|
|
81
|
+
kind === ACTION_KINDS.SPECIAL_MERGE ||
|
|
82
|
+
kind === ACTION_KINDS.NEVER_TOUCH_ADD
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadManifests(templateDir, manifestPath) {
|
|
87
|
+
const oldManifest = await loadManifest(manifestPath);
|
|
88
|
+
const tplFiles = await listShippedFiles(templateDir);
|
|
89
|
+
const newManifest = await buildManifestFromDir(templateDir, tplFiles);
|
|
90
|
+
return { oldManifest, newManifest };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function listShippedFiles(root, base = root, acc = []) {
|
|
94
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
95
|
+
const full = join(root, entry.name);
|
|
96
|
+
if (entry.isDirectory()) await listShippedFiles(full, base, acc);
|
|
97
|
+
else if (entry.isFile()) acc.push(relative(base, full).split(sep).join('/'));
|
|
98
|
+
}
|
|
99
|
+
return acc;
|
|
100
|
+
}
|
|
@@ -185,7 +185,12 @@
|
|
|
185
185
|
},
|
|
186
186
|
"consent": {
|
|
187
187
|
"commit_ttl_seconds": 300,
|
|
188
|
-
"gate_marker_ttl_seconds": 120
|
|
188
|
+
"gate_marker_ttl_seconds": 120,
|
|
189
|
+
"push_ttl_seconds": 300
|
|
190
|
+
},
|
|
191
|
+
"git": {
|
|
192
|
+
"protected_branches": null,
|
|
193
|
+
"branch_pattern": null
|
|
189
194
|
},
|
|
190
195
|
"swarm": {
|
|
191
196
|
"max_parallel": 4,
|