clawstrap 1.4.1 → 1.5.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.
Files changed (2) hide show
  1. package/dist/index.cjs +546 -79
  2. package/package.json +3 -1
package/dist/index.cjs CHANGED
@@ -88,6 +88,7 @@ __export(writers_exports, {
88
88
  appendToFutureConsiderations: () => appendToFutureConsiderations,
89
89
  appendToGotchaLog: () => appendToGotchaLog,
90
90
  appendToMemory: () => appendToMemory,
91
+ appendToOpenThreads: () => appendToOpenThreads,
91
92
  writeConventions: () => writeConventions
92
93
  });
93
94
  function formatEntry(source, text) {
@@ -114,6 +115,7 @@ function appendToMemory(rootDir, entries, source) {
114
115
  const appendText = "\n" + toAppend.join("\n") + "\n";
115
116
  import_node_fs14.default.appendFileSync(memoryPath, appendText, "utf-8");
116
117
  }
118
+ return toAppend.length;
117
119
  }
118
120
  function appendToGotchaLog(rootDir, entries) {
119
121
  const logPath = import_node_path15.default.join(rootDir, ".claude", "gotcha-log.md");
@@ -141,6 +143,19 @@ function appendToFutureConsiderations(rootDir, entries) {
141
143
  const toAppend = entries.map((e) => formatEntry("session", e)).join("\n");
142
144
  import_node_fs14.default.appendFileSync(fcPath, "\n" + toAppend + "\n", "utf-8");
143
145
  }
146
+ function appendToOpenThreads(rootDir, entries) {
147
+ const otPath = import_node_path15.default.join(rootDir, ".claude", "memory", "open-threads.md");
148
+ import_node_fs14.default.mkdirSync(import_node_path15.default.dirname(otPath), { recursive: true });
149
+ if (!import_node_fs14.default.existsSync(otPath)) {
150
+ import_node_fs14.default.writeFileSync(
151
+ otPath,
152
+ "# Open Threads\n\nUnresolved questions and next steps.\n\n",
153
+ "utf-8"
154
+ );
155
+ }
156
+ const toAppend = entries.map((e) => formatEntry("session", e)).join("\n");
157
+ import_node_fs14.default.appendFileSync(otPath, "\n" + toAppend + "\n", "utf-8");
158
+ }
144
159
  function buildAutoBlock(sections) {
145
160
  const lines = [AUTO_START];
146
161
  lines.push("## Naming Conventions");
@@ -177,6 +192,11 @@ function buildAutoBlock(sections) {
177
192
  } else {
178
193
  lines.push("- No comment patterns detected.");
179
194
  }
195
+ if (sections.architecture && sections.architecture.length > 0) {
196
+ lines.push("");
197
+ lines.push("## Architecture & Design Patterns");
198
+ for (const item of sections.architecture) lines.push(`- ${item}`);
199
+ }
180
200
  lines.push(AUTO_END);
181
201
  return lines.join("\n");
182
202
  }
@@ -263,12 +283,20 @@ var ClawstrapConfigSchema = import_zod.z.object({
263
283
  scan: import_zod.z.object({
264
284
  intervalDays: import_zod.z.number().default(7)
265
285
  }).default({}),
286
+ git: import_zod.z.object({
287
+ pollIntervalMinutes: import_zod.z.number().default(5)
288
+ }).default({}),
289
+ synthesis: import_zod.z.object({
290
+ enabled: import_zod.z.boolean().default(false),
291
+ triggerEveryN: import_zod.z.number().default(10)
292
+ }).default({}),
266
293
  silent: import_zod.z.boolean().default(false)
267
294
  }).optional(),
268
295
  watchState: import_zod.z.object({
269
296
  lastGitCommit: import_zod.z.string().optional(),
270
297
  lastScanAt: import_zod.z.string().optional(),
271
- lastTranscriptAt: import_zod.z.string().optional()
298
+ lastTranscriptAt: import_zod.z.string().optional(),
299
+ entriesSinceLastSynthesis: import_zod.z.coerce.number().optional()
272
300
  }).optional(),
273
301
  lastExport: LastExportSchema
274
302
  });
@@ -777,7 +805,7 @@ async function init(directory, options) {
777
805
  import_node_fs2.default.mkdirSync(targetDir, { recursive: true });
778
806
  }
779
807
  const config = ClawstrapConfigSchema.parse({
780
- version: "1.4.1",
808
+ version: "1.4.2",
781
809
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
782
810
  workspaceName: answers.workspaceName,
783
811
  targetDirectory: directory,
@@ -1499,9 +1527,8 @@ Exported to ${import_node_path14.default.relative(process.cwd(), outDir) || outD
1499
1527
  }
1500
1528
 
1501
1529
  // src/watch.ts
1502
- var import_node_path20 = __toESM(require("path"), 1);
1503
- var import_node_child_process4 = require("child_process");
1504
- var import_node_fs19 = __toESM(require("fs"), 1);
1530
+ var import_node_path22 = __toESM(require("path"), 1);
1531
+ var import_node_fs21 = __toESM(require("fs"), 1);
1505
1532
 
1506
1533
  // src/watch/git.ts
1507
1534
  var import_node_child_process = require("child_process");
@@ -1844,17 +1871,220 @@ async function runScan(rootDir) {
1844
1871
  init_writers();
1845
1872
 
1846
1873
  // src/watch/daemon.ts
1847
- var import_node_fs18 = __toESM(require("fs"), 1);
1848
- var import_node_path19 = __toESM(require("path"), 1);
1874
+ var import_node_fs20 = __toESM(require("fs"), 1);
1875
+ var import_node_path21 = __toESM(require("path"), 1);
1849
1876
  init_writers();
1850
1877
 
1851
- // src/watch/transcripts.ts
1878
+ // src/watch/synthesize.ts
1852
1879
  var import_node_fs17 = __toESM(require("fs"), 1);
1853
1880
  var import_node_path18 = __toESM(require("path"), 1);
1881
+ init_dedup();
1882
+ var SYNTH_START = "<!-- CLAWSTRAP:SYNTHESIS:START -->";
1883
+ var SYNTH_END = "<!-- CLAWSTRAP:SYNTHESIS:END -->";
1884
+ var MAX_ENTRIES_TO_SEND = 20;
1885
+ function extractExistingSummary(content) {
1886
+ const startIdx = content.indexOf(SYNTH_START);
1887
+ const endIdx = content.indexOf(SYNTH_END);
1888
+ if (startIdx === -1 || endIdx === -1) return null;
1889
+ const block = content.slice(startIdx + SYNTH_START.length, endIdx).trim();
1890
+ const lines = block.split("\n");
1891
+ let start = 0;
1892
+ if (/^##\s+Living Summary/.test(lines[start] ?? "")) start++;
1893
+ if (/^>\s+Updated:/.test(lines[start] ?? "")) start++;
1894
+ return lines.slice(start).join("\n").trim() || null;
1895
+ }
1896
+ function buildSynthBlock(summary) {
1897
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1898
+ return [
1899
+ SYNTH_START,
1900
+ "## Living Summary",
1901
+ `> Updated: ${ts}`,
1902
+ "",
1903
+ summary,
1904
+ SYNTH_END
1905
+ ].join("\n");
1906
+ }
1907
+ function writeSynthBlock(memoryPath, summary) {
1908
+ const content = import_node_fs17.default.existsSync(memoryPath) ? import_node_fs17.default.readFileSync(memoryPath, "utf-8") : "";
1909
+ const block = buildSynthBlock(summary);
1910
+ const startIdx = content.indexOf(SYNTH_START);
1911
+ const endIdx = content.indexOf(SYNTH_END);
1912
+ if (startIdx !== -1 && endIdx !== -1) {
1913
+ const before = content.slice(0, startIdx);
1914
+ const after = content.slice(endIdx + SYNTH_END.length);
1915
+ import_node_fs17.default.writeFileSync(memoryPath, before + block + after, "utf-8");
1916
+ return;
1917
+ }
1918
+ const headingMatch = /^#[^\n]*\n?/m.exec(content);
1919
+ if (headingMatch) {
1920
+ const insertAt = headingMatch.index + headingMatch[0].length;
1921
+ const updated = content.slice(0, insertAt) + "\n" + block + "\n" + content.slice(insertAt);
1922
+ import_node_fs17.default.writeFileSync(memoryPath, updated, "utf-8");
1923
+ } else {
1924
+ import_node_fs17.default.writeFileSync(memoryPath, block + "\n\n" + content, "utf-8");
1925
+ }
1926
+ }
1927
+ async function synthesizeMemory(rootDir, adapter) {
1928
+ const memoryPath = import_node_path18.default.join(rootDir, ".claude", "memory", "MEMORY.md");
1929
+ if (!import_node_fs17.default.existsSync(memoryPath)) return null;
1930
+ const content = import_node_fs17.default.readFileSync(memoryPath, "utf-8");
1931
+ const contentWithoutSynthBlock = content.replace(
1932
+ /<!-- CLAWSTRAP:SYNTHESIS:START -->[\s\S]*?<!-- CLAWSTRAP:SYNTHESIS:END -->/,
1933
+ ""
1934
+ );
1935
+ const allEntries = parseMemoryEntries(contentWithoutSynthBlock);
1936
+ if (allEntries.length === 0) return null;
1937
+ const recentEntries = allEntries.slice(-MAX_ENTRIES_TO_SEND);
1938
+ const existingSummary = extractExistingSummary(content);
1939
+ let prompt;
1940
+ if (existingSummary) {
1941
+ prompt = `You are maintaining a living summary of an AI agent workspace.
1942
+
1943
+ Current summary:
1944
+ ${existingSummary}
1945
+
1946
+ Recent new memory entries:
1947
+ ${recentEntries.join("\n---\n")}
1948
+
1949
+ Update the summary to incorporate the new information. Write 3\u20135 sentences of persistent truths about how this workspace operates. Output only the updated paragraph \u2014 no heading, no markdown, no explanation.`;
1950
+ } else {
1951
+ prompt = `You are summarising an AI agent workspace from its memory log.
1952
+
1953
+ Recent memory entries:
1954
+ ${recentEntries.join("\n---\n")}
1955
+
1956
+ Write a concise 3\u20135 sentence summary of the persistent truths about how this workspace operates. Output only the paragraph \u2014 no heading, no markdown, no explanation.`;
1957
+ }
1958
+ let response;
1959
+ try {
1960
+ response = await adapter.complete(prompt);
1961
+ } catch {
1962
+ return null;
1963
+ }
1964
+ const summary = response.replace(/^```(?:markdown)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
1965
+ if (!summary) return null;
1966
+ try {
1967
+ writeSynthBlock(memoryPath, summary);
1968
+ } catch {
1969
+ return null;
1970
+ }
1971
+ return summary;
1972
+ }
1973
+
1974
+ // src/watch/infer.ts
1975
+ var import_node_child_process2 = require("child_process");
1976
+ var import_node_fs18 = __toESM(require("fs"), 1);
1977
+ var import_node_path19 = __toESM(require("path"), 1);
1978
+ var CODE_EXTS2 = /* @__PURE__ */ new Set([".ts", ".js", ".tsx", ".jsx"]);
1979
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "tmp", "dist", ".claude"]);
1980
+ var MAX_FILES = 10;
1981
+ var MAX_LINES_PER_FILE = 150;
1982
+ var MIN_FILES = 3;
1983
+ function walkCodeFiles(rootDir) {
1984
+ const results = [];
1985
+ function walk(dir, depth = 0) {
1986
+ if (depth > 8) return;
1987
+ let entries;
1988
+ try {
1989
+ entries = import_node_fs18.default.readdirSync(dir, { withFileTypes: true });
1990
+ } catch {
1991
+ return;
1992
+ }
1993
+ for (const entry of entries) {
1994
+ if (SKIP_DIRS2.has(entry.name)) continue;
1995
+ const fullPath = import_node_path19.default.join(dir, entry.name);
1996
+ if (entry.isDirectory()) {
1997
+ walk(fullPath, depth + 1);
1998
+ } else if (entry.isFile() && CODE_EXTS2.has(import_node_path19.default.extname(entry.name))) {
1999
+ if (!/\.(test|spec)\.(ts|js|tsx|jsx)$/.test(entry.name)) {
2000
+ results.push(fullPath);
2001
+ }
2002
+ }
2003
+ }
2004
+ }
2005
+ walk(rootDir);
2006
+ return results;
2007
+ }
2008
+ function getRecentlyChangedFiles(rootDir) {
2009
+ try {
2010
+ const output = (0, import_node_child_process2.execSync)(
2011
+ `git -C "${rootDir}" log --format='' --name-only -n 100`,
2012
+ { encoding: "utf-8" }
2013
+ ).trim();
2014
+ if (!output) return [];
2015
+ const seen = /* @__PURE__ */ new Set();
2016
+ const files = [];
2017
+ for (const line of output.split("\n")) {
2018
+ const trimmed = line.trim();
2019
+ if (!trimmed || seen.has(trimmed)) continue;
2020
+ seen.add(trimmed);
2021
+ const ext = import_node_path19.default.extname(trimmed);
2022
+ if (!CODE_EXTS2.has(ext)) continue;
2023
+ if (/\.(test|spec)\.(ts|js|tsx|jsx)$/.test(trimmed)) continue;
2024
+ const abs = import_node_path19.default.join(rootDir, trimmed);
2025
+ if (import_node_fs18.default.existsSync(abs)) files.push(abs);
2026
+ }
2027
+ return files;
2028
+ } catch {
2029
+ return [];
2030
+ }
2031
+ }
2032
+ function readTruncated(filePath, rootDir) {
2033
+ let content;
2034
+ try {
2035
+ content = import_node_fs18.default.readFileSync(filePath, "utf-8");
2036
+ } catch {
2037
+ return "";
2038
+ }
2039
+ const lines = content.split("\n");
2040
+ const relPath = import_node_path19.default.relative(rootDir, filePath);
2041
+ const truncated = lines.length > MAX_LINES_PER_FILE ? lines.slice(0, MAX_LINES_PER_FILE).join("\n") + "\n// ... truncated" : lines.join("\n");
2042
+ return `=== ${relPath} ===
2043
+ ${truncated}`;
2044
+ }
2045
+ async function inferArchitecturePatterns(rootDir, syntacticSections, adapter) {
2046
+ let candidates = getRecentlyChangedFiles(rootDir);
2047
+ if (candidates.length < MIN_FILES) {
2048
+ candidates = walkCodeFiles(rootDir);
2049
+ }
2050
+ if (candidates.length < MIN_FILES) return [];
2051
+ const sampled = candidates.slice(0, MAX_FILES);
2052
+ const fileSamples = sampled.map((f) => readTruncated(f, rootDir)).filter(Boolean).join("\n\n");
2053
+ if (!fileSamples) return [];
2054
+ const syntacticSummary = [
2055
+ `Naming: ${syntacticSections.naming.join("; ")}`,
2056
+ `Imports: ${syntacticSections.imports.join("; ")}`,
2057
+ `Error handling: ${syntacticSections.errorHandling.join("; ")}`
2058
+ ].join("\n");
2059
+ const prompt = `You are analysing a software project to infer its architectural and design conventions.
2060
+
2061
+ Syntactic analysis already found:
2062
+ ${syntacticSummary}
2063
+
2064
+ Source file samples:
2065
+ ${fileSamples}
2066
+
2067
+ Based on the code, identify 3\u20138 architectural or design patterns as imperative rules.
2068
+ Rules must be specific to this codebase, not generic best practices.
2069
+ Format: one rule per line, starting with "Always", "Never", or "When".
2070
+ Output only the rules \u2014 no explanation, no numbering, no markdown.`;
2071
+ let response;
2072
+ try {
2073
+ response = await adapter.complete(prompt);
2074
+ } catch {
2075
+ return [];
2076
+ }
2077
+ const rules = response.replace(/^```(?:markdown)?\s*/m, "").replace(/\s*```\s*$/m, "").split("\n").map((line) => line.replace(/^\s*[-*\d.]+\s*/, "").trim()).filter((line) => /^(Always|Never|When)\b/i.test(line));
2078
+ return rules;
2079
+ }
2080
+
2081
+ // src/watch/transcripts.ts
2082
+ var import_node_fs19 = __toESM(require("fs"), 1);
2083
+ var import_node_path20 = __toESM(require("path"), 1);
1854
2084
  async function processTranscript(filePath, adapter) {
1855
2085
  let content;
1856
2086
  try {
1857
- content = import_node_fs17.default.readFileSync(filePath, "utf-8");
2087
+ content = import_node_fs19.default.readFileSync(filePath, "utf-8");
1858
2088
  } catch {
1859
2089
  return null;
1860
2090
  }
@@ -1897,13 +2127,13 @@ Each item must be a concise one-sentence string. Arrays may be empty.`;
1897
2127
  }
1898
2128
  }
1899
2129
  function watchTranscriptDir(rootDir, onNewFile) {
1900
- const sessionsDir = import_node_path18.default.join(rootDir, "tmp", "sessions");
1901
- import_node_fs17.default.mkdirSync(sessionsDir, { recursive: true });
1902
- const watcher = import_node_fs17.default.watch(sessionsDir, (event, filename) => {
2130
+ const sessionsDir = import_node_path20.default.join(rootDir, "tmp", "sessions");
2131
+ import_node_fs19.default.mkdirSync(sessionsDir, { recursive: true });
2132
+ const watcher = import_node_fs19.default.watch(sessionsDir, (event, filename) => {
1903
2133
  if (event !== "rename" || !filename) return;
1904
2134
  if (!filename.endsWith(".md")) return;
1905
- const filePath = import_node_path18.default.join(sessionsDir, filename);
1906
- if (!import_node_fs17.default.existsSync(filePath)) return;
2135
+ const filePath = import_node_path20.default.join(sessionsDir, filename);
2136
+ if (!import_node_fs19.default.existsSync(filePath)) return;
1907
2137
  onNewFile(filePath).catch(() => {
1908
2138
  });
1909
2139
  });
@@ -1913,12 +2143,12 @@ function watchTranscriptDir(rootDir, onNewFile) {
1913
2143
  }
1914
2144
 
1915
2145
  // src/watch/adapters/claude-local.ts
1916
- var import_node_child_process2 = require("child_process");
2146
+ var import_node_child_process3 = require("child_process");
1917
2147
  var ClaudeLocalAdapter = class {
1918
2148
  async complete(prompt) {
1919
2149
  const escaped = prompt.replace(/'/g, "'\\''");
1920
2150
  try {
1921
- const result = (0, import_node_child_process2.execSync)(`claude -p '${escaped}'`, {
2151
+ const result = (0, import_node_child_process3.execSync)(`claude -p '${escaped}'`, {
1922
2152
  encoding: "utf-8",
1923
2153
  timeout: 6e4,
1924
2154
  stdio: ["pipe", "pipe", "pipe"]
@@ -1999,12 +2229,12 @@ var OllamaAdapter = class {
1999
2229
  };
2000
2230
 
2001
2231
  // src/watch/adapters/codex-local.ts
2002
- var import_node_child_process3 = require("child_process");
2232
+ var import_node_child_process4 = require("child_process");
2003
2233
  var CodexLocalAdapter = class {
2004
2234
  async complete(prompt) {
2005
2235
  const escaped = prompt.replace(/'/g, "'\\''");
2006
2236
  try {
2007
- const result = (0, import_node_child_process3.execSync)(`codex '${escaped}'`, {
2237
+ const result = (0, import_node_child_process4.execSync)(`codex '${escaped}'`, {
2008
2238
  encoding: "utf-8",
2009
2239
  timeout: 6e4,
2010
2240
  stdio: ["pipe", "pipe", "pipe"]
@@ -2038,78 +2268,320 @@ function createAdapter(config) {
2038
2268
  }
2039
2269
 
2040
2270
  // src/watch/daemon.ts
2041
- async function runDaemon(rootDir, config) {
2042
- const silent = config.watch?.silent ?? false;
2043
- const log = silent ? () => {
2044
- } : (msg) => process.stdout.write(msg + "\n");
2271
+ async function runDaemon(rootDir, config, ui) {
2045
2272
  const cleanup = [];
2046
2273
  const shutdown = () => {
2047
2274
  cleanup.forEach((fn) => fn());
2275
+ ui.clear();
2048
2276
  clearPid(rootDir);
2049
2277
  process.exit(0);
2050
2278
  };
2051
2279
  process.on("SIGTERM", shutdown);
2052
2280
  process.on("SIGINT", shutdown);
2053
- log("[clawstrap watch] daemon started");
2281
+ ui.daemonStarted();
2054
2282
  const sinceCommit = config.watchState?.lastGitCommit ?? null;
2283
+ ui.gitStart();
2055
2284
  const gitResult = await runGitObserver(rootDir, sinceCommit);
2285
+ ui.gitDone(gitResult ? { entriesWritten: gitResult.entriesWritten, lastCommit: gitResult.lastCommit } : null);
2286
+ let lastGitCommit = gitResult?.lastCommit ?? sinceCommit;
2056
2287
  if (gitResult) {
2057
2288
  updateWatchState(rootDir, { lastGitCommit: gitResult.lastCommit });
2058
- log(`[clawstrap watch] git: ${gitResult.entriesWritten} entries written`);
2059
2289
  }
2060
2290
  const adapter = createAdapter(config);
2291
+ let entriesSinceLastSynthesis = config.watchState?.entriesSinceLastSynthesis ?? 0;
2292
+ const synthEnabled = config.watch?.synthesis?.enabled ?? false;
2293
+ const triggerEveryN = config.watch?.synthesis?.triggerEveryN ?? 10;
2294
+ const maybeSynthesize = async () => {
2295
+ if (!synthEnabled || entriesSinceLastSynthesis < triggerEveryN) return;
2296
+ ui.synthStart();
2297
+ const summary = await synthesizeMemory(rootDir, adapter);
2298
+ ui.synthDone(summary);
2299
+ entriesSinceLastSynthesis = 0;
2300
+ updateWatchState(rootDir, { entriesSinceLastSynthesis: "0" });
2301
+ };
2061
2302
  const stopTranscripts = watchTranscriptDir(rootDir, async (filePath) => {
2062
- log(`[clawstrap watch] transcript: processing ${import_node_path19.default.basename(filePath)}`);
2303
+ ui.transcriptStart(import_node_path21.default.basename(filePath));
2304
+ ui.llmCallStart();
2063
2305
  const result = await processTranscript(filePath, adapter);
2306
+ ui.llmCallDone(result ? { decisions: result.decisions.length, corrections: result.corrections.length, openThreads: result.openThreads.length } : null);
2064
2307
  if (result) {
2065
- const { appendToMemory: appendToMemory2, appendToGotchaLog: appendToGotchaLog2, appendToFutureConsiderations: appendToFutureConsiderations2 } = await Promise.resolve().then(() => (init_writers(), writers_exports));
2066
- if (result.decisions.length) appendToMemory2(rootDir, result.decisions, "session");
2308
+ const { appendToMemory: appendToMemory2, appendToGotchaLog: appendToGotchaLog2, appendToFutureConsiderations: appendToFutureConsiderations2, appendToOpenThreads: appendToOpenThreads2 } = await Promise.resolve().then(() => (init_writers(), writers_exports));
2309
+ let written = 0;
2310
+ if (result.decisions.length) written += appendToMemory2(rootDir, result.decisions, "session");
2067
2311
  if (result.corrections.length) appendToGotchaLog2(rootDir, result.corrections);
2068
2312
  if (result.deferredIdeas.length) appendToFutureConsiderations2(rootDir, result.deferredIdeas);
2069
- updateWatchState(rootDir, { lastTranscriptAt: (/* @__PURE__ */ new Date()).toISOString() });
2070
- log(
2071
- `[clawstrap watch] transcript: decisions=${result.decisions.length} corrections=${result.corrections.length}`
2072
- );
2313
+ if (result.openThreads.length) appendToOpenThreads2(rootDir, result.openThreads);
2314
+ entriesSinceLastSynthesis += written;
2315
+ updateWatchState(rootDir, {
2316
+ lastTranscriptAt: (/* @__PURE__ */ new Date()).toISOString(),
2317
+ entriesSinceLastSynthesis: String(entriesSinceLastSynthesis)
2318
+ });
2319
+ ui.transcriptWriteDone();
2320
+ await maybeSynthesize();
2073
2321
  }
2074
2322
  });
2075
2323
  cleanup.push(stopTranscripts);
2324
+ let gitRunning = false;
2325
+ const pollIntervalMinutes = config.watch?.git?.pollIntervalMinutes ?? 5;
2326
+ const gitPollTimer = setInterval(async () => {
2327
+ if (gitRunning) return;
2328
+ gitRunning = true;
2329
+ try {
2330
+ const result = await runGitObserver(rootDir, lastGitCommit);
2331
+ if (result && result.entriesWritten > 0) {
2332
+ ui.gitPollDone({ entriesWritten: result.entriesWritten, lastCommit: result.lastCommit });
2333
+ }
2334
+ if (result) {
2335
+ lastGitCommit = result.lastCommit;
2336
+ updateWatchState(rootDir, { lastGitCommit: result.lastCommit });
2337
+ }
2338
+ } finally {
2339
+ gitRunning = false;
2340
+ }
2341
+ }, pollIntervalMinutes * 60 * 1e3);
2342
+ cleanup.push(() => clearInterval(gitPollTimer));
2076
2343
  const intervalDays = config.watch?.scan?.intervalDays ?? 7;
2077
2344
  const lastScan = config.watchState?.lastScanAt ? new Date(config.watchState.lastScanAt) : null;
2078
2345
  const msSinceLastScan = lastScan ? Date.now() - lastScan.getTime() : Infinity;
2079
2346
  const scanIntervalMs = intervalDays * 24 * 60 * 60 * 1e3;
2080
2347
  const doScan = async () => {
2081
- log("[clawstrap watch] scan: running convention scan...");
2348
+ ui.scanStart(lastScan);
2349
+ ui.scanFilesStart();
2082
2350
  const sections = await runScan(rootDir);
2351
+ ui.scanFilesDone();
2352
+ if (config.watch?.adapter) {
2353
+ ui.inferStart();
2354
+ const rules = await inferArchitecturePatterns(rootDir, sections, adapter);
2355
+ ui.inferDone(rules.length > 0 ? rules.length : null);
2356
+ if (rules.length > 0) sections.architecture = rules;
2357
+ }
2083
2358
  writeConventions(rootDir, sections);
2084
2359
  updateWatchState(rootDir, { lastScanAt: (/* @__PURE__ */ new Date()).toISOString() });
2085
- log("[clawstrap watch] scan: conventions.md updated");
2360
+ ui.scanDone(sections.naming[0] ?? "");
2086
2361
  };
2087
2362
  if (msSinceLastScan >= scanIntervalMs) {
2088
2363
  await doScan();
2089
2364
  }
2090
2365
  const scanTimer = setInterval(doScan, scanIntervalMs);
2091
2366
  cleanup.push(() => clearInterval(scanTimer));
2092
- log("[clawstrap watch] watching for changes...");
2367
+ ui.showIdle(import_node_path21.default.join(rootDir, "tmp", "sessions"));
2093
2368
  await new Promise(() => {
2094
2369
  });
2095
2370
  }
2096
2371
  function updateWatchState(rootDir, updates) {
2097
- const configPath = import_node_path19.default.join(rootDir, ".clawstrap.json");
2372
+ const configPath = import_node_path21.default.join(rootDir, ".clawstrap.json");
2098
2373
  try {
2099
- const raw = JSON.parse(import_node_fs18.default.readFileSync(configPath, "utf-8"));
2374
+ const raw = JSON.parse(import_node_fs20.default.readFileSync(configPath, "utf-8"));
2100
2375
  raw["watchState"] = { ...raw["watchState"] ?? {}, ...updates };
2101
- import_node_fs18.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
2376
+ import_node_fs20.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
2102
2377
  } catch {
2103
2378
  }
2104
2379
  }
2105
2380
 
2381
+ // src/watch/ui.ts
2382
+ var import_picocolors = __toESM(require("picocolors"), 1);
2383
+ var import_ora = __toESM(require("ora"), 1);
2384
+ function formatAgo(date) {
2385
+ if (!date) return "never";
2386
+ const diffMs = Date.now() - date.getTime();
2387
+ const diffMins = Math.floor(diffMs / 6e4);
2388
+ if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`;
2389
+ const diffHours = Math.floor(diffMins / 60);
2390
+ if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
2391
+ const diffDays = Math.floor(diffHours / 24);
2392
+ return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
2393
+ }
2394
+ var T = {
2395
+ branch: import_picocolors.default.gray("\u251C\u2500"),
2396
+ last: import_picocolors.default.gray("\u2514\u2500"),
2397
+ check: import_picocolors.default.green("\u2713")
2398
+ };
2399
+ function header(label) {
2400
+ process.stdout.write(`
2401
+ ${import_picocolors.default.cyan("\u25C6")} ${import_picocolors.default.bold(label)}
2402
+ `);
2403
+ }
2404
+ function row(connector, label, value) {
2405
+ const val = value !== void 0 ? ` ${import_picocolors.default.bold(value)}` : "";
2406
+ process.stdout.write(`${connector} ${label}${val}
2407
+ `);
2408
+ }
2409
+ var SilentUI = class {
2410
+ daemonStarted() {
2411
+ }
2412
+ gitStart() {
2413
+ }
2414
+ gitDone(_result) {
2415
+ }
2416
+ gitPollDone(_result) {
2417
+ }
2418
+ transcriptStart(_filename) {
2419
+ }
2420
+ llmCallStart() {
2421
+ }
2422
+ llmCallDone(_counts) {
2423
+ }
2424
+ transcriptWriteDone() {
2425
+ }
2426
+ scanStart(_lastRunAt) {
2427
+ }
2428
+ scanFilesStart() {
2429
+ }
2430
+ scanFilesDone() {
2431
+ }
2432
+ scanDone(_namingStyle) {
2433
+ }
2434
+ synthStart() {
2435
+ }
2436
+ synthDone(_summary) {
2437
+ }
2438
+ inferStart() {
2439
+ }
2440
+ inferDone(_rulesCount) {
2441
+ }
2442
+ showIdle(_watchDir) {
2443
+ }
2444
+ clear() {
2445
+ }
2446
+ };
2447
+ var RichUI = class {
2448
+ spinner = null;
2449
+ daemonStarted() {
2450
+ process.stdout.write(`
2451
+ ${import_picocolors.default.cyan("clawstrap watch")} ${import_picocolors.default.dim("daemon started")}
2452
+ `);
2453
+ }
2454
+ // Git observer ──────────────────────────────────────────────────────────────
2455
+ gitStart() {
2456
+ header("Git observer");
2457
+ }
2458
+ gitDone(result) {
2459
+ if (!result) {
2460
+ row(T.last, import_picocolors.default.dim("No new commits found."));
2461
+ return;
2462
+ }
2463
+ row(T.branch, "Last processed commit", result.lastCommit.slice(0, 7));
2464
+ row(T.branch, "Entries written", String(result.entriesWritten));
2465
+ row(T.last, `Writing to MEMORY.md... ${T.check} done`);
2466
+ }
2467
+ gitPollDone(result) {
2468
+ const time = (/* @__PURE__ */ new Date()).toTimeString().slice(0, 5);
2469
+ process.stdout.write(
2470
+ `
2471
+ ${import_picocolors.default.cyan("\u25C6")} ${import_picocolors.default.bold("Git:")} +${result.entriesWritten} entr${result.entriesWritten === 1 ? "y" : "ies"} written ${import_picocolors.default.dim(result.lastCommit.slice(0, 7))} ${import_picocolors.default.dim(time)}
2472
+ `
2473
+ );
2474
+ }
2475
+ // Transcript ────────────────────────────────────────────────────────────────
2476
+ transcriptStart(filename) {
2477
+ header(`New session summary detected ${import_picocolors.default.cyan(filename)}`);
2478
+ }
2479
+ llmCallStart() {
2480
+ this.spinner = (0, import_ora.default)({
2481
+ text: `${T.branch} Sending to LLM adapter...`,
2482
+ prefixText: ""
2483
+ }).start();
2484
+ }
2485
+ llmCallDone(counts) {
2486
+ if (this.spinner) {
2487
+ if (counts) {
2488
+ this.spinner.succeed(`${T.branch} Sending to LLM adapter... ${T.check}`);
2489
+ } else {
2490
+ this.spinner.fail(`${T.branch} Sending to LLM adapter... failed`);
2491
+ }
2492
+ this.spinner = null;
2493
+ }
2494
+ if (counts) {
2495
+ row(T.branch, "Decisions found ", String(counts.decisions));
2496
+ row(T.branch, "Corrections found ", String(counts.corrections));
2497
+ row(T.branch, "Open threads found ", String(counts.openThreads));
2498
+ }
2499
+ }
2500
+ transcriptWriteDone() {
2501
+ row(T.last, `Writing to memory files... ${T.check} done`);
2502
+ }
2503
+ // Convention scan ───────────────────────────────────────────────────────────
2504
+ scanStart(lastRunAt) {
2505
+ header(`Convention scan ${import_picocolors.default.dim(`(last run: ${formatAgo(lastRunAt)})`)}`);
2506
+ }
2507
+ scanFilesStart() {
2508
+ this.spinner = (0, import_ora.default)({
2509
+ text: `${T.branch} Scanning files...`,
2510
+ prefixText: ""
2511
+ }).start();
2512
+ }
2513
+ scanFilesDone() {
2514
+ if (this.spinner) {
2515
+ this.spinner.succeed(`${T.branch} Scanning files... done`);
2516
+ this.spinner = null;
2517
+ }
2518
+ }
2519
+ scanDone(namingStyle) {
2520
+ if (namingStyle) {
2521
+ row(T.branch, "Naming convention ", namingStyle);
2522
+ }
2523
+ row(T.last, `Writing conventions.md... ${T.check} done`);
2524
+ }
2525
+ // Memory synthesis ──────────────────────────────────────────────────────────
2526
+ synthStart() {
2527
+ this.spinner?.stop();
2528
+ this.spinner = (0, import_ora.default)({
2529
+ text: `${T.branch} Synthesising memory...`,
2530
+ prefixText: ""
2531
+ }).start();
2532
+ }
2533
+ synthDone(summary) {
2534
+ if (this.spinner) {
2535
+ if (summary) {
2536
+ const preview = summary.length > 60 ? summary.slice(0, 60) + "\u2026" : summary;
2537
+ this.spinner.succeed(`${T.branch} Living summary updated ${import_picocolors.default.dim(preview)}`);
2538
+ } else {
2539
+ this.spinner.fail(`${T.branch} Memory synthesis failed`);
2540
+ }
2541
+ this.spinner = null;
2542
+ }
2543
+ }
2544
+ // Architecture inference ────────────────────────────────────────────────────
2545
+ inferStart() {
2546
+ this.spinner?.stop();
2547
+ this.spinner = (0, import_ora.default)({
2548
+ text: `${T.branch} Inferring architecture patterns...`,
2549
+ prefixText: ""
2550
+ }).start();
2551
+ }
2552
+ inferDone(rulesCount) {
2553
+ if (this.spinner) {
2554
+ if (rulesCount !== null && rulesCount > 0) {
2555
+ this.spinner.succeed(`${T.branch} Architecture patterns inferred ${import_picocolors.default.bold(String(rulesCount))} rules`);
2556
+ } else {
2557
+ this.spinner.fail(`${T.branch} Architecture inference failed`);
2558
+ }
2559
+ this.spinner = null;
2560
+ }
2561
+ }
2562
+ // Idle ──────────────────────────────────────────────────────────────────────
2563
+ showIdle(watchDir) {
2564
+ process.stdout.write(`
2565
+ ${import_picocolors.default.dim("\u25C7")} ${import_picocolors.default.dim("Watching for changes...")}
2566
+ `);
2567
+ row(T.last, import_picocolors.default.dim("Transcripts"), import_picocolors.default.dim(watchDir + " (listening)"));
2568
+ process.stdout.write("\n");
2569
+ }
2570
+ // Cleanup ───────────────────────────────────────────────────────────────────
2571
+ clear() {
2572
+ if (this.spinner) {
2573
+ this.spinner.stop();
2574
+ this.spinner = null;
2575
+ }
2576
+ }
2577
+ };
2578
+ function createUI(silent) {
2579
+ return silent ? new SilentUI() : new RichUI();
2580
+ }
2581
+
2106
2582
  // src/watch.ts
2107
2583
  async function watch(options) {
2108
2584
  const { config, rootDir } = loadWorkspace();
2109
- if (options._daemon) {
2110
- await runDaemon(rootDir, config);
2111
- return;
2112
- }
2113
2585
  if (options.stop) {
2114
2586
  const pid = readPid(rootDir);
2115
2587
  if (!pid || !isDaemonRunning(rootDir)) {
@@ -2123,61 +2595,56 @@ Daemon stopped (pid ${pid}).
2123
2595
  `);
2124
2596
  return;
2125
2597
  }
2598
+ const silent = options.silent ?? config.watch?.silent ?? false;
2599
+ const ui = createUI(silent);
2126
2600
  if (options.once) {
2127
- console.log("\nRunning all observers once...\n");
2601
+ ui.gitStart();
2128
2602
  const gitResult = await runGitObserver(rootDir, config.watchState?.lastGitCommit ?? null);
2603
+ ui.gitDone(gitResult ? { entriesWritten: gitResult.entriesWritten, lastCommit: gitResult.lastCommit } : null);
2129
2604
  if (gitResult) {
2130
2605
  persistWatchState(rootDir, { lastGitCommit: gitResult.lastCommit });
2131
- console.log(` \u2713 git: ${gitResult.entriesWritten} entries`);
2132
2606
  }
2607
+ const lastScanAt = config.watchState?.lastScanAt ? new Date(config.watchState.lastScanAt) : null;
2608
+ ui.scanStart(lastScanAt);
2609
+ ui.scanFilesStart();
2133
2610
  const sections = await runScan(rootDir);
2611
+ ui.scanFilesDone();
2612
+ if (config.watch?.adapter) {
2613
+ const adapter = createAdapter(config);
2614
+ ui.inferStart();
2615
+ const rules = await inferArchitecturePatterns(rootDir, sections, adapter);
2616
+ ui.inferDone(rules.length > 0 ? rules.length : null);
2617
+ if (rules.length > 0) sections.architecture = rules;
2618
+ }
2134
2619
  writeConventions(rootDir, sections);
2135
2620
  persistWatchState(rootDir, { lastScanAt: (/* @__PURE__ */ new Date()).toISOString() });
2136
- console.log(" \u2713 scan: conventions.md updated");
2137
- console.log("\nDone.\n");
2621
+ ui.scanDone(sections.naming[0] ?? "");
2138
2622
  return;
2139
2623
  }
2140
2624
  if (isDaemonRunning(rootDir)) {
2141
2625
  const pid = readPid(rootDir);
2142
2626
  console.log(`
2143
- Daemon already running (pid ${pid}). Use --stop to stop it.
2627
+ Watch is already running (pid ${pid}). Use --stop to stop it.
2144
2628
  `);
2145
2629
  return;
2146
2630
  }
2147
2631
  injectWatchHook(rootDir, config);
2148
- const self = process.argv[1];
2149
- const child = (0, import_node_child_process4.spawn)(process.execPath, [self, "watch", "--_daemon"], {
2150
- detached: true,
2151
- stdio: "ignore",
2152
- cwd: rootDir
2153
- });
2154
- child.unref();
2155
- if (child.pid) {
2156
- writePid(rootDir, child.pid);
2157
- if (!options.silent) {
2158
- console.log(`
2159
- Daemon started (pid ${child.pid}).`);
2160
- console.log(`Run 'clawstrap watch --stop' to stop it.
2161
- `);
2162
- }
2163
- } else {
2164
- console.error("\nFailed to start daemon.\n");
2165
- process.exit(1);
2166
- }
2632
+ writePid(rootDir, process.pid);
2633
+ await runDaemon(rootDir, config, ui);
2167
2634
  }
2168
2635
  function persistWatchState(rootDir, updates) {
2169
- const configPath = import_node_path20.default.join(rootDir, ".clawstrap.json");
2636
+ const configPath = import_node_path22.default.join(rootDir, ".clawstrap.json");
2170
2637
  try {
2171
- const raw = JSON.parse(import_node_fs19.default.readFileSync(configPath, "utf-8"));
2638
+ const raw = JSON.parse(import_node_fs21.default.readFileSync(configPath, "utf-8"));
2172
2639
  raw.watchState = { ...raw.watchState ?? {}, ...updates };
2173
- import_node_fs19.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
2640
+ import_node_fs21.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
2174
2641
  } catch {
2175
2642
  }
2176
2643
  }
2177
2644
  function injectWatchHook(rootDir, config) {
2178
- const governanceFile = import_node_path20.default.join(rootDir, "CLAUDE.md");
2179
- if (!import_node_fs19.default.existsSync(governanceFile)) return;
2180
- const content = import_node_fs19.default.readFileSync(governanceFile, "utf-8");
2645
+ const governanceFile = import_node_path22.default.join(rootDir, "CLAUDE.md");
2646
+ if (!import_node_fs21.default.existsSync(governanceFile)) return;
2647
+ const content = import_node_fs21.default.readFileSync(governanceFile, "utf-8");
2181
2648
  if (content.includes("<!-- CLAWSTRAP:WATCH -->")) return;
2182
2649
  const _config = config;
2183
2650
  void _config;
@@ -2204,23 +2671,23 @@ function injectWatchHook(rootDir, config) {
2204
2671
 
2205
2672
  The watch daemon picks this up automatically and updates MEMORY.md and gotcha-log.md.
2206
2673
  `;
2207
- import_node_fs19.default.appendFileSync(governanceFile, hook, "utf-8");
2674
+ import_node_fs21.default.appendFileSync(governanceFile, hook, "utf-8");
2208
2675
  }
2209
2676
 
2210
2677
  // src/analyze.ts
2211
- var import_node_fs20 = __toESM(require("fs"), 1);
2212
- var import_node_path21 = __toESM(require("path"), 1);
2678
+ var import_node_fs22 = __toESM(require("fs"), 1);
2679
+ var import_node_path23 = __toESM(require("path"), 1);
2213
2680
  init_writers();
2214
2681
  async function analyze() {
2215
2682
  const { rootDir } = loadWorkspace();
2216
2683
  console.log("\nScanning codebase conventions...\n");
2217
2684
  const sections = await runScan(rootDir);
2218
2685
  writeConventions(rootDir, sections);
2219
- const configPath = import_node_path21.default.join(rootDir, ".clawstrap.json");
2686
+ const configPath = import_node_path23.default.join(rootDir, ".clawstrap.json");
2220
2687
  try {
2221
- const raw = JSON.parse(import_node_fs20.default.readFileSync(configPath, "utf-8"));
2688
+ const raw = JSON.parse(import_node_fs22.default.readFileSync(configPath, "utf-8"));
2222
2689
  raw["watchState"] = { ...raw["watchState"] ?? {}, lastScanAt: (/* @__PURE__ */ new Date()).toISOString() };
2223
- import_node_fs20.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
2690
+ import_node_fs22.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
2224
2691
  } catch {
2225
2692
  }
2226
2693
  console.log(" \u2713 .claude/rules/conventions.md updated\n");
@@ -2228,7 +2695,7 @@ async function analyze() {
2228
2695
 
2229
2696
  // src/index.ts
2230
2697
  var program = new import_commander.Command();
2231
- program.name("clawstrap").description("Scaffold a production-ready AI agent workspace").version("1.4.1");
2698
+ program.name("clawstrap").description("Scaffold a production-ready AI agent workspace").version("1.4.2");
2232
2699
  program.command("init").description("Create a new AI workspace in the current directory").argument("[directory]", "Target directory", ".").option("-y, --yes", "Use defaults, skip prompts").option("--sdd", "Enable Spec-Driven Development mode").action(async (directory, options) => {
2233
2700
  await init(directory, options);
2234
2701
  });
@@ -2256,7 +2723,7 @@ Unknown format: ${options.format}. Supported: paperclip
2256
2723
  }
2257
2724
  await exportPaperclip(options);
2258
2725
  });
2259
- program.command("watch").description("Start adaptive memory daemon for this workspace").option("--stop", "Stop the running daemon").option("--silent", "Run without output").option("--once", "Run all observers once and exit (no persistent daemon)").option("--_daemon", void 0).action(async (options) => {
2726
+ program.command("watch").description("Start adaptive memory daemon for this workspace").option("--stop", "Stop the running daemon").option("--silent", "Run without output").option("--once", "Run all observers once and exit (no persistent daemon)").action(async (options) => {
2260
2727
  await watch(options);
2261
2728
  });
2262
2729
  program.command("analyze").description("Run codebase convention scan immediately").action(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawstrap",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Scaffold a production-ready AI agent workspace in under 2 minutes",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,8 @@
46
46
  "dependencies": {
47
47
  "@inquirer/prompts": "^7.0.0",
48
48
  "commander": "^13.0.0",
49
+ "ora": "^8.2.0",
50
+ "picocolors": "^1.1.1",
49
51
  "zod": "^3.24.0"
50
52
  },
51
53
  "devDependencies": {