jacquardSnapshot

← snapshot

17006 bytes
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()}…`;
}