tlc-claude-code 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/tlc/build.md +21 -2
- package/.claude/commands/tlc/plan.md +20 -6
- package/.claude/commands/tlc/progress.md +41 -15
- package/.claude/commands/tlc/review-pr.md +19 -10
- package/.claude/commands/tlc/status.md +23 -3
- package/.claude/commands/tlc/tlc.md +32 -16
- package/package.json +1 -1
- package/scripts/renumber-phases.js +283 -0
- package/scripts/renumber-phases.test.js +305 -0
- package/server/lib/workspace-manifest.js +138 -0
- package/server/lib/workspace-manifest.test.js +179 -0
|
@@ -117,7 +117,7 @@ Claude is the **project manager**. Claude reads the plan, breaks tasks into smal
|
|
|
117
117
|
|
|
118
118
|
### Step O1: Read the Plan
|
|
119
119
|
|
|
120
|
-
Read `.planning/phases/{N}-PLAN.md
|
|
120
|
+
Read `.planning/phases/{PHASE_ID}-PLAN.md`, falling back to legacy `.planning/phases/{N}-PLAN.md` when no prefixed file exists. Extract each task's goal, files, acceptance criteria, and test cases.
|
|
121
121
|
|
|
122
122
|
### Step O2: Check Orchestrator Health
|
|
123
123
|
|
|
@@ -395,6 +395,20 @@ This is the core TLC command. Tests before code, one task at a time.
|
|
|
395
395
|
/tlc:build <phase_number> --agents 5 # Limit parallel agents to 5
|
|
396
396
|
```
|
|
397
397
|
|
|
398
|
+
## Phase Resolution
|
|
399
|
+
|
|
400
|
+
Before loading plans, normalize the phase argument:
|
|
401
|
+
|
|
402
|
+
1. If the argument is prefixed, such as `CORE-3`, discover the workspace manifest with `server/lib/workspace-manifest.js`, resolve the prefix to a repo, and run the build in that repo directory (for example `../tlc-core`).
|
|
403
|
+
2. If the argument is unprefixed, such as `108`, resolve it to the current repo prefix when the workspace manifest exists, producing `TLC-108` in this repo.
|
|
404
|
+
3. If the prefix is unknown, stop with:
|
|
405
|
+
`Unknown prefix FOO. Known: TLC, CORE, SA`
|
|
406
|
+
4. If no workspace manifest exists:
|
|
407
|
+
- Prefixed cross-repo IDs return an error
|
|
408
|
+
- Unprefixed IDs still work in the current repo for backward compatibility
|
|
409
|
+
|
|
410
|
+
Use `{PHASE_ID}` for artifact lookup and keep `{phase_number}` as the numeric/local branch component when needed.
|
|
411
|
+
|
|
398
412
|
## CodeDB Acceleration
|
|
399
413
|
|
|
400
414
|
When CodeDB is available (`.mcp.json` has a `codedb` server), use these tools in the scan/search steps below. If CodeDB is unavailable, fall back to Grep/Glob/Read.
|
|
@@ -443,7 +457,12 @@ fi
|
|
|
443
457
|
|
|
444
458
|
### Step 1: Load Plans
|
|
445
459
|
|
|
446
|
-
Read
|
|
460
|
+
Read the phase plan using prefixed matching first:
|
|
461
|
+
|
|
462
|
+
- Preferred: `.planning/phases/{PREFIX}-{N}-PLAN.md`
|
|
463
|
+
- Backward-compatible fallback: `.planning/phases/{N}-PLAN.md`
|
|
464
|
+
|
|
465
|
+
Do not use the old ambiguous glob `.planning/phases/{phase}-*-PLAN.md` for primary lookup.
|
|
447
466
|
|
|
448
467
|
### Step 1a: Auto-Parallel Detection
|
|
449
468
|
|
|
@@ -173,6 +173,20 @@ The existing planning instructions below are the Inline Mode instructions and sh
|
|
|
173
173
|
|
|
174
174
|
If no phase number, auto-detect current phase from ROADMAP.md.
|
|
175
175
|
|
|
176
|
+
## Phase Resolution
|
|
177
|
+
|
|
178
|
+
Before reading or writing phase artifacts, normalize the requested phase argument:
|
|
179
|
+
|
|
180
|
+
1. If the user passes a prefixed ID such as `CORE-3`, discover the workspace manifest via `server/lib/workspace-manifest.js` and resolve the target repo from the prefix.
|
|
181
|
+
2. If the user passes an unprefixed ID such as `108`, resolve it to the current repo prefix when the workspace manifest is available, producing `TLC-108` in this repo.
|
|
182
|
+
3. If the user passes an unknown prefix, stop with:
|
|
183
|
+
`Unknown prefix FOO. Known: TLC, CORE, SA`
|
|
184
|
+
4. If no workspace manifest exists:
|
|
185
|
+
- Prefixed cross-repo IDs return an error because repo resolution is unavailable
|
|
186
|
+
- Unprefixed IDs still work in the current repo for backward compatibility
|
|
187
|
+
|
|
188
|
+
When a prefixed ID resolves to another repo, operate in that repo's directory (for example `CORE-3` resolves via the manifest and runs against `../tlc-core`).
|
|
189
|
+
|
|
176
190
|
## CodeDB Acceleration
|
|
177
191
|
|
|
178
192
|
When CodeDB is available (`.mcp.json` has a `codedb` server), use these tools in the scan/search steps below. If CodeDB is unavailable, fall back to Grep/Glob/Read.
|
|
@@ -194,7 +208,7 @@ If CodeDB is unavailable, fall back to Grep/Glob/Read.
|
|
|
194
208
|
|
|
195
209
|
Read:
|
|
196
210
|
- `.planning/ROADMAP.md` - phase goal
|
|
197
|
-
- `.planning/phases/{
|
|
211
|
+
- `.planning/phases/{PHASE_ID}-DISCUSSION.md` - implementation preferences
|
|
198
212
|
- `PROJECT.md` - tech stack, constraints
|
|
199
213
|
|
|
200
214
|
### Step 2: Research (if needed)
|
|
@@ -204,7 +218,7 @@ For phases requiring external knowledge:
|
|
|
204
218
|
- Check best practices
|
|
205
219
|
- Identify patterns
|
|
206
220
|
|
|
207
|
-
Create `.planning/phases/{
|
|
221
|
+
Create `.planning/phases/{PHASE_ID}-RESEARCH.md` with findings.
|
|
208
222
|
|
|
209
223
|
### Step 3: Break Into Tasks
|
|
210
224
|
|
|
@@ -270,10 +284,10 @@ Use `/tlc:claim` to claim a task, `/tlc:release` to release one.
|
|
|
270
284
|
|
|
271
285
|
### Step 4: Create Plan File
|
|
272
286
|
|
|
273
|
-
Create `.planning/phases/{N}-PLAN.md
|
|
287
|
+
Create `.planning/phases/{PHASE_ID}-PLAN.md` using the repo prefix when available, for example `TLC-{N}-PLAN.md`. If no workspace manifest or prefixed artifacts exist, fall back to legacy `{N}-PLAN.md`.
|
|
274
288
|
|
|
275
289
|
```markdown
|
|
276
|
-
# Phase {
|
|
290
|
+
# Phase {PHASE_ID}: {Name} - Plan
|
|
277
291
|
|
|
278
292
|
## Overview
|
|
279
293
|
|
|
@@ -380,7 +394,7 @@ If the result is `true`:
|
|
|
380
394
|
fs
|
|
381
395
|
});
|
|
382
396
|
console.log(JSON.stringify(r));
|
|
383
|
-
" ".planning/phases/{
|
|
397
|
+
" ".planning/phases/{PHASE_ID}-PLAN.md" 2>/dev/null
|
|
384
398
|
```
|
|
385
399
|
2. Report results: "GitHub: Created N issues, updated M, linked to project board"
|
|
386
400
|
3. If sync fails, warn but do not block: "GitHub sync failed: [reason]. Plan saved locally. Run `/tlc:issues sync` to retry."
|
|
@@ -413,7 +427,7 @@ Allow refinement if needed.
|
|
|
413
427
|
### Step 6: Save and Continue
|
|
414
428
|
|
|
415
429
|
```
|
|
416
|
-
Plan saved to .planning/phases/{
|
|
430
|
+
Plan saved to .planning/phases/{PHASE_ID}-PLAN.md
|
|
417
431
|
|
|
418
432
|
Ready to build?
|
|
419
433
|
1) Yes, run /tlc:build (writes tests first)
|
|
@@ -31,26 +31,40 @@ No project found.
|
|
|
31
31
|
Run /tlc:new-project to start, or /tlc:init to add TLC to existing code.
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
### Step 2:
|
|
34
|
+
### Step 2: Detect Workspace Scope
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Try to discover a workspace manifest via `server/lib/workspace-manifest.js`.
|
|
37
|
+
|
|
38
|
+
- If a manifest exists, switch to workspace-wide progress mode
|
|
39
|
+
- Use the manifest `workspace` name in the header
|
|
40
|
+
- Iterate all repos declared in the manifest, listing the current repo first
|
|
41
|
+
- Repos whose paths do not exist on disk must be shown as `[not cloned]`
|
|
42
|
+
- If no manifest exists, fall back to single-repo mode
|
|
43
|
+
|
|
44
|
+
### Step 3: Parse Roadmap
|
|
45
|
+
|
|
46
|
+
In single-repo mode, read `.planning/ROADMAP.md` and identify:
|
|
37
47
|
- Total phases
|
|
38
48
|
- Completed phases (marked `[x]` or `[completed]`)
|
|
39
49
|
- Current phase (marked `[>]` or `[current]`)
|
|
40
50
|
- Pending phases
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
In workspace-wide mode, do the same per repo and produce a compact repo summary such as:
|
|
53
|
+
|
|
54
|
+
`TLC: Phase TLC-108 (3/10) | CORE: Phase CORE-1b (idle) | SA: Phase SA-105f (complete)`
|
|
55
|
+
|
|
56
|
+
### Step 4: Check Current Phase State
|
|
43
57
|
|
|
44
58
|
For the current/next phase, check what exists:
|
|
45
59
|
|
|
46
60
|
| File | Meaning |
|
|
47
61
|
|------|---------|
|
|
48
|
-
| `{N}-DISCUSSION.md` | Discussed |
|
|
49
|
-
| `{N}-PLAN.md` | Planned |
|
|
50
|
-
| `{N}-TESTS.md` | Tests written |
|
|
51
|
-
| `{N}-VERIFIED.md` | Human verified |
|
|
62
|
+
| `{PREFIX}-{N}-DISCUSSION.md` or `{N}-DISCUSSION.md` | Discussed |
|
|
63
|
+
| `{PREFIX}-{N}-PLAN.md` or `{N}-PLAN.md` | Planned |
|
|
64
|
+
| `{PREFIX}-{N}-TESTS.md` or `{N}-TESTS.md` | Tests written |
|
|
65
|
+
| `{PREFIX}-{N}-VERIFIED.md` or `{N}-VERIFIED.md` | Human verified |
|
|
52
66
|
|
|
53
|
-
### Step
|
|
67
|
+
### Step 5: Run Tests
|
|
54
68
|
|
|
55
69
|
Execute test suite and capture:
|
|
56
70
|
- Total tests
|
|
@@ -58,7 +72,7 @@ Execute test suite and capture:
|
|
|
58
72
|
- Failing
|
|
59
73
|
- Skipped
|
|
60
74
|
|
|
61
|
-
### Step
|
|
75
|
+
### Step 6: Present Status
|
|
62
76
|
|
|
63
77
|
```
|
|
64
78
|
Project: My App
|
|
@@ -82,6 +96,18 @@ Tests: 47 total | 45 passing | 2 failing
|
|
|
82
96
|
Suggested: /tlc:build 4
|
|
83
97
|
```
|
|
84
98
|
|
|
99
|
+
When workspace-wide mode is active, prefer a compact workspace summary:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Workspace: tlc-platform
|
|
103
|
+
|
|
104
|
+
TLC: Phase TLC-108 (3/10) | CORE: Phase CORE-1b (idle) | SA: Phase SA-105f (complete)
|
|
105
|
+
|
|
106
|
+
[TLC] Tests: 142 total | [CORE] Tests: 38 total | [SA] Tests: 67 total
|
|
107
|
+
|
|
108
|
+
Next: /tlc in the active repo, or /tlc:build TLC-108
|
|
109
|
+
```
|
|
110
|
+
|
|
85
111
|
### Step 5b: Team Status (Multi-User)
|
|
86
112
|
|
|
87
113
|
If task markers (`[>@user]`, `[x@user]`) exist in PLAN.md, show team activity:
|
|
@@ -99,17 +125,17 @@ Parse `[>@user]` and `[x@user]` markers from current phase PLAN.md to build this
|
|
|
99
125
|
|
|
100
126
|
If no markers exist, skip this section (single-user mode).
|
|
101
127
|
|
|
102
|
-
### Step
|
|
128
|
+
### Step 7: Suggest Next Action
|
|
103
129
|
|
|
104
130
|
Based on state:
|
|
105
131
|
|
|
106
132
|
| State | Suggestion |
|
|
107
133
|
|-------|------------|
|
|
108
|
-
| No discussion | `/tlc:discuss {
|
|
109
|
-
| Discussed, no plan | `/tlc:plan {
|
|
110
|
-
| Planned, no tests | `/tlc:build {
|
|
111
|
-
| Tests failing | Fix failures, then `/tlc:build {
|
|
112
|
-
| Tests passing, not verified | `/tlc:verify {
|
|
134
|
+
| No discussion | `/tlc:discuss {PHASE_ID}` |
|
|
135
|
+
| Discussed, no plan | `/tlc:plan {PHASE_ID}` |
|
|
136
|
+
| Planned, no tests | `/tlc:build {PHASE_ID}` |
|
|
137
|
+
| Tests failing | Fix failures, then `/tlc:build {PHASE_ID}` |
|
|
138
|
+
| Tests passing, not verified | `/tlc:verify {PHASE_ID}` |
|
|
113
139
|
| Verified | Move to next phase |
|
|
114
140
|
| All phases done | `/tlc:complete` |
|
|
115
141
|
|
|
@@ -72,16 +72,25 @@ Same checks as `/tlc:review`:
|
|
|
72
72
|
*Automated review by [TLC](https://github.com/jurgencalleja/TLC)*
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
### Step 5: Post Review
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
#
|
|
79
|
-
gh pr
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
75
|
+
### Step 5: Post Review
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Submit approval review first
|
|
79
|
+
if ! gh pr review <number> --approve --body "<review_markdown>" 2>/tmp/pr-review-err; then
|
|
80
|
+
if grep -q "Can not approve your own pull request" /tmp/pr-review-err; then
|
|
81
|
+
gh pr comment <number> --body "<review_markdown>"
|
|
82
|
+
echo "Posted as comment (self-owned PR — GitHub blocks self-approval)"
|
|
83
|
+
else
|
|
84
|
+
cat /tmp/pr-review-err
|
|
85
|
+
fi
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Post as PR comment directly when comment-only mode is needed
|
|
89
|
+
gh pr comment <number> --body "<review_markdown>"
|
|
90
|
+
|
|
91
|
+
# Or request changes as a formal review
|
|
92
|
+
gh pr review <number> --request-changes --body "<review_markdown>"
|
|
93
|
+
```
|
|
85
94
|
|
|
86
95
|
## Example Output
|
|
87
96
|
|
|
@@ -12,9 +12,20 @@ If no phase specified, shows overall test status.
|
|
|
12
12
|
|
|
13
13
|
## Process
|
|
14
14
|
|
|
15
|
-
1. **Detect
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
1. **Detect workspace scope**
|
|
16
|
+
- Try `server/lib/workspace-manifest.js`
|
|
17
|
+
- If a workspace manifest exists, gather test status for every repo in the manifest with the current repo listed first
|
|
18
|
+
- Repos missing on disk show `[not cloned]`
|
|
19
|
+
- If no manifest exists, fall back to current-repo-only status
|
|
20
|
+
2. **Detect test framework** (Vitest, Jest, pytest, etc.)
|
|
21
|
+
3. **Run the test suite**
|
|
22
|
+
4. **Report results** with next action
|
|
23
|
+
|
|
24
|
+
When workspace mode is active, include a one-line summary such as:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
[TLC] 142 tests | [CORE] 38 tests | [SA] 67 tests
|
|
28
|
+
```
|
|
18
29
|
|
|
19
30
|
## Output Examples
|
|
20
31
|
|
|
@@ -27,6 +38,15 @@ Test Status
|
|
|
27
38
|
Ready for: /tlc:verify
|
|
28
39
|
```
|
|
29
40
|
|
|
41
|
+
**Workspace-wide:**
|
|
42
|
+
```
|
|
43
|
+
Test Status
|
|
44
|
+
───────────
|
|
45
|
+
[TLC] 142 tests | [CORE] 38 tests | [SA] 67 tests
|
|
46
|
+
|
|
47
|
+
Ready for: /tlc:verify in the active repo
|
|
48
|
+
```
|
|
49
|
+
|
|
30
50
|
**Some failing:**
|
|
31
51
|
```
|
|
32
52
|
Test Status
|
|
@@ -198,6 +198,14 @@ If no roadmap exists:
|
|
|
198
198
|
|
|
199
199
|
### Step 2: Find The Active Phase
|
|
200
200
|
|
|
201
|
+
Before matching phase artifacts, determine the current repo prefix:
|
|
202
|
+
|
|
203
|
+
1. Try workspace-aware discovery first:
|
|
204
|
+
- Load `/workspace/Tools/.tlc-workspace.json` when available, or discover the nearest manifest via `server/lib/workspace-manifest.js`
|
|
205
|
+
- Use the current repo path plus the manifest to resolve the repo prefix (`TLC`, `CORE`, `SA`, etc.)
|
|
206
|
+
2. If no manifest is available, scan `.planning/phases/` for existing prefixed artifacts such as `TLC-108-PLAN.md` and infer the current repo prefix from those filenames
|
|
207
|
+
3. If neither manifest nor prefixed files are available, fall back to legacy unprefixed detection
|
|
208
|
+
|
|
201
209
|
From the roadmap, identify:
|
|
202
210
|
|
|
203
211
|
- The current phase marked as in progress, current, or active
|
|
@@ -206,18 +214,22 @@ From the roadmap, identify:
|
|
|
206
214
|
|
|
207
215
|
Extract:
|
|
208
216
|
|
|
209
|
-
- Phase number
|
|
217
|
+
- Phase number / phase ID
|
|
210
218
|
- Phase name
|
|
211
219
|
- Task count if the roadmap or plan exposes it
|
|
212
220
|
- Completed task count if available
|
|
213
221
|
|
|
214
222
|
### Step 3: Check Artifacts In The Same Pass
|
|
215
223
|
|
|
216
|
-
For the active phase `N
|
|
224
|
+
For the active phase, build the preferred prefixed ID `{PREFIX}-{N}` when a prefix is known. Check artifacts in this order:
|
|
217
225
|
|
|
218
|
-
- Discussion: `.planning/phases/{N}-DISCUSSION.md`
|
|
219
|
-
- Plan: `.planning/phases/{N}-PLAN.md`
|
|
220
|
-
- Verification: `.planning/phases/{N}-VERIFIED.md`
|
|
226
|
+
- Discussion: `.planning/phases/{PREFIX}-{N}-DISCUSSION.md`, then legacy `.planning/phases/{N}-DISCUSSION.md`
|
|
227
|
+
- Plan: `.planning/phases/{PREFIX}-{N}-PLAN.md`, then legacy `.planning/phases/{N}-PLAN.md`
|
|
228
|
+
- Verification: `.planning/phases/{PREFIX}-{N}-VERIFIED.md`, then legacy `.planning/phases/{N}-VERIFIED.md`
|
|
229
|
+
|
|
230
|
+
Phase file pattern matching must accept both:
|
|
231
|
+
- Prefixed: `{PREFIX}-{N}-PLAN.md`, `{PREFIX}-{N}-DISCUSSION.md`, `{PREFIX}-{N}-VERIFIED.md`
|
|
232
|
+
- Legacy: `{N}-PLAN.md`, `{N}-DISCUSSION.md`, `{N}-VERIFIED.md`
|
|
221
233
|
|
|
222
234
|
If the plan exists, inspect it to determine build state:
|
|
223
235
|
|
|
@@ -228,16 +240,20 @@ If the plan exists, inspect it to determine build state:
|
|
|
228
240
|
Use these state rules:
|
|
229
241
|
|
|
230
242
|
1. No discussion file:
|
|
231
|
-
Run `/tlc:discuss {
|
|
243
|
+
Run `/tlc:discuss {PHASE_ID}`
|
|
232
244
|
2. Discussion exists, no plan:
|
|
233
|
-
Run `/tlc:plan {
|
|
245
|
+
Run `/tlc:plan {PHASE_ID}`
|
|
234
246
|
3. Plan exists and phase is not fully built:
|
|
235
|
-
Run `/tlc:build {
|
|
247
|
+
Run `/tlc:build {PHASE_ID}`
|
|
236
248
|
4. Build complete, no verification file:
|
|
237
|
-
Run `/tlc:verify {
|
|
249
|
+
Run `/tlc:verify {PHASE_ID}`
|
|
238
250
|
5. Verification exists or all phases are complete:
|
|
239
251
|
Suggest release, but do not auto-run it
|
|
240
252
|
|
|
253
|
+
Backward compatibility:
|
|
254
|
+
- If prefixed artifacts exist, prefer them everywhere in status output and auto-run commands
|
|
255
|
+
- If no prefixed artifacts exist and no workspace manifest is found, continue using legacy unprefixed phase IDs
|
|
256
|
+
|
|
241
257
|
## Output Format
|
|
242
258
|
|
|
243
259
|
Normal output must stay within 3-5 lines.
|
|
@@ -245,9 +261,9 @@ Normal output must stay within 3-5 lines.
|
|
|
245
261
|
Use this pattern:
|
|
246
262
|
|
|
247
263
|
```text
|
|
248
|
-
TLC v{version} | Phase {
|
|
264
|
+
TLC v{version} | Phase {PHASE_ID}: {Name} | {done}/{total} tasks done
|
|
249
265
|
Next: {action summary}
|
|
250
|
-
Running /tlc:{command} {
|
|
266
|
+
Running /tlc:{command} {PHASE_ID}...
|
|
251
267
|
```
|
|
252
268
|
|
|
253
269
|
If there is nothing safe to auto-run:
|
|
@@ -288,7 +304,7 @@ Auto-run:
|
|
|
288
304
|
Auto-run:
|
|
289
305
|
|
|
290
306
|
```text
|
|
291
|
-
/tlc:plan {
|
|
307
|
+
/tlc:plan {PHASE_ID}
|
|
292
308
|
```
|
|
293
309
|
|
|
294
310
|
### Planned, Not Built
|
|
@@ -296,7 +312,7 @@ Auto-run:
|
|
|
296
312
|
Auto-run:
|
|
297
313
|
|
|
298
314
|
```text
|
|
299
|
-
/tlc:build {
|
|
315
|
+
/tlc:build {PHASE_ID}
|
|
300
316
|
```
|
|
301
317
|
|
|
302
318
|
Use the next incomplete task from the plan in the status line when available.
|
|
@@ -306,7 +322,7 @@ Use the next incomplete task from the plan in the status line when available.
|
|
|
306
322
|
Auto-run:
|
|
307
323
|
|
|
308
324
|
```text
|
|
309
|
-
/tlc:verify {
|
|
325
|
+
/tlc:verify {PHASE_ID}
|
|
310
326
|
```
|
|
311
327
|
|
|
312
328
|
### All Complete
|
|
@@ -322,9 +338,9 @@ Run /tlc:complete or /tlc:new-milestone
|
|
|
322
338
|
## Example
|
|
323
339
|
|
|
324
340
|
```text
|
|
325
|
-
TLC v2.4.2 | Phase 8: Auth System | 3/5 tasks done
|
|
341
|
+
TLC v2.4.2 | Phase TLC-8: Auth System | 3/5 tasks done
|
|
326
342
|
Next: build task 4 (JWT middleware)
|
|
327
|
-
Running /tlc:build 8...
|
|
343
|
+
Running /tlc:build TLC-8...
|
|
328
344
|
```
|
|
329
345
|
|
|
330
346
|
## Summary
|
package/package.json
CHANGED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const ALLOWED_SUFFIXES = new Set([
|
|
5
|
+
'PLAN',
|
|
6
|
+
'DISCUSSION',
|
|
7
|
+
'TESTS',
|
|
8
|
+
'VERIFIED',
|
|
9
|
+
'TEST-PLAN',
|
|
10
|
+
'ARCHITECTURE',
|
|
11
|
+
'COMPLETION-PLAN',
|
|
12
|
+
'NOTE',
|
|
13
|
+
'AUDIT-REPORT',
|
|
14
|
+
'RESEARCH',
|
|
15
|
+
'DISCUSSION-addendum',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const PHASE_FILE_PATTERN = /^([A-Za-z0-9]+)-([A-Za-z-]+)\.md(\.superseded)?$/;
|
|
19
|
+
|
|
20
|
+
function printUsage(stream = process.stderr) {
|
|
21
|
+
stream.write(
|
|
22
|
+
'Usage: node scripts/renumber-phases.js --repo-path <path> --prefix <PREFIX> [--dry-run]\n'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const options = {
|
|
28
|
+
dryRun: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
32
|
+
const arg = argv[index];
|
|
33
|
+
|
|
34
|
+
if (arg === '--dry-run') {
|
|
35
|
+
options.dryRun = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (arg === '--repo-path' || arg === '--prefix') {
|
|
40
|
+
const value = argv[index + 1];
|
|
41
|
+
if (!value || value.startsWith('--')) {
|
|
42
|
+
throw new Error(`Missing value for ${arg}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (arg === '--repo-path') {
|
|
46
|
+
options.repoPath = value;
|
|
47
|
+
} else {
|
|
48
|
+
options.prefix = value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
index += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!options.repoPath || !options.prefix) {
|
|
59
|
+
throw new Error('Missing required arguments');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return options;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function escapeRegExp(value) {
|
|
66
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createPhaseReferenceRegex(prefix) {
|
|
70
|
+
return new RegExp(`\\bPhase\\s+(${escapeRegExp(prefix)}-)?(\\d+[A-Za-z]?)\\b`, 'g');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function updatePhaseReferences(content, prefix) {
|
|
74
|
+
const regex = createPhaseReferenceRegex(prefix);
|
|
75
|
+
let replacements = 0;
|
|
76
|
+
|
|
77
|
+
const updated = content.replace(regex, (match, existingPrefix, phaseId) => {
|
|
78
|
+
if (existingPrefix) {
|
|
79
|
+
return match;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
replacements += 1;
|
|
83
|
+
return `Phase ${prefix}-${phaseId}`;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
content: updated,
|
|
88
|
+
replacements,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isEligiblePhaseFile(name, prefix) {
|
|
93
|
+
const match = name.match(PHASE_FILE_PATTERN);
|
|
94
|
+
if (!match) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const [, phaseId, suffix, supersededExtension = ''] = match;
|
|
99
|
+
|
|
100
|
+
if (phaseId.startsWith(`${prefix}-`)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!/^\d+[A-Za-z]?$/.test(phaseId)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!ALLOWED_SUFFIXES.has(suffix)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
phaseId,
|
|
114
|
+
suffix,
|
|
115
|
+
supersededExtension,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function collectOperations(repoPath, prefix) {
|
|
120
|
+
const phasesDir = path.join(repoPath, '.planning', 'phases');
|
|
121
|
+
if (!fs.existsSync(phasesDir) || !fs.statSync(phasesDir).isDirectory()) {
|
|
122
|
+
throw new Error(`Missing phases directory: ${phasesDir}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const roadmapPath = path.join(repoPath, '.planning', 'ROADMAP.md');
|
|
126
|
+
const operations = [];
|
|
127
|
+
const errors = [];
|
|
128
|
+
|
|
129
|
+
for (const entry of fs.readdirSync(phasesDir, { withFileTypes: true })) {
|
|
130
|
+
if (!entry.isFile()) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const fileInfo = isEligiblePhaseFile(entry.name, prefix);
|
|
135
|
+
if (!fileInfo) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const nextName = `${prefix}-${fileInfo.phaseId}-${fileInfo.suffix}.md${fileInfo.supersededExtension}`;
|
|
140
|
+
operations.push({
|
|
141
|
+
type: 'rename-file',
|
|
142
|
+
from: path.join(phasesDir, entry.name),
|
|
143
|
+
to: path.join(phasesDir, nextName),
|
|
144
|
+
phaseId: fileInfo.phaseId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (fs.existsSync(roadmapPath)) {
|
|
149
|
+
const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
|
|
150
|
+
const updatedRoadmap = updatePhaseReferences(roadmapContent, prefix);
|
|
151
|
+
|
|
152
|
+
if (updatedRoadmap.replacements > 0) {
|
|
153
|
+
operations.push({
|
|
154
|
+
type: 'update-file',
|
|
155
|
+
target: roadmapPath,
|
|
156
|
+
nextContent: updatedRoadmap.content,
|
|
157
|
+
replacements: updatedRoadmap.replacements,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const renameOperation of operations.filter((operation) => operation.type === 'rename-file')) {
|
|
163
|
+
const content = fs.readFileSync(renameOperation.from, 'utf8');
|
|
164
|
+
const updated = updatePhaseReferences(content, prefix);
|
|
165
|
+
|
|
166
|
+
if (updated.replacements > 0) {
|
|
167
|
+
operations.push({
|
|
168
|
+
type: 'update-renamed-file',
|
|
169
|
+
target: renameOperation.to,
|
|
170
|
+
sourcePath: renameOperation.from,
|
|
171
|
+
nextContent: updated.content,
|
|
172
|
+
replacements: updated.replacements,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const renameOperation of operations.filter((operation) => operation.type === 'rename-file')) {
|
|
178
|
+
if (!fs.existsSync(renameOperation.to)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
errors.push(
|
|
183
|
+
`Cannot rename ${path.basename(renameOperation.from)} because ${path.basename(renameOperation.to)} already exists`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
operations,
|
|
189
|
+
errors,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function applyOperations(operations, dryRun) {
|
|
194
|
+
const summary = {
|
|
195
|
+
renamed: 0,
|
|
196
|
+
updated: 0,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
for (const operation of operations) {
|
|
200
|
+
if (operation.type === 'rename-file') {
|
|
201
|
+
if (dryRun) {
|
|
202
|
+
console.log(`DRY-RUN rename ${operation.from} -> ${operation.to}`);
|
|
203
|
+
} else {
|
|
204
|
+
fs.renameSync(operation.from, operation.to);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
summary.renamed += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (operation.type === 'update-file' || operation.type === 'update-renamed-file') {
|
|
212
|
+
if (dryRun) {
|
|
213
|
+
console.log(`DRY-RUN update ${operation.target} (${operation.replacements} references)`);
|
|
214
|
+
} else {
|
|
215
|
+
fs.writeFileSync(operation.target, operation.nextContent);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
summary.updated += operation.replacements;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return summary;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renumberPhases({ repoPath, prefix, dryRun = false }) {
|
|
226
|
+
const resolvedRepoPath = path.resolve(repoPath);
|
|
227
|
+
const trimmedPrefix = prefix.trim();
|
|
228
|
+
|
|
229
|
+
if (!trimmedPrefix) {
|
|
230
|
+
throw new Error('Prefix must not be empty');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { operations, errors } = collectOperations(resolvedRepoPath, trimmedPrefix);
|
|
234
|
+
const summary = applyOperations(operations, dryRun);
|
|
235
|
+
|
|
236
|
+
for (const error of errors) {
|
|
237
|
+
console.error(error);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const finalSummary = {
|
|
241
|
+
renamed: summary.renamed,
|
|
242
|
+
updated: summary.updated,
|
|
243
|
+
errors: errors.length,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
console.log(
|
|
247
|
+
`Renamed: ${finalSummary.renamed} files, Updated: ${finalSummary.updated} references, Errors: ${finalSummary.errors}`
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return finalSummary;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function main(argv = process.argv.slice(2)) {
|
|
254
|
+
try {
|
|
255
|
+
const options = parseArgs(argv);
|
|
256
|
+
renumberPhases(options);
|
|
257
|
+
return 0;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
260
|
+
|
|
261
|
+
if (message.startsWith('Missing required arguments') || message.startsWith('Missing value for')) {
|
|
262
|
+
printUsage(process.stderr);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
console.error(message);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (require.main === module) {
|
|
271
|
+
process.exit(main());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
ALLOWED_SUFFIXES,
|
|
276
|
+
PHASE_FILE_PATTERN,
|
|
277
|
+
collectOperations,
|
|
278
|
+
main,
|
|
279
|
+
parseArgs,
|
|
280
|
+
printUsage,
|
|
281
|
+
renumberPhases,
|
|
282
|
+
updatePhaseReferences,
|
|
283
|
+
};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import renumberModule from './renumber-phases.js';
|
|
9
|
+
|
|
10
|
+
const { main, parseArgs, renumberPhases, updatePhaseReferences } = renumberModule;
|
|
11
|
+
|
|
12
|
+
const SCRIPT_PATH = path.join(process.cwd(), 'scripts', 'renumber-phases.js');
|
|
13
|
+
const tempDirs = [];
|
|
14
|
+
|
|
15
|
+
function createTempRepo(structure = {}) {
|
|
16
|
+
const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'renumber-phases-'));
|
|
17
|
+
tempDirs.push(repoPath);
|
|
18
|
+
|
|
19
|
+
for (const [relativePath, content] of Object.entries(structure)) {
|
|
20
|
+
const filePath = path.join(repoPath, relativePath);
|
|
21
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
22
|
+
fs.writeFileSync(filePath, content);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return repoPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readFile(repoPath, relativePath) {
|
|
29
|
+
return fs.readFileSync(path.join(repoPath, relativePath), 'utf8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fileExists(repoPath, relativePath) {
|
|
33
|
+
return fs.existsSync(path.join(repoPath, relativePath));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createPlanningRepo() {
|
|
37
|
+
return createTempRepo({
|
|
38
|
+
'.planning/ROADMAP.md': [
|
|
39
|
+
'# Roadmap',
|
|
40
|
+
'### Phase 108: Build the tool',
|
|
41
|
+
'Depends on Phase 107 and Phase 101b.',
|
|
42
|
+
'Already migrated: Phase TLC-999.',
|
|
43
|
+
'',
|
|
44
|
+
].join('\n'),
|
|
45
|
+
'.planning/phases/108-PLAN.md': [
|
|
46
|
+
'# Phase 108',
|
|
47
|
+
'Depends on Phase 107.',
|
|
48
|
+
'Review Phase 101b before release.',
|
|
49
|
+
'Already references Phase TLC-105 correctly.',
|
|
50
|
+
'',
|
|
51
|
+
].join('\n'),
|
|
52
|
+
'.planning/phases/108-DISCUSSION.md': 'Phase 108 requires context from Phase 107.\n',
|
|
53
|
+
'.planning/phases/101b-RESEARCH.md': 'Research for Phase 101b.\n',
|
|
54
|
+
'.planning/phases/108-PLAN.md.superseded': 'Superseded Phase 108 draft.\n',
|
|
55
|
+
'.planning/phases/TLC-105-PLAN.md': 'Already migrated.\n',
|
|
56
|
+
'.planning/phases/77-OldPLAN.md': 'Should be ignored.\n',
|
|
57
|
+
'.planning/phases/77-PLAN.md.md': 'Should be ignored.\n',
|
|
58
|
+
'.planning/phases/notes.txt': 'Should be ignored.\n',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
|
|
65
|
+
while (tempDirs.length > 0) {
|
|
66
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('updatePhaseReferences', () => {
|
|
71
|
+
it('adds the prefix to bare phase references and leaves prefixed ones unchanged', () => {
|
|
72
|
+
const source = 'Phase 108 follows Phase 101b. Phase TLC-42 stays as-is.';
|
|
73
|
+
|
|
74
|
+
const result = updatePhaseReferences(source, 'TLC');
|
|
75
|
+
|
|
76
|
+
expect(result.content).toBe(
|
|
77
|
+
'Phase TLC-108 follows Phase TLC-101b. Phase TLC-42 stays as-is.'
|
|
78
|
+
);
|
|
79
|
+
expect(result.replacements).toBe(2);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('parseArgs', () => {
|
|
84
|
+
it('parses repo path, prefix, and dry-run', () => {
|
|
85
|
+
expect(
|
|
86
|
+
parseArgs(['--repo-path', '/tmp/repo', '--prefix', 'TLC', '--dry-run'])
|
|
87
|
+
).toEqual({
|
|
88
|
+
dryRun: true,
|
|
89
|
+
repoPath: '/tmp/repo',
|
|
90
|
+
prefix: 'TLC',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throws when required arguments are missing', () => {
|
|
95
|
+
expect(() => parseArgs(['--prefix', 'TLC'])).toThrow('Missing required arguments');
|
|
96
|
+
expect(() => parseArgs(['--repo-path', '/tmp/repo'])).toThrow('Missing required arguments');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('renumberPhases', () => {
|
|
101
|
+
it('renames matching files, updates roadmap references, and preserves ignored files', () => {
|
|
102
|
+
const repoPath = createPlanningRepo();
|
|
103
|
+
const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
104
|
+
const stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
105
|
+
|
|
106
|
+
const summary = renumberPhases({
|
|
107
|
+
repoPath,
|
|
108
|
+
prefix: 'TLC',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(summary).toEqual({
|
|
112
|
+
renamed: 4,
|
|
113
|
+
updated: 10,
|
|
114
|
+
errors: 0,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(true);
|
|
118
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-DISCUSSION.md')).toBe(true);
|
|
119
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-101b-RESEARCH.md')).toBe(true);
|
|
120
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md.superseded')).toBe(true);
|
|
121
|
+
|
|
122
|
+
expect(fileExists(repoPath, '.planning/phases/108-PLAN.md')).toBe(false);
|
|
123
|
+
expect(fileExists(repoPath, '.planning/phases/108-DISCUSSION.md')).toBe(false);
|
|
124
|
+
expect(fileExists(repoPath, '.planning/phases/101b-RESEARCH.md')).toBe(false);
|
|
125
|
+
expect(fileExists(repoPath, '.planning/phases/108-PLAN.md.superseded')).toBe(false);
|
|
126
|
+
|
|
127
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-105-PLAN.md')).toBe(true);
|
|
128
|
+
expect(fileExists(repoPath, '.planning/phases/77-OldPLAN.md')).toBe(true);
|
|
129
|
+
expect(fileExists(repoPath, '.planning/phases/77-PLAN.md.md')).toBe(true);
|
|
130
|
+
|
|
131
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-108: Build the tool');
|
|
132
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('Depends on Phase TLC-107 and Phase TLC-101b.');
|
|
133
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('Already migrated: Phase TLC-999.');
|
|
134
|
+
|
|
135
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain('Depends on Phase TLC-107.');
|
|
136
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain(
|
|
137
|
+
'Review Phase TLC-101b before release.'
|
|
138
|
+
);
|
|
139
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain(
|
|
140
|
+
'Already references Phase TLC-105 correctly.'
|
|
141
|
+
);
|
|
142
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-DISCUSSION.md')).toContain(
|
|
143
|
+
'Phase TLC-108 requires context from Phase TLC-107.'
|
|
144
|
+
);
|
|
145
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md.superseded')).toContain(
|
|
146
|
+
'Superseded Phase TLC-108 draft.'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(stdout).toHaveBeenLastCalledWith('Renamed: 4 files, Updated: 10 references, Errors: 0');
|
|
150
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('supports dry-run without modifying files', () => {
|
|
154
|
+
const repoPath = createPlanningRepo();
|
|
155
|
+
const originalRoadmap = readFile(repoPath, '.planning/ROADMAP.md');
|
|
156
|
+
const originalPlan = readFile(repoPath, '.planning/phases/108-PLAN.md');
|
|
157
|
+
const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
158
|
+
|
|
159
|
+
const summary = renumberPhases({
|
|
160
|
+
repoPath,
|
|
161
|
+
prefix: 'TLC',
|
|
162
|
+
dryRun: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(summary).toEqual({
|
|
166
|
+
renamed: 4,
|
|
167
|
+
updated: 10,
|
|
168
|
+
errors: 0,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(fileExists(repoPath, '.planning/phases/108-PLAN.md')).toBe(true);
|
|
172
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(false);
|
|
173
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toBe(originalRoadmap);
|
|
174
|
+
expect(readFile(repoPath, '.planning/phases/108-PLAN.md')).toBe(originalPlan);
|
|
175
|
+
|
|
176
|
+
expect(stdout.mock.calls).toEqual(
|
|
177
|
+
expect.arrayContaining([
|
|
178
|
+
[expect.stringContaining('DRY-RUN rename')],
|
|
179
|
+
[expect.stringContaining('DRY-RUN update')],
|
|
180
|
+
['Renamed: 4 files, Updated: 10 references, Errors: 0'],
|
|
181
|
+
])
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('is idempotent when run again on already-prefixed files', () => {
|
|
186
|
+
const repoPath = createPlanningRepo();
|
|
187
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
188
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
189
|
+
|
|
190
|
+
const firstSummary = renumberPhases({
|
|
191
|
+
repoPath,
|
|
192
|
+
prefix: 'TLC',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const secondSummary = renumberPhases({
|
|
196
|
+
repoPath,
|
|
197
|
+
prefix: 'TLC',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(firstSummary).toEqual({
|
|
201
|
+
renamed: 4,
|
|
202
|
+
updated: 10,
|
|
203
|
+
errors: 0,
|
|
204
|
+
});
|
|
205
|
+
expect(secondSummary).toEqual({
|
|
206
|
+
renamed: 0,
|
|
207
|
+
updated: 0,
|
|
208
|
+
errors: 0,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(true);
|
|
212
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-108: Build the tool');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('reports rename collisions as errors without overwriting files', () => {
|
|
216
|
+
const repoPath = createTempRepo({
|
|
217
|
+
'.planning/ROADMAP.md': '### Phase 108: Collision case\nPhase 108 only.\n',
|
|
218
|
+
'.planning/phases/108-PLAN.md': 'Phase 108.\n',
|
|
219
|
+
'.planning/phases/TLC-108-PLAN.md': 'Existing target.\n',
|
|
220
|
+
});
|
|
221
|
+
const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
222
|
+
const stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
223
|
+
|
|
224
|
+
const summary = renumberPhases({
|
|
225
|
+
repoPath,
|
|
226
|
+
prefix: 'TLC',
|
|
227
|
+
dryRun: true,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(summary).toEqual({
|
|
231
|
+
renamed: 1,
|
|
232
|
+
updated: 3,
|
|
233
|
+
errors: 1,
|
|
234
|
+
});
|
|
235
|
+
expect(stderr).toHaveBeenCalledWith(
|
|
236
|
+
'Cannot rename 108-PLAN.md because TLC-108-PLAN.md already exists'
|
|
237
|
+
);
|
|
238
|
+
expect(stdout).toHaveBeenLastCalledWith('Renamed: 1 files, Updated: 3 references, Errors: 1');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('throws when the phases directory is missing', () => {
|
|
242
|
+
const repoPath = createTempRepo({
|
|
243
|
+
'.planning/ROADMAP.md': '### Phase 1: Missing phases dir\n',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(() =>
|
|
247
|
+
renumberPhases({
|
|
248
|
+
repoPath,
|
|
249
|
+
prefix: 'TLC',
|
|
250
|
+
})
|
|
251
|
+
).toThrow(`Missing phases directory: ${path.join(repoPath, '.planning', 'phases')}`);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('main', () => {
|
|
256
|
+
it('returns 1 and prints usage for missing repo-path', () => {
|
|
257
|
+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
258
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
259
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
260
|
+
|
|
261
|
+
const exitCode = main(['--prefix', 'TLC']);
|
|
262
|
+
|
|
263
|
+
expect(exitCode).toBe(1);
|
|
264
|
+
expect(stdout).not.toHaveBeenCalled();
|
|
265
|
+
expect(stderr).toHaveBeenCalledWith(
|
|
266
|
+
'Usage: node scripts/renumber-phases.js --repo-path <path> --prefix <PREFIX> [--dry-run]\n'
|
|
267
|
+
);
|
|
268
|
+
expect(consoleError).toHaveBeenCalledWith('Missing required arguments');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('returns 1 when the phases directory does not exist', () => {
|
|
272
|
+
const repoPath = createTempRepo({
|
|
273
|
+
'.planning/ROADMAP.md': '### Phase 1: Missing phases dir\n',
|
|
274
|
+
});
|
|
275
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
276
|
+
|
|
277
|
+
const exitCode = main(['--repo-path', repoPath, '--prefix', 'TLC']);
|
|
278
|
+
|
|
279
|
+
expect(exitCode).toBe(1);
|
|
280
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
281
|
+
`Missing phases directory: ${path.join(repoPath, '.planning', 'phases')}`
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('works from the real CLI entry point', () => {
|
|
286
|
+
const repoPath = createTempRepo({
|
|
287
|
+
'.planning/ROADMAP.md': '### Phase 12: CLI check\nPhase 12 is ready.\n',
|
|
288
|
+
'.planning/phases/12-PLAN.md': 'Phase 12 is ready.\n',
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const result = spawnSync(
|
|
292
|
+
process.execPath,
|
|
293
|
+
[SCRIPT_PATH, '--repo-path', repoPath, '--prefix', 'TLC'],
|
|
294
|
+
{
|
|
295
|
+
encoding: 'utf8',
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
expect(result.status).toBe(0);
|
|
300
|
+
expect(result.stderr).toBe('');
|
|
301
|
+
expect(result.stdout).toContain('Renamed: 1 files, Updated: 3 references, Errors: 0');
|
|
302
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-12-PLAN.md')).toBe(true);
|
|
303
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-12: CLI check');
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const MANIFEST_FILENAME = '.tlc-workspace.json';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate the parsed workspace manifest structure.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} manifest - Parsed manifest JSON.
|
|
10
|
+
* @returns {object} The validated manifest object.
|
|
11
|
+
*/
|
|
12
|
+
function validateManifest(manifest) {
|
|
13
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
14
|
+
throw new Error(`Invalid workspace manifest: expected object in ${MANIFEST_FILENAME}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!manifest.repos || typeof manifest.repos !== 'object' || Array.isArray(manifest.repos)) {
|
|
18
|
+
throw new Error(`Invalid workspace manifest: missing repos key in ${MANIFEST_FILENAME}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return manifest;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Discover and parse the nearest workspace manifest by walking up the tree.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} startDir - Directory to start searching from.
|
|
28
|
+
* @returns {object|null} Parsed workspace manifest, or null when not found.
|
|
29
|
+
*/
|
|
30
|
+
function discoverWorkspace(startDir) {
|
|
31
|
+
let currentDir = path.resolve(startDir);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.statSync(currentDir).isDirectory()) {
|
|
35
|
+
currentDir = path.dirname(currentDir);
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
currentDir = path.dirname(currentDir);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
while (true) {
|
|
42
|
+
const manifestPath = path.join(currentDir, MANIFEST_FILENAME);
|
|
43
|
+
|
|
44
|
+
if (fs.existsSync(manifestPath)) {
|
|
45
|
+
let parsed;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Failed to parse workspace manifest at ${manifestPath}: ${error.message}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return validateManifest(parsed);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parentDir = path.dirname(currentDir);
|
|
59
|
+
if (parentDir === currentDir) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
currentDir = parentDir;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a repository definition by prefix.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} manifest - Parsed workspace manifest.
|
|
70
|
+
* @param {string} prefix - Repo prefix to look up.
|
|
71
|
+
* @returns {{ path: string, prefix: string, role: string }|null} Repo metadata or null.
|
|
72
|
+
*/
|
|
73
|
+
function resolveRepo(manifest, prefix) {
|
|
74
|
+
if (!manifest || !manifest.repos || !prefix) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const repoConfig of Object.values(manifest.repos)) {
|
|
79
|
+
if (repoConfig && repoConfig.prefix === prefix) {
|
|
80
|
+
return {
|
|
81
|
+
path: repoConfig.path,
|
|
82
|
+
prefix: repoConfig.prefix,
|
|
83
|
+
role: repoConfig.role,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a phase identifier into repo, prefix, and phase components.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} manifest - Parsed workspace manifest.
|
|
95
|
+
* @param {string} phaseId - Phase identifier such as "CORE-2" or "108".
|
|
96
|
+
* @param {string} currentRepoPrefix - Prefix of the current repo for unprefixed phase IDs.
|
|
97
|
+
* @returns {{ repo: string, prefix: string, phase: string }|null} Resolved phase metadata or null.
|
|
98
|
+
*/
|
|
99
|
+
function resolvePhase(manifest, phaseId, currentRepoPrefix) {
|
|
100
|
+
if (!manifest || !manifest.repos || typeof phaseId !== 'string' || phaseId.length === 0) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const prefixedMatch = /^([A-Z]+)-(.+)$/.exec(phaseId);
|
|
105
|
+
if (prefixedMatch) {
|
|
106
|
+
const [, prefix, phase] = prefixedMatch;
|
|
107
|
+
|
|
108
|
+
for (const [repo, repoConfig] of Object.entries(manifest.repos)) {
|
|
109
|
+
if (repoConfig && repoConfig.prefix === prefix) {
|
|
110
|
+
return { repo, prefix, phase };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!currentRepoPrefix) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const [repo, repoConfig] of Object.entries(manifest.repos)) {
|
|
122
|
+
if (repoConfig && repoConfig.prefix === currentRepoPrefix) {
|
|
123
|
+
return {
|
|
124
|
+
repo,
|
|
125
|
+
prefix: currentRepoPrefix,
|
|
126
|
+
phase: phaseId,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
discoverWorkspace,
|
|
136
|
+
resolveRepo,
|
|
137
|
+
resolvePhase,
|
|
138
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
discoverWorkspace,
|
|
8
|
+
resolveRepo,
|
|
9
|
+
resolvePhase,
|
|
10
|
+
} = await import('./workspace-manifest.js');
|
|
11
|
+
|
|
12
|
+
function makeTmpDir() {
|
|
13
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-manifest-'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeJson(filePath, value) {
|
|
17
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function removeDir(dirPath) {
|
|
21
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createManifest(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
workspace: 'tlc-platform',
|
|
27
|
+
repos: {
|
|
28
|
+
TLC: { path: 'TLC', prefix: 'TLC', role: 'Runtime library' },
|
|
29
|
+
'tlc-core': { path: 'tlc-core', prefix: 'CORE', role: 'Execution substrate' },
|
|
30
|
+
'tlc-standalone': {
|
|
31
|
+
path: 'tlc-standalone',
|
|
32
|
+
prefix: 'SA',
|
|
33
|
+
role: 'Provider-agnostic CLI',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
orchestrator: { url: 'http://localhost:3100', provider: 'tlc-core' },
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('workspace-manifest', () => {
|
|
42
|
+
const tempDirs = [];
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
while (tempDirs.length > 0) {
|
|
46
|
+
removeDir(tempDirs.pop());
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('discoverWorkspace from subdirectory finds manifest at parent', () => {
|
|
51
|
+
const rootDir = makeTmpDir();
|
|
52
|
+
tempDirs.push(rootDir);
|
|
53
|
+
|
|
54
|
+
const nestedDir = path.join(rootDir, 'TLC', 'server', 'lib');
|
|
55
|
+
fs.mkdirSync(nestedDir, { recursive: true });
|
|
56
|
+
writeJson(path.join(rootDir, '.tlc-workspace.json'), createManifest());
|
|
57
|
+
|
|
58
|
+
const manifest = discoverWorkspace(nestedDir);
|
|
59
|
+
|
|
60
|
+
expect(manifest).not.toBeNull();
|
|
61
|
+
expect(manifest.workspace).toBe('tlc-platform');
|
|
62
|
+
expect(manifest.repos.TLC.path).toBe('TLC');
|
|
63
|
+
expect(manifest.repos['tlc-core'].prefix).toBe('CORE');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('resolveRepo("CORE") returns tlc-core metadata', () => {
|
|
67
|
+
const manifest = createManifest();
|
|
68
|
+
|
|
69
|
+
expect(resolveRepo(manifest, 'CORE')).toEqual({
|
|
70
|
+
path: 'tlc-core',
|
|
71
|
+
prefix: 'CORE',
|
|
72
|
+
role: 'Execution substrate',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('resolvePhase("CORE-2") parses correctly', () => {
|
|
77
|
+
const manifest = createManifest();
|
|
78
|
+
|
|
79
|
+
expect(resolvePhase(manifest, 'CORE-2', 'TLC')).toEqual({
|
|
80
|
+
repo: 'tlc-core',
|
|
81
|
+
prefix: 'CORE',
|
|
82
|
+
phase: '2',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('resolvePhase("108", "TLC") returns TLC-108', () => {
|
|
87
|
+
const manifest = createManifest();
|
|
88
|
+
|
|
89
|
+
expect(resolvePhase(manifest, '108', 'TLC')).toEqual({
|
|
90
|
+
repo: 'TLC',
|
|
91
|
+
prefix: 'TLC',
|
|
92
|
+
phase: '108',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('discoverWorkspace from /tmp returns null when no manifest exists in tree', () => {
|
|
97
|
+
const rootDir = makeTmpDir();
|
|
98
|
+
tempDirs.push(rootDir);
|
|
99
|
+
|
|
100
|
+
const nestedDir = path.join(rootDir, 'lonely', 'child');
|
|
101
|
+
fs.mkdirSync(nestedDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
expect(discoverWorkspace(nestedDir)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('resolveRepo("UNKNOWN") returns null', () => {
|
|
107
|
+
const manifest = createManifest();
|
|
108
|
+
|
|
109
|
+
expect(resolveRepo(manifest, 'UNKNOWN')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('resolvePhase("NOPE-1") returns null', () => {
|
|
113
|
+
const manifest = createManifest();
|
|
114
|
+
|
|
115
|
+
expect(resolvePhase(manifest, 'NOPE-1', 'TLC')).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('invalid JSON throws descriptive error', () => {
|
|
119
|
+
const rootDir = makeTmpDir();
|
|
120
|
+
tempDirs.push(rootDir);
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(
|
|
123
|
+
path.join(rootDir, '.tlc-workspace.json'),
|
|
124
|
+
'{"workspace":"tlc-platform","repos":'
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(() => discoverWorkspace(rootDir)).toThrow(/parse workspace manifest/i);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('manifest missing repos key throws descriptive error', () => {
|
|
131
|
+
const rootDir = makeTmpDir();
|
|
132
|
+
tempDirs.push(rootDir);
|
|
133
|
+
|
|
134
|
+
writeJson(path.join(rootDir, '.tlc-workspace.json'), {
|
|
135
|
+
workspace: 'tlc-platform',
|
|
136
|
+
orchestrator: { url: 'http://localhost:3100', provider: 'tlc-core' },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(() => discoverWorkspace(rootDir)).toThrow(/missing repos key/i);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('discoverWorkspace accepts a file path and searches from its parent directory', () => {
|
|
143
|
+
const rootDir = makeTmpDir();
|
|
144
|
+
tempDirs.push(rootDir);
|
|
145
|
+
|
|
146
|
+
const repoDir = path.join(rootDir, 'TLC');
|
|
147
|
+
const filePath = path.join(repoDir, 'server', 'lib', 'workspace-manifest.js');
|
|
148
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
149
|
+
fs.writeFileSync(filePath, '// fixture');
|
|
150
|
+
writeJson(path.join(rootDir, '.tlc-workspace.json'), createManifest());
|
|
151
|
+
|
|
152
|
+
const manifest = discoverWorkspace(filePath);
|
|
153
|
+
|
|
154
|
+
expect(manifest.repos['tlc-standalone']).toEqual({
|
|
155
|
+
path: 'tlc-standalone',
|
|
156
|
+
prefix: 'SA',
|
|
157
|
+
role: 'Provider-agnostic CLI',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('resolvePhase returns null for unprefixed phase when current repo prefix is unknown', () => {
|
|
162
|
+
const manifest = createManifest();
|
|
163
|
+
|
|
164
|
+
expect(resolvePhase(manifest, '108', 'NOPE')).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('resolvePhase returns null when current repo prefix is not provided for unprefixed phase', () => {
|
|
168
|
+
const manifest = createManifest();
|
|
169
|
+
|
|
170
|
+
expect(resolvePhase(manifest, '108')).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('resolveRepo returns null when prefix is missing', () => {
|
|
174
|
+
const manifest = createManifest();
|
|
175
|
+
|
|
176
|
+
expect(resolveRepo(manifest)).toBeNull();
|
|
177
|
+
expect(resolveRepo(manifest, '')).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
});
|