forgedock 1.0.21 → 1.1.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 +227 -174
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +386 -0
- package/bin/engine/phases.mjs +148 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +27 -0
- package/bin/engine-cli.mjs +50 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +693 -1393
- package/bin/init-detect.mjs +95 -0
- package/bin/journey.mjs +783 -0
- package/bin/runner.mjs +847 -0
- package/bin/settings-hook.mjs +112 -0
- package/bin/tui.mjs +6 -3
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +85 -39
- package/commands/deploy-info.md +11 -5
- package/commands/design-bench.md +1 -0
- package/commands/design-render-critique-loop.md +1 -0
- package/commands/design.md +1 -0
- package/commands/forge-stats.md +2 -1
- package/commands/issue.md +1 -1
- package/commands/optimize.md +1 -1
- package/commands/orchestrate.md +346 -34
- package/commands/quality-gate.md +168 -80
- package/commands/review-pr-agents.md +69 -45
- package/commands/review-pr.md +54 -6
- package/commands/scope.md +11 -3
- package/commands/security-audit.md +24 -16
- package/commands/sync-ecosystem.md +2 -2
- package/commands/work-on/build/architect.md +47 -3
- package/commands/work-on/build/context.md +36 -5
- package/commands/work-on/build/implement.md +16 -4
- package/commands/work-on/build/validate.md +54 -8
- package/commands/work-on/build.md +27 -9
- package/commands/work-on/close.md +131 -3
- package/commands/work-on/investigate.md +24 -1
- package/commands/work-on/review.md +24 -0
- package/commands/work-on-monolithic.md +1 -0
- package/commands/work-on.md +271 -11
- package/docs/BRAND.md +6 -2
- package/docs/CONFIG.md +39 -34
- package/docs/site/.vitepress/config.mjs +85 -0
- package/docs/site/annotations-explained.md +55 -0
- package/docs/site/command-learning-path.md +104 -0
- package/docs/site/command-reference.md +3 -0
- package/docs/site/getting-started.md +42 -13
- package/docs/site/index.md +1 -1
- package/docs/site/troubleshooting.md +208 -0
- package/docs/spec/forge-protocol-v1.md +768 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/examples/forgedock-demo/README.md +113 -0
- package/examples/forgedock-demo/bootstrap.sh +125 -0
- package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
- package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
- package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
- package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
- package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
- package/examples/forgedock-demo/labels.json +21 -0
- package/examples/forgedock-demo/package.json +16 -0
- package/examples/forgedock-demo/scripts/smoke.js +61 -0
- package/examples/forgedock-demo/src/auth.js +28 -0
- package/examples/forgedock-demo/src/db.js +66 -0
- package/examples/forgedock-demo/src/routes/notes.js +64 -0
- package/examples/forgedock-demo/src/server.js +79 -0
- package/package.json +13 -3
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/forge.yaml.minimal +44 -0
|
@@ -0,0 +1,2339 @@
|
|
|
1
|
+
# Cinematic Onboarding TUI Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Turn `npx forgedock` into one continuous cinematic onboarding journey (preflight → install → detect → review → celebrate), wire the dormant SessionStart hook / registry / annotated-review modules into the CLI, and make every doc claim true.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two new zero-dependency modules — `bin/cinema.mjs` (gradients, block-art mark, motion primitives) and `bin/journey.mjs` (five act functions orchestrating the existing `init-detect.mjs`, `init-enrich-api.mjs`, `annotatedReviewScreen`, `registry.mjs`) — plus a new `bin/settings-hook.mjs` for idempotent `~/.claude/settings.json` hook merging. `bin/forgedock.mjs` shrinks to a router.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js ≥18 built-ins only (no npm dependencies). Tests: `node --test` (existing suite in `bin/tests/`).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md` — read it before starting.
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- **Zero npm dependencies.** Node built-ins only, ESM (`.mjs`).
|
|
16
|
+
- **Node ≥18** (`engines` in package.json).
|
|
17
|
+
- **No alternate screen buffer.** Animations redraw in place with cursor-up (`\x1b[NA`) while active, then settle to static lines. Scrollback must hold a plain receipt.
|
|
18
|
+
- **Color vs motion are independent gates.** `NO_COLOR`/non-TTY/`TERM=dumb` → no ANSI color. Non-TTY/`FORGE_NO_MOTION=1`/`CI`/`--fast` → no animation frames. `NO_COLOR` alone keeps motion (per spec degradation ladder).
|
|
19
|
+
- **Palette:** chrome stops `#f7f3ea #d9cfba #a99a82 #6e6252`; ember stops `#ff4d00 #ff8c1a #ffd166`.
|
|
20
|
+
- **Windows is first-class.** Never assume `HOME` alone: use `process.env.HOME || process.env.USERPROFILE || os.homedir()`. Never split paths on `/` — use `path.basename`.
|
|
21
|
+
- **Never a dead stop:** preflight failures print a fix card and continue; degraded steps are stated inline and echoed in the final receipt.
|
|
22
|
+
- **Filesystem safety:** existing forge.yaml is backed up before overwrite; `settings.json` merge is read-modify-write with a malformed-JSON guard (skip + warn, never clobber); non-TTY overwrite of an existing forge.yaml aborts.
|
|
23
|
+
- **Commits:** every commit uses `git commit -s` (DCO sign-off required). Never add AI attribution lines.
|
|
24
|
+
- **Run the full suite** (`npm test`) before every commit; all existing tests must stay green.
|
|
25
|
+
- **Ship version: 1.1.0** in both `package.json` and `.claude-plugin/plugin.json`.
|
|
26
|
+
|
|
27
|
+
## File Structure
|
|
28
|
+
|
|
29
|
+
| File | Responsibility |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `bin/cinema.mjs` (create) | Color-mode/motion detection, gradient rendering, the block-art F mark, shimmer/reveal/progress primitives. Pure presentation; no business logic. |
|
|
32
|
+
| `bin/settings-hook.mjs` (create) | Idempotent install/remove of the SessionStart hook entry in `~/.claude/settings.json`. |
|
|
33
|
+
| `bin/journey.mjs` (create) | The five acts (`preflight`, `forge`, `read`, `review`, `celebrate`), `runJourney`, ConfigDraft→forge.yaml writer, description detection (moved from forgedock.mjs). |
|
|
34
|
+
| `bin/forgedock.mjs` (modify) | Thin router: flag parsing, HOME fallback, command dispatch, `enable`/`disable`/`status`, uninstall hook removal. The inline `init()` template and `install()` body move to journey.mjs. |
|
|
35
|
+
| `bin/tui.mjs` (modify) | `annotatedReviewScreen` gains `opts.extraFields` (surfacing the detected description) and the prompt copy becomes "Press Enter to forge…". Nothing else changes. |
|
|
36
|
+
| `bin/tests/cinema.test.mjs`, `bin/tests/settings-hook.test.mjs`, `bin/tests/journey.test.mjs`, `bin/tests/router.test.mjs` (create) | Unit tests per module. |
|
|
37
|
+
| `README.md`, `docs/site/getting-started.md`, `docs/CONFIG.md`, `package.json`, `.claude-plugin/plugin.json` (modify) | Docs truth-pass + version 1.1.0. |
|
|
38
|
+
|
|
39
|
+
Existing modules consumed as-is (do NOT modify): `bin/init-detect.mjs` (`detectConfig(cwd) → ConfigDraft`), `bin/init-enrich-api.mjs` (`enrich(draft)`), `bin/registry.mjs` (`resolveState(dir)`, `setOptOut(dir, bool)`), `bin/hooks/session-start.mjs`, `bin/forge-utils.mjs`.
|
|
40
|
+
|
|
41
|
+
**Test convention** (copy from `bin/tests/registry.test.mjs`): `node:test` + `assert/strict`; modules that read env at load time are imported freshly via `pathToFileURL(...)` with a cache-busting `?_t=` param. `cinema.mjs` and `journey.mjs` must therefore take `env`/`stdout`/`argv` as *parameters* (with `process.*` defaults) so tests never need cache-busting.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
### Task 1: cinema.mjs — modes, gradients, the mark
|
|
46
|
+
|
|
47
|
+
**Files:**
|
|
48
|
+
- Create: `bin/cinema.mjs`
|
|
49
|
+
- Test: `bin/tests/cinema.test.mjs`
|
|
50
|
+
|
|
51
|
+
**Interfaces:**
|
|
52
|
+
- Consumes: nothing (pure module).
|
|
53
|
+
- Produces (used by Tasks 2, 5–9):
|
|
54
|
+
- `colorMode(env?, stdout?) → 'truecolor' | '256' | 'none'`
|
|
55
|
+
- `motionEnabled(argv?, env?, stdout?) → boolean`
|
|
56
|
+
- `CHROME_STOPS: string[]`, `EMBER_STOPS: string[]`
|
|
57
|
+
- `sampleGradient(stops, t) → [r,g,b]`
|
|
58
|
+
- `gradientLine(text, stops, mode, phase?) → string` (ANSI-colored, `\x1b[0m`-terminated; plain text when mode `'none'`)
|
|
59
|
+
- `gradientBlock(lines, stops, mode) → string[]` (diagonal: phase shifts per row)
|
|
60
|
+
- `HERO_MARK: string[]` (8 lines), `COMPACT_MARK: string[]` (4 lines)
|
|
61
|
+
- `renderMark(size, mode) → string[]` (`size` = `'hero' | 'compact'`, chrome gradient)
|
|
62
|
+
- `ember(text, mode) → string` (ember gradient shorthand)
|
|
63
|
+
- `sleep(ms) → Promise<void>`
|
|
64
|
+
|
|
65
|
+
- [ ] **Step 1: Write the failing tests**
|
|
66
|
+
|
|
67
|
+
Create `bin/tests/cinema.test.mjs`:
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
/**
|
|
71
|
+
* bin/tests/cinema.test.mjs — Unit tests for bin/cinema.mjs.
|
|
72
|
+
* Run with: node --test bin/tests/cinema.test.mjs
|
|
73
|
+
*/
|
|
74
|
+
import { describe, it } from "node:test";
|
|
75
|
+
import assert from "node:assert/strict";
|
|
76
|
+
import {
|
|
77
|
+
colorMode, motionEnabled, sampleGradient, gradientLine, gradientBlock,
|
|
78
|
+
renderMark, HERO_MARK, COMPACT_MARK, CHROME_STOPS, EMBER_STOPS, ember,
|
|
79
|
+
} from "../cinema.mjs";
|
|
80
|
+
|
|
81
|
+
const TTY = { isTTY: true };
|
|
82
|
+
const PIPE = { isTTY: false };
|
|
83
|
+
|
|
84
|
+
describe("colorMode", () => {
|
|
85
|
+
it("returns none when not a TTY", () => {
|
|
86
|
+
assert.equal(colorMode({ COLORTERM: "truecolor" }, PIPE), "none");
|
|
87
|
+
});
|
|
88
|
+
it("returns none when NO_COLOR is set", () => {
|
|
89
|
+
assert.equal(colorMode({ NO_COLOR: "1", COLORTERM: "truecolor" }, TTY), "none");
|
|
90
|
+
});
|
|
91
|
+
it("returns none when TERM is dumb", () => {
|
|
92
|
+
assert.equal(colorMode({ TERM: "dumb" }, TTY), "none");
|
|
93
|
+
});
|
|
94
|
+
it("returns truecolor when COLORTERM advertises it", () => {
|
|
95
|
+
assert.equal(colorMode({ COLORTERM: "truecolor" }, TTY), "truecolor");
|
|
96
|
+
assert.equal(colorMode({ COLORTERM: "24bit" }, TTY), "truecolor");
|
|
97
|
+
});
|
|
98
|
+
it("returns 256 for TERM=xterm-256color without COLORTERM", () => {
|
|
99
|
+
assert.equal(colorMode({ TERM: "xterm-256color" }, TTY), "256");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("motionEnabled", () => {
|
|
104
|
+
it("false when stdout is not a TTY", () => {
|
|
105
|
+
assert.equal(motionEnabled([], {}, PIPE), false);
|
|
106
|
+
});
|
|
107
|
+
it("false when FORGE_NO_MOTION is set", () => {
|
|
108
|
+
assert.equal(motionEnabled([], { FORGE_NO_MOTION: "1" }, TTY), false);
|
|
109
|
+
});
|
|
110
|
+
it("false when CI is set", () => {
|
|
111
|
+
assert.equal(motionEnabled([], { CI: "true" }, TTY), false);
|
|
112
|
+
});
|
|
113
|
+
it("false when --fast is passed", () => {
|
|
114
|
+
assert.equal(motionEnabled(["--fast"], {}, TTY), false);
|
|
115
|
+
});
|
|
116
|
+
it("true on a plain interactive TTY", () => {
|
|
117
|
+
assert.equal(motionEnabled([], {}, TTY), true);
|
|
118
|
+
});
|
|
119
|
+
it("NO_COLOR alone does NOT disable motion (spec: monochrome, motion intact)", () => {
|
|
120
|
+
assert.equal(motionEnabled([], { NO_COLOR: "1" }, TTY), true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("gradients", () => {
|
|
125
|
+
it("sampleGradient endpoints match first and last stops", () => {
|
|
126
|
+
assert.deepEqual(sampleGradient(["#000000", "#ffffff"], 0), [0, 0, 0]);
|
|
127
|
+
assert.deepEqual(sampleGradient(["#000000", "#ffffff"], 1), [255, 255, 255]);
|
|
128
|
+
});
|
|
129
|
+
it("gradientLine in none mode returns the input unchanged", () => {
|
|
130
|
+
assert.equal(gradientLine("FORGE", EMBER_STOPS, "none"), "FORGE");
|
|
131
|
+
});
|
|
132
|
+
it("gradientLine in truecolor mode emits 38;2 codes and resets", () => {
|
|
133
|
+
const out = gradientLine("AB", EMBER_STOPS, "truecolor");
|
|
134
|
+
assert.match(out, /\x1b\[38;2;\d+;\d+;\d+m/);
|
|
135
|
+
assert.ok(out.endsWith("\x1b[0m"));
|
|
136
|
+
});
|
|
137
|
+
it("gradientLine in 256 mode emits 38;5 codes, never 38;2", () => {
|
|
138
|
+
const out = gradientLine("AB", EMBER_STOPS, "256");
|
|
139
|
+
assert.match(out, /\x1b\[38;5;\d+m/);
|
|
140
|
+
assert.doesNotMatch(out, /38;2/);
|
|
141
|
+
});
|
|
142
|
+
it("gradientLine leaves spaces uncolored", () => {
|
|
143
|
+
const out = gradientLine(" ", EMBER_STOPS, "truecolor");
|
|
144
|
+
assert.equal(out, " \x1b[0m");
|
|
145
|
+
});
|
|
146
|
+
it("gradientBlock returns one string per input line", () => {
|
|
147
|
+
assert.equal(gradientBlock(["a", "b", "c"], CHROME_STOPS, "none").length, 3);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("the mark", () => {
|
|
152
|
+
it("hero mark has 8 lines, compact has 4", () => {
|
|
153
|
+
assert.equal(HERO_MARK.length, 8);
|
|
154
|
+
assert.equal(COMPACT_MARK.length, 4);
|
|
155
|
+
});
|
|
156
|
+
it("renderMark none mode equals the raw art", () => {
|
|
157
|
+
assert.deepEqual(renderMark("hero", "none"), HERO_MARK);
|
|
158
|
+
assert.deepEqual(renderMark("compact", "none"), COMPACT_MARK);
|
|
159
|
+
});
|
|
160
|
+
it("renderMark truecolor lines carry ANSI and settle with reset", () => {
|
|
161
|
+
const lines = renderMark("compact", "truecolor");
|
|
162
|
+
assert.equal(lines.length, 4);
|
|
163
|
+
for (const l of lines) assert.match(l, /\x1b\[38;2/);
|
|
164
|
+
});
|
|
165
|
+
it("ember() wraps text in the ember gradient", () => {
|
|
166
|
+
assert.equal(ember("X", "none"), "X");
|
|
167
|
+
assert.match(ember("X", "truecolor"), /38;2/);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
173
|
+
|
|
174
|
+
Run: `node --test bin/tests/cinema.test.mjs`
|
|
175
|
+
Expected: FAIL — `Cannot find module '.../bin/cinema.mjs'`
|
|
176
|
+
|
|
177
|
+
- [ ] **Step 3: Write the implementation**
|
|
178
|
+
|
|
179
|
+
Create `bin/cinema.mjs`:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
/**
|
|
183
|
+
* bin/cinema.mjs — Cinematic rendering primitives for ForgeDock's TUI.
|
|
184
|
+
*
|
|
185
|
+
* Zero-dependency. Pure presentation: gradients, the block-art F mark, and
|
|
186
|
+
* (in Task 2) shimmer / reveal / progress animation primitives.
|
|
187
|
+
*
|
|
188
|
+
* Two independent gates (spec: degradation ladder):
|
|
189
|
+
* colorMode() — 'truecolor' | '256' | 'none' (NO_COLOR, TTY, TERM)
|
|
190
|
+
* motionEnabled() — animation frames on/off (TTY, FORGE_NO_MOTION, CI, --fast)
|
|
191
|
+
*
|
|
192
|
+
* All functions take env/stdout/argv as parameters (with process defaults)
|
|
193
|
+
* so tests never need module-cache busting.
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Palette (spec: Chrome & Ember)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export const CHROME_STOPS = ["#f7f3ea", "#d9cfba", "#a99a82", "#6e6252"];
|
|
201
|
+
export const EMBER_STOPS = ["#ff4d00", "#ff8c1a", "#ffd166"];
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Gates
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Decide the color capability of the terminal.
|
|
209
|
+
* @returns {'truecolor'|'256'|'none'}
|
|
210
|
+
*/
|
|
211
|
+
export function colorMode(env = process.env, stdout = process.stdout) {
|
|
212
|
+
if (stdout.isTTY !== true) return "none";
|
|
213
|
+
if (env.NO_COLOR) return "none";
|
|
214
|
+
if (env.TERM === "dumb") return "none";
|
|
215
|
+
const ct = (env.COLORTERM || "").toLowerCase();
|
|
216
|
+
if (ct.includes("truecolor") || ct.includes("24bit")) return "truecolor";
|
|
217
|
+
if ((env.TERM || "").includes("256")) return "256";
|
|
218
|
+
// Windows Terminal / modern conhost support truecolor but don't set COLORTERM.
|
|
219
|
+
if (process.platform === "win32") return "truecolor";
|
|
220
|
+
return "256";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Decide whether animation frames run. Independent of color:
|
|
225
|
+
* NO_COLOR keeps motion (monochrome choreography); non-TTY/CI/--fast kill it.
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
export function motionEnabled(
|
|
229
|
+
argv = process.argv,
|
|
230
|
+
env = process.env,
|
|
231
|
+
stdout = process.stdout,
|
|
232
|
+
) {
|
|
233
|
+
if (stdout.isTTY !== true) return false;
|
|
234
|
+
if (env.FORGE_NO_MOTION) return false;
|
|
235
|
+
if (env.CI) return false;
|
|
236
|
+
if (argv.includes("--fast")) return false;
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Gradient math
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function hexToRgb(hex) {
|
|
245
|
+
const h = hex.replace("#", "");
|
|
246
|
+
return [
|
|
247
|
+
parseInt(h.slice(0, 2), 16),
|
|
248
|
+
parseInt(h.slice(2, 4), 16),
|
|
249
|
+
parseInt(h.slice(4, 6), 16),
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Sample a multi-stop gradient at position t ∈ [0,1].
|
|
255
|
+
* @returns {[number, number, number]} RGB triple.
|
|
256
|
+
*/
|
|
257
|
+
export function sampleGradient(stops, t) {
|
|
258
|
+
const rgb = stops.map(hexToRgb);
|
|
259
|
+
const clamped = Math.min(Math.max(t, 0), 1);
|
|
260
|
+
const seg = Math.min(Math.floor(clamped * (rgb.length - 1)), rgb.length - 2);
|
|
261
|
+
const localT = clamped * (rgb.length - 1) - seg;
|
|
262
|
+
const [a, b] = [rgb[seg], rgb[seg + 1]];
|
|
263
|
+
const mix = (x, y) => Math.round(x + (y - x) * localT);
|
|
264
|
+
return [mix(a[0], b[0]), mix(a[1], b[1]), mix(a[2], b[2])];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Quantize an RGB triple to the xterm-256 6×6×6 color cube. */
|
|
268
|
+
function to256([r, g, b]) {
|
|
269
|
+
const q = (v) => Math.round((v / 255) * 5);
|
|
270
|
+
return 16 + 36 * q(r) + 6 * q(g) + q(b);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Foreground escape for an RGB triple in the given mode ('' for none). */
|
|
274
|
+
function fg(rgb, mode) {
|
|
275
|
+
if (mode === "none") return "";
|
|
276
|
+
if (mode === "256") return `\x1b[38;5;${to256(rgb)}m`;
|
|
277
|
+
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Render one line of text with a per-character horizontal gradient.
|
|
282
|
+
* Spaces stay uncolored. `phase` shifts the gradient start (for diagonals).
|
|
283
|
+
* In 'none' mode returns the input text verbatim (no reset appended).
|
|
284
|
+
*/
|
|
285
|
+
export function gradientLine(text, stops, mode, phase = 0) {
|
|
286
|
+
if (mode === "none") return text;
|
|
287
|
+
const chars = [...text];
|
|
288
|
+
const n = Math.max(chars.length - 1, 1);
|
|
289
|
+
let out = "";
|
|
290
|
+
for (let i = 0; i < chars.length; i++) {
|
|
291
|
+
if (chars[i] === " ") {
|
|
292
|
+
out += " ";
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const t = Math.min(i / n + phase, 1);
|
|
296
|
+
out += fg(sampleGradient(stops, t), mode) + chars[i];
|
|
297
|
+
}
|
|
298
|
+
return out + "\x1b[0m";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Render a block of lines with a diagonal gradient (each row's phase shifts
|
|
303
|
+
* slightly, approximating the logo's 135° gradient).
|
|
304
|
+
* @returns {string[]}
|
|
305
|
+
*/
|
|
306
|
+
export function gradientBlock(lines, stops, mode) {
|
|
307
|
+
return lines.map((line, row) => gradientLine(line, stops, mode, row * 0.06));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// The mark — block-art interpretation of the ForgeDock "F" (design approved
|
|
312
|
+
// from the 2026-07-03 brainstorm mockups; geometry may be pixel-tuned).
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
export const HERO_MARK = [
|
|
316
|
+
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
|
317
|
+
" ▄▄██████████████████",
|
|
318
|
+
" ▄█████████████▀▀▀▀▀▀▀▀▀",
|
|
319
|
+
" ▄██████████▀▀",
|
|
320
|
+
" ▀▀▀▀▀ ▄▄███████▀",
|
|
321
|
+
" ▄████████▀",
|
|
322
|
+
" ▄██████▀",
|
|
323
|
+
" ▀▀▀▀",
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
export const COMPACT_MARK = [
|
|
327
|
+
" ▄▄████████",
|
|
328
|
+
" ▄█████▀▀▀▀▀",
|
|
329
|
+
" ▀▀ ▄████▀",
|
|
330
|
+
" ▀▀▀",
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Render the chrome-gradient mark.
|
|
335
|
+
* @param {'hero'|'compact'} size
|
|
336
|
+
* @param {'truecolor'|'256'|'none'} mode
|
|
337
|
+
* @returns {string[]}
|
|
338
|
+
*/
|
|
339
|
+
export function renderMark(size, mode) {
|
|
340
|
+
const art = size === "hero" ? HERO_MARK : COMPACT_MARK;
|
|
341
|
+
if (mode === "none") return [...art];
|
|
342
|
+
return gradientBlock(art, CHROME_STOPS, mode);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Ember-gradient text shorthand (wordmarks, act headers). */
|
|
346
|
+
export function ember(text, mode) {
|
|
347
|
+
return gradientLine(text, EMBER_STOPS, mode);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Promise-based sleep for animation pacing. */
|
|
351
|
+
export function sleep(ms) {
|
|
352
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
357
|
+
|
|
358
|
+
Run: `node --test bin/tests/cinema.test.mjs`
|
|
359
|
+
Expected: PASS (all describe blocks). Also run `npm test` — existing suite stays green.
|
|
360
|
+
|
|
361
|
+
- [ ] **Step 5: Commit**
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
git add bin/cinema.mjs bin/tests/cinema.test.mjs
|
|
365
|
+
git commit -s -m "feat(cinema): color/motion gates, gradient renderer, block-art mark"
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
### Task 2: cinema.mjs — shimmer, reveal rows, molten progress
|
|
371
|
+
|
|
372
|
+
**Files:**
|
|
373
|
+
- Modify: `bin/cinema.mjs` (append)
|
|
374
|
+
- Test: `bin/tests/cinema.test.mjs` (append)
|
|
375
|
+
|
|
376
|
+
**Interfaces:**
|
|
377
|
+
- Consumes: Task 1 exports.
|
|
378
|
+
- Produces (used by Tasks 5–9):
|
|
379
|
+
- `shimmer(lines, stops, { mode, motion, writer?, frames?, interval? }) → Promise<void>` — animated highlight sweep, settles to `gradientBlock` output; with `motion: false` writes the settled block immediately.
|
|
380
|
+
- `revealRows(rows, { mode, motion, writer?, minDisplayMs? }) → Promise<results[]>` — `rows: [{ label, run: async () => ({ ok, detail?, fix?, badge? }) }]`. Renders `✔ label detail` / `✖ label detail` (+ fix-card box lines when `fix` present, + `[high]/[med]/[low]` badge when `badge` present). Returns the array of results.
|
|
381
|
+
- `moltenBar(current, total, { width?, mode }) → string` — one static frame: ember-gradient `█…▓▒` fill + dim `░` rest.
|
|
382
|
+
- `fixCard(lines, mode) → string` — ember-bordered box (wraps `tui.box`).
|
|
383
|
+
|
|
384
|
+
- [ ] **Step 1: Write the failing tests**
|
|
385
|
+
|
|
386
|
+
Append to `bin/tests/cinema.test.mjs`:
|
|
387
|
+
|
|
388
|
+
```js
|
|
389
|
+
import { shimmer, revealRows, moltenBar, fixCard } from "../cinema.mjs";
|
|
390
|
+
|
|
391
|
+
/** Minimal writable stub capturing everything written. */
|
|
392
|
+
function fakeWriter() {
|
|
393
|
+
const chunks = [];
|
|
394
|
+
return {
|
|
395
|
+
chunks,
|
|
396
|
+
write(s) { chunks.push(s); return true; },
|
|
397
|
+
get text() { return chunks.join(""); },
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
describe("shimmer", () => {
|
|
402
|
+
it("without motion writes the settled block once, no cursor movement", async () => {
|
|
403
|
+
const w = fakeWriter();
|
|
404
|
+
await shimmer(["ABC", "DEF"], CHROME_STOPS, { mode: "none", motion: false, writer: w });
|
|
405
|
+
assert.equal(w.text, "ABC\nDEF\n");
|
|
406
|
+
assert.doesNotMatch(w.text, /\x1b\[\d+A/);
|
|
407
|
+
});
|
|
408
|
+
it("with motion ends settled and uses cursor-up (no alt screen)", async () => {
|
|
409
|
+
const w = fakeWriter();
|
|
410
|
+
await shimmer(["AB"], EMBER_STOPS, { mode: "truecolor", motion: true, writer: w, frames: 2, interval: 1 });
|
|
411
|
+
assert.match(w.text, /\x1b\[1A/); // in-place redraw
|
|
412
|
+
assert.doesNotMatch(w.text, /\x1b\[\?1049/); // never alt-screen
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe("revealRows", () => {
|
|
417
|
+
it("without motion prints one final line per row and returns results", async () => {
|
|
418
|
+
const w = fakeWriter();
|
|
419
|
+
const results = await revealRows(
|
|
420
|
+
[
|
|
421
|
+
{ label: "git", run: async () => ({ ok: true, detail: "2.45" }) },
|
|
422
|
+
{ label: "gh", run: async () => ({ ok: false, detail: "not found", fix: ["winget install GitHub.cli"] }) },
|
|
423
|
+
],
|
|
424
|
+
{ mode: "none", motion: false, writer: w },
|
|
425
|
+
);
|
|
426
|
+
assert.equal(results.length, 2);
|
|
427
|
+
assert.match(w.text, /✔ git\s+2\.45/);
|
|
428
|
+
assert.match(w.text, /✖ gh\s+not found/);
|
|
429
|
+
assert.match(w.text, /winget install GitHub\.cli/); // fix card rendered
|
|
430
|
+
});
|
|
431
|
+
it("renders confidence badges when provided", async () => {
|
|
432
|
+
const w = fakeWriter();
|
|
433
|
+
await revealRows(
|
|
434
|
+
[{ label: "owner/repo", run: async () => ({ ok: true, detail: "Rapier/ForgeDock", badge: "high" }) }],
|
|
435
|
+
{ mode: "none", motion: false, writer: w },
|
|
436
|
+
);
|
|
437
|
+
assert.match(w.text, /\[high\]/);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("moltenBar", () => {
|
|
442
|
+
it("none mode renders bracketed plain bar", () => {
|
|
443
|
+
const bar = moltenBar(3, 6, { width: 6, mode: "none" });
|
|
444
|
+
assert.equal(bar, "[█▓▒░░░]");
|
|
445
|
+
});
|
|
446
|
+
it("full bar has no empty cells", () => {
|
|
447
|
+
const bar = moltenBar(6, 6, { width: 6, mode: "none" });
|
|
448
|
+
assert.doesNotMatch(bar, /░/);
|
|
449
|
+
});
|
|
450
|
+
it("truecolor mode emits gradient codes", () => {
|
|
451
|
+
assert.match(moltenBar(3, 6, { width: 6, mode: "truecolor" }), /38;2/);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe("fixCard", () => {
|
|
456
|
+
it("boxes the fix lines", () => {
|
|
457
|
+
const card = fixCard(["run: gh auth login"], "none");
|
|
458
|
+
assert.match(card, /╭[─ ]*.*╮/s);
|
|
459
|
+
assert.match(card, /run: gh auth login/);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
465
|
+
|
|
466
|
+
Run: `node --test bin/tests/cinema.test.mjs`
|
|
467
|
+
Expected: FAIL — `shimmer` etc. not exported.
|
|
468
|
+
|
|
469
|
+
- [ ] **Step 3: Write the implementation**
|
|
470
|
+
|
|
471
|
+
Append to `bin/cinema.mjs`:
|
|
472
|
+
|
|
473
|
+
```js
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Animation primitives (Task 2)
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
import { box, stripAnsi } from "./tui.mjs";
|
|
479
|
+
|
|
480
|
+
const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
481
|
+
|
|
482
|
+
/** Ember-tinted status glyph for a result row. */
|
|
483
|
+
function statusGlyph(ok, mode) {
|
|
484
|
+
if (mode === "none") return ok ? "✔" : "✖";
|
|
485
|
+
const rgb = ok ? [255, 179, 71] : [224, 112, 80]; // amber / ember-red
|
|
486
|
+
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${ok ? "✔" : "✖"}\x1b[0m`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Dim wrapper honoring the mode gate. */
|
|
490
|
+
function dimText(s, mode) {
|
|
491
|
+
return mode === "none" ? s : `\x1b[2m${s}\x1b[22m`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Badge like [high] / [med] / [low], colorized per confidence. */
|
|
495
|
+
function badgeText(badge, mode) {
|
|
496
|
+
const label = badge === "medium" ? "med" : badge;
|
|
497
|
+
if (mode === "none") return `[${label}]`;
|
|
498
|
+
const rgb = badge === "high" ? [125, 219, 132] : badge === "medium" ? [232, 192, 96] : [224, 112, 80];
|
|
499
|
+
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m[${label}]\x1b[0m`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Ember-bordered fix card (wraps tui.box; border color applied when able). */
|
|
503
|
+
export function fixCard(lines, mode) {
|
|
504
|
+
const boxed = box(lines.map((l) => ` ${l} `), { title: "fix" });
|
|
505
|
+
if (mode === "none") return boxed;
|
|
506
|
+
return boxed
|
|
507
|
+
.split("\n")
|
|
508
|
+
.map((l) => l.replace(/^([╭╰│]|\s*[╭╰│])/u, (m) => `\x1b[38;2;255;107;53m${m}\x1b[0m`))
|
|
509
|
+
.join("\n");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function formatRow(label, res, mode) {
|
|
513
|
+
const parts = [` ${statusGlyph(res.ok, mode)} ${label.padEnd(16)}`];
|
|
514
|
+
if (res.detail) parts.push(dimText(String(res.detail), mode));
|
|
515
|
+
if (res.badge) parts.push(badgeText(res.badge, mode));
|
|
516
|
+
return parts.join(" ");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Reveal rows one at a time: spinner while `run()` is in flight, settling to
|
|
521
|
+
* a ✔/✖ line (+ optional fix card). Without motion: run, print final line.
|
|
522
|
+
* Every row's `run` executes regardless of motion — this is display-only.
|
|
523
|
+
*/
|
|
524
|
+
export async function revealRows(
|
|
525
|
+
rows,
|
|
526
|
+
{ mode, motion, writer = process.stdout, interval = 80, minDisplayMs = 120 } = {},
|
|
527
|
+
) {
|
|
528
|
+
const results = [];
|
|
529
|
+
for (const row of rows) {
|
|
530
|
+
let res;
|
|
531
|
+
if (!motion) {
|
|
532
|
+
res = await row.run();
|
|
533
|
+
} else {
|
|
534
|
+
let frame = 0;
|
|
535
|
+
writer.write(` \x1b[38;2;255;107;53m${BRAILLE[0]}\x1b[0m ${row.label}\n`);
|
|
536
|
+
const timer = setInterval(() => {
|
|
537
|
+
frame = (frame + 1) % BRAILLE.length;
|
|
538
|
+
writer.write(`\x1b[1A\x1b[2K \x1b[38;2;255;107;53m${BRAILLE[frame]}\x1b[0m ${row.label}\n`);
|
|
539
|
+
}, interval);
|
|
540
|
+
const started = Date.now();
|
|
541
|
+
try {
|
|
542
|
+
res = await row.run();
|
|
543
|
+
const elapsed = Date.now() - started;
|
|
544
|
+
if (elapsed < minDisplayMs) await sleep(minDisplayMs - elapsed);
|
|
545
|
+
} finally {
|
|
546
|
+
clearInterval(timer);
|
|
547
|
+
}
|
|
548
|
+
writer.write("\x1b[1A\x1b[2K");
|
|
549
|
+
}
|
|
550
|
+
writer.write(formatRow(row.label, res, mode) + "\n");
|
|
551
|
+
if (!res.ok && res.fix && res.fix.length > 0) {
|
|
552
|
+
writer.write(fixCard(res.fix, mode) + "\n");
|
|
553
|
+
}
|
|
554
|
+
results.push(res);
|
|
555
|
+
}
|
|
556
|
+
return results;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* One-pass highlight sweep over gradient block art, settling to the static
|
|
561
|
+
* gradientBlock render. In-place redraw via cursor-up; never alt-screen.
|
|
562
|
+
*/
|
|
563
|
+
export async function shimmer(
|
|
564
|
+
lines,
|
|
565
|
+
stops,
|
|
566
|
+
{ mode, motion, writer = process.stdout, frames = 14, interval = 55 } = {},
|
|
567
|
+
) {
|
|
568
|
+
const settled = gradientBlock(lines, stops, mode);
|
|
569
|
+
if (!motion || mode === "none") {
|
|
570
|
+
writer.write(settled.join("\n") + "\n");
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const width = Math.max(...lines.map((l) => l.length));
|
|
574
|
+
for (let f = 0; f <= frames; f++) {
|
|
575
|
+
const band = (f / frames) * (width + 16) - 8;
|
|
576
|
+
const frameLines = lines.map((line, row) => {
|
|
577
|
+
const chars = [...line];
|
|
578
|
+
let out = "";
|
|
579
|
+
for (let i = 0; i < chars.length; i++) {
|
|
580
|
+
if (chars[i] === " ") {
|
|
581
|
+
out += " ";
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const t = Math.min(i / Math.max(chars.length - 1, 1) + row * 0.06, 1);
|
|
585
|
+
let rgb = sampleGradient(stops, t);
|
|
586
|
+
const dist = Math.abs(i - band + row * 1.5);
|
|
587
|
+
if (dist < 6) {
|
|
588
|
+
const boost = 1 - dist / 6;
|
|
589
|
+
rgb = rgb.map((v) => Math.round(v + (255 - v) * boost));
|
|
590
|
+
}
|
|
591
|
+
out += `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${chars[i]}`;
|
|
592
|
+
}
|
|
593
|
+
return out + "\x1b[0m";
|
|
594
|
+
});
|
|
595
|
+
if (f > 0) writer.write(`\x1b[${lines.length}A`);
|
|
596
|
+
writer.write(frameLines.join("\n") + "\n");
|
|
597
|
+
await sleep(interval);
|
|
598
|
+
}
|
|
599
|
+
writer.write(`\x1b[${lines.length}A`);
|
|
600
|
+
writer.write(settled.join("\n") + "\n");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* One static frame of the molten progress bar: solid fill with a ▓▒ leading
|
|
605
|
+
* edge and dim ░ remainder. Caller redraws with cursor-up for animation.
|
|
606
|
+
*/
|
|
607
|
+
export function moltenBar(current, total, { width = 24, mode } = {}) {
|
|
608
|
+
const ratio = total === 0 ? 1 : Math.min(current / total, 1);
|
|
609
|
+
const filled = Math.round(ratio * width);
|
|
610
|
+
const solidLen = Math.max(filled - 2, 0);
|
|
611
|
+
const edge = "▓▒".slice(0, filled - solidLen);
|
|
612
|
+
const body = "█".repeat(solidLen) + edge;
|
|
613
|
+
const rest = "░".repeat(Math.max(width - filled, 0));
|
|
614
|
+
if (mode === "none") return `[${body}${rest}]`;
|
|
615
|
+
return gradientLine(body, EMBER_STOPS, mode) + dimText(rest, mode);
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Note: `stripAnsi` is imported for future width math in journey; if the linter flags it unused here, drop it from the import.
|
|
620
|
+
|
|
621
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
622
|
+
|
|
623
|
+
Run: `node --test bin/tests/cinema.test.mjs` → PASS. Then `npm test` → all green.
|
|
624
|
+
|
|
625
|
+
- [ ] **Step 5: Visual smoke check (eyeball only, no assert)**
|
|
626
|
+
|
|
627
|
+
Run: `node -e "import('./bin/cinema.mjs').then(async c => { await c.shimmer(c.HERO_MARK, c.CHROME_STOPS, { mode: 'truecolor', motion: true }); console.log(c.ember('F O R G E D O C K', 'truecolor')); console.log(c.moltenBar(18, 24, { mode: 'truecolor' })); })"`
|
|
628
|
+
Expected: chrome mark sweeps once and settles; ember wordmark; molten bar. (Run in Windows Terminal, not a piped shell.)
|
|
629
|
+
|
|
630
|
+
- [ ] **Step 6: Commit**
|
|
631
|
+
|
|
632
|
+
```bash
|
|
633
|
+
git add bin/cinema.mjs bin/tests/cinema.test.mjs
|
|
634
|
+
git commit -s -m "feat(cinema): shimmer, reveal rows, molten progress, fix cards"
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
639
|
+
### Task 3: settings-hook.mjs — idempotent SessionStart hook merge
|
|
640
|
+
|
|
641
|
+
**Files:**
|
|
642
|
+
- Create: `bin/settings-hook.mjs`
|
|
643
|
+
- Test: `bin/tests/settings-hook.test.mjs`
|
|
644
|
+
|
|
645
|
+
**Interfaces:**
|
|
646
|
+
- Consumes: nothing.
|
|
647
|
+
- Produces (used by Tasks 6, 8):
|
|
648
|
+
- `installSessionStartHook(settingsPath, hookScriptPath) → { status: 'installed' | 'already' | 'skipped-malformed' }`
|
|
649
|
+
- `removeSessionStartHook(settingsPath) → { status: 'removed' | 'absent' | 'skipped-malformed' }`
|
|
650
|
+
- Our entry is identified by its command containing `bin/hooks/session-start.mjs` (marker substring `HOOK_MARKER`).
|
|
651
|
+
|
|
652
|
+
- [ ] **Step 1: Write the failing tests**
|
|
653
|
+
|
|
654
|
+
Create `bin/tests/settings-hook.test.mjs`:
|
|
655
|
+
|
|
656
|
+
```js
|
|
657
|
+
/**
|
|
658
|
+
* bin/tests/settings-hook.test.mjs — Unit tests for bin/settings-hook.mjs.
|
|
659
|
+
* Run with: node --test bin/tests/settings-hook.test.mjs
|
|
660
|
+
*/
|
|
661
|
+
import { describe, it, beforeEach } from "node:test";
|
|
662
|
+
import assert from "node:assert/strict";
|
|
663
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
664
|
+
import { join } from "node:path";
|
|
665
|
+
import os from "node:os";
|
|
666
|
+
import { installSessionStartHook, removeSessionStartHook } from "../settings-hook.mjs";
|
|
667
|
+
|
|
668
|
+
const HOOK_SCRIPT = "C:/fake/forgedock/bin/hooks/session-start.mjs";
|
|
669
|
+
|
|
670
|
+
let dir, settingsPath;
|
|
671
|
+
beforeEach(() => {
|
|
672
|
+
dir = mkdtempSync(join(os.tmpdir(), "fd-hook-"));
|
|
673
|
+
settingsPath = join(dir, "settings.json");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
describe("installSessionStartHook", () => {
|
|
677
|
+
it("creates settings.json with the hook when absent", () => {
|
|
678
|
+
const res = installSessionStartHook(settingsPath, HOOK_SCRIPT);
|
|
679
|
+
assert.equal(res.status, "installed");
|
|
680
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
681
|
+
const cmds = JSON.stringify(parsed.hooks.SessionStart);
|
|
682
|
+
assert.match(cmds, /session-start\.mjs/);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("is idempotent — second run reports already, no duplicate entry", () => {
|
|
686
|
+
installSessionStartHook(settingsPath, HOOK_SCRIPT);
|
|
687
|
+
const res = installSessionStartHook(settingsPath, HOOK_SCRIPT);
|
|
688
|
+
assert.equal(res.status, "already");
|
|
689
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
690
|
+
const matches = JSON.stringify(parsed).match(/session-start\.mjs/g);
|
|
691
|
+
assert.equal(matches.length, 1);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("preserves unrelated hooks and settings keys", () => {
|
|
695
|
+
writeFileSync(
|
|
696
|
+
settingsPath,
|
|
697
|
+
JSON.stringify({
|
|
698
|
+
model: "opus",
|
|
699
|
+
hooks: { SessionStart: [{ hooks: [{ type: "command", command: "echo hi" }] }], Stop: [] },
|
|
700
|
+
}),
|
|
701
|
+
"utf-8",
|
|
702
|
+
);
|
|
703
|
+
installSessionStartHook(settingsPath, HOOK_SCRIPT);
|
|
704
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
705
|
+
assert.equal(parsed.model, "opus");
|
|
706
|
+
assert.ok(Array.isArray(parsed.hooks.Stop));
|
|
707
|
+
assert.equal(parsed.hooks.SessionStart.length, 2); // existing + ours
|
|
708
|
+
assert.match(JSON.stringify(parsed.hooks.SessionStart[0]), /echo hi/);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("skips (never clobbers) malformed JSON", () => {
|
|
712
|
+
writeFileSync(settingsPath, "{ not json !!", "utf-8");
|
|
713
|
+
const res = installSessionStartHook(settingsPath, HOOK_SCRIPT);
|
|
714
|
+
assert.equal(res.status, "skipped-malformed");
|
|
715
|
+
assert.equal(readFileSync(settingsPath, "utf-8"), "{ not json !!");
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
describe("removeSessionStartHook", () => {
|
|
720
|
+
it("removes only our entry", () => {
|
|
721
|
+
writeFileSync(
|
|
722
|
+
settingsPath,
|
|
723
|
+
JSON.stringify({
|
|
724
|
+
hooks: { SessionStart: [{ hooks: [{ type: "command", command: "echo hi" }] }] },
|
|
725
|
+
}),
|
|
726
|
+
"utf-8",
|
|
727
|
+
);
|
|
728
|
+
installSessionStartHook(settingsPath, HOOK_SCRIPT);
|
|
729
|
+
const res = removeSessionStartHook(settingsPath);
|
|
730
|
+
assert.equal(res.status, "removed");
|
|
731
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
732
|
+
assert.equal(parsed.hooks.SessionStart.length, 1);
|
|
733
|
+
assert.match(JSON.stringify(parsed.hooks.SessionStart[0]), /echo hi/);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("reports absent when file or entry missing", () => {
|
|
737
|
+
assert.equal(removeSessionStartHook(settingsPath).status, "absent");
|
|
738
|
+
writeFileSync(settingsPath, JSON.stringify({ hooks: {} }), "utf-8");
|
|
739
|
+
assert.equal(removeSessionStartHook(settingsPath).status, "absent");
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
745
|
+
|
|
746
|
+
Run: `node --test bin/tests/settings-hook.test.mjs`
|
|
747
|
+
Expected: FAIL — module not found.
|
|
748
|
+
|
|
749
|
+
- [ ] **Step 3: Write the implementation**
|
|
750
|
+
|
|
751
|
+
Create `bin/settings-hook.mjs`:
|
|
752
|
+
|
|
753
|
+
```js
|
|
754
|
+
/**
|
|
755
|
+
* bin/settings-hook.mjs — Idempotent SessionStart hook registration in
|
|
756
|
+
* ~/.claude/settings.json.
|
|
757
|
+
*
|
|
758
|
+
* Contract (spec: "Hook wiring"):
|
|
759
|
+
* - Read-modify-write; unrelated keys and hooks preserved verbatim
|
|
760
|
+
* - Our entry identified by HOOK_MARKER in its command string
|
|
761
|
+
* - Malformed JSON → skip and report, NEVER overwrite the file
|
|
762
|
+
* - Absolute hook script path baked in at install time
|
|
763
|
+
*/
|
|
764
|
+
|
|
765
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "fs";
|
|
766
|
+
import { dirname } from "path";
|
|
767
|
+
|
|
768
|
+
/** Substring identifying ForgeDock's own hook entry. */
|
|
769
|
+
export const HOOK_MARKER = "session-start.mjs";
|
|
770
|
+
|
|
771
|
+
function isOurs(entry) {
|
|
772
|
+
return JSON.stringify(entry).includes(HOOK_MARKER);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function readSettings(settingsPath) {
|
|
776
|
+
if (!existsSync(settingsPath)) return { settings: {}, fresh: true };
|
|
777
|
+
try {
|
|
778
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
779
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
780
|
+
return { settings: parsed, fresh: false };
|
|
781
|
+
} catch {
|
|
782
|
+
return null; // malformed — caller must not write
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function writeSettings(settingsPath, settings) {
|
|
787
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
788
|
+
const tmp = settingsPath + ".tmp";
|
|
789
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
790
|
+
renameSync(tmp, settingsPath);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Merge ForgeDock's SessionStart hook into settings.json.
|
|
795
|
+
* @param {string} settingsPath - Absolute path to ~/.claude/settings.json
|
|
796
|
+
* @param {string} hookScriptPath - Absolute path to bin/hooks/session-start.mjs
|
|
797
|
+
* @returns {{ status: 'installed'|'already'|'skipped-malformed' }}
|
|
798
|
+
*/
|
|
799
|
+
export function installSessionStartHook(settingsPath, hookScriptPath) {
|
|
800
|
+
const read = readSettings(settingsPath);
|
|
801
|
+
if (read === null) return { status: "skipped-malformed" };
|
|
802
|
+
const { settings } = read;
|
|
803
|
+
|
|
804
|
+
settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
805
|
+
const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
|
|
806
|
+
if (list.some(isOurs)) return { status: "already" };
|
|
807
|
+
|
|
808
|
+
list.push({
|
|
809
|
+
hooks: [
|
|
810
|
+
{
|
|
811
|
+
type: "command",
|
|
812
|
+
command: `node "${hookScriptPath}"`,
|
|
813
|
+
},
|
|
814
|
+
],
|
|
815
|
+
});
|
|
816
|
+
settings.hooks.SessionStart = list;
|
|
817
|
+
writeSettings(settingsPath, settings);
|
|
818
|
+
return { status: "installed" };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Remove ForgeDock's SessionStart hook entry; leaves everything else alone.
|
|
823
|
+
* @returns {{ status: 'removed'|'absent'|'skipped-malformed' }}
|
|
824
|
+
*/
|
|
825
|
+
export function removeSessionStartHook(settingsPath) {
|
|
826
|
+
const read = readSettings(settingsPath);
|
|
827
|
+
if (read === null) return { status: "skipped-malformed" };
|
|
828
|
+
const { settings, fresh } = read;
|
|
829
|
+
if (fresh) return { status: "absent" };
|
|
830
|
+
|
|
831
|
+
const list = settings.hooks && Array.isArray(settings.hooks.SessionStart)
|
|
832
|
+
? settings.hooks.SessionStart
|
|
833
|
+
: [];
|
|
834
|
+
const kept = list.filter((e) => !isOurs(e));
|
|
835
|
+
if (kept.length === list.length) return { status: "absent" };
|
|
836
|
+
|
|
837
|
+
settings.hooks.SessionStart = kept;
|
|
838
|
+
writeSettings(settingsPath, settings);
|
|
839
|
+
return { status: "removed" };
|
|
840
|
+
}
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
844
|
+
|
|
845
|
+
Run: `node --test bin/tests/settings-hook.test.mjs` → PASS. `npm test` → green.
|
|
846
|
+
|
|
847
|
+
- [ ] **Step 5: Commit**
|
|
848
|
+
|
|
849
|
+
```bash
|
|
850
|
+
git add bin/settings-hook.mjs bin/tests/settings-hook.test.mjs
|
|
851
|
+
git commit -s -m "feat(hook): idempotent SessionStart merge into settings.json"
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
---
|
|
855
|
+
|
|
856
|
+
### Task 4: journey.mjs — forge.yaml writer + backup (single source of truth)
|
|
857
|
+
|
|
858
|
+
**Files:**
|
|
859
|
+
- Create: `bin/journey.mjs`
|
|
860
|
+
- Test: `bin/tests/journey.test.mjs`
|
|
861
|
+
|
|
862
|
+
**Interfaces:**
|
|
863
|
+
- Consumes: nothing yet (pure functions).
|
|
864
|
+
- Produces (used by Tasks 7–9):
|
|
865
|
+
- `writeForgeYaml(values, lowConfidenceKeys, outputPath) → { todoCount }` — `values` is exactly the object `annotatedReviewScreen` resolves with (`owner, repo, name, description, root, worktreeBase, defaultBranch, stagingBranch`); low-confidence fields get a trailing `# TODO(forgedock:<key>) — verify this value` comment.
|
|
866
|
+
- `backupExisting(outputPath) → { backupName } | null` — renames an existing file to `forge.yaml.bak` (or timestamped variant); returns null when nothing existed. Uses `path.basename` (fixes the current `split("/")` Windows bug in forgedock.mjs:485).
|
|
867
|
+
- `detectDescription(cwd) → { value: string, source: 'README.md'|'CLAUDE.md'|'' }` — first-paragraph extraction moved verbatim from forgedock.mjs:363-455 (README then CLAUDE.md fallback; strips links/bold/inline code; 200 chars).
|
|
868
|
+
|
|
869
|
+
- [ ] **Step 1: Write the failing tests**
|
|
870
|
+
|
|
871
|
+
Create `bin/tests/journey.test.mjs`:
|
|
872
|
+
|
|
873
|
+
```js
|
|
874
|
+
/**
|
|
875
|
+
* bin/tests/journey.test.mjs — Unit tests for bin/journey.mjs.
|
|
876
|
+
* Run with: node --test bin/tests/journey.test.mjs
|
|
877
|
+
*/
|
|
878
|
+
import { describe, it, beforeEach } from "node:test";
|
|
879
|
+
import assert from "node:assert/strict";
|
|
880
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
881
|
+
import { join } from "node:path";
|
|
882
|
+
import os from "node:os";
|
|
883
|
+
import { writeForgeYaml, backupExisting, detectDescription } from "../journey.mjs";
|
|
884
|
+
|
|
885
|
+
const VALUES = {
|
|
886
|
+
owner: "RapierCraftStudios",
|
|
887
|
+
repo: "ForgeDock",
|
|
888
|
+
name: "Forge Dock",
|
|
889
|
+
description: 'Turn a "GitHub issue" into a merged PR',
|
|
890
|
+
root: "C:\\proj\\ForgeDock",
|
|
891
|
+
worktreeBase: "C:\\proj\\ForgeDock\\.claude\\worktrees",
|
|
892
|
+
defaultBranch: "main",
|
|
893
|
+
stagingBranch: "staging",
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
let dir;
|
|
897
|
+
beforeEach(() => {
|
|
898
|
+
dir = mkdtempSync(join(os.tmpdir(), "fd-journey-"));
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
describe("writeForgeYaml", () => {
|
|
902
|
+
it("writes required sections with the given values", () => {
|
|
903
|
+
const out = join(dir, "forge.yaml");
|
|
904
|
+
const res = writeForgeYaml(VALUES, [], out);
|
|
905
|
+
const yaml = readFileSync(out, "utf-8");
|
|
906
|
+
assert.match(yaml, /owner: "RapierCraftStudios"/);
|
|
907
|
+
assert.match(yaml, /repo: "ForgeDock"/);
|
|
908
|
+
assert.match(yaml, /default: "main"/);
|
|
909
|
+
assert.match(yaml, /staging: "staging"/);
|
|
910
|
+
assert.equal(res.todoCount, 0);
|
|
911
|
+
});
|
|
912
|
+
it("escapes quotes and backslashes", () => {
|
|
913
|
+
const out = join(dir, "forge.yaml");
|
|
914
|
+
writeForgeYaml(VALUES, [], out);
|
|
915
|
+
const yaml = readFileSync(out, "utf-8");
|
|
916
|
+
assert.match(yaml, /Turn a \\"GitHub issue\\"/);
|
|
917
|
+
assert.match(yaml, /C:\\\\proj\\\\ForgeDock/);
|
|
918
|
+
});
|
|
919
|
+
it("flags low-confidence keys with TODO comments and counts them", () => {
|
|
920
|
+
const out = join(dir, "forge.yaml");
|
|
921
|
+
const res = writeForgeYaml(VALUES, ["owner", "stagingBranch"], out);
|
|
922
|
+
const yaml = readFileSync(out, "utf-8");
|
|
923
|
+
assert.match(yaml, /owner: "RapierCraftStudios"\s+# TODO\(forgedock:owner\)/);
|
|
924
|
+
assert.match(yaml, /staging: "staging"\s+# TODO\(forgedock:stagingBranch\)/);
|
|
925
|
+
assert.equal(res.todoCount, 2);
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
describe("backupExisting", () => {
|
|
930
|
+
it("returns null when the file does not exist", () => {
|
|
931
|
+
assert.equal(backupExisting(join(dir, "forge.yaml")), null);
|
|
932
|
+
});
|
|
933
|
+
it("renames to forge.yaml.bak and returns basename (no slash-split bug)", () => {
|
|
934
|
+
const out = join(dir, "forge.yaml");
|
|
935
|
+
writeFileSync(out, "x", "utf-8");
|
|
936
|
+
const res = backupExisting(out);
|
|
937
|
+
assert.equal(res.backupName, "forge.yaml.bak");
|
|
938
|
+
assert.ok(existsSync(join(dir, "forge.yaml.bak")));
|
|
939
|
+
assert.ok(!existsSync(out));
|
|
940
|
+
});
|
|
941
|
+
it("timestamps the backup when forge.yaml.bak already exists", () => {
|
|
942
|
+
writeFileSync(join(dir, "forge.yaml"), "x", "utf-8");
|
|
943
|
+
writeFileSync(join(dir, "forge.yaml.bak"), "old", "utf-8");
|
|
944
|
+
const res = backupExisting(join(dir, "forge.yaml"));
|
|
945
|
+
assert.match(res.backupName, /^forge\.yaml\.bak\..+/);
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
describe("detectDescription", () => {
|
|
950
|
+
it("extracts the first README paragraph, stripping markdown", () => {
|
|
951
|
+
writeFileSync(
|
|
952
|
+
join(dir, "README.md"),
|
|
953
|
+
"# Title\n\n**Turn** a [GitHub issue](https://x) into a `merged PR`.\n\nMore.\n",
|
|
954
|
+
"utf-8",
|
|
955
|
+
);
|
|
956
|
+
const res = detectDescription(dir);
|
|
957
|
+
assert.equal(res.value, "Turn a GitHub issue into a merged PR.");
|
|
958
|
+
assert.equal(res.source, "README.md");
|
|
959
|
+
});
|
|
960
|
+
it("falls back to CLAUDE.md, then empty", () => {
|
|
961
|
+
assert.deepEqual(detectDescription(dir), { value: "", source: "" });
|
|
962
|
+
writeFileSync(join(dir, "CLAUDE.md"), "# T\n\nProject brain.\n", "utf-8");
|
|
963
|
+
assert.deepEqual(detectDescription(dir), { value: "Project brain.", source: "CLAUDE.md" });
|
|
964
|
+
});
|
|
965
|
+
});
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
969
|
+
|
|
970
|
+
Run: `node --test bin/tests/journey.test.mjs`
|
|
971
|
+
Expected: FAIL — module not found.
|
|
972
|
+
|
|
973
|
+
- [ ] **Step 3: Write the implementation**
|
|
974
|
+
|
|
975
|
+
Create `bin/journey.mjs`:
|
|
976
|
+
|
|
977
|
+
```js
|
|
978
|
+
/**
|
|
979
|
+
* bin/journey.mjs — The five-act onboarding journey for ForgeDock.
|
|
980
|
+
*
|
|
981
|
+
* Acts (added across Tasks 4–8):
|
|
982
|
+
* preflight() → forge() → read() → review() → celebrate()
|
|
983
|
+
*
|
|
984
|
+
* This file owns forge.yaml generation (single source of truth, driven from
|
|
985
|
+
* detection values) and orchestrates the existing modules:
|
|
986
|
+
* init-detect.mjs, init-enrich-api.mjs, tui.annotatedReviewScreen, registry.mjs
|
|
987
|
+
*/
|
|
988
|
+
|
|
989
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
990
|
+
import { join, basename } from "path";
|
|
991
|
+
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
// forge.yaml generation (Task 4)
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
997
|
+
|
|
998
|
+
/** Append the TODO flag for a low-confidence field. */
|
|
999
|
+
function todo(key, low) {
|
|
1000
|
+
return low.includes(key) ? ` # TODO(forgedock:${key}) — verify this value` : "";
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Write forge.yaml from reviewed values.
|
|
1005
|
+
* @param {{owner:string,repo:string,name:string,description:string,root:string,
|
|
1006
|
+
* worktreeBase:string,defaultBranch:string,stagingBranch:string}} v
|
|
1007
|
+
* Exactly the object annotatedReviewScreen resolves with.
|
|
1008
|
+
* @param {string[]} lowConfidenceKeys - keys to flag with # TODO comments.
|
|
1009
|
+
* @param {string} outputPath
|
|
1010
|
+
* @returns {{ todoCount: number }}
|
|
1011
|
+
*/
|
|
1012
|
+
export function writeForgeYaml(v, lowConfidenceKeys, outputPath) {
|
|
1013
|
+
const low = lowConfidenceKeys;
|
|
1014
|
+
const content = `# forge.yaml — ForgeDock Configuration
|
|
1015
|
+
#
|
|
1016
|
+
# Auto-generated by: npx forgedock
|
|
1017
|
+
# Fields flagged # TODO(forgedock:<field>) were guessed — verify them.
|
|
1018
|
+
#
|
|
1019
|
+
# Required sections: project, paths, branches
|
|
1020
|
+
# Optional sections: repos, project_board, services, review, verification
|
|
1021
|
+
#
|
|
1022
|
+
# See docs/CONFIG.md for full reference.
|
|
1023
|
+
|
|
1024
|
+
# =============================================================================
|
|
1025
|
+
# PROJECT (REQUIRED)
|
|
1026
|
+
# =============================================================================
|
|
1027
|
+
|
|
1028
|
+
project:
|
|
1029
|
+
name: "${esc(v.name)}"${todo("name", low)}
|
|
1030
|
+
owner: "${esc(v.owner)}"${todo("owner", low)}
|
|
1031
|
+
repo: "${esc(v.repo)}"${todo("repo", low)}
|
|
1032
|
+
description: "${esc(v.description)}"${todo("description", low)}
|
|
1033
|
+
|
|
1034
|
+
# =============================================================================
|
|
1035
|
+
# PATHS (REQUIRED)
|
|
1036
|
+
# =============================================================================
|
|
1037
|
+
|
|
1038
|
+
paths:
|
|
1039
|
+
root: "${esc(v.root)}"${todo("root", low)}
|
|
1040
|
+
worktree_base: "${esc(v.worktreeBase)}"${todo("worktreeBase", low)}
|
|
1041
|
+
|
|
1042
|
+
# =============================================================================
|
|
1043
|
+
# BRANCHES (REQUIRED)
|
|
1044
|
+
# =============================================================================
|
|
1045
|
+
|
|
1046
|
+
branches:
|
|
1047
|
+
default: "${esc(v.defaultBranch)}"${todo("defaultBranch", low)}
|
|
1048
|
+
staging: "${esc(v.stagingBranch)}"${todo("stagingBranch", low)}
|
|
1049
|
+
feature_pattern: "milestone/{slug}"
|
|
1050
|
+
|
|
1051
|
+
# =============================================================================
|
|
1052
|
+
# REPOS (OPTIONAL) — multi-repo configuration. Remove the # to enable.
|
|
1053
|
+
# =============================================================================
|
|
1054
|
+
|
|
1055
|
+
# repos:
|
|
1056
|
+
# default:
|
|
1057
|
+
# repo: "${esc(v.owner)}/${esc(v.repo)}"
|
|
1058
|
+
# staging_branch: "${esc(v.stagingBranch)}"
|
|
1059
|
+
# satellites:
|
|
1060
|
+
# - prefix: "mcp"
|
|
1061
|
+
# repo: "${esc(v.owner)}/your-satellite-repo"
|
|
1062
|
+
# staging_branch: "main"
|
|
1063
|
+
|
|
1064
|
+
# =============================================================================
|
|
1065
|
+
# PROJECT BOARD (OPTIONAL) — GitHub Projects v2 integration.
|
|
1066
|
+
# To find IDs: gh project list --owner ${esc(v.owner)}
|
|
1067
|
+
# =============================================================================
|
|
1068
|
+
|
|
1069
|
+
# project_board:
|
|
1070
|
+
# owner: "${esc(v.owner)}"
|
|
1071
|
+
# project_number: 1
|
|
1072
|
+
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
1073
|
+
# field_ids:
|
|
1074
|
+
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
1075
|
+
|
|
1076
|
+
# =============================================================================
|
|
1077
|
+
# REVIEW (OPTIONAL) — context injected into review agent prompts.
|
|
1078
|
+
# =============================================================================
|
|
1079
|
+
|
|
1080
|
+
# review:
|
|
1081
|
+
# tech_stack: "Node.js, TypeScript, PostgreSQL"
|
|
1082
|
+
# context: |
|
|
1083
|
+
# Describe your repo structure and any unusual conventions here.
|
|
1084
|
+
|
|
1085
|
+
# =============================================================================
|
|
1086
|
+
# VERIFICATION (OPTIONAL) — health checks for quality gate / validate.
|
|
1087
|
+
# =============================================================================
|
|
1088
|
+
|
|
1089
|
+
# verification:
|
|
1090
|
+
# health_endpoint: "https://api.example.com/health"
|
|
1091
|
+
# health_patterns:
|
|
1092
|
+
# - '"status": "ok"'
|
|
1093
|
+
`;
|
|
1094
|
+
writeFileSync(outputPath, content, "utf-8");
|
|
1095
|
+
const todoCount = (content.match(/# TODO\(forgedock:/g) || []).length;
|
|
1096
|
+
return { todoCount };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Back up an existing file to <name>.bak (timestamped if .bak exists).
|
|
1101
|
+
* @returns {{ backupName: string } | null} null when the file didn't exist.
|
|
1102
|
+
*/
|
|
1103
|
+
export function backupExisting(outputPath) {
|
|
1104
|
+
if (!existsSync(outputPath)) return null;
|
|
1105
|
+
const baseBak = outputPath + ".bak";
|
|
1106
|
+
const backupPath = existsSync(baseBak)
|
|
1107
|
+
? `${baseBak}.${new Date().toISOString().replace(/[:.]/g, "-")}`
|
|
1108
|
+
: baseBak;
|
|
1109
|
+
renameSync(outputPath, backupPath);
|
|
1110
|
+
return { backupName: basename(backupPath) };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
// Description detection (moved from forgedock.mjs init(), Task 4)
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
function firstParagraph(content) {
|
|
1118
|
+
const lines = content.split("\n");
|
|
1119
|
+
let started = false;
|
|
1120
|
+
const out = [];
|
|
1121
|
+
for (const line of lines) {
|
|
1122
|
+
if (!started && line.match(/^#/)) continue;
|
|
1123
|
+
if (!started && line.trim() === "") continue;
|
|
1124
|
+
if (!started && line.match(/^[!<\[`|]/)) continue;
|
|
1125
|
+
if (!started && line.match(/^---/)) continue;
|
|
1126
|
+
if (!started) started = true;
|
|
1127
|
+
if (line.trim() === "") break;
|
|
1128
|
+
out.push(line.trim());
|
|
1129
|
+
}
|
|
1130
|
+
if (out.length === 0) return "";
|
|
1131
|
+
return out
|
|
1132
|
+
.join(" ")
|
|
1133
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
1134
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
1135
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
1136
|
+
.slice(0, 200)
|
|
1137
|
+
.trim();
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Detect a project description from README.md, falling back to CLAUDE.md.
|
|
1142
|
+
* @returns {{ value: string, source: 'README.md'|'CLAUDE.md'|'' }}
|
|
1143
|
+
*/
|
|
1144
|
+
export function detectDescription(cwd) {
|
|
1145
|
+
for (const file of ["README.md", "CLAUDE.md"]) {
|
|
1146
|
+
try {
|
|
1147
|
+
const p = join(cwd, file);
|
|
1148
|
+
if (!existsSync(p)) continue;
|
|
1149
|
+
const value = firstParagraph(readFileSync(p, "utf-8").slice(0, 2048));
|
|
1150
|
+
if (value) return { value, source: file };
|
|
1151
|
+
} catch {
|
|
1152
|
+
// best-effort only
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return { value: "", source: "" };
|
|
1156
|
+
}
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1160
|
+
|
|
1161
|
+
Run: `node --test bin/tests/journey.test.mjs` → PASS. `npm test` → green.
|
|
1162
|
+
|
|
1163
|
+
- [ ] **Step 5: Commit**
|
|
1164
|
+
|
|
1165
|
+
```bash
|
|
1166
|
+
git add bin/journey.mjs bin/tests/journey.test.mjs
|
|
1167
|
+
git commit -s -m "feat(journey): ConfigDraft-driven forge.yaml writer, backup, description detection"
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
---
|
|
1171
|
+
|
|
1172
|
+
### Task 5: journey.mjs — Act I `preflight()`
|
|
1173
|
+
|
|
1174
|
+
**Files:**
|
|
1175
|
+
- Modify: `bin/journey.mjs` (append)
|
|
1176
|
+
- Test: `bin/tests/journey.test.mjs` (append)
|
|
1177
|
+
|
|
1178
|
+
**Interfaces:**
|
|
1179
|
+
- Consumes: `revealRows`, `renderMark`, `ember`, `shimmer` from `bin/cinema.mjs`.
|
|
1180
|
+
- Produces (used by Task 8/9):
|
|
1181
|
+
- `makeCtx(overrides?) → ctx` — journey context: `{ cwd, home, forgeHome, argv, env, stdout, mode, motion, exec, startedAt }`. `exec(cmd, args) → string` wraps `execFileSync` (throws on failure); injectable for tests. `startedAt` set by the caller (router) via `Date.now()`.
|
|
1182
|
+
- `preflight(ctx) → Promise<{ checks: Array<{ name, ok, detail, fix? }>, ghReady: boolean }>` — renders Act I (hero mark + wordmark + reveal rows) and returns verdicts. Never throws; never exits.
|
|
1183
|
+
|
|
1184
|
+
- [ ] **Step 1: Write the failing tests**
|
|
1185
|
+
|
|
1186
|
+
Append to `bin/tests/journey.test.mjs`:
|
|
1187
|
+
|
|
1188
|
+
```js
|
|
1189
|
+
import { makeCtx, preflight } from "../journey.mjs";
|
|
1190
|
+
|
|
1191
|
+
/** Writer stub + exec stub factory for act tests. */
|
|
1192
|
+
function fakeWriter() {
|
|
1193
|
+
const chunks = [];
|
|
1194
|
+
return { chunks, write(s) { chunks.push(s); return true; }, get text() { return chunks.join(""); }, isTTY: false };
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function stubCtx({ execMap = {}, home = os.tmpdir(), cwd = os.tmpdir() } = {}) {
|
|
1198
|
+
const w = fakeWriter();
|
|
1199
|
+
return {
|
|
1200
|
+
ctx: makeCtx({
|
|
1201
|
+
cwd,
|
|
1202
|
+
home,
|
|
1203
|
+
forgeHome: "C:/fake/forgedock",
|
|
1204
|
+
argv: [],
|
|
1205
|
+
env: {},
|
|
1206
|
+
stdout: w,
|
|
1207
|
+
mode: "none",
|
|
1208
|
+
motion: false,
|
|
1209
|
+
startedAt: 0,
|
|
1210
|
+
exec: (cmd, args) => {
|
|
1211
|
+
const key = [cmd, ...(args || [])].join(" ");
|
|
1212
|
+
if (key in execMap) {
|
|
1213
|
+
const v = execMap[key];
|
|
1214
|
+
if (v instanceof Error) throw v;
|
|
1215
|
+
return v;
|
|
1216
|
+
}
|
|
1217
|
+
throw new Error(`ENOENT: ${key}`);
|
|
1218
|
+
},
|
|
1219
|
+
}),
|
|
1220
|
+
w,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
describe("preflight", () => {
|
|
1225
|
+
it("all green when everything is present", async () => {
|
|
1226
|
+
const home = mkdtempSync(join(os.tmpdir(), "fd-home-"));
|
|
1227
|
+
const { mkdirSync } = await import("node:fs");
|
|
1228
|
+
mkdirSync(join(home, ".claude"), { recursive: true });
|
|
1229
|
+
const { ctx, w } = stubCtx({
|
|
1230
|
+
home,
|
|
1231
|
+
execMap: {
|
|
1232
|
+
"git --version": "git version 2.45.0",
|
|
1233
|
+
"gh --version": "gh version 2.52.0",
|
|
1234
|
+
"gh auth status": "Logged in to github.com",
|
|
1235
|
+
},
|
|
1236
|
+
});
|
|
1237
|
+
const res = await preflight(ctx);
|
|
1238
|
+
assert.equal(res.checks.every((c) => c.ok), true);
|
|
1239
|
+
assert.equal(res.ghReady, true);
|
|
1240
|
+
assert.match(w.text, /F O R G E D O C K/);
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it("missing gh yields a fix card and ghReady=false, but does not throw", async () => {
|
|
1244
|
+
const home = mkdtempSync(join(os.tmpdir(), "fd-home2-"));
|
|
1245
|
+
const { mkdirSync } = await import("node:fs");
|
|
1246
|
+
mkdirSync(join(home, ".claude"), { recursive: true });
|
|
1247
|
+
const { ctx, w } = stubCtx({
|
|
1248
|
+
home,
|
|
1249
|
+
execMap: { "git --version": "git version 2.45.0" },
|
|
1250
|
+
});
|
|
1251
|
+
const res = await preflight(ctx);
|
|
1252
|
+
const gh = res.checks.find((c) => c.name === "GitHub CLI");
|
|
1253
|
+
assert.equal(gh.ok, false);
|
|
1254
|
+
assert.equal(res.ghReady, false);
|
|
1255
|
+
assert.match(w.text, /cli\.github\.com/); // fix card content
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it("missing ~/.claude flags Claude Code check", async () => {
|
|
1259
|
+
const home = mkdtempSync(join(os.tmpdir(), "fd-home3-"));
|
|
1260
|
+
const { ctx } = stubCtx({ home, execMap: { "git --version": "git version 2.45.0" } });
|
|
1261
|
+
const res = await preflight(ctx);
|
|
1262
|
+
const cc = res.checks.find((c) => c.name === "Claude Code");
|
|
1263
|
+
assert.equal(cc.ok, false);
|
|
1264
|
+
});
|
|
1265
|
+
});
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
1269
|
+
|
|
1270
|
+
Run: `node --test bin/tests/journey.test.mjs`
|
|
1271
|
+
Expected: FAIL — `makeCtx`/`preflight` not exported.
|
|
1272
|
+
|
|
1273
|
+
- [ ] **Step 3: Write the implementation**
|
|
1274
|
+
|
|
1275
|
+
Append to `bin/journey.mjs`:
|
|
1276
|
+
|
|
1277
|
+
```js
|
|
1278
|
+
// ---------------------------------------------------------------------------
|
|
1279
|
+
// Journey context (Task 5)
|
|
1280
|
+
// ---------------------------------------------------------------------------
|
|
1281
|
+
|
|
1282
|
+
import { execFileSync } from "child_process";
|
|
1283
|
+
import os from "os";
|
|
1284
|
+
import {
|
|
1285
|
+
renderMark, ember, shimmer, revealRows, moltenBar, fixCard,
|
|
1286
|
+
colorMode, motionEnabled, CHROME_STOPS, HERO_MARK, COMPACT_MARK, sleep,
|
|
1287
|
+
} from "./cinema.mjs";
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Build the shared journey context. Every act takes this as its first arg.
|
|
1291
|
+
* All process-touching values are injectable for tests.
|
|
1292
|
+
*/
|
|
1293
|
+
export function makeCtx(overrides = {}) {
|
|
1294
|
+
const env = overrides.env ?? process.env;
|
|
1295
|
+
const stdout = overrides.stdout ?? process.stdout;
|
|
1296
|
+
const argv = overrides.argv ?? process.argv.slice(2);
|
|
1297
|
+
return {
|
|
1298
|
+
cwd: process.cwd(),
|
|
1299
|
+
home: env.HOME || env.USERPROFILE || os.homedir(),
|
|
1300
|
+
forgeHome: "",
|
|
1301
|
+
argv,
|
|
1302
|
+
env,
|
|
1303
|
+
stdout,
|
|
1304
|
+
mode: colorMode(env, stdout),
|
|
1305
|
+
motion: motionEnabled(argv, env, stdout),
|
|
1306
|
+
exec: (cmd, args) =>
|
|
1307
|
+
execFileSync(cmd, args, {
|
|
1308
|
+
encoding: "utf-8",
|
|
1309
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1310
|
+
timeout: 10000,
|
|
1311
|
+
}).trim(),
|
|
1312
|
+
startedAt: Date.now(),
|
|
1313
|
+
...overrides,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ---------------------------------------------------------------------------
|
|
1318
|
+
// Act I — Ignition: hero mark + preflight checks (Task 5)
|
|
1319
|
+
// ---------------------------------------------------------------------------
|
|
1320
|
+
|
|
1321
|
+
const dimLine = (ctx, s) => (ctx.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Render the hero banner and run preflight checks. Failures render fix cards
|
|
1325
|
+
* and the journey continues — advisory, never fatal.
|
|
1326
|
+
* @returns {Promise<{ checks: Array<{name, ok, detail, fix?}>, ghReady: boolean }>}
|
|
1327
|
+
*/
|
|
1328
|
+
export async function preflight(ctx) {
|
|
1329
|
+
const { stdout: w } = ctx;
|
|
1330
|
+
w.write("\n");
|
|
1331
|
+
await shimmer(HERO_MARK, CHROME_STOPS, { mode: ctx.mode, motion: ctx.motion, writer: w });
|
|
1332
|
+
w.write("\n " + ember("F O R G E D O C K", ctx.mode) + "\n");
|
|
1333
|
+
w.write(" " + dimLine(ctx, "──── lighting the forge ────────────────────") + "\n\n");
|
|
1334
|
+
|
|
1335
|
+
const rows = [
|
|
1336
|
+
{
|
|
1337
|
+
label: "Node",
|
|
1338
|
+
run: async () => {
|
|
1339
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
1340
|
+
return major >= 18
|
|
1341
|
+
? { ok: true, detail: `v${process.versions.node}` }
|
|
1342
|
+
: { ok: false, detail: `v${process.versions.node} — need ≥18`, fix: ["Upgrade Node: https://nodejs.org/"] };
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
label: "git",
|
|
1347
|
+
run: async () => {
|
|
1348
|
+
try {
|
|
1349
|
+
const v = ctx.exec("git", ["--version"]);
|
|
1350
|
+
return { ok: true, detail: v.replace(/^git version\s*/, "") };
|
|
1351
|
+
} catch {
|
|
1352
|
+
return { ok: false, detail: "not found", fix: ["Install git: https://git-scm.com/downloads"] };
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
},
|
|
1356
|
+
{
|
|
1357
|
+
label: "Claude Code",
|
|
1358
|
+
run: async () => {
|
|
1359
|
+
const claudeDir = join(ctx.home, ".claude");
|
|
1360
|
+
return existsSync(claudeDir)
|
|
1361
|
+
? { ok: true, detail: "~/.claude found" }
|
|
1362
|
+
: { ok: false, detail: "~/.claude not found", fix: ["Install Claude Code: https://claude.com/claude-code"] };
|
|
1363
|
+
},
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
label: "GitHub CLI",
|
|
1367
|
+
run: async () => {
|
|
1368
|
+
try {
|
|
1369
|
+
ctx.exec("gh", ["--version"]);
|
|
1370
|
+
} catch {
|
|
1371
|
+
return {
|
|
1372
|
+
ok: false,
|
|
1373
|
+
detail: "not found",
|
|
1374
|
+
fix: ["Install gh: https://cli.github.com/", "Windows: winget install GitHub.cli"],
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
try {
|
|
1378
|
+
ctx.exec("gh", ["auth", "status"]);
|
|
1379
|
+
return { ok: true, detail: "authenticated" };
|
|
1380
|
+
} catch {
|
|
1381
|
+
return { ok: false, detail: "not authenticated", fix: ["Run: gh auth login"] };
|
|
1382
|
+
}
|
|
1383
|
+
},
|
|
1384
|
+
},
|
|
1385
|
+
];
|
|
1386
|
+
|
|
1387
|
+
// Map check names for the return contract (label ≠ name only for clarity).
|
|
1388
|
+
const results = await revealRows(rows, { mode: ctx.mode, motion: ctx.motion, writer: w });
|
|
1389
|
+
const checks = rows.map((r, i) => ({ name: r.label === "git" ? "git" : r.label, ...results[i] }));
|
|
1390
|
+
const named = [
|
|
1391
|
+
{ ...checks[0], name: "Node" },
|
|
1392
|
+
{ ...checks[1], name: "git" },
|
|
1393
|
+
{ ...checks[2], name: "Claude Code" },
|
|
1394
|
+
{ ...checks[3], name: "GitHub CLI" },
|
|
1395
|
+
];
|
|
1396
|
+
return { checks: named, ghReady: named[3].ok };
|
|
1397
|
+
}
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
Note: `join` and `existsSync` are already imported at the top of journey.mjs (Task 4).
|
|
1401
|
+
|
|
1402
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1403
|
+
|
|
1404
|
+
Run: `node --test bin/tests/journey.test.mjs` → PASS. `npm test` → green.
|
|
1405
|
+
|
|
1406
|
+
- [ ] **Step 5: Commit**
|
|
1407
|
+
|
|
1408
|
+
```bash
|
|
1409
|
+
git add bin/journey.mjs bin/tests/journey.test.mjs
|
|
1410
|
+
git commit -s -m "feat(journey): Act I preflight with fix cards and hero banner"
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
---
|
|
1414
|
+
|
|
1415
|
+
### Task 6: journey.mjs — Act II `forge()` (install + hook + registry)
|
|
1416
|
+
|
|
1417
|
+
**Files:**
|
|
1418
|
+
- Modify: `bin/journey.mjs` (append)
|
|
1419
|
+
- Test: `bin/tests/journey.test.mjs` (append)
|
|
1420
|
+
|
|
1421
|
+
**Interfaces:**
|
|
1422
|
+
- Consumes: `installSessionStartHook` (Task 3), `moltenBar`/`ember` (Task 2). The symlink loop and `findMarkdownFiles` move here **verbatim in behavior** from `bin/forgedock.mjs:63-135` (regular files skipped with warning, changed links updated via `.tmp` rename, per-file errors other than ENOENT rethrown).
|
|
1423
|
+
- Produces (used by Tasks 8–9):
|
|
1424
|
+
- `findMarkdownFiles(dir) → Promise<string[]>` (sorted, recursive)
|
|
1425
|
+
- `forge(ctx) → Promise<{ installed, updated, skipped, total, hookStatus }>` — installs command symlinks from `join(ctx.forgeHome, "commands")` into `join(ctx.home, ".claude", "commands")`, redrawing a molten progress line per file when motion is on (plain per-file lines when off), then merges the SessionStart hook (`join(ctx.forgeHome, "bin", "hooks", "session-start.mjs")` into `join(ctx.home, ".claude", "settings.json")`).
|
|
1426
|
+
|
|
1427
|
+
- [ ] **Step 1: Write the failing tests**
|
|
1428
|
+
|
|
1429
|
+
Append to `bin/tests/journey.test.mjs`:
|
|
1430
|
+
|
|
1431
|
+
```js
|
|
1432
|
+
import { forge } from "../journey.mjs";
|
|
1433
|
+
import { lstatSync, readlinkSync, mkdirSync as mkdirSyncFs } from "node:fs";
|
|
1434
|
+
|
|
1435
|
+
describe("forge (Act II)", () => {
|
|
1436
|
+
it("symlinks commands, registers hook, reports counts", async () => {
|
|
1437
|
+
const home = mkdtempSync(join(os.tmpdir(), "fd-forge-home-"));
|
|
1438
|
+
const forgeHome = mkdtempSync(join(os.tmpdir(), "fd-forge-src-"));
|
|
1439
|
+
mkdirSyncFs(join(forgeHome, "commands", "sub"), { recursive: true });
|
|
1440
|
+
mkdirSyncFs(join(forgeHome, "bin", "hooks"), { recursive: true });
|
|
1441
|
+
writeFileSync(join(forgeHome, "commands", "a.md"), "A", "utf-8");
|
|
1442
|
+
writeFileSync(join(forgeHome, "commands", "sub", "b.md"), "B", "utf-8");
|
|
1443
|
+
writeFileSync(join(forgeHome, "bin", "hooks", "session-start.mjs"), "// hook", "utf-8");
|
|
1444
|
+
|
|
1445
|
+
const { ctx, w } = stubCtx({ home });
|
|
1446
|
+
ctx.forgeHome = forgeHome;
|
|
1447
|
+
const res = await forge(ctx);
|
|
1448
|
+
|
|
1449
|
+
assert.equal(res.installed, 2);
|
|
1450
|
+
assert.equal(res.total, 2);
|
|
1451
|
+
assert.equal(res.hookStatus, "installed");
|
|
1452
|
+
const link = join(home, ".claude", "commands", "a.md");
|
|
1453
|
+
assert.ok(lstatSync(link).isSymbolicLink());
|
|
1454
|
+
assert.equal(readlinkSync(link), join(forgeHome, "commands", "a.md"));
|
|
1455
|
+
const settings = JSON.parse(readFileSync(join(home, ".claude", "settings.json"), "utf-8"));
|
|
1456
|
+
assert.match(JSON.stringify(settings.hooks.SessionStart), /session-start\.mjs/);
|
|
1457
|
+
assert.match(w.text, /2.*commands|commands.*2/i);
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it("second run is idempotent: skips links, hook already", async () => {
|
|
1461
|
+
const home = mkdtempSync(join(os.tmpdir(), "fd-forge-home2-"));
|
|
1462
|
+
const forgeHome = mkdtempSync(join(os.tmpdir(), "fd-forge-src2-"));
|
|
1463
|
+
mkdirSyncFs(join(forgeHome, "commands"), { recursive: true });
|
|
1464
|
+
mkdirSyncFs(join(forgeHome, "bin", "hooks"), { recursive: true });
|
|
1465
|
+
writeFileSync(join(forgeHome, "commands", "a.md"), "A", "utf-8");
|
|
1466
|
+
writeFileSync(join(forgeHome, "bin", "hooks", "session-start.mjs"), "// hook", "utf-8");
|
|
1467
|
+
|
|
1468
|
+
const { ctx } = stubCtx({ home });
|
|
1469
|
+
ctx.forgeHome = forgeHome;
|
|
1470
|
+
await forge(ctx);
|
|
1471
|
+
const res2 = await forge(makeCtx({ ...ctx }));
|
|
1472
|
+
assert.equal(res2.installed, 0);
|
|
1473
|
+
assert.equal(res2.skipped, 1);
|
|
1474
|
+
assert.equal(res2.hookStatus, "already");
|
|
1475
|
+
});
|
|
1476
|
+
});
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
(Windows note: creating file symlinks requires Developer Mode or admin. If the symlink call fails with `EPERM` in CI, the test environment must enable Developer Mode — same requirement the existing `install()` already has. Do not code around it.)
|
|
1480
|
+
|
|
1481
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
1482
|
+
|
|
1483
|
+
Run: `node --test bin/tests/journey.test.mjs`
|
|
1484
|
+
Expected: FAIL — `forge` not exported.
|
|
1485
|
+
|
|
1486
|
+
- [ ] **Step 3: Write the implementation**
|
|
1487
|
+
|
|
1488
|
+
Append to `bin/journey.mjs`:
|
|
1489
|
+
|
|
1490
|
+
```js
|
|
1491
|
+
// ---------------------------------------------------------------------------
|
|
1492
|
+
// Act II — Forging: command symlinks + SessionStart hook (Task 6)
|
|
1493
|
+
// ---------------------------------------------------------------------------
|
|
1494
|
+
|
|
1495
|
+
import { mkdir, symlink, readlink, lstat, readdir, rename, unlink } from "fs/promises";
|
|
1496
|
+
import { relative, dirname as pathDirname } from "path";
|
|
1497
|
+
import { installSessionStartHook } from "./settings-hook.mjs";
|
|
1498
|
+
|
|
1499
|
+
/** Recursively find .md files, sorted (moved from forgedock.mjs). */
|
|
1500
|
+
export async function findMarkdownFiles(dir) {
|
|
1501
|
+
const results = [];
|
|
1502
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1503
|
+
for (const entry of entries) {
|
|
1504
|
+
const full = join(dir, entry.name);
|
|
1505
|
+
if (entry.isDirectory()) {
|
|
1506
|
+
results.push(...(await findMarkdownFiles(full)));
|
|
1507
|
+
} else if (entry.name.endsWith(".md")) {
|
|
1508
|
+
results.push(full);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return results.sort();
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Act II: link commands into ~/.claude/commands with a molten progress line,
|
|
1516
|
+
* then register the SessionStart hook.
|
|
1517
|
+
* Symlink semantics preserved verbatim from the original install():
|
|
1518
|
+
* regular files are skipped with a warning; changed links updated atomically.
|
|
1519
|
+
*/
|
|
1520
|
+
export async function forge(ctx) {
|
|
1521
|
+
const { stdout: w } = ctx;
|
|
1522
|
+
const commandsDir = join(ctx.forgeHome, "commands");
|
|
1523
|
+
const targetDir = join(ctx.home, ".claude", "commands");
|
|
1524
|
+
|
|
1525
|
+
w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
|
|
1526
|
+
await mkdir(targetDir, { recursive: true });
|
|
1527
|
+
|
|
1528
|
+
const files = await findMarkdownFiles(commandsDir);
|
|
1529
|
+
let installed = 0, updated = 0, skipped = 0;
|
|
1530
|
+
const barWidth = 24;
|
|
1531
|
+
let barShown = false;
|
|
1532
|
+
|
|
1533
|
+
for (let i = 0; i < files.length; i++) {
|
|
1534
|
+
const file = files[i];
|
|
1535
|
+
const rel = relative(commandsDir, file);
|
|
1536
|
+
const target = join(targetDir, rel);
|
|
1537
|
+
await mkdir(pathDirname(target), { recursive: true });
|
|
1538
|
+
|
|
1539
|
+
try {
|
|
1540
|
+
const stats = await lstat(target);
|
|
1541
|
+
if (stats.isSymbolicLink()) {
|
|
1542
|
+
const current = await readlink(target);
|
|
1543
|
+
if (current === file) {
|
|
1544
|
+
skipped++;
|
|
1545
|
+
} else {
|
|
1546
|
+
await symlink(file, target + ".tmp");
|
|
1547
|
+
await rename(target + ".tmp", target);
|
|
1548
|
+
updated++;
|
|
1549
|
+
}
|
|
1550
|
+
} else {
|
|
1551
|
+
if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
|
|
1552
|
+
w.write(` WARNING: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)\n`);
|
|
1553
|
+
skipped++;
|
|
1554
|
+
}
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
if (err.code !== "ENOENT") throw err;
|
|
1557
|
+
await symlink(file, target);
|
|
1558
|
+
installed++;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (ctx.motion) {
|
|
1562
|
+
if (barShown) w.write("\x1b[1A\x1b[2K");
|
|
1563
|
+
w.write(` ${moltenBar(i + 1, files.length, { width: barWidth, mode: ctx.mode })} ${i + 1}/${files.length} ${dimLine(ctx, "/" + rel.replace(/\.md$/, ""))}\n`);
|
|
1564
|
+
barShown = true;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
if (barShown) w.write("\x1b[1A\x1b[2K");
|
|
1568
|
+
|
|
1569
|
+
const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
|
|
1570
|
+
const settingsPath = join(ctx.home, ".claude", "settings.json");
|
|
1571
|
+
const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
|
|
1572
|
+
|
|
1573
|
+
const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
|
|
1574
|
+
w.write(` ${glyph(true)} ${files.length} slash commands linked ${dimLine(ctx, `(new ${installed}, updated ${updated}, unchanged ${skipped})`)}\n`);
|
|
1575
|
+
if (hookStatus === "skipped-malformed") {
|
|
1576
|
+
w.write(` ${glyph(false)} SessionStart hook NOT registered — ${settingsPath} is not valid JSON\n`);
|
|
1577
|
+
w.write(fixCard([`Fix the JSON in ${settingsPath}, then re-run: npx forgedock install`], ctx.mode) + "\n");
|
|
1578
|
+
} else {
|
|
1579
|
+
w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
return { installed, updated, skipped, total: files.length, hookStatus };
|
|
1583
|
+
}
|
|
1584
|
+
```
|
|
1585
|
+
|
|
1586
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1587
|
+
|
|
1588
|
+
Run: `node --test bin/tests/journey.test.mjs` → PASS. `npm test` → green.
|
|
1589
|
+
|
|
1590
|
+
- [ ] **Step 5: Commit**
|
|
1591
|
+
|
|
1592
|
+
```bash
|
|
1593
|
+
git add bin/journey.mjs bin/tests/journey.test.mjs
|
|
1594
|
+
git commit -s -m "feat(journey): Act II forge — symlinks with molten progress, hook registration"
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
---
|
|
1598
|
+
|
|
1599
|
+
### Task 7: annotatedReviewScreen `extraFields` + prompt copy
|
|
1600
|
+
|
|
1601
|
+
**Files:**
|
|
1602
|
+
- Modify: `bin/tui.mjs` (annotatedReviewScreen, around lines 835-870 and the prompt near line 987)
|
|
1603
|
+
- Test: `bin/tests/tui.test.mjs` (append)
|
|
1604
|
+
|
|
1605
|
+
**Interfaces:**
|
|
1606
|
+
- Consumes: existing `annotatedReviewScreen(draft, opts)`.
|
|
1607
|
+
- Produces: `opts.extraFields` — `{ description?: { value, confidence, source, why } }`. When `REVIEW_FIELDS` entry has `draftPath: null` and `extraFields[key]` exists, that field object seeds the row (instead of the empty low-confidence placeholder). Prompt copy becomes: `Press Enter to forge, or a number to edit a field`.
|
|
1608
|
+
|
|
1609
|
+
- [ ] **Step 1: Write the failing test**
|
|
1610
|
+
|
|
1611
|
+
Append to `bin/tests/tui.test.mjs` (follow that file's existing import style):
|
|
1612
|
+
|
|
1613
|
+
```js
|
|
1614
|
+
import { annotatedReviewScreen } from "../tui.mjs";
|
|
1615
|
+
|
|
1616
|
+
describe("annotatedReviewScreen extraFields", () => {
|
|
1617
|
+
it("non-TTY: description from extraFields is returned; medium conf not flagged low", async () => {
|
|
1618
|
+
// process.stdin.isTTY is false under node --test, so this exercises the non-TTY path.
|
|
1619
|
+
const draft = {
|
|
1620
|
+
project: {
|
|
1621
|
+
owner: { value: "o", confidence: "high", source: "s", why: "w" },
|
|
1622
|
+
repo: { value: "r", confidence: "high", source: "s", why: "w" },
|
|
1623
|
+
name: { value: "n", confidence: "medium", source: "s", why: "w" },
|
|
1624
|
+
},
|
|
1625
|
+
paths: {
|
|
1626
|
+
root: { value: "/p", confidence: "high", source: "s", why: "w" },
|
|
1627
|
+
worktreeBase: { value: "/p/w", confidence: "high", source: "s", why: "w" },
|
|
1628
|
+
},
|
|
1629
|
+
branches: {
|
|
1630
|
+
default: { value: "main", confidence: "high", source: "s", why: "w" },
|
|
1631
|
+
staging: { value: "main", confidence: "medium", source: "s", why: "w" },
|
|
1632
|
+
},
|
|
1633
|
+
meta: { remoteDetected: true },
|
|
1634
|
+
};
|
|
1635
|
+
const res = await annotatedReviewScreen(draft, {
|
|
1636
|
+
extraFields: {
|
|
1637
|
+
description: { value: "From README", confidence: "medium", source: "README.md", why: "First paragraph" },
|
|
1638
|
+
},
|
|
1639
|
+
});
|
|
1640
|
+
assert.equal(res.description, "From README");
|
|
1641
|
+
assert.ok(!res.lowConfidenceKeys.includes("description"));
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it("non-TTY without extraFields: description stays empty and low", async () => {
|
|
1645
|
+
const draft = {
|
|
1646
|
+
project: { owner: { value: "o", confidence: "high", source: "s", why: "w" },
|
|
1647
|
+
repo: { value: "r", confidence: "high", source: "s", why: "w" },
|
|
1648
|
+
name: { value: "n", confidence: "medium", source: "s", why: "w" } },
|
|
1649
|
+
paths: { root: { value: "/p", confidence: "high", source: "s", why: "w" },
|
|
1650
|
+
worktreeBase: { value: "/p/w", confidence: "high", source: "s", why: "w" } },
|
|
1651
|
+
branches: { default: { value: "main", confidence: "high", source: "s", why: "w" },
|
|
1652
|
+
staging: { value: "main", confidence: "medium", source: "s", why: "w" } },
|
|
1653
|
+
meta: { remoteDetected: true },
|
|
1654
|
+
};
|
|
1655
|
+
const res = await annotatedReviewScreen(draft, {});
|
|
1656
|
+
assert.equal(res.description, "");
|
|
1657
|
+
assert.ok(res.lowConfidenceKeys.includes("description"));
|
|
1658
|
+
});
|
|
1659
|
+
});
|
|
1660
|
+
```
|
|
1661
|
+
|
|
1662
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1663
|
+
|
|
1664
|
+
Run: `node --test bin/tests/tui.test.mjs`
|
|
1665
|
+
Expected: FAIL — `res.description` is `""` in the first test (extraFields ignored).
|
|
1666
|
+
|
|
1667
|
+
- [ ] **Step 3: Implement**
|
|
1668
|
+
|
|
1669
|
+
In `bin/tui.mjs`, change the `annotatedReviewScreen` signature (line ~835):
|
|
1670
|
+
|
|
1671
|
+
```js
|
|
1672
|
+
export async function annotatedReviewScreen(
|
|
1673
|
+
draft,
|
|
1674
|
+
{ hasExistingConfig = false, existingContent = "", showSources = false, extraFields = {} } = {},
|
|
1675
|
+
) {
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
And change `getField` + the seeding loop so `extraFields` wins for null-draftPath fields. Replace the loop body (lines ~861-867):
|
|
1679
|
+
|
|
1680
|
+
```js
|
|
1681
|
+
for (const fd of REVIEW_FIELDS) {
|
|
1682
|
+
const field =
|
|
1683
|
+
fd.draftPath === null && extraFields[fd.key]
|
|
1684
|
+
? extraFields[fd.key]
|
|
1685
|
+
: getField(fd.draftPath);
|
|
1686
|
+
values[fd.key] = field.value;
|
|
1687
|
+
confidences[fd.key] = field.confidence;
|
|
1688
|
+
sources[fd.key] = field.source;
|
|
1689
|
+
whys[fd.key] = field.why;
|
|
1690
|
+
}
|
|
1691
|
+
```
|
|
1692
|
+
|
|
1693
|
+
Then update the accept prompt (search for the string `Press Enter to accept all values` around line 987) to:
|
|
1694
|
+
|
|
1695
|
+
```js
|
|
1696
|
+
`Press Enter to forge, or a number to edit a field`
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
(keep the exact surrounding styling calls unchanged).
|
|
1700
|
+
|
|
1701
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1702
|
+
|
|
1703
|
+
Run: `node --test bin/tests/tui.test.mjs` → PASS. `npm test` → green.
|
|
1704
|
+
|
|
1705
|
+
- [ ] **Step 5: Commit**
|
|
1706
|
+
|
|
1707
|
+
```bash
|
|
1708
|
+
git add bin/tui.mjs bin/tests/tui.test.mjs
|
|
1709
|
+
git commit -s -m "feat(tui): annotatedReviewScreen extraFields + forge prompt copy"
|
|
1710
|
+
```
|
|
1711
|
+
|
|
1712
|
+
---
|
|
1713
|
+
|
|
1714
|
+
### Task 8: journey.mjs — Acts III–V (`read`, `review`, `celebrate`) + `runJourney`
|
|
1715
|
+
|
|
1716
|
+
**Files:**
|
|
1717
|
+
- Modify: `bin/journey.mjs` (append)
|
|
1718
|
+
- Test: `bin/tests/journey.test.mjs` (append)
|
|
1719
|
+
|
|
1720
|
+
**Interfaces:**
|
|
1721
|
+
- Consumes: `detectConfig` (init-detect.mjs), `enrich` (init-enrich-api.mjs), `annotatedReviewScreen` + `extraFields` (Task 7), `writeForgeYaml`/`backupExisting`/`detectDescription` (Task 4), `revealRows`/`renderMark`/`ember`/`box` (cinema/tui).
|
|
1722
|
+
- Produces (used by Task 9):
|
|
1723
|
+
- `read(ctx) → Promise<{ draft, description: {value, source} }>` — Act III: runs `detectConfig(ctx.cwd)` + `detectDescription(ctx.cwd)`, renders per-field reveal rows with confidence badges, runs `enrich(draft)` when `ctx.env.ANTHROPIC_API_KEY` is set (guarded try/catch; failure prints one dim line and continues).
|
|
1724
|
+
- `review(ctx, draft, description) → Promise<{ written: boolean, todoCount: number, backupName: string|null, aborted: boolean }>` — Act IV: existing forge.yaml + non-TTY → abort (protect config, `written:false, aborted:true`); otherwise `annotatedReviewScreen` → `backupExisting` → `writeForgeYaml`.
|
|
1725
|
+
- `celebrate(ctx, summary) → void` — Act V: compact mark, `Forged.` + elapsed seconds, receipt lines, next-steps box.
|
|
1726
|
+
- `runJourney(ctx) → Promise<number>` — Acts I→V in order; returns process exit code (0 unless review aborted → 1). SIGINT handler: restores cursor, prints partial-state line, exits 130.
|
|
1727
|
+
|
|
1728
|
+
- [ ] **Step 1: Write the failing tests**
|
|
1729
|
+
|
|
1730
|
+
Append to `bin/tests/journey.test.mjs`:
|
|
1731
|
+
|
|
1732
|
+
```js
|
|
1733
|
+
import { read, review, celebrate } from "../journey.mjs";
|
|
1734
|
+
|
|
1735
|
+
describe("read (Act III)", () => {
|
|
1736
|
+
it("returns a draft + description without a git repo (placeholders, low confidence)", async () => {
|
|
1737
|
+
const cwd = mkdtempSync(join(os.tmpdir(), "fd-read-"));
|
|
1738
|
+
writeFileSync(join(cwd, "README.md"), "# X\n\nA test project.\n", "utf-8");
|
|
1739
|
+
const { ctx, w } = stubCtx({ cwd });
|
|
1740
|
+
const res = await read(ctx);
|
|
1741
|
+
assert.equal(res.draft.project.owner.value, "your-github-org");
|
|
1742
|
+
assert.equal(res.description.value, "A test project.");
|
|
1743
|
+
assert.match(w.text, /\[low\]/); // badge rendered for placeholder
|
|
1744
|
+
});
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
describe("review (Act IV)", () => {
|
|
1748
|
+
it("non-TTY + no existing config: writes forge.yaml with TODO flags for low fields", async () => {
|
|
1749
|
+
const cwd = mkdtempSync(join(os.tmpdir(), "fd-review-"));
|
|
1750
|
+
const { ctx } = stubCtx({ cwd });
|
|
1751
|
+
const res0 = await read(ctx);
|
|
1752
|
+
const res = await review(ctx, res0.draft, res0.description);
|
|
1753
|
+
assert.equal(res.written, true);
|
|
1754
|
+
assert.equal(res.aborted, false);
|
|
1755
|
+
const yaml = readFileSync(join(cwd, "forge.yaml"), "utf-8");
|
|
1756
|
+
assert.match(yaml, /# TODO\(forgedock:owner\)/);
|
|
1757
|
+
assert.ok(res.todoCount >= 1);
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
it("non-TTY + existing config: aborts and leaves the file untouched", async () => {
|
|
1761
|
+
const cwd = mkdtempSync(join(os.tmpdir(), "fd-review2-"));
|
|
1762
|
+
writeFileSync(join(cwd, "forge.yaml"), "precious: true\n", "utf-8");
|
|
1763
|
+
const { ctx } = stubCtx({ cwd });
|
|
1764
|
+
const res0 = await read(ctx);
|
|
1765
|
+
const res = await review(ctx, res0.draft, res0.description);
|
|
1766
|
+
assert.equal(res.aborted, true);
|
|
1767
|
+
assert.equal(res.written, false);
|
|
1768
|
+
assert.equal(readFileSync(join(cwd, "forge.yaml"), "utf-8"), "precious: true\n");
|
|
1769
|
+
});
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
describe("celebrate (Act V)", () => {
|
|
1773
|
+
it("prints elapsed time, receipt, and next steps", () => {
|
|
1774
|
+
const { ctx, w } = stubCtx({});
|
|
1775
|
+
ctx.startedAt = Date.now() - 34000;
|
|
1776
|
+
celebrate(ctx, { written: true, todoCount: 2, total: 24, hookStatus: "installed" });
|
|
1777
|
+
assert.match(w.text, /Forged\./);
|
|
1778
|
+
assert.match(w.text, /34s|3[0-9]s/);
|
|
1779
|
+
assert.match(w.text, /work-on next/);
|
|
1780
|
+
assert.match(w.text, /2/); // TODO count surfaces in the receipt
|
|
1781
|
+
});
|
|
1782
|
+
});
|
|
1783
|
+
```
|
|
1784
|
+
|
|
1785
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
1786
|
+
|
|
1787
|
+
Run: `node --test bin/tests/journey.test.mjs`
|
|
1788
|
+
Expected: FAIL — `read`/`review`/`celebrate` not exported.
|
|
1789
|
+
|
|
1790
|
+
- [ ] **Step 3: Write the implementation**
|
|
1791
|
+
|
|
1792
|
+
Append to `bin/journey.mjs`:
|
|
1793
|
+
|
|
1794
|
+
```js
|
|
1795
|
+
// ---------------------------------------------------------------------------
|
|
1796
|
+
// Act III — Reading your repository (Task 8)
|
|
1797
|
+
// ---------------------------------------------------------------------------
|
|
1798
|
+
|
|
1799
|
+
import { detectConfig } from "./init-detect.mjs";
|
|
1800
|
+
import { enrich } from "./init-enrich-api.mjs";
|
|
1801
|
+
import { annotatedReviewScreen, box } from "./tui.mjs";
|
|
1802
|
+
|
|
1803
|
+
const badgeOf = (field) => field.confidence;
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* Act III: detect the repo, show each field as a reveal row with its
|
|
1807
|
+
* confidence badge, optionally AI-enrich. Detection runs first (it is fast);
|
|
1808
|
+
* rows are display pacing, not fake latency.
|
|
1809
|
+
*/
|
|
1810
|
+
export async function read(ctx) {
|
|
1811
|
+
const { stdout: w } = ctx;
|
|
1812
|
+
w.write("\n " + ember("Reading your repository", ctx.mode) + "\n\n");
|
|
1813
|
+
|
|
1814
|
+
const draft = await detectConfig(ctx.cwd);
|
|
1815
|
+
const description = detectDescription(ctx.cwd);
|
|
1816
|
+
|
|
1817
|
+
const rows = [
|
|
1818
|
+
{ label: "owner/repo", run: async () => ({ ok: true, detail: `${draft.project.owner.value}/${draft.project.repo.value}`, badge: badgeOf(draft.project.owner) }) },
|
|
1819
|
+
{ label: "default branch", run: async () => ({ ok: true, detail: draft.branches.default.value, badge: badgeOf(draft.branches.default) }) },
|
|
1820
|
+
{ label: "staging branch", run: async () => ({ ok: true, detail: draft.branches.staging.value, badge: badgeOf(draft.branches.staging) }) },
|
|
1821
|
+
{ label: "project name", run: async () => ({ ok: true, detail: draft.project.name.value, badge: badgeOf(draft.project.name) }) },
|
|
1822
|
+
{
|
|
1823
|
+
label: "description",
|
|
1824
|
+
run: async () => description.value
|
|
1825
|
+
? { ok: true, detail: `"${description.value.slice(0, 40)}${description.value.length > 40 ? "…" : ""}"`, badge: "medium" }
|
|
1826
|
+
: { ok: true, detail: "none found", badge: "low" },
|
|
1827
|
+
},
|
|
1828
|
+
];
|
|
1829
|
+
await revealRows(rows, { mode: ctx.mode, motion: ctx.motion, writer: w });
|
|
1830
|
+
|
|
1831
|
+
if (ctx.env.ANTHROPIC_API_KEY) {
|
|
1832
|
+
try {
|
|
1833
|
+
w.write(" " + dimLine(ctx, "✦ enriching with AI…") + "\n");
|
|
1834
|
+
await enrich(draft);
|
|
1835
|
+
} catch {
|
|
1836
|
+
w.write(" " + dimLine(ctx, "✦ AI enrichment unavailable — continuing with detection only") + "\n");
|
|
1837
|
+
}
|
|
1838
|
+
} else {
|
|
1839
|
+
w.write(" " + dimLine(ctx, "✦ no ANTHROPIC_API_KEY — skipping AI enrichment") + "\n");
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
return { draft, description };
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// ---------------------------------------------------------------------------
|
|
1846
|
+
// Act IV — The Review (Task 8)
|
|
1847
|
+
// ---------------------------------------------------------------------------
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* Act IV: the single interaction. Non-TTY + existing config aborts to protect
|
|
1851
|
+
* the file; non-TTY + fresh config writes detection values with TODO flags
|
|
1852
|
+
* (annotatedReviewScreen's non-TTY path returns them directly).
|
|
1853
|
+
*/
|
|
1854
|
+
export async function review(ctx, draft, description) {
|
|
1855
|
+
const { stdout: w } = ctx;
|
|
1856
|
+
const outputPath = join(ctx.cwd, "forge.yaml");
|
|
1857
|
+
const hasExisting = existsSync(outputPath);
|
|
1858
|
+
|
|
1859
|
+
if (hasExisting && process.stdin.isTTY !== true) {
|
|
1860
|
+
w.write("\n forge.yaml already exists — non-interactive run, aborting to protect it.\n");
|
|
1861
|
+
w.write(" " + dimLine(ctx, "Run interactively (or delete forge.yaml) to regenerate.") + "\n");
|
|
1862
|
+
return { written: false, todoCount: 0, backupName: null, aborted: true };
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
const extraFields = description.value
|
|
1866
|
+
? { description: { value: description.value, confidence: "medium", source: description.source, why: `First paragraph of ${description.source}` } }
|
|
1867
|
+
: {};
|
|
1868
|
+
|
|
1869
|
+
const accepted = await annotatedReviewScreen(draft, {
|
|
1870
|
+
hasExistingConfig: hasExisting,
|
|
1871
|
+
showSources: ctx.argv.includes("--verbose"),
|
|
1872
|
+
extraFields,
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
const backup = backupExisting(outputPath);
|
|
1876
|
+
if (backup) w.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
1877
|
+
|
|
1878
|
+
const { todoCount } = writeForgeYaml(accepted, accepted.lowConfidenceKeys, outputPath);
|
|
1879
|
+
return { written: true, todoCount, backupName: backup ? backup.backupName : null, aborted: false };
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// ---------------------------------------------------------------------------
|
|
1883
|
+
// Act V — Forged (Task 8)
|
|
1884
|
+
// ---------------------------------------------------------------------------
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* Act V: quench flash on the compact mark, receipt with real elapsed time,
|
|
1888
|
+
* next-steps box.
|
|
1889
|
+
*/
|
|
1890
|
+
export function celebrate(ctx, summary) {
|
|
1891
|
+
const { stdout: w } = ctx;
|
|
1892
|
+
const elapsed = Math.round((Date.now() - ctx.startedAt) / 1000);
|
|
1893
|
+
const mark = renderMark("compact", ctx.mode);
|
|
1894
|
+
|
|
1895
|
+
w.write("\n");
|
|
1896
|
+
w.write(mark[0] + "\n");
|
|
1897
|
+
w.write(mark[1] + " " + ember("Forged.", ctx.mode) + " " + dimLine(ctx, `install → config in ${elapsed}s`) + "\n");
|
|
1898
|
+
w.write(mark[2] + "\n");
|
|
1899
|
+
w.write(mark[3] + "\n\n");
|
|
1900
|
+
|
|
1901
|
+
const glyph = ctx.mode === "none" ? "✔" : "\x1b[38;2;255;179;71m✔\x1b[0m";
|
|
1902
|
+
if (summary.written) {
|
|
1903
|
+
const todoNote = summary.todoCount > 0 ? `${summary.todoCount} field${summary.todoCount === 1 ? "" : "s"} flagged # TODO` : "all fields detected";
|
|
1904
|
+
w.write(` ${glyph} forge.yaml written ${dimLine(ctx, todoNote)}\n`);
|
|
1905
|
+
}
|
|
1906
|
+
if (summary.total !== undefined) {
|
|
1907
|
+
const hookNote = summary.hookStatus === "skipped-malformed" ? "hook NOT active — see fix above" : "Claude Code knows this repo";
|
|
1908
|
+
w.write(` ${glyph} ${summary.total} commands · hook ${summary.hookStatus === "skipped-malformed" ? "skipped" : "active"} ${dimLine(ctx, hookNote)}\n`);
|
|
1909
|
+
}
|
|
1910
|
+
w.write("\n");
|
|
1911
|
+
w.write(
|
|
1912
|
+
box(
|
|
1913
|
+
[
|
|
1914
|
+
` 1. open claude in this repo`,
|
|
1915
|
+
` 2. run /work-on next — watch an issue become a merged PR`,
|
|
1916
|
+
],
|
|
1917
|
+
{ title: "what's next" },
|
|
1918
|
+
),
|
|
1919
|
+
);
|
|
1920
|
+
w.write(" " + dimLine(ctx, "docs: github.com/RapierCraftStudios/ForgeDock · ⭐ a star is the whole marketing budget") + "\n\n");
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// ---------------------------------------------------------------------------
|
|
1924
|
+
// The full journey (Task 8)
|
|
1925
|
+
// ---------------------------------------------------------------------------
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Acts I→V. Returns the process exit code.
|
|
1929
|
+
* SIGINT: restore cursor, summarize partial state, exit 130.
|
|
1930
|
+
*/
|
|
1931
|
+
export async function runJourney(ctx) {
|
|
1932
|
+
const onSigint = () => {
|
|
1933
|
+
ctx.stdout.write("\x1b[0m\x1b[?25h\n Interrupted. Partial state: commands may be installed; config not written.\n Finish anytime with: npx forgedock init\n");
|
|
1934
|
+
process.exit(130);
|
|
1935
|
+
};
|
|
1936
|
+
process.on("SIGINT", onSigint);
|
|
1937
|
+
try {
|
|
1938
|
+
await preflight(ctx);
|
|
1939
|
+
const forged = await forge(ctx);
|
|
1940
|
+
const { draft, description } = await read(ctx);
|
|
1941
|
+
const reviewed = await review(ctx, draft, description);
|
|
1942
|
+
celebrate(ctx, { ...reviewed, total: forged.total, hookStatus: forged.hookStatus });
|
|
1943
|
+
return reviewed.aborted ? 1 : 0;
|
|
1944
|
+
} finally {
|
|
1945
|
+
process.removeListener("SIGINT", onSigint);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
```
|
|
1949
|
+
|
|
1950
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1951
|
+
|
|
1952
|
+
Run: `node --test bin/tests/journey.test.mjs` → PASS. `npm test` → green.
|
|
1953
|
+
|
|
1954
|
+
- [ ] **Step 5: Commit**
|
|
1955
|
+
|
|
1956
|
+
```bash
|
|
1957
|
+
git add bin/journey.mjs bin/tests/journey.test.mjs
|
|
1958
|
+
git commit -s -m "feat(journey): Acts III-V — read, review, celebrate, runJourney"
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
---
|
|
1962
|
+
|
|
1963
|
+
### Task 9: forgedock.mjs — router refactor (+ enable/disable/status, HOME fix)
|
|
1964
|
+
|
|
1965
|
+
**Files:**
|
|
1966
|
+
- Modify: `bin/forgedock.mjs` (major: replace `install()`/`init()` bodies with journey calls; keep `uninstall()`/`update()`; add commands)
|
|
1967
|
+
- Test: `bin/tests/router.test.mjs` (create)
|
|
1968
|
+
|
|
1969
|
+
**Interfaces:**
|
|
1970
|
+
- Consumes: `makeCtx`, `runJourney`, `read`, `review`, `celebrate`, `findMarkdownFiles` (journey.mjs); `removeSessionStartHook` (settings-hook.mjs); `resolveState`, `setOptOut` (registry.mjs); `renderMark`, `ember`, `colorMode` (cinema.mjs).
|
|
1971
|
+
- Produces: the CLI surface —
|
|
1972
|
+
- bare / `install` → configured repo? status screen : `runJourney(ctx)`
|
|
1973
|
+
- `init` → Acts III–V (`read` → `review` → `celebrate`); `--manual` → plain `input()` prompts per field instead of the review screen
|
|
1974
|
+
- `enable` → `setOptOut(cwd, false)` + create `.forgedock` marker if no forge.yaml
|
|
1975
|
+
- `disable` → `setOptOut(cwd, true)`
|
|
1976
|
+
- `status` → compact lockup + `resolveState(cwd)` + hook/commands state
|
|
1977
|
+
- `uninstall` → existing removal + `removeSessionStartHook`
|
|
1978
|
+
- `update`, `help` → kept; help text lists ONLY real commands
|
|
1979
|
+
- Flags stripped from args before command resolution: `--fast`, `--manual`, `--verbose`
|
|
1980
|
+
- `HOME` resolution: `process.env.HOME || process.env.USERPROFILE || os.homedir()` — the hard `process.exit(1)` at forgedock.mjs:21-26 is deleted.
|
|
1981
|
+
|
|
1982
|
+
- [ ] **Step 1: Write the failing tests**
|
|
1983
|
+
|
|
1984
|
+
Create `bin/tests/router.test.mjs` (spawn the real CLI — non-TTY, so plain-log mode; `HOME`/`USERPROFILE` pointed at a temp dir):
|
|
1985
|
+
|
|
1986
|
+
```js
|
|
1987
|
+
/**
|
|
1988
|
+
* bin/tests/router.test.mjs — CLI-level tests for bin/forgedock.mjs routing.
|
|
1989
|
+
* Run with: node --test bin/tests/router.test.mjs
|
|
1990
|
+
*/
|
|
1991
|
+
import { describe, it } from "node:test";
|
|
1992
|
+
import assert from "node:assert/strict";
|
|
1993
|
+
import { spawnSync } from "node:child_process";
|
|
1994
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
1995
|
+
import { join, dirname } from "node:path";
|
|
1996
|
+
import os from "node:os";
|
|
1997
|
+
import { fileURLToPath } from "node:url";
|
|
1998
|
+
|
|
1999
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs");
|
|
2000
|
+
|
|
2001
|
+
function runCli(args, { cwd, home } = {}) {
|
|
2002
|
+
return spawnSync(process.execPath, [CLI, ...args], {
|
|
2003
|
+
cwd: cwd ?? mkdtempSync(join(os.tmpdir(), "fd-cli-cwd-")),
|
|
2004
|
+
env: { ...process.env, HOME: home, USERPROFILE: home, NO_COLOR: "1" },
|
|
2005
|
+
encoding: "utf-8",
|
|
2006
|
+
timeout: 30000,
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
describe("router", () => {
|
|
2011
|
+
it("help lists only real commands — no demo, no integrate", () => {
|
|
2012
|
+
const res = runCli(["help"], { home: mkdtempSync(join(os.tmpdir(), "fd-h-")) });
|
|
2013
|
+
assert.equal(res.status, 0);
|
|
2014
|
+
assert.match(res.stdout, /install/);
|
|
2015
|
+
assert.match(res.stdout, /enable/);
|
|
2016
|
+
assert.match(res.stdout, /disable/);
|
|
2017
|
+
assert.match(res.stdout, /status/);
|
|
2018
|
+
assert.doesNotMatch(res.stdout, /demo/);
|
|
2019
|
+
assert.doesNotMatch(res.stdout, /integrate/);
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
it("unknown command exits 1", () => {
|
|
2023
|
+
const res = runCli(["frobnicate"], { home: mkdtempSync(join(os.tmpdir(), "fd-u-")) });
|
|
2024
|
+
assert.equal(res.status, 1);
|
|
2025
|
+
assert.match(res.stdout + res.stderr, /Unknown command/);
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
it("status reports unmanaged in a fresh directory", () => {
|
|
2029
|
+
const res = runCli(["status"], { home: mkdtempSync(join(os.tmpdir(), "fd-s-")) });
|
|
2030
|
+
assert.equal(res.status, 0);
|
|
2031
|
+
assert.match(res.stdout, /unmanaged|not active/i);
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
it("disable then status reports opted out", () => {
|
|
2035
|
+
const home = mkdtempSync(join(os.tmpdir(), "fd-d-"));
|
|
2036
|
+
const cwd = mkdtempSync(join(os.tmpdir(), "fd-d-cwd-"));
|
|
2037
|
+
writeFileSync(join(cwd, "forge.yaml"), "project:\n", "utf-8");
|
|
2038
|
+
assert.equal(runCli(["disable"], { home, cwd }).status, 0);
|
|
2039
|
+
const res = runCli(["status"], { home, cwd });
|
|
2040
|
+
assert.match(res.stdout, /opted.?out|disabled/i);
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
it("enable in a bare directory creates the .forgedock marker", () => {
|
|
2044
|
+
const home = mkdtempSync(join(os.tmpdir(), "fd-e-"));
|
|
2045
|
+
const cwd = mkdtempSync(join(os.tmpdir(), "fd-e-cwd-"));
|
|
2046
|
+
assert.equal(runCli(["enable"], { home, cwd }).status, 0);
|
|
2047
|
+
assert.ok(existsSync(join(cwd, ".forgedock")));
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
it("works without HOME when USERPROFILE is set (no hard exit)", () => {
|
|
2051
|
+
const home = mkdtempSync(join(os.tmpdir(), "fd-w-"));
|
|
2052
|
+
const res = spawnSync(process.execPath, [CLI, "help"], {
|
|
2053
|
+
env: { ...process.env, HOME: "", USERPROFILE: home, NO_COLOR: "1" },
|
|
2054
|
+
encoding: "utf-8",
|
|
2055
|
+
timeout: 30000,
|
|
2056
|
+
});
|
|
2057
|
+
assert.equal(res.status, 0);
|
|
2058
|
+
assert.doesNotMatch(res.stdout + res.stderr, /HOME environment variable/);
|
|
2059
|
+
});
|
|
2060
|
+
});
|
|
2061
|
+
```
|
|
2062
|
+
|
|
2063
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
2064
|
+
|
|
2065
|
+
Run: `node --test bin/tests/router.test.mjs`
|
|
2066
|
+
Expected: FAIL — `enable`/`disable`/`status` hit `Unknown command`; the HOME test hits the hard exit; help currently lacks the new commands.
|
|
2067
|
+
|
|
2068
|
+
- [ ] **Step 3: Implement the router**
|
|
2069
|
+
|
|
2070
|
+
Rewrite `bin/forgedock.mjs` top and dispatch. Keep `uninstall()` and `update()` bodies (update `uninstall` to also remove the hook; both now use journey's `findMarkdownFiles`). Delete the old `install()`, `init()`, `confirmOverwrite()`, `findMarkdownFiles()` — they moved to journey.mjs. The new file skeleton:
|
|
2071
|
+
|
|
2072
|
+
```js
|
|
2073
|
+
#!/usr/bin/env node
|
|
2074
|
+
|
|
2075
|
+
import { fileURLToPath } from "url";
|
|
2076
|
+
import { dirname, join, relative } from "path";
|
|
2077
|
+
import { lstat, readlink, unlink } from "fs/promises";
|
|
2078
|
+
import { existsSync } from "fs";
|
|
2079
|
+
import { execSync } from "child_process";
|
|
2080
|
+
import os from "os";
|
|
2081
|
+
import { makeCtx, runJourney, read, review, celebrate, forge, preflight, findMarkdownFiles } from "./journey.mjs";
|
|
2082
|
+
import { removeSessionStartHook } from "./settings-hook.mjs";
|
|
2083
|
+
import { resolveState, setOptOut } from "./registry.mjs";
|
|
2084
|
+
import { renderMark, ember } from "./cinema.mjs";
|
|
2085
|
+
import { input } from "./tui.mjs";
|
|
2086
|
+
import { writeForgeYaml, backupExisting } from "./journey.mjs";
|
|
2087
|
+
|
|
2088
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
2089
|
+
const FORGE_HOME = dirname(dirname(__filename));
|
|
2090
|
+
|
|
2091
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
2092
|
+
const TARGET_DIR = join(HOME, ".claude", "commands");
|
|
2093
|
+
|
|
2094
|
+
const rawArgs = process.argv.slice(2);
|
|
2095
|
+
const FLAGS = new Set(["--fast", "--manual", "--verbose"]);
|
|
2096
|
+
const flags = rawArgs.filter((a) => FLAGS.has(a));
|
|
2097
|
+
const positional = rawArgs.filter((a) => !FLAGS.has(a));
|
|
2098
|
+
const command = positional[0] || "install";
|
|
2099
|
+
|
|
2100
|
+
function ctx() {
|
|
2101
|
+
const c = makeCtx({ argv: flags });
|
|
2102
|
+
c.forgeHome = FORGE_HOME;
|
|
2103
|
+
c.home = HOME;
|
|
2104
|
+
return c;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/** Compact status screen for configured/managed directories. */
|
|
2108
|
+
function statusScreen(c) {
|
|
2109
|
+
const state = resolveState(c.cwd);
|
|
2110
|
+
const mark = renderMark("compact", c.mode);
|
|
2111
|
+
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
2112
|
+
c.stdout.write("\n" + mark[0] + "\n");
|
|
2113
|
+
c.stdout.write(mark[1] + " " + ember("FORGEDOCK", c.mode) + " " + dim("status") + "\n");
|
|
2114
|
+
c.stdout.write(mark[2] + "\n" + mark[3] + "\n\n");
|
|
2115
|
+
const configured = existsSync(join(c.cwd, "forge.yaml"));
|
|
2116
|
+
c.stdout.write(` directory ${state}\n`);
|
|
2117
|
+
c.stdout.write(` forge.yaml ${configured ? "present" : "missing"}\n`);
|
|
2118
|
+
c.stdout.write(` commands ${existsSync(TARGET_DIR) ? "installed at " + TARGET_DIR : "not installed"}\n`);
|
|
2119
|
+
if (state === "managed-optedout") {
|
|
2120
|
+
c.stdout.write(`\n ForgeDock is disabled (opted out) here. Re-enable: npx forgedock enable\n`);
|
|
2121
|
+
} else if (state === "unmanaged") {
|
|
2122
|
+
c.stdout.write(`\n ForgeDock is not active in this directory. Activate: npx forgedock enable\n`);
|
|
2123
|
+
} else if (!configured) {
|
|
2124
|
+
c.stdout.write(`\n Generate config: npx forgedock init\n`);
|
|
2125
|
+
} else {
|
|
2126
|
+
c.stdout.write(`\n Reconfigure: npx forgedock init · Full journey: npx forgedock install\n`);
|
|
2127
|
+
}
|
|
2128
|
+
c.stdout.write("\n");
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
async function initFlow(c) {
|
|
2132
|
+
if (flags.includes("--manual")) {
|
|
2133
|
+
// Manual escape hatch: plain prompts, detection values as defaults.
|
|
2134
|
+
const { draft, description } = await read(c);
|
|
2135
|
+
const v = {
|
|
2136
|
+
owner: await input("GitHub owner", draft.project.owner.value),
|
|
2137
|
+
repo: await input("Repository", draft.project.repo.value),
|
|
2138
|
+
name: await input("Project name", draft.project.name.value),
|
|
2139
|
+
description: await input("Description", description.value),
|
|
2140
|
+
root: await input("Repo root", draft.paths.root.value),
|
|
2141
|
+
worktreeBase: await input("Worktree base", draft.paths.worktreeBase.value),
|
|
2142
|
+
defaultBranch: await input("Default branch", draft.branches.default.value),
|
|
2143
|
+
stagingBranch: await input("Staging branch", draft.branches.staging.value),
|
|
2144
|
+
};
|
|
2145
|
+
const outputPath = join(c.cwd, "forge.yaml");
|
|
2146
|
+
const backup = backupExisting(outputPath);
|
|
2147
|
+
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
2148
|
+
const { todoCount } = writeForgeYaml(v, [], outputPath);
|
|
2149
|
+
celebrate(c, { written: true, todoCount });
|
|
2150
|
+
return 0;
|
|
2151
|
+
}
|
|
2152
|
+
const { draft, description } = await read(c);
|
|
2153
|
+
const reviewed = await review(c, draft, description);
|
|
2154
|
+
celebrate(c, reviewed);
|
|
2155
|
+
return reviewed.aborted ? 1 : 0;
|
|
2156
|
+
}
|
|
2157
|
+
```
|
|
2158
|
+
|
|
2159
|
+
Dispatch (replacing the old switch; `help()` updated to list `install init enable disable status update uninstall help` + the three flags — copy the existing help() formatting style with cyan command names):
|
|
2160
|
+
|
|
2161
|
+
```js
|
|
2162
|
+
let exitCode = 0;
|
|
2163
|
+
switch (command) {
|
|
2164
|
+
case "install": {
|
|
2165
|
+
const c = ctx();
|
|
2166
|
+
if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
|
|
2167
|
+
statusScreen(c);
|
|
2168
|
+
} else {
|
|
2169
|
+
exitCode = await runJourney(c);
|
|
2170
|
+
}
|
|
2171
|
+
break;
|
|
2172
|
+
}
|
|
2173
|
+
case "init":
|
|
2174
|
+
exitCode = await initFlow(ctx());
|
|
2175
|
+
break;
|
|
2176
|
+
case "enable": {
|
|
2177
|
+
const c = ctx();
|
|
2178
|
+
await setOptOut(c.cwd, false);
|
|
2179
|
+
if (!existsSync(join(c.cwd, "forge.yaml")) && !existsSync(join(c.cwd, ".forgedock"))) {
|
|
2180
|
+
const { writeFileSync } = await import("fs");
|
|
2181
|
+
writeFileSync(join(c.cwd, ".forgedock"), "", "utf-8");
|
|
2182
|
+
}
|
|
2183
|
+
c.stdout.write("\n ForgeDock enabled in this directory.\n\n");
|
|
2184
|
+
break;
|
|
2185
|
+
}
|
|
2186
|
+
case "disable": {
|
|
2187
|
+
const c = ctx();
|
|
2188
|
+
await setOptOut(c.cwd, true);
|
|
2189
|
+
c.stdout.write("\n ForgeDock disabled in this directory. Re-enable: npx forgedock enable\n\n");
|
|
2190
|
+
break;
|
|
2191
|
+
}
|
|
2192
|
+
case "status":
|
|
2193
|
+
statusScreen(ctx());
|
|
2194
|
+
break;
|
|
2195
|
+
case "uninstall":
|
|
2196
|
+
await uninstall(); // existing body, now also calls removeSessionStartHook (below)
|
|
2197
|
+
break;
|
|
2198
|
+
case "update":
|
|
2199
|
+
await update(); // unchanged
|
|
2200
|
+
break;
|
|
2201
|
+
case "help":
|
|
2202
|
+
case "--help":
|
|
2203
|
+
case "-h":
|
|
2204
|
+
help();
|
|
2205
|
+
break;
|
|
2206
|
+
default:
|
|
2207
|
+
console.log(`Unknown command: ${command}`);
|
|
2208
|
+
help();
|
|
2209
|
+
exitCode = 1;
|
|
2210
|
+
}
|
|
2211
|
+
process.exit(exitCode);
|
|
2212
|
+
```
|
|
2213
|
+
|
|
2214
|
+
Inside the kept `uninstall()`, after the removal loop add:
|
|
2215
|
+
|
|
2216
|
+
```js
|
|
2217
|
+
const { status } = removeSessionStartHook(join(HOME, ".claude", "settings.json"));
|
|
2218
|
+
if (status === "removed") console.log(" Removed: SessionStart hook");
|
|
2219
|
+
```
|
|
2220
|
+
|
|
2221
|
+
and change its `COMMANDS_DIR` reference to `join(FORGE_HOME, "commands")`. Delete the `if (!process.env.HOME) { … process.exit(1) }` block entirely.
|
|
2222
|
+
|
|
2223
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
2224
|
+
|
|
2225
|
+
Run: `node --test bin/tests/router.test.mjs` → PASS. Then the full suite: `npm test` → green (fix any old tests that asserted the removed help text).
|
|
2226
|
+
|
|
2227
|
+
- [ ] **Step 5: Full-journey smoke test in a scratch repo (eyeball)**
|
|
2228
|
+
|
|
2229
|
+
```bash
|
|
2230
|
+
cd "$(mktemp -d)" && git init -q && git remote add origin https://github.com/RapierCraftStudios/ForgeDock.git
|
|
2231
|
+
node <ABSOLUTE_PATH_TO_REPO>/bin/forgedock.mjs
|
|
2232
|
+
```
|
|
2233
|
+
Expected: hero shimmer → preflight rows → molten install bar → detection rows with badges → review screen (press Enter) → quench + receipt + next-steps box. Re-run: status screen, not the movie. Then `node .../forgedock.mjs uninstall` and confirm the hook entry is gone from `~/.claude/settings.json`.
|
|
2234
|
+
|
|
2235
|
+
- [ ] **Step 6: Commit**
|
|
2236
|
+
|
|
2237
|
+
```bash
|
|
2238
|
+
git add bin/forgedock.mjs bin/tests/router.test.mjs
|
|
2239
|
+
git commit -s -m "feat(cli): router over the journey — enable/disable/status, hook uninstall, HOME fallback"
|
|
2240
|
+
```
|
|
2241
|
+
|
|
2242
|
+
---
|
|
2243
|
+
|
|
2244
|
+
### Task 10: Docs truth-pass + version 1.1.0
|
|
2245
|
+
|
|
2246
|
+
**Files:**
|
|
2247
|
+
- Modify: `README.md`, `docs/site/getting-started.md`, `docs/CONFIG.md`, `package.json`, `.claude-plugin/plugin.json`
|
|
2248
|
+
|
|
2249
|
+
**Interfaces:** none (docs). Verification is grep-based.
|
|
2250
|
+
|
|
2251
|
+
- [ ] **Step 1: README.md**
|
|
2252
|
+
|
|
2253
|
+
1. Delete the "30-second try" `npx forgedock demo` block (README lines ~52-58) and replace with:
|
|
2254
|
+
|
|
2255
|
+
```markdown
|
|
2256
|
+
### Try it in 60 seconds
|
|
2257
|
+
|
|
2258
|
+
npx forgedock
|
|
2259
|
+
|
|
2260
|
+
One command: it checks your environment, installs the slash commands, reads
|
|
2261
|
+
your repo, and writes a reviewed `forge.yaml` — you press Enter once.
|
|
2262
|
+
```
|
|
2263
|
+
|
|
2264
|
+
2. In the install section (~lines 174-208): requirements list becomes `Claude Code · GitHub CLI (authenticated) · Node.js ≥ 18`; collapse the two-step quickstart to the single `npx forgedock` (mention `npx forgedock init` as "re-generate config only"); DELETE the sentence claiming init "injects a short usage block into your project's CLAUDE.md" and replace with:
|
|
2265
|
+
|
|
2266
|
+
```markdown
|
|
2267
|
+
Installing also registers a SessionStart hook, so every Claude Code session
|
|
2268
|
+
in a forge-managed directory starts already knowing ForgeDock runs it.
|
|
2269
|
+
Per-directory control: `npx forgedock enable` / `disable` / `status`.
|
|
2270
|
+
```
|
|
2271
|
+
|
|
2272
|
+
3. In the maintenance commands list (~lines 199-204): remove `integrate`, add `enable`, `disable`, `status`.
|
|
2273
|
+
|
|
2274
|
+
- [ ] **Step 2: docs/site/getting-started.md**
|
|
2275
|
+
|
|
2276
|
+
Rewrite Step 2 (lines ~43-58): the config is created by `npx forgedock` itself (or `npx forgedock init` to redo it); `/forgedock-init` is the optional *enrichment* step run afterwards inside Claude Code to fill optional sections (boards, satellites, review context). Remove any instruction to run `/forgedock-init` before a forge.yaml exists.
|
|
2277
|
+
|
|
2278
|
+
- [ ] **Step 3: docs/CONFIG.md**
|
|
2279
|
+
|
|
2280
|
+
Update the CLI flags section (~lines 15-26) to document exactly what now exists: `--manual` (plain prompts, detection as defaults), `--verbose` (show all detection sources on the review screen), `--fast` (skip animation frames), `FORGE_NO_MOTION=1`. Remove any flag text that doesn't match Task 9's implementation.
|
|
2281
|
+
|
|
2282
|
+
- [ ] **Step 4: Versions**
|
|
2283
|
+
|
|
2284
|
+
`package.json`: `"version": "1.1.0"`. `.claude-plugin/plugin.json`: `"version": "1.1.0"`.
|
|
2285
|
+
|
|
2286
|
+
- [ ] **Step 5: Verify**
|
|
2287
|
+
|
|
2288
|
+
```bash
|
|
2289
|
+
grep -rn "forgedock demo" README.md docs/ ; grep -rn "forgedock integrate" README.md docs/
|
|
2290
|
+
grep -n "CLAUDE.md" README.md # remaining mentions must not claim injection
|
|
2291
|
+
grep -n '"version"' package.json .claude-plugin/plugin.json
|
|
2292
|
+
npm test
|
|
2293
|
+
```
|
|
2294
|
+
Expected: no `demo`/`integrate` hits; both versions `1.1.0`; suite green.
|
|
2295
|
+
|
|
2296
|
+
- [ ] **Step 6: Commit**
|
|
2297
|
+
|
|
2298
|
+
```bash
|
|
2299
|
+
git add README.md docs/site/getting-started.md docs/CONFIG.md package.json .claude-plugin/plugin.json
|
|
2300
|
+
git commit -s -m "docs: truth-pass — journey install story, real flags, v1.1.0"
|
|
2301
|
+
```
|
|
2302
|
+
|
|
2303
|
+
---
|
|
2304
|
+
|
|
2305
|
+
### Task 11: Final verification + demo re-record (manual)
|
|
2306
|
+
|
|
2307
|
+
**Files:** none (verification); optionally `docs/demo.tape` + `docs/demo.gif`.
|
|
2308
|
+
|
|
2309
|
+
- [ ] **Step 1: Full suite + fresh-machine simulation**
|
|
2310
|
+
|
|
2311
|
+
```bash
|
|
2312
|
+
npm test
|
|
2313
|
+
# Fresh HOME simulation (bash):
|
|
2314
|
+
export TESTHOME=$(mktemp -d) && HOME=$TESTHOME USERPROFILE=$TESTHOME node bin/forgedock.mjs --fast < /dev/null; echo "exit: $?"
|
|
2315
|
+
```
|
|
2316
|
+
Expected: suite green; non-TTY run prints the plain-log receipt, writes forge.yaml with TODO flags (fresh dir), exits 0.
|
|
2317
|
+
|
|
2318
|
+
- [ ] **Step 2: Degradation ladder spot checks (eyeball)**
|
|
2319
|
+
|
|
2320
|
+
Run `node bin/forgedock.mjs status` under: normal Windows Terminal (truecolor + motion), `NO_COLOR=1` (monochrome, motion), `--fast` (instant), piped (`| cat` — plain log). Each must be readable and truthful.
|
|
2321
|
+
|
|
2322
|
+
- [ ] **Step 3: Re-record the demo**
|
|
2323
|
+
|
|
2324
|
+
Update `docs/demo.tape` to drive the new journey (`npx forgedock` in a scratch repo) and regenerate `docs/demo.gif` with vhs (`vhs docs/demo.tape`). If vhs isn't installed, file a follow-up issue instead of blocking the merge — but do NOT ship a README that embeds a gif of the old flow without a note.
|
|
2325
|
+
|
|
2326
|
+
- [ ] **Step 4: Commit (if tape re-recorded)**
|
|
2327
|
+
|
|
2328
|
+
```bash
|
|
2329
|
+
git add docs/demo.tape docs/demo.gif
|
|
2330
|
+
git commit -s -m "docs: re-record demo for the cinematic journey"
|
|
2331
|
+
```
|
|
2332
|
+
|
|
2333
|
+
---
|
|
2334
|
+
|
|
2335
|
+
## Plan Self-Review (completed)
|
|
2336
|
+
|
|
2337
|
+
- **Spec coverage:** five acts (Tasks 5, 6, 8), hook wiring + uninstall (Tasks 3, 6, 9), enable/disable/status (Task 9), review-screen wiring + extraFields + prompt copy (Tasks 7, 8), ConfigDraft-driven writer with TODO flags (Task 4), degradation ladder gates (Task 1) + non-TTY behavior (Tasks 8, 9, 11), HOME/Windows fixes (Tasks 4, 9), docs truth-pass + 1.1.0 (Task 10), demo.tape (Task 11). Out of scope per spec: `demo` command, self-healing validate, skill-backend enrichment.
|
|
2338
|
+
- **Known simplifications (intentional):** Act III reveal rows pace the display of already-computed detection (detection is <1s; spec allows pacing, forbids fake *work*). `enrich()` mutates/returns the draft but its optional sections are not yet written into forge.yaml by `writeForgeYaml` — matching current spec scope where enrichment feeds the review context; extend the writer when the enrichment sections are promoted into the template (follow-up).
|
|
2339
|
+
- **Type consistency:** `annotatedReviewScreen` return keys (`owner…stagingBranch, lowConfidenceKeys`) == `writeForgeYaml` input keys; `ConfigDraft` paths used in Tasks 7–8 match `init-detect.mjs` exactly (`project.owner.value` etc.); `hookStatus` literals (`installed|already|skipped-malformed`) consistent across Tasks 3, 6, 8.
|