@izkac/forgekit 0.3.13 → 0.3.14
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/bin/forge.mjs +14 -2
- package/package.json +2 -1
- package/src/fleet.test.mjs +50 -0
- package/src/integrity.mjs +7 -1
- package/src/integrity.test.mjs +46 -0
- package/src/lib/fleet.mjs +61 -4
- package/src/lib.mjs +20 -3
- package/src/lib.test.mjs +128 -0
- package/src/repo-root.mjs +33 -0
- package/src/specs-sync.mjs +2 -4
package/bin/forge.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { findRepoRoot } from '../src/repo-root.mjs';
|
|
12
13
|
|
|
13
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const SRC = path.join(__dirname, '..', 'src');
|
|
@@ -93,12 +94,23 @@ if (!entry) {
|
|
|
93
94
|
process.exit(1);
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
// Every subcommand is project-scoped, so run it from the project root rather
|
|
98
|
+
// than wherever the shell happens to sit: `cd crates && forge status` used to
|
|
99
|
+
// report "no session" and `forge new` would have written a second .forge tree
|
|
100
|
+
// inside the workspace. An explicit relative `--cwd` still means what the
|
|
101
|
+
// caller typed, so absolutize it against the invocation dir first.
|
|
102
|
+
const invokedFrom = process.cwd();
|
|
103
|
+
const repoRoot = findRepoRoot(invokedFrom);
|
|
104
|
+
const args = [...(entry.prependArgs ?? []), ...rest].map((arg, i, all) =>
|
|
105
|
+
i > 0 && all[i - 1] === '--cwd' && !path.isAbsolute(arg) ? path.resolve(invokedFrom, arg) : arg,
|
|
106
|
+
);
|
|
107
|
+
|
|
97
108
|
const r = spawnSync(process.execPath, [path.join(SRC, entry.script), ...args], {
|
|
98
109
|
stdio: 'inherit',
|
|
99
|
-
cwd:
|
|
110
|
+
cwd: repoRoot,
|
|
100
111
|
env: {
|
|
101
112
|
...process.env,
|
|
113
|
+
FORGE_INVOKED_FROM: invokedFrom,
|
|
102
114
|
FORGEKIT_ROOT: path.resolve(__dirname, '..', '..', '..'),
|
|
103
115
|
FORGEKIT_CLI_ROOT: path.resolve(__dirname, '..'),
|
|
104
116
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@izkac/forgekit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"prepack": "node scripts/prepack.mjs",
|
|
19
|
+
"prepublishOnly": "npm run lint && npm test",
|
|
19
20
|
"test": "node scripts/run-tests.mjs",
|
|
20
21
|
"lint": "eslint src bin scripts"
|
|
21
22
|
},
|
package/src/fleet.test.mjs
CHANGED
|
@@ -80,6 +80,56 @@ test('listFleet self-heals entries whose session dir is gone', () => {
|
|
|
80
80
|
assert.equal(fs.existsSync(entryFile(project, 'gone')), false);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test('listFleet reconciles a stale entry against session.json on disk', () => {
|
|
84
|
+
// Regression: the registry is a cache, not a source of truth. A session
|
|
85
|
+
// whose phase advanced without a mirroring write (older CLI, a crash, a
|
|
86
|
+
// hand-edited record) showed its first-registered phase forever — helm's
|
|
87
|
+
// phase-0 sat at `brainstorm` in `forge fleet list` after reaching `done`.
|
|
88
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-reconcile-'), 'sessions');
|
|
89
|
+
const project = tmp('fleet-proj-');
|
|
90
|
+
const sessionDir = makeProject(project, 's5');
|
|
91
|
+
|
|
92
|
+
registerSession(project, makeSession('s5', { phase: 'brainstorm', tasksComplete: 0 }));
|
|
93
|
+
// Disk moves on without the registry.
|
|
94
|
+
const later = new Date(Date.now() + 60_000).toISOString();
|
|
95
|
+
fs.writeFileSync(
|
|
96
|
+
path.join(sessionDir, 'session.json'),
|
|
97
|
+
`${JSON.stringify(
|
|
98
|
+
makeSession('s5', {
|
|
99
|
+
phase: 'done',
|
|
100
|
+
tasksTotal: 20,
|
|
101
|
+
tasksComplete: 20,
|
|
102
|
+
openspecChange: 'phase-0',
|
|
103
|
+
updatedAt: later,
|
|
104
|
+
}),
|
|
105
|
+
)}\n`,
|
|
106
|
+
'utf8',
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const [entry] = listFleet();
|
|
110
|
+
assert.equal(entry.phase, 'done');
|
|
111
|
+
assert.equal(entry.tasksComplete, 20);
|
|
112
|
+
assert.equal(entry.tasksTotal, 20);
|
|
113
|
+
assert.equal(entry.openspecChange, 'phase-0');
|
|
114
|
+
assert.equal(entry.updatedAt, later);
|
|
115
|
+
// Reconciliation is persisted, so the next read is already correct.
|
|
116
|
+
assert.equal(JSON.parse(fs.readFileSync(entryFile(project, 's5'), 'utf8')).phase, 'done');
|
|
117
|
+
// lastSeen is a heartbeat, not a session field — reconciling must not forge one.
|
|
118
|
+
assert.ok(entry.lastSeen <= new Date().toISOString());
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('listFleet keeps registry-only fields when session.json is unreadable', () => {
|
|
122
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-unreadable-'), 'sessions');
|
|
123
|
+
const project = tmp('fleet-proj-');
|
|
124
|
+
const sessionDir = makeProject(project, 's6');
|
|
125
|
+
registerSession(project, makeSession('s6', { phase: 'verify' }));
|
|
126
|
+
fs.writeFileSync(path.join(sessionDir, 'session.json'), '{ not json', 'utf8');
|
|
127
|
+
|
|
128
|
+
const [entry] = listFleet();
|
|
129
|
+
assert.equal(entry.phase, 'verify');
|
|
130
|
+
assert.equal(entry.missing, false);
|
|
131
|
+
});
|
|
132
|
+
|
|
83
133
|
test('saveSession mirrors into the fleet registry', () => {
|
|
84
134
|
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-mirror-'), 'sessions');
|
|
85
135
|
const project = tmp('fleet-proj-');
|
package/src/integrity.mjs
CHANGED
|
@@ -112,9 +112,15 @@ export function resolveChangeDir(opts = {}) {
|
|
|
112
112
|
return forWrite ? openspecDir : liveOrArchived(openspecChanges, change);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Only a *specs* engine resolution can name the specs dir. The engine
|
|
116
|
+
// resolver's last resort is {engine:'openspec', dir:'openspec'}, so taking
|
|
117
|
+
// `.dir` unconditionally pointed specs sessions at `openspec/changes/<name>`
|
|
118
|
+
// in every project whose .forge/config.json has no `plan` block — the change
|
|
119
|
+
// dir, its brief.html, spine.json and e2e.json all silently missing.
|
|
115
120
|
let specsRoot = DEFAULT_SPECS_DIR;
|
|
116
121
|
try {
|
|
117
|
-
|
|
122
|
+
const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
|
|
123
|
+
if (engine.engine === 'specs') specsRoot = engine.dir;
|
|
118
124
|
} catch {
|
|
119
125
|
// keep default
|
|
120
126
|
}
|
package/src/integrity.test.mjs
CHANGED
|
@@ -146,6 +146,52 @@ test('resolveChangeDir: falls back to the archived copy after archive', () => {
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test('resolveChangeDir: a specs session never falls back to the openspec dir', () => {
|
|
150
|
+
// Regression: resolveProjectPlanEngine's last-resort default is
|
|
151
|
+
// {engine:'openspec', dir:'openspec'}, so a specs-engine session in a
|
|
152
|
+
// project whose .forge/config.json has no `plan` block (ADR-only config,
|
|
153
|
+
// pre-engine config, hand-written) resolved into openspec/changes/<name> —
|
|
154
|
+
// which made `forge phase implement` refuse a brief that was right there.
|
|
155
|
+
const cwd = tmp('forge-specs-dir-');
|
|
156
|
+
try {
|
|
157
|
+
const session = { planType: 'specs', openspecChange: 'my-change' };
|
|
158
|
+
fs.mkdirSync(path.join(cwd, '.forge'), { recursive: true });
|
|
159
|
+
fs.writeFileSync(
|
|
160
|
+
path.join(cwd, '.forge', 'config.json'),
|
|
161
|
+
`${JSON.stringify({ adr: { enabled: true, dir: 'docs/adr' } })}\n`,
|
|
162
|
+
'utf8',
|
|
163
|
+
);
|
|
164
|
+
const specsChange = path.join(cwd, 'specs', 'changes', 'my-change');
|
|
165
|
+
fs.mkdirSync(specsChange, { recursive: true });
|
|
166
|
+
|
|
167
|
+
assert.equal(resolveChangeDir({ cwd, session }), specsChange);
|
|
168
|
+
assert.equal(resolveChangeDir({ cwd, session, forWrite: true }), specsChange);
|
|
169
|
+
} finally {
|
|
170
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('resolveChangeDir: specs engine honors a configured plan.dir', () => {
|
|
175
|
+
// `forge init --no-openspec --plan-dir openspec` — specs engine reusing an
|
|
176
|
+
// existing OpenSpec tree without moving files.
|
|
177
|
+
const cwd = tmp('forge-specs-plandir-');
|
|
178
|
+
try {
|
|
179
|
+
const session = { planType: 'specs', openspecChange: 'my-change' };
|
|
180
|
+
fs.mkdirSync(path.join(cwd, '.forge'), { recursive: true });
|
|
181
|
+
fs.writeFileSync(
|
|
182
|
+
path.join(cwd, '.forge', 'config.json'),
|
|
183
|
+
`${JSON.stringify({ plan: { engine: 'specs', dir: 'openspec' } })}\n`,
|
|
184
|
+
'utf8',
|
|
185
|
+
);
|
|
186
|
+
const changeDir = path.join(cwd, 'openspec', 'changes', 'my-change');
|
|
187
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
188
|
+
|
|
189
|
+
assert.equal(resolveChangeDir({ cwd, session }), changeDir);
|
|
190
|
+
} finally {
|
|
191
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
149
195
|
test('spinePath/e2ePath forWrite target the live dir even after archive', () => {
|
|
150
196
|
const cwd = tmp('forge-write-guard-');
|
|
151
197
|
try {
|
package/src/lib/fleet.mjs
CHANGED
|
@@ -134,10 +134,59 @@ export function unregisterSession(projectRoot, sessionId) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
* Fields the registry mirrors from session.json. `session.json` is the source
|
|
138
|
+
* of truth; the entry is only a cache, so reads refresh it.
|
|
139
|
+
*/
|
|
140
|
+
const MIRRORED_FIELDS = [
|
|
141
|
+
'slug',
|
|
142
|
+
'phase',
|
|
143
|
+
'planType',
|
|
144
|
+
'openspecChange',
|
|
145
|
+
'tasksTotal',
|
|
146
|
+
'tasksComplete',
|
|
147
|
+
'createdAt',
|
|
148
|
+
'updatedAt',
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Refresh a cached entry from the session on disk. Returns true when anything
|
|
153
|
+
* changed. A registry write that never happened (older CLI, a crash mid-run,
|
|
154
|
+
* a hand-edited record) otherwise pins the entry at whatever phase it was
|
|
155
|
+
* first registered with, forever.
|
|
156
|
+
*
|
|
157
|
+
* @param {Record<string, any>} entry
|
|
158
|
+
* @param {string} sessionDir
|
|
159
|
+
*/
|
|
160
|
+
function reconcileEntry(entry, sessionDir) {
|
|
161
|
+
let session;
|
|
162
|
+
try {
|
|
163
|
+
session = JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf8'));
|
|
164
|
+
} catch {
|
|
165
|
+
return false; // unreadable/absent — keep the cached view
|
|
166
|
+
}
|
|
167
|
+
let changed = false;
|
|
168
|
+
for (const key of MIRRORED_FIELDS) {
|
|
169
|
+
const value = session[key];
|
|
170
|
+
if (value === undefined) continue;
|
|
171
|
+
if (entry[key] !== value) {
|
|
172
|
+
entry[key] = value;
|
|
173
|
+
changed = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const pace = session.resolvedPace ?? session.pace ?? null;
|
|
177
|
+
if (pace !== null && entry.pace !== pace) {
|
|
178
|
+
entry.pace = pace;
|
|
179
|
+
changed = true;
|
|
180
|
+
}
|
|
181
|
+
return changed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* All registry entries, newest first. Self-heals: entries are reconciled
|
|
186
|
+
* against each session.json on disk; entries whose session dir vanished
|
|
187
|
+
* (cleanup ran without unregister, project deleted the .forge dir) are
|
|
188
|
+
* removed; entries whose whole project path is unreachable (unplugged drive)
|
|
189
|
+
* are kept and marked `missing`.
|
|
141
190
|
*
|
|
142
191
|
* @returns {Array<Record<string, any>>}
|
|
143
192
|
*/
|
|
@@ -156,6 +205,14 @@ export function listFleet() {
|
|
|
156
205
|
}
|
|
157
206
|
const sessionDir = path.join(entry.project, '.forge', 'sessions', entry.sessionId);
|
|
158
207
|
if (fs.existsSync(sessionDir)) {
|
|
208
|
+
// Persist before stamping `missing`, which is a view flag, not state.
|
|
209
|
+
if (reconcileEntry(entry, sessionDir)) {
|
|
210
|
+
try {
|
|
211
|
+
fs.writeFileSync(file, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
|
|
212
|
+
} catch {
|
|
213
|
+
/* registry is advisory — a read-only registry still renders */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
159
216
|
entry.missing = false;
|
|
160
217
|
} else if (fs.existsSync(entry.project)) {
|
|
161
218
|
fs.rmSync(file, { force: true });
|
package/src/lib.mjs
CHANGED
|
@@ -7,8 +7,11 @@ import crypto from 'node:crypto';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { registerSession } from './lib/fleet.mjs';
|
|
10
|
+
import { findRepoRoot } from './repo-root.mjs';
|
|
10
11
|
|
|
11
|
-
export
|
|
12
|
+
export { findRepoRoot };
|
|
13
|
+
|
|
14
|
+
export const REPO_ROOT = findRepoRoot();
|
|
12
15
|
export const FORGE_DIR = path.join(REPO_ROOT, '.forge');
|
|
13
16
|
export const SESSIONS_DIR = path.join(FORGE_DIR, 'sessions');
|
|
14
17
|
export const ACTIVE_FILE = path.join(FORGE_DIR, 'active.json');
|
|
@@ -137,7 +140,21 @@ export function saveSession(dir, session) {
|
|
|
137
140
|
registerSession(path.resolve(dir, '..', '..', '..'), session);
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Age of a session in days, for retention. Legacy and hand-written records
|
|
145
|
+
* carry `startedAt` (or nothing) instead of `createdAt`; an undatable record
|
|
146
|
+
* counts as **infinitely old** rather than brand new — `new Date(undefined)`
|
|
147
|
+
* is NaN and `NaN > RETENTION_DAYS` is false, which let abandoned sessions
|
|
148
|
+
* survive every cleanup run forever.
|
|
149
|
+
*
|
|
150
|
+
* @param {Record<string, any>} session
|
|
151
|
+
* @returns {number} days, or Infinity when no date can be read
|
|
152
|
+
*/
|
|
140
153
|
export function sessionAgeDays(session) {
|
|
141
|
-
const
|
|
142
|
-
|
|
154
|
+
for (const value of [session?.createdAt, session?.startedAt, session?.updatedAt]) {
|
|
155
|
+
if (!value) continue;
|
|
156
|
+
const at = new Date(value).getTime();
|
|
157
|
+
if (!Number.isNaN(at)) return (Date.now() - at) / (1000 * 60 * 60 * 24);
|
|
158
|
+
}
|
|
159
|
+
return Infinity;
|
|
143
160
|
}
|
package/src/lib.test.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { findRepoRoot, sessionAgeDays } from './lib.mjs';
|
|
9
|
+
|
|
10
|
+
const SESSION_STATUS = path.join(
|
|
11
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
12
|
+
'session-status.mjs',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
function tmp(prefix) {
|
|
16
|
+
return fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), prefix)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('sessionAgeDays reads createdAt, then startedAt, then updatedAt', () => {
|
|
20
|
+
const days = (n) => new Date(Date.now() - n * 86400000).toISOString();
|
|
21
|
+
|
|
22
|
+
assert.ok(Math.abs(sessionAgeDays({ createdAt: days(3) }) - 3) < 0.01);
|
|
23
|
+
// Hand-written / legacy records carry startedAt (a bare date) instead.
|
|
24
|
+
assert.ok(Math.abs(sessionAgeDays({ startedAt: days(5).slice(0, 10) }) - 5) < 1.01);
|
|
25
|
+
assert.ok(Math.abs(sessionAgeDays({ updatedAt: days(2) }) - 2) < 0.01);
|
|
26
|
+
// createdAt wins when several are present.
|
|
27
|
+
assert.ok(
|
|
28
|
+
Math.abs(sessionAgeDays({ createdAt: days(9), startedAt: days(1), updatedAt: days(1) }) - 9) <
|
|
29
|
+
0.01,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('sessionAgeDays treats an undatable session as infinitely old, not age 0', () => {
|
|
34
|
+
// Regression: `new Date(undefined)` → NaN, and `NaN > RETENTION_DAYS` is
|
|
35
|
+
// false, so a session record without a date was never "too old" and
|
|
36
|
+
// survived every cleanup run forever.
|
|
37
|
+
assert.equal(sessionAgeDays({ phase: 'implement' }), Infinity);
|
|
38
|
+
assert.equal(sessionAgeDays({ createdAt: 'not-a-date' }), Infinity);
|
|
39
|
+
assert.equal(sessionAgeDays({}), Infinity);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('forge cleanup removes an undatable abandoned session', () => {
|
|
43
|
+
const root = tmp('forge-cleanup-');
|
|
44
|
+
const sessionDir = path.join(root, '.forge', 'sessions', 'legacy');
|
|
45
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
path.join(sessionDir, 'session.json'),
|
|
48
|
+
// No date of any kind — the shape that lingered in volo, minus the
|
|
49
|
+
// startedAt that now gives such records a real age.
|
|
50
|
+
`${JSON.stringify({ slug: 'legacy', phase: 'implement' })}\n`,
|
|
51
|
+
'utf8',
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const cleanup = path.join(path.dirname(SESSION_STATUS), 'cleanup-sessions.mjs');
|
|
55
|
+
const out = execFileSync(process.execPath, [cleanup], {
|
|
56
|
+
cwd: root,
|
|
57
|
+
env: { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('forge-cleanup-fleet-'), 's') },
|
|
58
|
+
}).toString();
|
|
59
|
+
|
|
60
|
+
assert.match(out, /"reason": "retention"/);
|
|
61
|
+
assert.equal(fs.existsSync(sessionDir), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('findRepoRoot walks up to the nearest .forge, then .git, then falls back', () => {
|
|
65
|
+
const root = tmp('forge-root-');
|
|
66
|
+
const nested = path.join(root, 'crates', 'helm-vfs', 'src');
|
|
67
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
68
|
+
|
|
69
|
+
// No markers anywhere: the start dir is the root.
|
|
70
|
+
assert.equal(findRepoRoot(nested), nested);
|
|
71
|
+
|
|
72
|
+
// .git alone marks the project.
|
|
73
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
74
|
+
assert.equal(findRepoRoot(nested), root);
|
|
75
|
+
|
|
76
|
+
// .forge wins over .git when both are present but at different depths:
|
|
77
|
+
// a nested checkout with its own session is its own project.
|
|
78
|
+
const inner = path.join(root, 'crates');
|
|
79
|
+
fs.mkdirSync(path.join(inner, '.forge'), { recursive: true });
|
|
80
|
+
assert.equal(findRepoRoot(nested), inner);
|
|
81
|
+
assert.equal(findRepoRoot(root), root);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('forge status finds the session from a subdirectory of the project', () => {
|
|
85
|
+
const root = tmp('forge-subdir-');
|
|
86
|
+
const sessionDir = path.join(root, '.forge', 'sessions', 's1');
|
|
87
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
88
|
+
const now = new Date().toISOString();
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
path.join(sessionDir, 'session.json'),
|
|
91
|
+
`${JSON.stringify({
|
|
92
|
+
id: 's1',
|
|
93
|
+
slug: 'fixture',
|
|
94
|
+
createdAt: now,
|
|
95
|
+
updatedAt: now,
|
|
96
|
+
phase: 'implement',
|
|
97
|
+
planType: 'specs',
|
|
98
|
+
openspecChange: 'my-change',
|
|
99
|
+
tasksTotal: 3,
|
|
100
|
+
tasksComplete: 1,
|
|
101
|
+
})}\n`,
|
|
102
|
+
'utf8',
|
|
103
|
+
);
|
|
104
|
+
fs.writeFileSync(
|
|
105
|
+
path.join(root, '.forge', 'active.json'),
|
|
106
|
+
`${JSON.stringify({ sessionId: 's1' })}\n`,
|
|
107
|
+
'utf8',
|
|
108
|
+
);
|
|
109
|
+
const nested = path.join(root, 'crates', 'helm-vfs');
|
|
110
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
111
|
+
|
|
112
|
+
const env = { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('forge-subdir-fleet-'), 's') };
|
|
113
|
+
const out = execFileSync(process.execPath, [SESSION_STATUS], { cwd: nested, env }).toString();
|
|
114
|
+
const status = JSON.parse(out);
|
|
115
|
+
|
|
116
|
+
assert.equal(status.status, 'ok');
|
|
117
|
+
assert.equal(status.sessionId, 's1');
|
|
118
|
+
// Paths stay relative to the project root, not to the working directory.
|
|
119
|
+
assert.equal(status.sessionPath, '.forge/sessions/s1');
|
|
120
|
+
|
|
121
|
+
// ...and through the bin, which re-roots the child process, so writes land
|
|
122
|
+
// in the project's .forge rather than creating a second tree in the subdir.
|
|
123
|
+
const FORGE_BIN = path.join(path.dirname(SESSION_STATUS), '..', 'bin', 'forge.mjs');
|
|
124
|
+
execFileSync(process.execPath, [FORGE_BIN, 'phase', 'brainstorm'], { cwd: nested, env });
|
|
125
|
+
const saved = JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf8'));
|
|
126
|
+
assert.equal(saved.phase, 'brainstorm');
|
|
127
|
+
assert.equal(fs.existsSync(path.join(nested, '.forge')), false);
|
|
128
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Project-root resolution.
|
|
4
|
+
*
|
|
5
|
+
* Standalone (node builtins only) so the `forge` bin can re-root itself
|
|
6
|
+
* without importing session machinery.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Nearest enclosing project root: the closest ancestor holding `.forge/`,
|
|
14
|
+
* else the closest holding `.git/` (a repo boundary — a nested checkout must
|
|
15
|
+
* not adopt the enclosing project's sessions), else the start dir.
|
|
16
|
+
*
|
|
17
|
+
* Without this, forge was bound to the exact cwd: `cd crates && forge status`
|
|
18
|
+
* reported "no session" in a repo that had one, and `forge new` there would
|
|
19
|
+
* have created a second `.forge` tree inside the workspace.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} [start]
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function findRepoRoot(start = process.cwd()) {
|
|
25
|
+
let dir = path.resolve(start);
|
|
26
|
+
for (;;) {
|
|
27
|
+
if (fs.existsSync(path.join(dir, '.forge'))) return dir;
|
|
28
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
29
|
+
const parent = path.dirname(dir);
|
|
30
|
+
if (parent === dir) return path.resolve(start);
|
|
31
|
+
dir = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/specs-sync.mjs
CHANGED
|
@@ -115,10 +115,8 @@ export function applyDeltaToMain(capability, mainBody, deltaBody) {
|
|
|
115
115
|
const reqHeaderEnd = reqMatch ? reqMatch.index + reqMatch[0].length : main.length;
|
|
116
116
|
const before = main.slice(0, reqHeaderEnd);
|
|
117
117
|
const afterReq = main.slice(reqHeaderEnd);
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
// Simpler: treat everything after ## Requirements as the requirements body
|
|
121
|
-
// until EOF (OpenSpec main specs usually end there).
|
|
118
|
+
// Treat everything after ## Requirements as the requirements body until the
|
|
119
|
+
// next non-Requirements ## heading (OpenSpec main specs usually end there).
|
|
122
120
|
let reqBody = afterReq.replace(/^\n+/, '');
|
|
123
121
|
const trailingMatch = /\n##\s+(?!Requirements).*$/m.exec(`\n${reqBody}`);
|
|
124
122
|
let trailing = '';
|