ndrstnd 0.1.1 → 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable ndrstnd changes are documented here. Versions are released as unprefixed Git tags and published through npm and the repository’s Homebrew tap.
4
+
5
+ ## 0.2.0 — 2026-07-20
6
+
7
+ This release contains the changes made after 0.1.0 through the current 0.2.0 release candidate.
8
+
9
+ ### Agent analysis and reliability
10
+
11
+ - Replaced the fragile compact analysis wire format in the agent prompt with an explicit named-object JSON contract. Chapters, steps, deferred items, forward references, focus ranges, and test executions now have named properties that coding agents can generate and repair more reliably.
12
+ - Kept compatibility with legacy compact responses while making the explicit format canonical, so existing cached or in-flight agent behavior can be diagnosed without weakening the new contract.
13
+ - Changed evidence references from brittle hunk ID strings to zero-based indexes in the numbered review manifest, with validation that every meaningful hunk is classified and every hunk appears in the timeline.
14
+ - Hardened JSON extraction, wire-schema validation, prose limits, evidence invariants, focus ranges, step ordering, duplicate detection, and repair prompts. Failed drafts receive targeted repair turns and transient agent failures get one fresh-client retry.
15
+ - Added clearer analysis progress and heartbeat output so long Codex or Claude Code runs are observable instead of looking hung.
16
+
17
+ ### Failure diagnosis and CLI
18
+
19
+ - Added a portable `ndrstnd-analysis-diagnostic` JSON artifact for review failures. It records tool/runtime metadata, sanitized command and scope information, per-turn timings and activity, response hashes, extraction metadata, validation phases, and transport errors without including prompts, patches, environment variables, or raw responses by default.
20
+ - Added `--diagnostic-include-agent-output` for private escalations when the maintainer needs the raw agent response; such artifacts are explicitly marked sensitive.
21
+ - Made CLI failures print numbered reporting steps, including where to find the diagnostic artifact, what command and scope to attach, and what to do when the artifact itself cannot be written.
22
+ - Improved missing-agent and authentication guidance, including actionable commands for installing or signing in to Codex or Claude Code.
23
+
24
+ ### Packaging, Homebrew, and documentation
25
+
26
+ - Added native `better-sqlite3` rebuild coverage to the test lifecycle and a packaged-binding smoke test in the Homebrew formula.
27
+ - Pinned the 0.1.1 and 0.1.2 Homebrew source archive hashes and documented the tap trust step and release hash workflow.
28
+ - Documented Node.js runtime and native dependency rules, merge-base review scoping, cached analysis behavior, explicit agent selection, conversation imports, and the failure-diagnostic reporting process.
29
+ - Removed a misleading Homebrew hero command from the project site.
30
+
31
+ ### Verification
32
+
33
+ - Expanded analysis, CLI, parser, packaging, and diagnostic regression coverage. The release checks are `npm run lint`, `npm test`, `npm run build`, and `npm pack --dry-run`.
34
+
35
+ ### Homebrew publication follow-up
36
+
37
+ The formula remains pinned to the last published 0.1.2 archive until the 0.2.0 Git tag exists on GitHub. After publishing the tag, compute the archive SHA-256 with the command documented at the top of `Formula/ndrstnd.rb`, update that formula’s URL and hash, run Homebrew audit/install checks, and commit the formula update.
38
+
39
+ ## 0.1.0
40
+
41
+ - Initial public ndrstnd release as a local comprehension workspace with Story, Timeline, Test plan, and Full diff views.
package/README.md CHANGED
@@ -41,6 +41,12 @@ Without a branch, ndrstnd reviews the checked-out branch including staged, unsta
41
41
 
42
42
  Add `--conversation path/to/ndrstnd-conversation-v1.json` to ground the narrative in the dialogue that produced the branch: motives, rejected alternatives, constraints, and any observed test runs feed the Story and the Test plan.
43
43
 
44
+ ## When a review fails
45
+
46
+ ndrstnd never creates a partial or invented HTML review. If a review reaches the repository and fails during collection, agent analysis, validation, or artifact writing, it writes a shareable diagnostic JSON file under that repository’s `.ndrstnd/` directory and prints numbered reporting steps. Keep the file unchanged and share it with the exact command, selected agent, and intended base/target scope. It contains environment and scope metadata plus hashed prompt/response metadata, but excludes prompts, diffs, environment variables, and raw agent responses by default.
47
+
48
+ If the maintainer needs to inspect the agent’s response, rerun the same command with `--diagnostic-include-agent-output` and share that diagnostic only through a private channel; it is explicitly marked as sensitive. If ndrstnd cannot write the diagnostic file, preserve the complete terminal output instead. Do not attach credentials, tokens, or repository contents unless requested through a secure channel.
49
+
44
50
  ## Scope
45
51
 
46
52
  ndrstnd is for understanding code: it explains the implementation story, evidence, risk signals, and selected lines. It deliberately does not critique the change, submit review comments, or edit the branch.
@@ -54,7 +54,7 @@ export declare function buildPromptReviewInput(input: CollectedReviewInput, conv
54
54
  signal: import("../shared/domain.js").FileSignal;
55
55
  signalReason: string | undefined;
56
56
  hunks: {
57
- id: string;
57
+ index: number;
58
58
  fileId: string;
59
59
  path: string | undefined;
60
60
  oldStart: number;
@@ -73,11 +73,11 @@ export declare function buildPromptReviewInput(input: CollectedReviewInput, conv
73
73
  }[];
74
74
  }[];
75
75
  construction: {
76
- suggestedEvidenceOrder: string[];
76
+ suggestedEvidenceOrder: number[];
77
77
  defineBeforeUse: {
78
78
  symbol: string | undefined;
79
- definedIn: string;
80
- usedIn: string;
79
+ definedIn: number;
80
+ usedIn: number;
81
81
  }[];
82
82
  };
83
83
  conversation: {
@@ -1,8 +1,8 @@
1
- import { AnalysisDocumentSchema } from "../shared/analysis-schema.js";
1
+ import { AnalysisDocumentSchema, TestExecutionSchema } from "../shared/analysis-schema.js";
2
2
  import { z } from "zod";
3
3
  import { deriveEvidenceOrder } from "./evidence-ordering.js";
4
4
  export function parseAnalysisDocument(value, input, options) {
5
- const document = parseWireAnalysisDocument(value);
5
+ const document = parseWireAnalysisDocument(value, input);
6
6
  const duplicateChapterIds = duplicated(document.chapters.map((chapter) => chapter.id));
7
7
  if (duplicateChapterIds.length > 0)
8
8
  throw new Error(`Analysis chapter ids are duplicated: ${duplicateChapterIds.join(", ")}. Give every chapter in c a unique id.`);
@@ -98,6 +98,59 @@ export const PROSE_WORD_RANGES = {
98
98
  goal: { min: 12, max: 40 },
99
99
  youNowHave: { min: 12, max: 40 },
100
100
  };
101
+ const ManifestIndexSchema = z.number().int().min(0);
102
+ const AgentDeferredSchema = z.object({
103
+ concern: z.string().min(1).max(700),
104
+ resolvedByStepId: z.string().min(1).max(80).nullable().optional(),
105
+ });
106
+ const AgentForwardRefSchema = z.object({
107
+ symbol: z.string().min(1),
108
+ introducedByStepId: z.string().min(1).max(80),
109
+ });
110
+ const AgentChapterSchema = z.object({
111
+ id: z.string().min(1).max(80),
112
+ title: z.string().min(1).max(120),
113
+ kind: z.enum(["feature", "decision", "behavior", "non_functional", "risk", "test", "other"]),
114
+ synopsis: z.string().min(1).max(1_200),
115
+ before: z.string().max(900).nullable().optional(),
116
+ after: z.string().max(900).nullable().optional(),
117
+ confidence: z.enum(["high", "medium", "low"]),
118
+ attention: z.enum(["low", "contained", "elevated", "high", "critical"]),
119
+ riskCategories: z.array(z.enum(["formatting", "refactor", "behavior", "performance", "security"])),
120
+ evidenceIndexes: z.array(ManifestIndexSchema).min(1),
121
+ });
122
+ const AgentStepSchema = z.object({
123
+ id: z.string().min(1).max(80),
124
+ title: z.string().min(1).max(120),
125
+ goal: z.string().min(1).max(900),
126
+ youNowHave: z.string().min(1).max(900),
127
+ deferred: z.array(AgentDeferredSchema),
128
+ dependsOn: z.array(z.string().min(1).max(80)),
129
+ forwardRefs: z.array(AgentForwardRefSchema),
130
+ advancesChapterIds: z.array(z.string().min(1).max(80)).min(1),
131
+ evidenceIndexes: z.array(ManifestIndexSchema).min(1),
132
+ });
133
+ const AgentOmittedGroupSchema = z.object({
134
+ title: z.string().min(1).max(240),
135
+ reason: z.string().min(1).max(700),
136
+ evidenceIndexes: z.array(ManifestIndexSchema).min(1),
137
+ });
138
+ const AgentFocusSchema = z.object({
139
+ evidenceIndex: ManifestIndexSchema,
140
+ ranges: z.array(z.object({
141
+ startLine: z.number().int().min(1),
142
+ endLine: z.number().int().min(1),
143
+ })).min(1).max(5),
144
+ });
145
+ const AgentAnalysisDocumentSchema = z.object({
146
+ summary: z.string().min(1).max(1_600),
147
+ chapters: z.array(AgentChapterSchema),
148
+ steps: z.array(AgentStepSchema),
149
+ omittedGroups: z.array(AgentOmittedGroupSchema),
150
+ unclassifiedEvidenceIndexes: z.array(ManifestIndexSchema),
151
+ focus: z.array(AgentFocusSchema).optional(),
152
+ testExecution: z.array(TestExecutionSchema).max(5).optional(),
153
+ });
101
154
  function duplicated(values) {
102
155
  const seen = new Set();
103
156
  const dupes = new Set();
@@ -182,21 +235,31 @@ function validateStepPlan(document, input, meaningfulEvidence) {
182
235
  if ((stepIndex.get(before.id) ?? 0) < (stepIndex.get(after.id) ?? 0))
183
236
  continue;
184
237
  if (after.forwardRefs[constraint.symbol] !== before.id) {
185
- throw new Error(`Analysis step order violates symbol ${constraint.symbol}: ${before.id} must come before ${after.id}. Reorder the steps, or declare forwardRefs {"${constraint.symbol}":"${before.id}"} on ${after.id} if the forward use is intentional.`);
238
+ throw new Error(`Analysis step order violates symbol ${constraint.symbol}: ${before.id} must come before ${after.id}. Reorder the steps, or declare forwardRefs [{"symbol":"${constraint.symbol}","introducedByStepId":"${before.id}"}] on ${after.id} if the forward use is intentional.`);
186
239
  }
187
240
  }
188
241
  }
242
+ const AGENT_ANALYSIS_FORMAT = `Return exactly one JSON object using explicit property names. This is intentionally more verbose than the legacy compact format: never use the short keys s, c, t, o, u, f, or x; never use positional arrays or hunk ID strings.
243
+
244
+ The top-level properties are summary, chapters, steps, omittedGroups, unclassifiedEvidenceIndexes, and optionally focus and testExecution. Chapters are objects with id, title, kind, synopsis, before, after, confidence, attention, riskCategories, and evidenceIndexes. Use null for before or after when there is no concrete before or after description. Steps are objects with id, title, goal, youNowHave, deferred, dependsOn, forwardRefs, advancesChapterIds, and evidenceIndexes. Each deferred item is an object with concern and optional resolvedByStepId; use null when no later step resolves it. Each forward reference is an object with symbol and introducedByStepId. Focus is an array of objects with evidenceIndex and ranges; each range is an object with startLine and endLine. Test executions are objects with command, outcome, summary, and source.
245
+
246
+ All evidenceIndexes and unclassifiedEvidenceIndexes values are zero-based integer indexes into the numbered review manifest. Each meaningful evidence index must appear exactly once in a chapter, omitted group, or unclassifiedEvidenceIndexes, and exactly once in steps. Group every low-signal evidence index into an omitted group with a short reason; unclassifiedEvidenceIndexes is a last resort. Focus drives the Evidence zoom excerpts: include it only for chapter evidence and give each focused evidence index 1-3 new-file line ranges. Omit testExecution when no run was actually observed; never invent execution evidence. Use empty arrays for no deferred concerns, dependencies, forward references, omitted groups, or unclassified evidence. This is the canonical shape, and every object field must be present except before, after, resolvedByStepId, focus, and testExecution.
247
+
248
+ Canonical shape:
249
+ {"summary":"...","chapters":[{"id":"chapter-01","title":"...","kind":"behavior","synopsis":"...","before":null,"after":null,"confidence":"medium","attention":"contained","riskCategories":["behavior"],"evidenceIndexes":[0]}],"steps":[{"id":"step-01","title":"...","goal":"...","youNowHave":"...","deferred":[{"concern":"...","resolvedByStepId":null}],"dependsOn":[],"forwardRefs":[],"advancesChapterIds":["chapter-01"],"evidenceIndexes":[0]}],"omittedGroups":[],"unclassifiedEvidenceIndexes":[],"focus":[{"evidenceIndex":0,"ranges":[{"startLine":10,"endLine":12}]}],"testExecution":[{"command":"npm test","outcome":"passed","summary":"...","source":"repository"}]}`;
189
250
  export function analysisPrompt(input, conversation) {
190
251
  const reviewInput = buildPromptReviewInput(input, conversation);
191
- return `You are ndrstnd, a comprehension assistant. Explain a branch without critiquing it or proposing changes. Prioritize the implementation story and behavior changes. Return compact minified JSON only with this shape: {s,c:[[id,title,kind,synopsis,before|null,after|null,confidence,attention,riskCategories,evidenceIds]],t:[[id,title,goal,youNowHave,[[concern,resolvedByStepId|null]],dependsOn,forwardRefs,advancesChapterIds,evidenceIds]],o:[[title,reason,evidenceIds]],u:[evidenceId],f:{evidenceId:[[startLine,endLine]]},x:[[command,outcome,summary,source]]}. In each c tuple, position 3 is kind and may be exactly feature, decision, behavior, non_functional, risk, test, or other; position 9 is riskCategories and may contain only formatting, refactor, behavior, performance, or security. Confidence is high, medium, or low. Attention is low, contained, elevated, high, or critical. Use only listed evidence IDs. Every meaningful evidence ID must appear exactly once in c, o, or u, and exactly once in t. Group every low-signal evidence ID into an omitted group in o with a concise reason such as lockfile churn or generated output; u is a last resort for evidence that truly cannot be classified. f drives the Evidence zoom excerpts: for every evidence ID used in c, list 1-3 [startLine,endLine] ranges of new-file line numbers marking the exact lines a reviewer must read first - the load-bearing statements, not whole hunks, imports, or boilerplate. Each range must fall inside that hunk's new-file lines; inspect the patch to choose them precisely. x reports test or build runs that were actually observed: when the conversation or repository shows a command and its result, add [command,outcome,summary,source] with outcome passed, failed, mixed, or unknown and source conversation or repository. Omit x entirely when no run was observed - never invent execution evidence.
252
+ return `You are ndrstnd, a comprehension assistant. Explain a branch without critiquing it or proposing changes. Prioritize the implementation story and behavior changes.
253
+
254
+ ${AGENT_ANALYSIS_FORMAT}
192
255
 
193
256
  Prose depth is validated and out-of-range fields are rejected, so hit these word counts: summary ${PROSE_WORD_RANGES.summary.min}-${PROSE_WORD_RANGES.summary.max} words; each synopsis ${PROSE_WORD_RANGES.synopsis.min}-${PROSE_WORD_RANGES.synopsis.max} words across two or three sentences explaining what changed, how it works, and why it matters; before and after ${PROSE_WORD_RANGES.beforeAfter.min}-${PROSE_WORD_RANGES.beforeAfter.max} words each describing concrete observable behavior; each step goal ${PROSE_WORD_RANGES.goal.min}-${PROSE_WORD_RANGES.goal.max} words stating the intent and mechanism; each youNowHave ${PROSE_WORD_RANGES.youNowHave.min}-${PROSE_WORD_RANGES.youNowHave.max} words stating the capability that now exists. Titles stay under 10 words. Name the actual functions, types, and files involved. Never answer with a single vague sentence, and never pad - every sentence must add information a reviewer can act on.
194
257
 
195
- Timeline steps are a rational reconstruction of how to build this branch. They are not commit chronology, file order, or the Story chapters repeated. Each step must be one capability increment; explain its intent in goal, its postcondition in youNowHave, intentionally postponed concerns in deferred, earlier step ids in dependsOn, unavoidable forward symbol uses in forwardRefs as {"Symbol":"later-step-id"}, the Story chapters it advances, and its evidence IDs. The manifest's construction.defineBeforeUse entries are hard ordering rules: each symbol must be defined in the same or an earlier step than its use, or the using step must declare it in forwardRefs. construction.suggestedEvidenceOrder is one valid linearization you may regroup into steps.
258
+ Timeline steps are a rational reconstruction of how to build this branch. They are not commit chronology, file order, or the Story chapters repeated. Each step must be one capability increment; explain its intent in goal, its postcondition in youNowHave, intentionally postponed concerns in deferred, earlier step ids in dependsOn, unavoidable forward symbol uses in forwardRefs as objects with symbol and introducedByStepId, the Story chapters it advances, and its evidence indexes. The manifest's construction.defineBeforeUse entries are hard ordering rules: each symbol must be defined in the same or an earlier step than its use, or the using step must declare it in forwardRefs. construction.suggestedEvidenceOrder is one valid linearization of manifest indexes you may regroup into steps.
196
259
 
197
260
  When the review input includes conversation, it is the dialogue between the user and the coding agent that produced this branch. Treat it as primary evidence of intent: explain why changes were made, which alternatives were considered or rejected, and which incidents, requirements, or downstream consumers motivated them, weaving those stated reasons into the summary, chapter synopses, before/after, step goals, and deferred concerns instead of guessing intent from the code alone. Attribute reasons faithfully - never invent motives the conversation does not support, and never copy credentials or secrets from it. When conversation is absent, ground every claim in the diff and repository alone.
198
261
 
199
- You are running in the reviewed repository with a read-only sandbox. The review input below is a compact manifest. Hunks that carry a patch field include their complete diff text inline - never re-fetch those with git. For hunks without inline patch text, or when you need surrounding code, use the file paths, hunk IDs, line anchors, and suggested git commands to inspect only what you still need for a high-quality comprehension story. Prefer grouping related evidence IDs by behavior or decision instead of mirroring path order.
262
+ You are running in the reviewed repository with a read-only sandbox. The review input below is a compact manifest. Hunks that carry a patch field include their complete diff text inline - never re-fetch those with git. For hunks without inline patch text, or when you need surrounding code, use the file paths, manifest indexes, line anchors, and suggested git commands to inspect only what you still need for a high-quality comprehension story. Prefer grouping related evidence indexes by behavior or decision instead of mirroring path order.
200
263
 
201
264
  Review input:
202
265
  ${JSON.stringify(reviewInput)}`;
@@ -212,7 +275,7 @@ export function buildPromptReviewInput(input, conversation) {
212
275
  const filesById = new Map(input.files.map((file) => [file.id, file]));
213
276
  const hunksByFile = new Map();
214
277
  let inlinePatchBudget = INLINE_PATCH_BUDGET;
215
- for (const hunk of input.hunks) {
278
+ for (const [index, hunk] of input.hunks.entries()) {
216
279
  const file = filesById.get(hunk.fileId);
217
280
  let patch;
218
281
  if (file?.signal === "meaningful" && !file.binary && hunk.lines.length > 0) {
@@ -222,7 +285,7 @@ export function buildPromptReviewInput(input, conversation) {
222
285
  patch = text;
223
286
  }
224
287
  }
225
- const compact = compactHunk(hunk, file?.path, patch);
288
+ const compact = compactHunk(hunk, file?.path, patch, index);
226
289
  const list = hunksByFile.get(hunk.fileId) ?? [];
227
290
  list.push(compact);
228
291
  hunksByFile.set(hunk.fileId, list);
@@ -237,7 +300,7 @@ export function buildPromptReviewInput(input, conversation) {
237
300
  summaryCommand: `git diff --stat --find-renames --find-copies ${diffRange(input)}`,
238
301
  patchCommand: `git diff --no-ext-diff --unified=80 --find-renames --find-copies ${diffRange(input)} -- <path>`,
239
302
  currentFileCommand: "sed -n '<start>,<end>p' <path>",
240
- note: "Hunks with a patch field carry their complete diff inline; run the patch command only for hunks without one or when surrounding code matters. For untracked working-tree files, inspect the current file directly and use the manifest hunk anchors as evidence IDs.",
303
+ note: "Hunks with a patch field carry their complete diff inline; run the patch command only for hunks without one or when surrounding code matters. For untracked working-tree files, inspect the current file directly. Refer to hunks by their numbered manifest index, never by inventing an identifier.",
241
304
  },
242
305
  files: input.files.map((file) => ({
243
306
  id: file.id,
@@ -250,10 +313,10 @@ export function buildPromptReviewInput(input, conversation) {
250
313
  hunks: hunksByFile.get(file.id) ?? [],
251
314
  })),
252
315
  construction: {
253
- suggestedEvidenceOrder: evidenceOrder.orderedEvidenceIds,
316
+ suggestedEvidenceOrder: evidenceOrder.orderedEvidenceIds.map((id) => input.hunks.findIndex((hunk) => hunk.id === id)),
254
317
  defineBeforeUse: evidenceOrder.constraints
255
318
  .filter((constraint) => constraint.reason === "symbol")
256
- .map((constraint) => ({ symbol: constraint.symbol, definedIn: constraint.beforeEvidenceId, usedIn: constraint.afterEvidenceId })),
319
+ .map((constraint) => ({ symbol: constraint.symbol, definedIn: input.hunks.findIndex((hunk) => hunk.id === constraint.beforeEvidenceId), usedIn: input.hunks.findIndex((hunk) => hunk.id === constraint.afterEvidenceId) })),
257
320
  },
258
321
  conversation: compactConversation(conversation),
259
322
  };
@@ -265,88 +328,213 @@ const CompactChapterSchema = z.tuple([
265
328
  z.string().min(1).max(80),
266
329
  z.string().min(1).max(120),
267
330
  KindSchema,
268
- z.string().min(1).max(420),
269
- z.string().max(300).nullable(),
270
- z.string().max(300).nullable(),
331
+ z.string().min(1).max(1_200),
332
+ z.string().max(900).nullable(),
333
+ z.string().max(900).nullable(),
271
334
  ConfidenceSchema,
272
335
  z.enum(["low", "contained", "elevated", "high", "critical"]),
273
336
  z.array(CompactRiskCategorySchema),
274
- z.array(z.string().min(1)).min(1),
337
+ z.array(z.union([z.number().int().min(0), z.string().min(1)])).min(1),
275
338
  ]);
276
339
  const CompactStepSchema = z.tuple([
277
340
  z.string().min(1).max(80),
278
341
  z.string().min(1).max(120),
279
- z.string().min(1).max(320),
280
- z.string().min(1).max(320),
281
- z.array(z.tuple([z.string().min(1).max(220), z.string().min(1).max(80).nullable()])),
342
+ z.string().min(1).max(900),
343
+ z.string().min(1).max(900),
344
+ z.array(z.tuple([z.string().min(1).max(700), z.string().min(1).max(80).nullable()])),
282
345
  z.array(z.string().min(1).max(80)),
283
346
  z.record(z.string().min(1), z.string().min(1).max(80)),
284
347
  z.array(z.string().min(1).max(80)).min(1),
285
- z.array(z.string().min(1)).min(1),
348
+ z.array(z.union([z.number().int().min(0), z.string().min(1)])).min(1),
286
349
  ]);
350
+ const CompactEvidenceRefSchema = z.union([z.number().int().min(0), z.string().min(1)]);
351
+ const CompactChapterObjectSchema = z.object({
352
+ id: z.string().min(1).max(80),
353
+ title: z.string().min(1).max(120),
354
+ kind: KindSchema,
355
+ synopsis: z.string().min(1).max(1_200),
356
+ before: z.string().max(900).nullable().optional(),
357
+ after: z.string().max(900).nullable().optional(),
358
+ confidence: ConfidenceSchema,
359
+ attention: z.enum(["low", "contained", "elevated", "high", "critical"]),
360
+ riskCategories: z.array(CompactRiskCategorySchema),
361
+ evidenceIndexes: z.array(z.number().int().min(0)).min(1),
362
+ });
363
+ const CompactStepObjectSchema = z.object({
364
+ id: z.string().min(1).max(80),
365
+ title: z.string().min(1).max(120),
366
+ goal: z.string().min(1).max(900),
367
+ youNowHave: z.string().min(1).max(900),
368
+ deferred: z.array(AgentDeferredSchema),
369
+ dependsOn: z.array(z.string().min(1).max(80)),
370
+ forwardRefs: z.record(z.string().min(1), z.string().min(1).max(80)),
371
+ advancesChapterIds: z.array(z.string().min(1).max(80)).min(1),
372
+ evidenceIndexes: z.array(z.number().int().min(0)).min(1),
373
+ });
374
+ const CompactOmittedGroupSchema = z.tuple([z.string().min(1).max(240), z.string().min(1).max(700), z.array(CompactEvidenceRefSchema).min(1)]);
375
+ const CompactOmittedGroupObjectSchema = z.object({ title: z.string().min(1).max(240), reason: z.string().min(1).max(700), evidenceIndexes: z.array(z.number().int().min(0)).min(1) });
287
376
  const CompactAnalysisDocumentSchema = z.object({
288
- s: z.string().min(1).max(560),
289
- c: z.array(CompactChapterSchema),
290
- t: z.array(CompactStepSchema),
291
- o: z.array(z.tuple([z.string().min(1).max(120), z.string().min(1).max(220), z.array(z.string().min(1)).min(1)])),
292
- u: z.array(z.string().min(1)),
377
+ s: z.string().min(1).max(1_600),
378
+ c: z.array(z.union([CompactChapterSchema, CompactChapterObjectSchema])),
379
+ t: z.array(z.union([CompactStepSchema, CompactStepObjectSchema])),
380
+ o: z.array(z.union([CompactOmittedGroupSchema, CompactOmittedGroupObjectSchema])),
381
+ u: z.array(CompactEvidenceRefSchema),
293
382
  f: z.record(z.string().min(1), z.array(z.tuple([z.number().int().min(1), z.number().int().min(1)])).min(1).max(5)).optional(),
294
383
  x: z.array(z.tuple([z.string().min(1).max(200), z.enum(["passed", "failed", "mixed", "unknown"]), z.string().min(1).max(300), z.enum(["conversation", "repository"])])).max(5).optional(),
295
384
  });
296
- function parseWireAnalysisDocument(value) {
385
+ function parseWireAnalysisDocument(value, input) {
297
386
  const full = AnalysisDocumentSchema.safeParse(value);
298
387
  if (full.success)
299
388
  return full.data;
389
+ const agent = AgentAnalysisDocumentSchema.safeParse(value);
390
+ if (agent.success)
391
+ return normalizeAgentAnalysisDocument(agent.data, input);
300
392
  const compactResult = CompactAnalysisDocumentSchema.safeParse(value);
301
393
  if (!compactResult.success) {
302
394
  // Repair turns quote this message; reporting the schema the document was aiming for keeps those turns productive.
303
- const fullShaped = value !== null && typeof value === "object" && ("summary" in value || "chapters" in value);
304
- const [shape, error] = fullShaped ? ["full", full.error] : ["compact", compactResult.error];
305
- throw new Error(`Analysis document did not match the ${shape} shape: ${error.issues.slice(0, 8).map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).join("; ")}`);
395
+ const shape = isAgentShaped(value) ? "agent" : isFullShaped(value) ? "full" : "compact";
396
+ const error = shape === "agent" ? agent.error : shape === "full" ? full.error : compactResult.error;
397
+ throw new Error(`Analysis document did not match the ${shape} shape: ${formatSchemaIssues(error)}`);
306
398
  }
307
399
  const compact = compactResult.data;
400
+ const evidenceId = (reference) => typeof reference === "string" && !/^\d+$/.test(reference) ? reference : evidenceIdAtIndex(input, Number(reference));
308
401
  return AnalysisDocumentSchema.parse({
309
402
  summary: compact.s,
310
- chapters: compact.c.map((chapter) => ({
311
- id: chapter[0],
312
- title: chapter[1],
313
- kind: chapter[2],
314
- synopsis: chapter[3],
315
- before: chapter[4] ?? undefined,
316
- after: chapter[5] ?? undefined,
317
- confidence: chapter[6],
318
- attention: chapter[7],
319
- riskCategories: chapter[8],
320
- evidenceIds: chapter[9],
321
- })),
322
- steps: compact.t.map((step) => ({
323
- id: step[0],
324
- title: step[1],
325
- goal: step[2],
326
- youNowHave: step[3],
403
+ chapters: compact.c.map((chapter) => Array.isArray(chapter) ? {
404
+ id: chapter[0], title: chapter[1], kind: chapter[2], synopsis: chapter[3],
405
+ before: chapter[4] ?? undefined, after: chapter[5] ?? undefined,
406
+ confidence: chapter[6], attention: chapter[7], riskCategories: chapter[8],
407
+ evidenceIds: chapter[9].map(evidenceId),
408
+ } : {
409
+ id: chapter.id, title: chapter.title, kind: chapter.kind, synopsis: chapter.synopsis,
410
+ before: chapter.before ?? undefined, after: chapter.after ?? undefined,
411
+ confidence: chapter.confidence, attention: chapter.attention, riskCategories: chapter.riskCategories,
412
+ evidenceIds: chapter.evidenceIndexes.map(evidenceId),
413
+ }),
414
+ steps: compact.t.map((step) => Array.isArray(step) ? {
415
+ id: step[0], title: step[1], goal: step[2], youNowHave: step[3],
327
416
  deferred: step[4].map((item) => ({ concern: item[0], resolvedByStepId: item[1] ?? undefined })),
328
- dependsOn: step[5],
329
- forwardRefs: step[6],
330
- advancesChapterIds: step[7],
331
- evidenceIds: step[8],
332
- })),
333
- omittedGroups: compact.o.map((group) => ({ title: group[0], reason: group[1], evidenceIds: group[2] })),
334
- unclassifiedEvidenceIds: compact.u,
335
- focus: compact.f === undefined ? undefined : Object.fromEntries(Object.entries(compact.f).map(([evidenceId, ranges]) => [evidenceId, ranges.map(([start, end]) => ({ start, end }))])),
417
+ dependsOn: step[5], forwardRefs: step[6], advancesChapterIds: step[7],
418
+ evidenceIds: step[8].map(evidenceId),
419
+ } : {
420
+ id: step.id, title: step.title, goal: step.goal, youNowHave: step.youNowHave,
421
+ deferred: step.deferred.map((item) => ({ concern: item.concern, resolvedByStepId: item.resolvedByStepId ?? undefined })),
422
+ dependsOn: step.dependsOn, forwardRefs: step.forwardRefs, advancesChapterIds: step.advancesChapterIds,
423
+ evidenceIds: step.evidenceIndexes.map(evidenceId),
424
+ }),
425
+ omittedGroups: compact.o.map((group) => Array.isArray(group) ? { title: group[0], reason: group[1], evidenceIds: group[2].map(evidenceId) } : { title: group.title, reason: group.reason, evidenceIds: group.evidenceIndexes.map(evidenceId) }),
426
+ unclassifiedEvidenceIds: compact.u.map(evidenceId),
427
+ focus: compact.f === undefined ? undefined : Object.fromEntries(Object.entries(compact.f).map(([reference, ranges]) => [evidenceId(reference), ranges.map(([start, end]) => ({ start, end }))])),
336
428
  testExecution: compact.x === undefined ? undefined : compact.x.map(([command, outcome, summary, source]) => ({ command, outcome, summary, source })),
337
429
  });
338
430
  }
431
+ function normalizeAgentAnalysisDocument(document, input) {
432
+ return AnalysisDocumentSchema.parse({
433
+ summary: document.summary,
434
+ chapters: document.chapters.map((chapter) => ({
435
+ id: chapter.id,
436
+ title: chapter.title,
437
+ kind: chapter.kind,
438
+ synopsis: chapter.synopsis,
439
+ before: chapter.before ?? undefined,
440
+ after: chapter.after ?? undefined,
441
+ confidence: chapter.confidence,
442
+ attention: chapter.attention,
443
+ riskCategories: chapter.riskCategories,
444
+ evidenceIds: chapter.evidenceIndexes.map((index) => evidenceIdAtIndex(input, index)),
445
+ })),
446
+ steps: document.steps.map((step) => ({
447
+ id: step.id,
448
+ title: step.title,
449
+ goal: step.goal,
450
+ youNowHave: step.youNowHave,
451
+ deferred: step.deferred.map((item) => ({ concern: item.concern, resolvedByStepId: item.resolvedByStepId ?? undefined })),
452
+ dependsOn: step.dependsOn,
453
+ forwardRefs: normalizeForwardRefs(step.id, step.forwardRefs),
454
+ advancesChapterIds: step.advancesChapterIds,
455
+ evidenceIds: step.evidenceIndexes.map((index) => evidenceIdAtIndex(input, index)),
456
+ })),
457
+ omittedGroups: document.omittedGroups.map((group) => ({
458
+ title: group.title,
459
+ reason: group.reason,
460
+ evidenceIds: group.evidenceIndexes.map((index) => evidenceIdAtIndex(input, index)),
461
+ })),
462
+ unclassifiedEvidenceIds: document.unclassifiedEvidenceIndexes.map((index) => evidenceIdAtIndex(input, index)),
463
+ focus: normalizeFocus(document.focus, input),
464
+ testExecution: document.testExecution,
465
+ });
466
+ }
467
+ function evidenceIdAtIndex(input, index) {
468
+ const hunk = input.hunks[index];
469
+ if (hunk === undefined)
470
+ throw new Error(`Evidence index ${index} is outside the review input manifest.`);
471
+ return hunk.id;
472
+ }
473
+ function normalizeForwardRefs(stepId, refs) {
474
+ const normalized = {};
475
+ for (const ref of refs) {
476
+ if (normalized[ref.symbol] !== undefined)
477
+ throw new Error(`Analysis step ${stepId} declares forward reference ${ref.symbol} more than once.`);
478
+ normalized[ref.symbol] = ref.introducedByStepId;
479
+ }
480
+ return normalized;
481
+ }
482
+ function normalizeFocus(focus, input) {
483
+ if (focus === undefined)
484
+ return undefined;
485
+ const normalized = {};
486
+ for (const entry of focus) {
487
+ const evidenceId = evidenceIdAtIndex(input, entry.evidenceIndex);
488
+ if (normalized[evidenceId] !== undefined)
489
+ throw new Error(`Focus declares evidence index ${entry.evidenceIndex} more than once.`);
490
+ normalized[evidenceId] = entry.ranges.map((range) => ({ start: range.startLine, end: range.endLine }));
491
+ }
492
+ return normalized;
493
+ }
494
+ function isFullShaped(value) {
495
+ return value !== null && typeof value === "object" && ("summary" in value || "chapters" in value || "steps" in value);
496
+ }
497
+ function isAgentShaped(value) {
498
+ if (!isFullShaped(value))
499
+ return false;
500
+ if ("unclassifiedEvidenceIndexes" in value)
501
+ return true;
502
+ const objectValue = value;
503
+ const collections = [objectValue["chapters"], objectValue["steps"], objectValue["omittedGroups"]];
504
+ return collections.some((collection) => Array.isArray(collection) && collection.some((item) => item !== null && typeof item === "object" && "evidenceIndexes" in item));
505
+ }
506
+ function formatSchemaIssues(error) {
507
+ const issues = collectSchemaIssues(error).slice(0, 8);
508
+ return issues.length === 0
509
+ ? "document: Invalid input"
510
+ : issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).join("; ");
511
+ }
512
+ function collectSchemaIssues(error, prefix = []) {
513
+ const messages = [];
514
+ for (const issue of error.issues) {
515
+ const path = [...prefix, ...issue.path];
516
+ if (issue.code === "invalid_union") {
517
+ const nested = issue.unionErrors.flatMap((unionError) => collectSchemaIssues(unionError, path));
518
+ const specific = nested.filter((candidate) => candidate.path.length > path.length);
519
+ messages.push(...(specific.length > 0 ? specific : nested));
520
+ }
521
+ else {
522
+ messages.push({ path, message: issue.message });
523
+ }
524
+ }
525
+ return messages;
526
+ }
339
527
  function hunkPatchText(hunk) {
340
528
  const markers = { context: " ", addition: "+", deletion: "-" };
341
529
  const oldCount = hunk.lines.filter((line) => line.kind !== "addition").length;
342
530
  const newCount = hunk.lines.filter((line) => line.kind !== "deletion").length;
343
531
  return [`@@ -${hunk.oldStart},${oldCount} +${hunk.newStart},${newCount} @@`, ...hunk.lines.map((line) => `${markers[line.kind]}${line.content}`)].join("\n");
344
532
  }
345
- function compactHunk(hunk, path, patch) {
533
+ function compactHunk(hunk, path, patch, index) {
346
534
  const additions = hunk.lines.filter((line) => line.kind === "addition");
347
535
  const deletions = hunk.lines.filter((line) => line.kind === "deletion");
348
536
  const context = hunk.lines.length - additions.length - deletions.length;
349
- // Samples only anchor hunk IDs to recognizable content; the agent reads the inline
537
+ // Samples anchor manifest indexes to recognizable content; the agent reads the inline
350
538
  // patch or inspects the real one for detail, so two short previews per hunk are enough.
351
539
  const sampleLines = deletions.length > 0 && additions.length > 0 ? [deletions[0], additions[0]] : [...deletions, ...additions].slice(0, 2);
352
540
  const changedLineSamples = sampleLines.map((line) => ({
@@ -356,7 +544,7 @@ function compactHunk(hunk, path, patch) {
356
544
  preview: line.content.trim().slice(0, 100),
357
545
  }));
358
546
  return {
359
- id: hunk.id,
547
+ index,
360
548
  fileId: hunk.fileId,
361
549
  path,
362
550
  oldStart: hunk.oldStart,