@izkac/forgekit 0.3.13 → 0.3.15

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.
@@ -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
+ });
@@ -7,12 +7,14 @@
7
7
  * forge new <slug> [--chat-id <id>] [--signal <text>]
8
8
  */
9
9
 
10
+ import { spawnSync } from 'node:child_process';
10
11
  import {
11
12
  defaultSession,
12
13
  defaultStatus,
13
14
  ensureForgeLayout,
14
15
  FORGE_DIR,
15
16
  makeSessionId,
17
+ REPO_ROOT,
16
18
  saveSession,
17
19
  scaffoldSessionDirs,
18
20
  sessionPath,
@@ -55,6 +57,19 @@ scaffoldSessionDirs(dir);
55
57
  const session = defaultSession(sessionId, slug);
56
58
  if (cursorChatId) session.cursorChatId = cursorChatId;
57
59
 
60
+ // Where this session started, so reviewers have a diff range even when the
61
+ // project never enables checkpoints (and `forge checkpoint --range` has a
62
+ // base from commit one).
63
+ const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' });
64
+ if (head.status === 0) {
65
+ session.baseCommit = head.stdout.trim();
66
+ const branch = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
67
+ cwd: REPO_ROOT,
68
+ encoding: 'utf8',
69
+ });
70
+ if (branch.status === 0) session.branch = branch.stdout.trim();
71
+ }
72
+
58
73
  const paceFields = resolveSessionPaceFields({
59
74
  forgeDir: FORGE_DIR,
60
75
  slug: session.slug,
@@ -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
+ }
@@ -17,6 +17,7 @@ import {
17
17
  touchSession,
18
18
  } from './lib/fleet.mjs';
19
19
  import { resolveEffectivePreferences } from './preferences.mjs';
20
+ import { sessionHealth } from './health.mjs';
20
21
 
21
22
  function getActiveSessionInfo() {
22
23
  const active = readActive();
@@ -88,6 +89,15 @@ export function buildForgeMessage(info) {
88
89
  if (session.tasksTotal > 0) {
89
90
  lines.push(`Tasks: ${session.tasksComplete}/${session.tasksTotal}`);
90
91
  }
92
+ // Only when something is wrong: a resumed session that is red or was
93
+ // abandoned mid-implement should say so before the agent picks up where it
94
+ // thinks it left off.
95
+ if (info.dir) {
96
+ const health = sessionHealth({ cwd: REPO_ROOT, sessionDir: info.dir, session });
97
+ if (health.state === 'red' || health.state === 'stale') {
98
+ lines.push(`Health: ${health.line}`);
99
+ }
100
+ }
91
101
  if (needsOpenSpecPlan(session)) {
92
102
  lines.push(OPENSPEC_PLAN_REMINDER);
93
103
  }
@@ -16,6 +16,7 @@ import {
16
16
  REPO_ROOT,
17
17
  } from './lib.mjs';
18
18
  import { resolveEffectivePreferences } from './preferences.mjs';
19
+ import { sessionHealth } from './health.mjs';
19
20
 
20
21
  const args = process.argv.slice(2);
21
22
  let sessionId = null;
@@ -46,12 +47,17 @@ const pace = resolveEffectivePreferences({
46
47
  signalText: session.paceSignal || session.slug || '',
47
48
  });
48
49
 
50
+ const health = sessionHealth({ cwd: REPO_ROOT, sessionDir: dir, session });
51
+
49
52
  process.stdout.write(
50
53
  JSON.stringify(
51
54
  {
52
55
  status: 'ok',
53
56
  sessionId,
54
57
  sessionPath: path.relative(REPO_ROOT, dir).replace(/\\/g, '/'),
58
+ // Verdict first: a status dump that never says "this session is red and
59
+ // nobody has touched it since yesterday" makes the operator derive it.
60
+ health,
55
61
  session,
56
62
  progress: status,
57
63
  pace: {
@@ -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 = '';
@@ -113,7 +113,8 @@ Testing: [references/test-strategy.md](./references/test-strategy.md) — tier 1
113
113
 
114
114
  ## Guardrails (every phase)
115
115
 
116
- - No autonomous `git commit` / push unless the user explicitly asks
116
+ - No autonomous `git commit` / push unless the user explicitly asks. **Never push.** The one sanctioned commit is `forge checkpoint` at a task-group boundary, and only when the project set `.forge/config.json` → `git.checkpoint` (default `off`); it refuses on the default branch and excludes `.forge/` scratch
117
+ - **Session health** — `forge status` returns a `health` verdict (`healthy` / `stale` / `red` / `done`). On resume, read it before continuing: a red e2e run or an idle session mid-implement is the first thing to tell the user about
117
118
  - Tests required for behavior changes
118
119
  - Trace ecosystem consumers when contracts change
119
120
  - Honor `openspec/config.yaml` prefixes when the project uses them (OpenSpec engine)
@@ -268,8 +268,12 @@ OpenSpec commands remain available standalone (OpenSpec-engine projects):
268
268
 
269
269
  ```bash
270
270
  forge new <slug> [--signal "…"] # new session + set active (resolves pace; warn-only doctor)
271
- forge status # active session JSON (+ effective pace)
271
+ forge status # active session JSON (+ effective pace + health verdict)
272
272
  forge phase <phase> […] # update phase / openspec / task counters
273
+ forge checkpoint --group <name> [--tasks <ids>]
274
+ # commit this group's work (opt-in; never pushes)
275
+ forge checkpoint --dry-run # what a checkpoint would commit
276
+ forge checkpoint --range [--last] # diff range for a reviewer brief ({DIFF_RANGE})
273
277
  forge cleanup [--dry-run] # prune sessions >14 days or finished
274
278
  forge evidence --task <nn>-<slug> --command "<cmd>" --exit 0 --summary "<text>"
275
279
  # stamp tier-2 test-evidence.md
@@ -572,7 +576,7 @@ forge models metered # WRITE .forge/models.local.json
572
576
 
573
577
  Guardrails in every subagent brief (honor the **project’s** agent docs too):
574
578
 
575
- - No autonomous `git commit` / push unless the user asks
579
+ - No autonomous `git commit` / push unless the user asks — subagents never commit at all
576
580
  - Implementer runs tier 1 (scoped) + tier 2 (narrow) tests; coordinator saves `tasks/<nn>-<slug>/test-evidence.md` before marking task done
577
581
  - Trace downstream consumers when contracts change
578
582
 
@@ -580,6 +584,79 @@ Prompt templates: [subagents/](../subagents/)
580
584
 
581
585
  ---
582
586
 
587
+ ## Checkpoints (opt-in commits)
588
+
589
+ Off by default. Enable per project in `.forge/config.json`:
590
+
591
+ ```json
592
+ { "git": { "checkpoint": "per-group" } }
593
+ ```
594
+
595
+ | Mode | When the coordinator runs `forge checkpoint` |
596
+ | ---- | -------------------------------------------- |
597
+ | `off` (default) | never — nothing is committed, reviewers read the working tree |
598
+ | `per-group` | at each `tasks.md` group boundary |
599
+ | `per-task` | after each task |
600
+
601
+ Why: a long session otherwise accumulates the whole change as one uncommitted
602
+ working tree — one bad `git checkout` from losing a day of agent work, with
603
+ every reviewer after task 1 reading a diff that contains all previous tasks.
604
+
605
+ Guarantees — the reason this is safe to automate:
606
+
607
+ - **Never pushes.** Nothing leaves the machine.
608
+ - **Refuses on the default branch** (`main` / `master`) unless
609
+ `--allow-default-branch` or `git.allowDefaultBranch: true`.
610
+ - **Refuses mid-merge / rebase / cherry-pick / revert / bisect.**
611
+ - **Excludes `.forge/`** — session scratch never lands in project history.
612
+ - Nothing to commit is success, not an error, and never makes an empty commit.
613
+ - Records `{ sha, group, tasks, at }` on the session, so reviewers get a real
614
+ range: `groupRange` (this group) and `range` (whole session, from
615
+ `session.baseCommit`, which `forge new` records even when checkpoints are off).
616
+
617
+ ```bash
618
+ forge checkpoint --group 06-helm-cli --tasks 6.1-6.4
619
+ forge checkpoint --dry-run # list what would be committed
620
+ forge checkpoint --range --last # {DIFF_RANGE} for the group reviewer
621
+ ```
622
+
623
+ **Reviewer scope.** A group review happens *before* that group's checkpoint,
624
+ so HEAD is still the previous one and a `<base>..HEAD` range would be empty.
625
+ Use the `reviewTarget` field from `forge checkpoint --range --last`:
626
+
627
+ | Tree state | `reviewTarget` |
628
+ | ---------- | -------------- |
629
+ | group still uncommitted | `git diff <last checkpoint>` **plus** the untracked files listed by name — `git diff` never shows them, and new files are most of what an implementer writes |
630
+ | group checkpointed | `<last checkpoint>..HEAD` |
631
+
632
+ `range` in the same output is the commit range only; it is empty mid-group by
633
+ design. `--last` scopes to the current group, without it the base is
634
+ `session.baseCommit` (the whole session).
635
+
636
+ Caveat: a checkpoint stages **everything outside `.forge/`**, including
637
+ unrelated edits sitting in the tree. Check `--dry-run` when the working tree
638
+ was not clean before the session started.
639
+
640
+ ---
641
+
642
+ ## Session health
643
+
644
+ `forge status` returns a verdict next to the data, and the reminder hook
645
+ surfaces it on resume when it is not healthy:
646
+
647
+ | State | Meaning |
648
+ | ----- | ------- |
649
+ | `red` | e2e run failing (named step), or `verify-evidence.md` records BLOCKED |
650
+ | `stale` | no session write for `health.idleHours` (default 4), or e2e results no longer match `e2e.json` |
651
+ | `healthy` | none of the above |
652
+ | `done` | phase `done` / `skipped` |
653
+
654
+ `forge fleet list` renders the same verdict as a HEALTH column plus a reason
655
+ line per unhealthy session, so a red or abandoned session is visible without
656
+ opening the project.
657
+
658
+ ---
659
+
583
660
  ## Bundled skills (self-contained)
584
661
 
585
662
  Forge vendors adapted Superpowers skills (MIT) under `skills/forge/skills/`.
@@ -56,7 +56,24 @@ Honor [../references/runtime-integrity.md](../references/runtime-integrity.md) i
56
56
  ```
57
57
  (Refuses non-zero exit without `--allow-fail`; template + rules in [../references/test-evidence.md](../references/test-evidence.md).)
58
58
  7. Mark task complete (`tasks.md` `- [x]` or update `tasksComplete`). Detect group boundary: next line in `tasks.md` is a new `##` heading, or no remaining tasks under the current heading.
59
- 8. Repeat.
59
+ 8. **Checkpoint** — when the project opts in (`.forge/config.json` → `git.checkpoint`):
60
+ ```bash
61
+ forge checkpoint --group <nn>-<slug> --tasks <ids> # per-group: at the boundary; per-task: after each task
62
+ ```
63
+ Commits the group's work and records the sha on the session. Never pushes,
64
+ refuses on the default branch, and leaves `.forge/` scratch out of the
65
+ commit. Default is `off`: nothing is committed and reviewers read the
66
+ working tree, as before.
67
+
68
+ **Reviewer scope (step 4).** The group review runs *before* this
69
+ checkpoint, so fill `{DIFF_RANGE}` from `forge checkpoint --range --last` →
70
+ its **`reviewTarget`** field. While the group is uncommitted that is
71
+ `git diff <last checkpoint>` plus the untracked files named explicitly (a
72
+ diff never shows them); once checkpointed it collapses to a plain commit
73
+ range. Do **not** paste `range` during a pre-checkpoint review — it is
74
+ empty until the group lands. Without checkpoints, every reviewer after task
75
+ 1 re-reads all previous tasks' diffs.
76
+ 9. Repeat.
60
77
 
61
78
  **Batching:** consecutive small same-area tasks (docs, config, wording) may share one implementer brief + one review — see the batching rules in [subagent-driven-development](../skills/subagent-driven-development/SKILL.md). Never batch money/auth/contract/migration tasks.
62
79
 
@@ -66,7 +83,7 @@ forge phase implement --tasks-complete <N> --subagents <total dispatched so far>
66
83
 
67
84
  ## Forge constraints (include in every brief)
68
85
 
69
- - **No** autonomous `git commit` or `git push`
86
+ - **No** autonomous `git commit` or `git push` — implementer subagents never commit. Checkpoints are the coordinator's job and only when `git.checkpoint` is enabled (`forge checkpoint`, which still never pushes)
70
87
  - **Tier 2 tests only** before claiming task done — narrowest command for this task ([test-strategy.md](../references/test-strategy.md)); **not** the full workspace suite unless the task requires it
71
88
  - Trace ecosystem consumers when contracts change
72
89
  - Minimal diff — surgical changes only
@@ -21,7 +21,7 @@ Capability specs beat narrow task wording when they conflict. See
21
21
 
22
22
  {FILE_LIST}
23
23
 
24
- Diff range: {DIFF_RANGE} <!-- e.g. `git diff` (uncommitted) or BASE..HEAD -->
24
+ Diff range: {DIFF_RANGE} <!-- `forge checkpoint --range --last` → paste its `reviewTarget` (scopes to this group; names untracked files a diff hides). No checkpoints: `git diff` + the untracked files in `git status`. -->
25
25
 
26
26
  **Read the actual code.** The summary above was written by the party under review — it is a map, not evidence. Read the changed files (or the diff range) before any verdict; verify each spec requirement against what the code does, not what the summary says it does.
27
27