@graypark/loophaus 3.6.1 → 3.7.0

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.
@@ -11,6 +11,53 @@ The user runs `/loop-plan` once and gets a single merged branch with all work do
11
11
 
12
12
  ---
13
13
 
14
+ ## Pre-flight: Update Check
15
+
16
+ Run this silently before starting:
17
+
18
+ ```bash
19
+ _UPDATE=$(loophaus update-check 2>/dev/null | grep "UPGRADE_AVAILABLE" || true)
20
+ ```
21
+
22
+ If `_UPDATE` contains `UPGRADE_AVAILABLE`:
23
+ 1. Show: `loophaus update available: v{current} → v{latest}`
24
+ 2. Ask user:
25
+ - **A) Upgrade now** → run `loophaus upgrade`, then continue
26
+ - **B) Always auto-upgrade** → run `loophaus config set autoUpgrade true && loophaus upgrade`, then continue
27
+ - **C) Not now** → run `loophaus config set updateCheck snoozed` silently, then continue
28
+ - **D) Never ask again** → run `loophaus config set updateCheck false`, then continue
29
+
30
+ If auto-upgrade is enabled (`loophaus config get autoUpgrade` returns `true`):
31
+ - Skip prompt, run `loophaus upgrade` silently, continue.
32
+
33
+ If no update or check fails: continue silently (never block the user).
34
+
35
+ ## Pre-flight: Skill Routing Check
36
+
37
+ Check if the project has a CLAUDE.md with loophaus skill routing:
38
+
39
+ ```bash
40
+ CLAUDE_MD="$(pwd)/CLAUDE.md"
41
+ ROUTING_MARKER=".loophaus/routing-offered.json"
42
+ ```
43
+
44
+ If CLAUDE.md exists but has no `## loophaus skill routing` section, AND routing hasn't been offered before:
45
+ 1. Suggest adding this section to CLAUDE.md:
46
+ ```markdown
47
+ ## loophaus skill routing
48
+
49
+ | User intent | Route to |
50
+ |-------------|----------|
51
+ | Feature implementation, refactoring, multi-step task | `/loophaus:loop-plan` |
52
+ | "start the loop", "continue implementing" | `/loophaus:loop` |
53
+ | "stop the loop", "cancel" | `/loophaus:loop-stop` |
54
+ | "what's the status", "how far along" | `/loophaus:loop-pulse` |
55
+ ```
56
+ 2. If user agrees, add the section to CLAUDE.md
57
+ 3. Write `{ "offeredAt": "<ISO date>" }` to `.loophaus/routing-offered.json` so we don't ask again
58
+
59
+ If CLAUDE.md doesn't exist or routing already offered: skip silently.
60
+
14
61
  ## Phase 0: Cleanup Previous Data
15
62
 
16
63
  Before starting a new plan, apply the cleanup policy from `.loophaus/config.json`:
package/commands/loop.md CHANGED
@@ -6,6 +6,17 @@ allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
6
6
 
7
7
  # /loop — Start Iterative Dev Loop
8
8
 
9
+ ## Pre-flight
10
+
11
+ ```bash
12
+ _UPDATE=$(loophaus update-check 2>/dev/null | grep "UPGRADE_AVAILABLE" || true)
13
+ ```
14
+ If update available and autoUpgrade enabled: run `loophaus upgrade` silently.
15
+ If update available and not auto: show one-line notice `loophaus v{new} available. Run: loophaus upgrade`.
16
+ Otherwise: continue silently.
17
+
18
+ ---
19
+
9
20
  Execute the setup script to initialize the loop:
10
21
 
11
22
  ```!
@@ -16,12 +16,13 @@ const showHelp = args.includes("--help") || args.includes("-h");
16
16
  const KNOWN_FLAGS = new Set([
17
17
  "--help", "-h", "--version", "--dry-run", "--force", "--local", "--verbose",
18
18
  "--host", "--claude", "--kiro", "--name", "--speed", "--count", "--base", "--story",
19
- "--all", "--traces", "--sessions", "--results", "--before", "--config",
19
+ "--all", "--traces", "--sessions", "--results", "--before", "--config", "--quiet",
20
20
  ]);
21
21
  const VALID_COMMANDS = [
22
22
  "install", "uninstall", "status", "stats", "loops", "watch",
23
23
  "replay", "compare", "worktree", "parallel", "quality",
24
- "sessions", "resume", "benchmark", "clean", "help",
24
+ "sessions", "resume", "benchmark", "clean", "config",
25
+ "update-check", "upgrade", "help",
25
26
  ];
26
27
  function validateFlags() {
27
28
  for (const arg of args) {
@@ -114,6 +115,9 @@ Usage:
114
115
  npx @graypark/loophaus quality [--story US-001]
115
116
  npx @graypark/loophaus benchmark
116
117
  npx @graypark/loophaus clean [--all|--traces|--sessions|--results] [--before DATE]
118
+ npx @graypark/loophaus config [list|get|set] [key] [value]
119
+ npx @graypark/loophaus update-check
120
+ npx @graypark/loophaus upgrade
117
121
  npx @graypark/loophaus sessions
118
122
  npx @graypark/loophaus resume <session-id>
119
123
  npx @graypark/loophaus --version
@@ -156,6 +160,11 @@ async function detectHosts() {
156
160
  return hosts;
157
161
  }
158
162
  async function runInstall() {
163
+ const { getPackageVersion } = await import("../lib/paths.js");
164
+ const version = getPackageVersion();
165
+ const quiet = args.includes("--quiet");
166
+ const loophausDir = join(process.env.HOME || "~", ".loophaus");
167
+ const welcomePath = join(loophausDir, ".welcome-seen");
159
168
  let targets = [];
160
169
  if (host) {
161
170
  targets = [host];
@@ -192,6 +201,57 @@ async function runInstall() {
192
201
  s?.stop();
193
202
  }
194
203
  }
204
+ if (quiet || dryRun)
205
+ return;
206
+ // First-run welcome or upgrade notice
207
+ const { mkdir: mk, writeFile: wf, readFile: rf } = await import("node:fs/promises");
208
+ await mk(loophausDir, { recursive: true });
209
+ let isFirstRun = false;
210
+ try {
211
+ const seen = await rf(welcomePath, "utf-8");
212
+ // Existing install — show What's New if version changed
213
+ if (seen.trim() !== version) {
214
+ await wf(welcomePath, version, "utf-8");
215
+ try {
216
+ const changelog = await rf(join(__dirname, "..", "CHANGELOG.md"), "utf-8");
217
+ const firstEntry = changelog.match(/## \[[\d.]+\][^\n]*\n([\s\S]*?)(?=\n## \[|$)/);
218
+ if (firstEntry) {
219
+ console.log(`\n \x1b[36mWhat's New in v${version}:\x1b[0m`);
220
+ const lines = firstEntry[1].trim().split("\n").slice(0, 8);
221
+ for (const l of lines)
222
+ console.log(` ${l}`);
223
+ }
224
+ }
225
+ catch { /* no CHANGELOG */ }
226
+ }
227
+ }
228
+ catch {
229
+ isFirstRun = true;
230
+ await wf(welcomePath, version, "utf-8");
231
+ }
232
+ if (isFirstRun) {
233
+ console.log(`
234
+ \x1b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\x1b[0m
235
+ Welcome to \x1b[1mloophaus\x1b[0m v${version}
236
+
237
+ Control plane for coding agents.
238
+ Iterative dev loops with quality verification.
239
+
240
+ Quick start:
241
+ /loop-plan <describe your task>
242
+
243
+ Commands:
244
+ /loop-plan Interview → PRD → implement → verify
245
+ /loop Start loop with existing PRD
246
+ /loop-pulse Check progress
247
+ /loop-stop Cancel loop
248
+
249
+ CLI:
250
+ loophaus benchmark Project quality score
251
+ loophaus config list View settings
252
+ loophaus upgrade Update to latest
253
+ \x1b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\x1b[0m`);
254
+ }
195
255
  }
196
256
  async function runUninstall() {
197
257
  if (host === "claude-code" || args.includes("--claude")) {
@@ -683,6 +743,140 @@ Options:
683
743
  console.log(" Nothing to clean.");
684
744
  }
685
745
  }
746
+ async function runUpdateCheck() {
747
+ const { getPackageVersion } = await import("../lib/paths.js");
748
+ const { checkForUpdate } = await import("../core/update-checker.js");
749
+ const current = getPackageVersion();
750
+ const result = await checkForUpdate(current);
751
+ console.log("Update Check");
752
+ console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
753
+ console.log(` Current: v${result.current}`);
754
+ console.log(` Latest: v${result.latest}`);
755
+ console.log(` Status: ${result.status}`);
756
+ if (result.message)
757
+ console.log(` Note: ${result.message}`);
758
+ if (result.status === "upgrade_available") {
759
+ console.log(`\n \x1b[33mUpdate available: v${result.current} → v${result.latest}\x1b[0m`);
760
+ console.log(` Run: loophaus upgrade`);
761
+ }
762
+ }
763
+ async function runUpgrade() {
764
+ const { getPackageVersion } = await import("../lib/paths.js");
765
+ const { checkForUpdate } = await import("../core/update-checker.js");
766
+ const { execFile: ef } = await import("node:child_process");
767
+ const { promisify } = await import("node:util");
768
+ const execFileAsync = promisify(ef);
769
+ const current = getPackageVersion();
770
+ const result = await checkForUpdate(current);
771
+ if (result.status === "up_to_date") {
772
+ console.log(`Already on latest version: v${current}`);
773
+ return;
774
+ }
775
+ if (result.status !== "upgrade_available" && result.status !== "snoozed") {
776
+ console.log(`No update available (status: ${result.status})`);
777
+ return;
778
+ }
779
+ console.log(`Upgrading loophaus: v${result.current} → v${result.latest}`);
780
+ const s = spinner("Installing...");
781
+ try {
782
+ await execFileAsync("npm", ["install", "-g", `@graypark/loophaus@${result.latest}`], { timeout: 120_000 });
783
+ s.stop();
784
+ console.log(`\u2714 Installed v${result.latest}`);
785
+ const s2 = spinner("Reinstalling plugins...");
786
+ try {
787
+ await execFileAsync("loophaus", ["install", "--force"], { timeout: 60_000 });
788
+ s2.stop();
789
+ console.log("\u2714 Plugins reinstalled");
790
+ }
791
+ catch {
792
+ s2.stop();
793
+ console.log(" Note: Run 'loophaus install --force' to update plugins.");
794
+ }
795
+ console.log(`\n Upgrade complete: v${result.current} → v${result.latest}`);
796
+ }
797
+ catch (err) {
798
+ s.stop();
799
+ console.error(`\u2718 Upgrade failed: ${err.message}`);
800
+ console.error(" Try manually: npm install -g @graypark/loophaus@latest");
801
+ }
802
+ }
803
+ async function runConfigCmd() {
804
+ const { readConfig, writeConfig } = await import("../core/cleanup.js");
805
+ const sub = args[1];
806
+ const KNOWN_KEYS = {
807
+ "cleanup.onNewPlan": "Policy when /loop-plan starts: archive | delete | keep",
808
+ "cleanup.traceRetentionDays": "Days to keep trace data",
809
+ "cleanup.sessionRetentionDays": "Days to keep session checkpoints",
810
+ "updateCheck": "Check for updates on skill execution: true | false",
811
+ "autoUpgrade": "Auto-upgrade without prompting: true | false",
812
+ };
813
+ if (!sub || sub === "list") {
814
+ const config = await readConfig();
815
+ console.log("Configuration (.loophaus/config.json)");
816
+ console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
817
+ for (const [key, desc] of Object.entries(KNOWN_KEYS)) {
818
+ const val = getNestedValue(config, key);
819
+ console.log(` ${key.padEnd(30)} ${String(val ?? "(default)").padEnd(12)} ${desc}`);
820
+ }
821
+ console.log(`\nUsage: loophaus config set <key> <value>`);
822
+ return;
823
+ }
824
+ if (sub === "get") {
825
+ const key = args[2];
826
+ if (!key) {
827
+ console.log("Usage: loophaus config get <key>");
828
+ return;
829
+ }
830
+ const config = await readConfig();
831
+ const val = getNestedValue(config, key);
832
+ console.log(val !== undefined ? String(val) : "(not set)");
833
+ return;
834
+ }
835
+ if (sub === "set") {
836
+ const key = args[3] ? args[2] : args[2];
837
+ const value = args[3] || args[3];
838
+ if (!key || value === undefined) {
839
+ console.log("Usage: loophaus config set <key> <value>");
840
+ return;
841
+ }
842
+ const rawValue = args[3];
843
+ if (!key || rawValue === undefined) {
844
+ console.log("Usage: loophaus config set <key> <value>");
845
+ return;
846
+ }
847
+ if (!KNOWN_KEYS[key]) {
848
+ console.log(`Warning: '${key}' is not a known config key.`);
849
+ }
850
+ const config = await readConfig();
851
+ const parsed = rawValue === "true" ? true : rawValue === "false" ? false : isNaN(Number(rawValue)) ? rawValue : Number(rawValue);
852
+ setNestedValue(config, key, parsed);
853
+ await writeConfig(config);
854
+ console.log(`Set ${key} = ${String(parsed)}`);
855
+ return;
856
+ }
857
+ console.log("Usage: loophaus config [list|get|set] [key] [value]");
858
+ }
859
+ function getNestedValue(obj, path) {
860
+ const parts = path.split(".");
861
+ let current = obj;
862
+ for (const part of parts) {
863
+ if (current == null || typeof current !== "object")
864
+ return undefined;
865
+ current = current[part];
866
+ }
867
+ return current;
868
+ }
869
+ function setNestedValue(obj, path, value) {
870
+ const parts = path.split(".");
871
+ let current = obj;
872
+ for (let i = 0; i < parts.length - 1; i++) {
873
+ if (!(parts[i] in current) || typeof current[parts[i]] !== "object") {
874
+ current[parts[i]] = {};
875
+ }
876
+ current = current[parts[i]];
877
+ }
878
+ current[parts[parts.length - 1]] = value;
879
+ }
686
880
  try {
687
881
  switch (command) {
688
882
  case "install":
@@ -724,6 +918,15 @@ try {
724
918
  case "clean":
725
919
  await runCleanCmd();
726
920
  break;
921
+ case "config":
922
+ await runConfigCmd();
923
+ break;
924
+ case "update-check":
925
+ await runUpdateCheck();
926
+ break;
927
+ case "upgrade":
928
+ await runUpgrade();
929
+ break;
727
930
  case "sessions":
728
931
  await runSessions();
729
932
  break;
@@ -11,6 +11,53 @@ The user runs `/loop-plan` once and gets a single merged branch with all work do
11
11
 
12
12
  ---
13
13
 
14
+ ## Pre-flight: Update Check
15
+
16
+ Run this silently before starting:
17
+
18
+ ```bash
19
+ _UPDATE=$(loophaus update-check 2>/dev/null | grep "UPGRADE_AVAILABLE" || true)
20
+ ```
21
+
22
+ If `_UPDATE` contains `UPGRADE_AVAILABLE`:
23
+ 1. Show: `loophaus update available: v{current} → v{latest}`
24
+ 2. Ask user:
25
+ - **A) Upgrade now** → run `loophaus upgrade`, then continue
26
+ - **B) Always auto-upgrade** → run `loophaus config set autoUpgrade true && loophaus upgrade`, then continue
27
+ - **C) Not now** → run `loophaus config set updateCheck snoozed` silently, then continue
28
+ - **D) Never ask again** → run `loophaus config set updateCheck false`, then continue
29
+
30
+ If auto-upgrade is enabled (`loophaus config get autoUpgrade` returns `true`):
31
+ - Skip prompt, run `loophaus upgrade` silently, continue.
32
+
33
+ If no update or check fails: continue silently (never block the user).
34
+
35
+ ## Pre-flight: Skill Routing Check
36
+
37
+ Check if the project has a CLAUDE.md with loophaus skill routing:
38
+
39
+ ```bash
40
+ CLAUDE_MD="$(pwd)/CLAUDE.md"
41
+ ROUTING_MARKER=".loophaus/routing-offered.json"
42
+ ```
43
+
44
+ If CLAUDE.md exists but has no `## loophaus skill routing` section, AND routing hasn't been offered before:
45
+ 1. Suggest adding this section to CLAUDE.md:
46
+ ```markdown
47
+ ## loophaus skill routing
48
+
49
+ | User intent | Route to |
50
+ |-------------|----------|
51
+ | Feature implementation, refactoring, multi-step task | `/loophaus:loop-plan` |
52
+ | "start the loop", "continue implementing" | `/loophaus:loop` |
53
+ | "stop the loop", "cancel" | `/loophaus:loop-stop` |
54
+ | "what's the status", "how far along" | `/loophaus:loop-pulse` |
55
+ ```
56
+ 2. If user agrees, add the section to CLAUDE.md
57
+ 3. Write `{ "offeredAt": "<ISO date>" }` to `.loophaus/routing-offered.json` so we don't ask again
58
+
59
+ If CLAUDE.md doesn't exist or routing already offered: skip silently.
60
+
14
61
  ## Phase 0: Cleanup Previous Data
15
62
 
16
63
  Before starting a new plan, apply the cleanup policy from `.loophaus/config.json`:
@@ -6,6 +6,17 @@ allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
6
6
 
7
7
  # /loop — Start Iterative Dev Loop
8
8
 
9
+ ## Pre-flight
10
+
11
+ ```bash
12
+ _UPDATE=$(loophaus update-check 2>/dev/null | grep "UPGRADE_AVAILABLE" || true)
13
+ ```
14
+ If update available and autoUpgrade enabled: run `loophaus upgrade` silently.
15
+ If update available and not auto: show one-line notice `loophaus v{new} available. Run: loophaus upgrade`.
16
+ Otherwise: continue silently.
17
+
18
+ ---
19
+
9
20
  Execute the setup script to initialize the loop:
10
21
 
11
22
  ```!
@@ -0,0 +1,30 @@
1
+ export type UpdateStatus = "up_to_date" | "upgrade_available" | "snoozed" | "disabled" | "error";
2
+ export interface UpdateCheckResult {
3
+ status: UpdateStatus;
4
+ current: string;
5
+ latest: string;
6
+ message?: string;
7
+ }
8
+ export interface UpdateCache {
9
+ checkedAt: string;
10
+ status: "up_to_date" | "upgrade_available";
11
+ current: string;
12
+ latest: string;
13
+ }
14
+ export interface SnoozeState {
15
+ version: string;
16
+ level: number;
17
+ snoozedAt: string;
18
+ }
19
+ export interface UpdateConfig {
20
+ updateCheck?: boolean;
21
+ autoUpgrade?: boolean;
22
+ }
23
+ export declare function compareVersions(current: string, latest: string): -1 | 0 | 1;
24
+ export declare function isCacheFresh(cache: UpdateCache, nowMs: number): boolean;
25
+ export declare function isSnoozed(snooze: SnoozeState, version: string, nowMs: number): boolean;
26
+ export declare function getSnoozeHours(level: number): number;
27
+ export declare function readConfig(cwd?: string): Promise<UpdateConfig>;
28
+ export declare function checkForUpdate(currentVersion: string, homeCwd?: string): Promise<UpdateCheckResult>;
29
+ export declare function snoozeUpdate(version: string, homeCwd?: string): Promise<SnoozeState>;
30
+ export declare function getUpdateStatus(currentVersion: string, homeCwd?: string): Promise<string>;
@@ -0,0 +1,172 @@
1
+ // core/update-checker.ts
2
+ // npm registry version check with cache + snooze (gstack-style)
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { get } from "node:https";
6
+ const REGISTRY_URL = "https://registry.npmjs.org/@graypark/loophaus/latest";
7
+ const FETCH_TIMEOUT_MS = 5_000;
8
+ // Cache TTL in minutes
9
+ const TTL_UP_TO_DATE = 60;
10
+ const TTL_UPGRADE_AVAILABLE = 720;
11
+ // Snooze durations in hours
12
+ const SNOOZE_HOURS = [24, 48, 168]; // level 1, 2, 3+
13
+ function getLoophausDir(cwd) {
14
+ return join(cwd || process.env.HOME || "~", ".loophaus");
15
+ }
16
+ function getCachePath(cwd) {
17
+ return join(getLoophausDir(cwd), "update-cache.json");
18
+ }
19
+ function getSnoozePath(cwd) {
20
+ return join(getLoophausDir(cwd), "update-snoozed.json");
21
+ }
22
+ // --- Pure functions ---
23
+ export function compareVersions(current, latest) {
24
+ const a = current.split(".").map(Number);
25
+ const b = latest.split(".").map(Number);
26
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
27
+ const av = a[i] || 0;
28
+ const bv = b[i] || 0;
29
+ if (av < bv)
30
+ return -1;
31
+ if (av > bv)
32
+ return 1;
33
+ }
34
+ return 0;
35
+ }
36
+ export function isCacheFresh(cache, nowMs) {
37
+ const age = (nowMs - new Date(cache.checkedAt).getTime()) / 60_000;
38
+ const ttl = cache.status === "up_to_date" ? TTL_UP_TO_DATE : TTL_UPGRADE_AVAILABLE;
39
+ return age < ttl;
40
+ }
41
+ export function isSnoozed(snooze, version, nowMs) {
42
+ if (snooze.version !== version)
43
+ return false; // new version resets snooze
44
+ const level = Math.min(snooze.level, SNOOZE_HOURS.length) - 1;
45
+ const durationMs = (SNOOZE_HOURS[level] ?? SNOOZE_HOURS[SNOOZE_HOURS.length - 1]) * 3600_000;
46
+ const elapsed = nowMs - new Date(snooze.snoozedAt).getTime();
47
+ return elapsed < durationMs;
48
+ }
49
+ export function getSnoozeHours(level) {
50
+ const idx = Math.min(level, SNOOZE_HOURS.length) - 1;
51
+ return SNOOZE_HOURS[idx] ?? SNOOZE_HOURS[SNOOZE_HOURS.length - 1];
52
+ }
53
+ // --- I/O functions ---
54
+ async function readJson(path) {
55
+ try {
56
+ return JSON.parse(await readFile(path, "utf-8"));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ async function writeJson(path, data) {
63
+ await mkdir(join(path, ".."), { recursive: true });
64
+ await writeFile(path, JSON.stringify(data, null, 2), "utf-8");
65
+ }
66
+ async function fetchLatestVersion() {
67
+ return new Promise((resolve) => {
68
+ const timer = setTimeout(() => resolve(null), FETCH_TIMEOUT_MS);
69
+ try {
70
+ const req = get(REGISTRY_URL, { timeout: FETCH_TIMEOUT_MS }, (res) => {
71
+ let data = "";
72
+ res.on("data", (chunk) => { data += chunk.toString(); });
73
+ res.on("end", () => {
74
+ clearTimeout(timer);
75
+ try {
76
+ const pkg = JSON.parse(data);
77
+ resolve(pkg.version || null);
78
+ }
79
+ catch {
80
+ resolve(null);
81
+ }
82
+ });
83
+ });
84
+ req.on("error", () => { clearTimeout(timer); resolve(null); });
85
+ }
86
+ catch {
87
+ clearTimeout(timer);
88
+ resolve(null);
89
+ }
90
+ });
91
+ }
92
+ export async function readConfig(cwd) {
93
+ const configPath = join(cwd || process.cwd(), ".loophaus", "config.json");
94
+ try {
95
+ const raw = await readFile(configPath, "utf-8");
96
+ return JSON.parse(raw);
97
+ }
98
+ catch {
99
+ return {};
100
+ }
101
+ }
102
+ export async function checkForUpdate(currentVersion, homeCwd) {
103
+ const dir = getLoophausDir(homeCwd);
104
+ const cachePath = getCachePath(homeCwd);
105
+ const snoozePath = getSnoozePath(homeCwd);
106
+ const now = Date.now();
107
+ // Check config
108
+ const config = await readConfig(homeCwd);
109
+ if (config.updateCheck === false) {
110
+ return { status: "disabled", current: currentVersion, latest: currentVersion };
111
+ }
112
+ // Check cache
113
+ const cache = await readJson(cachePath);
114
+ if (cache && isCacheFresh(cache, now)) {
115
+ if (cache.status === "up_to_date") {
116
+ return { status: "up_to_date", current: currentVersion, latest: cache.latest };
117
+ }
118
+ // Cache says upgrade available — check snooze
119
+ const snooze = await readJson(snoozePath);
120
+ if (snooze && isSnoozed(snooze, cache.latest, now)) {
121
+ const hours = getSnoozeHours(snooze.level);
122
+ return { status: "snoozed", current: currentVersion, latest: cache.latest, message: `Snoozed for ${hours}h (level ${snooze.level})` };
123
+ }
124
+ return { status: "upgrade_available", current: currentVersion, latest: cache.latest };
125
+ }
126
+ // Fetch from registry
127
+ const latest = await fetchLatestVersion();
128
+ if (!latest) {
129
+ return { status: "error", current: currentVersion, latest: currentVersion, message: "Could not reach npm registry" };
130
+ }
131
+ const cmp = compareVersions(currentVersion, latest);
132
+ const status = cmp < 0 ? "upgrade_available" : "up_to_date";
133
+ // Write cache
134
+ await mkdir(dir, { recursive: true });
135
+ const newCache = { checkedAt: new Date().toISOString(), status, current: currentVersion, latest };
136
+ await writeJson(cachePath, newCache);
137
+ if (status === "upgrade_available") {
138
+ // Check snooze for the new version
139
+ const snooze = await readJson(snoozePath);
140
+ if (snooze && isSnoozed(snooze, latest, now)) {
141
+ const hours = getSnoozeHours(snooze.level);
142
+ return { status: "snoozed", current: currentVersion, latest, message: `Snoozed for ${hours}h (level ${snooze.level})` };
143
+ }
144
+ }
145
+ return { status, current: currentVersion, latest };
146
+ }
147
+ export async function snoozeUpdate(version, homeCwd) {
148
+ const snoozePath = getSnoozePath(homeCwd);
149
+ const existing = await readJson(snoozePath);
150
+ let level = 1;
151
+ if (existing && existing.version === version) {
152
+ level = existing.level + 1;
153
+ }
154
+ const snooze = {
155
+ version,
156
+ level,
157
+ snoozedAt: new Date().toISOString(),
158
+ };
159
+ await writeJson(snoozePath, snooze);
160
+ return snooze;
161
+ }
162
+ export async function getUpdateStatus(currentVersion, homeCwd) {
163
+ const result = await checkForUpdate(currentVersion, homeCwd);
164
+ switch (result.status) {
165
+ case "up_to_date": return "";
166
+ case "upgrade_available": return `UPGRADE_AVAILABLE ${result.current} ${result.latest}`;
167
+ case "snoozed": return "";
168
+ case "disabled": return "";
169
+ case "error": return "";
170
+ default: return "";
171
+ }
172
+ }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graypark/loophaus",
3
- "version": "3.6.1",
3
+ "version": "3.7.0",
4
4
  "type": "module",
5
5
  "description": "loophaus — Control plane for coding agents. Iterative dev loops with multi-agent orchestration.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graypark/loophaus",
3
- "version": "3.6.1",
3
+ "version": "3.7.0",
4
4
  "type": "module",
5
5
  "description": "loophaus — Control plane for coding agents. Iterative dev loops with multi-agent orchestration.",
6
6
  "license": "MIT",