@mrciphersmith/keryx 0.3.28 → 0.3.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -623,9 +623,12 @@ Grouped by what you are trying to do, not by internal module layout.
623
623
  rounds-with-cost/rounds-total count for partial coverage), project-wide
624
624
  trigger spend (never attributed to a flow — the run record carries no flow
625
625
  reference), who confirmed each acceptance criterion and who signed
626
- completion (with identity basis), and every `flow complete` attempt's gate
627
- outcomes. A figure nobody recorded is reported as "not recorded", never as
628
- zero. Writes `.metaproject/data/governance/artifacts/latest.{md,json}`, the
626
+ completion (with identity basis), every `flow complete` attempt's gate
627
+ outcomes, and each flow's acceptance coverage — how many criteria carry a
628
+ runnable `[verify: exec ...]` or `[verify: invariant ...]` marker (a flow
629
+ with no recorded kinds counts as fully unclassified; see `keryx flow ac kinds`
630
+ in the [CLI reference](docs/docs/cli-reference.md#verification-kinds)). A
631
+ figure nobody recorded is reported as "not recorded", never as zero. Writes `.metaproject/data/governance/artifacts/latest.{md,json}`, the
629
632
  same convention `keryx health run` uses; `--all-projects` also covers every
630
633
  project in the user-global registry. In the TUI, the sidebar's
631
634
  **Governance** row shows `no report — click to run`,
package/dist/cli.js CHANGED
@@ -13447,7 +13447,7 @@ var package_default;
13447
13447
  var init_package = __esm(() => {
13448
13448
  package_default = {
13449
13449
  name: "@mrciphersmith/keryx",
13450
- version: "0.3.28",
13450
+ version: "0.3.29",
13451
13451
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
13452
13452
  private: false,
13453
13453
  publishConfig: {
@@ -49602,6 +49602,162 @@ var init_confirm_token = __esm(() => {
49602
49602
  CONFIRMATION_CAVEAT = "The token proves an interactive confirmation step ran outside the agent's tool roster, within its TTL, " + "for this criteria checksum \u2014 not that a human ran it, and not who.";
49603
49603
  });
49604
49604
 
49605
+ // src/flow/ac-kinds.ts
49606
+ function maskCodeSpans(text) {
49607
+ return text.replace(/`[^`]*`/g, (span) => " ".repeat(span.length));
49608
+ }
49609
+ function scanMarkers(text) {
49610
+ const masked = maskCodeSpans(text);
49611
+ let occurrences = 0;
49612
+ let lastStart = -1;
49613
+ for (let at = masked.indexOf(MARKER_OPEN);at !== -1; at = masked.indexOf(MARKER_OPEN, at + MARKER_OPEN.length)) {
49614
+ occurrences += 1;
49615
+ lastStart = at;
49616
+ }
49617
+ const end = text.trimEnd().length;
49618
+ return { occurrences, lastStart, trailing: occurrences > 0 && end > lastStart && text[end - 1] === "]", end };
49619
+ }
49620
+ function stripVerifyMarker(text) {
49621
+ const scan = scanMarkers(text);
49622
+ if (!scan.trailing || scan.occurrences !== 1)
49623
+ return text;
49624
+ return text.slice(0, scan.lastStart).trimEnd();
49625
+ }
49626
+ function unclassified(error) {
49627
+ return error === undefined ? { record: { kind: "unclassified" } } : { record: { kind: "unclassified" }, error };
49628
+ }
49629
+ function parseAcKindText(text) {
49630
+ const scan = scanMarkers(text);
49631
+ if (scan.occurrences === 0)
49632
+ return unclassified();
49633
+ if (scan.occurrences > 1) {
49634
+ return unclassified("a second [verify: \u2026] marker \u2014 a criterion carries exactly one, as the last thing on its line");
49635
+ }
49636
+ if (!scan.trailing) {
49637
+ return unclassified("the [verify: \u2026] marker must be the last thing on the line and close with `]`");
49638
+ }
49639
+ const payload = text.slice(scan.lastStart + MARKER_OPEN.length, scan.end - 1).trim();
49640
+ if (payload.length === 0)
49641
+ return unclassified(`an empty [verify: \u2026] marker \u2014 expected ${KIND_WORDS}`);
49642
+ const word = /^[A-Za-z]+/.exec(payload)?.[0] ?? "";
49643
+ const rest = payload.slice(word.length).trim();
49644
+ switch (word) {
49645
+ case "judged":
49646
+ return rest.length === 0 ? { record: { kind: "judged" } } : unclassified("`judged` takes no arguments \u2014 write [verify: judged]");
49647
+ case "exec":
49648
+ case "invariant": {
49649
+ if (rest.length === 0)
49650
+ return unclassified(`\`${word}\` requires a backticked command, e.g. [verify: ${word} \`bun test path/to.test.ts\`]`);
49651
+ const command = /^`([^`]*)`$/.exec(rest);
49652
+ if (command === null)
49653
+ return unclassified(`\`${word}\` requires exactly one backticked command after the kind, e.g. [verify: ${word} \`bun test path/to.test.ts\`]`);
49654
+ const check = (command[1] ?? "").trim();
49655
+ if (check.length === 0)
49656
+ return unclassified(`\`${word}\` has an empty backticked command`);
49657
+ return { record: { kind: word, check } };
49658
+ }
49659
+ case "none": {
49660
+ if (rest.length === 0)
49661
+ return unclassified("`none` requires a reason after an em dash, e.g. [verify: none \u2014 <reason>]");
49662
+ const reason = /^\u2014\s*([\s\S]*)$/.exec(rest);
49663
+ if (reason === null)
49664
+ return unclassified("`none` requires its reason after an em dash (\u2014), e.g. [verify: none \u2014 <reason>]");
49665
+ const why = (reason[1] ?? "").trim();
49666
+ if (why.length === 0)
49667
+ return unclassified("`none` has an empty reason after the em dash");
49668
+ return { record: { kind: "none", reason: why } };
49669
+ }
49670
+ default:
49671
+ return unclassified(`unknown verification kind "${word.length > 0 ? word : payload}" \u2014 expected ${KIND_WORDS}`);
49672
+ }
49673
+ }
49674
+ function parseAcKinds(content) {
49675
+ const criteria = [];
49676
+ const errors = [];
49677
+ for (const line of content.split(/\r?\n/)) {
49678
+ const match = AC_LINE.exec(line);
49679
+ if (!match?.[1])
49680
+ continue;
49681
+ const id = match[1].toUpperCase();
49682
+ const rawText = (match[2] ?? "").trim();
49683
+ const parsed = parseAcKindText(rawText);
49684
+ if (parsed.error !== undefined)
49685
+ errors.push({ id, message: parsed.error });
49686
+ criteria.push({ id, text: stripVerifyMarker(rawText), rawText, record: parsed.record });
49687
+ }
49688
+ return { criteria, errors };
49689
+ }
49690
+ function emptyCounts() {
49691
+ return { exec: 0, invariant: 0, judged: 0, none: 0, unclassified: 0 };
49692
+ }
49693
+ function buildAcKindReport(flowId, content) {
49694
+ const parsed = parseAcKinds(content);
49695
+ const criteria = {};
49696
+ for (const criterion of parsed.criteria) {
49697
+ if (!(criterion.id in criteria))
49698
+ criteria[criterion.id] = criterion.record;
49699
+ }
49700
+ return { report: reportFromRecords(flowId, criteria), errors: parsed.errors };
49701
+ }
49702
+ function reportFromRecords(flowId, criteria) {
49703
+ const counts = emptyCounts();
49704
+ for (const record of Object.values(criteria))
49705
+ counts[record.kind] += 1;
49706
+ return {
49707
+ flowId,
49708
+ criteria,
49709
+ counts,
49710
+ runnable: counts.exec + counts.invariant,
49711
+ total: Object.keys(criteria).length
49712
+ };
49713
+ }
49714
+ function renderAcKindDistribution(report) {
49715
+ const { counts } = report;
49716
+ const percent = report.total === 0 ? 0 : Math.round(report.runnable / report.total * 100);
49717
+ const tail = [`${counts.none} accepted as unverifiable`, `${counts.judged} judged`, ...counts.unclassified > 0 ? [`${counts.unclassified} unclassified`] : []];
49718
+ return [
49719
+ `acceptance kinds: exec ${counts.exec} invariant ${counts.invariant} judged ${counts.judged} none ${counts.none} unclassified ${counts.unclassified} (${report.total} criteria)`,
49720
+ `coverage: ${report.runnable}/${report.total} runnable (${percent}%) \xB7 ${tail.join(" \xB7 ")}`
49721
+ ];
49722
+ }
49723
+ function describeAcKind(record) {
49724
+ switch (record.kind) {
49725
+ case "exec":
49726
+ case "invariant":
49727
+ return `${record.kind} \`${record.check}\``;
49728
+ case "none":
49729
+ return `none \u2014 ${record.reason}`;
49730
+ default:
49731
+ return record.kind;
49732
+ }
49733
+ }
49734
+ function readAcKindRecords(value) {
49735
+ if (typeof value !== "object" || value === null || Array.isArray(value))
49736
+ return;
49737
+ const out = {};
49738
+ for (const [id, raw] of Object.entries(value)) {
49739
+ if (typeof raw !== "object" || raw === null)
49740
+ return;
49741
+ const record = raw;
49742
+ const kind = record["kind"];
49743
+ if ((kind === "exec" || kind === "invariant") && typeof record["check"] === "string")
49744
+ out[id] = { kind, check: record["check"] };
49745
+ else if (kind === "judged")
49746
+ out[id] = { kind };
49747
+ else if (kind === "none" && typeof record["reason"] === "string")
49748
+ out[id] = { kind, reason: record["reason"] };
49749
+ else if (kind === "unclassified")
49750
+ out[id] = { kind };
49751
+ else
49752
+ return;
49753
+ }
49754
+ return out;
49755
+ }
49756
+ var AC_LINE, MARKER_OPEN = "[verify:", KIND_WORDS = "exec, invariant, judged or none";
49757
+ var init_ac_kinds = __esm(() => {
49758
+ AC_LINE = /^\s*[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
49759
+ });
49760
+
49605
49761
  // src/review/cost.ts
49606
49762
  function estimateTokens2(text) {
49607
49763
  return Math.ceil(text.length / 4);
@@ -49676,7 +49832,7 @@ import path120 from "path";
49676
49832
  function parseAcceptanceCriteria(content) {
49677
49833
  const out = [];
49678
49834
  for (const line of content.split(/\r?\n/)) {
49679
- const match = AC_LINE.exec(line);
49835
+ const match = AC_LINE2.exec(line);
49680
49836
  if (match?.[1]) {
49681
49837
  out.push({ id: match[1].toUpperCase(), text: (match[2] ?? "").trim() });
49682
49838
  }
@@ -49715,6 +49871,9 @@ function classifyNotCheckable(criterionText) {
49715
49871
  reason: `not-checkable: every clause of this criterion names a condition no diff records (${labels.join(", ")}) \u2014 assigned without asking Jev.`
49716
49872
  };
49717
49873
  }
49874
+ function classifyAcCriterionNotCheckable(criterion) {
49875
+ return classifyNotCheckable(stripVerifyMarker(criterion.text));
49876
+ }
49718
49877
  function extractCriterionTokens(text) {
49719
49878
  const tokens = new Set;
49720
49879
  for (const match of text.matchAll(/`([^`]+)`/g)) {
@@ -49732,7 +49891,7 @@ function extractCriterionTokens(text) {
49732
49891
  return [...tokens];
49733
49892
  }
49734
49893
  function computeAcFacts(criterion, diffText, changedFiles) {
49735
- const tokens = extractCriterionTokens(criterion.text);
49894
+ const tokens = extractCriterionTokens(stripVerifyMarker(criterion.text));
49736
49895
  const tokensPresent = tokens.filter((token) => diffText.includes(token) || changedFiles.some((file) => file.includes(token)));
49737
49896
  const tokensAbsent = tokens.filter((token) => !tokensPresent.includes(token));
49738
49897
  const testFiles = changedFiles.filter((file) => /\.(test|spec)\.[a-z]+$/i.test(file));
@@ -49757,7 +49916,7 @@ function computeAcFacts(criterion, diffText, changedFiles) {
49757
49916
  function questionFor(criterion) {
49758
49917
  return {
49759
49918
  type: "noul",
49760
- instructions: `Given the deterministic facts and the redacted diff evidence above, does this change LIKELY SATISFY this frozen ` + `acceptance criterion? Criterion ${criterion.id}: ${redactSensitiveText(criterion.text)}`
49919
+ instructions: `Given the deterministic facts and the redacted diff evidence above, does this change LIKELY SATISFY this frozen ` + `acceptance criterion? Criterion ${criterion.id}: ${redactSensitiveText(stripVerifyMarker(criterion.text))}`
49761
49920
  };
49762
49921
  }
49763
49922
  function selectMatchedHunks(facts, regions) {
@@ -49918,12 +50077,13 @@ function renderAcCheckAdvisoryNotice(verdicts) {
49918
50077
  const inDoubt = verdicts.filter((v) => v.status === "not-evident").map((v) => v.id);
49919
50078
  return `acceptance-criteria check (advisory): ${counts.likelyMet} likely met, ${counts.notEvident} not evident, ${counts.notCheckable} not checkable` + (inDoubt.length > 0 ? ` \u2014 in doubt: ${inDoubt.join(", ")}` : "") + ` (\`keryx flow check-ac\` for detail)`;
49920
50079
  }
49921
- var AC_LINE, NOT_CHECKABLE_MARKERS, AC_CHECK_TOKEN_BUDGET = 64000, MAX_MATCHED_HUNKS_PER_ITEM = 8, BATCH_BUDGET_FRACTION = 0.5, MAX_ITEMS_PER_BATCH = 6, DEFAULT_AC_CHECK_THRESHOLD = 0.5, STATUS_LABEL;
50080
+ var AC_LINE2, NOT_CHECKABLE_MARKERS, AC_CHECK_TOKEN_BUDGET = 64000, MAX_MATCHED_HUNKS_PER_ITEM = 8, BATCH_BUDGET_FRACTION = 0.5, MAX_ITEMS_PER_BATCH = 6, DEFAULT_AC_CHECK_THRESHOLD = 0.5, STATUS_LABEL;
49922
50081
  var init_check_ac = __esm(() => {
49923
50082
  init_service2();
49924
50083
  init_fs();
49925
50084
  init_review_gate();
49926
- AC_LINE = /^\s*[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
50085
+ init_ac_kinds();
50086
+ AC_LINE2 = /^\s*[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
49927
50087
  NOT_CHECKABLE_MARKERS = [
49928
50088
  { label: "live check", re: /\blive[- ]?check\b|\brun(?:s|ning)? with\b.*\benv\b|\blive[- ]?run\b/i },
49929
50089
  { label: "CI green", re: /\bCI (?:is )?green\b|\bCI passes\b/i },
@@ -50385,6 +50545,10 @@ var init_templates3 = __esm(() => {
50385
50545
  // src/flow/service.ts
50386
50546
  import { mkdir as mkdir30, readFile as readFile58 } from "fs/promises";
50387
50547
  import path122 from "path";
50548
+ async function deriveAcKinds(cwd, dir, flowId) {
50549
+ const { report } = buildAcKindReport(flowId, await readFile58(acPath(cwd, dir), "utf8"));
50550
+ return { ...report.criteria };
50551
+ }
50388
50552
  function recordAttempt(task, outcome, at, detail) {
50389
50553
  const attempts = task.attempts ?? { count: 0, log: [] };
50390
50554
  attempts.count += 1;
@@ -50579,6 +50743,7 @@ function createFlowService(deps) {
50579
50743
  throw new Error("Cannot freeze: acceptance-criteria.md must contain at least one real `- ACn:` criterion.");
50580
50744
  }
50581
50745
  flow.acChecksum = await acChecksum(cwd, dir);
50746
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
50582
50747
  flow.status = "ready";
50583
50748
  return save(cwd, dir, flow, "frozen", `${criteria.length} criteria; checksum recorded`);
50584
50749
  });
@@ -50723,10 +50888,15 @@ function createFlowService(deps) {
50723
50888
  detail = `${normalizedCriterion}: "${previousText ?? "(new)"}" -> "${normalizedText}" (${reason.trim()})`;
50724
50889
  }
50725
50890
  flow.acChecksum = await acChecksum(cwd, dir);
50891
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
50726
50892
  flow.acConfirmed = {};
50727
50893
  return save(cwd, dir, flow, "ac-updated", detail);
50728
50894
  });
50729
50895
  },
50896
+ async acKinds({ cwd, id }) {
50897
+ const { dir, flow } = await load(cwd, id);
50898
+ return buildAcKindReport(flow.id, await readFile58(acPath(cwd, dir), "utf8"));
50899
+ },
50730
50900
  async acReseal({ cwd, id, reason }) {
50731
50901
  if (!reason?.trim()) {
50732
50902
  throw new Error('flow ac reseal requires --reason "<why the checksum is stale>"');
@@ -50745,6 +50915,7 @@ function createFlowService(deps) {
50745
50915
  throw new Error(`flow ac reseal: refusing \u2014 ${evidence.reason}. ` + "Reseal only re-seals a stale checksum over an unchanged file; it is not a way to " + 'accept an edit. If the criteria really changed, use `keryx flow ac update <id> --reason "..."`, ' + "which re-seals AND voids the prior confirmations.");
50746
50916
  }
50747
50917
  flow.acChecksum = current;
50918
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
50748
50919
  return save(cwd, dir, flow, "ac-resealed", reason);
50749
50920
  });
50750
50921
  },
@@ -51418,6 +51589,8 @@ var init_service9 = __esm(() => {
51418
51589
  init_confirm_token();
51419
51590
  init_confirm_token();
51420
51591
  init_store3();
51592
+ init_ac_kinds();
51593
+ init_ac_kinds();
51421
51594
  init_review_gate();
51422
51595
  init_check_ac();
51423
51596
  init_store3();
@@ -94059,6 +94232,25 @@ var init_modal_host = __esm(() => {
94059
94232
  hosts = new WeakMap;
94060
94233
  });
94061
94234
 
94235
+ // src/tui/ac-kinds-surface.ts
94236
+ function formatAcKindLines(item) {
94237
+ if (item.acKinds === undefined) {
94238
+ if (item.acFrozen === false) {
94239
+ return ["Verification kinds: not frozen yet (kinds are read when the criteria are frozen)."];
94240
+ }
94241
+ return [
94242
+ "Verification kinds: not recorded (this flow was frozen before kinds existed; every criterion reads unclassified)."
94243
+ ];
94244
+ }
94245
+ const report = reportFromRecords(item.id, item.acKinds);
94246
+ const rows = Object.entries(report.criteria).map(([id, record]) => `${id.padEnd(ID_WIDTH)}${describeAcKind(record)}`);
94247
+ return [...renderAcKindDistribution(report), "", ...rows.length === 0 ? ["(no criteria)"] : rows];
94248
+ }
94249
+ var ID_WIDTH = 5;
94250
+ var init_ac_kinds_surface = __esm(() => {
94251
+ init_service9();
94252
+ });
94253
+
94062
94254
  // src/review/scope.ts
94063
94255
  function classifyPath2(path, config = DEFAULT_REVIEW_SCOPE_CONFIG) {
94064
94256
  const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
@@ -94333,7 +94525,7 @@ function isCommentOnly(hunk, block, path) {
94333
94525
  }
94334
94526
  return sawComment;
94335
94527
  }
94336
- function emptyCounts() {
94528
+ function emptyCounts2() {
94337
94529
  const droppedByReason = Object.fromEntries(SCOPE_DROP_REASONS.map((reason) => [reason, 0]));
94338
94530
  return {
94339
94531
  filesSeen: 0,
@@ -94376,7 +94568,7 @@ function windowsFor(blocks, contextLines) {
94376
94568
  }
94377
94569
  function buildReviewScope(diff, config) {
94378
94570
  const resolved = resolveConfig(config);
94379
- const counts = emptyCounts();
94571
+ const counts = emptyCounts2();
94380
94572
  const drops = [];
94381
94573
  const regions = [];
94382
94574
  const files = [];
@@ -94488,7 +94680,7 @@ function buildReviewScope(diff, config) {
94488
94680
  }
94489
94681
  function buildPathScope(paths, config) {
94490
94682
  const resolved = resolveConfig(config);
94491
- const counts = emptyCounts();
94683
+ const counts = emptyCounts2();
94492
94684
  const drops = [];
94493
94685
  const files = [];
94494
94686
  for (const path of paths) {
@@ -95192,7 +95384,7 @@ async function runCheckAc(cwd, id, opts = {}, deps = {}) {
95192
95384
  const changedFiles = scope.files;
95193
95385
  const notCheckable = [];
95194
95386
  const checkableCriteria = criteria.filter((criterion) => {
95195
- const classification = classifyNotCheckable(criterion.text);
95387
+ const classification = classifyAcCriterionNotCheckable(criterion);
95196
95388
  if (classification === undefined)
95197
95389
  return true;
95198
95390
  notCheckable.push(notCheckableVerdict(criterion, classification.reason));
@@ -95992,6 +96184,11 @@ async function loadInspectorFlows(cwd) {
95992
96184
  if (await isCompletionInterrupted(cwd, flow, dir)) {
95993
96185
  item.interrupted = interruptedCompletionLine(flow.id);
95994
96186
  }
96187
+ const kinds = readAcKindRecords(flow.acKinds);
96188
+ item.acFrozen = flow.acChecksum !== null;
96189
+ if (kinds !== undefined) {
96190
+ item.acKinds = kinds;
96191
+ }
95995
96192
  try {
95996
96193
  const cache = await readAcCheckCache(acCheckCachePath(cwd, dir));
95997
96194
  if (cache !== undefined) {
@@ -96209,6 +96406,7 @@ function presentFlows(openModal, otui, chrome, options) {
96209
96406
  let selected = 0;
96210
96407
  let listScroll = 0;
96211
96408
  let detailScroll = 0;
96409
+ let acScroll = 0;
96212
96410
  let listNode;
96213
96411
  let detailNode;
96214
96412
  let acNode;
@@ -96231,7 +96429,7 @@ function presentFlows(openModal, otui, chrome, options) {
96231
96429
  if (checking) {
96232
96430
  return ["Checking\u2026", "", "Running `keryx flow check-ac` against the frozen criteria \u2014 this can take a few seconds."];
96233
96431
  }
96234
- const raw = item !== undefined ? formatAcCheckLines(item) : ["No flow selected."];
96432
+ const raw = item !== undefined ? [...formatAcKindLines(item), "", ...formatAcCheckLines(item)] : ["No flow selected."];
96235
96433
  const withError = checkError !== undefined ? [...raw, "", `Error: ${checkError}`] : raw;
96236
96434
  return wrapLines(withError.join(`
96237
96435
  `), tabWidth).split(`
@@ -96250,7 +96448,8 @@ function presentFlows(openModal, otui, chrome, options) {
96250
96448
  `);
96251
96449
  }
96252
96450
  if (acNode !== undefined) {
96253
- acNode.content = windowLines(acLines(), 0, bodyRows).join(`
96451
+ acScroll = clampScroll(acScroll, acLines().length, bodyRows);
96452
+ acNode.content = windowLines(acLines(), acScroll, bodyRows).join(`
96254
96453
  `);
96255
96454
  }
96256
96455
  };
@@ -96264,6 +96463,7 @@ function presentFlows(openModal, otui, chrome, options) {
96264
96463
  }
96265
96464
  selected = clamped;
96266
96465
  detailScroll = 0;
96466
+ acScroll = 0;
96267
96467
  paintSelection();
96268
96468
  };
96269
96469
  const handle = openModal(otui, chrome, {
@@ -96284,7 +96484,8 @@ function presentFlows(openModal, otui, chrome, options) {
96284
96484
  return;
96285
96485
  }
96286
96486
  if (tabId === "ac") {
96287
- acNode = paintLines(otui, renderer, body, windowLines(acLines(), 0, bodyRows));
96487
+ acScroll = clampScroll(acScroll, acLines().length, bodyRows);
96488
+ acNode = paintLines(otui, renderer, body, windowLines(acLines(), acScroll, bodyRows));
96288
96489
  return;
96289
96490
  }
96290
96491
  detailScroll = clampScroll(detailScroll, detailLines().length, bodyRows);
@@ -96354,6 +96555,12 @@ function presentFlows(openModal, otui, chrome, options) {
96354
96555
  }
96355
96556
  return;
96356
96557
  }
96558
+ if (handle.activeTab() === "ac" && (token === "pageup" || token === "pagedown")) {
96559
+ const step = token === "pageup" ? -bodyRows : bodyRows;
96560
+ acScroll = clampScroll(acScroll + step, acLines().length, bodyRows);
96561
+ paintSelection();
96562
+ return;
96563
+ }
96357
96564
  if (onDetail && (token === "pageup" || token === "pagedown")) {
96358
96565
  const step = token === "pageup" ? -bodyRows : bodyRows;
96359
96566
  detailScroll = clampScroll(detailScroll + step, detailLines().length, bodyRows);
@@ -96369,6 +96576,7 @@ function openFlows(otui, chrome, options) {
96369
96576
  var FLOWS_COMMAND = "/flows", AC_COMMAND = "/ac", FLOWS_FOOTER, MARKER_GLYPH;
96370
96577
  var init_flow_inspector = __esm(() => {
96371
96578
  init_modal_host();
96579
+ init_ac_kinds_surface();
96372
96580
  init_inspector_sources();
96373
96581
  FLOWS_FOOTER = [
96374
96582
  { key: "[/]", label: "flow" },
@@ -97271,12 +97479,30 @@ function summarizeConfirmations(flow) {
97271
97479
  }
97272
97480
  };
97273
97481
  }
97482
+ function summarizeAcceptance(flow, criteriaInFile) {
97483
+ const records = readAcKindRecords(flow.acKinds);
97484
+ if (records === undefined) {
97485
+ const total = criteriaInFile;
97486
+ return {
97487
+ recorded: false,
97488
+ frozen: flow.acChecksum !== null,
97489
+ total,
97490
+ counts: { exec: 0, invariant: 0, judged: 0, none: 0, unclassified: total ?? 0 },
97491
+ runnable: 0
97492
+ };
97493
+ }
97494
+ const report = reportFromRecords(flow.id, records);
97495
+ return { recorded: true, total: report.total, counts: { ...report.counts }, runnable: report.runnable };
97496
+ }
97274
97497
  function summarizeGateOutcomes(flow) {
97275
97498
  if (flow.completionAttempts === undefined) {
97276
97499
  return { recorded: false };
97277
97500
  }
97278
97501
  return { recorded: true, attempts: flow.completionAttempts };
97279
97502
  }
97503
+ var init_accountability = __esm(() => {
97504
+ init_ac_kinds();
97505
+ });
97280
97506
 
97281
97507
  // src/governance/aggregate.ts
97282
97508
  async function collectFlowGovernance(cwd, ledger) {
@@ -97285,6 +97511,14 @@ async function collectFlowGovernance(cwd, ledger) {
97285
97511
  for (const dir of dirs) {
97286
97512
  const flow = await readFlow(cwd, dir);
97287
97513
  const manifests = await readFlowReviewManifests(cwd, dir);
97514
+ let criteriaInFile;
97515
+ if (readAcKindRecords(flow.acKinds) === undefined) {
97516
+ try {
97517
+ criteriaInFile = (await readAcCriteria(cwd, dir)).length;
97518
+ } catch {
97519
+ criteriaInFile = undefined;
97520
+ }
97521
+ }
97288
97522
  flows.push({
97289
97523
  id: flow.id,
97290
97524
  dir,
@@ -97297,6 +97531,7 @@ async function collectFlowGovernance(cwd, ledger) {
97297
97531
  spend: summarizeReviewSpend(manifests),
97298
97532
  confirmations: summarizeConfirmations(flow),
97299
97533
  gateOutcomes: summarizeGateOutcomes(flow),
97534
+ acceptance: summarizeAcceptance(flow, criteriaInFile),
97300
97535
  dispatch: collectFlowDispatch(ledger, flow.id)
97301
97536
  });
97302
97537
  }
@@ -97335,9 +97570,11 @@ async function collectProjectGovernance(root, filters, displayName) {
97335
97570
  }
97336
97571
  var POLICY_DECISIONS_NOT_RECORDED;
97337
97572
  var init_aggregate = __esm(() => {
97573
+ init_ac_kinds();
97338
97574
  init_store3();
97339
97575
  init_record2();
97340
97576
  init_spend();
97577
+ init_accountability();
97341
97578
  POLICY_DECISIONS_NOT_RECORDED = {
97342
97579
  recorded: false,
97343
97580
  reason: "no durable log of an interactive session's own allow/ask/deny policy decisions exists in this build " + "(unattended dispatch runs are different: their denials are recorded per run, and reported under each " + "flow's own dispatch section below)"
@@ -97419,6 +97656,19 @@ function identityLine(identity) {
97419
97656
  }
97420
97657
  return `${identity.value ?? "unknown"} [${identity.basis}]`;
97421
97658
  }
97659
+ function renderAcceptanceLine(acceptance) {
97660
+ if (acceptance === undefined || !acceptance.recorded) {
97661
+ const total = acceptance?.total;
97662
+ if (acceptance?.frozen === false) {
97663
+ return `acceptance coverage: not frozen yet (${total ?? 0} criteria drafted; kinds are read at freeze)`;
97664
+ }
97665
+ return total === undefined ? "acceptance coverage: not recorded (predates verification kinds; every criterion reads unclassified)" : `acceptance coverage: 0/${total} runnable (0%) \u2014 all ${total} unclassified (predates verification kinds)`;
97666
+ }
97667
+ const { counts } = acceptance;
97668
+ const total = acceptance.total ?? 0;
97669
+ const percent = total === 0 ? 0 : Math.round(acceptance.runnable / total * 100);
97670
+ return `acceptance coverage: ${acceptance.runnable}/${total} runnable (${percent}%) \u2014 ` + `exec ${counts.exec}, invariant ${counts.invariant}, judged ${counts.judged}, none ${counts.none}, unclassified ${counts.unclassified}`;
97671
+ }
97422
97672
  function renderGovernanceMarkdown(report) {
97423
97673
  const lines = [
97424
97674
  "# Governance report",
@@ -97451,6 +97701,7 @@ function renderGovernanceMarkdown(report) {
97451
97701
  lines.push(`status: ${flow.status}`);
97452
97702
  lines.push(`owner: ${identityLine(flow.owner)}`);
97453
97703
  lines.push(`review spend: spent=${usd(flow.spend.spentUsd)} (rounds_with_spent=${flow.spend.roundsWithSpentUsd}), ` + `input_tokens=${tokens(flow.spend.inputTokens)} (rounds=${flow.spend.roundsWithInputTokens}), ` + `output_tokens=${tokens(flow.spend.outputTokens)} (rounds=${flow.spend.roundsWithOutputTokens}), ` + `rounds_with_cost=${flow.spend.roundsWithCost}/${flow.spend.roundsTotal}`);
97704
+ lines.push(renderAcceptanceLine(flow.acceptance));
97454
97705
  if (!flow.confirmations.recorded) {
97455
97706
  lines.push("confirmations: not recorded (predates signing)");
97456
97707
  } else {
@@ -121586,6 +121837,47 @@ async function runSimple(args, action) {
121586
121837
  const id = requireId(args);
121587
121838
  const flow = await getService2()[action]({ cwd: process.cwd(), id });
121588
121839
  console.log(` ${style.green(symbols.ok)} Flow ${flow.id} ${style.cyan(symbols.arrow)} ${flowStatusLabel(flow.status)}`);
121840
+ if (action === "freeze") {
121841
+ await printAcKindDistribution(process.cwd(), flow.id);
121842
+ }
121843
+ }
121844
+ async function printAcKindDistribution(cwd, id) {
121845
+ try {
121846
+ const { report, errors } = await getService2().acKinds({ cwd, id });
121847
+ for (const line of renderAcKindDistribution(report)) {
121848
+ console.log(` ${style.dim(line)}`);
121849
+ }
121850
+ for (const error of errors) {
121851
+ console.log(` ${style.yellow(WARN)} ${error.id}: ${error.message} ${style.dim("(read as unclassified)")}`);
121852
+ }
121853
+ if (errors.length > 0) {
121854
+ note(`\`keryx flow ac kinds ${id}\` names each malformed marker. The freeze went through: a kind never refuses one.`);
121855
+ }
121856
+ } catch {}
121857
+ }
121858
+ async function runAcKinds(args) {
121859
+ const asJson = args.includes("--json");
121860
+ const { positionals } = parseAcArgs(args.filter((arg) => arg !== "--json"), "kinds", [], 1);
121861
+ const id = requireId(positionals);
121862
+ const { report, errors } = await getService2().acKinds({ cwd: process.cwd(), id });
121863
+ if (errors.length > 0) {
121864
+ process.exitCode = 1;
121865
+ }
121866
+ if (asJson) {
121867
+ console.log(JSON.stringify({ ...report, errors }, null, 2));
121868
+ return;
121869
+ }
121870
+ heading(`Flow ${report.flowId}: acceptance kinds`);
121871
+ for (const [criterionId, record] of Object.entries(report.criteria)) {
121872
+ console.log(` ${style.bold(criterionId.padEnd(5))} ${describeAcKind(record)}`);
121873
+ }
121874
+ console.log("");
121875
+ for (const line of renderAcKindDistribution(report)) {
121876
+ console.log(` ${line}`);
121877
+ }
121878
+ for (const error of errors) {
121879
+ console.error(`${style.red(symbols.cross)} ${error.id}: ${error.message}`);
121880
+ }
121589
121881
  }
121590
121882
  async function runTask(args) {
121591
121883
  const sub = args[0];
@@ -121731,6 +122023,9 @@ async function runAc(args) {
121731
122023
  }
121732
122024
  return;
121733
122025
  }
122026
+ if (sub === "kinds") {
122027
+ return await runAcKinds(args.slice(1));
122028
+ }
121734
122029
  if (sub === "reseal") {
121735
122030
  const { positionals, values } = parseAcArgs(args.slice(1), "reseal", AC_RESEAL_FLAGS, 1);
121736
122031
  const id = requireId(positionals);
@@ -121742,7 +122037,7 @@ async function runAc(args) {
121742
122037
  console.log(` ${style.green(symbols.ok)} Checksum re-sealed over the unchanged file; ${style.dim(`${Object.keys(flow.acConfirmed).length} confirmation(s) kept`)}.`);
121743
122038
  return;
121744
122039
  }
121745
- throw new Error('Usage: keryx flow ac <confirm <id> <ACn> | update <id> --reason "<why>" [--criterion ACn --text "<criterion>"] | reseal <id> --reason "<why>"> ...');
122040
+ throw new Error('Usage: keryx flow ac <confirm <id> <ACn> | update <id> --reason "<why>" [--criterion ACn --text "<criterion>"] | reseal <id> --reason "<why>" | kinds <id> [--json]> ...');
121746
122041
  }
121747
122042
  async function runCheckAcCommand(args) {
121748
122043
  const id = requireId(args);
@@ -122030,6 +122325,7 @@ function printHelp5() {
122030
122325
  'keryx flow ac update <id> --reason "<why>" (re-freeze the file as already edited; VOIDS prior confirmations)',
122031
122326
  'keryx flow ac update <id> --criterion ACn --text "<criterion>" --reason "<why>" (rewrite/append that one criterion, then re-freeze; VOIDS prior confirmations)',
122032
122327
  'keryx flow ac reseal <id> --reason "<why>" (checksum stale, file unchanged; KEEPS confirmations)',
122328
+ "keryx flow ac kinds <id> [--json] (verification kind per criterion; read-only, never gates)",
122033
122329
  " every `flow ac` subcommand refuses an argument it does not use \u2014 an extra positional, an unknown flag, or --criterion/--text given alone",
122034
122330
  "keryx flow check-ac <id> [--diff <ref>|--pr <n>] [--json] [--refresh] (ADVISORY: Jev vs. the frozen criteria; never changes flow state)",
122035
122331
  "keryx flow implemented <id> --pr <url>",
@@ -158414,6 +158710,19 @@ var init_command_registry = __esm(() => {
158414
158710
  read: false,
158415
158711
  sideEffects: ["writes flow.json's acChecksum to match the file on disk; acConfirmed is left untouched"]
158416
158712
  },
158713
+ {
158714
+ module: "tasks",
158715
+ command: "flow ac kinds",
158716
+ summary: "Read a flow's acceptance-criteria.md and report each criterion's verification kind \u2014 exec, invariant, " + "judged, none or unclassified \u2014 from its trailing `[verify: ...]` marker, with the distribution and the " + "runnable coverage. Reads only: never gates, never runs a check, never calls a model. Exits 1 when a " + "marker is malformed, naming the criterion; that criterion reads unclassified.",
158717
+ intent: ["\u0432\u0438\u0434\u044B \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u043A\u0440\u0438\u0442\u0435\u0440\u0438\u0435\u0432", "flow ac kinds", "acceptance criteria verification kinds", "ac coverage"],
158718
+ args: [
158719
+ { name: "<id>", type: "string", required: true, desc: "flow id" },
158720
+ { name: "json", type: "bool", required: false, desc: "print the report as JSON, with the per-criterion records and any marker errors" }
158721
+ ],
158722
+ json: true,
158723
+ read: true,
158724
+ sideEffects: []
158725
+ },
158417
158726
  {
158418
158727
  module: "tasks",
158419
158728
  command: "flow complete",
@@ -177115,6 +177424,10 @@ ledger (.metaproject/data/trigger/runs.jsonl). Never re-runs a gate, never
177115
177424
  calls a model or a network service. A figure nobody recorded is reported as
177116
177425
  "not recorded", never as zero.
177117
177426
 
177427
+ Each flow also shows its acceptance coverage: how many criteria are runnable
177428
+ (exec or invariant) of all of them. A flow frozen before verification kinds
177429
+ existed reads as fully unclassified, not as zero criteria.
177430
+
177118
177431
  \`report\` writes .metaproject/data/governance/artifacts/latest.md and
177119
177432
  latest.json (schema-versioned), then prints the report. \`show\` reprints the
177120
177433
  most recently written report without regenerating it.
package/dist/core.js CHANGED
@@ -21627,6 +21627,7 @@ init_validator();
21627
21627
  var exports_service2 = {};
21628
21628
  __export(exports_service2, {
21629
21629
  AC_CHECK_TOKEN_BUDGET: () => AC_CHECK_TOKEN_BUDGET,
21630
+ AC_KINDS: () => AC_KINDS,
21630
21631
  CONFIRMATION_CAVEAT: () => CONFIRMATION_CAVEAT,
21631
21632
  REVIEW_GATE_CONFIG_PATH: () => REVIEW_GATE_CONFIG_PATH,
21632
21633
  acCheckCacheKey: () => acCheckCacheKey,
@@ -21636,10 +21637,13 @@ __export(exports_service2, {
21636
21637
  assertAcIntact: () => assertAcIntact,
21637
21638
  baseBranchCondition: () => baseBranchCondition,
21638
21639
  batchAcCheckItems: () => batchAcCheckItems,
21640
+ buildAcKindReport: () => buildAcKindReport,
21641
+ classifyAcCriterionNotCheckable: () => classifyAcCriterionNotCheckable,
21639
21642
  classifyNotCheckable: () => classifyNotCheckable,
21640
21643
  computeAcFacts: () => computeAcFacts,
21641
21644
  confirmPreconditionError: () => confirmPreconditionError,
21642
21645
  createFlowService: () => createFlowService,
21646
+ describeAcKind: () => describeAcKind,
21643
21647
  evaluatedVerdict: () => evaluatedVerdict,
21644
21648
  extractCriterionTokens: () => extractCriterionTokens,
21645
21649
  factsOnlyVerdict: () => factsOnlyVerdict,
@@ -21649,12 +21653,16 @@ __export(exports_service2, {
21649
21653
  parseAcceptanceCriteria: () => parseAcceptanceCriteria,
21650
21654
  readAcCheckCache: () => readAcCheckCache,
21651
21655
  readAcCheckEnabled: () => readAcCheckEnabled,
21656
+ readAcKindRecords: () => readAcKindRecords,
21652
21657
  readFlow: () => readFlow,
21653
21658
  renderAcCheckAdvisoryNotice: () => renderAcCheckAdvisoryNotice,
21654
21659
  renderAcCheckReport: () => renderAcCheckReport,
21660
+ renderAcKindDistribution: () => renderAcKindDistribution,
21661
+ reportFromRecords: () => reportFromRecords,
21655
21662
  resolveFlowDir: () => resolveFlowDir,
21656
21663
  selectMatchedHunks: () => selectMatchedHunks,
21657
21664
  statusLabel: () => statusLabel,
21665
+ stripVerifyMarker: () => stripVerifyMarker,
21658
21666
  summarizeVerdicts: () => summarizeVerdicts,
21659
21667
  validateCriterionName: () => validateCriterionName,
21660
21668
  writeAcCheckCache: () => writeAcCheckCache
@@ -23979,6 +23987,161 @@ async function readRequireConfirmationDefault(cwd) {
23979
23987
  return false;
23980
23988
  }
23981
23989
  }
23990
+ // src/flow/ac-kinds.ts
23991
+ var AC_KINDS = ["exec", "invariant", "judged", "none", "unclassified"];
23992
+ var AC_LINE = /^\s*[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
23993
+ var MARKER_OPEN = "[verify:";
23994
+ function maskCodeSpans(text) {
23995
+ return text.replace(/`[^`]*`/g, (span) => " ".repeat(span.length));
23996
+ }
23997
+ function scanMarkers(text) {
23998
+ const masked = maskCodeSpans(text);
23999
+ let occurrences = 0;
24000
+ let lastStart = -1;
24001
+ for (let at = masked.indexOf(MARKER_OPEN);at !== -1; at = masked.indexOf(MARKER_OPEN, at + MARKER_OPEN.length)) {
24002
+ occurrences += 1;
24003
+ lastStart = at;
24004
+ }
24005
+ const end = text.trimEnd().length;
24006
+ return { occurrences, lastStart, trailing: occurrences > 0 && end > lastStart && text[end - 1] === "]", end };
24007
+ }
24008
+ function stripVerifyMarker(text) {
24009
+ const scan = scanMarkers(text);
24010
+ if (!scan.trailing || scan.occurrences !== 1)
24011
+ return text;
24012
+ return text.slice(0, scan.lastStart).trimEnd();
24013
+ }
24014
+ var KIND_WORDS = "exec, invariant, judged or none";
24015
+ function unclassified(error) {
24016
+ return error === undefined ? { record: { kind: "unclassified" } } : { record: { kind: "unclassified" }, error };
24017
+ }
24018
+ function parseAcKindText(text) {
24019
+ const scan = scanMarkers(text);
24020
+ if (scan.occurrences === 0)
24021
+ return unclassified();
24022
+ if (scan.occurrences > 1) {
24023
+ return unclassified("a second [verify: \u2026] marker \u2014 a criterion carries exactly one, as the last thing on its line");
24024
+ }
24025
+ if (!scan.trailing) {
24026
+ return unclassified("the [verify: \u2026] marker must be the last thing on the line and close with `]`");
24027
+ }
24028
+ const payload = text.slice(scan.lastStart + MARKER_OPEN.length, scan.end - 1).trim();
24029
+ if (payload.length === 0)
24030
+ return unclassified(`an empty [verify: \u2026] marker \u2014 expected ${KIND_WORDS}`);
24031
+ const word = /^[A-Za-z]+/.exec(payload)?.[0] ?? "";
24032
+ const rest = payload.slice(word.length).trim();
24033
+ switch (word) {
24034
+ case "judged":
24035
+ return rest.length === 0 ? { record: { kind: "judged" } } : unclassified("`judged` takes no arguments \u2014 write [verify: judged]");
24036
+ case "exec":
24037
+ case "invariant": {
24038
+ if (rest.length === 0)
24039
+ return unclassified(`\`${word}\` requires a backticked command, e.g. [verify: ${word} \`bun test path/to.test.ts\`]`);
24040
+ const command = /^`([^`]*)`$/.exec(rest);
24041
+ if (command === null)
24042
+ return unclassified(`\`${word}\` requires exactly one backticked command after the kind, e.g. [verify: ${word} \`bun test path/to.test.ts\`]`);
24043
+ const check = (command[1] ?? "").trim();
24044
+ if (check.length === 0)
24045
+ return unclassified(`\`${word}\` has an empty backticked command`);
24046
+ return { record: { kind: word, check } };
24047
+ }
24048
+ case "none": {
24049
+ if (rest.length === 0)
24050
+ return unclassified("`none` requires a reason after an em dash, e.g. [verify: none \u2014 <reason>]");
24051
+ const reason = /^\u2014\s*([\s\S]*)$/.exec(rest);
24052
+ if (reason === null)
24053
+ return unclassified("`none` requires its reason after an em dash (\u2014), e.g. [verify: none \u2014 <reason>]");
24054
+ const why = (reason[1] ?? "").trim();
24055
+ if (why.length === 0)
24056
+ return unclassified("`none` has an empty reason after the em dash");
24057
+ return { record: { kind: "none", reason: why } };
24058
+ }
24059
+ default:
24060
+ return unclassified(`unknown verification kind "${word.length > 0 ? word : payload}" \u2014 expected ${KIND_WORDS}`);
24061
+ }
24062
+ }
24063
+ function parseAcKinds(content) {
24064
+ const criteria = [];
24065
+ const errors = [];
24066
+ for (const line of content.split(/\r?\n/)) {
24067
+ const match = AC_LINE.exec(line);
24068
+ if (!match?.[1])
24069
+ continue;
24070
+ const id = match[1].toUpperCase();
24071
+ const rawText = (match[2] ?? "").trim();
24072
+ const parsed = parseAcKindText(rawText);
24073
+ if (parsed.error !== undefined)
24074
+ errors.push({ id, message: parsed.error });
24075
+ criteria.push({ id, text: stripVerifyMarker(rawText), rawText, record: parsed.record });
24076
+ }
24077
+ return { criteria, errors };
24078
+ }
24079
+ function emptyCounts() {
24080
+ return { exec: 0, invariant: 0, judged: 0, none: 0, unclassified: 0 };
24081
+ }
24082
+ function buildAcKindReport(flowId, content) {
24083
+ const parsed = parseAcKinds(content);
24084
+ const criteria = {};
24085
+ for (const criterion of parsed.criteria) {
24086
+ if (!(criterion.id in criteria))
24087
+ criteria[criterion.id] = criterion.record;
24088
+ }
24089
+ return { report: reportFromRecords(flowId, criteria), errors: parsed.errors };
24090
+ }
24091
+ function reportFromRecords(flowId, criteria) {
24092
+ const counts = emptyCounts();
24093
+ for (const record of Object.values(criteria))
24094
+ counts[record.kind] += 1;
24095
+ return {
24096
+ flowId,
24097
+ criteria,
24098
+ counts,
24099
+ runnable: counts.exec + counts.invariant,
24100
+ total: Object.keys(criteria).length
24101
+ };
24102
+ }
24103
+ function renderAcKindDistribution(report) {
24104
+ const { counts } = report;
24105
+ const percent = report.total === 0 ? 0 : Math.round(report.runnable / report.total * 100);
24106
+ const tail = [`${counts.none} accepted as unverifiable`, `${counts.judged} judged`, ...counts.unclassified > 0 ? [`${counts.unclassified} unclassified`] : []];
24107
+ return [
24108
+ `acceptance kinds: exec ${counts.exec} invariant ${counts.invariant} judged ${counts.judged} none ${counts.none} unclassified ${counts.unclassified} (${report.total} criteria)`,
24109
+ `coverage: ${report.runnable}/${report.total} runnable (${percent}%) \xB7 ${tail.join(" \xB7 ")}`
24110
+ ];
24111
+ }
24112
+ function describeAcKind(record) {
24113
+ switch (record.kind) {
24114
+ case "exec":
24115
+ case "invariant":
24116
+ return `${record.kind} \`${record.check}\``;
24117
+ case "none":
24118
+ return `none \u2014 ${record.reason}`;
24119
+ default:
24120
+ return record.kind;
24121
+ }
24122
+ }
24123
+ function readAcKindRecords(value) {
24124
+ if (typeof value !== "object" || value === null || Array.isArray(value))
24125
+ return;
24126
+ const out = {};
24127
+ for (const [id, raw] of Object.entries(value)) {
24128
+ if (typeof raw !== "object" || raw === null)
24129
+ return;
24130
+ const record = raw;
24131
+ const kind = record["kind"];
24132
+ if ((kind === "exec" || kind === "invariant") && typeof record["check"] === "string")
24133
+ out[id] = { kind, check: record["check"] };
24134
+ else if (kind === "judged")
24135
+ out[id] = { kind };
24136
+ else if (kind === "none" && typeof record["reason"] === "string")
24137
+ out[id] = { kind, reason: record["reason"] };
24138
+ else if (kind === "unclassified")
24139
+ out[id] = { kind };
24140
+ else
24141
+ return;
24142
+ }
24143
+ return out;
24144
+ }
23982
24145
  // src/flow/check-ac.ts
23983
24146
  import { createHash as createHash8 } from "crypto";
23984
24147
  import { chmod as chmod4, mkdir as mkdir9, readFile as readFile21 } from "fs/promises";
@@ -23992,11 +24155,11 @@ function estimateTokens(text) {
23992
24155
  // src/flow/check-ac.ts
23993
24156
  init_service2();
23994
24157
  init_fs();
23995
- var AC_LINE = /^\s*[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
24158
+ var AC_LINE2 = /^\s*[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
23996
24159
  function parseAcceptanceCriteria(content) {
23997
24160
  const out = [];
23998
24161
  for (const line of content.split(/\r?\n/)) {
23999
- const match = AC_LINE.exec(line);
24162
+ const match = AC_LINE2.exec(line);
24000
24163
  if (match?.[1]) {
24001
24164
  out.push({ id: match[1].toUpperCase(), text: (match[2] ?? "").trim() });
24002
24165
  }
@@ -24043,6 +24206,9 @@ function classifyNotCheckable(criterionText) {
24043
24206
  reason: `not-checkable: every clause of this criterion names a condition no diff records (${labels.join(", ")}) \u2014 assigned without asking Jev.`
24044
24207
  };
24045
24208
  }
24209
+ function classifyAcCriterionNotCheckable(criterion) {
24210
+ return classifyNotCheckable(stripVerifyMarker(criterion.text));
24211
+ }
24046
24212
  function extractCriterionTokens(text) {
24047
24213
  const tokens = new Set;
24048
24214
  for (const match of text.matchAll(/`([^`]+)`/g)) {
@@ -24060,7 +24226,7 @@ function extractCriterionTokens(text) {
24060
24226
  return [...tokens];
24061
24227
  }
24062
24228
  function computeAcFacts(criterion, diffText, changedFiles) {
24063
- const tokens = extractCriterionTokens(criterion.text);
24229
+ const tokens = extractCriterionTokens(stripVerifyMarker(criterion.text));
24064
24230
  const tokensPresent = tokens.filter((token) => diffText.includes(token) || changedFiles.some((file) => file.includes(token)));
24065
24231
  const tokensAbsent = tokens.filter((token) => !tokensPresent.includes(token));
24066
24232
  const testFiles = changedFiles.filter((file) => /\.(test|spec)\.[a-z]+$/i.test(file));
@@ -24086,7 +24252,7 @@ var AC_CHECK_TOKEN_BUDGET = 64000;
24086
24252
  function questionFor(criterion) {
24087
24253
  return {
24088
24254
  type: "noul",
24089
- instructions: `Given the deterministic facts and the redacted diff evidence above, does this change LIKELY SATISFY this frozen ` + `acceptance criterion? Criterion ${criterion.id}: ${redactSensitiveText(criterion.text)}`
24255
+ instructions: `Given the deterministic facts and the redacted diff evidence above, does this change LIKELY SATISFY this frozen ` + `acceptance criterion? Criterion ${criterion.id}: ${redactSensitiveText(stripVerifyMarker(criterion.text))}`
24090
24256
  };
24091
24257
  }
24092
24258
  var MAX_MATCHED_HUNKS_PER_ITEM = 8;
@@ -24431,6 +24597,10 @@ function renderJournal(createdAt) {
24431
24597
  }
24432
24598
 
24433
24599
  // src/flow/service.ts
24600
+ async function deriveAcKinds(cwd, dir, flowId) {
24601
+ const { report } = buildAcKindReport(flowId, await readFile23(acPath(cwd, dir), "utf8"));
24602
+ return { ...report.criteria };
24603
+ }
24434
24604
  function recordAttempt(task, outcome, at, detail) {
24435
24605
  const attempts = task.attempts ?? { count: 0, log: [] };
24436
24606
  attempts.count += 1;
@@ -24628,6 +24798,7 @@ function createFlowService(deps) {
24628
24798
  throw new Error("Cannot freeze: acceptance-criteria.md must contain at least one real `- ACn:` criterion.");
24629
24799
  }
24630
24800
  flow.acChecksum = await acChecksum(cwd, dir);
24801
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
24631
24802
  flow.status = "ready";
24632
24803
  return save(cwd, dir, flow, "frozen", `${criteria.length} criteria; checksum recorded`);
24633
24804
  });
@@ -24772,10 +24943,15 @@ function createFlowService(deps) {
24772
24943
  detail = `${normalizedCriterion}: "${previousText ?? "(new)"}" -> "${normalizedText}" (${reason.trim()})`;
24773
24944
  }
24774
24945
  flow.acChecksum = await acChecksum(cwd, dir);
24946
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
24775
24947
  flow.acConfirmed = {};
24776
24948
  return save(cwd, dir, flow, "ac-updated", detail);
24777
24949
  });
24778
24950
  },
24951
+ async acKinds({ cwd, id }) {
24952
+ const { dir, flow } = await load(cwd, id);
24953
+ return buildAcKindReport(flow.id, await readFile23(acPath(cwd, dir), "utf8"));
24954
+ },
24779
24955
  async acReseal({ cwd, id, reason }) {
24780
24956
  if (!reason?.trim()) {
24781
24957
  throw new Error('flow ac reseal requires --reason "<why the checksum is stale>"');
@@ -24794,6 +24970,7 @@ function createFlowService(deps) {
24794
24970
  throw new Error(`flow ac reseal: refusing \u2014 ${evidence.reason}. ` + "Reseal only re-seals a stale checksum over an unchanged file; it is not a way to " + 'accept an edit. If the criteria really changed, use `keryx flow ac update <id> --reason "..."`, ' + "which re-seals AND voids the prior confirmations.");
24795
24971
  }
24796
24972
  flow.acChecksum = current;
24973
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
24797
24974
  return save(cwd, dir, flow, "ac-resealed", reason);
24798
24975
  });
24799
24976
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.3.28",
3
+ "version": "0.3.29",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -62,11 +62,26 @@ document changes.
62
62
  - problem;
63
63
  - goal;
64
64
  - users;
65
- - requirements;
66
- - success criteria;
65
+ - requirements, each carrying a verification field (see Verification Field below);
66
+ - success criteria, split into two lists (see Success Contract below);
67
67
  - risks;
68
68
  - recommendation.
69
69
 
70
+ ### Success Contract
71
+
72
+ The PRD's success criteria are two lists, never one:
73
+
74
+ - **Release criteria** are checkable when the work ships: a test, a command, a
75
+ file, a count. Each is something a release can be blocked on.
76
+ - **Outcome criteria** are what should be true some time after release: adoption,
77
+ a measured change, a behavior seen in use. Each outcome criterion either names
78
+ the observation that would confirm it (what is looked at, where, and when) or
79
+ declares `not measured — <reason>`.
80
+
81
+ An outcome criterion with neither an observation nor a `not measured — <reason>`
82
+ is not a criterion. A list whose every entry is `not measured` is acceptable: it
83
+ is honest. A PRD with no outcome list at all is not.
84
+
70
85
  ## Specification Contract
71
86
 
72
87
  `specification.md` must state:
@@ -79,6 +94,21 @@ document changes.
79
94
  - integration points;
80
95
  - acceptance criteria.
81
96
 
97
+ ### Verification Field
98
+
99
+ Every requirement in the PRD (each `R<n>` under Requirements) carries one verification field, in the
100
+ same vocabulary a flow criterion's trailing marker uses:
101
+
102
+ ```markdown
103
+ Verification: exec `<command>` # a command whose exit code decides it
104
+ Verification: invariant `<command>` # a command that must keep passing
105
+ Verification: judged # a person reads the evidence
106
+ Verification: none — <reason> # honestly unverifiable, with the reason
107
+ ```
108
+
109
+ `exec` and `invariant` name a backticked command; `none` names its reason after
110
+ an em dash. A requirement with no field is a defect, not a `none`.
111
+
82
112
  ## Verification Contract
83
113
 
84
114
  Before finishing:
@@ -88,6 +118,11 @@ Before finishing:
88
118
  - verify README links to all package files;
89
119
  - verify schemas are referenced from specification when present;
90
120
  - verify status is honest: `draft`, `spec ready`, `implemented`, or `future`;
121
+ - verify every PRD requirement carries a `Verification:` field, and
122
+ fail the package when one does not, naming that requirement;
123
+ - verify the PRD has both a release-criteria list and an outcome-criteria list,
124
+ and fail the package when the outcome list is missing (an outcome list whose
125
+ entries are all `not measured — <reason>` passes);
91
126
  - update `docs/requirements/roadmap.md` when the package represents a module or
92
127
  standard capability.
93
128
 
@@ -109,10 +109,8 @@ Add optional files only when justified:
109
109
  Write concise Markdown with explicit headings. Required contracts:
110
110
 
111
111
  - README: purpose, status, document index, scope, related modules.
112
- - PRD: problem, goal, users, requirements, success criteria, risks,
113
- recommendation.
114
- - Specification: identity, structure, manifest/config, CLI/skill surface, data
115
- contracts, integrations, acceptance criteria.
112
+ - PRD: problem, goal, users, requirements (each `R<n>` carries a `Verification:` field: `exec`/`invariant` with a backticked command, `judged`, or `none — <reason>`), success criteria (a release list and an outcome list; an outcome criterion names its observation or declares `not measured — <reason>`), risks, recommendation.
113
+ - Specification: identity, structure, manifest/config, CLI/skill surface, data contracts, integrations, acceptance criteria.
116
114
 
117
115
  When updating existing docs, preserve useful content and bump versions.
118
116
 
@@ -125,6 +123,7 @@ Run a local verification pass:
125
123
  - README links to every package file;
126
124
  - schema files are valid JSON;
127
125
  - specification references schemas when present;
126
+ - every PRD requirement has a `Verification:` field (a package with a requirement that omits it FAILS, naming that requirement); the PRD has a release list and an outcome list (no outcome list FAILS; an outcome list whose entries are all `not measured — <reason>` passes);
128
127
  - roadmap is updated for new module/standard capabilities;
129
128
  - no implementation status is overstated.
130
129
 
@@ -154,6 +153,7 @@ Do not emit the Final Response until all of these hold:
154
153
  - Every Markdown file in the package carries a `Version`, and every file you changed has a bumped one.
155
154
  - `README.md` links to every file in the package; the specification links to each schema it defines.
156
155
  - Every `schemas/*.json` parses as valid JSON.
156
+ - Every specification requirement carries a `Verification:` field, and the PRD carries both a release list and an outcome list; a package missing either was reported as `verification: fail`, naming the requirement.
157
157
  - `docs/requirements/roadmap.md` is updated when the package represents a new module or capability, or the report states why it does not.
158
158
  - No implementation claim appears that code does not support; anything unbuilt is marked planned or future.
159
159
  - `docpack-review` has been run in Phase 5 and reports zero blockers. Remaining warnings appear verbatim in `remaining_gaps`.