import { api } from "./api";
import type { DecisionDto, SnapshotDto, VerdictDto } from "./types";
/**
* Jackie — the interviewer, working the floor.
*
* This is unblock condition #2 from the architecture, standing outside the
* substrate: a trigger engine with a hard interruption budget, a significance
* classifier, a question taxonomy over `RationaleFamily`, and author-approved
* synthesis into `Decision` objects.
*
* She is not a tour guide. She shows you what is on the floor, tells you what
* she noticed about each of it, and lets you go wherever you like — including
* nowhere. She offers to record why; she does not corner you into it. The
* substrate's own words for this are "recognised, never policed", and an
* interviewer that railroads you is just a form with a voice.
*
* Honest scope:
*
* - **Jackie is scripted, not clever.** Her questions are the fixed
* five-family taxonomy below and her remarks are templates over real repo
* data. No model is called; nothing is uploaded. The intelligence in a
* jacquard decision is the author's answer, not the interviewer's question.
* - **Jackie is an agent and can never attest.** She drafts a rationale and
* proposes it; `proposed_by` says `agent` because it is true. Settling it
* takes a person's own words in a separate object, and no code path here
* mints an attestation — the constructor takes a `HumanIdentity`.
*/
/** How many times Jackie may offer to record, per place, before dropping it. */
export const INTERRUPTION_BUDGET = 4;
/** The taxonomy. One question per `RationaleFamily`, in the docs' own words. */
export const QUESTIONS: { family: string; ask: string; why: string }[] = [
{
family: "alternatives",
ask: "What else did you consider here, and what ruled it out?",
why: "Alternatives considered and what ruled them out.",
},
{
family: "constraints",
ask: "What constraint forced this shape?",
why: "The constraint that forced this shape.",
},
{
family: "missed-abstraction",
ask: "Was there an abstraction within reach that you skipped — deliberately or not?",
why: "An abstraction within reach that was skipped.",
},
{
family: "deferred-work",
ask: "What would another week have changed?",
why: "Known debt: what another week would have changed.",
},
{
family: "confidence-risk",
ask: "Which part of this are you least sure will hold up?",
why: "The part the author is least sure will hold up.",
},
];
export interface Significance {
score: number;
/** Above this, Jackie mentions it unprompted. Below, she waits to be asked. */
worthMentioning: boolean;
reasons: string[];
}
/**
* The significance classifier. Deliberately legible arithmetic rather than a
* model: an interviewer that cannot explain why it spoke up is one you learn
* to tune out.
*/
export function classify(input: {
changedPaths: string[];
provenance?: string;
governed: number;
unsettled: number;
}): Significance {
const reasons: string[] = [];
let score = 0;
if (input.changedPaths.length >= 3) {
score += 2;
reasons.push(`${input.changedPaths.length} paths changed`);
} else if (input.changedPaths.length > 0) {
score += 1;
reasons.push(`${input.changedPaths.length} path changed`);
}
if (input.provenance === "agent" || input.provenance === "mixed") {
score += 2;
reasons.push(`${input.provenance}-authored — rationale is least likely to exist`);
}
if (input.unsettled > 0) {
score += 3;
reasons.push(`${input.unsettled} governing decision(s) unsettled`);
}
if (input.governed === 0 && input.changedPaths.length > 0) {
score += 1;
reasons.push("no decision governs these paths");
}
return { score, worthMentioning: score >= 3, reasons };
}
/** Somewhere on the floor you can go and look at. */
export type Place =
| {
id: string;
kind: "gate";
label: string;
pitch: string;
say: string;
verdict: VerdictDto;
from: string;
into: string;
changed: string[];
}
| {
id: string;
kind: "file";
label: string;
pitch: string;
say: string;
path: string;
content: string;
truncated: boolean;
governedBy: DecisionDto[];
}
| {
id: string;
kind: "decision";
label: string;
pitch: string;
say: string;
decision: DecisionDto;
}
| {
id: string;
kind: "snapshot";
label: string;
pitch: string;
say: string;
snapshot: SnapshotDto;
}
| {
id: string;
kind: "cloth";
label: string;
pitch: string;
say: string;
entries: SnapshotDto[];
};
export interface Floor {
repo: string;
orgName: string;
/** Jackie's opening remark — what's here, in one breath. */
opening: string;
places: Place[];
significance: Significance;
/** Scope a drafted decision would govern, from the real diff. */
scope: string[];
changed: string[];
}
/** What Jackie drafts. A human still has to put their name to it. */
export interface DecisionDraft {
title: string;
rationale: string;
families: string[];
scope: string[];
answers: { family: string; ask: string; answer: string }[];
}
const MAX_FILE_CHARS = 1600;
/** Longest common directory prefix of the changed paths, as a scope. */
function scopeFrom(paths: string[]): string[] {
if (paths.length === 0) return [];
const split = paths.map((p) => p.split("/"));
const first = split[0] ?? [];
const prefix: string[] = [];
for (let i = 0; i < first.length - 1; i++) {
const seg = first[i];
if (split.every((p) => p[i] === seg)) prefix.push(seg as string);
else break;
}
return prefix.length > 0 ? [prefix.join("/")] : [];
}
function governedBy(path: string, decisions: DecisionDto[]): DecisionDto[] {
return decisions.filter((d) =>
d.scope.some((s) => path === s || path.startsWith(`${s}/`)),
);
}
/**
* Reads the repo and lays out what there is to see. Every line Jackie speaks
* comes from this data — she never narrates something she has not read.
*/
export async function buildFloor(repo: string): Promise<Floor> {
const detail = await api.repo(repo);
const [allDecisions, log] = await Promise.all([
api.decisions(repo).then((d) => d.decisions).catch(() => [] as DecisionDto[]),
api
.log(repo, detail.default_ref, 40)
.catch(() => ({ entries: [] as SnapshotDto[], ref: "", head: "" })),
]);
const unsettled = allDecisions.filter((d) => d.state === "unsettled");
const head = log.entries[0] ?? null;
// The most interesting un-promoted ref: a blocked one beats a clean one.
let gate: {
verdict: VerdictDto;
from: string;
into: string;
changed: string[];
} | null = null;
for (const from of detail.refs
.map((r) => r.name)
.filter((n) => n !== detail.default_ref)) {
try {
const preview = await api.promotePreview(repo, from, detail.default_ref);
if (preview.changed.length === 0) continue;
const candidate = {
verdict: preview.verdict,
from,
into: detail.default_ref,
changed: preview.changed,
};
if (!gate || preview.verdict.verdict === "blocked") gate = candidate;
if (preview.verdict.verdict === "blocked") break;
} catch {
/* unbound or non-fast-forward refs are not places */
}
}
const changed = gate?.changed ?? [];
const significance = classify({
changedPaths: changed,
provenance: head?.provenance,
governed: changed.filter((p) => governedBy(p, allDecisions).length > 0).length,
unsettled: unsettled.length,
});
const places: Place[] = [];
if (gate) {
const blocked = gate.verdict.verdict === "blocked";
const ungoverned =
gate.verdict.verdict === "admitted" && gate.verdict.ungoverned;
places.push({
id: "gate",
kind: "gate",
label: blocked ? "the blocked promotion" : "the gate",
pitch: blocked
? `There's work parked at the gate — ${gate.from} can't go into ${gate.into} yet.`
: ungoverned
? `${gate.from} would go straight into ${gate.into}, but nothing governs it.`
: `${gate.from} is clear to go into ${gate.into}.`,
say: blocked
? `${gate.from} into ${gate.into} is blocked. The blast radius touches a decision no human has settled. Nobody's being punished — the work just parks until someone puts their name to it.`
: ungoverned
? `${gate.from} into ${gate.into} would be admitted, but zero decisions govern these paths. That's an absence of governance, not a pass. Worth knowing before you promote it.`
: `${gate.from} into ${gate.into} is admitted. Every governing decision has been attested by a person.`,
verdict: gate.verdict,
from: gate.from,
into: gate.into,
changed: gate.changed,
});
}
// The files the change actually touched — read from the ref the change is
// ON, not from the target. A path added on a branch does not exist in the
// branch it is being promoted into, which is the whole point of a diff.
const sourceHead = gate
? (detail.refs.find((r) => r.name === gate.from)?.head ?? head?.id ?? null)
: (head?.id ?? null);
if (sourceHead && changed.length > 0) {
try {
const snap = await api.snapshot(repo, sourceHead);
for (const path of changed.slice(0, 4)) {
const file = snap.files.find((f) => f.path === path);
if (!file) continue;
const blob = await api.blob(repo, file.blob);
const content = blob.content ?? "";
const govs = governedBy(path, allDecisions);
const openUnsettled = govs.filter((d) => d.state === "unsettled");
places.push({
id: `file:${path}`,
kind: "file",
label: path.split("/").pop() ?? path,
pitch:
openUnsettled.length > 0
? `${path} — governed by a decision nobody has signed.`
: govs.length === 0
? `${path} — changed, and nothing governs it.`
: `${path} — governed, and settled.`,
say:
openUnsettled.length > 0
? `This is ${path}. An unsettled decision governs it, which is why the gate is holding the promotion.`
: govs.length === 0
? `This is ${path}. No decision declares anything about it, so the gate has nothing to check here.`
: `This is ${path}. It's governed, and someone has already put their name to that decision.`,
path,
content: content.slice(0, MAX_FILE_CHARS),
truncated: content.length > MAX_FILE_CHARS,
governedBy: govs,
});
}
} catch {
/* a file we cannot read is a place we don't offer */
}
}
for (const decision of unsettled.slice(0, 3)) {
const by = decision.proposed_by.display_name ?? decision.proposed_by.id;
places.push({
id: `decision:${decision.id}`,
kind: "decision",
label: `"${decision.title.length > 34 ? `${decision.title.slice(0, 34)}…` : decision.title}"`,
pitch: `A decision ${by} proposed is still waiting on a human.`,
say: `"${decision.title}". ${by} proposed it and it's unsettled — proposing isn't attesting, even for the proposer. If you've read the change, you're the one who can settle it.`,
decision,
});
}
if (head) {
const who = head.author.display_name ?? head.author.id;
places.push({
id: "head",
kind: "snapshot",
label: "the latest pick",
pitch: `The most recent snapshot, ${head.provenance}-authored.`,
say: `The last pick was "${head.message}", by ${who}, provenance ${head.provenance}. That label lives inside the content address — relabel it and you get a different snapshot, not an edited one.`,
snapshot: head,
});
}
if (log.entries.length > 0) {
places.push({
id: "cloth",
kind: "cloth",
label: "the cloth so far",
pitch: "The whole history, woven — you can see whose hands did what.",
say: `This is everything on ${detail.default_ref}, as cloth. Gold is human, indigo is agent, and where they alternate the work was mixed. It's generated from the provenance in the addresses — nothing decorative about it.`,
entries: log.entries,
});
}
const opening =
`${detail.org.name}. ` +
(unsettled.length > 0
? `${unsettled.length} decision${unsettled.length === 1 ? "" : "s"} waiting on a human, `
: "Nothing waiting on a human, ") +
`${log.entries.length} snapshot${log.entries.length === 1 ? "" : "s"} on ${detail.default_ref}. ` +
`Have a look at whatever you like — I'll tell you what I noticed.`;
return {
repo,
orgName: detail.org.name,
opening,
places,
significance,
scope: scopeFrom(changed),
changed,
};
}
/**
* Which questions Jackie would ask about a given place, if you say yes.
* Bounded by the interruption budget — she asks a few good ones, not all five.
*/
export function questionsFor(place: Place, significance: Significance) {
const order: string[] = [];
if (place.kind === "file") {
const unsettledHere = place.governedBy.some((d) => d.state === "unsettled");
if (unsettledHere) order.push("constraints", "confidence-risk");
else order.push("constraints", "alternatives");
order.push("missed-abstraction");
} else if (place.kind === "gate") {
order.push("constraints", "confidence-risk", "deferred-work");
} else {
order.push("alternatives", "constraints", "confidence-risk");
}
order.push("deferred-work", "confidence-risk", "alternatives");
const budget = significance.worthMentioning ? INTERRUPTION_BUDGET : 2;
const seen = new Set<string>();
const out: { family: string; ask: string }[] = [];
for (const family of order) {
if (seen.has(family) || out.length >= budget) continue;
const q = QUESTIONS.find((x) => x.family === family);
if (!q) continue;
seen.add(family);
out.push({ family: q.family, ask: q.ask });
}
return out;
}
/**
* Match what someone said against the places on offer. Deliberately dumb
* keyword overlap — when it misses, the buttons are still right there, and a
* wrong guess that navigates you somewhere is worse than no guess.
*/
export function matchSpoken(said: string, places: Place[]): Place | null {
const text = said.toLowerCase().trim();
if (!text) return null;
let best: { place: Place; score: number } | null = null;
for (const place of places) {
const hay = `${place.label} ${place.kind} ${place.pitch}`.toLowerCase();
let score = 0;
for (const word of text.split(/\W+/).filter((w) => w.length > 3)) {
if (hay.includes(word)) score += 1;
}
if (place.kind === "gate" && /gate|block|promot/.test(text)) score += 3;
if (place.kind === "cloth" && /cloth|weave|histor|graph/.test(text)) score += 3;
if (place.kind === "decision" && /decision|waiting|sign|attest/.test(text)) score += 2;
if (place.kind === "file" && /file|code|read|source/.test(text)) score += 2;
if (score > 0 && (!best || score > best.score)) best = { place, score };
}
return best && best.score >= 2 ? best.place : null;
}
/**
* Synthesis. Jackie stitches the answers into a rationale — quoting the
* author rather than paraphrasing, so what she drafts stays traceable to
* what was actually said.
*/
export function synthesize(
answers: { family: string; ask: string; answer: string }[],
context: { scope: string[]; changed: string[]; subject?: string },
): DecisionDraft {
const kept = answers.filter((a) => a.answer.trim().length > 0);
const families = [...new Set(kept.map((a) => a.family))];
const subject =
context.subject ??
context.scope[0] ??
context.changed[0]?.split("/").slice(0, -1).join("/") ??
"this change";
const first = kept[0]?.answer.trim() ?? "";
const title =
first.length > 0
? `${subject}: ${shorten(first, 58)}`
: `${subject}: rationale recorded in interview`;
const body = kept
.map((a) => {
const q = QUESTIONS.find((x) => x.family === a.family);
return `${q?.why ?? a.family}\n Asked: ${a.ask}\n Author said: "${a.answer.trim()}"`;
})
.join("\n\n");
const rationale =
kept.length > 0
? `${body}\n\n— Drafted by Jackie from a spoken interview about ${subject}. ` +
`This is machine synthesis of the author's answers; it is not an attestation.`
: `No answers were given. Drafted by Jackie as an empty placeholder — there is nothing here a human should sign.`;
return {
title: shorten(title, 92),
rationale,
families,
scope: context.scope,
answers: kept,
};
}
function shorten(s: string, n: number): string {
const clean = s.replace(/\s+/g, " ").trim();
return clean.length <= n ? clean : `${clean.slice(0, n - 1).trimEnd()}…`;
}