agent-horde 0.1.3 → 0.1.5

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.
@@ -22,12 +22,31 @@ interface CollaborationCommand {
22
22
  function evidenceRefs(state: CollaborationState, refs: string[] = []): string[] {
23
23
  for (const id of refs) {
24
24
  if (state.evidence.some((e) => e.id === id)) continue;
25
+ // A review is the record members most want to cite; it already names the evidence it rests on.
26
+ const review = state.reviews.find((r) => r.id === id);
27
+ if (review) throw new Error(`horde: ${id} is a review, not evidence; cite its evidence instead: ${review.evidence.join(", ") || "none recorded"}`);
28
+ // Evidence ids share the event counter, so the E# of the event that recorded V#, A# or Q# is
29
+ // the guess a member makes when it knows the record but not its evidence.
30
+ const recorded = recordedAtEvent(state, id);
31
+ if (recorded) throw new Error(`horde: ${id} is the event that recorded ${recorded.id}, not evidence; ${recorded.hint}`);
25
32
  const kind = /^[A-Z]\d+$/.test(id) && !id.startsWith("E") ? `; ${id} is not evidence, refs take E# ids (attach evidence first, or name ${id} as the target)` : "";
26
33
  throw new Error(`horde: unknown evidence ${id}${kind}`);
27
34
  }
28
35
  return refs;
29
36
  }
30
37
 
38
+ function recordedAtEvent(state: CollaborationState, id: string): { id: string; hint: string } | undefined {
39
+ const seq = /^E(\d+)$/.exec(id)?.[1];
40
+ if (!seq) return undefined;
41
+ const review = state.reviews.find((r) => r.id === `V${seq}`);
42
+ if (review) return { id: review.id, hint: `cite ${review.id}'s evidence instead: ${review.evidence.join(", ") || "none recorded"}` };
43
+ const artifact = state.artifacts.find((a) => a.id === `A${seq}`);
44
+ if (artifact) return { id: artifact.id, hint: `attach evidence to ${artifact.id} first, or name ${artifact.id} as the target` };
45
+ const message = state.messages.find((m) => m.id === `Q${seq}`);
46
+ if (message) return { id: message.id, hint: `cite the evidence on ${message.id} instead: ${[...message.evidence, ...(message.responseEvidence ?? [])].join(", ") || "none recorded"}` };
47
+ return undefined;
48
+ }
49
+
31
50
  /** Name the stale ref and both revisions, so a retry does not have to guess which evidence to replace. */
32
51
  function staleEvidence(state: CollaborationState, refs: string[], target: string, revision: string): string | undefined {
33
52
  const stale = refs.map((id) => state.evidence.find((e) => e.id === id)).find((e) => e && (e.target !== target || e.revision !== revision));
@@ -43,6 +62,8 @@ function newMessage(c: CollaborationCommand): EventDraft {
43
62
  const task = path.join(ctx.hordeDir, "runs", ctx.runId, "TASK.md");
44
63
  const count = fs.existsSync(task) ? Number(/^Members: (\d+)/m.exec(fs.readFileSync(task, "utf8"))?.[1]) : 0;
45
64
  if (!/^member-[1-9]\d*$/.test(to) || (count && Number(to.slice(7)) > count)) throw new Error("horde: recipient must be a member of this run");
65
+ // The recipient defaults to the target's owner, which is the sender itself on its own work.
66
+ if (to === ctx.actor) throw new Error(`horde: a message cannot be addressed to its sender; ${target} is yours, name another member with to=member-N`);
46
67
  if (p.revision && p.revision !== info.revision) throw new Error("horde: target changed; inspect current revision");
47
68
  const message: PeerMessage = {
48
69
  id: `Q${seq}`, sender: ctx.actor, to, target, revision: info.revision, kind: p.kind ?? "question",
@@ -175,7 +196,29 @@ function recordReview(c: CollaborationCommand): EventDraft {
175
196
  verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked,
176
197
  text, evidence: refs },
177
198
  ...completeAssignedReview(c, target, `V${seq}`, text, refs),
178
- ...(["refutes", "partially-supports"].includes(p.verdict) ? automaticChallenge(c, target, info, text, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
199
+ ...(["refutes", "partially-supports"].includes(p.verdict) ? automaticChallenge(c, target, info, text, refs) : {}),
200
+ ...(p.verdict === "supports" && !p.unchecked.trim() ? coverageClearance(c, target, info, `V${seq}`, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
201
+ }
202
+
203
+ /** The challenge a partially-supports review raised in its own event, as opposed to an objection a member wrote. */
204
+ function coverageChallenge(state: CollaborationState, message: PeerMessage): boolean {
205
+ return state.reviews.some((r) => r.id === `V${message.id.slice(1)}` && r.actor === message.sender && r.verdict === "partially-supports");
206
+ }
207
+
208
+ /**
209
+ * A partial review opens a blocking challenge that only its sender may resolve. When that review
210
+ * contradicted nothing and another member later supports the whole revision, the unchecked scope
211
+ * is covered: the full review clears the challenge, and the resolution names both reviews.
212
+ */
213
+ function coverageClearance(c: CollaborationCommand, target: string, info: { revision: string }, reviewId: string, refs: string[]): Record<string, unknown> {
214
+ const cleared = c.state.messages.filter((m) =>
215
+ m.target === target && m.revision === info.revision && m.blocking && m.state !== "resolved" && m.sender !== c.ctx.actor && coverageChallenge(c.state, m));
216
+ if (!cleared.length) return {};
217
+ return { clearances: cleared.map((m) => ({
218
+ id: m.id,
219
+ text: `cleared by ${reviewId}: ${c.ctx.actor}'s full-scope supporting review of ${target}@${info.revision.slice(0, 12)} covers the scope V${m.id.slice(1)} left unchecked`,
220
+ refs,
221
+ })) };
179
222
  }
180
223
 
181
224
  /** The parent's assignment closes with the review id and its evidence, so the thread shows what decided it. */
@@ -59,7 +59,12 @@ export function retainArtifact(ctx: EventContext, file: string): { path: string;
59
59
  const runDir = path.join(ctx.hordeDir, "runs", ctx.runId);
60
60
  const root = fs.realpathSync(path.join(runDir, "artifacts"));
61
61
  const candidate = path.isAbsolute(file) ? file : path.join(runDir, file);
62
- const real = fs.realpathSync(candidate);
62
+ let real: string;
63
+ try {
64
+ real = fs.realpathSync(candidate);
65
+ } catch {
66
+ throw new Error(`horde: artifactPath not found: ${file}; artifacts live under runs/${ctx.runId}/artifacts/`);
67
+ }
63
68
  if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile()) throw new Error("horde: evidence must be a file inside this run's artifacts directory");
64
69
  const bytes = fs.readFileSync(real);
65
70
  const hash = hashText(bytes);
@@ -75,7 +75,11 @@ export const HordeParams = Type.Object({
75
75
  }),
76
76
  ),
77
77
  note: Type.Optional(
78
- Type.String({ description: "For a resource: a short pointer (path, command, or fact id) others should reuse" }),
78
+ Type.String({
79
+ description:
80
+ "For a resource: a short pointer (path, command, or fact id) others should reuse. For done/drop/rule-out: " +
81
+ "the result line, when text is not given",
82
+ }),
79
83
  ),
80
84
  cleanup: Type.Optional(
81
85
  Type.String({