@staix/agent-hub 0.30.0 → 0.32.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
@@ -0,0 +1,260 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { openHubDatabase, type Database } from "./db-schema.ts";
3
+
4
+ /**
5
+ * #1053 T2: structured questions a peer asks the person. A row is the one record that the waiting tool call, the late-answer envelope and the inbox all read.
6
+ * This module knows the row shape and its state machine only: it never learns what `hub_ask` is, never touches a bus, a socket or a timer, and never logs.
7
+ * Question and answer text are stored here and nowhere else (hub.db, like a task title): the daemon keeps them out of logs, events and memory capture.
8
+ */
9
+
10
+ export const QUESTION_STATES = ["open", "answered", "superseded", "cancelled", "expired"] as const;
11
+ export type QuestionState = (typeof QUESTION_STATES)[number];
12
+ /** Why a row left `open` without an answer; a closed list, so a reason can be shown and counted without any text. */
13
+ export const QUESTION_REASONS = ["superseded", "stopped", "turn_aborted", "session_changed", "expired", "unattended"] as const;
14
+ export type QuestionReason = (typeof QUESTION_REASONS)[number];
15
+ /** Where an answer came from. `acon` is the plugin action of T5; the others are the console's own surfaces. */
16
+ export const QUESTION_SURFACES = ["terminal", "console", "dashboard", "acon"] as const;
17
+ export type QuestionSurface = (typeof QUESTION_SURFACES)[number];
18
+
19
+ export const MAX_QUESTIONS = 4, MAX_OPTIONS = 8, MIN_OPTIONS = 2;
20
+ export const MAX_PROMPT = 500, MAX_LABEL = 80, MAX_DESCRIPTION = 240, MAX_OTHER = 500;
21
+ /** Bounds on rows that can wait at once: one peer cannot fill the inbox, and the whole table stays small. */
22
+ export const MAX_OPEN_PER_PEER = 8, MAX_OPEN_TOTAL = 64;
23
+ const ID = /^[A-Za-z0-9_.-]{1,64}$/;
24
+ export const QUESTION_SET = /^qs-[a-f0-9]{12}$/;
25
+ /** Controls other than tab and newline: they never reach a person's screen or another agent's prompt. */
26
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/;
27
+
28
+ export interface AskOption { id: string; label: string; description?: string }
29
+ export interface AskQuestion { id: string; prompt: string; options: AskOption[]; multi?: boolean; allowOther?: boolean }
30
+ export interface QuestionAnswer { questionId: string; optionIds: string[]; other?: string }
31
+ export interface AskInput { questions: AskQuestion[]; task?: number }
32
+
33
+ export interface QuestionRow {
34
+ set: string;
35
+ peer: string;
36
+ /** The peer's native session when it asked; "" when the adapter reports none (unknown never cancels). */
37
+ session: string;
38
+ task?: number;
39
+ /** Asked in a PII turn or about a PII task: surfaces show a stub title and the late envelope is private. */
40
+ private: boolean;
41
+ state: QuestionState;
42
+ created: number;
43
+ settled?: number;
44
+ reason?: QuestionReason;
45
+ surface?: QuestionSurface;
46
+ /** The console's verified identity (`ActionMeta.identity`) of an answer given in the acon panel; absent for the terminal and the hub's own console, whose person is the local user. */
47
+ person?: string;
48
+ questions: AskQuestion[];
49
+ answers?: QuestionAnswer[];
50
+ /** 0 nothing to publish, 1 a hub envelope (late answer or expiry notice) is owed to the peer, 2 it was published. */
51
+ envelope: 0 | 1 | 2;
52
+ }
53
+
54
+ type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
55
+ const fail = (error: string): { ok: false; error: string } => ({ ok: false, error });
56
+ const plain = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
57
+ const length = (text: string): number => Array.from(text).length;
58
+ const closed = (value: Record<string, unknown>, keys: readonly string[]): boolean => Object.keys(value).every(key => keys.includes(key));
59
+ /** Text a person reads: no controls, trimmed, bounded by code points. Messages name the field, never the value. */
60
+ function text(value: unknown, field: string, max: number, optional = false): Parsed<string | undefined> {
61
+ if (value === undefined && optional) return { ok: true, value: undefined };
62
+ if (typeof value !== "string") return fail(`${field} must be a string`);
63
+ const trimmed = value.trim();
64
+ if (!trimmed) return fail(`${field} must not be empty`);
65
+ if (length(trimmed) > max) return fail(`${field} is longer than ${max} characters`);
66
+ if (CONTROL.test(trimmed)) return fail(`${field} holds a control character`);
67
+ return { ok: true, value: trimmed };
68
+ }
69
+
70
+ /** The `hub_ask` input, closed and bounded exactly as the spec's Design section. Never echoes a value. */
71
+ export function parseAsk(input: unknown): Parsed<AskInput> {
72
+ if (!plain(input) || !closed(input, ["questions", "task"])) return fail("hub_ask takes questions and an optional task");
73
+ if (!Array.isArray(input.questions) || input.questions.length < 1 || input.questions.length > MAX_QUESTIONS) return fail(`questions must hold 1 to ${MAX_QUESTIONS} questions`);
74
+ let task: number | undefined;
75
+ if (input.task !== undefined) {
76
+ if (typeof input.task !== "number" || !Number.isSafeInteger(input.task) || input.task < 1) return fail("task must be a positive task number");
77
+ task = input.task;
78
+ }
79
+ const questions: AskQuestion[] = [], seen = new Set<string>();
80
+ for (const [index, raw] of input.questions.entries()) {
81
+ const at = `questions[${index}]`;
82
+ if (!plain(raw) || !closed(raw, ["id", "prompt", "options", "multi", "allowOther"])) return fail(`${at} takes id, prompt, options, multi and allowOther`);
83
+ if (typeof raw.id !== "string" || !ID.test(raw.id)) return fail(`${at}.id must be 1 to 64 characters of letters, digits, dot, dash or underscore`);
84
+ if (seen.has(raw.id)) return fail(`${at}.id repeats an earlier question id`);
85
+ seen.add(raw.id);
86
+ const prompt = text(raw.prompt, `${at}.prompt`, MAX_PROMPT);
87
+ if (!prompt.ok) return prompt;
88
+ if (!Array.isArray(raw.options) || raw.options.length < MIN_OPTIONS || raw.options.length > MAX_OPTIONS) return fail(`${at}.options must hold ${MIN_OPTIONS} to ${MAX_OPTIONS} options`);
89
+ if (raw.multi !== undefined && typeof raw.multi !== "boolean") return fail(`${at}.multi must be a boolean`);
90
+ if (raw.allowOther !== undefined && typeof raw.allowOther !== "boolean") return fail(`${at}.allowOther must be a boolean`);
91
+ const options: AskOption[] = [], ids = new Set<string>();
92
+ for (const [position, option] of raw.options.entries()) {
93
+ const where = `${at}.options[${position}]`;
94
+ if (!plain(option) || !closed(option, ["id", "label", "description"])) return fail(`${where} takes id, label and description`);
95
+ if (typeof option.id !== "string" || !ID.test(option.id)) return fail(`${where}.id must be 1 to 64 characters of letters, digits, dot, dash or underscore`);
96
+ if (ids.has(option.id)) return fail(`${where}.id repeats an earlier option id`);
97
+ ids.add(option.id);
98
+ const label = text(option.label, `${where}.label`, MAX_LABEL);
99
+ if (!label.ok) return label;
100
+ const description = text(option.description, `${where}.description`, MAX_DESCRIPTION, true);
101
+ if (!description.ok) return description;
102
+ options.push({ id: option.id, label: label.value!, ...(description.value !== undefined ? { description: description.value } : {}) });
103
+ }
104
+ questions.push({ id: raw.id, prompt: prompt.value!, options, ...(raw.multi === true ? { multi: true } : {}), ...(raw.allowOther === true ? { allowOther: true } : {}) });
105
+ }
106
+ return { ok: true, value: { questions, ...(task !== undefined ? { task } : {}) } };
107
+ }
108
+
109
+ /**
110
+ * A person's answers against the stored row: every question answered once, only known option ids, one option unless `multi`, free text only where
111
+ * `allowOther` and at most 500 characters. Accepts the array form (`[{ questionId, optionIds, other? }]`, the tool result's) and the record form acon
112
+ * submits (`{ [questionId]: { selected, other? } }`). A reason names a question by position, never its text or the offered value.
113
+ */
114
+ export function validateAnswers(questions: readonly AskQuestion[], input: unknown): Parsed<QuestionAnswer[]> {
115
+ let rows: unknown[];
116
+ // The record form is rewritten into the array form; a malformed record entry is remembered by position, never by a marker key a caller could also send.
117
+ const malformed = new Set<number>();
118
+ if (Array.isArray(input)) rows = input;
119
+ else if (plain(input)) rows = Object.entries(input).map(([questionId, value], position) => {
120
+ if (!plain(value) || !closed(value, ["selected", "other"])) { malformed.add(position); return { questionId }; }
121
+ return { questionId, optionIds: value.selected, ...(value.other !== undefined ? { other: value.other } : {}) };
122
+ });
123
+ else return fail("answers must be a list or a map of question ids");
124
+ if (rows.length > MAX_QUESTIONS) return fail("answers name more questions than were asked");
125
+ const byId = new Map(questions.map((question, index) => [question.id, { question, index }])), answers = new Map<string, QuestionAnswer>();
126
+ for (const [position, raw] of rows.entries()) {
127
+ if (malformed.has(position) || !plain(raw) || !closed(raw, ["questionId", "optionIds", "other"]) || typeof raw.questionId !== "string") return fail(`answers[${position}] takes questionId, optionIds and other`);
128
+ const known = byId.get(raw.questionId);
129
+ if (!known) return fail(`answers[${position}] names a question that was not asked`);
130
+ if (answers.has(raw.questionId)) return fail(`answers[${position}] answers question ${known.index + 1} twice`);
131
+ const { question, index } = known, label = `question ${index + 1}`;
132
+ const chosen = raw.optionIds === undefined ? [] : raw.optionIds;
133
+ if (!Array.isArray(chosen) || chosen.some(id => typeof id !== "string")) return fail(`${label}: options must be a list of option ids`);
134
+ if (new Set(chosen).size !== chosen.length) return fail(`${label}: an option is chosen twice`);
135
+ const offered = new Set(question.options.map(option => option.id));
136
+ if (chosen.some(id => !offered.has(id as string))) return fail(`${label}: an option id was not offered`);
137
+ if (chosen.length > 1 && question.multi !== true) return fail(`${label}: takes one option`);
138
+ let other: string | undefined;
139
+ if (raw.other !== undefined) {
140
+ if (question.allowOther !== true) return fail(`${label}: does not take other text`);
141
+ const parsed = text(raw.other, `${label}: other text`, MAX_OTHER);
142
+ if (!parsed.ok) return parsed;
143
+ other = parsed.value;
144
+ }
145
+ if (!chosen.length && other === undefined) return fail(`${label}: choose an option${question.allowOther === true ? " or give other text" : ""}`);
146
+ answers.set(raw.questionId, { questionId: raw.questionId, optionIds: chosen as string[], ...(other !== undefined ? { other } : {}) });
147
+ }
148
+ const missing = questions.findIndex(question => !answers.has(question.id));
149
+ if (missing >= 0) return fail(`question ${missing + 1} has no answer`);
150
+ return { ok: true, value: questions.map(question => answers.get(question.id)!) };
151
+ }
152
+
153
+ interface RawRow {
154
+ set_id: string; peer: string; session: string; task: number | null; private: number; state: string; created: number; settled: number | null; reason: string | null;
155
+ surface: string | null; person: string | null; questions: string; answers: string | null; envelope: number;
156
+ }
157
+ function rowOf(raw: RawRow): QuestionRow {
158
+ return {
159
+ set: raw.set_id, peer: raw.peer, session: raw.session, ...(raw.task !== null ? { task: raw.task } : {}), private: raw.private === 1, state: raw.state as QuestionState,
160
+ created: raw.created, ...(raw.settled !== null ? { settled: raw.settled } : {}), ...(raw.reason !== null ? { reason: raw.reason as QuestionReason } : {}),
161
+ ...(raw.surface !== null ? { surface: raw.surface as QuestionSurface } : {}), ...(raw.person ? { person: raw.person } : {}), questions: JSON.parse(raw.questions) as AskQuestion[],
162
+ ...(raw.answers !== null ? { answers: JSON.parse(raw.answers) as QuestionAnswer[] } : {}), envelope: raw.envelope as 0 | 1 | 2,
163
+ };
164
+ }
165
+
166
+ export type AnswerResult = { ok: true; row: QuestionRow } | { ok: false; error: string; state?: QuestionState };
167
+
168
+ /** `questions` in hub.db. Additive table (no format bump): an older hub ignores it, which `Daemon` guards for recovery with the `questions` feature. */
169
+ export class QuestionStore {
170
+ private readonly db: Database;
171
+ constructor(dbPath: string) {
172
+ this.db = openHubDatabase(dbPath, { create: true });
173
+ this.db.run(`CREATE TABLE IF NOT EXISTS questions (
174
+ set_id TEXT PRIMARY KEY, peer TEXT NOT NULL, session TEXT NOT NULL DEFAULT '', task INTEGER, private INTEGER NOT NULL DEFAULT 0,
175
+ state TEXT NOT NULL CHECK (state IN ('open','answered','superseded','cancelled','expired')), created INTEGER NOT NULL, settled INTEGER, reason TEXT, surface TEXT,
176
+ questions TEXT NOT NULL, answers TEXT, envelope INTEGER NOT NULL DEFAULT 0)`);
177
+ // The verified person of an acon answer: an additive column, so a table made before it is altered in place and an older hub simply never reads it.
178
+ if (!(this.db.query("PRAGMA table_info(questions)").all() as { name: string }[]).some(column => column.name === "person")) this.db.run("ALTER TABLE questions ADD COLUMN person TEXT");
179
+ this.db.run("CREATE INDEX IF NOT EXISTS questions_by_state ON questions(state, peer)");
180
+ }
181
+ close(): void { try { this.db.close(); } catch { /* already closed */ } }
182
+
183
+ get(set: string): QuestionRow | undefined {
184
+ if (typeof set !== "string" || !QUESTION_SET.test(set)) return undefined;
185
+ const raw = this.db.query("SELECT * FROM questions WHERE set_id = ?").get(set) as RawRow | null;
186
+ return raw ? rowOf(raw) : undefined;
187
+ }
188
+ /** Oldest first, bounded. */
189
+ list(filter: { state?: QuestionState; peer?: string } = {}, limit = 200): QuestionRow[] {
190
+ const where: string[] = [], args: (string | number)[] = [];
191
+ if (filter.state) { where.push("state = ?"); args.push(filter.state); }
192
+ if (filter.peer) { where.push("peer = ?"); args.push(filter.peer); }
193
+ return (this.db.query(`SELECT * FROM questions ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created, set_id LIMIT ?`).all(...args, limit) as RawRow[]).map(rowOf);
194
+ }
195
+ openCount(peer?: string): number {
196
+ const row = (peer === undefined ? this.db.query("SELECT COUNT(*) AS n FROM questions WHERE state = 'open'").get() : this.db.query("SELECT COUNT(*) AS n FROM questions WHERE state = 'open' AND peer = ?").get(peer)) as { n: number };
197
+ return row.n;
198
+ }
199
+ /** Any row an older daemon would silently orphan: an open question, or an envelope still owed. */
200
+ hasPending(): boolean {
201
+ return (this.db.query("SELECT EXISTS(SELECT 1 FROM questions WHERE state = 'open' OR envelope = 1) AS present").get() as { present: number }).present === 1;
202
+ }
203
+
204
+ /** A new open row. Refuses past the per-peer and total bounds, so a loop cannot flood the inbox. */
205
+ ask(input: { peer: string; session: string; task?: number; private: boolean; questions: AskQuestion[] }, now: number): QuestionRow | { error: string } {
206
+ if (this.openCount(input.peer) >= MAX_OPEN_PER_PEER) return { error: `${MAX_OPEN_PER_PEER} questions from this peer are already waiting for the person; wait for an answer before asking more` };
207
+ if (this.openCount() >= MAX_OPEN_TOTAL) return { error: "too many questions are waiting for the person; try again later" };
208
+ const set = `qs-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
209
+ this.db.query("INSERT INTO questions(set_id, peer, session, task, private, state, created, questions) VALUES(?, ?, ?, ?, ?, 'open', ?, ?)")
210
+ .run(set, input.peer, input.session, input.task ?? null, input.private ? 1 : 0, now, JSON.stringify(input.questions));
211
+ return this.get(set)!;
212
+ }
213
+
214
+ /**
215
+ * The one terminal transition: a compare-and-set on `state = 'open'`. Of any number of concurrent callers (a person's answer, a stop, an expiry) exactly one
216
+ * sees `changes === 1`; every other gets the row's real terminal state back. `late` says no waiting call will carry the result, so an envelope is owed.
217
+ */
218
+ answer(set: string, raw: unknown, surface: QuestionSurface, now: number, late: boolean, person?: string): AnswerResult {
219
+ const row = this.get(set);
220
+ if (!row) return { ok: false, error: "no such question" };
221
+ if (row.state !== "open") return { ok: false, error: `question is already ${row.state}`, state: row.state };
222
+ const parsed = validateAnswers(row.questions, raw);
223
+ if (!parsed.ok) return { ok: false, error: parsed.error };
224
+ const result = this.db.query("UPDATE questions SET state = 'answered', settled = ?, surface = ?, person = ?, answers = ?, envelope = ? WHERE set_id = ? AND state = 'open'")
225
+ .run(now, surface, person ?? null, JSON.stringify(parsed.value), late ? 1 : 0, set);
226
+ if (result.changes !== 1) { const now_ = this.get(set); return { ok: false, error: `question is already ${now_?.state ?? "settled"}`, ...(now_ ? { state: now_.state } : {}) }; }
227
+ return { ok: true, row: this.get(set)! };
228
+ }
229
+ /** Leave `open` for `superseded`, `cancelled` or `expired`. Returns the row only when this call made the transition. */
230
+ settle(set: string, state: "superseded" | "cancelled" | "expired", reason: QuestionReason, now: number, notify = false): QuestionRow | undefined {
231
+ if (typeof set !== "string" || !QUESTION_SET.test(set)) return undefined;
232
+ const result = this.db.query("UPDATE questions SET state = ?, settled = ?, reason = ?, envelope = ? WHERE set_id = ? AND state = 'open'").run(state, now, reason, notify ? 1 : 0, set);
233
+ return result.changes === 1 ? this.get(set) : undefined;
234
+ }
235
+ /** Every open row of one peer, each through `settle`; the rows this call settled. */
236
+ settleFor(peer: string, state: "superseded" | "cancelled", reason: QuestionReason, now: number, only?: (row: QuestionRow) => boolean): QuestionRow[] {
237
+ const settled: QuestionRow[] = [];
238
+ for (const row of this.list({ state: "open", peer }, MAX_OPEN_PER_PEER * 4)) {
239
+ if (only && !only(row)) continue;
240
+ const done = this.settle(row.set, state, reason, now);
241
+ if (done) settled.push(done);
242
+ }
243
+ return settled;
244
+ }
245
+ /** Open rows older than the ttl become `expired` with a notice owed; the caller marks it published when a waiting call carried the result instead. */
246
+ expireDue(now: number, ttlMs: number): QuestionRow[] {
247
+ const expired: QuestionRow[] = [];
248
+ for (const row of this.list({ state: "open" }, MAX_OPEN_TOTAL * 2)) if (now - row.created >= ttlMs) { const done = this.settle(row.set, "expired", "expired", now, true); if (done) expired.push(done); }
249
+ return expired;
250
+ }
251
+ /** Rows whose envelope is owed (a late answer or an expiry notice that a crash or an offline peer kept from going out). */
252
+ owed(): QuestionRow[] {
253
+ return (this.db.query("SELECT * FROM questions WHERE envelope = 1 ORDER BY settled, set_id LIMIT 200").all() as RawRow[]).map(rowOf);
254
+ }
255
+ published(set: string): void { this.db.query("UPDATE questions SET envelope = 2 WHERE set_id = ? AND envelope = 1").run(set); }
256
+ /** Settled rows older than `keepMs` go; open and owed rows never do. */
257
+ prune(now: number, keepMs: number): number {
258
+ return this.db.query("DELETE FROM questions WHERE state != 'open' AND envelope != 1 AND settled IS NOT NULL AND settled < ?").run(now - keepMs).changes;
259
+ }
260
+ }
@@ -0,0 +1,106 @@
1
+ import { createHash } from "node:crypto";
2
+ import { types } from "node:util";
3
+ import { validSourceId } from "./sources.ts";
4
+
5
+ export const EVIDENCE_RELATIONS = ["supports", "contradicts", "context"] as const;
6
+ export const MAX_EVIDENCE_LOCATOR_SPAN = 4096;
7
+ export const MAX_EVIDENCE_RECORD_BYTES = 4096;
8
+
9
+ /** Proposed zero-based, half-open scalar-value offsets. T1 stores bounds; T2 resolves and verifies them. */
10
+ export interface EvidenceLocatorV1 { start: number; end: number }
11
+ /** #931 provenance only: a relation never creates a #901 person verdict or verifies entailment. */
12
+ export interface EvidenceLinkV1 {
13
+ id: string;
14
+ claimId: string;
15
+ claimRevision: string;
16
+ sourceId: string;
17
+ rawSha256: string;
18
+ projectionSha256: string;
19
+ projectionVersion: string;
20
+ locator: EvidenceLocatorV1;
21
+ relation: (typeof EVIDENCE_RELATIONS)[number];
22
+ recordedBy: string;
23
+ taskId: number;
24
+ revision: number;
25
+ }
26
+ /** The actor is host-stamped. Revision defaults to 1; a later revision names an existing link explicitly. */
27
+ export type EvidenceRecordInput = Omit<EvidenceLinkV1, "id" | "recordedBy"> & { id?: string };
28
+ export type EvidenceReceipt = Pick<EvidenceLinkV1, "id" | "revision" | "taskId">;
29
+
30
+ const FIELDS = ["id", "claimId", "claimRevision", "sourceId", "rawSha256", "projectionSha256", "projectionVersion", "locator", "relation", "recordedBy", "taskId", "revision"] as const;
31
+ const INPUT_FIELDS = FIELDS.filter(field => field !== "recordedBy");
32
+ const HASH = /^[0-9a-f]{64}$/;
33
+ const LINK_ID = /^ev:[0-9a-f]{32}$/;
34
+ function fail(field: string, cause: string): never {
35
+ throw new Error(`evidence ${field}: ${cause}; correct this field using the hub_evidence_record schema and retry`);
36
+ }
37
+ function dataObject(value: unknown, allowed: readonly string[], field: string): Record<string, unknown> {
38
+ if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value)
39
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))
40
+ || Object.getOwnPropertySymbols(value).length
41
+ || Object.values(Object.getOwnPropertyDescriptors(value)).some(descriptor => !Object.hasOwn(descriptor, "value"))) fail(field, "expected an inert plain data object");
42
+ if (Object.getOwnPropertyNames(value).some(key => !allowed.includes(key))) fail(field, "unknown field");
43
+ return value as Record<string, unknown>;
44
+ }
45
+ function integer(value: unknown, field: string, minimum = 1): number {
46
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(field, `expected a safe integer >= ${minimum}`);
47
+ return value;
48
+ }
49
+ function token(value: unknown, field: string, expression: RegExp): string {
50
+ if (typeof value !== "string" || !expression.test(value)) fail(field, "invalid identifier or hash");
51
+ return value;
52
+ }
53
+ function claimId(value: unknown): string {
54
+ const id = token(value, "claimId", /^claim:[1-9][0-9]{0,15}:(?:0|[1-9][0-9]{0,15}):[1-9][0-9]?$/);
55
+ const [, task, created, slot] = id.split(":");
56
+ if (!Number.isSafeInteger(Number(task)) || !Number.isSafeInteger(Number(created)) || Number(slot) > 50) fail("claimId", "invalid task incarnation or claim slot");
57
+ return id;
58
+ }
59
+ function locator(value: unknown): EvidenceLocatorV1 {
60
+ const row = dataObject(value, ["start", "end"], "locator");
61
+ const start = integer(row.start, "locator.start", 0), end = integer(row.end, "locator.end");
62
+ if (end <= start || end - start > MAX_EVIDENCE_LOCATOR_SPAN) fail("locator", `expected a nonempty range of at most ${MAX_EVIDENCE_LOCATOR_SPAN} scalar values`);
63
+ return { start, end };
64
+ }
65
+ function parsed(value: unknown, stored: boolean): EvidenceRecordInput | EvidenceLinkV1 {
66
+ const row = dataObject(value, stored ? FIELDS : INPUT_FIELDS, "record");
67
+ const taskId = integer(row.taskId, "taskId"), revision = row.revision === undefined && !stored ? 1 : integer(row.revision, "revision");
68
+ const id = row.id === undefined && !stored ? undefined : token(row.id, "id", LINK_ID);
69
+ if (revision > 1 && id === undefined) fail("id", "a later revision must name its existing link");
70
+ const sourceId = row.sourceId;
71
+ if (!validSourceId(sourceId)) fail("sourceId", "expected a registered src:<16 hex> id");
72
+ const relation = row.relation;
73
+ if (typeof relation !== "string" || !(EVIDENCE_RELATIONS as readonly string[]).includes(relation)) fail("relation", "expected supports, contradicts or context");
74
+ const common: EvidenceRecordInput = {
75
+ ...(id === undefined ? {} : { id }),
76
+ claimId: claimId(row.claimId),
77
+ claimRevision: token(row.claimRevision, "claimRevision", /^[0-9a-f]{16}$/),
78
+ sourceId,
79
+ rawSha256: token(row.rawSha256, "rawSha256", HASH),
80
+ projectionSha256: token(row.projectionSha256, "projectionSha256", HASH),
81
+ projectionVersion: token(row.projectionVersion, "projectionVersion", /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,63}$/),
82
+ locator: locator(row.locator),
83
+ relation: relation as EvidenceLinkV1["relation"],
84
+ taskId, revision,
85
+ };
86
+ const result = stored ? { ...common, id: id!, recordedBy: token(row.recordedBy, "recordedBy", /^[A-Za-z0-9][A-Za-z0-9._:@~+-]{0,127}$/) } : common;
87
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") > MAX_EVIDENCE_RECORD_BYTES) fail("record", `expected at most ${MAX_EVIDENCE_RECORD_BYTES} bytes`);
88
+ return result;
89
+ }
90
+ export function parseEvidenceRecord(value: unknown): EvidenceRecordInput { return parsed(value, false) as EvidenceRecordInput; }
91
+ export function parseEvidenceLink(value: unknown): EvidenceLinkV1 { return parsed(value, true) as EvidenceLinkV1; }
92
+
93
+ /** Stable local identity for an identical initial write, without consulting a clock or creating a claim store. */
94
+ export function evidenceLinkId(input: EvidenceRecordInput, recordedBy: string): string {
95
+ const parsed = parseEvidenceRecord(input);
96
+ if (parsed.id !== undefined) return parsed.id;
97
+ const actor = token(recordedBy, "recordedBy", /^[A-Za-z0-9][A-Za-z0-9._:@~+-]{0,127}$/);
98
+ return `ev:${createHash("sha256").update(JSON.stringify({ ...parsed, recordedBy: actor })).digest("hex").slice(0, 32)}`;
99
+ }
100
+ /** Fixed field order; parsing first prevents accessors, toJSON or unknown narrative fields from being serialized. */
101
+ export function evidenceLinkCanonical(value: EvidenceLinkV1): string {
102
+ const row = parseEvidenceLink(value);
103
+ return JSON.stringify({ id: row.id, claimId: row.claimId, claimRevision: row.claimRevision, sourceId: row.sourceId,
104
+ rawSha256: row.rawSha256, projectionSha256: row.projectionSha256, projectionVersion: row.projectionVersion,
105
+ locator: row.locator, relation: row.relation, recordedBy: row.recordedBy, taskId: row.taskId, revision: row.revision });
106
+ }
@@ -54,6 +54,8 @@ export interface RestartSnapshot {
54
54
  researchExperiments?: { protocols: number; attempts: number; events: number; observations: number; digest: string };
55
55
  researchComparisons?: { plans: number; approvals: number; contexts: number; digest: string };
56
56
  researchRunners?: { count: number; digest: string };
57
+ researchEvidence?: { links: number; revisions: number; digest: string };
58
+ reviewComments?: { comments: number; packages: number; digest: string };
57
59
  };
58
60
  }
59
61
 
@@ -130,6 +132,18 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
130
132
  if (!value || typeof value !== "object" || Array.isArray(value) || !Number.isSafeInteger(value.count) || value.count < 0
131
133
  || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
132
134
  }
135
+ if (s.integrity && Object.hasOwn(s.integrity, "researchEvidence")) {
136
+ const value = s.integrity.researchEvidence;
137
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !["links", "revisions", "digest"].includes(key))
138
+ || ![value.links, value.revisions].every(count => Number.isSafeInteger(count) && count >= 0)
139
+ || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
140
+ }
141
+ if (s.integrity && Object.hasOwn(s.integrity, "reviewComments")) {
142
+ const value = s.integrity.reviewComments;
143
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !["comments", "packages", "digest"].includes(key))
144
+ || ![value.comments, value.packages].every(count => Number.isSafeInteger(count) && count >= 0)
145
+ || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
146
+ }
133
147
  if (bus.journal !== undefined) { try { validateJournalSnapshot(bus.journal); } catch { return undefined; } }
134
148
  return s as RestartSnapshot;
135
149
  }
@@ -0,0 +1,181 @@
1
+ import { types } from "node:util";
2
+ import type { ReviewAnchor, ReviewSelector } from "@staix/agent-console/contract";
3
+ import { sanitize } from "./envelope.ts";
4
+ import { projectionPath } from "./projected-diff.ts";
5
+ import type { ReviewMaterial } from "./review-material.ts";
6
+ export type { ReviewAnchor, ReviewSelector, ReviewThread } from "@staix/agent-console/contract";
7
+
8
+ /** Conformance bounds belong to the published acon contract; hub write byte bounds are a separate layer below. */
9
+ const CONTRACT = { exact: 2000, context: 64, body: 4000, threads: 200, selectors: 4 };
10
+ function data(value: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
11
+ if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value)
12
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.getOwnPropertySymbols(value).length) return undefined;
13
+ const descriptors = Object.getOwnPropertyDescriptors(value);
14
+ if (Object.keys(descriptors).some(key => !keys.includes(key)) || Object.values(descriptors).some(field => !Object.hasOwn(field, "value") || !field.enumerable)) return undefined;
15
+ return value as Record<string, unknown>;
16
+ }
17
+ function list(value: unknown, max: number): value is unknown[] {
18
+ if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length > max || Object.getOwnPropertySymbols(value).length) return false;
19
+ const fields = Object.getOwnPropertyDescriptors(value);
20
+ return Object.keys(fields).length === value.length + 1 && Object.entries(fields).every(([key, field]) => key === "length"
21
+ || /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < value.length && Object.hasOwn(field, "value") && field.enumerable);
22
+ }
23
+ const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
24
+ const positive = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 1;
25
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
26
+
27
+ function selectorProblems(value: unknown, at: string): string[] {
28
+ const row = data(value, ["kind", "startLine", "endLine", "exact", "prefix", "suffix", "hunkHeader", "scheme", "ref", "x", "y", "width", "height", "page"]);
29
+ if (!row) return [`${at}: inert closed selector required`];
30
+ const allowed: Record<string, string[]> = {
31
+ lineRange: ["kind", "startLine", "endLine"], textQuote: ["kind", "exact", "prefix", "suffix"],
32
+ diffHunk: ["kind", "hunkHeader"], structural: ["kind", "scheme", "ref"], region: ["kind", "x", "y", "width", "height", "page"],
33
+ };
34
+ if (typeof row.kind !== "string" || !Object.hasOwn(allowed, row.kind)) return [`${at}.kind is unsupported`];
35
+ if (Object.keys(row).some(key => !allowed[row.kind as string]!.includes(key))) return [`${at}: unknown selector fields`];
36
+ switch (row.kind) {
37
+ case "lineRange": return positive(row.startLine) && positive(row.endLine) && row.startLine <= row.endLine ? [] : [`${at}: invalid startLine/endLine`];
38
+ case "textQuote": return text(row.exact) && row.exact.length <= CONTRACT.exact && typeof row.prefix === "string" && row.prefix.length <= CONTRACT.context
39
+ && typeof row.suffix === "string" && row.suffix.length <= CONTRACT.context ? [] : [`${at}: invalid exact/prefix/suffix bounds`];
40
+ case "diffHunk": return typeof row.hunkHeader === "string" && row.hunkHeader.startsWith("@@") ? [] : [`${at}.hunkHeader must start with @@`];
41
+ case "structural": return text(row.scheme) && text(row.ref) ? [] : [`${at}: scheme and ref are required`];
42
+ case "region": return [row.x, row.y, row.width, row.height].every(finite) && (row.page === undefined || positive(row.page)) ? [] : [`${at}: invalid region coordinates or page`];
43
+ default: return [`${at}.kind is unsupported`];
44
+ }
45
+ }
46
+
47
+ export function reviewAnchorContractProblems(value: unknown): string[] {
48
+ const row = data(value, ["path", "side", "contentHash", "projection", "selectors"]);
49
+ if (!row) return ["anchor: inert closed object required"];
50
+ const problems: string[] = [];
51
+ if (!text(row.path)) problems.push("anchor.path must be nonempty");
52
+ if (row.side !== undefined && row.side !== "base" && row.side !== "head") problems.push("anchor.side must be base or head");
53
+ if (row.contentHash !== undefined && (typeof row.contentHash !== "string" || !/^[a-f0-9]{64}$/.test(row.contentHash))) problems.push("anchor.contentHash must be lowercase sha256");
54
+ if (row.projection !== undefined) {
55
+ const projection = data(row.projection, ["name", "version"]);
56
+ if (!projection || !text(projection.name) || !text(projection.version)) problems.push("anchor.projection needs name and version");
57
+ }
58
+ if (!list(row.selectors, CONTRACT.selectors) || !row.selectors.length) problems.push("anchor.selectors needs 1-4 inert selectors");
59
+ else row.selectors.forEach((selector, index) => problems.push(...selectorProblems(selector, `anchor.selectors[${index}]`)));
60
+ return problems;
61
+ }
62
+ export function reviewThreadContractProblems(value: unknown): string[] {
63
+ const row = data(value, ["id", "anchor", "body", "author", "at", "state"]);
64
+ if (!row) return ["thread: inert closed object required"];
65
+ const problems: string[] = [];
66
+ for (const field of ["id", "author"]) if (!text(row[field])) problems.push(`thread.${field} must be nonempty`);
67
+ if (!text(row.body) || row.body.length > CONTRACT.body) problems.push("thread.body must contain 1-4000 characters");
68
+ if (typeof row.at !== "string" || Number.isNaN(Date.parse(row.at))) problems.push("thread.at must be a valid timestamp");
69
+ if (row.state !== undefined && row.state !== "open" && row.state !== "resolved") problems.push("thread.state must be open or resolved");
70
+ problems.push(...reviewAnchorContractProblems(row.anchor).map(problem => `thread.${problem}`));
71
+ return problems;
72
+ }
73
+ export function reviewThreadsContractProblems(value: unknown): string[] {
74
+ if (!list(value, CONTRACT.threads)) return ["threads must be an inert array of at most 200 entries"];
75
+ const problems: string[] = [], ids = new Set<string>();
76
+ value.forEach((thread, index) => {
77
+ problems.push(...reviewThreadContractProblems(thread).map(problem => `threads[${index}]: ${problem}`));
78
+ const row = data(thread, ["id", "anchor", "body", "author", "at", "state"]);
79
+ if (row && typeof row.id === "string") { if (ids.has(row.id)) problems.push(`threads[${index}].id repeats`); ids.add(row.id); }
80
+ });
81
+ return problems;
82
+ }
83
+
84
+ export class ReviewAnchorError extends Error {
85
+ constructor(readonly field: string, readonly maxBytes?: number, readonly shapeFields?: readonly string[]) {
86
+ super("review anchor validation failed");
87
+ this.name = "ReviewAnchorError";
88
+ this.message = this.safeMessage + (maxBytes === undefined ? "" : " (hub bound)");
89
+ Object.freeze(this);
90
+ }
91
+ get safeMessage(): string {
92
+ if (this.shapeFields) return `review anchor fields ${this.shapeFields.join(", ")} do not match the acon contract; correct those fields and retry`;
93
+ if (this.maxBytes !== undefined) return `review anchor ${this.field} exceeds ${this.maxBytes} UTF-8 bytes; shorten that field and retry`;
94
+ return `review anchor ${this.field} is invalid; correct that field and retry`;
95
+ }
96
+ }
97
+ /** Returns only messages assembled by the anchor validator; arbitrary caught errors are never reflected. */
98
+ export function reviewAnchorErrorMessage(error: unknown): string {
99
+ return error instanceof ReviewAnchorError ? error.safeMessage : "review anchor is invalid; correct its fields and retry";
100
+ }
101
+ function fail(field: string, maxBytes?: number): never { throw new ReviewAnchorError(field, maxBytes); }
102
+ function contractField(problem: string): string {
103
+ const selector = /^anchor\.selectors\[(\d+)\]/.exec(problem);
104
+ if (selector) {
105
+ const prefix = `anchor.selectors[${selector[1]}]`;
106
+ const member = /^\.([A-Za-z]+)/.exec(problem.slice(prefix.length))?.[1];
107
+ if (member) return `selectors[${selector[1]}].${member}`;
108
+ if (problem.includes("startLine/endLine")) return `selectors[${selector[1]}].startLine/endLine`;
109
+ if (problem.includes("exact/prefix/suffix")) return `selectors[${selector[1]}].exact/prefix/suffix`;
110
+ if (problem.includes("region coordinates or page")) return `selectors[${selector[1]}].x/y/width/height/page`;
111
+ if (problem.includes("scheme and ref")) return `selectors[${selector[1]}].scheme/ref`;
112
+ return `selectors[${selector[1]}]`;
113
+ }
114
+ const field = /^anchor\.(path|side|contentHash|projection|selectors)\b/.exec(problem)?.[1];
115
+ return field ?? "anchor";
116
+ }
117
+ function failShape(problems: readonly string[]): never {
118
+ const fields = [...new Set(problems.map(contractField))];
119
+ throw new ReviewAnchorError("anchor", undefined, fields.length ? fields : ["anchor"]);
120
+ }
121
+ function writeQuoteText(value: string, maxBytes: number, field: string): string {
122
+ if (value.includes("\0")) return fail(field);
123
+ if (Buffer.byteLength(value, "utf8") > maxBytes) return fail(field, maxBytes);
124
+ return value;
125
+ }
126
+ function writeText(value: string, maxBytes: number, field: string): string {
127
+ if (value.includes("\0")) return fail(field);
128
+ if (Buffer.byteLength(value, "utf8") > maxBytes) return fail(field, maxBytes);
129
+ const safe = sanitize(value);
130
+ if (Buffer.byteLength(safe, "utf8") > maxBytes) return fail(field, maxBytes);
131
+ return safe;
132
+ }
133
+ /** The hub write boundary. Quote selector text stays byte-bounded and exact; other stored text is sanitized here. */
134
+ export function parseReviewAnchor(value: unknown): ReviewAnchor {
135
+ const problems = reviewAnchorContractProblems(value);
136
+ if (problems.length) return failShape(problems);
137
+ const row = value as ReviewAnchor, path = writeText(row.path, 4096, "path");
138
+ if (/[\p{Cc}\p{Cf}\\]/u.test(path) || /^[a-z]:/i.test(path) || !projectionPath({ top: "", dir: "", prefix: "" }, path)) return fail("path");
139
+ const selectors = row.selectors.map((selector, index): ReviewSelector => {
140
+ switch (selector.kind) {
141
+ case "lineRange":
142
+ if (!Number.isSafeInteger(selector.startLine) || !Number.isSafeInteger(selector.endLine)) return fail(`selectors[${index}].startLine/endLine`);
143
+ return { ...selector };
144
+ case "textQuote": return { kind: "textQuote", exact: writeQuoteText(selector.exact, 512, `selectors[${index}].exact`), prefix: writeQuoteText(selector.prefix, 64, `selectors[${index}].prefix`), suffix: writeQuoteText(selector.suffix, 64, `selectors[${index}].suffix`) };
145
+ case "diffHunk": return { kind: "diffHunk", hunkHeader: writeText(selector.hunkHeader, 256, `selectors[${index}].hunkHeader`) };
146
+ case "structural": return { kind: "structural", scheme: writeText(selector.scheme, 128, `selectors[${index}].scheme`), ref: writeText(selector.ref, 512, `selectors[${index}].ref`) };
147
+ case "region":
148
+ if (selector.page !== undefined && !Number.isSafeInteger(selector.page)) return fail(`selectors[${index}].page`);
149
+ return { ...selector };
150
+ }
151
+ });
152
+ return { path, ...(row.side === undefined ? {} : { side: row.side }), ...(row.contentHash === undefined ? {} : { contentHash: row.contentHash }),
153
+ ...(row.projection ? { projection: { name: writeText(row.projection.name, 128, "projection.name"), version: writeText(row.projection.version, 128, "projection.version") } } : {}), selectors };
154
+ }
155
+
156
+ export interface ReviewAnchorStatus { status: "anchored" | "moved" | "outdated" | "unavailable"; range?: { startLine: number; endLine: number } }
157
+ function quotePosition(text: string, quote: Extract<ReviewSelector, { kind: "textQuote" }>): number | undefined {
158
+ let count = 0, contextual = 0, first = -1, contextualAt = -1;
159
+ if (!quote.exact) return undefined;
160
+ for (let at = text.indexOf(quote.exact); at !== -1; at = text.indexOf(quote.exact, at + 1)) {
161
+ count++; first = at;
162
+ const end = at + quote.exact.length;
163
+ if (at >= quote.prefix.length && text.slice(at - quote.prefix.length, at) === quote.prefix
164
+ && text.slice(end, end + quote.suffix.length) === quote.suffix) { contextual++; contextualAt = at; }
165
+ }
166
+ return count === 1 ? first : contextual === 1 ? contextualAt : undefined;
167
+ }
168
+ /** Placement only. Projection identity precedes byte equality; no stored selector or hash is rewritten. */
169
+ export function anchorStatus(anchor: ReviewAnchor, material: ReviewMaterial): ReviewAnchorStatus {
170
+ if (material.state !== "available" || material.path !== anchor.path || material.side !== (anchor.side ?? "head")) return { status: "unavailable" };
171
+ if (anchor.projection?.name !== material.projection?.name || anchor.projection?.version !== material.projection?.version) return { status: "outdated" };
172
+ if (anchor.contentHash !== undefined && anchor.contentHash === material.contentHash) return { status: "anchored" };
173
+ for (const selector of anchor.selectors) if (selector.kind === "textQuote") {
174
+ const at = quotePosition(material.text, selector);
175
+ if (at === undefined) continue;
176
+ const startLine = material.text.slice(0, at).split("\n").length;
177
+ const endLine = material.text.slice(0, at + selector.exact.length - 1).split("\n").length;
178
+ return { status: "moved", range: { startLine, endLine } };
179
+ }
180
+ return { status: "outdated" };
181
+ }
@@ -4,6 +4,19 @@ import { childEnv } from "./child-process.ts";
4
4
  import { hubGitSync } from "./git.ts";
5
5
  import { symbolsOverlap } from "./symbol-normalizer.ts";
6
6
  import type { SpecTaskReport } from "./board.ts";
7
+ import type { ReviewFinding } from "./review-findings.ts";
8
+ import type { ReviewAnchorStatus } from "./review-anchor.ts";
9
+
10
+ /** Read-time placement only; the stored finding and its selectors remain unchanged. */
11
+ export function renderFindingAnchor(finding: ReviewFinding, placement: ReviewAnchorStatus): string {
12
+ const anchor = finding.anchor;
13
+ if (!anchor) return "";
14
+ if (placement.status === "unavailable") return "Anchor: unavailable; reopen the task review to inspect current public material.";
15
+ const range = placement.status === "outdated" ? undefined : placement.range ?? anchor.selectors.find(selector => selector.kind === "lineRange");
16
+ const location = anchor.path + (range ? `:${range.startLine}-${range.endLine}` : "");
17
+ const quotes = anchor.selectors.flatMap(selector => selector.kind === "textQuote" ? [selector.exact] : []);
18
+ return sanitize([`Anchor: ${location} (${placement.status})`, ...quotes.map(quote => `> ${quote.replace(/\n/g, "\n> ")}`)].join("\n"));
19
+ }
7
20
 
8
21
  /**
9
22
  * #520: the review brief ahub builds itself, in place of `ocr delegate preview` and `rule`. Every reviewer gets the same