homeostat 0.1.1 → 0.1.3
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 +1 -1
- package/dist/src/curate.js +7 -2
- package/dist/src/daily.js +47 -15
- package/dist/src/doctor.js +5 -3
- package/dist/src/git.d.ts +5 -0
- package/dist/src/git.js +24 -1
- package/dist/src/hook.js +10 -3
- package/dist/src/init.js +4 -2
- package/dist/src/learn.js +10 -2
- package/dist/src/query.js +15 -4
- package/dist/src/sync.js +11 -0
- package/dist/src/touched.d.ts +2 -0
- package/dist/src/touched.js +1 -1
- package/dist/src/validate.js +14 -4
- package/package.json +4 -4
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.
|
|
9
|
+
npx --yes homeostat@0.1.3 init
|
|
10
10
|
|
|
11
11
|
# In Claude Code:
|
|
12
12
|
claude plugin marketplace add ysugarmen/homeostat
|
package/dist/src/curate.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,52 @@ 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
|
+
const merged = mergeText(onBase(doc.path) ?? "", mine.text, doc.text);
|
|
156
|
+
lessons.splice(lessons.indexOf(doc), 1);
|
|
157
|
+
if (merged === undefined) {
|
|
158
|
+
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`;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
mine.text = merged;
|
|
162
|
+
mine.reason = "document edits go to the owning team; includes a section patch from review";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
148
165
|
// 2. Decide each lesson.
|
|
149
|
-
const
|
|
166
|
+
const inForce = kb.entries
|
|
150
167
|
.filter((e) => e.entry.frontmatter.status === "accepted")
|
|
151
|
-
.map((e) => e.entry
|
|
152
|
-
|
|
168
|
+
.map((e) => e.entry);
|
|
169
|
+
// New lessons that teach the same thing, from anyone in this run, are decided together.
|
|
170
|
+
const fresh = lessons.flatMap((l) => {
|
|
171
|
+
const entry = l.kind === "new" ? parseEntry(l.text).entry : undefined;
|
|
172
|
+
return entry ? [{ l, entry }] : [];
|
|
173
|
+
});
|
|
174
|
+
const groups = new Map();
|
|
175
|
+
for (const a of fresh) {
|
|
176
|
+
groups.set(a.l, fresh.filter((b) => b === a || sameLesson(a.entry, b.entry)).map((b) => b.l));
|
|
177
|
+
}
|
|
153
178
|
for (const l of lessons)
|
|
154
|
-
decide(l, files, config,
|
|
179
|
+
decide(l, files, config, inForce, groups.get(l)?.length ?? 1);
|
|
180
|
+
for (const [l, group] of groups) {
|
|
181
|
+
if (group.length < 2)
|
|
182
|
+
continue;
|
|
183
|
+
const winner = group.reduce((best, x) => (x.confidence?.score ?? -1) > (best.confidence?.score ?? -1) ? x : best);
|
|
184
|
+
if (winner === l || l.decision === "declined")
|
|
185
|
+
continue;
|
|
186
|
+
l.decision = "declined";
|
|
187
|
+
l.reason = `the same lesson as ${winner.id} in this run, which carries it (${winner.decision})`;
|
|
188
|
+
}
|
|
155
189
|
// 3–4. Per developer: one daily PR (merged), one review PR (assigned).
|
|
156
190
|
const report = { at: now.toISOString(), members: [], settled, lessons: [] };
|
|
157
191
|
const byMember = new Map();
|
|
@@ -302,7 +336,7 @@ function pendingLesson(member, path, text, hash, before) {
|
|
|
302
336
|
reason: "",
|
|
303
337
|
};
|
|
304
338
|
}
|
|
305
|
-
function decide(l, files, config,
|
|
339
|
+
function decide(l, files, config, inForce, occurrences) {
|
|
306
340
|
if (l.kind === "doc") {
|
|
307
341
|
l.decision = "review";
|
|
308
342
|
l.reason ||= "document edits go to the owning team";
|
|
@@ -324,11 +358,9 @@ function decide(l, files, config, acceptedFms, newFms) {
|
|
|
324
358
|
return;
|
|
325
359
|
}
|
|
326
360
|
const fm = entry.frontmatter;
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
detectConflicts(newFms).filter((c) => c.kind === "possible-duplicate" && c.ids.includes(fm.id))
|
|
331
|
-
.length;
|
|
361
|
+
const dupOf = l.kind === "new"
|
|
362
|
+
? inForce.find((x) => x.frontmatter.id !== fm.id && sameLesson(x, entry))?.frontmatter.id
|
|
363
|
+
: undefined;
|
|
332
364
|
const c = confidence({
|
|
333
365
|
entry,
|
|
334
366
|
config,
|
|
@@ -336,14 +368,14 @@ function decide(l, files, config, acceptedFms, newFms) {
|
|
|
336
368
|
occurrences,
|
|
337
369
|
risks: issues.filter((i) => i.code.startsWith("risk.")).map((i) => i.message),
|
|
338
370
|
errors: issues.filter((i) => !i.code.startsWith("risk.")).map((i) => i.message),
|
|
339
|
-
...(
|
|
371
|
+
...(dupOf ? { duplicateOf: dupOf } : {}),
|
|
340
372
|
...(fm.conflictsWith?.length ? { conflictsWith: fm.conflictsWith } : {}),
|
|
341
373
|
});
|
|
342
374
|
l.confidence = c;
|
|
343
375
|
l.decision = c.decision;
|
|
344
376
|
l.reason = c.reason;
|
|
345
|
-
if (
|
|
346
|
-
l.duplicateOf =
|
|
377
|
+
if (dupOf)
|
|
378
|
+
l.duplicateOf = dupOf;
|
|
347
379
|
}
|
|
348
380
|
const change = (l, kind) => ({
|
|
349
381
|
path: l.path,
|
package/dist/src/doctor.js
CHANGED
|
@@ -108,7 +108,9 @@ function pinnedVersions(root) {
|
|
|
108
108
|
const checks = [];
|
|
109
109
|
for (const path of ci) {
|
|
110
110
|
const text = readFileSync(join(root, path), "utf8");
|
|
111
|
-
|
|
111
|
+
// Only command lines (npx, dlx, a run: step or a script list item): a job's `name:` or a
|
|
112
|
+
// `git config user.name "homeostat deep-dive"` line reads the same but runs nothing.
|
|
113
|
+
for (const m of text.matchAll(/(?:\bnpx(?:\s+(?:--yes|-y))?|\bdlx|\brun:|^\s*-)\s+homeostat(@[^\s"']*)?\s+(validate|daily|curate|deep-dive|digest|export)\b/gm)) {
|
|
112
114
|
const version = m[1]?.slice(1);
|
|
113
115
|
if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
|
|
114
116
|
checks.push(fail(`${path} runs homeostat${m[1] ?? ""}: pin an exact version (homeostat@${VERSION}) so a new release cannot run unreviewed`));
|
|
@@ -147,8 +149,8 @@ export async function securityReport(root, config, io) {
|
|
|
147
149
|
const changes = [
|
|
148
150
|
...access,
|
|
149
151
|
am.enabled
|
|
150
|
-
? info(`
|
|
151
|
-
: ok("auto-merge is off: every
|
|
152
|
+
? info(`the daily run merges lessons at confidence ${config.scoring.confidence.accept}+ (${config.scoring.confidence.coreAccept}+ for core), declines below ${config.scoring.confidence.decline}, and asks a reviewer in between; risky content, conflicts and document edits always go to a reviewer; ${am.areas.length ? `areas ${am.areas.join(", ")}` : "all areas"}${am.excludeAreas.length ? ` except ${am.excludeAreas.join(", ")}` : ""}`)
|
|
153
|
+
: ok("auto-merge is off: the daily run sends every lesson to a reviewer"),
|
|
152
154
|
existsSync(join(root, ".github/CODEOWNERS")) || existsSync(join(root, "CODEOWNERS"))
|
|
153
155
|
? ok("CODEOWNERS routes each area to its reviewers")
|
|
154
156
|
: warn("no CODEOWNERS file: run homeostat codeowners"),
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
package/dist/src/learn.js
CHANGED
|
@@ -134,7 +134,9 @@ async function learn(url, opts, io) {
|
|
|
134
134
|
};
|
|
135
135
|
frontmatter.score = score(frontmatter, { config }).score;
|
|
136
136
|
const text = formatEntry(frontmatter, body);
|
|
137
|
-
const
|
|
137
|
+
const found = validate({ ...files, [path]: text }).issues.filter((i) => i.file === path);
|
|
138
|
+
const issues = found.filter((i) => i.severity === "error");
|
|
139
|
+
const warnings = found.filter((i) => i.severity !== "error");
|
|
138
140
|
if (issues.length > 0) {
|
|
139
141
|
io.stdout(`${formatIssues(issues, io).text}nothing written\n`);
|
|
140
142
|
return 1;
|
|
@@ -166,6 +168,8 @@ async function learn(url, opts, io) {
|
|
|
166
168
|
writePendingFile(root, path, text);
|
|
167
169
|
sendPending(root, io);
|
|
168
170
|
io.stdout(`saved ${id} ${summary}: it applies to your sessions now, and tomorrow's daily run decides it for the team\n`);
|
|
171
|
+
if (warnings.length)
|
|
172
|
+
io.stdout(formatIssues(warnings, io).text);
|
|
169
173
|
return 0;
|
|
170
174
|
}
|
|
171
175
|
/**
|
|
@@ -203,7 +207,9 @@ function learnDoc(root, files, config, opts, io) {
|
|
|
203
207
|
throw new LearnError(e.message);
|
|
204
208
|
throw e;
|
|
205
209
|
}
|
|
206
|
-
const
|
|
210
|
+
const found = validate({ ...files, [path]: text }).issues.filter((i) => i.file === path);
|
|
211
|
+
const issues = found.filter((i) => i.severity === "error");
|
|
212
|
+
const warnings = found.filter((i) => i.severity !== "error");
|
|
207
213
|
if (issues.length > 0) {
|
|
208
214
|
io.stdout(`${formatIssues(issues, io).text}nothing written\n`);
|
|
209
215
|
return 1;
|
|
@@ -226,6 +232,8 @@ function learnDoc(root, files, config, opts, io) {
|
|
|
226
232
|
writePendingFile(root, path, text);
|
|
227
233
|
sendPending(root, io);
|
|
228
234
|
io.stdout(`${what}: it applies to your sessions now, and tomorrow's daily run decides it for the team\n`);
|
|
235
|
+
if (warnings.length)
|
|
236
|
+
io.stdout(formatIssues(warnings, io).text);
|
|
229
237
|
return 0;
|
|
230
238
|
}
|
|
231
239
|
/** Sends pending lessons to the daily run in the background, when the KB has an origin. */
|
package/dist/src/query.js
CHANGED
|
@@ -18,6 +18,7 @@ function load(opts, io, view = {}) {
|
|
|
18
18
|
const pendingIds = new Set(kb.entries.filter((e) => pending.has(e.path)).map((e) => e.entry.frontmatter.id));
|
|
19
19
|
return {
|
|
20
20
|
root,
|
|
21
|
+
issues: kb.issues,
|
|
21
22
|
config: kb.config,
|
|
22
23
|
entries: kb.entries.map((e) => e.entry),
|
|
23
24
|
docs: kb.docs,
|
|
@@ -75,9 +76,11 @@ export function runExplain(id, opts, io) {
|
|
|
75
76
|
}
|
|
76
77
|
io.stdout(opts.json
|
|
77
78
|
? `${JSON.stringify({ ...entry.frontmatter, sections: entry.sections }, null, 2)}\n`
|
|
78
|
-
: `${explainEntry(entry, config)}\n${pendingIds.has(id)
|
|
79
|
-
? "
|
|
80
|
-
:
|
|
79
|
+
: `${explainEntry(entry, config)}\n${!pendingIds.has(id)
|
|
80
|
+
? ""
|
|
81
|
+
: entry.frontmatter.flags?.length
|
|
82
|
+
? "Pending in this clone, flagged: it is out of your sessions until the next daily run takes the flag to the team.\n"
|
|
83
|
+
: "Pending in this clone: it applies to your sessions now; the next daily run decides it for the team.\n"}`);
|
|
81
84
|
return 0;
|
|
82
85
|
});
|
|
83
86
|
}
|
|
@@ -105,7 +108,7 @@ export function runList(opts, io) {
|
|
|
105
108
|
}
|
|
106
109
|
export function runStatus(opts, io) {
|
|
107
110
|
return guard(io, () => {
|
|
108
|
-
const { root, config, entries, docs } = load(opts, io);
|
|
111
|
+
const { root, config, entries, docs, issues } = load(opts, io);
|
|
109
112
|
const days = opts.days ? Number(opts.days) : 7;
|
|
110
113
|
const now = io.now?.() ?? new Date();
|
|
111
114
|
// The report counts its own window; document decay looks back 90 days.
|
|
@@ -113,6 +116,14 @@ export function runStatus(opts, io) {
|
|
|
113
116
|
since: new Date(now.getTime() - Math.max(days, 90) * 86_400_000).toISOString(),
|
|
114
117
|
});
|
|
115
118
|
io.stdout(`${kbReport(entries, config, { days, now, events, docs })}\n`);
|
|
119
|
+
// Includes pending lessons, which CI's validate never sees: say so here, before the daily run.
|
|
120
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
121
|
+
if (errors.length) {
|
|
122
|
+
io.stdout(`- Problems: ${errors.length}, including pending lessons\n${errors
|
|
123
|
+
.slice(0, 5)
|
|
124
|
+
.map((i) => ` ${i.file ?? ""}: ${i.message}\n`)
|
|
125
|
+
.join("")}`);
|
|
126
|
+
}
|
|
116
127
|
return 0;
|
|
117
128
|
});
|
|
118
129
|
}
|
package/dist/src/sync.js
CHANGED
|
@@ -28,7 +28,9 @@ export function pendingFingerprint(root) {
|
|
|
28
28
|
const idOf = (path) => path.split("/").at(-1)?.replace(/\.md$/, "") ?? path;
|
|
29
29
|
export async function runSync(opts, io) {
|
|
30
30
|
const root = resolve(opts.dir ?? io.env?.HOMEOSTAT_DIR ?? ".");
|
|
31
|
+
let said = false;
|
|
31
32
|
const say = (s) => {
|
|
33
|
+
said = true;
|
|
32
34
|
if (!opts.quiet)
|
|
33
35
|
io.stdout(s);
|
|
34
36
|
};
|
|
@@ -124,6 +126,15 @@ export async function runSync(opts, io) {
|
|
|
124
126
|
}
|
|
125
127
|
for (const n of notices)
|
|
126
128
|
say(`${n.text}\n`);
|
|
129
|
+
// A sync by hand that changed nothing still says so, rather than printing nothing.
|
|
130
|
+
if (!said) {
|
|
131
|
+
const count = Object.keys(readPendingFiles(root)).length;
|
|
132
|
+
say(!isMember
|
|
133
|
+
? `up to date with ${base}; "${member}" is not a member in homeostat.yaml, so lessons stay local\n`
|
|
134
|
+
: count
|
|
135
|
+
? `up to date: ${count} pending lesson(s) already on ${candidatesBranch(member)}\n`
|
|
136
|
+
: `up to date with ${base}; no pending lessons\n`);
|
|
137
|
+
}
|
|
127
138
|
writeSyncState(root, {
|
|
128
139
|
lastSync: now,
|
|
129
140
|
...(lastHeartbeat ? { lastHeartbeat } : {}),
|
package/dist/src/touched.d.ts
CHANGED
|
@@ -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.
|
package/dist/src/touched.js
CHANGED
|
@@ -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/dist/src/validate.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import { applyFixes,
|
|
2
|
+
import { applyFixes, readKbView, validate, writeKbFile, writePendingFile, } from "@homeostat/core";
|
|
3
3
|
import { formatIssues, painter, plural } from "./format.js";
|
|
4
4
|
/** `homeostat validate [dir]`. Returns the process exit code: 1 when there are errors. */
|
|
5
5
|
export function runValidate(dir, opts, io) {
|
|
6
6
|
const root = resolve(dir);
|
|
7
|
-
|
|
7
|
+
// Pending lessons are checked too: CI never sees them, so this is where a developer would.
|
|
8
|
+
let view = readKbView(root);
|
|
9
|
+
let files = view.files;
|
|
8
10
|
let result = validate(files);
|
|
9
11
|
if (opts.fix) {
|
|
10
12
|
const byFile = new Map();
|
|
@@ -15,16 +17,24 @@ export function runValidate(dir, opts, io) {
|
|
|
15
17
|
}
|
|
16
18
|
let fields = 0;
|
|
17
19
|
for (const [path, fixes] of byFile) {
|
|
18
|
-
|
|
20
|
+
const fixed = applyFixes(files[path] ?? "", fixes);
|
|
21
|
+
if (view.pending.has(path))
|
|
22
|
+
writePendingFile(root, path, fixed);
|
|
23
|
+
else
|
|
24
|
+
writeKbFile(root, path, fixed);
|
|
19
25
|
fields += fixes.length;
|
|
20
26
|
}
|
|
21
27
|
if (byFile.size > 0 && !opts.json) {
|
|
22
28
|
io.stdout(`fixed ${plural(fields, "field")} in ${plural(byFile.size, "file")}\n`);
|
|
23
|
-
|
|
29
|
+
view = readKbView(root);
|
|
30
|
+
files = view.files;
|
|
24
31
|
result = validate(files);
|
|
25
32
|
}
|
|
26
33
|
}
|
|
27
34
|
io.stdout(opts.json ? `${JSON.stringify(toJson(result), null, 2)}\n` : format(result, io));
|
|
35
|
+
if (!opts.json && view.pending.size > 0) {
|
|
36
|
+
io.stdout(`(including ${plural(view.pending.size, "pending lesson")} from .homeostat/pending, which the daily run decides)\n`);
|
|
37
|
+
}
|
|
28
38
|
return result.ok ? 0 : 1;
|
|
29
39
|
}
|
|
30
40
|
function toJson(result) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homeostat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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/core": "0.1.
|
|
43
|
-
"@homeostat/
|
|
44
|
-
"@homeostat/
|
|
42
|
+
"@homeostat/core": "0.1.3",
|
|
43
|
+
"@homeostat/mcp": "0.1.3",
|
|
44
|
+
"@homeostat/dashboard": "0.1.3"
|
|
45
45
|
},
|
|
46
46
|
"publishConfig": {
|
|
47
47
|
"access": "public",
|