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.
@@ -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`. Extract each task's goal, files, acceptance criteria, and test cases.
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 all `.planning/phases/{phase}-*-PLAN.md` files for this phase.
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/{N}-DISCUSSION.md` - implementation preferences
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/{N}-RESEARCH.md` with findings.
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 {N}: {Name} - Plan
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/{N}-PLAN.md" 2>/dev/null
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/{N}-PLAN.md
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: Parse Roadmap
34
+ ### Step 2: Detect Workspace Scope
35
35
 
36
- Read `.planning/ROADMAP.md` and identify:
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
- ### Step 3: Check Current Phase State
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 4: Run Tests
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 5: Present Status
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 6: Suggest Next Action
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 {N}` |
109
- | Discussed, no plan | `/tlc:plan {N}` |
110
- | Planned, no tests | `/tlc:build {N}` |
111
- | Tests failing | Fix failures, then `/tlc:build {N}` |
112
- | Tests passing, not verified | `/tlc:verify {N}` |
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
- # Post as PR comment
79
- gh pr comment <number> --body "<review_markdown>"
80
-
81
- # Or submit as review
82
- gh pr review <number> --approve --body "<review_markdown>"
83
- gh pr review <number> --request-changes --body "<review_markdown>"
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 test framework** (Vitest, Jest, pytest, etc.)
16
- 2. **Run the test suite**
17
- 3. **Report results** with next action
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`, check:
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 {N}`
243
+ Run `/tlc:discuss {PHASE_ID}`
232
244
  2. Discussion exists, no plan:
233
- Run `/tlc:plan {N}`
245
+ Run `/tlc:plan {PHASE_ID}`
234
246
  3. Plan exists and phase is not fully built:
235
- Run `/tlc:build {N}`
247
+ Run `/tlc:build {PHASE_ID}`
236
248
  4. Build complete, no verification file:
237
- Run `/tlc:verify {N}`
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 {N}: {Name} | {done}/{total} tasks done
264
+ TLC v{version} | Phase {PHASE_ID}: {Name} | {done}/{total} tasks done
249
265
  Next: {action summary}
250
- Running /tlc:{command} {N}...
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 {N}
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 {N}
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 {N}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tlc-claude-code",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "TLC - Test Led Coding for Claude Code",
5
5
  "bin": {
6
6
  "tlc-claude-code": "./bin/install.js",
@@ -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
+ });