@staix/agent-hub 0.27.0 → 0.28.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 (89) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +7 -3
  6. package/docs/agent-notes/tasks.md +5 -0
  7. package/docs/agent-notes/tests.md +6 -5
  8. package/docs/design/adapters.md +76 -0
  9. package/docs/design/agent-hub-architecture.md +67 -0
  10. package/docs/design/agent-hub.md +52 -0
  11. package/docs/design/bus.md +48 -0
  12. package/docs/design/task-board.md +86 -0
  13. package/docs/dogfood.md +16 -1
  14. package/docs/operations.md +308 -12
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +11 -3
  17. package/docs/sdlc.md +1 -1
  18. package/docs/security.md +8 -3
  19. package/docs/smoke.md +2 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  21. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  22. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  23. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  24. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  25. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  26. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  27. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  29. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  30. package/docs/verified.json +13 -13
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +338 -112
  34. package/src/adapters/claude-channel.ts +12 -2
  35. package/src/adapters/compat.ts +1 -1
  36. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  39. package/src/adapters/jcode.ts +37 -4
  40. package/src/adapters/qwen-dual.ts +3 -2
  41. package/src/cli/agents.ts +4 -2
  42. package/src/cli/bench.ts +4 -2
  43. package/src/cli/board-read.ts +25 -0
  44. package/src/cli/claude-homes.ts +2 -2
  45. package/src/cli/console-state.ts +8 -3
  46. package/src/cli/help.ts +6 -5
  47. package/src/cli/identity.ts +9 -1
  48. package/src/cli/init-prompt.ts +179 -0
  49. package/src/cli/init-settings.ts +229 -0
  50. package/src/cli/init.ts +102 -9
  51. package/src/cli/launch.ts +3 -1
  52. package/src/cli/main.ts +106 -35
  53. package/src/cli/output.ts +14 -0
  54. package/src/cli/setup-homes.ts +18 -3
  55. package/src/cli/specs.ts +150 -0
  56. package/src/cli/terminal-recovery.ts +35 -11
  57. package/src/hub/board.ts +56 -3
  58. package/src/hub/bus.ts +54 -7
  59. package/src/hub/child-process.ts +6 -4
  60. package/src/hub/config-apply.ts +4 -1
  61. package/src/hub/config-edit.ts +1 -1
  62. package/src/hub/config-tier.ts +1 -0
  63. package/src/hub/control-client.ts +28 -4
  64. package/src/hub/cost-governance.ts +22 -6
  65. package/src/hub/crash.ts +20 -0
  66. package/src/hub/daemon.ts +304 -56
  67. package/src/hub/delivery-hints.ts +6 -3
  68. package/src/hub/delivery-journal.ts +14 -3
  69. package/src/hub/hub-tools.ts +10 -6
  70. package/src/hub/peers.ts +1 -1
  71. package/src/hub/review-brief.ts +18 -0
  72. package/src/hub/routing.ts +7 -3
  73. package/src/hub/settings.ts +2 -0
  74. package/src/hub/specs-config.ts +43 -0
  75. package/src/hub/specs-task.ts +108 -0
  76. package/src/hub/specs.ts +1008 -0
  77. package/src/hub/task-list-page.ts +75 -0
  78. package/src/hub/task-sweep.ts +10 -1
  79. package/src/hub/tasks.ts +448 -40
  80. package/src/hub/terminal-open.ts +10 -5
  81. package/src/hub/tool-request.ts +131 -0
  82. package/src/ui/office-model.ts +17 -1
  83. package/src/ui/office-space.ts +1 -1
  84. package/templates/config.json +1 -0
  85. package/templates/profiles/dev.json +1 -0
  86. package/templates/profiles/general.json +1 -0
  87. package/templates/profiles/research.json +1 -0
  88. package/templates/profiles/writing.json +1 -0
  89. package/templates/specs.block.md +5 -0
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { personEnv } from "./child-process.ts";
3
- import { openStartTerminal, orcaExecutable, shellQuote, startTerminalWorktree, type CommandRunner } from "../cli/terminal-recovery.ts";
3
+ import { openStartTerminal, orcaExecutable, resolveStartTerminalWorktree, shellQuote, startTerminalCandidate, type CommandRunner } from "../cli/terminal-recovery.ts";
4
4
 
5
5
  /** #269: where a terminal for a hub-made start comes from, or why there is none. */
6
6
  export type TerminalOpened = { ok: true; via: string } | { ok: false; why: string; detail?: string };
@@ -18,14 +18,15 @@ const PLACEHOLDER = /\{(command|title|cwd)\}/g;
18
18
 
19
19
  /**
20
20
  * The providers, in order: the machine-local `terminal.open` command template, then Orca (the path recovery uses)
21
- * when this project has an Orca worktree. `run` replaces the Orca CLI in tests.
21
+ * when Orca is in play for this project. `available` only says Orca could be used; `open` asks Orca for the worktree
22
+ * whose root is this project's root and refuses when there is none (#1089). `run` replaces the Orca CLI in tests.
22
23
  */
23
24
  export function terminalOpener(opts: { template: string[]; cwd: string; stateDir: string; env?: NodeJS.ProcessEnv; run?: CommandRunner }): TerminalOpener {
24
25
  const env = opts.env ?? process.env;
25
- const worktree = () => startTerminalWorktree(opts.cwd, opts.stateDir, env);
26
+ const orcaInPlay = () => startTerminalCandidate(opts.cwd, opts.stateDir, env);
26
27
  const available = (): TerminalOpened => {
27
28
  if (opts.template.length) return { ok: true, via: "terminal.open" };
28
- if (!worktree()) return { ok: false, why: "no terminal.open command is configured and this project has no Orca worktree on record" };
29
+ if (!orcaInPlay()) return { ok: false, why: "no terminal.open command is configured and this project has no Orca worktree on record" };
29
30
  if (!opts.run && !Bun.which(orcaExecutable())) return { ok: false, why: `the ${orcaExecutable()} command is not on PATH` };
30
31
  return { ok: true, via: "orca" };
31
32
  };
@@ -38,7 +39,11 @@ export function terminalOpener(opts: { template: string[]; cwd: string; stateDir
38
39
  const command = argv.map(shellQuote).join(" ");
39
40
  try {
40
41
  if (can.via === "orca") {
41
- await openStartTerminal(worktree()!, command, title, opts.run ? { runner: opts.run } : undefined);
42
+ // The worktree comes from Orca by this project's root, never from the environment of whatever terminal started this hub (#1089).
43
+ const orca = opts.run ? { runner: opts.run } : undefined;
44
+ const target = await resolveStartTerminalWorktree(opts.cwd, orca);
45
+ if (!target.ok) return target;
46
+ await openStartTerminal(target.worktreeId, command, title, orca);
42
47
  return can;
43
48
  }
44
49
  // One pass with a function: a placeholder inside a substituted value (a project path that contains "{cwd}")
@@ -0,0 +1,131 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { canonical } from "./capabilities.ts";
3
+
4
+ export const TOOL_WRITE_FEATURE = "tool_write_idempotency";
5
+ const RETENTION_MS = 15 * 60 * 1000;
6
+ const MAX_ENTRIES = 512;
7
+ const MAX_REPLY_BYTES = 64 * 1024;
8
+ const MAX_PAYLOAD_BYTES = 1024 * 1024;
9
+ const MAX_FUTURE_MS = 30_000;
10
+ type Reply = Record<string, unknown>;
11
+
12
+ /** Writes with uncertain transport outcomes; delivery completion uses its journal rather than the replay registry. */
13
+ export function toolWriteOperation(msg: Record<string, unknown>): "hub_task_propose" | "hub_review" | "hub_delivery_done" | undefined {
14
+ if (msg.t === "delivery_complete") return "hub_delivery_done";
15
+ if (msg.t === "task") {
16
+ // Match the control dispatcher's legacy operation coercion, so accepted writes cannot bypass replay protection.
17
+ let operation: string;
18
+ try { operation = String(msg.op); } catch { return undefined; }
19
+ if (operation === "hub_task_propose" || operation === "hub_review") return operation;
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ export function newToolRequestId(instanceId: string, now = Date.now()): string {
25
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(instanceId) || !Number.isSafeInteger(now) || now < 1) throw new Error("tool request identity is unavailable");
26
+ return `v1/${instanceId}/${now}/${randomUUID()}`;
27
+ }
28
+
29
+ function settlingRead(msg: Record<string, unknown>): { read: string; absent: string } {
30
+ const args = msg.args && typeof msg.args === "object" && !Array.isArray(msg.args) ? msg.args as Record<string, unknown> : {};
31
+ const id = typeof args.id === "number" ? args.id : typeof args.id === "string" && /^\s*\d+\s*$/.test(args.id) ? Number(args.id) : undefined;
32
+ const delivery = typeof msg.deliveryId === "string" && /^(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/.test(msg.deliveryId) ? msg.deliveryId : "<delivery-id>";
33
+ if (toolWriteOperation(msg) === "hub_delivery_done") return { read: `ahub queue show ${delivery} (person or conductor)`, absent: "completion is absent" };
34
+ if (toolWriteOperation(msg) === "hub_review" && Number.isSafeInteger(id) && id! > 0) return { read: `hub_task_show {id: ${id}}`, absent: "this verdict is absent" };
35
+ return { read: "hub_task_list (then hub_task_show for the matching task)", absent: "the task is absent" };
36
+ }
37
+ function safeRequestId(requestId: unknown): string | undefined {
38
+ return typeof requestId === "string" && requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(requestId) ? requestId : undefined;
39
+ }
40
+ export function toolWriteUnknownReply(msg: Record<string, unknown>, requestId?: string, cause?: string): Reply {
41
+ const token = safeRequestId(requestId), settlement = settlingRead(msg);
42
+ const replay = token && toolWriteOperation(msg) !== "hub_delivery_done" ? `, reusing request_id ${JSON.stringify(token)}` : "";
43
+ return { ok: false, outcome: "unknown", ...(token ? { requestId: token } : {}),
44
+ error: `Write outcome unknown${cause ? `: ${cause}` : ". The hub may have applied the write"}. Read ${settlement.read}; call again only if ${settlement.absent}${replay}.` };
45
+ }
46
+ export function toolWriteRefusedReply(msg: Record<string, unknown>, requestId: unknown, cause: string): Reply {
47
+ const token = safeRequestId(requestId);
48
+ return { ok: false, outcome: "refused", ...(token ? { requestId: token } : {}),
49
+ error: `Write request refused: ${cause}. This attempt did not run. Read ${settlingRead(msg).read} before another write.` };
50
+ }
51
+
52
+ interface Entry { bucket: string; scope: string; payload: string; issuedAt: number; settled: boolean; reply: Promise<Reply> }
53
+ interface Request { id: unknown; scope: string; bucket?: string; payload: unknown; current: (reply?: Reply) => boolean }
54
+
55
+ /** One daemon epoch. Expired ids are rejected, not forgotten and reapplied. */
56
+ export class ToolWriteRegistry {
57
+ private readonly entries = new Map<string, Entry>();
58
+ private watermark = 0;
59
+ private readonly clock: () => number;
60
+ private readonly maxEntries: number;
61
+ private readonly retentionMs: number;
62
+ private readonly maxReplyBytes: number;
63
+ constructor(private readonly instanceId: string, options: { now?: () => number; maxEntries?: number; retentionMs?: number; maxReplyBytes?: number } = {}) {
64
+ this.clock = options.now ?? Date.now;
65
+ this.maxEntries = options.maxEntries ?? MAX_ENTRIES;
66
+ this.retentionMs = options.retentionMs ?? RETENTION_MS;
67
+ this.maxReplyBytes = options.maxReplyBytes ?? MAX_REPLY_BYTES;
68
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(instanceId) || ![this.maxEntries, this.retentionMs, this.maxReplyBytes].every(value => Number.isSafeInteger(value) && value > 0)) throw new Error("invalid tool replay registry bounds");
69
+ }
70
+
71
+ async run(request: Request, work: () => Promise<Reply>): Promise<Reply> {
72
+ const refuse = (cause: string) => toolWriteRefusedReply(this.message(request.payload), request.id, cause);
73
+ const unavailable = (cause: string) => toolWriteUnknownReply(this.message(request.payload), typeof request.id === "string" ? request.id : undefined, cause);
74
+ const current = (reply?: Reply) => { try { return request.current(reply === undefined ? undefined : structuredClone(reply)) === true; } catch { return false; } };
75
+ if (!current()) return refuse("current caller authority or privacy is unavailable");
76
+ const observed = this.clock();
77
+ if (!Number.isSafeInteger(observed) || observed < 1) return refuse("the replay clock is unavailable");
78
+ const now = this.watermark = Math.max(this.watermark, observed);
79
+ const parts = typeof request.id === "string" && request.id.length <= 256 ? request.id.split("/") : [];
80
+ const issuedAt = Number(parts[2]);
81
+ if (parts.length !== 4 || parts[0] !== "v1" || parts[1] !== this.instanceId || !/^[1-9][0-9]{0,15}$/.test(parts[2] ?? "")
82
+ || !Number.isSafeInteger(issuedAt) || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(parts[3] ?? "")) return refuse("the request ID is invalid or belongs to another daemon instance; inspect its original outcome");
83
+ let payload: string, scope: string, bucket: string;
84
+ try {
85
+ if (typeof request.scope !== "string" || request.scope.length > 4096) throw new Error("invalid scope");
86
+ const bucketKey = request.bucket ?? request.scope;
87
+ if (typeof bucketKey !== "string" || bucketKey.length > 4096) throw new Error("invalid bucket");
88
+ scope = createHash("sha256").update(request.scope).digest("hex");
89
+ bucket = createHash("sha256").update(bucketKey).digest("hex");
90
+ const encoded = JSON.stringify(request.payload);
91
+ if (!encoded || Buffer.byteLength(encoded) > MAX_PAYLOAD_BYTES) throw new Error("request too large");
92
+ const value: unknown = JSON.parse(encoded);
93
+ const stack = [{ value, depth: 0 }]; let nodes = 0;
94
+ while (stack.length) {
95
+ const row = stack.pop()!;
96
+ if (++nodes > 20_000 || row.depth > 32) throw new Error("request too complex");
97
+ if (row.value && typeof row.value === "object") for (const child of Object.values(row.value)) stack.push({ value: child, depth: row.depth + 1 });
98
+ }
99
+ payload = createHash("sha256").update(canonical(value)).digest("hex");
100
+ } catch { return refuse("the request is too large or malformed for replay protection"); }
101
+ // Pending writes never age out: losing their key could apply an unfinished write twice.
102
+ for (const [id, entry] of this.entries) if (entry.settled && now - entry.issuedAt >= this.retentionMs) this.entries.delete(id);
103
+ const existing = this.entries.get(request.id as string);
104
+ if (existing) {
105
+ if (existing.scope !== scope || existing.bucket !== bucket || existing.payload !== payload) return refuse("the request ID was already used by another authority or for different arguments");
106
+ const reply = await existing.reply;
107
+ return current(reply) ? structuredClone(reply) : unavailable("caller authority or privacy changed before replay");
108
+ }
109
+ if (now - issuedAt >= this.retentionMs || issuedAt > now + MAX_FUTURE_MS) return refuse("the request ID is expired or its timestamp is outside the replay window; inspect its original outcome");
110
+ // ponytail: 512 calls per actor/scope per 15 min and 64 KiB per reply; a durable outcome ledger is the upgrade path for higher sustained write volume.
111
+ if ([...this.entries.values()].filter(entry => entry.bucket === bucket).length >= this.maxEntries) return refuse("this actor's replay window is full; inspect its outstanding writes and wait for that window to drain");
112
+ const entry: Entry = { bucket, scope, payload, issuedAt, settled: false, reply: Promise.resolve({}) };
113
+ this.entries.set(request.id as string, entry);
114
+ entry.reply = Promise.resolve().then(async () => {
115
+ if (!current()) return refuse("caller authority or privacy changed before execution");
116
+ try {
117
+ const result = await work();
118
+ const { rid: _rid, ...body } = result;
119
+ const encoded = JSON.stringify({ ...body, requestId: request.id });
120
+ if (Buffer.byteLength(encoded) > this.maxReplyBytes) return unavailable("the write result exceeds the bounded replay record; inspect the write directly");
121
+ return JSON.parse(encoded) as Reply;
122
+ } catch { return unavailable("the write raised an unexpected failure and may already have applied"); }
123
+ }).finally(() => { entry.settled = true; });
124
+ const reply = await entry.reply;
125
+ return current(reply) ? structuredClone(reply) : unavailable("caller authority or privacy changed before the result");
126
+ }
127
+
128
+ private message(payload: unknown): Record<string, unknown> {
129
+ return payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : {};
130
+ }
131
+ }
@@ -163,6 +163,11 @@ export interface OfficeScene {
163
163
  links: OfficeLink[];
164
164
  /** Sticky desk assignment; pass the whole scene back as `previous` so slots survive churn. */
165
165
  slots: Record<string, number>;
166
+ /**
167
+ * Public card data for tasks a desk bubble names that the rendering limits (8 per room, 2 on an overfull desk) left out of
168
+ * every `cards` list. Zero geometry: the dashboard never draws it, acon Space maps it. Same allowlisted fields as `cards`.
169
+ */
170
+ attributedCards: OfficeCard[];
166
171
  /** Task id -> placement ("room:todo" | "room:work" | "room:review" | "shelf:done" | "desk:<peer>" | "counted:unknown"). */
167
172
  placed: Record<string, string>;
168
173
  arcs: OfficeArc[];
@@ -359,6 +364,17 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
359
364
  : state === "in_progress" || state === "changes_requested" ? (owner && shownSet.has(owner) ? `desk:${owner}` : "room:work")
360
365
  : state === "backlog" ? "counted:backlog" : "counted:unknown";
361
366
  }
367
+ // A bubble's attributed task may sit past the rendering cut; keep its public card so a consumer that draws more cards than the page can.
368
+ const drawn = new Set<number>([...desks.flatMap((desk) => desk.cards), ...rooms.flatMap((room) => room.cards)].map((entry) => entry.id));
369
+ const taskById = new Map<number, OfficeTaskInput>(tasks.map((task) => [task.id, task]));
370
+ const attributedCards: OfficeCard[] = [];
371
+ for (const desk of desks) {
372
+ const id = /^#(\d+) /.exec(desk.bubble)?.[1];
373
+ const task = id === undefined ? undefined : taskById.get(Number(id));
374
+ if (!task || drawn.has(task.id)) continue;
375
+ drawn.add(task.id);
376
+ attributedCards.push(card(task, 0, 0, 0, 0));
377
+ }
362
378
  const transitions: OfficeTransitions = { desksAdded: [], desksRemoved: [], cardsMoved: [] };
363
379
  if (previous) {
364
380
  const prevPlaced = previous.placed ?? {};
@@ -440,7 +456,7 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
440
456
  cols,
441
457
  width: Math.max(desksWidth, ...rooms.map(room => room.x + room.w), 1),
442
458
  height: Math.max(desksBottom, ...rooms.map(room => room.y + room.h), 1),
443
- desks, extraPeers, rooms, counts, transitions, slots, placed,
459
+ desks, extraPeers, rooms, counts, transitions, slots, placed, attributedCards,
444
460
  links,
445
461
  arcs: keptArcs, alerts: keptAlerts, latched, settle, at, eventTime, lastSeq,
446
462
  };
@@ -70,7 +70,7 @@ export function officeSpace(model: OfficeScene, project: { id: string; label?: s
70
70
  };
71
71
  const cards: SpaceScene["cards"] = [];
72
72
  const seen = new Set<number>();
73
- for (const card of [...model.desks.flatMap((d) => d.cards), ...model.rooms.flatMap((r) => r.cards)]) {
73
+ for (const card of [...model.desks.flatMap((d) => d.cards), ...model.rooms.flatMap((r) => r.cards), ...(model.attributedCards ?? [])]) {
74
74
  if (seen.has(card.id)) continue;
75
75
  seen.add(card.id);
76
76
  const zone = zoneOf(model.placed[String(card.id)]);
@@ -32,6 +32,7 @@
32
32
  "tasks": { "release_after_min": 30 },
33
33
  "checks": { "timeout_s": 600 },
34
34
  "snapshots": { "enabled": true, "keep": 20 },
35
+ "specs": { "enabled": true, "review": "report", "roots": [], "stamps_file": "" },
35
36
  "review": { "adaptive": false, "min_reviews": 5 },
36
37
  "recovery": { "auto_resume_after_crash": false },
37
38
  "capabilities": {},
@@ -22,6 +22,7 @@
22
22
  "review_checklist": [
23
23
  "Map the changed signatures and call sites to {target}, and name each one that does not match.",
24
24
  "{check}",
25
+ "Name each new or changed error, refusal or failure message that lacks its cause (what failed and why) or its next step (the command, setting or target that fixes it); where secrets, ids or paths must stay out, it names the cause class and how to check it.",
25
26
  "List what is unmet in hub_review's unmet, one item each."
26
27
  ]
27
28
  }
@@ -26,6 +26,7 @@
26
26
  "review_checklist": [
27
27
  "Assess the deliverables against {target}, and name each one that does not match.",
28
28
  "{check}",
29
+ "Name each new or changed error, refusal or failure message that lacks its cause (what failed and why) or its next step (the command, setting or target that fixes it); where secrets, ids or paths must stay out, it names the cause class and how to check it.",
29
30
  "List what is unmet in hub_review's unmet, one item each."
30
31
  ]
31
32
  }
@@ -57,6 +57,7 @@
57
57
  "review_checklist": [
58
58
  "Assess scope, provenance and unsupported claims against {target}, and name each one that does not match.",
59
59
  "{check}",
60
+ "Name each new or changed error, refusal or failure message that lacks its cause (what failed and why) or its next step (the command, setting or target that fixes it); where secrets, ids or paths must stay out, it names the cause class and how to check it.",
60
61
  "List what is unmet in hub_review's unmet, one item each."
61
62
  ]
62
63
  }
@@ -28,6 +28,7 @@
28
28
  "review_checklist": [
29
29
  "Assess scope, provenance and unsupported claims against {target}, and name each one that does not match.",
30
30
  "{check}",
31
+ "Name each new or changed error, refusal or failure message that lacks its cause (what failed and why) or its next step (the command, setting or target that fixes it); where secrets, ids or paths must stay out, it names the cause class and how to check it.",
31
32
  "List what is unmet in hub_review's unmet, one item each."
32
33
  ]
33
34
  }
@@ -0,0 +1,5 @@
1
+ ## Design specs
2
+
3
+ - Design specs are Markdown files with `id` and `type` frontmatter whose `paths` name the files they govern. The hub names the specs covering a task when you propose or accept it, and `hub_specs` answers on request.
4
+ - Read a covering spec when your change touches a boundary, contract, invariant or decision it states. Follow it, or reconcile the contradiction; never silently diverge.
5
+ - When your change alters one of those, update the owning spec in the same change. Never edit `verified-against`; a person stamps it.