clawstrap 1.4.0 → 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 +556 -82
  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
  });
@@ -751,7 +779,8 @@ var WORKLOAD_LABELS2 = {
751
779
  custom: "General Purpose"
752
780
  };
753
781
  async function init(directory, options) {
754
- const targetDir = import_node_path3.default.resolve(directory);
782
+ const answers = options.yes ? getDefaults(directory) : await runPrompts();
783
+ const targetDir = directory === "." ? import_node_path3.default.resolve(answers.workspaceName) : import_node_path3.default.resolve(directory);
755
784
  if (import_node_fs2.default.existsSync(targetDir)) {
756
785
  const hasClawstrap = import_node_fs2.default.existsSync(
757
786
  import_node_path3.default.join(targetDir, ".clawstrap.json")
@@ -775,9 +804,8 @@ async function init(directory, options) {
775
804
  } else {
776
805
  import_node_fs2.default.mkdirSync(targetDir, { recursive: true });
777
806
  }
778
- const answers = options.yes ? getDefaults(directory) : await runPrompts();
779
807
  const config = ClawstrapConfigSchema.parse({
780
- version: "1.0.0",
808
+ version: "1.4.2",
781
809
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
782
810
  workspaceName: answers.workspaceName,
783
811
  targetDirectory: directory,
@@ -806,9 +834,16 @@ async function init(directory, options) {
806
834
  for (const dir of result.dirsCreated) {
807
835
  console.log(` \u2713 ${dir}/`);
808
836
  }
809
- console.log(`
837
+ const folderName = import_node_path3.default.basename(targetDir);
838
+ if (directory === ".") {
839
+ console.log(`
840
+ Done. Run \`cd ${folderName}\` and open GETTING_STARTED.md to begin.
841
+ `);
842
+ } else {
843
+ console.log(`
810
844
  Done. Open GETTING_STARTED.md to begin.
811
845
  `);
846
+ }
812
847
  }
813
848
 
814
849
  // src/add-agent.ts
@@ -1492,9 +1527,8 @@ Exported to ${import_node_path14.default.relative(process.cwd(), outDir) || outD
1492
1527
  }
1493
1528
 
1494
1529
  // src/watch.ts
1495
- var import_node_path20 = __toESM(require("path"), 1);
1496
- var import_node_child_process4 = require("child_process");
1497
- 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);
1498
1532
 
1499
1533
  // src/watch/git.ts
1500
1534
  var import_node_child_process = require("child_process");
@@ -1837,17 +1871,220 @@ async function runScan(rootDir) {
1837
1871
  init_writers();
1838
1872
 
1839
1873
  // src/watch/daemon.ts
1840
- var import_node_fs18 = __toESM(require("fs"), 1);
1841
- 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);
1842
1876
  init_writers();
1843
1877
 
1844
- // src/watch/transcripts.ts
1878
+ // src/watch/synthesize.ts
1845
1879
  var import_node_fs17 = __toESM(require("fs"), 1);
1846
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);
1847
2084
  async function processTranscript(filePath, adapter) {
1848
2085
  let content;
1849
2086
  try {
1850
- content = import_node_fs17.default.readFileSync(filePath, "utf-8");
2087
+ content = import_node_fs19.default.readFileSync(filePath, "utf-8");
1851
2088
  } catch {
1852
2089
  return null;
1853
2090
  }
@@ -1890,13 +2127,13 @@ Each item must be a concise one-sentence string. Arrays may be empty.`;
1890
2127
  }
1891
2128
  }
1892
2129
  function watchTranscriptDir(rootDir, onNewFile) {
1893
- const sessionsDir = import_node_path18.default.join(rootDir, "tmp", "sessions");
1894
- import_node_fs17.default.mkdirSync(sessionsDir, { recursive: true });
1895
- 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) => {
1896
2133
  if (event !== "rename" || !filename) return;
1897
2134
  if (!filename.endsWith(".md")) return;
1898
- const filePath = import_node_path18.default.join(sessionsDir, filename);
1899
- 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;
1900
2137
  onNewFile(filePath).catch(() => {
1901
2138
  });
1902
2139
  });
@@ -1906,12 +2143,12 @@ function watchTranscriptDir(rootDir, onNewFile) {
1906
2143
  }
1907
2144
 
1908
2145
  // src/watch/adapters/claude-local.ts
1909
- var import_node_child_process2 = require("child_process");
2146
+ var import_node_child_process3 = require("child_process");
1910
2147
  var ClaudeLocalAdapter = class {
1911
2148
  async complete(prompt) {
1912
2149
  const escaped = prompt.replace(/'/g, "'\\''");
1913
2150
  try {
1914
- const result = (0, import_node_child_process2.execSync)(`claude -p '${escaped}'`, {
2151
+ const result = (0, import_node_child_process3.execSync)(`claude -p '${escaped}'`, {
1915
2152
  encoding: "utf-8",
1916
2153
  timeout: 6e4,
1917
2154
  stdio: ["pipe", "pipe", "pipe"]
@@ -1992,12 +2229,12 @@ var OllamaAdapter = class {
1992
2229
  };
1993
2230
 
1994
2231
  // src/watch/adapters/codex-local.ts
1995
- var import_node_child_process3 = require("child_process");
2232
+ var import_node_child_process4 = require("child_process");
1996
2233
  var CodexLocalAdapter = class {
1997
2234
  async complete(prompt) {
1998
2235
  const escaped = prompt.replace(/'/g, "'\\''");
1999
2236
  try {
2000
- const result = (0, import_node_child_process3.execSync)(`codex '${escaped}'`, {
2237
+ const result = (0, import_node_child_process4.execSync)(`codex '${escaped}'`, {
2001
2238
  encoding: "utf-8",
2002
2239
  timeout: 6e4,
2003
2240
  stdio: ["pipe", "pipe", "pipe"]
@@ -2031,78 +2268,320 @@ function createAdapter(config) {
2031
2268
  }
2032
2269
 
2033
2270
  // src/watch/daemon.ts
2034
- async function runDaemon(rootDir, config) {
2035
- const silent = config.watch?.silent ?? false;
2036
- const log = silent ? () => {
2037
- } : (msg) => process.stdout.write(msg + "\n");
2271
+ async function runDaemon(rootDir, config, ui) {
2038
2272
  const cleanup = [];
2039
2273
  const shutdown = () => {
2040
2274
  cleanup.forEach((fn) => fn());
2275
+ ui.clear();
2041
2276
  clearPid(rootDir);
2042
2277
  process.exit(0);
2043
2278
  };
2044
2279
  process.on("SIGTERM", shutdown);
2045
2280
  process.on("SIGINT", shutdown);
2046
- log("[clawstrap watch] daemon started");
2281
+ ui.daemonStarted();
2047
2282
  const sinceCommit = config.watchState?.lastGitCommit ?? null;
2283
+ ui.gitStart();
2048
2284
  const gitResult = await runGitObserver(rootDir, sinceCommit);
2285
+ ui.gitDone(gitResult ? { entriesWritten: gitResult.entriesWritten, lastCommit: gitResult.lastCommit } : null);
2286
+ let lastGitCommit = gitResult?.lastCommit ?? sinceCommit;
2049
2287
  if (gitResult) {
2050
2288
  updateWatchState(rootDir, { lastGitCommit: gitResult.lastCommit });
2051
- log(`[clawstrap watch] git: ${gitResult.entriesWritten} entries written`);
2052
2289
  }
2053
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
+ };
2054
2302
  const stopTranscripts = watchTranscriptDir(rootDir, async (filePath) => {
2055
- log(`[clawstrap watch] transcript: processing ${import_node_path19.default.basename(filePath)}`);
2303
+ ui.transcriptStart(import_node_path21.default.basename(filePath));
2304
+ ui.llmCallStart();
2056
2305
  const result = await processTranscript(filePath, adapter);
2306
+ ui.llmCallDone(result ? { decisions: result.decisions.length, corrections: result.corrections.length, openThreads: result.openThreads.length } : null);
2057
2307
  if (result) {
2058
- const { appendToMemory: appendToMemory2, appendToGotchaLog: appendToGotchaLog2, appendToFutureConsiderations: appendToFutureConsiderations2 } = await Promise.resolve().then(() => (init_writers(), writers_exports));
2059
- 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");
2060
2311
  if (result.corrections.length) appendToGotchaLog2(rootDir, result.corrections);
2061
2312
  if (result.deferredIdeas.length) appendToFutureConsiderations2(rootDir, result.deferredIdeas);
2062
- updateWatchState(rootDir, { lastTranscriptAt: (/* @__PURE__ */ new Date()).toISOString() });
2063
- log(
2064
- `[clawstrap watch] transcript: decisions=${result.decisions.length} corrections=${result.corrections.length}`
2065
- );
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();
2066
2321
  }
2067
2322
  });
2068
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));
2069
2343
  const intervalDays = config.watch?.scan?.intervalDays ?? 7;
2070
2344
  const lastScan = config.watchState?.lastScanAt ? new Date(config.watchState.lastScanAt) : null;
2071
2345
  const msSinceLastScan = lastScan ? Date.now() - lastScan.getTime() : Infinity;
2072
2346
  const scanIntervalMs = intervalDays * 24 * 60 * 60 * 1e3;
2073
2347
  const doScan = async () => {
2074
- log("[clawstrap watch] scan: running convention scan...");
2348
+ ui.scanStart(lastScan);
2349
+ ui.scanFilesStart();
2075
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
+ }
2076
2358
  writeConventions(rootDir, sections);
2077
2359
  updateWatchState(rootDir, { lastScanAt: (/* @__PURE__ */ new Date()).toISOString() });
2078
- log("[clawstrap watch] scan: conventions.md updated");
2360
+ ui.scanDone(sections.naming[0] ?? "");
2079
2361
  };
2080
2362
  if (msSinceLastScan >= scanIntervalMs) {
2081
2363
  await doScan();
2082
2364
  }
2083
2365
  const scanTimer = setInterval(doScan, scanIntervalMs);
2084
2366
  cleanup.push(() => clearInterval(scanTimer));
2085
- log("[clawstrap watch] watching for changes...");
2367
+ ui.showIdle(import_node_path21.default.join(rootDir, "tmp", "sessions"));
2086
2368
  await new Promise(() => {
2087
2369
  });
2088
2370
  }
2089
2371
  function updateWatchState(rootDir, updates) {
2090
- const configPath = import_node_path19.default.join(rootDir, ".clawstrap.json");
2372
+ const configPath = import_node_path21.default.join(rootDir, ".clawstrap.json");
2091
2373
  try {
2092
- 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"));
2093
2375
  raw["watchState"] = { ...raw["watchState"] ?? {}, ...updates };
2094
- 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");
2095
2377
  } catch {
2096
2378
  }
2097
2379
  }
2098
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
+
2099
2582
  // src/watch.ts
2100
2583
  async function watch(options) {
2101
2584
  const { config, rootDir } = loadWorkspace();
2102
- if (options._daemon) {
2103
- await runDaemon(rootDir, config);
2104
- return;
2105
- }
2106
2585
  if (options.stop) {
2107
2586
  const pid = readPid(rootDir);
2108
2587
  if (!pid || !isDaemonRunning(rootDir)) {
@@ -2116,61 +2595,56 @@ Daemon stopped (pid ${pid}).
2116
2595
  `);
2117
2596
  return;
2118
2597
  }
2598
+ const silent = options.silent ?? config.watch?.silent ?? false;
2599
+ const ui = createUI(silent);
2119
2600
  if (options.once) {
2120
- console.log("\nRunning all observers once...\n");
2601
+ ui.gitStart();
2121
2602
  const gitResult = await runGitObserver(rootDir, config.watchState?.lastGitCommit ?? null);
2603
+ ui.gitDone(gitResult ? { entriesWritten: gitResult.entriesWritten, lastCommit: gitResult.lastCommit } : null);
2122
2604
  if (gitResult) {
2123
2605
  persistWatchState(rootDir, { lastGitCommit: gitResult.lastCommit });
2124
- console.log(` \u2713 git: ${gitResult.entriesWritten} entries`);
2125
2606
  }
2607
+ const lastScanAt = config.watchState?.lastScanAt ? new Date(config.watchState.lastScanAt) : null;
2608
+ ui.scanStart(lastScanAt);
2609
+ ui.scanFilesStart();
2126
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
+ }
2127
2619
  writeConventions(rootDir, sections);
2128
2620
  persistWatchState(rootDir, { lastScanAt: (/* @__PURE__ */ new Date()).toISOString() });
2129
- console.log(" \u2713 scan: conventions.md updated");
2130
- console.log("\nDone.\n");
2621
+ ui.scanDone(sections.naming[0] ?? "");
2131
2622
  return;
2132
2623
  }
2133
2624
  if (isDaemonRunning(rootDir)) {
2134
2625
  const pid = readPid(rootDir);
2135
2626
  console.log(`
2136
- Daemon already running (pid ${pid}). Use --stop to stop it.
2627
+ Watch is already running (pid ${pid}). Use --stop to stop it.
2137
2628
  `);
2138
2629
  return;
2139
2630
  }
2140
2631
  injectWatchHook(rootDir, config);
2141
- const self = process.argv[1];
2142
- const child = (0, import_node_child_process4.spawn)(process.execPath, [self, "watch", "--_daemon"], {
2143
- detached: true,
2144
- stdio: "ignore",
2145
- cwd: rootDir
2146
- });
2147
- child.unref();
2148
- if (child.pid) {
2149
- writePid(rootDir, child.pid);
2150
- if (!options.silent) {
2151
- console.log(`
2152
- Daemon started (pid ${child.pid}).`);
2153
- console.log(`Run 'clawstrap watch --stop' to stop it.
2154
- `);
2155
- }
2156
- } else {
2157
- console.error("\nFailed to start daemon.\n");
2158
- process.exit(1);
2159
- }
2632
+ writePid(rootDir, process.pid);
2633
+ await runDaemon(rootDir, config, ui);
2160
2634
  }
2161
2635
  function persistWatchState(rootDir, updates) {
2162
- const configPath = import_node_path20.default.join(rootDir, ".clawstrap.json");
2636
+ const configPath = import_node_path22.default.join(rootDir, ".clawstrap.json");
2163
2637
  try {
2164
- 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"));
2165
2639
  raw.watchState = { ...raw.watchState ?? {}, ...updates };
2166
- 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");
2167
2641
  } catch {
2168
2642
  }
2169
2643
  }
2170
2644
  function injectWatchHook(rootDir, config) {
2171
- const governanceFile = import_node_path20.default.join(rootDir, "CLAUDE.md");
2172
- if (!import_node_fs19.default.existsSync(governanceFile)) return;
2173
- 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");
2174
2648
  if (content.includes("<!-- CLAWSTRAP:WATCH -->")) return;
2175
2649
  const _config = config;
2176
2650
  void _config;
@@ -2197,23 +2671,23 @@ function injectWatchHook(rootDir, config) {
2197
2671
 
2198
2672
  The watch daemon picks this up automatically and updates MEMORY.md and gotcha-log.md.
2199
2673
  `;
2200
- import_node_fs19.default.appendFileSync(governanceFile, hook, "utf-8");
2674
+ import_node_fs21.default.appendFileSync(governanceFile, hook, "utf-8");
2201
2675
  }
2202
2676
 
2203
2677
  // src/analyze.ts
2204
- var import_node_fs20 = __toESM(require("fs"), 1);
2205
- 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);
2206
2680
  init_writers();
2207
2681
  async function analyze() {
2208
2682
  const { rootDir } = loadWorkspace();
2209
2683
  console.log("\nScanning codebase conventions...\n");
2210
2684
  const sections = await runScan(rootDir);
2211
2685
  writeConventions(rootDir, sections);
2212
- const configPath = import_node_path21.default.join(rootDir, ".clawstrap.json");
2686
+ const configPath = import_node_path23.default.join(rootDir, ".clawstrap.json");
2213
2687
  try {
2214
- 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"));
2215
2689
  raw["watchState"] = { ...raw["watchState"] ?? {}, lastScanAt: (/* @__PURE__ */ new Date()).toISOString() };
2216
- 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");
2217
2691
  } catch {
2218
2692
  }
2219
2693
  console.log(" \u2713 .claude/rules/conventions.md updated\n");
@@ -2221,7 +2695,7 @@ async function analyze() {
2221
2695
 
2222
2696
  // src/index.ts
2223
2697
  var program = new import_commander.Command();
2224
- program.name("clawstrap").description("Scaffold a production-ready AI agent workspace").version("1.4.0");
2698
+ program.name("clawstrap").description("Scaffold a production-ready AI agent workspace").version("1.4.2");
2225
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) => {
2226
2700
  await init(directory, options);
2227
2701
  });
@@ -2249,7 +2723,7 @@ Unknown format: ${options.format}. Supported: paperclip
2249
2723
  }
2250
2724
  await exportPaperclip(options);
2251
2725
  });
2252
- 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) => {
2253
2727
  await watch(options);
2254
2728
  });
2255
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.0",
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": {