baesian 0.0.0-stage → 1.0.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.
- package/LICENSE.md +105 -0
- package/README.md +217 -2
- package/dist/agents.js +249 -0
- package/dist/api.js +13 -0
- package/dist/bin.js +14 -0
- package/dist/ci.js +82 -0
- package/dist/cite.js +66 -0
- package/dist/cli.js +831 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
- package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
- package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
- package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
- package/dist/client/assets/index-9yf70Vqv.css +1 -0
- package/dist/client/assets/index-Dwg9e0YR.js +15 -0
- package/dist/client/index.html +15 -0
- package/dist/facts.js +278 -0
- package/dist/folder-storage.js +71 -0
- package/dist/frontmatter.js +38 -0
- package/dist/gate-paths.js +77 -0
- package/dist/gate.js +237 -0
- package/dist/git.js +100 -0
- package/dist/globs.js +120 -0
- package/dist/hookblock.js +146 -0
- package/dist/hooks.js +911 -0
- package/dist/ids.js +23 -0
- package/dist/login.js +301 -0
- package/dist/mcp.js +303 -0
- package/dist/merges.js +849 -0
- package/dist/modes.js +245 -0
- package/dist/notify.js +91 -0
- package/dist/ops.js +381 -0
- package/dist/passkey.js +86 -0
- package/dist/personal.js +163 -0
- package/dist/practice-answers.js +42 -0
- package/dist/proposals.js +197 -0
- package/dist/quote.js +28 -0
- package/dist/report.js +261 -0
- package/dist/routing.js +411 -0
- package/dist/schema.js +1178 -0
- package/dist/server.js +514 -0
- package/dist/similarity.js +45 -0
- package/dist/sshsig.js +159 -0
- package/dist/state.js +135 -0
- package/dist/storage.js +61 -0
- package/dist/store.js +2259 -0
- package/dist/style.js +408 -0
- package/dist/sync.js +797 -0
- package/dist/trailers.js +121 -0
- package/dist/trust.js +222 -0
- package/dist/usage-fields.js +161 -0
- package/dist/vault.js +849 -0
- package/dist/verify.js +443 -0
- package/dist/version.js +70 -0
- package/dist/wait.js +449 -0
- package/dist/wikilinks.js +25 -0
- package/package.json +71 -4
package/dist/ops.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { CARD_TEMPLATE } from "./agents.js";
|
|
2
|
+
import { cite, citeIn } from "./cite.js";
|
|
3
|
+
import { agentLabel, byLine, cardAuthor, plain, q, reviewerOf, said } from "./quote.js";
|
|
4
|
+
import { POSITION_NAMES, ROW_NAMES } from "./routing.js";
|
|
5
|
+
import { attention, blank, cmd, dim, errorLines, failure, fields, head, id, indent, level, lit, literal, next, ok, strong, status as statusPiece, table, text, warning, } from "./style.js";
|
|
6
|
+
import { NoCard, NoDeck, NotAllowed, PENDING } from "./store.js";
|
|
7
|
+
import { describeAll, describeBrief, describeOutcome, exitCodeFor, exitCodeForAll, replyHint, ruleHint, waitForAll, waitForVerdict, } from "./wait.js";
|
|
8
|
+
/** Who files a card: the agent, and the model its session runs on, if the plugin's hooks heard one (B-k0w7d). */
|
|
9
|
+
const filer = (s, actor) => {
|
|
10
|
+
const model = actor.sessions?.length ? s.sessionModel(actor.sessions) : undefined;
|
|
11
|
+
return { agent: actor.agent, ...(model ? { model } : {}) };
|
|
12
|
+
};
|
|
13
|
+
// ---------------------------------------------------------------- lines
|
|
14
|
+
/** A time as people read it: to the minute, in UTC. */
|
|
15
|
+
export const when = (iso) => (/^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(iso) ? `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC` : iso);
|
|
16
|
+
/** The card itself: the question, the crux, the options, each text quoted with the agent that wrote it, line by line. */
|
|
17
|
+
export function cardDetails(card) {
|
|
18
|
+
const m = card.meta;
|
|
19
|
+
const by = (what, t) => said(what, cardAuthor(m), t);
|
|
20
|
+
const undo = { cheap: "easy", moderate: "costly", "one-way": "hard" }[m.reverse];
|
|
21
|
+
const out = [
|
|
22
|
+
text(by("question", m.question)),
|
|
23
|
+
text(by("doing", m.doing)),
|
|
24
|
+
text(`stakes: ${undo} to undo${m.touches.length ? `; touches ${m.touches.join(", ")}` : ""}`),
|
|
25
|
+
blank,
|
|
26
|
+
text(`${by("crux", m.crux)}${m.crux_preference ? " (a preference)" : ""}`),
|
|
27
|
+
];
|
|
28
|
+
if (m.crux_fact)
|
|
29
|
+
out.push(text(by("crux_fact", m.crux_fact)));
|
|
30
|
+
if (m.answered_in_chat)
|
|
31
|
+
out.push(text(by(`answered_in_chat (the reviewer's answer in chat, the agent's pick; marked seen${citeIn("B-hswmx")})`, m.answered_in_chat)));
|
|
32
|
+
out.push(text(`${by("proposal (the agent's pick)", m.proposal)}; if: ${q(m.crux_proposal)}; then: ${q(m.proposal_then)}`));
|
|
33
|
+
out.push(text(`${by("alternative", m.alternative)}; if: ${q(m.crux_alternative)}; then: ${q(m.alternative_then)}`));
|
|
34
|
+
if (m.premise)
|
|
35
|
+
out.push(text(`${by(`premise, unverified, of the ${m.premise_option}`, m.premise)}; check: ${q(m.premise_check ?? "")}`));
|
|
36
|
+
if (m.acted)
|
|
37
|
+
out.push(text(by(`acted, already built for the ${m.acted_option}`, m.acted)));
|
|
38
|
+
if (m.rationale)
|
|
39
|
+
out.push(text(by("rationale", m.rationale)));
|
|
40
|
+
if (m.wrong_if)
|
|
41
|
+
out.push(text(`${by("wrong_if", m.wrong_if)}; check: ${q(m.check ?? "")}`));
|
|
42
|
+
if (m.secret_reason)
|
|
43
|
+
out.push(text(by(`secret_reason (filed though it looks like it holds a secret${citeIn("B-jfkv9")})`, m.secret_reason)));
|
|
44
|
+
if (card.body.trim())
|
|
45
|
+
out.push(text(by("body", card.body.trim())));
|
|
46
|
+
return indent(2, out);
|
|
47
|
+
}
|
|
48
|
+
/** An answer, coloured by what it does: resolves, sends the card back, or neither. */
|
|
49
|
+
function response(r) {
|
|
50
|
+
if (r === "accept" || r === "alternative" || r === "correction")
|
|
51
|
+
return ok(r);
|
|
52
|
+
if (r === "reject")
|
|
53
|
+
return failure(r);
|
|
54
|
+
if (r === "question" || r === "need_context" || r === "see_it_run" || r === "defer")
|
|
55
|
+
return attention(r);
|
|
56
|
+
return r;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One verdict, naming what it decided (an accept names the proposal it accepted, as `wait` does), and when; then the
|
|
60
|
+
* reviewer's comment on a line of its own, as `wait` gives it, and the rules it retired.
|
|
61
|
+
*/
|
|
62
|
+
export function verdictLines(s, v) {
|
|
63
|
+
const proposal = v.response === "accept" ? s.revisions(v.card).find((r) => r.meta.revision === v.revision)?.meta.proposal : undefined;
|
|
64
|
+
const what = v.choice ?? proposal;
|
|
65
|
+
const out = [text(id(v.card), ` r${v.revision} `, response(v.response), ` by ${byLine(reviewerOf(v))}${what !== undefined ? `: ${q(what)}` : ""}${v.seen ? ", answered in chat first" : ""}`, " ", dim(when(v.at)))];
|
|
66
|
+
if (v.comment)
|
|
67
|
+
out.push(indent(2, text(said("Comment", reviewerOf(v), v.comment))));
|
|
68
|
+
if (v.retire_rules?.length)
|
|
69
|
+
out.push(indent(2, text(`It retires ${v.retire_rules.join(", ")}.`)));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** Why a possible duplicate matched: the same topic, the same title, or a title like it. */
|
|
73
|
+
function matchedBy(m, title) {
|
|
74
|
+
if (m.by === "topic")
|
|
75
|
+
return "same topic";
|
|
76
|
+
const norm = (t) => t.toLowerCase().replace(/\s+/g, " ").trim();
|
|
77
|
+
return title !== undefined && norm(title) === norm(m.card.meta.title) ? "same title" : "similar title";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* What `baesian file` prints when the decision may already be on file (exit 3), and the server's error text: the cards
|
|
81
|
+
* it matched, and what to do in each case, with their IDs filled in. `title` is the draft's, to tell the same title from
|
|
82
|
+
* a similar one.
|
|
83
|
+
*/
|
|
84
|
+
export function duplicateLines(e, title) {
|
|
85
|
+
const out = [head("attention", attention("not filed"), ": this decision may already be on file")];
|
|
86
|
+
out.push(...table(undefined, e.matches.map((m) => [id(m.card.meta.id), statusPiece(m.status), matchedBy(m, title ?? e.title), said("title", cardAuthor(m.card.meta), m.card.meta.title)])));
|
|
87
|
+
const open = e.matches.find((m) => PENDING.has(m.status));
|
|
88
|
+
const decided = e.matches.find((m) => m.status === "resolved");
|
|
89
|
+
if (open)
|
|
90
|
+
out.push(next(`baesian attach ${open.card.meta.id}`, "the same question: wait on that card instead of filing another"));
|
|
91
|
+
if (decided)
|
|
92
|
+
out.push(next(`baesian show ${decided.card.meta.id}`, "the same question, decided already: follow that decision"));
|
|
93
|
+
// A topic is one question (D112): a different question on the same subject is a narrower topic, not a duplicate.
|
|
94
|
+
const byTopic = e.matches.find((m) => m.by === "topic" && m.card.meta.topic);
|
|
95
|
+
if (byTopic) {
|
|
96
|
+
out.push(indent(2, text(`A different question on the same subject: give it a narrower topic of its own, such as ${byTopic.card.meta.topic}/<what it asks>, list what it builds on in depends_on, and file it again.`)));
|
|
97
|
+
}
|
|
98
|
+
const against = (decided ?? open ?? e.matches[0]).card.meta.id;
|
|
99
|
+
out.push(indent(2, text(`An argument against that decision: set supersedes: ${against}, and file it with `, cmd("--new"), ".")));
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
/** The level a card is shown at, and why; or that the gate held it back, which means its agent goes ahead with its pick. */
|
|
103
|
+
export function levelField(g, filer = true) {
|
|
104
|
+
const row = ROW_NAMES[g.row];
|
|
105
|
+
// No quotation marks: in an agent's lines they mean a text someone else wrote (D169).
|
|
106
|
+
const at = POSITION_NAMES[g.position];
|
|
107
|
+
if (g.outcome === "held") {
|
|
108
|
+
if (g.mode === "shadow")
|
|
109
|
+
return { label: "level", value: [level(g.level ?? "provisional"), `; in shadow, the repo's preferences would have held it back (${row}: ${at})`] };
|
|
110
|
+
const go = filer ? "go ahead with your pick, as provisional, whatever level you asked for" : "its agent goes ahead with its pick, as provisional";
|
|
111
|
+
return { label: "level", value: [attention("held back"), ` (${row}: ${at}): ${go}. It counts as settled at merge; the reviewer may still answer it later`] };
|
|
112
|
+
}
|
|
113
|
+
const why = {
|
|
114
|
+
row: `(${row}: ${at})`,
|
|
115
|
+
floor: `(${row}: never without me, always)`,
|
|
116
|
+
"one-way": "(it's one-way, and a one-way card is never held back)",
|
|
117
|
+
agent: `the level ${filer ? "you" : "its agent"} asked for`,
|
|
118
|
+
baesian: "(a card Baesian files is always shown)",
|
|
119
|
+
answer: "(a revision that follows an answer is always shown)",
|
|
120
|
+
chat: "(a choice the reviewer answered in chat is always shown, for them to confirm)",
|
|
121
|
+
error: "(the gate couldn't run, so the card is shown)",
|
|
122
|
+
}[g.reason];
|
|
123
|
+
return { label: "level", value: [level(g.level ?? "provisional"), g.reason === "agent" ? `, ${why}` : ` ${why}`] };
|
|
124
|
+
}
|
|
125
|
+
/** The trailer a commit that builds on a card carries, at the end of its line, so it copies whole. */
|
|
126
|
+
const carries = (cardId) => indent(2, text("Commits that build on it carry the trailer ", lit(`Baesian-Card: ${cardId}`)));
|
|
127
|
+
/** What filing or revising printed: the headline, the level, the deck, notes, and what to do next. */
|
|
128
|
+
function filed(result, title) {
|
|
129
|
+
const { id: cardId, level: lvl, deck, revision } = result.card.meta;
|
|
130
|
+
const held = result.gate.outcome === "held" && result.gate.mode === "enforce";
|
|
131
|
+
const out = [head("done", "filed ", id(revision > 1 ? `${cardId} r${revision}` : cardId), " ", title, held ? ", held back" : "")];
|
|
132
|
+
out.push(...fields([
|
|
133
|
+
levelField(result.gate),
|
|
134
|
+
result.card.meta.answered_in_chat !== undefined && { label: "seen", value: `answered in chat: the reviewer confirms it with one tap, and calibration leaves it out${cite("B-hswmx")}` },
|
|
135
|
+
{ label: "deck", value: deck },
|
|
136
|
+
result.hardBecause && { label: "raised", value: [`to `, level("hard"), ` by what it touches, ${result.hardBecause.join(", ")}: an agent can raise a card's level, never lower it${cite("D42", "D144")}`] },
|
|
137
|
+
result.nearTopics.length > 0 && { label: "topic", value: `new; did you mean ${result.nearTopics.join(" or ")}?` },
|
|
138
|
+
]));
|
|
139
|
+
for (const w of result.warnings)
|
|
140
|
+
out.push(indent(2, warning(w)));
|
|
141
|
+
if (!held && lvl === "blocking")
|
|
142
|
+
out.push(next(`baesian wait ${cardId}`, "before building on it"));
|
|
143
|
+
if (!held && lvl === "hard")
|
|
144
|
+
out.push(next(`baesian wait ${cardId}`, "don't proceed with it until this exits 0"));
|
|
145
|
+
out.push(carries(cardId));
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------- filing
|
|
149
|
+
/** `baesian file`: file a card, notify for blocking and hard cards, and say what to do next. */
|
|
150
|
+
export function fileOp(s, draft, body, actor) {
|
|
151
|
+
const result = s.fileCard(draft, body, { ...filer(s, actor), force: actor.force ?? false });
|
|
152
|
+
actor.notify(result.card);
|
|
153
|
+
return { lines: filed(result, q(result.card.meta.title)), data: result };
|
|
154
|
+
}
|
|
155
|
+
/** `baesian revise`: the next revision of a returned card. */
|
|
156
|
+
export function reviseOp(s, cardId, draft, body, actor) {
|
|
157
|
+
const result = s.reviseCard(cardId, draft, body, filer(s, actor));
|
|
158
|
+
actor.notify(result.card);
|
|
159
|
+
return { lines: filed(result, q(result.card.meta.title)), data: result };
|
|
160
|
+
}
|
|
161
|
+
/** `baesian reply`: the agent's one reply to an answer against its pick (D89, D104). */
|
|
162
|
+
export function replyOp(s, cardId, reply, actor) {
|
|
163
|
+
const card = s.replyToCard(cardId, reply, filer(s, actor));
|
|
164
|
+
actor.notify(card);
|
|
165
|
+
return {
|
|
166
|
+
lines: [
|
|
167
|
+
head("done", "filed ", id(`${cardId} r${card.meta.revision}`), ": your reply goes to the reviewer, and the card is open again"),
|
|
168
|
+
card.meta.level === "hard"
|
|
169
|
+
? next(`baesian wait ${cardId}`, "it's hard: don't commit work that cites it until this exits 0")
|
|
170
|
+
: indent(2, text("Until they answer again, proceed with their decision; ", cmd("baesian inbox"), " brings the new answer.")),
|
|
171
|
+
],
|
|
172
|
+
data: { card: cardId, revision: card.meta.revision, level: card.meta.level },
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** `baesian rule`: draft the rule the reviewer asked for, as a card (D110). */
|
|
176
|
+
export function ruleOp(s, from, rule, covers, actor) {
|
|
177
|
+
const card = s.draftRule(from, rule, covers, filer(s, actor));
|
|
178
|
+
actor.notify(card);
|
|
179
|
+
return { lines: [head("done", "filed ", id(card.meta.id), ": the rule goes to the reviewer as a card, and is saved once they accept it")], data: { card: card.meta.id, from } };
|
|
180
|
+
}
|
|
181
|
+
/** `baesian attach`: wait on an existing card instead of filing a duplicate. */
|
|
182
|
+
export function attachOp(s, cardId, agent) {
|
|
183
|
+
s.attach(cardId, agent);
|
|
184
|
+
const waiting = s.waiting(cardId);
|
|
185
|
+
return {
|
|
186
|
+
lines: [head("done", "attached to ", id(cardId), `; ${waiting === 1 ? "1 agent is" : `${waiting} agents are`} waiting on it`), next(`baesian wait ${cardId}`, "for its answer")],
|
|
187
|
+
data: { card: cardId, waiting },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** `baesian withdraw`: withdraw a card this agent filed or attached to. */
|
|
191
|
+
export function withdrawOp(s, cardId, reason, agent) {
|
|
192
|
+
return { lines: [head("done", "withdrew ", id(cardId))], data: s.withdraw(cardId, reason, agent) };
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------- waiting and the inbox
|
|
195
|
+
/** What to do after a wait, by its exit code (P18): the MCP tool's `next`. */
|
|
196
|
+
export const NEXT = { 0: "use", 4: "read", 5: "proceed", 6: "park", 7: "again", 8: "go" };
|
|
197
|
+
/**
|
|
198
|
+
* `baesian wait`: one run of at most `SLICE_MS` (D101, D168). An answer it gives is marked delivered, so `inbox` and
|
|
199
|
+
* the hooks don't hand it over again.
|
|
200
|
+
*/
|
|
201
|
+
export async function waitOp(s, cardId, opts) {
|
|
202
|
+
const outcome = await waitForVerdict(s, cardId, opts);
|
|
203
|
+
if (outcome.kind === "resolved" || outcome.kind === "answered")
|
|
204
|
+
s.markDelivered(cardId, outcome);
|
|
205
|
+
const exit = exitCodeFor(outcome);
|
|
206
|
+
return { lines: describeOutcome(outcome), data: { ...outcome, next: NEXT[exit], exit } };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* `baesian wait` on several cards, or a deck's (B-xnae9): one run, until every card is answered, or one comes back or is
|
|
210
|
+
* held back; or, without `untilAnswered`, until the run's slice or the wait's time is over. Each answer it gives is
|
|
211
|
+
* marked delivered; a run that ends with "again" gives none yet, so they all come when the wait ends.
|
|
212
|
+
*/
|
|
213
|
+
export async function waitAllOp(s, ids, opts) {
|
|
214
|
+
const outcome = await waitForAll(s, ids, opts);
|
|
215
|
+
if (outcome.ended !== "again")
|
|
216
|
+
for (const c of outcome.cards)
|
|
217
|
+
if (c.kind === "resolved" || c.kind === "answered")
|
|
218
|
+
s.markDelivered(c.id, c);
|
|
219
|
+
const exit = exitCodeForAll(outcome);
|
|
220
|
+
return { lines: describeAll(outcome), data: { ...outcome, next: NEXT[exit], exit } };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The cards `baesian wait --deck` waits on (B-xnae9): the deck's cards still with the reviewer (open, or rerouted to
|
|
224
|
+
* another reviewer) that this agent filed or attached to, on this branch (`Store.branchCards`). This agent's, not every
|
|
225
|
+
* card in the deck: an agent wakes for the answers it acts on. And on this branch, since agents share names (every
|
|
226
|
+
* Claude Code session is `claude-code` unless it's named), so the branch tells them apart, as `baesian inbox` does.
|
|
227
|
+
*/
|
|
228
|
+
export function deckWaitIds(s, deck, agent) {
|
|
229
|
+
s.requireInit();
|
|
230
|
+
if (!s.deck(deck))
|
|
231
|
+
throw new NoDeck(`no deck "${deck}"`);
|
|
232
|
+
return s.view(() => {
|
|
233
|
+
const attached = new Set(s.attachments().filter((a) => a.agent === agent).map((a) => a.card));
|
|
234
|
+
const mine = s.branchCards().filter((c) => c.meta.deck === deck && (c.meta.source_agent === agent || attached.has(c.meta.id)));
|
|
235
|
+
// In the order they were filed, which the agent knows them by; the ID only breaks a tie.
|
|
236
|
+
const ids = mine
|
|
237
|
+
.filter((c) => ["open", "rerouted"].includes(s.status(c.meta.id)))
|
|
238
|
+
.sort((x, y) => x.meta.created.localeCompare(y.meta.created) || x.meta.id.localeCompare(y.meta.id))
|
|
239
|
+
.map((c) => c.meta.id);
|
|
240
|
+
if (!ids.length) {
|
|
241
|
+
throw new NotAllowed(`deck "${deck}" has no cards of yours still with the reviewer (filed or attached to by ${agent}, on this branch): nothing to wait on`, [
|
|
242
|
+
{ command: "baesian inbox", why: "any answers you haven't seen" },
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
return ids;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** The headline over new verdicts: how many, on this branch's cards. */
|
|
249
|
+
export const verdictsHeadline = (n) => `${n === 1 ? "1 new verdict" : `${n} new verdicts`} on this branch's cards`;
|
|
250
|
+
/** `baesian inbox`: new verdicts on this branch's cards, less those `wait` delivered, inside one view. */
|
|
251
|
+
export function inboxOp(s, opts) {
|
|
252
|
+
return s.view(() => {
|
|
253
|
+
const entries = s.inbox(opts).map((item) => {
|
|
254
|
+
// Only the answer that's standing now: an earlier one, or one already replied to, takes no reply.
|
|
255
|
+
const latest = s.card(item.card);
|
|
256
|
+
const standing = item.verdict.card === item.card && latest !== undefined && item.verdict.revision === latest.meta.revision;
|
|
257
|
+
// A yes to a convention card asks for a rule as the toggle does (D161).
|
|
258
|
+
const asks = Boolean(item.verdict.make_rule) || (latest?.meta.convention !== undefined && item.verdict.response === "accept");
|
|
259
|
+
return { ...item, canReply: standing && s.canReply(item.card), ruleAsked: standing && asks && s.ruleAsked(item.card) };
|
|
260
|
+
});
|
|
261
|
+
if (!entries.length)
|
|
262
|
+
return { lines: [head("done", "no new verdicts")], data: entries };
|
|
263
|
+
const lines = [head("attention", verdictsHeadline(entries.length))];
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
// A dependent returned by its parent's verdict (D80) is listed under its own ID.
|
|
266
|
+
lines.push(...(e.verdict.card === e.card ? verdictLines(s, e.verdict) : [text(id(e.card), " ", attention("returned"), `, by a verdict on ${e.verdict.card}`)]));
|
|
267
|
+
if (e.brief)
|
|
268
|
+
lines.push(...describeBrief(e.brief));
|
|
269
|
+
if (e.ruleAsked)
|
|
270
|
+
lines.push(...indent(2, ruleHint(e.card)));
|
|
271
|
+
if (e.canReply)
|
|
272
|
+
lines.push(...indent(2, replyHint(e.card)));
|
|
273
|
+
}
|
|
274
|
+
return { lines, data: entries };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/** `baesian show`: the card as the client shows it, with who's waiting, its briefs and its verdicts. */
|
|
278
|
+
export function showOp(s, cardId) {
|
|
279
|
+
s.requireInit();
|
|
280
|
+
return s.view(() => {
|
|
281
|
+
const card = s.card(cardId);
|
|
282
|
+
if (!card)
|
|
283
|
+
throw new NoCard(cardId);
|
|
284
|
+
const m = card.meta;
|
|
285
|
+
const status = s.status(cardId);
|
|
286
|
+
const decision = s.decision(cardId);
|
|
287
|
+
const verdicts = s.verdicts(cardId);
|
|
288
|
+
// Only a card still with the reviewer has agents waiting on it: a marker a wait left when its run ended (exit 7) says
|
|
289
|
+
// nothing once the card is answered.
|
|
290
|
+
const waiting = PENDING.has(status) || status === "held" ? s.waitingOn(cardId).map(({ marker, stale }) => ({ agent: marker.agent, since: marker.since, at: marker.at, stale })) : [];
|
|
291
|
+
const briefs = s.briefs(cardId);
|
|
292
|
+
const gate = s.gateRecord(cardId);
|
|
293
|
+
const by = verdicts.filter((v) => v.revision === m.revision).at(-1);
|
|
294
|
+
const lines = [head(undefined, id(cardId), " ", statusPiece(status), " ", said("title", cardAuthor(m), m.title))];
|
|
295
|
+
lines.push(...fields([
|
|
296
|
+
m.topic !== undefined && { label: "topic", value: m.topic },
|
|
297
|
+
{ label: "deck", value: m.deck },
|
|
298
|
+
// The model its agent ran on, as the harness named it to the plugin (B-k0w7d).
|
|
299
|
+
m.source_model !== undefined && { label: "model", value: plain(m.source_model) },
|
|
300
|
+
gate ? levelField(gate, false) : { label: "level", value: level(m.level) },
|
|
301
|
+
decision !== undefined && { label: "decided", value: by ? `by ${byLine(reviewerOf(by))}: ${q(decision)}` : q(decision) },
|
|
302
|
+
...waiting.map((w) => ({ label: "waiting", value: `${agentLabel(w.agent)}, ${w.stale ? `stopped (last seen ${when(w.at)})` : `since ${when(w.since)}`}` })),
|
|
303
|
+
]));
|
|
304
|
+
lines.push(blank, ...cardDetails(card));
|
|
305
|
+
for (const b of briefs)
|
|
306
|
+
lines.push(...describeBrief(b));
|
|
307
|
+
if (verdicts.length)
|
|
308
|
+
lines.push(blank, text("verdicts:"), ...indent(2, verdicts.flatMap((v) => verdictLines(s, v))));
|
|
309
|
+
return { lines, data: { ...card, status, ...(decision !== undefined ? { decision } : {}), verdicts, waiting, briefs, ...(gate ? { gate } : {}) } };
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Counts by status, in a fixed order: "3 resolved, 1 returned to its agent, 1 withdrawn". */
|
|
313
|
+
function counts(statuses) {
|
|
314
|
+
const order = ["open", "rerouted", "held", "returned", "deferred", "resolved", "withdrawn", "superseded"];
|
|
315
|
+
const say = { returned: "returned to its agent", rerouted: "rerouted to another reviewer" };
|
|
316
|
+
return order
|
|
317
|
+
.map((st) => [st, statuses.filter((x) => x === st).length])
|
|
318
|
+
.filter(([, n]) => n > 0)
|
|
319
|
+
.map(([st, n]) => `${n} ${say[st] && n === 1 ? say[st] : st === "returned" && n > 1 ? "returned to their agents" : st}`)
|
|
320
|
+
.join(", ");
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* `baesian list`: the cards still with the reviewer, or all of them; this branch's, or every branch's. A headline with
|
|
324
|
+
* the counts, then a row a card, in the order they were filed. What every row shares, the author and the deck, is said
|
|
325
|
+
* once under the headline (B-gff98); the titles stay quoted, and their author named (D169).
|
|
326
|
+
*/
|
|
327
|
+
export function listOp(s, opts) {
|
|
328
|
+
s.requireInit();
|
|
329
|
+
// A card's status reads the others: read the live layer once (Store.view), not once per card.
|
|
330
|
+
return s.view(() => {
|
|
331
|
+
const cards = (opts.branch ? s.branchCards() : s.cards())
|
|
332
|
+
.filter((c) => opts.all || PENDING.has(s.status(c.meta.id)))
|
|
333
|
+
.sort((x, y) => x.meta.created.localeCompare(y.meta.created) || x.meta.id.localeCompare(y.meta.id));
|
|
334
|
+
const data = cards.map((c) => ({ ...c.meta, status: s.status(c.meta.id) }));
|
|
335
|
+
if (!cards.length)
|
|
336
|
+
return { lines: [head("done", opts.all ? "no cards" : "nothing waiting on the reviewer")], data };
|
|
337
|
+
const author = (c) => byLine(cardAuthor(c.meta));
|
|
338
|
+
const oneAuthor = new Set(cards.map(author)).size === 1;
|
|
339
|
+
const oneDeck = new Set(cards.map((c) => c.meta.deck)).size === 1;
|
|
340
|
+
const n = cards.length;
|
|
341
|
+
const what = opts.all ? `${n} card${n === 1 ? "" : "s"}` : `${n} card${n === 1 ? "" : "s"} still with the reviewer`;
|
|
342
|
+
const lines = [head(undefined, strong(what), ": ", counts(data.map((d) => d.status)))];
|
|
343
|
+
const shared = [oneAuthor ? `all filed by ${author(cards[0])}` : "", oneDeck ? `${oneAuthor ? "in" : "all in"} deck ${cards[0].meta.deck}` : ""].filter(Boolean).join(", ");
|
|
344
|
+
if (shared)
|
|
345
|
+
lines.push(indent(2, text(dim(shared))));
|
|
346
|
+
const header = ["id", "status", "level", "topic", ...(oneDeck ? [] : ["deck"]), ...(oneAuthor ? ["title"] : ["title, by its author"])];
|
|
347
|
+
const rows = cards.map((c, i) => [
|
|
348
|
+
id(c.meta.id),
|
|
349
|
+
statusPiece(data[i].status),
|
|
350
|
+
level(c.meta.level),
|
|
351
|
+
c.meta.topic ?? "",
|
|
352
|
+
...(oneDeck ? [] : [c.meta.deck]),
|
|
353
|
+
oneAuthor ? q(c.meta.title) : `by ${author(c)}: ${q(c.meta.title)}`,
|
|
354
|
+
]);
|
|
355
|
+
lines.push(...table(header, rows));
|
|
356
|
+
return { lines, data };
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/** `baesian topics`: the canonical topics in use. Topics are slugs (`Topic`), so they print as they are. */
|
|
360
|
+
export function topicsOp(s) {
|
|
361
|
+
s.requireInit();
|
|
362
|
+
const topics = [...s.view(() => s.topics())].sort();
|
|
363
|
+
if (!topics.length)
|
|
364
|
+
return { lines: [head(undefined, "no topics yet")], data: topics };
|
|
365
|
+
return {
|
|
366
|
+
lines: [head(undefined, `${topics.length} topic${topics.length === 1 ? "" : "s"} in use: reuse one only for the same question`), ...indent(2, topics.map((t) => text(t)))],
|
|
367
|
+
data: topics,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/** `baesian template`: the card draft to fill in, as it is, to save to a file. */
|
|
371
|
+
export function templateOp() {
|
|
372
|
+
return { lines: CARD_TEMPLATE.trimEnd().split("\n").map(literal), data: { template: CARD_TEMPLATE } };
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* An error as the CLI and the MCP server both say it, word for word: its headline and next steps (`errorLines`), and for
|
|
376
|
+
* a card that isn't there, where to see the ones that are.
|
|
377
|
+
*/
|
|
378
|
+
export function failureLines(e) {
|
|
379
|
+
return [...errorLines(e), ...(e instanceof NoCard ? [next("baesian list --all", "the cards in this repo")] : [])];
|
|
380
|
+
}
|
|
381
|
+
//# sourceMappingURL=ops.js.map
|
package/dist/passkey.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
2
|
+
// Passkey proofs (D139, D163): a WebAuthn assertion whose challenge is the SHA-256 digest of the file it proves (P16
|
|
3
|
+
// section 7), so the reviewer's own passkey signed that file's bytes. Or, when one tap confirms several answers (B-a6xpc),
|
|
4
|
+
// an assertion whose challenge is the digest of the list of their files' digests, which every proof of the batch holds,
|
|
5
|
+
// so the passkey signed each file's digest as part of that list. Checked offline against a key pinned in CI (D159).
|
|
6
|
+
// Checking an assertion needs no CBOR: only registration does, and that's the service's (M9c).
|
|
7
|
+
/** User present and user verified: the face, fingerprint or PIN the proof stands for (D139). */
|
|
8
|
+
const UP = 0x01;
|
|
9
|
+
const UV = 0x04;
|
|
10
|
+
const sha256 = (b) => createHash("sha256").update(b).digest();
|
|
11
|
+
/** The challenge a proof of `file` must carry: its bytes' SHA-256 digest, in base64url, as WebAuthn encodes a challenge. */
|
|
12
|
+
export function challengeOf(file) {
|
|
13
|
+
return sha256(file).toString("base64url");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* What a batch's passkey signs (B-a6xpc): a line naming it, then each file's SHA-256 digest in lowercase hex, one per line.
|
|
17
|
+
* Its first line keeps it apart from any file a proof could be for: no verdict or preference file reads so.
|
|
18
|
+
*/
|
|
19
|
+
export function batchMessage(digests) {
|
|
20
|
+
return Buffer.from(`baesian-batch\n${digests.map((d) => `${d}\n`).join("")}`);
|
|
21
|
+
}
|
|
22
|
+
/** The challenge a batch's proofs carry: the SHA-256 digest of `batchMessage()`, in base64url. */
|
|
23
|
+
export function batchChallenge(digests) {
|
|
24
|
+
return sha256(batchMessage(digests)).toString("base64url");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Why a passkey proof doesn't prove `file`, or undefined if it does. The proof must be an assertion (`webauthn.get`) for
|
|
28
|
+
* the service's origin and relying party, made with user verification, signed by `key`, whose challenge is the file's
|
|
29
|
+
* digest; or, in the batch form, whose challenge is the digest of its list, which must hold the file's digest. A proof
|
|
30
|
+
* made in a frame of another site doesn't count.
|
|
31
|
+
*/
|
|
32
|
+
export function proofProblem(proof, file, key, service) {
|
|
33
|
+
if (proof.credential !== key.credential)
|
|
34
|
+
return "it was made with another passkey";
|
|
35
|
+
if (proof.batch && !proof.batch.includes(sha256(file).toString("hex")))
|
|
36
|
+
return "its batch doesn't list the file's digest, so the passkey didn't confirm this file";
|
|
37
|
+
const clientDataJson = Buffer.from(proof.client_data, "base64url");
|
|
38
|
+
let client;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(clientDataJson.toString("utf8"));
|
|
41
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
42
|
+
return "its client data isn't an object";
|
|
43
|
+
client = parsed;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return "its client data isn't JSON";
|
|
47
|
+
}
|
|
48
|
+
if (client.type !== "webauthn.get")
|
|
49
|
+
return `it's a ${String(client.type)}, not an assertion (webauthn.get)`;
|
|
50
|
+
if (proof.batch) {
|
|
51
|
+
if (client.challenge !== batchChallenge(proof.batch))
|
|
52
|
+
return "its challenge isn't the digest of its batch";
|
|
53
|
+
}
|
|
54
|
+
else if (client.challenge !== challengeOf(file))
|
|
55
|
+
return "its challenge isn't the file's digest";
|
|
56
|
+
if (client.origin !== service.origin)
|
|
57
|
+
return `it was made on ${String(client.origin)}, not ${service.origin}`;
|
|
58
|
+
if (client.crossOrigin === true)
|
|
59
|
+
return "it was made in a frame of another site";
|
|
60
|
+
const auth = Buffer.from(proof.authenticator_data, "base64url");
|
|
61
|
+
if (auth.length < 37)
|
|
62
|
+
return "its authenticator data is too short";
|
|
63
|
+
if (!auth.subarray(0, 32).equals(sha256(service.rp_id)))
|
|
64
|
+
return `it's for another relying party than ${service.rp_id}`;
|
|
65
|
+
const flags = auth[32];
|
|
66
|
+
if (!(flags & UP))
|
|
67
|
+
return "the passkey didn't see the reviewer present";
|
|
68
|
+
if (!(flags & UV))
|
|
69
|
+
return "the passkey didn't verify the reviewer (no face, fingerprint or PIN)";
|
|
70
|
+
const signed = Buffer.concat([auth, sha256(clientDataJson)]);
|
|
71
|
+
const signature = Buffer.from(proof.signature, "base64url");
|
|
72
|
+
try {
|
|
73
|
+
const pub = createPublicKey({ key: Buffer.from(key.public_key, "base64url"), format: "der", type: "spki" });
|
|
74
|
+
const ok = key.alg === -7
|
|
75
|
+
? // WebAuthn's ECDSA signatures are DER, Node's default; workerd takes no key object inside an options object.
|
|
76
|
+
pub.asymmetricKeyType === "ec" && pub.asymmetricKeyDetails?.namedCurve === "prime256v1" && verify("sha256", signed, pub, signature)
|
|
77
|
+
: key.alg === -8
|
|
78
|
+
? pub.asymmetricKeyType === "ed25519" && verify(null, signed, pub, signature)
|
|
79
|
+
: pub.asymmetricKeyType === "rsa" && verify("sha256", signed, pub, signature);
|
|
80
|
+
return ok ? undefined : "its signature doesn't match the passkey's key";
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return `its signature can't be checked (${e instanceof Error ? e.message : String(e)})`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=passkey.js.map
|
package/dist/personal.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { git } from "./git.js";
|
|
5
|
+
import { RECORD_DIR } from "./store.js";
|
|
6
|
+
import { parseTrailers } from "./trailers.js";
|
|
7
|
+
// Personal mode (B-4rwyz): one person uses Baesian in a repo they share with collaborators who don't. Nothing of
|
|
8
|
+
// Baesian's is committed: the config is the clone's (`PERSONAL_CONFIG`, in the live layer), the live layer plays the
|
|
9
|
+
// record's part, the gate runs from hooks of the clone's own (src/hooks.ts, `installPersonalHooks()`), and AGENTS.md is
|
|
10
|
+
// left alone. This module holds what the hooks and `baesian init` need for it that isn't the hooks themselves: keeping
|
|
11
|
+
// the trailers out of shared history, and keeping a stray `.baesian/` out of commits.
|
|
12
|
+
// ---------------------------------------------------------------- trailers, kept out of shared history
|
|
13
|
+
/**
|
|
14
|
+
* The message with its `Baesian-Card` and `Baesian-Ack` trailers taken out (B-4rwyz), where git reads them as trailers:
|
|
15
|
+
* the lines of the message's last paragraph that are Baesian's, with any continuation lines, and the blank lines before
|
|
16
|
+
* that paragraph when nothing else was in it. Comment lines, and anything from a `--verbose` scissors line on, are left
|
|
17
|
+
* as they are: git takes them out itself. A message with no Baesian trailers is returned unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export function withoutBaesianTrailers(raw, cwd) {
|
|
20
|
+
const t = parseTrailers(raw, cwd);
|
|
21
|
+
if (!t.cards.length && !t.acks.size && !t.malformed.length)
|
|
22
|
+
return raw;
|
|
23
|
+
const lines = raw.split("\n");
|
|
24
|
+
const blank = (i) => !lines[i].trim();
|
|
25
|
+
// The message as git records it: indexes of the lines that aren't comments, up to a scissors line (`cleanMessage()`).
|
|
26
|
+
const kept = [];
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
if (/^# -+ >8 -+\r?$/.test(lines[i]))
|
|
29
|
+
break;
|
|
30
|
+
if (!lines[i].startsWith("#"))
|
|
31
|
+
kept.push(i);
|
|
32
|
+
}
|
|
33
|
+
let end = kept.length;
|
|
34
|
+
while (end > 0 && blank(kept[end - 1]))
|
|
35
|
+
end--;
|
|
36
|
+
let start = end;
|
|
37
|
+
while (start > 0 && !blank(kept[start - 1]))
|
|
38
|
+
start--;
|
|
39
|
+
const drop = new Set();
|
|
40
|
+
let ours = false;
|
|
41
|
+
for (let k = start; k < end; k++) {
|
|
42
|
+
const line = lines[kept[k]];
|
|
43
|
+
if (/^[ \t]/.test(line)) {
|
|
44
|
+
if (ours)
|
|
45
|
+
drop.add(kept[k]);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
ours = /^baesian-(card|ack)[ \t]*:/i.test(line);
|
|
49
|
+
if (ours)
|
|
50
|
+
drop.add(kept[k]);
|
|
51
|
+
}
|
|
52
|
+
if (!drop.size)
|
|
53
|
+
return raw;
|
|
54
|
+
if (kept.slice(start, end).every((i) => drop.has(i))) {
|
|
55
|
+
for (let k = start - 1; k >= 0 && blank(kept[k]); k--)
|
|
56
|
+
drop.add(kept[k]);
|
|
57
|
+
}
|
|
58
|
+
return lines.filter((_, i) => !drop.has(i)).join("\n");
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------- the cards a commit cites, from commit-msg to post-commit
|
|
61
|
+
/**
|
|
62
|
+
* Where `commit-msg` notes the cards a commit cites once it has taken their trailers out of the message, for
|
|
63
|
+
* `post-commit` to write the ledger from (D15): this worktree's own git directory, beside its index, as
|
|
64
|
+
* `baesian-staged.json` is. With the tree being committed, so a note left by a commit that failed after `commit-msg`
|
|
65
|
+
* is never taken for another's.
|
|
66
|
+
*/
|
|
67
|
+
function citedPath(top) {
|
|
68
|
+
return git(top, ["rev-parse", "--path-format=absolute", "--git-path", "baesian-cited.json"]);
|
|
69
|
+
}
|
|
70
|
+
/** Forget what an earlier commit attempt noted: `pre-commit` starts each commit afresh. */
|
|
71
|
+
export function clearCited(top) {
|
|
72
|
+
rmSync(citedPath(top), { force: true });
|
|
73
|
+
}
|
|
74
|
+
/** Note the cards the commit being made cites, with its tree, as the index holds it now (`commit-msg`). */
|
|
75
|
+
export function noteCited(top, cards) {
|
|
76
|
+
const path = citedPath(top);
|
|
77
|
+
if (!cards.length)
|
|
78
|
+
return rmSync(path, { force: true });
|
|
79
|
+
const tree = execFileSync("git", ["write-tree"], { cwd: top, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
80
|
+
writeFileSync(path, `${JSON.stringify({ tree, cards })}\n`);
|
|
81
|
+
}
|
|
82
|
+
/** The cards `commit-msg` noted for this commit, if its tree is the one noted; the note is gone after. */
|
|
83
|
+
export function takeCited(top, commit) {
|
|
84
|
+
const path = citedPath(top);
|
|
85
|
+
if (!existsSync(path))
|
|
86
|
+
return [];
|
|
87
|
+
try {
|
|
88
|
+
const noted = JSON.parse(readFileSync(path, "utf8"));
|
|
89
|
+
const tree = git(top, ["rev-parse", `${commit}^{tree}`]);
|
|
90
|
+
return noted.tree === tree && Array.isArray(noted.cards) ? noted.cards.filter((c) => typeof c === "string") : [];
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
rmSync(path, { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** A post-rewrite hook's input: each rewritten commit, and the commit it became. */
|
|
100
|
+
export function rewrites(input) {
|
|
101
|
+
const out = new Map();
|
|
102
|
+
for (const line of input.split("\n")) {
|
|
103
|
+
const [from, to] = line.trim().split(/\s+/);
|
|
104
|
+
if (from && to && /^[0-9a-f]{40,64}$/.test(from) && /^[0-9a-f]{40,64}$/.test(to))
|
|
105
|
+
out.set(from, to);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------- `.baesian/`, never in a personal clone's commits
|
|
110
|
+
const EXCLUDE_NOTE = "# Baesian, in personal mode (B-4rwyz): nothing of Baesian's is committed from this clone";
|
|
111
|
+
const EXCLUDE_LINE = `/${RECORD_DIR}/`;
|
|
112
|
+
/** The clone's own ignore file, `info/exclude` in git's common directory: never committed. */
|
|
113
|
+
function excludePath(store) {
|
|
114
|
+
return git(store.repo.toplevel, ["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"]);
|
|
115
|
+
}
|
|
116
|
+
/** Add `/.baesian/` to the clone's `info/exclude`, so a stray record is never committed. Returns whether it was added. */
|
|
117
|
+
export function excludeRecord(store) {
|
|
118
|
+
const path = excludePath(store);
|
|
119
|
+
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
120
|
+
if (text.split("\n").includes(EXCLUDE_LINE))
|
|
121
|
+
return false;
|
|
122
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
123
|
+
writeFileSync(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}${EXCLUDE_NOTE}\n${EXCLUDE_LINE}\n`);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/** Take out what `excludeRecord()` added, when the clone switches to shared mode. Returns whether it was there. */
|
|
127
|
+
export function includeRecord(store) {
|
|
128
|
+
const path = excludePath(store);
|
|
129
|
+
if (!existsSync(path))
|
|
130
|
+
return false;
|
|
131
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
132
|
+
const next = lines.filter((l) => l !== EXCLUDE_NOTE && l !== EXCLUDE_LINE);
|
|
133
|
+
if (next.length === lines.length)
|
|
134
|
+
return false;
|
|
135
|
+
writeFileSync(path, next.join("\n"));
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------- is the repo shared already?
|
|
139
|
+
/**
|
|
140
|
+
* Why a clone can't be set up in personal mode, or nothing (B-4rwyz): some branch of the repo has committed a record,
|
|
141
|
+
* so the repo uses Baesian in shared mode already; or this worktree has a `.baesian/`, which `baesian init` wrote for
|
|
142
|
+
* shared mode and nobody committed.
|
|
143
|
+
*/
|
|
144
|
+
export function sharedAlready(store) {
|
|
145
|
+
const commit = recordCommit(store);
|
|
146
|
+
if (commit) {
|
|
147
|
+
return `this repo uses Baesian in shared mode already: its record, ${RECORD_DIR}/, is committed (in ${commit}), so its decisions travel with the code. Personal mode is for a repo whose collaborators don't use Baesian: run \`baesian init\` here instead.`;
|
|
148
|
+
}
|
|
149
|
+
if (existsSync(join(store.repo.toplevel, RECORD_DIR))) {
|
|
150
|
+
return `this worktree has a ${RECORD_DIR}/ that isn't committed, which \`baesian init\` writes for shared mode. Personal mode writes nothing in the repo: delete ${RECORD_DIR}/ (and Baesian's block in the repo's hooks, and in AGENTS.md, if \`baesian init\` added them), then run \`baesian init --personal\` again.`;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
/** A commit on any branch that holds the record's config, by its short ID: then the repo is shared. */
|
|
155
|
+
export function recordCommit(store) {
|
|
156
|
+
try {
|
|
157
|
+
return git(store.repo.toplevel, ["log", "--all", "-1", "--format=%h", "--", `${RECORD_DIR}/config.json`]) || undefined;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return undefined; // no commits yet
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=personal.js.map
|