homeostat 0.1.0 → 0.1.2
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/cli.d.ts +6 -1
- package/dist/src/cli.js +38 -1
- package/dist/src/daily.d.ts +23 -0
- package/dist/src/daily.js +442 -0
- package/dist/src/doctor.js +5 -3
- package/dist/src/flag.js +7 -10
- package/dist/src/git.d.ts +9 -0
- package/dist/src/git.js +48 -0
- package/dist/src/hook.js +95 -6
- package/dist/src/init.js +7 -7
- package/dist/src/learn.d.ts +2 -0
- package/dist/src/learn.js +25 -11
- package/dist/src/mcp.d.ts +3 -3
- package/dist/src/mcp.js +6 -11
- package/dist/src/query.js +33 -8
- package/dist/src/sync.d.ts +41 -0
- package/dist/src/sync.js +163 -0
- package/dist/src/up.js +2 -2
- 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.2 init
|
|
10
10
|
|
|
11
11
|
# In Claude Code:
|
|
12
12
|
claude plugin marketplace add ysugarmen/homeostat
|
package/dist/src/cli.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface Io {
|
|
|
12
12
|
now?: () => Date;
|
|
13
13
|
/** Reads all of stdin; hooks receive their event JSON this way. */
|
|
14
14
|
stdin?: () => Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Runs `homeostat <args>` detached, so a hook returns at once while a sync runs. Only the real
|
|
17
|
+
* process has one; tests and embedded callers leave it out, so nothing runs behind them.
|
|
18
|
+
*/
|
|
19
|
+
background?: (args: string[]) => void;
|
|
15
20
|
/** Test seams for the agents: a git-host provider and a drafter instead of the real ones. */
|
|
16
21
|
provider?: Provider;
|
|
17
22
|
drafter?: Drafter;
|
|
@@ -22,4 +27,4 @@ export interface Io {
|
|
|
22
27
|
}
|
|
23
28
|
export declare const processIo: Io;
|
|
24
29
|
/** Runs the CLI with `argv` (as in `process.argv`) and returns the exit code. */
|
|
25
|
-
export declare function runCli(argv: readonly string[],
|
|
30
|
+
export declare function runCli(argv: readonly string[], given?: Io): Promise<number>;
|
package/dist/src/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { createInterface } from "node:readline/promises";
|
|
2
3
|
import { VERSION } from "@homeostat/core";
|
|
3
4
|
import { Command, CommanderError } from "commander";
|
|
4
5
|
import { runCodeowners } from "./codeowners.js";
|
|
5
6
|
import { runCurate } from "./curate.js";
|
|
7
|
+
import { runDaily } from "./daily.js";
|
|
6
8
|
import { runDeepDive } from "./deep-dive.js";
|
|
7
9
|
import { runDigest } from "./digest.js";
|
|
8
10
|
import { runConvertDocs } from "./docs.js";
|
|
@@ -16,6 +18,7 @@ import { runLearn } from "./learn.js";
|
|
|
16
18
|
import { runMcp } from "./mcp.js";
|
|
17
19
|
import { runExplain, runList, runSearch, runStatus } from "./query.js";
|
|
18
20
|
import { runSeed } from "./seed.js";
|
|
21
|
+
import { runSync } from "./sync.js";
|
|
19
22
|
import { runDashboard, runUp } from "./up.js";
|
|
20
23
|
import { runValidate } from "./validate.js";
|
|
21
24
|
async function readStdin() {
|
|
@@ -40,9 +43,24 @@ export const processIo = {
|
|
|
40
43
|
...(process.stdin.isTTY ? { ask: askTerminal } : {}),
|
|
41
44
|
env: process.env,
|
|
42
45
|
stdin: readStdin,
|
|
46
|
+
background: (args) => {
|
|
47
|
+
const bin = process.argv[1];
|
|
48
|
+
if (!bin)
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
spawn(process.execPath, [bin, ...args], { detached: true, stdio: "ignore" }).unref();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Best effort: the next hook tries again.
|
|
55
|
+
}
|
|
56
|
+
},
|
|
43
57
|
};
|
|
44
58
|
/** Runs the CLI with `argv` (as in `process.argv`) and returns the exit code. */
|
|
45
|
-
export async function runCli(argv,
|
|
59
|
+
export async function runCli(argv, given = processIo) {
|
|
60
|
+
// HOMEOSTAT_NO_SYNC=1 keeps hooks and learn from syncing in the background (`homeostat sync` by hand still works).
|
|
61
|
+
const io = { ...given };
|
|
62
|
+
if (given.env?.HOMEOSTAT_NO_SYNC)
|
|
63
|
+
delete io.background;
|
|
46
64
|
let exitCode = 0;
|
|
47
65
|
const program = new Command("homeostat")
|
|
48
66
|
.description("Team knowledge base for coding agents.")
|
|
@@ -180,6 +198,25 @@ export async function runCli(argv, io = processIo) {
|
|
|
180
198
|
.action(async (opts) => {
|
|
181
199
|
exitCode = await runDigest(opts, io);
|
|
182
200
|
});
|
|
201
|
+
program
|
|
202
|
+
.command("daily")
|
|
203
|
+
.description("The daily run (CI): one PR per developer from their pending lessons and the reviews on their PRs; merges, declines or asks a reviewer by confidence.")
|
|
204
|
+
.option("--dir <dir>", "knowledge base checkout", ".")
|
|
205
|
+
.option("--since <date>", "read reviews on PRs merged since this date (default: agents.curator.lookbackHours ago)")
|
|
206
|
+
.option("--dry-run", "decide and report, but commit, open and merge nothing")
|
|
207
|
+
.option("--no-reviews", "skip drafting from review comments (no model calls): pending lessons only")
|
|
208
|
+
.action(async (opts) => {
|
|
209
|
+
exitCode = await runDaily({ ...opts, ...(opts.reviews === false ? { noReviews: true } : {}) }, io);
|
|
210
|
+
});
|
|
211
|
+
program
|
|
212
|
+
.command("sync")
|
|
213
|
+
.description("Send your pending lessons to the daily run, pull the KB and clear decided lessons. The plugin runs it for you.")
|
|
214
|
+
.option("--dir <dir>", "knowledge base root (default: $HOMEOSTAT_DIR or the current dir)")
|
|
215
|
+
.option("--quiet", "print nothing")
|
|
216
|
+
.option("--push-only", "send pending lessons, skip the pull")
|
|
217
|
+
.action(async (opts) => {
|
|
218
|
+
exitCode = await runSync(opts, io);
|
|
219
|
+
});
|
|
183
220
|
program
|
|
184
221
|
.command("doctor")
|
|
185
222
|
.description("Security review: what is read, what leaves, who can change the rules, and the blast radius.")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Io } from "./cli.ts";
|
|
2
|
+
/**
|
|
3
|
+
* `homeostat daily`: the one scheduled job (the KB repo's CI, or `homeostat up`). Laptops need not
|
|
4
|
+
* be on: it reads only what is already on the server. For each developer it:
|
|
5
|
+
*
|
|
6
|
+
* 1. takes their pending lessons (pushed to `candidates/<member>` by their sync) and the lessons
|
|
7
|
+
* drafted from review comments on their own merged PRs
|
|
8
|
+
* 2. scores each lesson (confidence, step 3): merge, decline, or a reviewer
|
|
9
|
+
* 3. opens ONE daily PR with the confident lessons and merges it, every decision explained
|
|
10
|
+
* 4. opens a review PR for the uncertain ones, asking the area's owners to review
|
|
11
|
+
* 5. records decisions, heartbeats and a run report on `homeostat/state`, which the developers'
|
|
12
|
+
* sync reads to clear decided lessons, and the dashboard reads to show it all
|
|
13
|
+
*
|
|
14
|
+
* It also settles earlier review PRs: merged by a reviewer means merged, closed means declined.
|
|
15
|
+
*/
|
|
16
|
+
export interface DailyOptions {
|
|
17
|
+
dir?: string;
|
|
18
|
+
since?: string;
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
/** Skip drafting from review comments (no model calls): pending lessons only. */
|
|
21
|
+
noReviews?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function runDaily(opts: DailyOptions, io: Io): Promise<number>;
|
|
@@ -0,0 +1,442 @@
|
|
|
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";
|
|
3
|
+
import { askerFor } from "./agents.js";
|
|
4
|
+
import { plural } from "./format.js";
|
|
5
|
+
import { commitTree, forcePush, git, showFile } from "./git.js";
|
|
6
|
+
import { CliError, providerFor } from "./provider.js";
|
|
7
|
+
import { publish } from "./publish.js";
|
|
8
|
+
const repoRef = (ref) => {
|
|
9
|
+
const [owner = "", name = ""] = ref.split("/");
|
|
10
|
+
return { owner, name };
|
|
11
|
+
};
|
|
12
|
+
const accepted = (text) => text.replace(/^status: candidate$/m, "status: accepted");
|
|
13
|
+
export async function runDaily(opts, io) {
|
|
14
|
+
try {
|
|
15
|
+
return await daily(opts, io);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
if (e instanceof CliError) {
|
|
19
|
+
io.stderr(`${e.message}\n`);
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function daily(opts, io) {
|
|
26
|
+
const root = resolve(opts.dir ?? ".");
|
|
27
|
+
const files = readKbFiles(root);
|
|
28
|
+
const kb = validate(files);
|
|
29
|
+
const config = kb.config;
|
|
30
|
+
if (!config)
|
|
31
|
+
throw new CliError("homeostat.yaml is missing or invalid; run homeostat validate");
|
|
32
|
+
const sources = config.sources;
|
|
33
|
+
if (!sources)
|
|
34
|
+
throw new CliError("homeostat.yaml has no sources section; the daily run needs one");
|
|
35
|
+
const base = sources.baseBranch;
|
|
36
|
+
const kbRepo = repoRef(sources.kbRepo);
|
|
37
|
+
const now = io.now?.() ?? new Date();
|
|
38
|
+
const today = now.toISOString().slice(0, 10);
|
|
39
|
+
const provider = providerFor(sources.host, io, "read pull requests and open the daily PRs");
|
|
40
|
+
const remote = (ref) => `refs/remotes/origin/${ref}`;
|
|
41
|
+
git(root, "fetch", "--quiet", "origin", `+refs/heads/${base}:${remote(base)}`);
|
|
42
|
+
git(root, "fetch", "--quiet", "origin", "+refs/heads/candidates/*:refs/remotes/origin/candidates/*");
|
|
43
|
+
git(root, "fetch", "--quiet", "origin", `+refs/heads/${STATE_BRANCH}:${remote(STATE_BRANCH)}`);
|
|
44
|
+
const onState = (path) => git(root, "show", `${remote(STATE_BRANCH)}:${path}`);
|
|
45
|
+
const onBase = (path) => showFile(root, remote(base), path);
|
|
46
|
+
const members = config.team.members.map((m) => m.id);
|
|
47
|
+
const decisions = new Map();
|
|
48
|
+
for (const m of members) {
|
|
49
|
+
try {
|
|
50
|
+
decisions.set(m, JSON.parse(onState(decisionsPath(m)) ?? ""));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
decisions.set(m, { member: m, decided: [] });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Settle review PRs from earlier runs that a reviewer has since merged or closed.
|
|
57
|
+
const open = new Set((await provider.listOpenPullRequests(kbRepo)).map((p) => p.sourceBranch));
|
|
58
|
+
let settled = 0;
|
|
59
|
+
for (const d of decisions.values()) {
|
|
60
|
+
for (const r of d.decided) {
|
|
61
|
+
if (r.decision !== "review" || !r.pr || open.has(r.pr))
|
|
62
|
+
continue;
|
|
63
|
+
const onMain = onBase(r.path);
|
|
64
|
+
r.decision = onMain !== undefined && onMain.includes(`id: ${r.id}`) ? "merged" : "declined";
|
|
65
|
+
r.reason = r.decision === "merged" ? "merged by a reviewer" : "closed by a reviewer";
|
|
66
|
+
r.at = now.toISOString();
|
|
67
|
+
settled++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const lessons = [];
|
|
71
|
+
const decidedHashes = (m) => new Set(decisions.get(m)?.decided.map((r) => r.hash));
|
|
72
|
+
const heartbeats = {};
|
|
73
|
+
// 1a. Pending lessons, from each developer's candidates branch.
|
|
74
|
+
for (const m of members) {
|
|
75
|
+
const branch = candidatesBranch(m);
|
|
76
|
+
const ref = remote(branch);
|
|
77
|
+
if (git(root, "rev-parse", "--verify", "--quiet", ref) === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
const hb = git(root, "show", `${ref}:${HEARTBEAT_PATH}`);
|
|
80
|
+
if (hb)
|
|
81
|
+
heartbeats[m] = hb;
|
|
82
|
+
const changed = (git(root, "diff", "--name-status", "--no-renames", `${remote(base)}...${ref}`, "--", "knowledge") ?? "")
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
const seen = decidedHashes(m);
|
|
86
|
+
for (const line of changed) {
|
|
87
|
+
const [status, path = ""] = line.split("\t");
|
|
88
|
+
if (status !== "A" && status !== "M")
|
|
89
|
+
continue;
|
|
90
|
+
const text = showFile(root, ref, path) ?? "";
|
|
91
|
+
const hash = lessonHash(text);
|
|
92
|
+
if (seen.has(hash))
|
|
93
|
+
continue;
|
|
94
|
+
lessons.push(pendingLesson(m, path, text, hash, status === "A" ? undefined : onBase(path)));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 1b. Lessons from review comments, filed under the developer whose PR it was.
|
|
98
|
+
let drafted = [];
|
|
99
|
+
if (!opts.noReviews) {
|
|
100
|
+
const since = opts.since
|
|
101
|
+
? new Date(opts.since)
|
|
102
|
+
: new Date(now.getTime() - config.agents.curator.lookbackHours * 3_600_000);
|
|
103
|
+
const asker = askerFor(config, "curator", io, root);
|
|
104
|
+
const result = await curate({
|
|
105
|
+
files,
|
|
106
|
+
provider,
|
|
107
|
+
drafter: io.drafter ?? new ClaudeDrafter(asker),
|
|
108
|
+
patcher: io.patcher ?? new ClaudeDocPatcher(asker),
|
|
109
|
+
since,
|
|
110
|
+
today,
|
|
111
|
+
});
|
|
112
|
+
drafted = result.entries;
|
|
113
|
+
for (const e of drafted) {
|
|
114
|
+
const member = e.prAuthor ?? e.frontmatter.source.author;
|
|
115
|
+
if (decidedHashes(member).has(lessonHash(e.text)))
|
|
116
|
+
continue;
|
|
117
|
+
lessons.push({
|
|
118
|
+
member,
|
|
119
|
+
path: e.path,
|
|
120
|
+
text: accepted(e.text),
|
|
121
|
+
hash: lessonHash(e.text),
|
|
122
|
+
id: e.frontmatter.id,
|
|
123
|
+
title: e.frontmatter.title,
|
|
124
|
+
kind: "new",
|
|
125
|
+
origin: "review",
|
|
126
|
+
originator: e.frontmatter.source.author,
|
|
127
|
+
...(e.frontmatter.source.url ? { url: e.frontmatter.source.url } : {}),
|
|
128
|
+
decision: "review",
|
|
129
|
+
reason: "",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const d of result.docs) {
|
|
133
|
+
lessons.push({
|
|
134
|
+
member: d.sections[0]?.prAuthor ?? members[0] ?? "",
|
|
135
|
+
path: d.path,
|
|
136
|
+
text: d.text,
|
|
137
|
+
hash: lessonHash(d.text),
|
|
138
|
+
id: d.path,
|
|
139
|
+
title: `Update ${d.sections.map((s) => `#${s.id}`).join(", ")} in ${d.path}`,
|
|
140
|
+
kind: "doc",
|
|
141
|
+
origin: "review",
|
|
142
|
+
originator: d.sections[0]?.prAuthor ?? "",
|
|
143
|
+
decision: "review",
|
|
144
|
+
reason: "document edits go to the owning team",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// 2. Decide each lesson.
|
|
149
|
+
const acceptedFms = kb.entries
|
|
150
|
+
.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() : []);
|
|
153
|
+
for (const l of lessons)
|
|
154
|
+
decide(l, files, config, acceptedFms, newFms);
|
|
155
|
+
// 3–4. Per developer: one daily PR (merged), one review PR (assigned).
|
|
156
|
+
const report = { at: now.toISOString(), members: [], settled, lessons: [] };
|
|
157
|
+
const byMember = new Map();
|
|
158
|
+
for (const l of lessons)
|
|
159
|
+
byMember.set(l.member, [...(byMember.get(l.member) ?? []), l]);
|
|
160
|
+
for (const [member, list] of byMember) {
|
|
161
|
+
const merged = list.filter((l) => l.decision === "merged");
|
|
162
|
+
const review = list.filter((l) => l.decision === "review");
|
|
163
|
+
const declined = list.filter((l) => l.decision === "declined");
|
|
164
|
+
const row = {
|
|
165
|
+
member,
|
|
166
|
+
merged: merged.length,
|
|
167
|
+
declined: declined.length,
|
|
168
|
+
review: review.length,
|
|
169
|
+
};
|
|
170
|
+
report.members.push(row);
|
|
171
|
+
io.stdout(`${member}: ${merged.length} merged, ${review.length} to review, ${declined.length} declined\n`);
|
|
172
|
+
for (const l of list)
|
|
173
|
+
io.stdout(` ${l.decision.padEnd(8)} ${l.id}: ${l.reason}\n`);
|
|
174
|
+
if (opts.dryRun)
|
|
175
|
+
continue;
|
|
176
|
+
const reconfirm = declined
|
|
177
|
+
.flatMap((l) => l.duplicateOf ? [reconfirmChange(files, kb.entries, l.duplicateOf, today)] : [])
|
|
178
|
+
.filter((c) => c !== undefined);
|
|
179
|
+
const changes = [...merged.map((l) => change(l, "merge")), ...reconfirm];
|
|
180
|
+
if (changes.length) {
|
|
181
|
+
const pr = await publish({
|
|
182
|
+
root,
|
|
183
|
+
branch: `homeostat/daily-${member}-${today}`,
|
|
184
|
+
changes,
|
|
185
|
+
provider,
|
|
186
|
+
kbRepo,
|
|
187
|
+
base,
|
|
188
|
+
title: `homeostat: daily lessons · ${member} · ${today}`,
|
|
189
|
+
body: prBody(member, today, list, "daily"),
|
|
190
|
+
io,
|
|
191
|
+
});
|
|
192
|
+
if (pr.pr) {
|
|
193
|
+
row.dailyPr = pr.pr.url;
|
|
194
|
+
try {
|
|
195
|
+
await provider.mergePullRequest(kbRepo, pr.pr.id);
|
|
196
|
+
io.stdout(` merged ${pr.pr.url}\n`);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
// Branch protection or a failing check: the confident lessons wait for a person instead.
|
|
200
|
+
row.mergeError = e.message;
|
|
201
|
+
for (const l of merged) {
|
|
202
|
+
l.decision = "review";
|
|
203
|
+
l.reason = `${l.reason}; the host refused the merge`;
|
|
204
|
+
}
|
|
205
|
+
io.stdout(` could not merge ${pr.pr.url}: ${row.mergeError}; it stays open for a reviewer\n`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const l of merged)
|
|
209
|
+
if (row.dailyPr)
|
|
210
|
+
Object.assign(l, { pr: pr.branch });
|
|
211
|
+
}
|
|
212
|
+
if (review.length) {
|
|
213
|
+
const pr = await publish({
|
|
214
|
+
root,
|
|
215
|
+
branch: `homeostat/review-${member}-${today}`,
|
|
216
|
+
changes: review.map((l) => change(l, "review")),
|
|
217
|
+
provider,
|
|
218
|
+
kbRepo,
|
|
219
|
+
base,
|
|
220
|
+
title: `homeostat: ${plural(review.length, "lesson")} to review · ${member} · ${today}`,
|
|
221
|
+
body: prBody(member, today, list, "review"),
|
|
222
|
+
io,
|
|
223
|
+
});
|
|
224
|
+
if (pr.pr) {
|
|
225
|
+
row.reviewPr = pr.pr.url;
|
|
226
|
+
const logins = reviewerLogins(config, review, member);
|
|
227
|
+
if (logins.length)
|
|
228
|
+
await provider.requestReviewers?.(kbRepo, pr.pr.id, logins).catch(() => undefined);
|
|
229
|
+
}
|
|
230
|
+
for (const l of review)
|
|
231
|
+
Object.assign(l, { pr: pr.branch });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// 5. Record decisions, heartbeats and the report for the laptops and the dashboard.
|
|
235
|
+
for (const l of lessons) {
|
|
236
|
+
report.lessons.push({
|
|
237
|
+
member: l.member,
|
|
238
|
+
id: l.id,
|
|
239
|
+
decision: l.decision,
|
|
240
|
+
...(l.confidence ? { confidence: l.confidence.score } : {}),
|
|
241
|
+
reason: l.reason,
|
|
242
|
+
});
|
|
243
|
+
const record = {
|
|
244
|
+
path: l.path,
|
|
245
|
+
id: l.id,
|
|
246
|
+
hash: l.hash,
|
|
247
|
+
decision: l.decision,
|
|
248
|
+
reason: l.reason,
|
|
249
|
+
...(l.confidence ? { confidence: l.confidence.score } : {}),
|
|
250
|
+
...(l.pr ? { pr: l.pr } : {}),
|
|
251
|
+
at: now.toISOString(),
|
|
252
|
+
};
|
|
253
|
+
const d = decisions.get(l.member) ?? { member: l.member, decided: [] };
|
|
254
|
+
d.decided = [...d.decided.filter((r) => r.hash !== l.hash), record].slice(-500);
|
|
255
|
+
decisions.set(l.member, d);
|
|
256
|
+
}
|
|
257
|
+
if (!opts.dryRun) {
|
|
258
|
+
const state = {
|
|
259
|
+
[`runs/${today}.json`]: `${JSON.stringify(report, null, 1)}\n`,
|
|
260
|
+
};
|
|
261
|
+
for (const [m, d] of decisions)
|
|
262
|
+
if (d.decided.length)
|
|
263
|
+
state[decisionsPath(m)] = `${JSON.stringify(d, null, 1)}\n`;
|
|
264
|
+
for (const [m, hb] of Object.entries(heartbeats))
|
|
265
|
+
state[`heartbeats/${m}.json`] = `${hb}\n`;
|
|
266
|
+
const parent = git(root, "rev-parse", "--verify", "--quiet", remote(STATE_BRANCH));
|
|
267
|
+
const commit = commitTree(root, parent, state, `homeostat: daily run ${today}`);
|
|
268
|
+
forcePush(root, commit, STATE_BRANCH);
|
|
269
|
+
await notifyReviewQueue(config, report, [...decisions.values()], now, io);
|
|
270
|
+
}
|
|
271
|
+
logEvent(root, {
|
|
272
|
+
ts: now.toISOString(),
|
|
273
|
+
type: "agent-run",
|
|
274
|
+
agent: "daily",
|
|
275
|
+
ok: true,
|
|
276
|
+
summary: `${report.members.reduce((n, m) => n + m.merged, 0)} merged, ${report.members.reduce((n, m) => n + m.review, 0)} to review, ${report.members.reduce((n, m) => n + m.declined, 0)} declined, ${settled} settled`,
|
|
277
|
+
});
|
|
278
|
+
if (lessons.length === 0)
|
|
279
|
+
io.stdout("no new lessons\n");
|
|
280
|
+
if (opts.dryRun)
|
|
281
|
+
io.stdout("dry run: nothing committed, opened or merged\n");
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
function pendingLesson(member, path, text, hash, before) {
|
|
285
|
+
const doc = isDocPath(path);
|
|
286
|
+
const fm = doc ? undefined : parseEntry(text).entry?.frontmatter;
|
|
287
|
+
const id = fm?.id ?? path;
|
|
288
|
+
const flagOnly = !!fm &&
|
|
289
|
+
before !== undefined &&
|
|
290
|
+
(parseEntry(before).entry?.frontmatter.flags?.length ?? 0) < (fm.flags?.length ?? 0);
|
|
291
|
+
return {
|
|
292
|
+
member,
|
|
293
|
+
path,
|
|
294
|
+
text: doc ? text : accepted(text),
|
|
295
|
+
hash,
|
|
296
|
+
id,
|
|
297
|
+
title: fm?.title ?? path,
|
|
298
|
+
kind: doc ? "doc" : before === undefined ? "new" : flagOnly ? "flag" : "update",
|
|
299
|
+
origin: "pending",
|
|
300
|
+
originator: fm?.source.author ?? member,
|
|
301
|
+
decision: "review",
|
|
302
|
+
reason: "",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function decide(l, files, config, acceptedFms, newFms) {
|
|
306
|
+
if (l.kind === "doc") {
|
|
307
|
+
l.decision = "review";
|
|
308
|
+
l.reason ||= "document edits go to the owning team";
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (l.kind === "flag") {
|
|
312
|
+
// A flag needs no approval: it disputes the rule, which takes it out of sessions, until a
|
|
313
|
+
// reviewer resolves it. Making it wait would keep a wrong rule in force.
|
|
314
|
+
l.decision = "merged";
|
|
315
|
+
l.reason = "a flag disputes the rule at once; a reviewer resolves it";
|
|
316
|
+
l.text = l.text.replace(/^status: accepted$/m, "status: disputed");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const entry = parseEntry(l.text).entry;
|
|
320
|
+
const issues = validate({ ...files, [l.path]: l.text }).issues.filter((i) => i.file === l.path && i.severity === "error");
|
|
321
|
+
if (!entry) {
|
|
322
|
+
l.decision = "declined";
|
|
323
|
+
l.reason = `does not parse: ${issues[0]?.message ?? "invalid entry"}`;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
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;
|
|
332
|
+
const c = confidence({
|
|
333
|
+
entry,
|
|
334
|
+
config,
|
|
335
|
+
originator: l.originator,
|
|
336
|
+
occurrences,
|
|
337
|
+
risks: issues.filter((i) => i.code.startsWith("risk.")).map((i) => i.message),
|
|
338
|
+
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) ?? "" } : {}),
|
|
340
|
+
...(fm.conflictsWith?.length ? { conflictsWith: fm.conflictsWith } : {}),
|
|
341
|
+
});
|
|
342
|
+
l.confidence = c;
|
|
343
|
+
l.decision = c.decision;
|
|
344
|
+
l.reason = c.reason;
|
|
345
|
+
if (dup && l.kind === "new")
|
|
346
|
+
l.duplicateOf = dup.ids.find((i) => i !== fm.id);
|
|
347
|
+
}
|
|
348
|
+
const change = (l, kind) => ({
|
|
349
|
+
path: l.path,
|
|
350
|
+
text: l.text,
|
|
351
|
+
message: `homeostat: ${kind === "merge" ? "merge" : "propose"} ${l.id} (${l.reason})`,
|
|
352
|
+
});
|
|
353
|
+
/** A duplicate reconfirms the rule it repeats: its lastConfirmed moves to today. */
|
|
354
|
+
function reconfirmChange(files, entries, id, today) {
|
|
355
|
+
const found = entries.find((e) => e.entry.frontmatter.id === id);
|
|
356
|
+
const text = found ? files[found.path] : undefined;
|
|
357
|
+
if (!found || !text)
|
|
358
|
+
return undefined;
|
|
359
|
+
return {
|
|
360
|
+
path: found.path,
|
|
361
|
+
text: text.replace(/^lastConfirmed: .*$/m, `lastConfirmed: ${today}`),
|
|
362
|
+
message: `homeostat: reconfirm ${id} (a lesson repeated it)`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function reviewerLogins(config, review, member) {
|
|
366
|
+
const handles = new Set();
|
|
367
|
+
for (const l of review) {
|
|
368
|
+
const area = parseEntry(l.text).entry?.frontmatter.area ?? "core";
|
|
369
|
+
const team = Object.values(config.teams).find((t) => t.areas.includes(area));
|
|
370
|
+
const owners = team
|
|
371
|
+
? team.handle
|
|
372
|
+
? [team.handle]
|
|
373
|
+
: team.members.map((id) => config.team.members.find((m) => m.id === id)?.handle)
|
|
374
|
+
: config.team.members
|
|
375
|
+
.filter((m) => m.areas.includes(area) && m.id !== member)
|
|
376
|
+
.map((m) => m.handle);
|
|
377
|
+
const fallback = config.team.members
|
|
378
|
+
.filter((m) => m.role === "lead" || m.role === "senior")
|
|
379
|
+
.map((m) => m.handle);
|
|
380
|
+
for (const h of owners.length ? owners : fallback)
|
|
381
|
+
if (h?.startsWith("@") && !h.includes("/"))
|
|
382
|
+
handles.add(h.slice(1));
|
|
383
|
+
}
|
|
384
|
+
const self = config.team.members.find((m) => m.id === member)?.handle?.slice(1);
|
|
385
|
+
return [...handles].filter((h) => h !== self);
|
|
386
|
+
}
|
|
387
|
+
function prBody(member, today, list, kind) {
|
|
388
|
+
const rows = list.map((l) => {
|
|
389
|
+
const why = l.confidence ? `${l.reason}<br>${explainConfidence(l.confidence)}` : l.reason;
|
|
390
|
+
const src = l.url ? `[review](${l.url})` : l.origin === "pending" ? "written by hand" : "";
|
|
391
|
+
return `| ${l.decision} | ${l.title} (\`${l.id}\`) | ${l.confidence?.score ?? ""} | ${src} | ${why} |`;
|
|
392
|
+
});
|
|
393
|
+
return [
|
|
394
|
+
kind === "daily"
|
|
395
|
+
? `Daily lessons for ${member}, ${today}. The lessons marked merged passed every check and the confidence threshold, so this PR merges itself. Declined lessons are listed for the record; lessons to review are in a separate PR.`
|
|
396
|
+
: `Lessons for ${member}, ${today}, that the daily run could not decide. Merge to accept them, close to decline, or edit first. The developer's sync picks up the outcome.`,
|
|
397
|
+
"",
|
|
398
|
+
"| Decision | Lesson | Confidence | Source | Why |",
|
|
399
|
+
"| --- | --- | --- | --- | --- |",
|
|
400
|
+
...rows,
|
|
401
|
+
"",
|
|
402
|
+
"Decided by `homeostat daily`. Thresholds and weights are in `scoring.confidence` in homeostat.yaml; `homeostat flag <id>` sends any rule back for review.",
|
|
403
|
+
].join("\n");
|
|
404
|
+
}
|
|
405
|
+
/** The morning notice: today's review PRs, and a reminder for lessons waiting longer. */
|
|
406
|
+
async function notifyReviewQueue(config, report, decisions, now, io) {
|
|
407
|
+
const variable = config.notifications.slackWebhookEnv;
|
|
408
|
+
const url = variable ? io.env?.[variable] : undefined;
|
|
409
|
+
const waiting = report.members.filter((m) => m.review > 0 && m.reviewPr);
|
|
410
|
+
const cutoff = new Date(now.getTime() - REVIEW_REMINDER_DAYS * 86_400_000).toISOString();
|
|
411
|
+
const overdue = decisions.flatMap((d) => d.decided
|
|
412
|
+
.filter((r) => r.decision === "review" && r.at < cutoff)
|
|
413
|
+
.map((r) => ({ member: d.member, ...r })));
|
|
414
|
+
if (!url || !/^https:\/\/hooks\.slack\.com\//.test(url))
|
|
415
|
+
return;
|
|
416
|
+
if (waiting.length === 0 && overdue.length === 0)
|
|
417
|
+
return;
|
|
418
|
+
const text = [
|
|
419
|
+
...(waiting.length
|
|
420
|
+
? [
|
|
421
|
+
`*Homeostat: ${plural(waiting.reduce((n, m) => n + m.review, 0), "lesson")} waiting for review*`,
|
|
422
|
+
...waiting.map((m) => `• ${m.member}: ${m.review} — ${m.reviewPr}`),
|
|
423
|
+
]
|
|
424
|
+
: []),
|
|
425
|
+
...(overdue.length
|
|
426
|
+
? [
|
|
427
|
+
`*Still waiting after ${REVIEW_REMINDER_DAYS} days: ${plural(overdue.length, "lesson")}*`,
|
|
428
|
+
...overdue.map((r) => `• ${r.id} (${r.member}, since ${r.at.slice(0, 10)}) — ${r.pr ?? ""}`),
|
|
429
|
+
]
|
|
430
|
+
: []),
|
|
431
|
+
].join("\n");
|
|
432
|
+
try {
|
|
433
|
+
await (io.fetch ?? fetch)(url, {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: { "content-type": "application/json" },
|
|
436
|
+
body: JSON.stringify({ text, mrkdwn: true }),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// The dashboard shows the queue too; a failed notice must not fail the run.
|
|
441
|
+
}
|
|
442
|
+
}
|
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/flag.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { flagEntry, logEvent, readKbFiles, scanSecrets, validate } from "@homeostat/core";
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { flagEntry, logEvent, readKbView, scanSecrets, validate, writePendingFile, } from "@homeostat/core";
|
|
4
3
|
import { commitFile, git, pushBranch } from "./git.js";
|
|
5
4
|
import { memberIdFrom } from "./init.js";
|
|
5
|
+
import { sendPending } from "./learn.js";
|
|
6
6
|
import { CliError } from "./provider.js";
|
|
7
7
|
/** `homeostat flag <id>`: reports that an entry is wrong. It loses score and goes back to review. */
|
|
8
8
|
export function runFlag(id, opts, io) {
|
|
@@ -15,7 +15,7 @@ export function runFlag(id, opts, io) {
|
|
|
15
15
|
throw new CliError(`the reason contains a possible ${leak.kind}; remove it before flagging`);
|
|
16
16
|
if (opts.push && !opts.branch)
|
|
17
17
|
throw new CliError("--push needs --branch");
|
|
18
|
-
const kb = validate(
|
|
18
|
+
const kb = validate(readKbView(root).files);
|
|
19
19
|
if (!kb.config) {
|
|
20
20
|
throw new CliError("homeostat.yaml is missing or invalid; run homeostat validate");
|
|
21
21
|
}
|
|
@@ -48,12 +48,9 @@ export function runFlag(id, opts, io) {
|
|
|
48
48
|
io.stdout(`flagged ${id} on ${opts.branch}${opts.push ? " (pushed)" : ""}; it is ${status} until a reviewer decides\n`);
|
|
49
49
|
return 0;
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
writeFileSync(full, text);
|
|
56
|
-
io.stdout(`flagged ${id} in ${found.path}; it is ${status} until a reviewer decides. Commit it on a branch and open a PR.\n`);
|
|
51
|
+
writePendingFile(root, found.path, text);
|
|
52
|
+
sendPending(root, io);
|
|
53
|
+
io.stdout(`flagged ${id}: it no longer applies to your sessions (${status}), and tomorrow's daily run takes the flag to the team\n`);
|
|
57
54
|
return 0;
|
|
58
55
|
}
|
|
59
56
|
catch (e) {
|
package/dist/src/git.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Runs git in `dir`; undefined when it fails (not a repo, unknown ref, ...). */
|
|
2
2
|
export declare function git(dir: string, ...args: string[]): string | undefined;
|
|
3
|
+
/** A file's exact text on a ref (git() trims, which would change a lesson's hash). */
|
|
4
|
+
export declare function showFile(dir: string, ref: string, path: string): string | undefined;
|
|
3
5
|
/** True when `path` exists in the tree of `ref`. */
|
|
4
6
|
export declare function existsOnRef(dir: string, ref: string, path: string): boolean;
|
|
5
7
|
/**
|
|
@@ -8,3 +10,10 @@ export declare function existsOnRef(dir: string, ref: string, path: string): boo
|
|
|
8
10
|
*/
|
|
9
11
|
export declare function commitFile(dir: string, branch: string, path: string, content: string, message: string): string;
|
|
10
12
|
export declare function pushBranch(dir: string, branch: string, remote?: string): void;
|
|
13
|
+
/**
|
|
14
|
+
* Builds a commit of `base` plus `files` (path → content) with a throwaway index, without touching
|
|
15
|
+
* the working tree, the real index or any branch. Returns the commit id.
|
|
16
|
+
*/
|
|
17
|
+
export declare function commitTree(dir: string, base: string | undefined, files: Record<string, string>, message: string): string;
|
|
18
|
+
/** Force-pushes a commit to a branch on origin (for transport branches only). */
|
|
19
|
+
export declare function forcePush(dir: string, commit: string, branch: string, remote?: string): void;
|