@mrciphersmith/keryx 0.3.27 → 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/dist/core.js CHANGED
@@ -9178,12 +9178,19 @@ var init_export = __esm(() => {
9178
9178
  SKILL_RUNTIMES = [...HARNESS_SKILL_RUNTIMES, "plugin"];
9179
9179
  });
9180
9180
 
9181
+ // src/gdskills/model-version.ts
9182
+ var TRAILING_ALIAS_WORDS;
9183
+ var init_model_version = __esm(() => {
9184
+ TRAILING_ALIAS_WORDS = new Set(["latest", "preview"]);
9185
+ });
9186
+
9181
9187
  // src/gdskills/model-tier.ts
9182
9188
  function isModelTier(value) {
9183
9189
  return typeof value === "string" && MODEL_TIERS.includes(value);
9184
9190
  }
9185
9191
  var MODEL_TIERS, TIER_ALIASES, SKILL_TIER_KEY = "model_tier", TIER_DECLARATION;
9186
9192
  var init_model_tier = __esm(() => {
9193
+ init_model_version();
9187
9194
  MODEL_TIERS = ["light", "standard", "deep"];
9188
9195
  TIER_ALIASES = new Map([["cheap", "light"]]);
9189
9196
  TIER_DECLARATION = new RegExp(`^\\s*(?:[-*]\\s*)?"?(?:${SKILL_TIER_KEY}|model-tier|modelTier)"?\\s*[:=]\\s*(.+?)\\s*,?\\s*$`, "i");
@@ -21620,6 +21627,7 @@ init_validator();
21620
21627
  var exports_service2 = {};
21621
21628
  __export(exports_service2, {
21622
21629
  AC_CHECK_TOKEN_BUDGET: () => AC_CHECK_TOKEN_BUDGET,
21630
+ AC_KINDS: () => AC_KINDS,
21623
21631
  CONFIRMATION_CAVEAT: () => CONFIRMATION_CAVEAT,
21624
21632
  REVIEW_GATE_CONFIG_PATH: () => REVIEW_GATE_CONFIG_PATH,
21625
21633
  acCheckCacheKey: () => acCheckCacheKey,
@@ -21629,10 +21637,13 @@ __export(exports_service2, {
21629
21637
  assertAcIntact: () => assertAcIntact,
21630
21638
  baseBranchCondition: () => baseBranchCondition,
21631
21639
  batchAcCheckItems: () => batchAcCheckItems,
21640
+ buildAcKindReport: () => buildAcKindReport,
21641
+ classifyAcCriterionNotCheckable: () => classifyAcCriterionNotCheckable,
21632
21642
  classifyNotCheckable: () => classifyNotCheckable,
21633
21643
  computeAcFacts: () => computeAcFacts,
21634
21644
  confirmPreconditionError: () => confirmPreconditionError,
21635
21645
  createFlowService: () => createFlowService,
21646
+ describeAcKind: () => describeAcKind,
21636
21647
  evaluatedVerdict: () => evaluatedVerdict,
21637
21648
  extractCriterionTokens: () => extractCriterionTokens,
21638
21649
  factsOnlyVerdict: () => factsOnlyVerdict,
@@ -21642,12 +21653,16 @@ __export(exports_service2, {
21642
21653
  parseAcceptanceCriteria: () => parseAcceptanceCriteria,
21643
21654
  readAcCheckCache: () => readAcCheckCache,
21644
21655
  readAcCheckEnabled: () => readAcCheckEnabled,
21656
+ readAcKindRecords: () => readAcKindRecords,
21645
21657
  readFlow: () => readFlow,
21646
21658
  renderAcCheckAdvisoryNotice: () => renderAcCheckAdvisoryNotice,
21647
21659
  renderAcCheckReport: () => renderAcCheckReport,
21660
+ renderAcKindDistribution: () => renderAcKindDistribution,
21661
+ reportFromRecords: () => reportFromRecords,
21648
21662
  resolveFlowDir: () => resolveFlowDir,
21649
21663
  selectMatchedHunks: () => selectMatchedHunks,
21650
21664
  statusLabel: () => statusLabel,
21665
+ stripVerifyMarker: () => stripVerifyMarker,
21651
21666
  summarizeVerdicts: () => summarizeVerdicts,
21652
21667
  validateCriterionName: () => validateCriterionName,
21653
21668
  writeAcCheckCache: () => writeAcCheckCache
@@ -23972,6 +23987,161 @@ async function readRequireConfirmationDefault(cwd) {
23972
23987
  return false;
23973
23988
  }
23974
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
+ }
23975
24145
  // src/flow/check-ac.ts
23976
24146
  import { createHash as createHash8 } from "crypto";
23977
24147
  import { chmod as chmod4, mkdir as mkdir9, readFile as readFile21 } from "fs/promises";
@@ -23985,11 +24155,11 @@ function estimateTokens(text) {
23985
24155
  // src/flow/check-ac.ts
23986
24156
  init_service2();
23987
24157
  init_fs();
23988
- var AC_LINE = /^\s*[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
24158
+ var AC_LINE2 = /^\s*[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
23989
24159
  function parseAcceptanceCriteria(content) {
23990
24160
  const out = [];
23991
24161
  for (const line of content.split(/\r?\n/)) {
23992
- const match = AC_LINE.exec(line);
24162
+ const match = AC_LINE2.exec(line);
23993
24163
  if (match?.[1]) {
23994
24164
  out.push({ id: match[1].toUpperCase(), text: (match[2] ?? "").trim() });
23995
24165
  }
@@ -24036,6 +24206,9 @@ function classifyNotCheckable(criterionText) {
24036
24206
  reason: `not-checkable: every clause of this criterion names a condition no diff records (${labels.join(", ")}) \u2014 assigned without asking Jev.`
24037
24207
  };
24038
24208
  }
24209
+ function classifyAcCriterionNotCheckable(criterion) {
24210
+ return classifyNotCheckable(stripVerifyMarker(criterion.text));
24211
+ }
24039
24212
  function extractCriterionTokens(text) {
24040
24213
  const tokens = new Set;
24041
24214
  for (const match of text.matchAll(/`([^`]+)`/g)) {
@@ -24053,7 +24226,7 @@ function extractCriterionTokens(text) {
24053
24226
  return [...tokens];
24054
24227
  }
24055
24228
  function computeAcFacts(criterion, diffText, changedFiles) {
24056
- const tokens = extractCriterionTokens(criterion.text);
24229
+ const tokens = extractCriterionTokens(stripVerifyMarker(criterion.text));
24057
24230
  const tokensPresent = tokens.filter((token) => diffText.includes(token) || changedFiles.some((file) => file.includes(token)));
24058
24231
  const tokensAbsent = tokens.filter((token) => !tokensPresent.includes(token));
24059
24232
  const testFiles = changedFiles.filter((file) => /\.(test|spec)\.[a-z]+$/i.test(file));
@@ -24079,7 +24252,7 @@ var AC_CHECK_TOKEN_BUDGET = 64000;
24079
24252
  function questionFor(criterion) {
24080
24253
  return {
24081
24254
  type: "noul",
24082
- 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))}`
24083
24256
  };
24084
24257
  }
24085
24258
  var MAX_MATCHED_HUNKS_PER_ITEM = 8;
@@ -24424,6 +24597,10 @@ function renderJournal(createdAt) {
24424
24597
  }
24425
24598
 
24426
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
+ }
24427
24604
  function recordAttempt(task, outcome, at, detail) {
24428
24605
  const attempts = task.attempts ?? { count: 0, log: [] };
24429
24606
  attempts.count += 1;
@@ -24621,6 +24798,7 @@ function createFlowService(deps) {
24621
24798
  throw new Error("Cannot freeze: acceptance-criteria.md must contain at least one real `- ACn:` criterion.");
24622
24799
  }
24623
24800
  flow.acChecksum = await acChecksum(cwd, dir);
24801
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
24624
24802
  flow.status = "ready";
24625
24803
  return save(cwd, dir, flow, "frozen", `${criteria.length} criteria; checksum recorded`);
24626
24804
  });
@@ -24765,10 +24943,15 @@ function createFlowService(deps) {
24765
24943
  detail = `${normalizedCriterion}: "${previousText ?? "(new)"}" -> "${normalizedText}" (${reason.trim()})`;
24766
24944
  }
24767
24945
  flow.acChecksum = await acChecksum(cwd, dir);
24946
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
24768
24947
  flow.acConfirmed = {};
24769
24948
  return save(cwd, dir, flow, "ac-updated", detail);
24770
24949
  });
24771
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
+ },
24772
24955
  async acReseal({ cwd, id, reason }) {
24773
24956
  if (!reason?.trim()) {
24774
24957
  throw new Error('flow ac reseal requires --reason "<why the checksum is stale>"');
@@ -24787,6 +24970,7 @@ function createFlowService(deps) {
24787
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.");
24788
24971
  }
24789
24972
  flow.acChecksum = current;
24973
+ flow.acKinds = await deriveAcKinds(cwd, dir, flow.id);
24790
24974
  return save(cwd, dir, flow, "ac-resealed", reason);
24791
24975
  });
24792
24976
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.3.27",
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": {
@@ -57,13 +57,14 @@ candidate set. `src/gdskills/model-tier.ts` then:
57
57
  | Tier | Resolves to |
58
58
  |---|---|
59
59
  | `standard` | the session's model, always |
60
- | `deep` | the highest-ranked discovered model **strictly above** the session's, else the session's |
61
- | `light` | the lowest-ranked discovered model **strictly below** the session's, else the session's |
60
+ | `deep` | the best discovered model **above** the session's — a larger size class, or a NEWER generation of the session model's own family — else the session's |
61
+ | `light` | the **next size step below** the session's (an Opus-class session takes the Sonnet class, not the Haiku class), else the session's; the smallest class only when nothing sits between |
62
62
 
63
63
  Anchoring on the session model is what makes "never a downgrade" checkable
64
64
  rather than hoped for: a tier can only move away from the session model in the
65
65
  direction its own name points, and a candidate at the session's own rank is a
66
- lateral move and never taken.
66
+ lateral move and never taken. An OLDER sibling of the session's own family is
67
+ never `light` or `deep` either.
67
68
 
68
69
  ## What the hints are, and what they are not
69
70
 
@@ -83,13 +84,24 @@ residue, and it is kept in the smallest shape that works:
83
84
  A model whose name is a codename carrying no size is unrankable by design. That
84
85
  is the honest outcome, not a gap to be patched with folklore.
85
86
 
86
- **Version is deliberately not ranked here.** `rankModelId` never parses a
87
- version number out of an id — two ids in the same size-word family but a
88
- different generation (an older vs. a newer numbered release of the same
89
- line) rank IDENTICALLY through this resolution, and a
90
- `standard`/`light`/`deep` dispatch never distinguishes between them. That
91
- stays true for `spawn_subagent` and `keryx review tier` after the decision
92
- below; it is a DIFFERENT consumer's rule, not this one's.
87
+ **Version is a second axis, not a size hint (flow 358).** `rankModelId` still
88
+ never parses a version number out of an id: the size words order size CLASSES.
89
+ Generation is ordered separately, and only within **the same family and
90
+ vendor** — the id with its version tokens removed (`familyKey`) must match. A
91
+ newer release outranks an older one of its own family, so a session on the
92
+ older one resolves `deep` to the newer sibling, and a session on the newer one
93
+ never resolves `light` or `deep` to the older. A version number is **never**
94
+ compared across families: a higher number on one line says nothing about a
95
+ different line.
96
+
97
+ **A larger class of an older generation is ambiguous, not "above".** When the
98
+ only discovered models above the session are a larger size class whose version is
99
+ older than the session's, size words alone cannot call them stronger, and
100
+ `deep` must not move onto a model that is older-generation and — by the
101
+ profile price — the more expensive one. Without an agent the tier keeps the
102
+ session's model as `session-fallback`, with the reason recorded (reason id
103
+ `kept-older-generation`, or `kept-older-generation-pricier` when the profile
104
+ price shows it costs more). The agent fallback below is the only other way out.
93
105
 
94
106
  The routing "derived default table" (`deriveDefaultTable`,
95
107
  `src/harness/routing/derive-default-table.ts`, flow 327/PRD §6.3) is that
@@ -102,8 +114,9 @@ version ranks higher there — the family word (a size marker from this same
102
114
  `MODEL_RANK_HINTS` list) already carries the size class, so version is free
103
115
  to order generations within it. That table reuses
104
116
  `rankModelId`/`MODEL_RANK_HINTS` UNMODIFIED for the size axis and adds its
105
- own separate, conservative version parse on top of it — nothing above
106
- changes for this module's own tier resolution.
117
+ own separate, conservative version parse on top of it. Since flow 358 the tier
118
+ resolution reads the same version parse (`src/gdskills/model-version.ts`, shared
119
+ so neither module imports the other).
107
120
 
108
121
  That version parse (`parseModelVersion`/`familyKey`, round 2) reads BOTH a
109
122
  dotted id (`vendor-opus-5.5`, `gemini-3.8-flash`) and a real
@@ -152,6 +165,35 @@ Do not "fix" a fallback by adding model ids to the code. Add a size word to the
152
165
  hints if one genuinely applies, or leave it: running on the session's model is a
153
166
  correct answer.
154
167
 
168
+ ## The agent fallback: comparing candidate models, nothing else
169
+
170
+ When the ranking is **refused** but the provider did report other models, or a
171
+ tier is **ambiguous** (an older-generation larger class, above), an injected
172
+ agent may be asked to put the candidate models in order. It is a port
173
+ (`TierRankAgent`): `src/gdskills/model-tier.ts` holds no network and no
174
+ filesystem, the caller supplies the real one, and a test supplies a fake.
175
+
176
+ Its limits are the point:
177
+
178
+ - **It compares candidate models and never rates a task's own difficulty.** It is
179
+ shown no task, no diff and no user text — only the discovered ids and the
180
+ profile prices — and it is asked one question: which of these models is more
181
+ capable than which. The tier is still computed by `assignTier` from signals
182
+ the orchestrator holds.
183
+ - It must answer JSON, `{"order": [...]}`, best first, and must place the session's
184
+ own model. An id that was not discovered is dropped. An agent error, a
185
+ timeout, a malformed answer, or one that leaves out the session's model
186
+ resolves to the session's model, exactly as without an agent.
187
+ - It never places `standard` or `deep` below the session: `deep` is only ever taken
188
+ from above the session in its order, `standard` is never asked. A larger
189
+ class of an older generation that costs more than the session's model is
190
+ refused whatever it says.
191
+ - The result is cached by a hash of the candidate catalogue with prices, so an
192
+ identical catalogue asks once.
193
+ - It runs on the **light** tier of the session's own provider — the cheap step
194
+ below the session, or the session model itself when nothing sits below.
195
+ - A resolution it produced is recorded `tier_resolution: agent-ranked`.
196
+
155
197
  ## Where this actually runs
156
198
 
157
199
  Two places, and they are not the same shape:
@@ -236,8 +278,9 @@ arithmetic straight back in the caller's head.
236
278
 
237
279
  ## Adaptive: `inherit: true`
238
280
 
239
- A model is named only when discovery assigned one **other than** the session's
240
- (`tier_resolution: discovered`). In every other case — no session named, ranking
281
+ A model is named only when discovery — or, failing that, the agent fallback —
282
+ assigned one **other than** the session's (`tier_resolution: discovered` or
283
+ `agent-ranked`). In every other case — no session named, ranking
241
284
  refused, or the tier resolving to the session's own model — the block carries the
242
285
  tier and `inherit: true`, and the **host** resolves it against the models its own
243
286
  dispatch tool offers:
@@ -255,18 +298,19 @@ fallback. Exit status is always 0.
255
298
  ## Recording the decision
256
299
 
257
300
  The dispatch carries the tier, the ordered rule ids that produced it, which of
258
- the three outcomes occurred, and what was on the table when it did —
259
- `model.tier`, `model.tier_reasons`, `model.tier_resolution` and
301
+ the outcomes occurred, and what was on the table when it did —
302
+ `model.tier`, `model.tier_reasons` (the assignment rule ids, then `resolve:<reason id>` for the resolution), `model.tier_resolution` and
260
303
  `model.model_discovery` in
261
304
  `.metaproject/core/gdskills/contracts/subagent-dispatch.schema.json`.
262
305
 
263
- `tier_resolution` distinguishes three facts that must not be flattened:
306
+ `tier_resolution` distinguishes four facts that must not be flattened:
264
307
 
265
308
  | Value | Means |
266
309
  |---|---|
267
310
  | `discovered` | a discovered model was assigned to this tier |
268
311
  | `session-ranked` | ranking worked and placed the tier at the session's model |
269
- | `session-fallback` | ranking was refused; the session's model is kept |
312
+ | `session-fallback` | ranking was refused or was ambiguous; the session's model is kept |
313
+ | `agent-ranked` | the agent fallback put the candidates in order and its answer was used |
270
314
 
271
315
  "Fell back to the session model" and "assigned the session model because it
272
316
  ranked there" are different facts. A run that cannot be explained afterwards is
@@ -286,6 +330,9 @@ recording them is one copy rather than four decisions.
286
330
  the resolution code.
287
331
  - Take the candidate set from runtime detection. Never from a literal list of
288
332
  what exists.
333
+ - The agent fallback compares candidate models. Never ask any model to rate a
334
+ task's own difficulty, and never let the fallback place `standard` or `deep`
335
+ below the session's model.
289
336
  - When the candidates cannot be ranked, the block is adaptive (`inherit: true`).
290
337
  The host never goes below its session model for `standard` or `deep`, and takes
291
338
  a lighter class only for a `light` tier.
@@ -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`.
@@ -79,7 +79,8 @@
79
79
  "enum": [
80
80
  "discovered",
81
81
  "session-ranked",
82
- "session-fallback"
82
+ "session-fallback",
83
+ "agent-ranked"
83
84
  ]
84
85
  },
85
86
  "model_discovery": {
@@ -83,7 +83,7 @@
83
83
  }
84
84
  },
85
85
  "model": {
86
- "description": "Which model runs this dispatch. Skills declare a `tier`; at dispatch time src/gdskills/model-tier.ts DISCOVERS the models runtime provider detection reported for the active provider, ranks them by size markers in their names, and places the tier relative to the session's own model — `standard` is the session model, `deep` a discovered model ranked above it, `light` one ranked below. No table of model ids exists anywhere in the resolution. When the candidates cannot be ranked, every tier keeps the session's model. `provider`/`model` remain available for an explicit lateral selection, and an omitted block inherits the parent verbatim. Two things fill this block. The interactive `spawn_subagent` tool does it in code: it builds the tier map with `buildTierMap` from its host's detection result, hands it to `resolveChildModel`, and records the outcome on the dispatch's run trace. An orchestrator authoring a dispatch document RUNS `keryx review tier`, which takes the §4.4 signals as flags and prints exactly the fields below, ready to paste (`--json` prints this object alone). It is a command rather than a documented function call because an orchestrator is an agent following prose and cannot call a TypeScript function: told to `decideDispatchModel`, it computed the tier in its head from a table, which is the mechanical step that belongs in code. When nothing can be worked out the command emits `inherit: true` instead of `provider`/`model` — the dispatch runs on the caller's own model, never a downgrade. Neither path is enforced by a reader — nothing validates a recorded resolution against the model that actually ran — so these fields are a record, not a gate.",
86
+ "description": "Which model runs this dispatch. Skills declare a `tier`; at dispatch time src/gdskills/model-tier.ts DISCOVERS the models runtime provider detection reported for the active provider, ranks them by size markers in their names, and places the tier relative to the session's own model — `standard` is the session model, `deep` a discovered model above it (a larger size class, or a newer generation of the session model's own family), `light` the next size step below it. When the ranking is refused or ambiguous an injected agent may order the candidate models instead (it compares models, never rates a task); its answer is recorded as `agent-ranked`. No table of model ids exists anywhere in the resolution. When the candidates cannot be ranked, every tier keeps the session's model. `provider`/`model` remain available for an explicit lateral selection, and an omitted block inherits the parent verbatim. Two things fill this block. The interactive `spawn_subagent` tool does it in code: it builds the tier map with `buildTierMap` from its host's detection result, hands it to `resolveChildModel`, and records the outcome on the dispatch's run trace. An orchestrator authoring a dispatch document RUNS `keryx review tier`, which takes the §4.4 signals as flags and prints exactly the fields below, ready to paste (`--json` prints this object alone). It is a command rather than a documented function call because an orchestrator is an agent following prose and cannot call a TypeScript function: told to `decideDispatchModel`, it computed the tier in its head from a table, which is the mechanical step that belongs in code. When nothing can be worked out the command emits `inherit: true` instead of `provider`/`model` — the dispatch runs on the caller's own model, never a downgrade. Neither path is enforced by a reader — nothing validates a recorded resolution against the model that actually ran — so these fields are a record, not a gate.",
87
87
  "type": "object",
88
88
  "additionalProperties": false,
89
89
  "properties": {
@@ -95,14 +95,14 @@
95
95
  "enum": ["light", "standard", "deep"]
96
96
  },
97
97
  "tier_reasons": {
98
- "description": "The ordered rule ids `assignTier` applied, e.g. [\"base:standard\", \"floor:blast-radius\"]. Produced by `keryx review tier` from the signals passed as flags, never written by hand: a reason list an author composes records the story they tell about the tier rather than the rules that produced it. Recorded so a tier can be explained after the run instead of re-derived from a state nobody kept. Ids rather than prose, because they are compared across runs.",
98
+ "description": "The ordered rule ids `assignTier` applied, e.g. [\"base:standard\", \"floor:blast-radius\"], followed by one `resolve:<reason-id>` entry from the model resolution (e.g. `resolve:picked-above`, `resolve:kept-older-generation-pricier`, `resolve:agent-picked-below`). Produced by `keryx review tier` from the signals passed as flags, never written by hand: a reason list an author composes records the story they tell about the tier rather than the rules that produced it. Recorded so a tier can be explained after the run instead of re-derived from a state nobody kept. Ids rather than prose, because they are compared across runs.",
99
99
  "type": "array",
100
100
  "items": { "type": "string", "minLength": 1 }
101
101
  },
102
102
  "tier_resolution": {
103
- "description": "Which of three outcomes produced the model, as `resolveTierModel` reports it on `TierResolution.source`. `discovered` — a model reported by runtime provider detection was assigned to this tier. `session-ranked` — ranking WORKED and placed the tier at the session's own model (always so for `standard`; for `light`/`deep` when nothing discovered ranks below/above the session). `session-fallback` — ranking was refused, or the ranking carried no rank for the session model, so the session's model is used because nothing could be worked out. Collapsing the last two would hide whether the environment was understood. Recorded, never read back: it explains a finished run and gates nothing.",
103
+ "description": "Which of four outcomes produced the model, as `resolveTierModel` reports it on `TierResolution.source`. `discovered` — a model reported by runtime provider detection was assigned to this tier. `session-ranked` — ranking WORKED and placed the tier at the session's own model (always so for `standard`; for `light`/`deep` when nothing discovered ranks below/above the session). `session-fallback` — ranking was refused or was ambiguous (a larger size class that is an older generation), or the ranking carried no rank for the session model, so the session's model is used because nothing could be worked out. `agent-ranked` — the ranking was refused or ambiguous and an injected agent, shown only the discovered ids and profile prices, put the candidate models in order and its validated answer picked the model; it compares models and never rates a task. Collapsing these would hide whether the environment was understood. Recorded, never read back: it explains a finished run and gates nothing.",
104
104
  "type": "string",
105
- "enum": ["discovered", "session-ranked", "session-fallback"]
105
+ "enum": ["discovered", "session-ranked", "session-fallback", "agent-ranked"]
106
106
  },
107
107
  "model_discovery": {
108
108
  "description": "What was on the table when the tier was resolved: which models runtime detection reported for the active provider, how the size-marker hints ordered them, and — when ranking was refused — why. `tier_resolution` alone says a fallback happened and never says what it fell back FROM.",