@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 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
- const args = [...(entry.prependArgs ?? []), ...rest];
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: process.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.13",
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
  },
@@ -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
- specsRoot = resolveProjectPlanEngine(cwd, { useUserDefault: false }).dir;
122
+ const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
123
+ if (engine.engine === 'specs') specsRoot = engine.dir;
118
124
  } catch {
119
125
  // keep default
120
126
  }
@@ -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
- * All registry entries, newest first. Self-heals: entries whose session dir
138
- * vanished (cleanup ran without unregister, project deleted the .forge dir)
139
- * are removed; entries whose whole project path is unreachable (unplugged
140
- * drive) are kept and marked `missing`.
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 const REPO_ROOT = process.cwd();
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 created = new Date(session.createdAt).getTime();
142
- return (Date.now() - created) / (1000 * 60 * 60 * 24);
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
  }
@@ -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
+ }
@@ -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
- // Stop at next ## section if any
119
- const nextSection = /^##\s+/m.exec(afterReq.replace(/^\n/, ''));
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 = '';