homeostat 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Homeostat turns review feedback that led to a code change into short, scored rul
6
6
 
7
7
  ```sh
8
8
  # In a repo for your team's knowledge base:
9
- npx --yes homeostat@0.1.2 init
9
+ npx --yes homeostat@0.1.4 init
10
10
 
11
11
  # In Claude Code:
12
12
  claude plugin marketplace add ysugarmen/homeostat
@@ -168,8 +168,13 @@ async function curateRun(opts, io) {
168
168
  for (const d of result.docs) {
169
169
  io.stdout(`~ ${d.path} (${d.sections.map((s) => `#${s.id}`).join(", ")})\n`);
170
170
  }
171
- for (const s of result.skipped)
172
- io.stdout(`- ${s.url}: ${s.reason}\n`);
171
+ for (const s of result.skipped) {
172
+ // With --show-prompts nothing was sent, so the empty answer is not the model's verdict.
173
+ const reason = opts.showPrompts && s.reason === "no lasting rule in the thread"
174
+ ? "prompt shown above; nothing sent"
175
+ : s.reason;
176
+ io.stdout(`- ${s.url}: ${reason}\n`);
177
+ }
173
178
  if (result.redacted) {
174
179
  io.stdout(`redacted ${result.redacted} secret(s) from review threads before drafting\n`);
175
180
  }
package/dist/src/daily.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { resolve } from "node:path";
2
- import { ClaudeDocPatcher, ClaudeDrafter, candidatesBranch, confidence, curate, decisionsPath, detectConflicts, explainConfidence, HEARTBEAT_PATH, isDocPath, lessonHash, logEvent, parseEntry, REVIEW_REMINDER_DAYS, readKbFiles, STATE_BRANCH, validate, } from "@homeostat/core";
2
+ import { ClaudeDocPatcher, ClaudeDrafter, candidatesBranch, confidence, curate, decisionsPath, explainConfidence, HEARTBEAT_PATH, isDocPath, lessonHash, logEvent, parseEntry, REVIEW_REMINDER_DAYS, readKbFiles, STATE_BRANCH, sameLesson, validate, } from "@homeostat/core";
3
3
  import { askerFor } from "./agents.js";
4
4
  import { plural } from "./format.js";
5
- import { commitTree, forcePush, git, showFile } from "./git.js";
5
+ import { commitTree, forcePush, git, mergeText, showFile } from "./git.js";
6
6
  import { CliError, providerFor } from "./provider.js";
7
7
  import { publish } from "./publish.js";
8
8
  const repoRef = (ref) => {
@@ -140,18 +140,57 @@ async function daily(opts, io) {
140
140
  kind: "doc",
141
141
  origin: "review",
142
142
  originator: d.sections[0]?.prAuthor ?? "",
143
+ ...(d.sections[0]?.url ? { url: d.sections[0].url } : {}),
143
144
  decision: "review",
144
145
  reason: "document edits go to the owning team",
145
146
  });
146
147
  }
147
148
  }
149
+ // A developer's pending edit and a review-drafted patch to the same document become one change:
150
+ // merged three ways against the base branch, or the developer's edit alone if they conflict.
151
+ for (const doc of lessons.filter((l) => l.kind === "doc" && l.origin === "review")) {
152
+ const mine = lessons.find((l) => l !== doc && l.kind === "doc" && l.path === doc.path && l.member === doc.member);
153
+ if (!mine)
154
+ continue;
155
+ // Each side stamps the document's `updated:` line with its own day; take the later one, or
156
+ // that single line would make every pair of edits from different days conflict.
157
+ const day = (t) => /^updated: (.*)$/m.exec(t)?.[1] ?? "";
158
+ const latest = [day(mine.text), day(doc.text)].sort().at(-1) ?? "";
159
+ const stamp = (t) => (latest ? t.replace(/^updated: .*$/m, `updated: ${latest}`) : t);
160
+ const merged = mergeText(onBase(doc.path) ?? "", stamp(mine.text), stamp(doc.text));
161
+ lessons.splice(lessons.indexOf(doc), 1);
162
+ if (merged === undefined) {
163
+ mine.reason = `document edits go to the owning team; a section patch from review (${doc.url ?? "a review thread"}) touched the same lines and was left out`;
164
+ }
165
+ else {
166
+ mine.text = merged;
167
+ mine.reason = "document edits go to the owning team; includes a section patch from review";
168
+ }
169
+ }
148
170
  // 2. Decide each lesson.
149
- const acceptedFms = kb.entries
171
+ const inForce = kb.entries
150
172
  .filter((e) => e.entry.frontmatter.status === "accepted")
151
- .map((e) => e.entry.frontmatter);
152
- const newFms = lessons.flatMap((l) => l.kind === "new" ? [parseEntry(l.text).entry?.frontmatter ?? []].flat() : []);
173
+ .map((e) => e.entry);
174
+ // New lessons that teach the same thing, from anyone in this run, are decided together.
175
+ const fresh = lessons.flatMap((l) => {
176
+ const entry = l.kind === "new" ? parseEntry(l.text).entry : undefined;
177
+ return entry ? [{ l, entry }] : [];
178
+ });
179
+ const groups = new Map();
180
+ for (const a of fresh) {
181
+ groups.set(a.l, fresh.filter((b) => b === a || sameLesson(a.entry, b.entry)).map((b) => b.l));
182
+ }
153
183
  for (const l of lessons)
154
- decide(l, files, config, acceptedFms, newFms);
184
+ decide(l, files, config, inForce, groups.get(l)?.length ?? 1);
185
+ for (const [l, group] of groups) {
186
+ if (group.length < 2)
187
+ continue;
188
+ const winner = group.reduce((best, x) => (x.confidence?.score ?? -1) > (best.confidence?.score ?? -1) ? x : best);
189
+ if (winner === l || l.decision === "declined")
190
+ continue;
191
+ l.decision = "declined";
192
+ l.reason = `the same lesson as ${winner.id} in this run, which carries it (${winner.decision})`;
193
+ }
155
194
  // 3–4. Per developer: one daily PR (merged), one review PR (assigned).
156
195
  const report = { at: now.toISOString(), members: [], settled, lessons: [] };
157
196
  const byMember = new Map();
@@ -302,7 +341,7 @@ function pendingLesson(member, path, text, hash, before) {
302
341
  reason: "",
303
342
  };
304
343
  }
305
- function decide(l, files, config, acceptedFms, newFms) {
344
+ function decide(l, files, config, inForce, occurrences) {
306
345
  if (l.kind === "doc") {
307
346
  l.decision = "review";
308
347
  l.reason ||= "document edits go to the owning team";
@@ -324,11 +363,9 @@ function decide(l, files, config, acceptedFms, newFms) {
324
363
  return;
325
364
  }
326
365
  const fm = entry.frontmatter;
327
- const others = acceptedFms.filter((x) => x.id !== fm.id);
328
- const dup = detectConflicts([...others, fm]).find((c) => c.kind === "possible-duplicate" && c.ids.includes(fm.id));
329
- const occurrences = 1 +
330
- detectConflicts(newFms).filter((c) => c.kind === "possible-duplicate" && c.ids.includes(fm.id))
331
- .length;
366
+ const dupOf = l.kind === "new"
367
+ ? inForce.find((x) => x.frontmatter.id !== fm.id && sameLesson(x, entry))?.frontmatter.id
368
+ : undefined;
332
369
  const c = confidence({
333
370
  entry,
334
371
  config,
@@ -336,14 +373,14 @@ function decide(l, files, config, acceptedFms, newFms) {
336
373
  occurrences,
337
374
  risks: issues.filter((i) => i.code.startsWith("risk.")).map((i) => i.message),
338
375
  errors: issues.filter((i) => !i.code.startsWith("risk.")).map((i) => i.message),
339
- ...(dup && l.kind === "new" ? { duplicateOf: dup.ids.find((i) => i !== fm.id) ?? "" } : {}),
376
+ ...(dupOf ? { duplicateOf: dupOf } : {}),
340
377
  ...(fm.conflictsWith?.length ? { conflictsWith: fm.conflictsWith } : {}),
341
378
  });
342
379
  l.confidence = c;
343
380
  l.decision = c.decision;
344
381
  l.reason = c.reason;
345
- if (dup && l.kind === "new")
346
- l.duplicateOf = dup.ids.find((i) => i !== fm.id);
382
+ if (dupOf)
383
+ l.duplicateOf = dupOf;
347
384
  }
348
385
  const change = (l, kind) => ({
349
386
  path: l.path,
package/dist/src/git.d.ts CHANGED
@@ -17,3 +17,8 @@ export declare function pushBranch(dir: string, branch: string, remote?: string)
17
17
  export declare function commitTree(dir: string, base: string | undefined, files: Record<string, string>, message: string): string;
18
18
  /** Force-pushes a commit to a branch on origin (for transport branches only). */
19
19
  export declare function forcePush(dir: string, commit: string, branch: string, remote?: string): void;
20
+ /**
21
+ * Three-way merge of two edits to one file (`git merge-file`), or undefined when they conflict.
22
+ * Section edits to different sections of a document merge cleanly.
23
+ */
24
+ export declare function mergeText(base: string, ours: string, theirs: string): string | undefined;
package/dist/src/git.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  /** Runs git in `dir`; undefined when it fails (not a repo, unknown ref, ...). */
@@ -119,3 +119,26 @@ export function commitTree(dir, base, files, message) {
119
119
  export function forcePush(dir, commit, branch, remote = "origin") {
120
120
  gitOrThrow(dir, ["push", "--quiet", "--force", remote, `${commit}:refs/heads/${branch}`]);
121
121
  }
122
+ /**
123
+ * Three-way merge of two edits to one file (`git merge-file`), or undefined when they conflict.
124
+ * Section edits to different sections of a document merge cleanly.
125
+ */
126
+ export function mergeText(base, ours, theirs) {
127
+ const dir = mkdtempSync(join(tmpdir(), "homeostat-merge-"));
128
+ try {
129
+ const [o, b, t] = ["ours", "base", "theirs"].map((n) => join(dir, n));
130
+ writeFileSync(o, ours);
131
+ writeFileSync(b, base);
132
+ writeFileSync(t, theirs);
133
+ return execFileSync("git", ["merge-file", "-p", o, b, t], {
134
+ encoding: "utf8",
135
+ stdio: ["ignore", "pipe", "ignore"],
136
+ });
137
+ }
138
+ catch {
139
+ return undefined; // a non-zero exit is the number of conflicts
140
+ }
141
+ finally {
142
+ rmSync(dir, { recursive: true, force: true });
143
+ }
144
+ }
package/dist/src/hook.js CHANGED
@@ -4,7 +4,7 @@ import { AGENT_ENV, CONFIG_FILE, CORE_AREA, docContext, logEvent, pruneEvents, r
4
4
  import { git } from "./git.js";
5
5
  import { memberIdFrom } from "./init.js";
6
6
  import { pendingFingerprint, readSyncState, syncDue, takeNotices } from "./sync.js";
7
- import { touchedPaths } from "./touched.js";
7
+ import { touchedPaths, uncommitted } from "./touched.js";
8
8
  /** Claude Code's limit on SessionStart `additionalContext`. */
9
9
  const MAX_CONTEXT_CHARS = 10_000;
10
10
  /** The KB checkout: the plugin's kb_dir option, then $HOMEOSTAT_DIR, then the session dir itself. */
@@ -58,10 +58,13 @@ async function sessionStart(input, io) {
58
58
  });
59
59
  }
60
60
  if (input.session_id) {
61
+ const previous = readState(kbRoot, input.session_id);
61
62
  writeState(kbRoot, input.session_id, {
62
- ...readState(kbRoot, input.session_id),
63
+ ...previous,
63
64
  injected: selection.items.map((i) => i.entry.frontmatter.id),
64
65
  view: viewKey(kbRoot),
66
+ // Kept from the first start: resume, clear and compact start the same session again.
67
+ dirtyAtStart: previous.dirtyAtStart ?? uncommitted(cwd),
65
68
  });
66
69
  }
67
70
  // Catch up with the daily run in the background; this session starts with what is local now.
@@ -141,7 +144,11 @@ async function stop(input, io) {
141
144
  const state = readState(kbRoot, input.session_id);
142
145
  if (state.asked)
143
146
  return;
144
- const edits = countEdits(input.transcript_path);
147
+ // Edit tool calls, or files changed since the session started, whichever is more: agents also
148
+ // edit through the shell (sed, scripts), which no tool call shows.
149
+ const before = new Set(state.dirtyAtStart ?? []);
150
+ const changed = state.dirtyAtStart ? uncommitted(cwd).filter((p) => !before.has(p)).length : 0;
151
+ const edits = Math.max(countEdits(input.transcript_path), changed);
145
152
  if (edits < config.agents.handoff.minEdits && !state.compacted)
146
153
  return;
147
154
  const member = memberIdFrom(git(cwd, "config", "user.name"));
package/dist/src/init.js CHANGED
@@ -36,8 +36,10 @@ ${handleLine}
36
36
  # payments: ["services/payments/**"]
37
37
  areas: {}
38
38
 
39
- scoring:
40
- autoMergeThreshold: 3
39
+ # The daily run decides each lesson by a 0-100 confidence: it merges at 70+ (80+ for core),
40
+ # declines below 35, and sends the rest to a reviewer. Tune it under scoring.confidence.
41
+ # scoring:
42
+ # confidence: { accept: 70, coreAccept: 80, decline: 35 }
41
43
 
42
44
  distribution:
43
45
  tokenBudget: 2000
@@ -1,3 +1,5 @@
1
+ /** Paths from `git status --porcelain`, using the new name for renames. */
2
+ export declare function uncommitted(dir: string): string[];
1
3
  /**
2
4
  * Repo-relative paths a session in `dir` is likely to touch: uncommitted changes, the current
3
5
  * branch's changes against its base, then files from recent commits. Best first, de-duplicated.
@@ -3,7 +3,7 @@ const MAX_PATHS = 500;
3
3
  const RECENT_COMMITS = 10;
4
4
  const lines = (text) => (text ?? "").split("\n").filter(Boolean);
5
5
  /** Paths from `git status --porcelain`, using the new name for renames. */
6
- function uncommitted(dir) {
6
+ export function uncommitted(dir) {
7
7
  return lines(git(dir, "status", "--porcelain=v1", "--untracked-files=all")).map((line) => {
8
8
  const path = line.slice(3);
9
9
  const arrow = path.indexOf(" -> ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homeostat",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Turn your team's code-review lessons into rules every coding agent follows: Claude Code, MCP, Cursor, Copilot, AGENTS.md.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -39,9 +39,9 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "commander": "^15.0.0",
42
- "@homeostat/mcp": "0.1.2",
43
- "@homeostat/core": "0.1.2",
44
- "@homeostat/dashboard": "0.1.2"
42
+ "@homeostat/core": "0.1.4",
43
+ "@homeostat/mcp": "0.1.4",
44
+ "@homeostat/dashboard": "0.1.4"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public",