@homeostat/core 0.1.0 → 0.1.1

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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared names for the automated daily flow (local pending lessons → one daily PR per developer
3
+ * → a decision by confidence score). Laptops and the daily run talk only through git refs on the
4
+ * KB repo's origin, so nothing needs a service:
5
+ *
6
+ * - `candidates/<member>`: the base branch plus that developer's pending lessons and a heartbeat,
7
+ * force-pushed by the developer's sync. Transport only; never merged.
8
+ * - `homeostat/state`: written by the daily run: each developer's decisions, collected heartbeats
9
+ * and one report per run. Laptops read their decisions from it; the dashboard reads all of it.
10
+ */
11
+ export declare const STATE_BRANCH = "homeostat/state";
12
+ export declare const candidatesBranch: (member: string) => string;
13
+ export declare const HEARTBEAT_PATH = ".homeostat-sync/heartbeat.json";
14
+ export declare const decisionsPath: (member: string) => string;
15
+ /** Identifies one version of a lesson file, so a decision applies to exactly what was submitted. */
16
+ export declare const lessonHash: (text: string) => string;
17
+ export type Decision = "merged" | "declined" | "review";
18
+ export interface DecisionRecord {
19
+ path: string;
20
+ id: string;
21
+ hash: string;
22
+ decision: Decision;
23
+ /** Why: the score breakdown summary, or the reviewer's action. */
24
+ reason: string;
25
+ confidence?: number;
26
+ pr?: string;
27
+ at: string;
28
+ }
29
+ export interface DecisionsFile {
30
+ member: string;
31
+ decided: DecisionRecord[];
32
+ }
33
+ export interface Heartbeat {
34
+ member: string;
35
+ at: string;
36
+ /** The base-branch commit the developer's clone was on after the sync. */
37
+ kbCommit: string | null;
38
+ /** Commits the clone could not fast-forward (local edits in the way), 0 when current. */
39
+ behind: number;
40
+ pending: number;
41
+ version: string;
42
+ }
43
+ /** One daily run, written to `runs/<date>.json` on the state branch. */
44
+ export interface RunReport {
45
+ at: string;
46
+ members: {
47
+ member: string;
48
+ merged: number;
49
+ declined: number;
50
+ review: number;
51
+ dailyPr?: string;
52
+ reviewPr?: string;
53
+ mergeError?: string;
54
+ }[];
55
+ /** Review PRs from earlier runs that a reviewer merged or closed since. */
56
+ settled: number;
57
+ lessons: {
58
+ member: string;
59
+ id: string;
60
+ decision: Decision;
61
+ confidence?: number;
62
+ reason: string;
63
+ }[];
64
+ }
65
+ /** Review items older than this are repeated in the morning notice and marked on the dashboard. */
66
+ export declare const REVIEW_REMINDER_DAYS = 2;
67
+ /** A developer whose sync has not reported for this long is shown as not syncing. */
68
+ export declare const STALE_HEARTBEAT_HOURS = 48;
@@ -0,0 +1,21 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Shared names for the automated daily flow (local pending lessons → one daily PR per developer
4
+ * → a decision by confidence score). Laptops and the daily run talk only through git refs on the
5
+ * KB repo's origin, so nothing needs a service:
6
+ *
7
+ * - `candidates/<member>`: the base branch plus that developer's pending lessons and a heartbeat,
8
+ * force-pushed by the developer's sync. Transport only; never merged.
9
+ * - `homeostat/state`: written by the daily run: each developer's decisions, collected heartbeats
10
+ * and one report per run. Laptops read their decisions from it; the dashboard reads all of it.
11
+ */
12
+ export const STATE_BRANCH = "homeostat/state";
13
+ export const candidatesBranch = (member) => `candidates/${member}`;
14
+ export const HEARTBEAT_PATH = ".homeostat-sync/heartbeat.json";
15
+ export const decisionsPath = (member) => `decisions/${member}.json`;
16
+ /** Identifies one version of a lesson file, so a decision applies to exactly what was submitted. */
17
+ export const lessonHash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
18
+ /** Review items older than this are repeated in the morning notice and marked on the dashboard. */
19
+ export const REVIEW_REMINDER_DAYS = 2;
20
+ /** A developer whose sync has not reported for this long is shown as not syncing. */
21
+ export const STALE_HEARTBEAT_HOURS = 48;
package/dist/src/check.js CHANGED
@@ -37,10 +37,9 @@ export function checkEntry(entry, ctx) {
37
37
  }
38
38
  }
39
39
  }
40
- const score = ctx.score ?? fm.score;
41
- if (fm.status === "accepted" && score < ctx.autoMergeThreshold && !fm.approvedBy) {
42
- issues.push(error("accepted.belowThreshold", `accepted with score ${score} below the auto-merge threshold ${ctx.autoMergeThreshold}; set approvedBy or lower the status`, { path: "status", file }));
43
- }
40
+ // No approval field is required: a rule is accepted by the merge that put it on the base
41
+ // branch, decided by the daily run's confidence score or a reviewer (branch protection makes
42
+ // sure nothing else can merge).
44
43
  const tokens = countTokens(entry.body);
45
44
  if (fm.tokens !== tokens) {
46
45
  issues.push({
@@ -1,5 +1,5 @@
1
1
  import { CORE_AREA } from "./check.js";
2
- import { CONFIG_FILE } from "./config.js";
2
+ import { CONFIG_FILE, isSenior } from "./config.js";
3
3
  import { error } from "./issues.js";
4
4
  export const CODEOWNERS_FILE = "CODEOWNERS";
5
5
  const HEADER = `# Generated by homeostat from ${CONFIG_FILE} and knowledge/. Do not edit; run \`homeostat codeowners\`.`;
@@ -21,7 +21,7 @@ export function generateCodeowners(config, entries) {
21
21
  missingHandle.add(id);
22
22
  return handle ? [handle] : [];
23
23
  });
24
- const seniors = members.filter((m) => m.role === "senior").map((m) => m.id);
24
+ const seniors = members.filter((m) => isSenior(m.role)).map((m) => m.id);
25
25
  if (seniors.length === 0) {
26
26
  issues.push(error("codeowners.noSeniors", "no member has role senior, so nobody would own homeostat.yaml", {
27
27
  file: CONFIG_FILE,
@@ -0,0 +1,39 @@
1
+ import type { Decision } from "./automation.ts";
2
+ import type { Config } from "./config.ts";
3
+ import type { Entry } from "./entry.ts";
4
+ /**
5
+ * How sure the team can be that a lesson is right and worth every agent following, 0–100. The
6
+ * daily run merges lessons at or above `accept`, declines those below `decline`, and sends the
7
+ * rest to a reviewer. Every point comes with a reason, printed in the daily PR (automation, step 3).
8
+ */
9
+ export interface ConfidenceInput {
10
+ entry: Entry;
11
+ config: Config;
12
+ /** The member the lesson came from: its writer, or the reviewer whose comment it is drawn from. */
13
+ originator: string;
14
+ /** Times the same lesson turned up independently in this run, the first included. */
15
+ occurrences?: number;
16
+ /** The model's own rating of a drafted lesson, 0–1. */
17
+ modelRating?: number;
18
+ /** Risky-content findings: they always send the lesson to a person. */
19
+ risks?: readonly string[];
20
+ /** Validation errors other than risky content: the lesson cannot be merged. */
21
+ errors?: readonly string[];
22
+ /** An accepted rule this lesson repeats: it reconfirms that rule instead of adding a new one. */
23
+ duplicateOf?: string;
24
+ /** Accepted rules this lesson contradicts. */
25
+ conflictsWith?: readonly string[];
26
+ }
27
+ export interface Confidence {
28
+ score: number;
29
+ parts: {
30
+ label: string;
31
+ delta: number;
32
+ }[];
33
+ decision: Decision;
34
+ /** One line for the PR and the developer's notice. */
35
+ reason: string;
36
+ }
37
+ export declare function confidence(input: ConfidenceInput): Confidence;
38
+ /** The breakdown as one readable line, e.g. "+50 review comment… · +14 from dana (senior)". */
39
+ export declare const explainConfidence: (c: Confidence) => string;
@@ -0,0 +1,101 @@
1
+ import { CORE_AREA } from "./check.js";
2
+ const SPECIFIC = /`[^`]+`|\w+\(\)|[\w.-]+\/[\w./*-]+|@[\w-]+\/[\w-]+|\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b/;
3
+ const VAGUE_TITLE = /^(lesson|note|rule|todo|misc|fix|update)\s*\d*$/i;
4
+ export function confidence(input) {
5
+ const { entry, config } = input;
6
+ const c = config.scoring.confidence;
7
+ const fm = entry.frontmatter;
8
+ const parts = [];
9
+ const add = (label, delta) => {
10
+ if (delta !== 0)
11
+ parts.push({ label, delta });
12
+ };
13
+ const sourceBase = {
14
+ "pr-feedback": fm.source.changedCode ? c.base.prFeedbackChangedCode : c.base.prFeedback,
15
+ manual: c.base.manual,
16
+ "agent-handoff": c.base.agentHandoff,
17
+ "deep-dive": c.base.deepDive,
18
+ }[fm.source.type];
19
+ add(fm.source.type === "pr-feedback"
20
+ ? fm.source.changedCode
21
+ ? "review comment followed by a code change"
22
+ : "review comment, no code change after it"
23
+ : fm.source.type === "agent-handoff"
24
+ ? "written by an agent"
25
+ : fm.source.type === "deep-dive"
26
+ ? "produced by the deep dive"
27
+ : "written by a team member", sourceBase);
28
+ const member = config.team.members.find((m) => m.id === input.originator);
29
+ if (member) {
30
+ add(`from ${member.id} (${member.role})`, c.roles[member.role]);
31
+ const owns = member.areas.includes(fm.area) ||
32
+ Object.values(config.teams).some((t) => t.members.includes(member.id) && t.areas.includes(fm.area));
33
+ if (owns)
34
+ add(`${member.id} works in ${fm.area}`, c.bonus.areaOwner);
35
+ }
36
+ const text = `${entry.sections.rule ?? ""} ${fm.title}`;
37
+ if (SPECIFIC.test(text))
38
+ add("names a concrete function, path or package", c.bonus.specific);
39
+ const words = fm.title.trim().split(/\s+/).length;
40
+ if (VAGUE_TITLE.test(fm.title.trim()) || words < 3)
41
+ add(`vague title "${fm.title}"`, -c.penalty.vagueTitle);
42
+ else
43
+ add("clear title", c.bonus.goodTitle);
44
+ const repeats = Math.min(2, Math.max(0, (input.occurrences ?? 1) - 1));
45
+ if (repeats)
46
+ add(`seen ${repeats + 1} times independently`, repeats * c.bonus.repeat);
47
+ if (input.modelRating !== undefined) {
48
+ add("model rating", Math.round((input.modelRating - 0.5) * 2 * c.bonus.modelRating));
49
+ }
50
+ for (const f of fm.flags ?? [])
51
+ add(`flagged by ${f.by}`, -c.penalty.flag);
52
+ if (input.conflictsWith?.length)
53
+ add(`contradicts ${input.conflictsWith.join(", ")}`, -c.penalty.conflict);
54
+ const score = Math.max(0, Math.min(100, parts.reduce((n, p) => n + p.delta, 0)));
55
+ const accept = fm.area === CORE_AREA || fm.scope.some((g) => g === "**") ? c.coreAccept : c.accept;
56
+ const summary = `confidence ${score}`;
57
+ // Hard rules first: some outcomes never depend on the score.
58
+ if (input.errors?.length)
59
+ return { score, parts, decision: "declined", reason: `does not validate: ${input.errors[0]}` };
60
+ if (input.duplicateOf) {
61
+ return {
62
+ score,
63
+ parts,
64
+ decision: "declined",
65
+ reason: `duplicate of ${input.duplicateOf}, which it reconfirms`,
66
+ };
67
+ }
68
+ if (input.risks?.length)
69
+ return {
70
+ score,
71
+ parts,
72
+ decision: "review",
73
+ reason: `${summary}; risky content always goes to a person`,
74
+ };
75
+ if (input.conflictsWith?.length && score >= c.decline) {
76
+ return {
77
+ score,
78
+ parts,
79
+ decision: "review",
80
+ reason: `${summary}; contradicts ${input.conflictsWith.join(", ")}`,
81
+ };
82
+ }
83
+ const gate = config.scoring.autoMerge;
84
+ const inScope = gate.enabled &&
85
+ !gate.excludeAreas.includes(fm.area) &&
86
+ (gate.areas.length === 0 || gate.areas.includes(fm.area));
87
+ if (score < c.decline)
88
+ return { score, parts, decision: "declined", reason: `${summary}, below ${c.decline}` };
89
+ if (score >= accept && inScope)
90
+ return { score, parts, decision: "merged", reason: `${summary}, at least ${accept}` };
91
+ return {
92
+ score,
93
+ parts,
94
+ decision: "review",
95
+ reason: score >= accept
96
+ ? `${summary}; area ${fm.area} always gets a reviewer`
97
+ : `${summary}, between ${c.decline} and ${accept}`,
98
+ };
99
+ }
100
+ /** The breakdown as one readable line, e.g. "+50 review comment… · +14 from dana (senior)". */
101
+ export const explainConfidence = (c) => c.parts.map((p) => `${p.delta > 0 ? "+" : ""}${p.delta} ${p.label}`).join(" · ");
@@ -1,6 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { type Issue } from "./issues.ts";
3
3
  export declare const CONFIG_FILE = "homeostat.yaml";
4
+ export declare const ROLES: readonly ["junior", "member", "senior", "lead"];
5
+ export type Role = (typeof ROLES)[number];
6
+ /** Seniors and leads: they own the config and unclaimed areas, and add the senior-author bonus. */
7
+ export declare const isSenior: (role: Role) => role is "lead" | "senior";
4
8
  /** Point values for each scoring signal; see `score()`. */
5
9
  export declare const DEFAULT_WEIGHTS: {
6
10
  /** pr-feedback where the PR's code changed after the comment: the accepted-feedback signal. */
@@ -27,6 +31,8 @@ export declare const configSchema: z.ZodObject<{
27
31
  members: z.ZodArray<z.ZodObject<{
28
32
  id: z.ZodString;
29
33
  role: z.ZodEnum<{
34
+ junior: "junior";
35
+ lead: "lead";
30
36
  member: "member";
31
37
  senior: "senior";
32
38
  }>;
@@ -65,6 +71,36 @@ export declare const configSchema: z.ZodObject<{
65
71
  daily: "daily";
66
72
  weekly: "weekly";
67
73
  }>>;
74
+ confidence: z.ZodPrefault<z.ZodObject<{
75
+ accept: z.ZodDefault<z.ZodInt>;
76
+ coreAccept: z.ZodDefault<z.ZodInt>;
77
+ decline: z.ZodDefault<z.ZodInt>;
78
+ base: z.ZodPrefault<z.ZodObject<{
79
+ prFeedbackChangedCode: z.ZodDefault<z.ZodInt>;
80
+ prFeedback: z.ZodDefault<z.ZodInt>;
81
+ manual: z.ZodDefault<z.ZodInt>;
82
+ agentHandoff: z.ZodDefault<z.ZodInt>;
83
+ deepDive: z.ZodDefault<z.ZodInt>;
84
+ }, z.core.$strict>>;
85
+ roles: z.ZodPrefault<z.ZodObject<{
86
+ junior: z.ZodDefault<z.ZodInt>;
87
+ member: z.ZodDefault<z.ZodInt>;
88
+ senior: z.ZodDefault<z.ZodInt>;
89
+ lead: z.ZodDefault<z.ZodInt>;
90
+ }, z.core.$strict>>;
91
+ bonus: z.ZodPrefault<z.ZodObject<{
92
+ areaOwner: z.ZodDefault<z.ZodInt>;
93
+ specific: z.ZodDefault<z.ZodInt>;
94
+ goodTitle: z.ZodDefault<z.ZodInt>;
95
+ repeat: z.ZodDefault<z.ZodInt>;
96
+ modelRating: z.ZodDefault<z.ZodInt>;
97
+ }, z.core.$strict>>;
98
+ penalty: z.ZodPrefault<z.ZodObject<{
99
+ vagueTitle: z.ZodDefault<z.ZodInt>;
100
+ conflict: z.ZodDefault<z.ZodInt>;
101
+ flag: z.ZodDefault<z.ZodInt>;
102
+ }, z.core.$strict>>;
103
+ }, z.core.$strict>>;
68
104
  }, z.core.$strict>>;
69
105
  agents: z.ZodPrefault<z.ZodObject<{
70
106
  runtime: z.ZodDefault<z.ZodEnum<{
@@ -5,6 +5,9 @@ import { error } from "./issues.js";
5
5
  import { ID_PATTERN } from "./schema.js";
6
6
  import { fromZod } from "./zod-issues.js";
7
7
  export const CONFIG_FILE = "homeostat.yaml";
8
+ export const ROLES = ["junior", "member", "senior", "lead"];
9
+ /** Seniors and leads: they own the config and unclaimed areas, and add the senior-author bonus. */
10
+ export const isSenior = (role) => role === "senior" || role === "lead";
8
11
  /** Point values for each scoring signal; see `score()`. */
9
12
  export const DEFAULT_WEIGHTS = {
10
13
  /** pr-feedback where the PR's code changed after the comment: the accepted-feedback signal. */
@@ -41,7 +44,11 @@ export const configSchema = z.strictObject({
41
44
  members: z
42
45
  .array(z.strictObject({
43
46
  id: name,
44
- role: z.enum(["senior", "member"]),
47
+ /**
48
+ * junior, member, senior or lead (team manager). Seniors and leads own the config and
49
+ * unclaimed areas; the role also weighs a member's lessons in the daily decision.
50
+ */
51
+ role: z.enum(ROLES),
45
52
  /** The curator never reads this member's pull requests or review comments. */
46
53
  optOut: z.boolean().optional(),
47
54
  areas: z.array(z.string()).default([]),
@@ -92,6 +99,50 @@ export const configSchema = z.strictObject({
92
99
  })
93
100
  .prefault({}),
94
101
  reviewCadence: z.enum(["daily", "weekly", "biweekly"]).default("weekly"),
102
+ /**
103
+ * The daily run's 0–100 confidence per lesson: merged at or above `accept` (`coreAccept` for
104
+ * rules that reach every session), declined below `decline`, a reviewer in between.
105
+ */
106
+ confidence: z
107
+ .strictObject({
108
+ accept: z.int().min(0).max(100).default(70),
109
+ coreAccept: z.int().min(0).max(100).default(80),
110
+ decline: z.int().min(0).max(100).default(35),
111
+ base: z
112
+ .strictObject({
113
+ prFeedbackChangedCode: z.int().default(50),
114
+ prFeedback: z.int().default(20),
115
+ manual: z.int().default(45),
116
+ agentHandoff: z.int().default(25),
117
+ deepDive: z.int().default(40),
118
+ })
119
+ .prefault({}),
120
+ roles: z
121
+ .strictObject({
122
+ junior: z.int().default(0),
123
+ member: z.int().default(6),
124
+ senior: z.int().default(14),
125
+ lead: z.int().default(20),
126
+ })
127
+ .prefault({}),
128
+ bonus: z
129
+ .strictObject({
130
+ areaOwner: z.int().default(6),
131
+ specific: z.int().default(8),
132
+ goodTitle: z.int().default(4),
133
+ repeat: z.int().default(10),
134
+ modelRating: z.int().default(10),
135
+ })
136
+ .prefault({}),
137
+ penalty: z
138
+ .strictObject({
139
+ vagueTitle: z.int().default(15),
140
+ conflict: z.int().default(25),
141
+ flag: z.int().default(20),
142
+ })
143
+ .prefault({}),
144
+ })
145
+ .prefault({}),
95
146
  })
96
147
  .prefault({}),
97
148
  agents: z
@@ -18,6 +18,8 @@ export interface CuratedEntry {
18
18
  path: string;
19
19
  text: string;
20
20
  frontmatter: Frontmatter;
21
+ /** The member whose pull request the review comment was on; the daily run files it under them. */
22
+ prAuthor?: string;
21
23
  /**
22
24
  * Risky-content findings (see `risk.ts`). Such an entry stays a candidate and fails `validate`
23
25
  * until a member reads it and sets `riskReviewedBy`, so the PR shows it but cannot merge it.
@@ -32,6 +34,7 @@ export interface DocUpdate {
32
34
  id: string;
33
35
  reason: string;
34
36
  url: string;
37
+ prAuthor?: string;
35
38
  }[];
36
39
  /** Risky-content findings; such an update fails validate until a member signs it off. */
37
40
  risks: string[];
@@ -136,7 +136,8 @@ export async function curate(input) {
136
136
  files = { ...files, [entry.path]: entry.text };
137
137
  ids.add(entry.frontmatter.id);
138
138
  known.add(key);
139
- result.entries.push(entry);
139
+ const prAuthor = config.team.members.find((m) => m.handle === `@${pr.author.id}`)?.id;
140
+ result.entries.push(prAuthor ? { ...entry, prAuthor } : entry);
140
141
  }
141
142
  }
142
143
  }
@@ -197,7 +198,13 @@ async function docPatchStep(ctx, patcher, thread, url) {
197
198
  }
198
199
  const risks = errors.map((i) => i.message);
199
200
  const existing = ctx.result.docs.find((d) => d.path === proposal.path);
200
- const section = { id: proposal.sectionId, reason: proposal.reason, url };
201
+ const prAuthor = ctx.config.team.members.find((m) => m.handle === `@${thread.pullRequest.author.id}`)?.id;
202
+ const section = {
203
+ id: proposal.sectionId,
204
+ reason: proposal.reason,
205
+ url,
206
+ ...(prAuthor ? { prAuthor } : {}),
207
+ };
201
208
  if (existing) {
202
209
  existing.text = text;
203
210
  existing.sections.push(section);
@@ -18,7 +18,7 @@ export function explainEntry(e, config) {
18
18
  : []),
19
19
  `Applies to: ${fm.scope.join(", ")}`,
20
20
  `Source: ${fm.source.type} by ${fm.source.author}${fm.source.url ? ` — ${fm.source.url}` : ""}${fm.source.changedCode ? " (code changed after the comment)" : ""}`,
21
- `Created ${fm.created}, last confirmed ${fm.lastConfirmed}`,
21
+ `Created ${fm.created}, last confirmed ${fm.lastConfirmed}${fm.approvedBy ? `, approved by ${fm.approvedBy}` : ""}`,
22
22
  ...(fm.conflictsWith?.length ? [`Conflicts with: ${fm.conflictsWith.join(", ")}`] : []),
23
23
  ...(fm.supersedes ? [`Supersedes: ${fm.supersedes}`] : []),
24
24
  ...(fm.flags ?? []).map((f) => `Flagged by ${f.by} on ${f.date}: ${f.reason}`),
@@ -2,3 +2,23 @@ import type { KbFiles } from "./validate.ts";
2
2
  /** Reads the parts of a KB that `validate()` looks at: the config and everything under knowledge/. */
3
3
  export declare function readKbFiles(root: string): KbFiles;
4
4
  export declare function writeKbFile(root: string, path: string, content: string): void;
5
+ /**
6
+ * A developer's own lessons, not yet decided by the daily run. They live in the KB clone but
7
+ * outside git (`.homeostat/` is ignored), so the clone stays clean for its daily pull, and they
8
+ * apply to that developer's sessions at once. Paths mirror the KB: `knowledge/<area>/<id>.md`.
9
+ */
10
+ export declare const PENDING_DIR = ".homeostat/pending";
11
+ export declare function readPendingFiles(root: string): KbFiles;
12
+ /**
13
+ * The KB as one developer sees it: the committed files with their pending lessons on top. With
14
+ * `inForce`, a pending candidate reads as accepted, so the developer's own sessions follow their
15
+ * own lessons right away; the daily run still decides whether the team gets them.
16
+ */
17
+ export declare function readKbView(root: string, opts?: {
18
+ inForce?: boolean;
19
+ }): {
20
+ files: KbFiles;
21
+ pending: Set<string>;
22
+ };
23
+ export declare function writePendingFile(root: string, path: string, content: string): void;
24
+ export declare function removePendingFile(root: string, path: string): void;
package/dist/src/files.js CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join, sep } from "node:path";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, sep } from "node:path";
3
3
  import { CONFIG_FILE } from "./config.js";
4
4
  /** Reads the parts of a KB that `validate()` looks at: the config and everything under knowledge/. */
5
5
  export function readKbFiles(root) {
@@ -20,3 +20,46 @@ export function readKbFiles(root) {
20
20
  export function writeKbFile(root, path, content) {
21
21
  writeFileSync(join(root, ...path.split("/")), content);
22
22
  }
23
+ /**
24
+ * A developer's own lessons, not yet decided by the daily run. They live in the KB clone but
25
+ * outside git (`.homeostat/` is ignored), so the clone stays clean for its daily pull, and they
26
+ * apply to that developer's sessions at once. Paths mirror the KB: `knowledge/<area>/<id>.md`.
27
+ */
28
+ export const PENDING_DIR = ".homeostat/pending";
29
+ export function readPendingFiles(root) {
30
+ const files = {};
31
+ const base = join(root, PENDING_DIR, "knowledge");
32
+ if (!existsSync(base))
33
+ return files;
34
+ for (const rel of readdirSync(base, { recursive: true, encoding: "utf8" })) {
35
+ if (!rel.endsWith(".md"))
36
+ continue;
37
+ files[`knowledge/${rel.split(sep).join("/")}`] = readFileSync(join(base, rel), "utf8");
38
+ }
39
+ return files;
40
+ }
41
+ /**
42
+ * The KB as one developer sees it: the committed files with their pending lessons on top. With
43
+ * `inForce`, a pending candidate reads as accepted, so the developer's own sessions follow their
44
+ * own lessons right away; the daily run still decides whether the team gets them.
45
+ */
46
+ export function readKbView(root, opts = {}) {
47
+ const pending = readPendingFiles(root);
48
+ if (opts.inForce) {
49
+ for (const [path, text] of Object.entries(pending)) {
50
+ // A flagged lesson is out of force until the daily run or a reviewer settles it.
51
+ if (/^flags:\s*\n\s+-/m.test(text))
52
+ continue;
53
+ pending[path] = text.replace(/^status: candidate$/m, "status: accepted");
54
+ }
55
+ }
56
+ return { files: { ...readKbFiles(root), ...pending }, pending: new Set(Object.keys(pending)) };
57
+ }
58
+ export function writePendingFile(root, path, content) {
59
+ const full = join(root, PENDING_DIR, ...path.split("/"));
60
+ mkdirSync(dirname(full), { recursive: true });
61
+ writeFileSync(full, content);
62
+ }
63
+ export function removePendingFile(root, path) {
64
+ rmSync(join(root, PENDING_DIR, ...path.split("/")), { force: true });
65
+ }
@@ -1,3 +1,4 @@
1
+ export { candidatesBranch, type Decision, type DecisionRecord, type DecisionsFile, decisionsPath, HEARTBEAT_PATH, type Heartbeat, lessonHash, REVIEW_REMINDER_DAYS, type RunReport, STALE_HEARTBEAT_HOURS, STATE_BRANCH, } from "./automation.ts";
1
2
  export { type AutoMergeDecision, autoMergeDecision, type MergeCandidate, splitAutoMerge, } from "./automerge.ts";
2
3
  export { type BodySections, parseBody, RULE_MAX_WORDS, WHY_MAX_WORDS } from "./body.ts";
3
4
  export { type CheckContext, CORE_AREA, checkEntry } from "./check.ts";
@@ -5,7 +6,8 @@ export { type ApiAskerOptions, type Asker, apiAsker, type ClaudeClient, ClaudeUn
5
6
  export { AGENT_ENV, type ClaudeCodeAskerOptions, type CommandRunner, claudeCodeArgs, claudeCodeAsker, type RunResult, } from "./claude-code.ts";
6
7
  export { bedrockAsker, bedrockModelId, type CloudAskerOptions, type CloudClient, vertexAsker, } from "./cloud.ts";
7
8
  export { CODEOWNERS_FILE, generateCodeowners } from "./codeowners.ts";
8
- export { CONFIG_FILE, type Config, configSchema, DEFAULT_AGENT_MODEL, DEFAULT_WEIGHTS, EXPORT_TARGETS, parseConfig, type Weights, } from "./config.ts";
9
+ export { type Confidence, type ConfidenceInput, confidence, explainConfidence, } from "./confidence.ts";
10
+ export { CONFIG_FILE, type Config, configSchema, DEFAULT_AGENT_MODEL, DEFAULT_WEIGHTS, EXPORT_TARGETS, isSenior, parseConfig, ROLES, type Role, type Weights, } from "./config.ts";
9
11
  export { type Conflict, type ConflictKind, detectConflicts, scopesOverlap, TITLE_SIMILARITY, titleSimilarity, } from "./conflicts.ts";
10
12
  export { type AgentName, cronMatches, digestCron, digestDue, dueAgents, nextRun, } from "./cron.ts";
11
13
  export { type CuratedEntry, CurateError, type CurateInput, type CurateResult, curate, type DocUpdate, type Skipped, } from "./curator/curate.ts";
@@ -24,7 +26,7 @@ export { type Entry, formatEntry, parseEntry } from "./entry.ts";
24
26
  export { describeEvent, EVENTS_DB, type Event, type EventQuery, formatEvent, LEGACY_EVENTS, logEvent, logFailure, pruneEvents, readEvents, } from "./events.ts";
25
27
  export { BLOCK_FILES, type ExportDoc, type ExportOutput, type ExportTarget, exportRules, OWNED_DIRS, } from "./export.ts";
26
28
  export { BLOCK_END, BLOCK_START, removeBlock, replaceBlock } from "./export-block.ts";
27
- export { readKbFiles, writeKbFile } from "./files.ts";
29
+ export { PENDING_DIR, readKbFiles, readKbView, readPendingFiles, removePendingFile, writeKbFile, writePendingFile, } from "./files.ts";
28
30
  export { applyFixes } from "./fix.ts";
29
31
  export { type Flag, flagEntry } from "./flag.ts";
30
32
  export { error, type Fix, hasErrors, type Issue, type Severity } from "./issues.ts";
package/dist/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { candidatesBranch, decisionsPath, HEARTBEAT_PATH, lessonHash, REVIEW_REMINDER_DAYS, STALE_HEARTBEAT_HOURS, STATE_BRANCH, } from "./automation.js";
1
2
  export { autoMergeDecision, splitAutoMerge, } from "./automerge.js";
2
3
  export { parseBody, RULE_MAX_WORDS, WHY_MAX_WORDS } from "./body.js";
3
4
  export { CORE_AREA, checkEntry } from "./check.js";
@@ -5,7 +6,8 @@ export { apiAsker, ClaudeUnavailableError, } from "./claude.js";
5
6
  export { AGENT_ENV, claudeCodeArgs, claudeCodeAsker, } from "./claude-code.js";
6
7
  export { bedrockAsker, bedrockModelId, vertexAsker, } from "./cloud.js";
7
8
  export { CODEOWNERS_FILE, generateCodeowners } from "./codeowners.js";
8
- export { CONFIG_FILE, configSchema, DEFAULT_AGENT_MODEL, DEFAULT_WEIGHTS, EXPORT_TARGETS, parseConfig, } from "./config.js";
9
+ export { confidence, explainConfidence, } from "./confidence.js";
10
+ export { CONFIG_FILE, configSchema, DEFAULT_AGENT_MODEL, DEFAULT_WEIGHTS, EXPORT_TARGETS, isSenior, parseConfig, ROLES, } from "./config.js";
9
11
  export { detectConflicts, scopesOverlap, TITLE_SIMILARITY, titleSimilarity, } from "./conflicts.js";
10
12
  export { cronMatches, digestCron, digestDue, dueAgents, nextRun, } from "./cron.js";
11
13
  export { CurateError, curate, } from "./curator/curate.js";
@@ -24,7 +26,7 @@ export { formatEntry, parseEntry } from "./entry.js";
24
26
  export { describeEvent, EVENTS_DB, formatEvent, LEGACY_EVENTS, logEvent, logFailure, pruneEvents, readEvents, } from "./events.js";
25
27
  export { BLOCK_FILES, exportRules, OWNED_DIRS, } from "./export.js";
26
28
  export { BLOCK_END, BLOCK_START, removeBlock, replaceBlock } from "./export-block.js";
27
- export { readKbFiles, writeKbFile } from "./files.js";
29
+ export { PENDING_DIR, readKbFiles, readKbView, readPendingFiles, removePendingFile, writeKbFile, writePendingFile, } from "./files.js";
28
30
  export { applyFixes } from "./fix.js";
29
31
  export { flagEntry } from "./flag.js";
30
32
  export { error, hasErrors } from "./issues.js";
@@ -19,6 +19,7 @@ export declare class GitHubProvider implements Provider {
19
19
  id: number;
20
20
  url: string;
21
21
  }>;
22
+ requestReviewers(repo: RepoRef, pullRequestId: number, logins: string[]): Promise<void>;
22
23
  mergePullRequest(repo: RepoRef, pullRequestId: number): Promise<void>;
23
24
  private repoUrl;
24
25
  private headers;
@@ -101,6 +101,11 @@ export class GitHubProvider {
101
101
  const created = body;
102
102
  return { id: created.number, url: created.html_url };
103
103
  }
104
+ async requestReviewers(repo, pullRequestId, logins) {
105
+ if (logins.length === 0)
106
+ return;
107
+ await requestJson("GitHub", this.fetch, `${this.repoUrl(repo)}/pulls/${pullRequestId}/requested_reviewers`, { method: "POST", headers: this.headers(), body: { reviewers: logins } });
108
+ }
104
109
  async mergePullRequest(repo, pullRequestId) {
105
110
  await requestJson("GitHub", this.fetch, `${this.repoUrl(repo)}/pulls/${pullRequestId}/merge`, {
106
111
  method: "PUT",
@@ -63,6 +63,8 @@ export interface Provider {
63
63
  }>;
64
64
  /** Squash-merges a PR on the KB repo; used only for entries that pass every auto-merge gate. */
65
65
  mergePullRequest(repo: RepoRef, pullRequestId: number): Promise<void>;
66
+ /** Asks these git-host users (logins, no @) to review; hosts without it mention them instead. */
67
+ requestReviewers?(repo: RepoRef, pullRequestId: number, logins: string[]): Promise<void>;
66
68
  }
67
69
  export interface ProviderOptions {
68
70
  /** Access token owned by the team (see the permissions model in the execution plan). */
@@ -1,4 +1,4 @@
1
- import type { Config, Weights } from "./config.ts";
1
+ import { type Config, type Weights } from "./config.ts";
2
2
  import type { Frontmatter } from "./schema.ts";
3
3
  export interface ScoreContext {
4
4
  config: Config;
package/dist/src/score.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isSenior } from "./config.js";
1
2
  const BASE_SIGNAL = {
2
3
  manual: "manual",
3
4
  "agent-handoff": "agentHandoff",
@@ -26,8 +27,8 @@ export function score(entry, ctx) {
26
27
  add(BASE_SIGNAL[source.type], `source is ${source.type}`);
27
28
  }
28
29
  const author = ctx.config.team.members.find((m) => m.id === source.author);
29
- if (author?.role === "senior")
30
- add("seniorAuthor", `author ${author.id} is senior`);
30
+ if (author && isSenior(author.role))
31
+ add("seniorAuthor", `author ${author.id} is ${author.role}`);
31
32
  if (entry.conflictsWith?.length) {
32
33
  add("conflict", `conflicts with ${entry.conflictsWith.join(", ")}`);
33
34
  }
@@ -1,2 +1,2 @@
1
1
  /** The release version; `scripts/version.mjs` keeps it in step with every package.json. */
2
- export declare const VERSION = "0.1.0";
2
+ export declare const VERSION = "0.1.1";
@@ -1,2 +1,2 @@
1
1
  /** The release version; `scripts/version.mjs` keeps it in step with every package.json. */
2
- export const VERSION = "0.1.0";
2
+ export const VERSION = "0.1.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeostat/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Schemas, validation, scoring and agents behind homeostat, the team knowledge base for coding agents.",
5
5
  "keywords": [
6
6
  "homeostat",