@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.
- package/bin/forge.mjs +119 -107
- package/bin/forgekit.mjs +83 -83
- package/bin/review.mjs +81 -81
- package/package.json +2 -1
- package/scripts/prepack.mjs +78 -78
- package/scripts/run-tests.mjs +50 -50
- package/src/adr.mjs +236 -236
- package/src/adr.test.mjs +170 -170
- package/src/change.mjs +327 -234
- package/src/change.test.mjs +145 -83
- package/src/cleanup-sessions.mjs +84 -84
- package/src/config.mjs +103 -103
- package/src/defer.mjs +75 -75
- package/src/doctor.mjs +350 -341
- package/src/doctor.test.mjs +114 -114
- package/src/fleet.test.mjs +50 -0
- package/src/init.mjs +680 -621
- package/src/install.mjs +815 -815
- package/src/install.test.mjs +180 -180
- package/src/integrity-check.mjs +60 -60
- package/src/integrity.mjs +688 -682
- package/src/integrity.test.mjs +612 -566
- package/src/lib/fleet.mjs +61 -4
- package/src/lib.mjs +160 -143
- package/src/lib.test.mjs +128 -0
- package/src/models.defaults.json +41 -41
- package/src/new-session.mjs +99 -99
- package/src/openspec-overlays/README.md +19 -19
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
- package/src/paths.mjs +92 -92
- package/src/plan-engine.mjs +321 -278
- package/src/plan-engine.test.mjs +447 -283
- package/src/preferences.defaults.json +78 -78
- package/src/preferences.mjs +438 -438
- package/src/preferences.test.mjs +174 -174
- package/src/record-evidence.mjs +204 -204
- package/src/repo-root.mjs +33 -0
- package/src/resolve-model.mjs +312 -312
- package/src/resolve-model.test.mjs +194 -194
- package/src/review/cli.test.mjs +117 -117
- package/src/review/export.mjs +172 -172
- package/src/review/export.test.mjs +197 -197
- package/src/review/fixtures/valid-review.json +42 -42
- package/src/review/lib.mjs +894 -894
- package/src/review/lib.test.mjs +266 -266
- package/src/review/schema.json +196 -196
- package/src/review/signals.test.mjs +62 -62
- package/src/score-cli.mjs +68 -68
- package/src/score.mjs +568 -568
- package/src/score.test.mjs +366 -366
- package/src/session-reminder.mjs +207 -207
- package/src/session-status.mjs +70 -70
- package/src/set-models.mjs +186 -186
- package/src/set-phase.mjs +205 -205
- package/src/set-prefs.mjs +294 -294
- package/src/specs-sync.mjs +232 -0
- package/src/specs-sync.test.mjs +114 -0
- package/src/spine.mjs +93 -93
- package/src/triage-prompt.mjs +175 -175
- package/src/triage-prompt.test.mjs +50 -50
- package/src/vendor-openspec-overlays.mjs +176 -176
- package/src/vendor-openspec-overlays.test.mjs +62 -62
- package/vendor/skills/archive-to-adr/SKILL.md +149 -149
- package/vendor/skills/forge/SKILL.md +136 -136
- package/vendor/skills/forge/docs/forge.md +650 -647
- package/vendor/skills/forge/phases/brainstorm.md +23 -23
- package/vendor/skills/forge/phases/finish.md +90 -87
- package/vendor/skills/forge/phases/implement.md +77 -77
- package/vendor/skills/forge/phases/plan-openspec.md +60 -60
- package/vendor/skills/forge/phases/plan-specs.md +163 -117
- package/vendor/skills/forge/phases/review.md +25 -25
- package/vendor/skills/forge/phases/verify.md +124 -124
- package/vendor/skills/forge/references/forge-layout.md +85 -85
- package/vendor/skills/forge/references/pace.md +115 -115
- package/vendor/skills/forge/references/plan-routing.md +52 -51
- package/vendor/skills/forge/references/runtime-integrity.md +232 -232
- package/vendor/skills/forge/references/substantial-work.md +37 -37
- package/vendor/skills/forge/references/test-evidence.md +30 -30
- package/vendor/skills/forge/references/test-strategy.md +68 -68
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
- package/vendor/skills/thorough-code-review/SKILL.md +290 -290
- package/vendor/skills/thorough-code-review/examples.md +133 -133
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
- package/vendor/templates/adr/README.md +7 -7
- package/vendor/templates/adr/decisions.md +141 -141
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
- package/vendor/templates/project/claude/commands/forge-build.md +17 -17
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
- package/vendor/templates/project/claude/commands/forge-status.md +16 -16
- package/vendor/templates/project/claude/commands/forge.md +16 -16
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
- package/vendor/templates/project/cursor/commands/forge.md +16 -16
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
- 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
|
-
*
|
|
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
|
@@ -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
|
-
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
export const
|
|
15
|
-
export const
|
|
16
|
-
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}
|
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
|
+
});
|
package/src/models.defaults.json
CHANGED
|
@@ -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
|
+
}
|