forge-cc 0.1.4 → 0.1.6

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 (52) hide show
  1. package/README.md +96 -10
  2. package/dist/cli.js +225 -7
  3. package/dist/cli.js.map +1 -1
  4. package/dist/go/auto-chain.d.ts +37 -5
  5. package/dist/go/auto-chain.js +220 -81
  6. package/dist/go/auto-chain.js.map +1 -1
  7. package/dist/go/executor.d.ts +2 -0
  8. package/dist/go/executor.js.map +1 -1
  9. package/dist/hooks/pre-commit.js +9 -3
  10. package/dist/hooks/pre-commit.js.map +1 -1
  11. package/dist/reporter/human.d.ts +5 -0
  12. package/dist/reporter/human.js +30 -0
  13. package/dist/reporter/human.js.map +1 -1
  14. package/dist/setup/templates.js +97 -122
  15. package/dist/setup/templates.js.map +1 -1
  16. package/dist/spec/generator.d.ts +20 -0
  17. package/dist/spec/generator.js +23 -2
  18. package/dist/spec/generator.js.map +1 -1
  19. package/dist/spec/interview.d.ts +20 -2
  20. package/dist/spec/interview.js +8 -0
  21. package/dist/spec/interview.js.map +1 -1
  22. package/dist/spec/scanner.d.ts +34 -0
  23. package/dist/spec/scanner.js +93 -0
  24. package/dist/spec/scanner.js.map +1 -1
  25. package/dist/spec/templates.d.ts +22 -0
  26. package/dist/spec/templates.js +8 -0
  27. package/dist/spec/templates.js.map +1 -1
  28. package/dist/utils/platform.d.ts +29 -0
  29. package/dist/utils/platform.js +90 -0
  30. package/dist/utils/platform.js.map +1 -0
  31. package/dist/worktree/identity.d.ts +9 -0
  32. package/dist/worktree/identity.js +32 -0
  33. package/dist/worktree/identity.js.map +1 -0
  34. package/dist/worktree/manager.d.ts +70 -0
  35. package/dist/worktree/manager.js +217 -0
  36. package/dist/worktree/manager.js.map +1 -0
  37. package/dist/worktree/parallel.d.ts +87 -0
  38. package/dist/worktree/parallel.js +328 -0
  39. package/dist/worktree/parallel.js.map +1 -0
  40. package/dist/worktree/session.d.ts +64 -0
  41. package/dist/worktree/session.js +193 -0
  42. package/dist/worktree/session.js.map +1 -0
  43. package/dist/worktree/state-merge.d.ts +43 -0
  44. package/dist/worktree/state-merge.js +162 -0
  45. package/dist/worktree/state-merge.js.map +1 -0
  46. package/hooks/pre-commit-verify.js +9 -3
  47. package/hooks/version-check.js +78 -78
  48. package/package.json +1 -1
  49. package/skills/forge-go.md +39 -0
  50. package/skills/forge-setup.md +183 -157
  51. package/skills/forge-spec.md +50 -12
  52. package/skills/forge-update.md +92 -72
@@ -0,0 +1,193 @@
1
+ import { join } from "node:path";
2
+ import { openSync, closeSync, unlinkSync, mkdirSync, existsSync, } from "node:fs";
3
+ import { readJsonFileSync, writeJsonFileSync, generateSessionId, } from "../utils/platform.js";
4
+ /**
5
+ * Get the path to the session registry file.
6
+ * Located at <repoRoot>/.forge/sessions.json
7
+ */
8
+ export function getRegistryPath(repoRoot) {
9
+ return join(repoRoot, ".forge", "sessions.json");
10
+ }
11
+ // ---------------------------------------------------------------------------
12
+ // File-based lock for registry writes
13
+ // ---------------------------------------------------------------------------
14
+ const LOCK_RETRIES = 10;
15
+ const LOCK_RETRY_MS = 100;
16
+ function getLockPath(repoRoot) {
17
+ return join(repoRoot, ".forge", "sessions.lock");
18
+ }
19
+ /**
20
+ * Acquire an exclusive lock file. Retries with backoff on contention.
21
+ * Uses O_CREAT|O_EXCL which atomically fails if the file exists.
22
+ */
23
+ function acquireLock(repoRoot) {
24
+ const lockPath = getLockPath(repoRoot);
25
+ const dir = join(repoRoot, ".forge");
26
+ if (!existsSync(dir)) {
27
+ mkdirSync(dir, { recursive: true });
28
+ }
29
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
30
+ try {
31
+ // O_CREAT | O_EXCL | O_WRONLY — fails if file already exists
32
+ const fd = openSync(lockPath, "wx");
33
+ closeSync(fd);
34
+ return;
35
+ }
36
+ catch {
37
+ if (attempt === LOCK_RETRIES - 1) {
38
+ // Last attempt — force-remove stale lock and try once more
39
+ try {
40
+ unlinkSync(lockPath);
41
+ const fd = openSync(lockPath, "wx");
42
+ closeSync(fd);
43
+ return;
44
+ }
45
+ catch {
46
+ throw new Error(`Failed to acquire session registry lock at ${lockPath} after ${LOCK_RETRIES} attempts`);
47
+ }
48
+ }
49
+ // Busy-wait (sync context)
50
+ const waitUntil = Date.now() + LOCK_RETRY_MS;
51
+ while (Date.now() < waitUntil) {
52
+ // spin
53
+ }
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * Release the lock file.
59
+ */
60
+ function releaseLock(repoRoot) {
61
+ try {
62
+ unlinkSync(getLockPath(repoRoot));
63
+ }
64
+ catch {
65
+ // Lock already removed — non-fatal
66
+ }
67
+ }
68
+ /**
69
+ * Execute a callback while holding the registry lock.
70
+ * Ensures read-modify-write operations are serialized.
71
+ */
72
+ function withRegistryLock(repoRoot, fn) {
73
+ acquireLock(repoRoot);
74
+ try {
75
+ return fn();
76
+ }
77
+ finally {
78
+ releaseLock(repoRoot);
79
+ }
80
+ }
81
+ /**
82
+ * Load the session registry. Returns empty registry if file doesn't exist.
83
+ */
84
+ export function loadRegistry(repoRoot) {
85
+ const data = readJsonFileSync(getRegistryPath(repoRoot));
86
+ if (data === null) {
87
+ return { sessions: [] };
88
+ }
89
+ return data;
90
+ }
91
+ /**
92
+ * Save the session registry atomically.
93
+ */
94
+ export function saveRegistry(repoRoot, registry) {
95
+ writeJsonFileSync(getRegistryPath(repoRoot), registry);
96
+ }
97
+ /**
98
+ * Register a new session. Returns the created session.
99
+ * Uses file-based locking to prevent lost updates from concurrent writes.
100
+ */
101
+ export function registerSession(repoRoot, params) {
102
+ return withRegistryLock(repoRoot, () => {
103
+ const registry = loadRegistry(repoRoot);
104
+ const session = {
105
+ id: generateSessionId(),
106
+ user: params.user.name,
107
+ email: params.user.email,
108
+ skill: params.skill,
109
+ milestone: params.milestone,
110
+ branch: params.branch,
111
+ worktreePath: params.worktreePath,
112
+ startedAt: new Date().toISOString(),
113
+ pid: process.pid,
114
+ status: "active",
115
+ };
116
+ registry.sessions.push(session);
117
+ saveRegistry(repoRoot, registry);
118
+ return session;
119
+ });
120
+ }
121
+ /**
122
+ * Deregister (remove) a session by ID.
123
+ * Uses file-based locking to prevent lost updates.
124
+ */
125
+ export function deregisterSession(repoRoot, sessionId) {
126
+ withRegistryLock(repoRoot, () => {
127
+ const registry = loadRegistry(repoRoot);
128
+ registry.sessions = registry.sessions.filter((s) => s.id !== sessionId);
129
+ saveRegistry(repoRoot, registry);
130
+ });
131
+ }
132
+ /**
133
+ * Update a session's status.
134
+ * Uses file-based locking to prevent lost updates.
135
+ */
136
+ export function updateSessionStatus(repoRoot, sessionId, status) {
137
+ withRegistryLock(repoRoot, () => {
138
+ const registry = loadRegistry(repoRoot);
139
+ const session = registry.sessions.find((s) => s.id === sessionId);
140
+ if (session) {
141
+ session.status = status;
142
+ saveRegistry(repoRoot, registry);
143
+ }
144
+ });
145
+ }
146
+ /**
147
+ * Check if a process with the given PID is still running.
148
+ */
149
+ function isPidAlive(pid) {
150
+ try {
151
+ process.kill(pid, 0);
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ /**
159
+ * Find and mark stale sessions.
160
+ * A session is stale if its PID is no longer running.
161
+ * Uses file-based locking to prevent lost updates.
162
+ */
163
+ export function detectStaleSessions(repoRoot) {
164
+ return withRegistryLock(repoRoot, () => {
165
+ const registry = loadRegistry(repoRoot);
166
+ const newlyStale = [];
167
+ for (const session of registry.sessions) {
168
+ if (session.status === "active" && !isPidAlive(session.pid)) {
169
+ session.status = "stale";
170
+ newlyStale.push(session);
171
+ }
172
+ }
173
+ if (newlyStale.length > 0) {
174
+ saveRegistry(repoRoot, registry);
175
+ }
176
+ return newlyStale;
177
+ });
178
+ }
179
+ /**
180
+ * Get all active sessions.
181
+ */
182
+ export function getActiveSessions(repoRoot) {
183
+ const registry = loadRegistry(repoRoot);
184
+ return registry.sessions.filter((s) => s.status === "active");
185
+ }
186
+ /**
187
+ * Get a session by ID.
188
+ */
189
+ export function getSession(repoRoot, sessionId) {
190
+ const registry = loadRegistry(repoRoot);
191
+ return registry.sessions.find((s) => s.id === sessionId) ?? null;
192
+ }
193
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/worktree/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,43 @@
1
+ export interface MergeResult {
2
+ stateUpdated: boolean;
3
+ roadmapUpdated: boolean;
4
+ warnings: string[];
5
+ }
6
+ /**
7
+ * Merge state from a completed worktree session back to the main repo.
8
+ *
9
+ * - STATE.md: updates the milestone row with completion status and the
10
+ * Last Session date.
11
+ * - ROADMAP.md: marks the milestone row as complete with the given date.
12
+ *
13
+ * Uses synchronous I/O throughout — consistent with all other worktree modules.
14
+ *
15
+ * @param mainRepoDir - Absolute path to the main repository
16
+ * @param worktreeDir - Absolute path to the completed worktree
17
+ * @param milestoneNumber - The milestone that was completed
18
+ * @param completionDate - Date string (YYYY-MM-DD) for the completion
19
+ */
20
+ export declare function mergeSessionState(mainRepoDir: string, worktreeDir: string, milestoneNumber: number, completionDate: string): MergeResult;
21
+ /**
22
+ * Update a specific milestone row in a ROADMAP.md file.
23
+ * Uses structured line-by-line parsing (not regex replace) to safely
24
+ * update the status column of the milestone's table row.
25
+ *
26
+ * @param roadmapPath - Absolute path to ROADMAP.md
27
+ * @param milestoneNumber - Which milestone to update
28
+ * @param newStatus - New status string (e.g., "Complete (2026-02-15)")
29
+ * @returns true if the milestone was found and updated
30
+ */
31
+ export declare function updateRoadmapMilestoneStatus(roadmapPath: string, milestoneNumber: number, newStatus: string): boolean;
32
+ /**
33
+ * Update the milestone progress table in STATE.md.
34
+ * Reads the current STATE.md, finds the milestone row, updates its status.
35
+ * Also updates the `**Last Session:**` date if present.
36
+ * Preserves all other content.
37
+ *
38
+ * @param statePath - Absolute path to STATE.md
39
+ * @param milestoneNumber - Which milestone to update
40
+ * @param newStatus - New status string
41
+ * @returns true if the milestone was found and updated
42
+ */
43
+ export declare function updateStateMilestoneRow(statePath: string, milestoneNumber: number, newStatus: string): boolean;
@@ -0,0 +1,162 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteFileSync } from "../utils/platform.js";
4
+ // ---------------------------------------------------------------------------
5
+ // mergeSessionState
6
+ // ---------------------------------------------------------------------------
7
+ /**
8
+ * Merge state from a completed worktree session back to the main repo.
9
+ *
10
+ * - STATE.md: updates the milestone row with completion status and the
11
+ * Last Session date.
12
+ * - ROADMAP.md: marks the milestone row as complete with the given date.
13
+ *
14
+ * Uses synchronous I/O throughout — consistent with all other worktree modules.
15
+ *
16
+ * @param mainRepoDir - Absolute path to the main repository
17
+ * @param worktreeDir - Absolute path to the completed worktree
18
+ * @param milestoneNumber - The milestone that was completed
19
+ * @param completionDate - Date string (YYYY-MM-DD) for the completion
20
+ */
21
+ export function mergeSessionState(mainRepoDir, worktreeDir, milestoneNumber, completionDate) {
22
+ const warnings = [];
23
+ let stateUpdated = false;
24
+ let roadmapUpdated = false;
25
+ const completionStatus = `Complete (${completionDate})`;
26
+ // --- Check that the worktree has planning files --------------------------
27
+ const worktreeStatePath = join(worktreeDir, ".planning", "STATE.md");
28
+ const worktreeRoadmapPath = join(worktreeDir, ".planning", "ROADMAP.md");
29
+ if (!existsSync(worktreeStatePath)) {
30
+ warnings.push(`Worktree STATE.md not found at ${worktreeStatePath} — skipping state merge`);
31
+ }
32
+ if (!existsSync(worktreeRoadmapPath)) {
33
+ warnings.push(`Worktree ROADMAP.md not found at ${worktreeRoadmapPath} — skipping roadmap merge`);
34
+ }
35
+ // --- Update main repo STATE.md ------------------------------------------
36
+ const mainStatePath = join(mainRepoDir, ".planning", "STATE.md");
37
+ if (existsSync(mainStatePath)) {
38
+ const milestoneUpdated = updateStateMilestoneRow(mainStatePath, milestoneNumber, completionStatus);
39
+ if (milestoneUpdated) {
40
+ stateUpdated = true;
41
+ }
42
+ else {
43
+ warnings.push(`Milestone ${milestoneNumber} row not found in main repo STATE.md — no state update performed`);
44
+ }
45
+ }
46
+ else {
47
+ warnings.push(`Main repo STATE.md not found at ${mainStatePath} — cannot update state`);
48
+ }
49
+ // --- Update main repo ROADMAP.md ----------------------------------------
50
+ const mainRoadmapPath = join(mainRepoDir, ".planning", "ROADMAP.md");
51
+ if (existsSync(mainRoadmapPath)) {
52
+ const milestoneUpdated = updateRoadmapMilestoneStatus(mainRoadmapPath, milestoneNumber, completionStatus);
53
+ if (milestoneUpdated) {
54
+ roadmapUpdated = true;
55
+ }
56
+ else {
57
+ warnings.push(`Milestone ${milestoneNumber} row not found in main repo ROADMAP.md — no roadmap update performed`);
58
+ }
59
+ }
60
+ else {
61
+ warnings.push(`Main repo ROADMAP.md not found at ${mainRoadmapPath} — cannot update roadmap`);
62
+ }
63
+ return { stateUpdated, roadmapUpdated, warnings };
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // updateRoadmapMilestoneStatus
67
+ // ---------------------------------------------------------------------------
68
+ /**
69
+ * Update a specific milestone row in a ROADMAP.md file.
70
+ * Uses structured line-by-line parsing (not regex replace) to safely
71
+ * update the status column of the milestone's table row.
72
+ *
73
+ * @param roadmapPath - Absolute path to ROADMAP.md
74
+ * @param milestoneNumber - Which milestone to update
75
+ * @param newStatus - New status string (e.g., "Complete (2026-02-15)")
76
+ * @returns true if the milestone was found and updated
77
+ */
78
+ export function updateRoadmapMilestoneStatus(roadmapPath, milestoneNumber, newStatus) {
79
+ const content = readFileSync(roadmapPath, "utf-8");
80
+ const lines = content.split("\n");
81
+ let found = false;
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ // Match table rows: | <number> | <name> | <status> |
85
+ // Cells are separated by |. Split and check the first data cell.
86
+ if (!line.trimStart().startsWith("|"))
87
+ continue;
88
+ const cells = line.split("|");
89
+ // A valid table row split by | produces: ["", " cell1 ", " cell2 ", " cell3 ", ""]
90
+ // We need at least 4 separators (5 segments) for a 3-column table row.
91
+ if (cells.length < 5)
92
+ continue;
93
+ const firstCell = cells[1].trim();
94
+ const parsedNumber = parseInt(firstCell, 10);
95
+ if (isNaN(parsedNumber) || parsedNumber !== milestoneNumber)
96
+ continue;
97
+ // Check if this is already completed — if so, last completer wins with warning.
98
+ const currentStatus = cells[3].trim();
99
+ const alreadyComplete = currentStatus.toLowerCase().startsWith("complete");
100
+ // Update the status cell (index 3), preserving cell padding.
101
+ cells[3] = ` ${newStatus} `;
102
+ lines[i] = cells.join("|");
103
+ found = true;
104
+ if (alreadyComplete) {
105
+ // Last completer wins — overwrite is intentional but callers may
106
+ // want to know. We still return true since the update was applied.
107
+ }
108
+ break;
109
+ }
110
+ if (found) {
111
+ atomicWriteFileSync(roadmapPath, lines.join("\n"));
112
+ }
113
+ return found;
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // updateStateMilestoneRow
117
+ // ---------------------------------------------------------------------------
118
+ /**
119
+ * Update the milestone progress table in STATE.md.
120
+ * Reads the current STATE.md, finds the milestone row, updates its status.
121
+ * Also updates the `**Last Session:**` date if present.
122
+ * Preserves all other content.
123
+ *
124
+ * @param statePath - Absolute path to STATE.md
125
+ * @param milestoneNumber - Which milestone to update
126
+ * @param newStatus - New status string
127
+ * @returns true if the milestone was found and updated
128
+ */
129
+ export function updateStateMilestoneRow(statePath, milestoneNumber, newStatus) {
130
+ const content = readFileSync(statePath, "utf-8");
131
+ const lines = content.split("\n");
132
+ let milestoneFound = false;
133
+ // Extract the date from the status string for Last Session update.
134
+ // Status format is typically "Complete (YYYY-MM-DD)".
135
+ const dateMatch = newStatus.match(/\((\d{4}-\d{2}-\d{2})\)/);
136
+ const completionDate = dateMatch ? dateMatch[1] : null;
137
+ for (let i = 0; i < lines.length; i++) {
138
+ const line = lines[i];
139
+ // --- Update milestone table row ----------------------------------------
140
+ if (line.trimStart().startsWith("|")) {
141
+ const cells = line.split("|");
142
+ if (cells.length >= 5) {
143
+ const firstCell = cells[1].trim();
144
+ const parsedNumber = parseInt(firstCell, 10);
145
+ if (!isNaN(parsedNumber) && parsedNumber === milestoneNumber) {
146
+ cells[3] = ` ${newStatus} `;
147
+ lines[i] = cells.join("|");
148
+ milestoneFound = true;
149
+ }
150
+ }
151
+ }
152
+ // --- Update Last Session date ------------------------------------------
153
+ if (completionDate && line.match(/\*\*Last Session:\*\*/)) {
154
+ lines[i] = line.replace(/(\*\*Last Session:\*\*\s*)\S+/, `$1${completionDate}`);
155
+ }
156
+ }
157
+ if (milestoneFound) {
158
+ atomicWriteFileSync(statePath, lines.join("\n"));
159
+ }
160
+ return milestoneFound;
161
+ }
162
+ //# sourceMappingURL=state-merge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-merge.js","sourceRoot":"","sources":["../../src/worktree/state-merge.ts"],"names":[],"mappings":"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"}
@@ -35,8 +35,9 @@ function checkPreCommit(hookData) {
35
35
  const projectDir = process.cwd();
36
36
 
37
37
  // Check 1: Wrong branch protection
38
+ let branch = "unknown";
38
39
  try {
39
- const branch = execSync("git branch --show-current", {
40
+ branch = execSync("git branch --show-current", {
40
41
  encoding: "utf-8",
41
42
  }).trim();
42
43
  if (branch === "main" || branch === "master") {
@@ -49,8 +50,13 @@ function checkPreCommit(hookData) {
49
50
  // Can't determine branch — allow
50
51
  }
51
52
 
52
- // Check 2: Verify cache exists
53
- const cachePath = join(projectDir, ".forge", "last-verify.json");
53
+ // Check 2: Verify cache exists — per-branch first, fall back to legacy path
54
+ const slug = branch.replace(/\//g, "-").toLowerCase();
55
+ const perBranchCachePath = join(projectDir, ".forge", "verify-cache", `${slug}.json`);
56
+ const legacyCachePath = join(projectDir, ".forge", "last-verify.json");
57
+ const cachePath = existsSync(perBranchCachePath)
58
+ ? perBranchCachePath
59
+ : legacyCachePath;
54
60
  if (!existsSync(cachePath)) {
55
61
  return {
56
62
  decision: "block",
@@ -1,78 +1,78 @@
1
- #!/usr/bin/env node
2
-
3
- import { execSync } from "node:child_process";
4
- import { readFileSync } from "node:fs";
5
- import { join, dirname } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
-
8
- // Read hook input from stdin
9
- let input = "";
10
- process.stdin.setEncoding("utf-8");
11
- process.stdin.on("data", (chunk) => {
12
- input += chunk;
13
- });
14
- process.stdin.on("end", () => {
15
- try {
16
- checkForUpdate();
17
- } catch {
18
- // Silent failure — never crash, never block
19
- }
20
- // Always exit cleanly with no output (allow session to proceed)
21
- process.exit(0);
22
- });
23
-
24
- function checkForUpdate() {
25
- // Get installed version from forge-cc's own package.json
26
- const __dirname = dirname(fileURLToPath(import.meta.url));
27
- const pkgPath = join(__dirname, "..", "package.json");
28
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
29
- const currentVersion = pkg.version;
30
-
31
- if (!currentVersion) return;
32
-
33
- // Query npm registry for latest version (5 second timeout)
34
- let latestVersion;
35
- try {
36
- const result = execSync("npm view forge-cc version --json", {
37
- encoding: "utf-8",
38
- timeout: 5000,
39
- stdio: ["pipe", "pipe", "pipe"],
40
- });
41
- latestVersion = JSON.parse(result);
42
- } catch {
43
- // Offline, npm slow, or package not published yet — skip silently
44
- return;
45
- }
46
-
47
- if (typeof latestVersion !== "string" || !latestVersion) return;
48
-
49
- // Compare versions using semver-compatible numeric comparison
50
- if (isOutdated(currentVersion, latestVersion)) {
51
- process.stderr.write(
52
- `[forge] Update available: v${currentVersion} → v${latestVersion}. Run /forge:update to upgrade.\n`
53
- );
54
- }
55
- }
56
-
57
- /**
58
- * Returns true if `current` is older than `latest`.
59
- * Splits on ".", compares major/minor/patch numerically.
60
- */
61
- function isOutdated(current, latest) {
62
- const parseParts = (v) =>
63
- v
64
- .replace(/^v/, "")
65
- .split(".")
66
- .map((n) => parseInt(n, 10));
67
-
68
- const cur = parseParts(current);
69
- const lat = parseParts(latest);
70
-
71
- for (let i = 0; i < 3; i++) {
72
- const c = cur[i] || 0;
73
- const l = lat[i] || 0;
74
- if (l > c) return true;
75
- if (c > l) return false;
76
- }
77
- return false;
78
- }
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ // Read hook input from stdin
9
+ let input = "";
10
+ process.stdin.setEncoding("utf-8");
11
+ process.stdin.on("data", (chunk) => {
12
+ input += chunk;
13
+ });
14
+ process.stdin.on("end", () => {
15
+ try {
16
+ checkForUpdate();
17
+ } catch {
18
+ // Silent failure — never crash, never block
19
+ }
20
+ // Always exit cleanly with no output (allow session to proceed)
21
+ process.exit(0);
22
+ });
23
+
24
+ function checkForUpdate() {
25
+ // Get installed version from forge-cc's own package.json
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const pkgPath = join(__dirname, "..", "package.json");
28
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
29
+ const currentVersion = pkg.version;
30
+
31
+ if (!currentVersion) return;
32
+
33
+ // Query npm registry for latest version (5 second timeout)
34
+ let latestVersion;
35
+ try {
36
+ const result = execSync("npm view forge-cc version --json", {
37
+ encoding: "utf-8",
38
+ timeout: 5000,
39
+ stdio: ["pipe", "pipe", "pipe"],
40
+ });
41
+ latestVersion = JSON.parse(result);
42
+ } catch {
43
+ // Offline, npm slow, or package not published yet — skip silently
44
+ return;
45
+ }
46
+
47
+ if (typeof latestVersion !== "string" || !latestVersion) return;
48
+
49
+ // Compare versions using semver-compatible numeric comparison
50
+ if (isOutdated(currentVersion, latestVersion)) {
51
+ process.stderr.write(
52
+ `[forge] Update available: v${currentVersion} → v${latestVersion}. Run /forge:update to upgrade.\n`
53
+ );
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Returns true if `current` is older than `latest`.
59
+ * Splits on ".", compares major/minor/patch numerically.
60
+ */
61
+ function isOutdated(current, latest) {
62
+ const parseParts = (v) =>
63
+ v
64
+ .replace(/^v/, "")
65
+ .split(".")
66
+ .map((n) => parseInt(n, 10));
67
+
68
+ const cur = parseParts(current);
69
+ const lat = parseParts(latest);
70
+
71
+ for (let i = 0; i < 3; i++) {
72
+ const c = cur[i] || 0;
73
+ const l = lat[i] || 0;
74
+ if (l > c) return true;
75
+ if (c > l) return false;
76
+ }
77
+ return false;
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-cc",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Pre-PR verification harness for Claude Code agents — gate runner + CLI + MCP server",
5
5
  "type": "module",
6
6
  "license": "MIT",