@izkac/forgekit 0.3.12 → 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.
Files changed (120) hide show
  1. package/bin/forge.mjs +119 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +2 -1
  5. package/scripts/prepack.mjs +78 -78
  6. package/scripts/run-tests.mjs +50 -50
  7. package/src/adr.mjs +236 -236
  8. package/src/adr.test.mjs +170 -170
  9. package/src/change.mjs +327 -234
  10. package/src/change.test.mjs +145 -83
  11. package/src/cleanup-sessions.mjs +84 -84
  12. package/src/config.mjs +103 -103
  13. package/src/defer.mjs +75 -75
  14. package/src/doctor.mjs +350 -341
  15. package/src/doctor.test.mjs +114 -114
  16. package/src/fleet.test.mjs +50 -0
  17. package/src/init.mjs +680 -621
  18. package/src/install.mjs +815 -815
  19. package/src/install.test.mjs +180 -180
  20. package/src/integrity-check.mjs +60 -60
  21. package/src/integrity.mjs +688 -682
  22. package/src/integrity.test.mjs +612 -566
  23. package/src/lib/fleet.mjs +61 -4
  24. package/src/lib.mjs +160 -143
  25. package/src/lib.test.mjs +128 -0
  26. package/src/models.defaults.json +41 -41
  27. package/src/new-session.mjs +99 -99
  28. package/src/openspec-overlays/README.md +19 -19
  29. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  30. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  31. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  32. package/src/paths.mjs +92 -92
  33. package/src/plan-engine.mjs +321 -278
  34. package/src/plan-engine.test.mjs +447 -283
  35. package/src/preferences.defaults.json +78 -78
  36. package/src/preferences.mjs +438 -438
  37. package/src/preferences.test.mjs +174 -174
  38. package/src/record-evidence.mjs +204 -204
  39. package/src/repo-root.mjs +33 -0
  40. package/src/resolve-model.mjs +312 -312
  41. package/src/resolve-model.test.mjs +194 -194
  42. package/src/review/cli.test.mjs +117 -117
  43. package/src/review/export.mjs +172 -172
  44. package/src/review/export.test.mjs +197 -197
  45. package/src/review/fixtures/valid-review.json +42 -42
  46. package/src/review/lib.mjs +894 -894
  47. package/src/review/lib.test.mjs +266 -266
  48. package/src/review/schema.json +196 -196
  49. package/src/review/signals.test.mjs +62 -62
  50. package/src/score-cli.mjs +68 -68
  51. package/src/score.mjs +568 -568
  52. package/src/score.test.mjs +366 -366
  53. package/src/session-reminder.mjs +207 -207
  54. package/src/session-status.mjs +70 -70
  55. package/src/set-models.mjs +186 -186
  56. package/src/set-phase.mjs +205 -205
  57. package/src/set-prefs.mjs +294 -294
  58. package/src/specs-sync.mjs +232 -0
  59. package/src/specs-sync.test.mjs +114 -0
  60. package/src/spine.mjs +93 -93
  61. package/src/triage-prompt.mjs +175 -175
  62. package/src/triage-prompt.test.mjs +50 -50
  63. package/src/vendor-openspec-overlays.mjs +176 -176
  64. package/src/vendor-openspec-overlays.test.mjs +62 -62
  65. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  66. package/vendor/skills/forge/SKILL.md +136 -136
  67. package/vendor/skills/forge/docs/forge.md +650 -647
  68. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  69. package/vendor/skills/forge/phases/finish.md +90 -87
  70. package/vendor/skills/forge/phases/implement.md +77 -77
  71. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  72. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  73. package/vendor/skills/forge/phases/review.md +25 -25
  74. package/vendor/skills/forge/phases/verify.md +124 -124
  75. package/vendor/skills/forge/references/forge-layout.md +85 -85
  76. package/vendor/skills/forge/references/pace.md +115 -115
  77. package/vendor/skills/forge/references/plan-routing.md +52 -51
  78. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  79. package/vendor/skills/forge/references/substantial-work.md +37 -37
  80. package/vendor/skills/forge/references/test-evidence.md +30 -30
  81. package/vendor/skills/forge/references/test-strategy.md +68 -68
  82. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  83. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  84. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  85. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  86. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  87. package/vendor/skills/thorough-code-review/examples.md +133 -133
  88. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  89. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  90. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  91. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  92. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  93. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  94. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  95. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  96. package/vendor/templates/adr/README.md +7 -7
  97. package/vendor/templates/adr/decisions.md +141 -141
  98. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  99. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  100. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  101. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  102. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  103. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  104. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  105. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  106. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  107. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  108. package/vendor/templates/project/claude/commands/forge.md +16 -16
  109. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  110. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  111. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  112. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  113. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  114. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  115. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  116. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  117. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  118. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  119. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  120. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
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
@@ -1,143 +1,160 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Shared helpers for Forge session management.
4
- */
5
-
6
- import crypto from 'node:crypto';
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import { registerSession } from './lib/fleet.mjs';
10
-
11
- export const REPO_ROOT = process.cwd();
12
- export const FORGE_DIR = path.join(REPO_ROOT, '.forge');
13
- export const SESSIONS_DIR = path.join(FORGE_DIR, 'sessions');
14
- export const ACTIVE_FILE = path.join(FORGE_DIR, 'active.json');
15
- export const RETENTION_DAYS = 14;
16
-
17
- export function ensureForgeLayout() {
18
- fs.mkdirSync(SESSIONS_DIR, { recursive: true });
19
- }
20
-
21
- export function slugify(input) {
22
- return String(input)
23
- .toLowerCase()
24
- .replace(/[^a-z0-9]+/g, '-')
25
- .replace(/^-+|-+$/g, '')
26
- .slice(0, 48) || 'session';
27
- }
28
-
29
- export function utcCompactNow() {
30
- return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
31
- }
32
-
33
- export function randomSuffix(bytes = 3) {
34
- return crypto.randomBytes(bytes).toString('hex');
35
- }
36
-
37
- export function makeSessionId(slug) {
38
- return `${utcCompactNow()}-${slugify(slug)}-${randomSuffix()}`;
39
- }
40
-
41
- export function sessionPath(sessionId) {
42
- return path.join(SESSIONS_DIR, sessionId);
43
- }
44
-
45
- export function readJson(filePath) {
46
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
47
- }
48
-
49
- export function writeJson(filePath, data) {
50
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
51
- fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
52
- }
53
-
54
- export function readActive() {
55
- if (!fs.existsSync(ACTIVE_FILE)) return null;
56
- try {
57
- return readJson(ACTIVE_FILE);
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- export function writeActive(sessionId) {
64
- writeJson(ACTIVE_FILE, {
65
- sessionId,
66
- sessionPath: path.relative(REPO_ROOT, sessionPath(sessionId)).replace(/\\/g, '/'),
67
- updatedAt: new Date().toISOString(),
68
- });
69
- }
70
-
71
- export function clearActive() {
72
- if (fs.existsSync(ACTIVE_FILE)) fs.unlinkSync(ACTIVE_FILE);
73
- }
74
-
75
- export function defaultSession(sessionId, slug) {
76
- const now = new Date().toISOString();
77
- return {
78
- id: sessionId,
79
- slug: slugify(slug),
80
- createdAt: now,
81
- updatedAt: now,
82
- phase: 'triage',
83
- planType: null,
84
- openspecChange: null,
85
- forgeSkipped: false,
86
- cursorChatId: null,
87
- tasksTotal: 0,
88
- tasksComplete: 0,
89
- /** Requested pace: auto | thorough | standard | brisk | lite */
90
- pace: 'auto',
91
- /** Concrete pace after auto resolve or explicit pin */
92
- resolvedPace: null,
93
- paceReason: null,
94
- paceSignal: null,
95
- pacePinned: false,
96
- preferencesOverride: null,
97
- };
98
- }
99
-
100
- export function defaultStatus(session) {
101
- return {
102
- sessionId: session.id,
103
- phase: session.phase,
104
- planType: session.planType,
105
- openspecChange: session.openspecChange,
106
- tasksTotal: session.tasksTotal,
107
- tasksComplete: session.tasksComplete,
108
- pace: session.pace ?? null,
109
- resolvedPace: session.resolvedPace ?? null,
110
- paceReason: session.paceReason ?? null,
111
- updatedAt: session.updatedAt,
112
- };
113
- }
114
-
115
- export function scaffoldSessionDirs(dir) {
116
- for (const sub of ['brainstorm', 'tasks', 'reviews']) {
117
- fs.mkdirSync(path.join(dir, sub), { recursive: true });
118
- }
119
- }
120
-
121
- export function loadSession(sessionId) {
122
- const dir = sessionPath(sessionId);
123
- const file = path.join(dir, 'session.json');
124
- if (!fs.existsSync(file)) {
125
- throw new Error(`Session not found: ${sessionId}`);
126
- }
127
- return { dir, session: readJson(file) };
128
- }
129
-
130
- export function saveSession(dir, session) {
131
- session.updatedAt = new Date().toISOString();
132
- writeJson(path.join(dir, 'session.json'), session);
133
- writeJson(path.join(dir, 'status.json'), defaultStatus(session));
134
- // Mirror into ~/.forgekit/fleet so `forge fleet` sees every project's
135
- // sessions. Project root derived from dir (<root>/.forge/sessions/<id>),
136
- // not REPO_ROOT, so callers with explicit dirs mirror correctly too.
137
- registerSession(path.resolve(dir, '..', '..', '..'), session);
138
- }
139
-
140
- export function sessionAgeDays(session) {
141
- const created = new Date(session.createdAt).getTime();
142
- return (Date.now() - created) / (1000 * 60 * 60 * 24);
143
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared helpers for Forge session management.
4
+ */
5
+
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { registerSession } from './lib/fleet.mjs';
10
+ import { findRepoRoot } from './repo-root.mjs';
11
+
12
+ export { findRepoRoot };
13
+
14
+ export const REPO_ROOT = findRepoRoot();
15
+ export const FORGE_DIR = path.join(REPO_ROOT, '.forge');
16
+ export const SESSIONS_DIR = path.join(FORGE_DIR, 'sessions');
17
+ export const ACTIVE_FILE = path.join(FORGE_DIR, 'active.json');
18
+ export const RETENTION_DAYS = 14;
19
+
20
+ export function ensureForgeLayout() {
21
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
22
+ }
23
+
24
+ export function slugify(input) {
25
+ return String(input)
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, '-')
28
+ .replace(/^-+|-+$/g, '')
29
+ .slice(0, 48) || 'session';
30
+ }
31
+
32
+ export function utcCompactNow() {
33
+ return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
34
+ }
35
+
36
+ export function randomSuffix(bytes = 3) {
37
+ return crypto.randomBytes(bytes).toString('hex');
38
+ }
39
+
40
+ export function makeSessionId(slug) {
41
+ return `${utcCompactNow()}-${slugify(slug)}-${randomSuffix()}`;
42
+ }
43
+
44
+ export function sessionPath(sessionId) {
45
+ return path.join(SESSIONS_DIR, sessionId);
46
+ }
47
+
48
+ export function readJson(filePath) {
49
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
50
+ }
51
+
52
+ export function writeJson(filePath, data) {
53
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
54
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
55
+ }
56
+
57
+ export function readActive() {
58
+ if (!fs.existsSync(ACTIVE_FILE)) return null;
59
+ try {
60
+ return readJson(ACTIVE_FILE);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export function writeActive(sessionId) {
67
+ writeJson(ACTIVE_FILE, {
68
+ sessionId,
69
+ sessionPath: path.relative(REPO_ROOT, sessionPath(sessionId)).replace(/\\/g, '/'),
70
+ updatedAt: new Date().toISOString(),
71
+ });
72
+ }
73
+
74
+ export function clearActive() {
75
+ if (fs.existsSync(ACTIVE_FILE)) fs.unlinkSync(ACTIVE_FILE);
76
+ }
77
+
78
+ export function defaultSession(sessionId, slug) {
79
+ const now = new Date().toISOString();
80
+ return {
81
+ id: sessionId,
82
+ slug: slugify(slug),
83
+ createdAt: now,
84
+ updatedAt: now,
85
+ phase: 'triage',
86
+ planType: null,
87
+ openspecChange: null,
88
+ forgeSkipped: false,
89
+ cursorChatId: null,
90
+ tasksTotal: 0,
91
+ tasksComplete: 0,
92
+ /** Requested pace: auto | thorough | standard | brisk | lite */
93
+ pace: 'auto',
94
+ /** Concrete pace after auto resolve or explicit pin */
95
+ resolvedPace: null,
96
+ paceReason: null,
97
+ paceSignal: null,
98
+ pacePinned: false,
99
+ preferencesOverride: null,
100
+ };
101
+ }
102
+
103
+ export function defaultStatus(session) {
104
+ return {
105
+ sessionId: session.id,
106
+ phase: session.phase,
107
+ planType: session.planType,
108
+ openspecChange: session.openspecChange,
109
+ tasksTotal: session.tasksTotal,
110
+ tasksComplete: session.tasksComplete,
111
+ pace: session.pace ?? null,
112
+ resolvedPace: session.resolvedPace ?? null,
113
+ paceReason: session.paceReason ?? null,
114
+ updatedAt: session.updatedAt,
115
+ };
116
+ }
117
+
118
+ export function scaffoldSessionDirs(dir) {
119
+ for (const sub of ['brainstorm', 'tasks', 'reviews']) {
120
+ fs.mkdirSync(path.join(dir, sub), { recursive: true });
121
+ }
122
+ }
123
+
124
+ export function loadSession(sessionId) {
125
+ const dir = sessionPath(sessionId);
126
+ const file = path.join(dir, 'session.json');
127
+ if (!fs.existsSync(file)) {
128
+ throw new Error(`Session not found: ${sessionId}`);
129
+ }
130
+ return { dir, session: readJson(file) };
131
+ }
132
+
133
+ export function saveSession(dir, session) {
134
+ session.updatedAt = new Date().toISOString();
135
+ writeJson(path.join(dir, 'session.json'), session);
136
+ writeJson(path.join(dir, 'status.json'), defaultStatus(session));
137
+ // Mirror into ~/.forgekit/fleet so `forge fleet` sees every project's
138
+ // sessions. Project root derived from dir (<root>/.forge/sessions/<id>),
139
+ // not REPO_ROOT, so callers with explicit dirs mirror correctly too.
140
+ registerSession(path.resolve(dir, '..', '..', '..'), session);
141
+ }
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
+ */
153
+ export function sessionAgeDays(session) {
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;
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
+ });
@@ -1,41 +1,41 @@
1
- {
2
- "billing": "included",
3
- "agents": {
4
- "cursor": {
5
- "included": {
6
- "fast": "inherit",
7
- "standard": "inherit",
8
- "capable": "inherit"
9
- },
10
- "metered": {
11
- "fast": "gpt-5.6-terra-medium",
12
- "standard": "claude-sonnet-5-thinking-high",
13
- "capable": "claude-opus-4-8-thinking-high"
14
- }
15
- },
16
- "claude-code": {
17
- "included": {
18
- "fast": "haiku",
19
- "standard": "sonnet",
20
- "capable": "opus"
21
- },
22
- "metered": {
23
- "fast": "haiku",
24
- "standard": "sonnet",
25
- "capable": "opus"
26
- }
27
- },
28
- "codex": {
29
- "included": {
30
- "fast": "inherit",
31
- "standard": "inherit",
32
- "capable": "inherit"
33
- },
34
- "metered": {
35
- "fast": "inherit",
36
- "standard": "inherit",
37
- "capable": "inherit"
38
- }
39
- }
40
- }
41
- }
1
+ {
2
+ "billing": "included",
3
+ "agents": {
4
+ "cursor": {
5
+ "included": {
6
+ "fast": "inherit",
7
+ "standard": "inherit",
8
+ "capable": "inherit"
9
+ },
10
+ "metered": {
11
+ "fast": "gpt-5.6-terra-medium",
12
+ "standard": "claude-sonnet-5-thinking-high",
13
+ "capable": "claude-opus-4-8-thinking-high"
14
+ }
15
+ },
16
+ "claude-code": {
17
+ "included": {
18
+ "fast": "haiku",
19
+ "standard": "sonnet",
20
+ "capable": "opus"
21
+ },
22
+ "metered": {
23
+ "fast": "haiku",
24
+ "standard": "sonnet",
25
+ "capable": "opus"
26
+ }
27
+ },
28
+ "codex": {
29
+ "included": {
30
+ "fast": "inherit",
31
+ "standard": "inherit",
32
+ "capable": "inherit"
33
+ },
34
+ "metered": {
35
+ "fast": "inherit",
36
+ "standard": "inherit",
37
+ "capable": "inherit"
38
+ }
39
+ }
40
+ }
41
+ }