@devspecai/pi-plugin 0.0.0-stage → 0.10.3

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 (106) hide show
  1. package/CHANGELOG.md +899 -0
  2. package/LICENSE +21 -0
  3. package/README.md +103 -2
  4. package/extensions/.gitkeep +0 -0
  5. package/extensions/core/activation-recovery.ts +135 -0
  6. package/extensions/core/active-deliveries.ts +171 -0
  7. package/extensions/core/active-session-plans.ts +374 -0
  8. package/extensions/core/adapter-overlap.ts +247 -0
  9. package/extensions/core/assistant-speech.ts +44 -0
  10. package/extensions/core/attach-context.ts +25 -0
  11. package/extensions/core/attachment-limits.ts +20 -0
  12. package/extensions/core/attachments.ts +487 -0
  13. package/extensions/core/auth-store.ts +91 -0
  14. package/extensions/core/automation-run-retry.ts +31 -0
  15. package/extensions/core/background-journal.ts +85 -0
  16. package/extensions/core/background-observer.ts +279 -0
  17. package/extensions/core/bond-recovery.ts +65 -0
  18. package/extensions/core/bond-state.ts +80 -0
  19. package/extensions/core/clear-conversation.ts +53 -0
  20. package/extensions/core/client-versions.ts +56 -0
  21. package/extensions/core/client.ts +704 -0
  22. package/extensions/core/command-offer.ts +88 -0
  23. package/extensions/core/command-runner.ts +62 -0
  24. package/extensions/core/command-turn.ts +88 -0
  25. package/extensions/core/commit-ledger.ts +195 -0
  26. package/extensions/core/commit-stamp.ts +98 -0
  27. package/extensions/core/config.ts +63 -0
  28. package/extensions/core/connection-context.ts +28 -0
  29. package/extensions/core/context-budget.ts +175 -0
  30. package/extensions/core/control-result-outbox.ts +80 -0
  31. package/extensions/core/control.ts +156 -0
  32. package/extensions/core/ctx-guard.ts +33 -0
  33. package/extensions/core/debug.ts +136 -0
  34. package/extensions/core/delivery-queue.ts +92 -0
  35. package/extensions/core/dispatch.ts +108 -0
  36. package/extensions/core/duck.ts +128 -0
  37. package/extensions/core/git-observer.ts +382 -0
  38. package/extensions/core/github-identity.ts +26 -0
  39. package/extensions/core/implementation-contract.ts +773 -0
  40. package/extensions/core/instructions.ts +40 -0
  41. package/extensions/core/interaction-events.ts +834 -0
  42. package/extensions/core/json-schema.ts +16 -0
  43. package/extensions/core/local-activity.ts +34 -0
  44. package/extensions/core/mcp-config-guard.ts +117 -0
  45. package/extensions/core/model-catalog.ts +76 -0
  46. package/extensions/core/model-surface.ts +125 -0
  47. package/extensions/core/notice-reply.ts +215 -0
  48. package/extensions/core/owner-inject.ts +148 -0
  49. package/extensions/core/post-session-receipt.ts +70 -0
  50. package/extensions/core/preempt.ts +100 -0
  51. package/extensions/core/project-choice.ts +145 -0
  52. package/extensions/core/project-scope.ts +335 -0
  53. package/extensions/core/project-selection-ui.ts +202 -0
  54. package/extensions/core/provenance-refs.ts +158 -0
  55. package/extensions/core/push-channel.ts +281 -0
  56. package/extensions/core/question-receipt-lifecycle.ts +158 -0
  57. package/extensions/core/recall.ts +121 -0
  58. package/extensions/core/remote-payload.ts +1329 -0
  59. package/extensions/core/remote-session-arg.ts +24 -0
  60. package/extensions/core/repository-context.ts +35 -0
  61. package/extensions/core/request-chain.ts +1244 -0
  62. package/extensions/core/result-projection.ts +939 -0
  63. package/extensions/core/result.ts +100 -0
  64. package/extensions/core/review-context.ts +70 -0
  65. package/extensions/core/review-watch.ts +94 -0
  66. package/extensions/core/room-access.ts +12 -0
  67. package/extensions/core/room-context.ts +178 -0
  68. package/extensions/core/room-reader.ts +308 -0
  69. package/extensions/core/room-store.ts +381 -0
  70. package/extensions/core/room-sync.ts +79 -0
  71. package/extensions/core/room-tools.ts +86 -0
  72. package/extensions/core/room-unread.ts +173 -0
  73. package/extensions/core/session-events.ts +62 -0
  74. package/extensions/core/session-polls.ts +292 -0
  75. package/extensions/core/settings.ts +39 -0
  76. package/extensions/core/status-line.ts +432 -0
  77. package/extensions/core/still-to-discuss.ts +124 -0
  78. package/extensions/core/subagent-fleet.ts +238 -0
  79. package/extensions/core/system-notice.ts +227 -0
  80. package/extensions/core/telemetry.ts +177 -0
  81. package/extensions/core/tool-selection.ts +72 -0
  82. package/extensions/core/trail-events.ts +247 -0
  83. package/extensions/core/trail-projection.ts +373 -0
  84. package/extensions/core/trail.ts +465 -0
  85. package/extensions/core/turn-lifecycle.ts +472 -0
  86. package/extensions/core/version-mismatch.ts +165 -0
  87. package/extensions/core/work-prompt.ts +44 -0
  88. package/extensions/core/work-state.ts +179 -0
  89. package/extensions/devspec-adapter-guard.ts +106 -0
  90. package/extensions/devspec-auth.ts +191 -0
  91. package/extensions/devspec-brand.ts +140 -0
  92. package/extensions/devspec-commit-ledger.ts +477 -0
  93. package/extensions/devspec-context.ts +146 -0
  94. package/extensions/devspec-memory.ts +136 -0
  95. package/extensions/devspec-provenance-assistance.ts +233 -0
  96. package/extensions/devspec-remote.ts +5152 -0
  97. package/extensions/devspec-tools.ts +552 -0
  98. package/extensions/devspec-work.ts +244 -0
  99. package/extensions/generated/README.md +18 -0
  100. package/extensions/generated/mcp-catalog.ts +15811 -0
  101. package/package.json +71 -4
  102. package/prompts/.gitkeep +0 -0
  103. package/runtime/commit-stamp.mjs +279 -0
  104. package/runtime/git-observer-shim.mjs +390 -0
  105. package/skills/.gitkeep +0 -0
  106. package/themes/.gitkeep +0 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * What this agent has and has not read in its room (item ce1ffca1; brief 4d258656,
3
+ * decision c4c6190f). Lives in the existing room copy, never a second store.
4
+ *
5
+ * Two bookmarks, kept apart on purpose:
6
+ * - "read": the exact message versions `read_room_context` handed to the model.
7
+ * Only the reader writes it. A wake, a notice or the agent's own reply never do.
8
+ * - "presented": what a notice has TOLD the model exists. It decides whether a
9
+ * mid-turn notice has anything new to say; it never marks anything read.
10
+ * The coverage checkpoint ("saved locally through here") is a third, separate fact.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { closeSync, openSync, readSync } from "node:fs";
14
+ import type { DatabaseSync } from "node:sqlite";
15
+ import { hasAssistantSpeech as hasVisibleText } from "./assistant-speech.ts";
16
+ import type { StoredRoomRecord } from "./room-store.ts";
17
+
18
+ /** Additive table: older caches simply have nothing read yet. */
19
+ export const ROOM_MARKS_TABLE = "CREATE TABLE IF NOT EXISTS room_marks (kind TEXT NOT NULL, id TEXT NOT NULL, version TEXT NOT NULL, PRIMARY KEY (kind, id))";
20
+ export type RoomMarkKind = "read" | "presented" | "own";
21
+ export interface RoomMarks { read: Map<string, string>; presented: Set<string>; own: Set<string> }
22
+ type Db = Pick<DatabaseSync, "prepare" | "exec">;
23
+
24
+ /**
25
+ * Who "this agent" is. Exact ids come from Pi's own post receipts. The label
26
+ * covers rows posted before this attachment's copy existed (every attach starts
27
+ * a fresh copy); it is set only when the connection has a codename, because a
28
+ * bare tool name would also match every other Pi the owner runs.
29
+ */
30
+ export interface RoomSelf { connectionId: string; ownIds: ReadonlySet<string>; label: string | null }
31
+ export type RoomMessageChange = "new" | "updated";
32
+ export interface RoomWaitingMessage {
33
+ message_id: string;
34
+ sequence: number;
35
+ created_at: string;
36
+ change: RoomMessageChange;
37
+ for_you: boolean;
38
+ author: string;
39
+ kind: string;
40
+ version: string;
41
+ /** Presentation identity: a growing unread bubble is one key; a change after a read is a new one. */
42
+ key: string;
43
+ }
44
+ export interface RoomUnreadSummary {
45
+ unread: number;
46
+ updated: number;
47
+ for_you: number;
48
+ oldest_unread_at: string | null;
49
+ by_author: Array<{ author: string; kind: string; messages: number }>;
50
+ }
51
+ export interface RoomUnreadState { self: RoomSelf; marks: RoomMarks; waiting: RoomWaitingMessage[] }
52
+
53
+ /** The version a reader saw: what a person sees (text and attachments), never lifecycle state. */
54
+ export function roomMessageVersion(r: Pick<StoredRoomRecord, "content" | "attachments">): string {
55
+ return createHash("sha256").update(JSON.stringify([r.content, r.attachments.map(a => [a.resource_id, a.filename, a.type])])).digest("hex").slice(0, 32);
56
+ }
57
+
58
+ export function isOwnRoomMessage(r: Pick<StoredRoomRecord, "message_id" | "actor">, self: RoomSelf): boolean {
59
+ if (self.ownIds.has(r.message_id)) return true;
60
+ if (r.actor.kind !== "agent" || !self.label) return false;
61
+ // The server labels an agent's posts "Tool · Codename (Owner)".
62
+ return r.actor.display_name === self.label || r.actor.display_name.startsWith(`${self.label} (`);
63
+ }
64
+
65
+ /**
66
+ * A real message is judged by what a person in the room sees. Text (or an
67
+ * attachment) from a person, another agent or the in-session AI counts whether
68
+ * final or still in progress; system lines (presence markers, notices), empty
69
+ * rows in any state, deleted rows and this agent's own rows never do.
70
+ */
71
+ export function isRealRoomMessage(r: StoredRoomRecord, self: RoomSelf): boolean {
72
+ if (r.state === "deleted" || r.actor.kind === "system") return false;
73
+ if (isOwnRoomMessage(r, self)) return false;
74
+ return hasVisibleText(r.content) || r.attachments.length > 0;
75
+ }
76
+
77
+ /** Oldest first, in the room's stable total order. A changed read message is "updated", never unread again. */
78
+ export function waitingRoomMessages(records: Iterable<StoredRoomRecord>, read: ReadonlyMap<string, string>, self: RoomSelf): RoomWaitingMessage[] {
79
+ const waiting: RoomWaitingMessage[] = [];
80
+ for (const r of records) {
81
+ if (!isRealRoomMessage(r, self)) continue;
82
+ const version = roomMessageVersion(r);
83
+ const seen = read.get(r.message_id);
84
+ if (seen === version) continue;
85
+ const change: RoomMessageChange = seen === undefined ? "new" : "updated";
86
+ waiting.push({
87
+ message_id: r.message_id, sequence: r.order.sequence, created_at: r.order.created_at, change,
88
+ for_you: r.addressee.kind === "connection" && r.addressee.connection_id === self.connectionId,
89
+ author: r.actor.display_name, kind: r.actor.kind, version,
90
+ key: change === "new" ? roomPresentedKey(r.message_id) : `updated:${r.message_id}:${seen}`,
91
+ });
92
+ }
93
+ return waiting.sort((a, b) => a.sequence - b.sequence);
94
+ }
95
+
96
+ /** A command Pi was woken with has been TOLD, so it never makes a notice fresh. It stays unread until read. */
97
+ export function roomPresentedKey(messageId: string): string { return `new:${messageId.toLowerCase()}`; }
98
+
99
+ /** The commands one delivery hands the model, by exact id: the queue item's list or bundle, and the primary. */
100
+ export function deliveredCommandKeys(primaryMessageId: string | undefined, details: unknown): string[] {
101
+ const ids = new Set<string>();
102
+ if (primaryMessageId) ids.add(primaryMessageId.toLowerCase());
103
+ const d = details as { command_message_ids?: unknown; bundle?: { commands?: unknown } } | null | undefined;
104
+ const listed = Array.isArray(d?.command_message_ids) ? d.command_message_ids
105
+ : Array.isArray(d?.bundle?.commands) ? d.bundle.commands.map(c => (c as { id?: unknown } | null)?.id) : [];
106
+ for (const id of listed) if (typeof id === "string" && id) ids.add(id.toLowerCase());
107
+ return [...ids].map(roomPresentedKey);
108
+ }
109
+
110
+ /** Counts only. Author labels are participant-written data; callers encode them as JSON. */
111
+ export function summarizeRoomUnread(waiting: readonly RoomWaitingMessage[]): RoomUnreadSummary {
112
+ const authors = new Map<string, { author: string; kind: string; messages: number }>();
113
+ for (const w of waiting) {
114
+ const author = w.author; const k = JSON.stringify([w.kind, author]);
115
+ const entry = authors.get(k) ?? { author, kind: w.kind, messages: 0 };
116
+ entry.messages++; authors.set(k, entry);
117
+ }
118
+ return {
119
+ unread: waiting.filter(w => w.change === "new").length,
120
+ updated: waiting.filter(w => w.change === "updated").length,
121
+ for_you: waiting.filter(w => w.for_you).length,
122
+ oldest_unread_at: waiting.find(w => w.change === "new")?.created_at ?? null,
123
+ by_author: [...authors.values()].sort((a, b) => b.messages - a.messages || a.author.localeCompare(b.author)),
124
+ };
125
+ }
126
+
127
+ /** Something waiting that no notice has told the model about yet. */
128
+ export function hasUnpresented(state: Pick<RoomUnreadState, "waiting" | "marks">): boolean {
129
+ return state.waiting.some(w => !state.marks.presented.has(w.key));
130
+ }
131
+
132
+ export function readRoomMarks(db: Db): RoomMarks {
133
+ const marks: RoomMarks = { read: new Map(), presented: new Set(), own: new Set() };
134
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='room_marks'").get()) return marks;
135
+ for (const row of db.prepare("SELECT kind, id, version FROM room_marks").all()) {
136
+ const id = String(row.id);
137
+ if (row.kind === "read") marks.read.set(id, String(row.version));
138
+ else if (row.kind === "presented") marks.presented.add(id);
139
+ else if (row.kind === "own") marks.own.add(id);
140
+ }
141
+ return marks;
142
+ }
143
+
144
+ /** Caller owns the transaction. A read mark records the version handed over, replacing an older one. */
145
+ export function writeRoomMarks(db: Db, kind: RoomMarkKind, entries: ReadonlyArray<readonly [string, string]>): void {
146
+ const upsert = db.prepare("INSERT INTO room_marks VALUES (?,?,?) ON CONFLICT(kind, id) DO UPDATE SET version=excluded.version");
147
+ for (const [id, version] of entries) upsert.run(kind, id, version);
148
+ }
149
+
150
+ /** One full pass over the saved copy. Callers cache it by the file change counter. */
151
+ export function loadRoomUnread(db: Db, connectionId: string): RoomUnreadState {
152
+ const marks = readRoomMarks(db);
153
+ const row = db.prepare("SELECT value FROM meta WHERE key='self_label'").get();
154
+ const selfLabel = row ? JSON.parse(String(row.value)) as unknown : null;
155
+ const self: RoomSelf = { connectionId: connectionId.toLowerCase(), ownIds: marks.own, label: typeof selfLabel === "string" && selfLabel ? selfLabel : null };
156
+ const records = db.prepare("SELECT body FROM messages WHERE state<>'deleted' ORDER BY sequence").all().map(r => JSON.parse(String(r.body)) as StoredRoomRecord);
157
+ return { self, marks, waiting: waitingRoomMessages(records, marks.read, self) };
158
+ }
159
+
160
+ /**
161
+ * SQLite's file change counter (header bytes 24-27) moves on every committed
162
+ * write in rollback-journal mode, which the room copy uses. Reading it is the
163
+ * cheap "has the local copy changed?" check on Pi's per-tool-call path.
164
+ */
165
+ export function roomFileChangeCounter(path: string): number | null {
166
+ let fd: number | undefined;
167
+ try {
168
+ fd = openSync(path, "r");
169
+ const header = Buffer.alloc(4);
170
+ return readSync(fd, header, 0, 4, 24) === 4 ? header.readUInt32BE(0) : null;
171
+ } catch { return null; }
172
+ finally { if (fd !== undefined) closeSync(fd); }
173
+ }
@@ -0,0 +1,62 @@
1
+ /** Historical record events, never current state or command authority (81a0cccd). */
2
+ import { advertisesRoomContext } from "./room-context.ts";
3
+
4
+ type Actor = { kind: "agent" | "person" | "platform"; label: string };
5
+ type Base = { id: string; at: string; kind: "task" | "memory" | "artifact"; record_id: string };
6
+ export type SessionEvent = Base & (
7
+ | { event: "produced" | "retracted" | "archived"; actor: Actor | null }
8
+ | { event: "referenced"; message_id: string }
9
+ | { event: "lifecycle_changed"; from: string; to: string }
10
+ | { event: "superseded"; by: string | null; actor: Actor | null }
11
+ );
12
+ export interface SessionEvents {
13
+ version: 1; advisory: true; status: "available" | "unchanged" | "unavailable";
14
+ as_of: string; revision: string | null; events: SessionEvent[]; truncated: boolean;
15
+ }
16
+ function bad(): never { throw new Error("invalid session events"); }
17
+ function object(x: unknown): Record<string, unknown> { if (!x || typeof x !== "object" || Array.isArray(x)) bad(); return x as Record<string, unknown>; }
18
+ function text(x: unknown): string { if (typeof x !== "string" || !x) bad(); return x; }
19
+ function uuid(x: unknown): string { const s = text(x); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(s)) bad(); return s; }
20
+ function date(x: unknown): string { const s = text(x); if (!/^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(s) || !Number.isFinite(Date.parse(s))) bad(); return s; }
21
+ function actor(x: unknown): Actor | null {
22
+ if (x === null) return null;
23
+ const a = object(x); if (a.kind !== "agent" && a.kind !== "person" && a.kind !== "platform") bad();
24
+ return { kind: a.kind, label: text(a.label) };
25
+ }
26
+ export function advertisesSessionEvents(resource: unknown): boolean {
27
+ try {
28
+ for (const item of object(resource).contents as unknown[]) {
29
+ if (!advertisesRoomContext({ contents: [item] })) continue;
30
+ const c = object(JSON.parse(text(object(item).text))); const a = object(c.activation);
31
+ const r = object(a.session_events_requires);
32
+ if (c.kind === "devspec.product.remote_ingress_contract" && a.session_events_negotiation_argument === "session_events_version" && a.session_events_revision_argument === "session_events_revision" && r.room_context_version === 1 && r.session_events_version === 1) return true;
33
+ }
34
+ } catch { /* Older advertisement: keep the existing room lane. */ }
35
+ return false;
36
+ }
37
+ /** Ignore additive fields; an unknown variant rejects only this advisory section. */
38
+ export function parseSessionEvents(value: unknown): SessionEvents {
39
+ const s = object(value);
40
+ if (s.version !== 1 || s.advisory !== true || !["available", "unchanged", "unavailable"].includes(String(s.status)) || typeof s.truncated !== "boolean" || !Array.isArray(s.events) || s.events.length > 2000) bad();
41
+ const revision = s.revision === null ? null : text(s.revision);
42
+ if (revision !== null && !/^sha256:[a-f0-9]{64}$/.test(revision)) bad();
43
+ if (s.status === "unavailable" ? revision !== null || s.events.length > 0 || s.truncated : revision === null) bad();
44
+ if (s.status === "unchanged" && s.events.length) bad();
45
+ const seen = new Set<string>();
46
+ const events = s.events.map((value): SessionEvent => {
47
+ const e = object(value); const id = text(e.id);
48
+ if (id.length > 200 || seen.has(id)) bad(); seen.add(id);
49
+ if (e.kind !== "task" && e.kind !== "memory" && e.kind !== "artifact") bad();
50
+ const base: Base = { id, at: date(e.at), kind: e.kind, record_id: uuid(e.record_id) };
51
+ switch (e.event) {
52
+ case "produced": case "retracted": case "archived": return { ...base, event: e.event, actor: actor(e.actor) };
53
+ case "referenced": return { ...base, event: e.event, message_id: uuid(e.message_id) };
54
+ case "lifecycle_changed":
55
+ if (e.kind !== "task") bad();
56
+ return { ...base, event: e.event, from: text(e.from), to: text(e.to) };
57
+ case "superseded": return { ...base, event: e.event, by: e.by === null ? null : uuid(e.by), actor: actor(e.actor) };
58
+ default: return bad();
59
+ }
60
+ });
61
+ return { version: 1, advisory: true, status: s.status as SessionEvents["status"], as_of: date(s.as_of), revision, events, truncated: s.truncated };
62
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Strict top-level session_polls projection on changed poll_connection packages
3
+ * (item 66bd18f6 / 852eab6b). Presence is advisory read-awareness only.
4
+ */
5
+
6
+ export const SESSION_POLL_PROJECTION_VERSION = 1 as const;
7
+ /**
8
+ * Version 2 (item bba35976) is never missing while the session has polls: the newest
9
+ * that fit the text budget, the totals, and a continuation naming where the rest
10
+ * are. Version 1 refused outright past 16 active polls, so a busy room arrived as
11
+ * no polls at all.
12
+ */
13
+ export const SESSION_POLL_PROJECTION_VERSION_2 = 2 as const;
14
+ export const SESSION_POLL_CONTINUATION = "Call manage_poll list to read every poll in this session." as const;
15
+ export const SESSION_POLL_MAX_ACTIVE = 16;
16
+ export const SESSION_POLL_MAX_ENDED = 5;
17
+ export const SESSION_POLL_MAX_OPTIONS = 30;
18
+ export const SESSION_POLL_MAX_QUESTION_CHARS = 400;
19
+ export const SESSION_POLL_MAX_LABEL_CHARS = 80;
20
+ export const SESSION_POLL_MAX_VOTER_CHARS = 80;
21
+ export const SESSION_POLL_MAX_TOTAL_TEXT_CHARS = 16_384;
22
+ export const SESSION_POLL_AUTHORITY_NOTE =
23
+ "Advisory read-awareness only. Presence does not authorize execution or mutation; manage_poll still requires a capability-authenticated caller identity and expected_revision. Votes are not commands and do not keep Working on." as const;
24
+
25
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
26
+
27
+ export interface SessionPollOptionProjection {
28
+ label: string;
29
+ human_vote_count: number;
30
+ percent: number;
31
+ voters: string[];
32
+ }
33
+
34
+ export interface SessionPollProjection {
35
+ id: string;
36
+ question: string;
37
+ status: "active" | "ended";
38
+ revision: number;
39
+ multi_select: boolean;
40
+ allow_write_in: boolean;
41
+ recommendation: string | null;
42
+ human_voter_count: number;
43
+ winning_labels: string[];
44
+ options: SessionPollOptionProjection[];
45
+ }
46
+
47
+ export interface SessionPollsProjectionV1 {
48
+ version: typeof SESSION_POLL_PROJECTION_VERSION;
49
+ advisory: true;
50
+ authority_note: typeof SESSION_POLL_AUTHORITY_NOTE;
51
+ inventory: {
52
+ active_returned: number;
53
+ ended_returned: number;
54
+ truncated_ended: boolean;
55
+ };
56
+ polls: SessionPollProjection[];
57
+ }
58
+
59
+ export interface SessionPollsProjectionV2 {
60
+ version: typeof SESSION_POLL_PROJECTION_VERSION_2;
61
+ advisory: true;
62
+ authority_note: typeof SESSION_POLL_AUTHORITY_NOTE;
63
+ inventory: {
64
+ active_total: number;
65
+ active_returned: number;
66
+ truncated_active: boolean;
67
+ ended_total: number;
68
+ ended_returned: number;
69
+ truncated_ended: boolean;
70
+ continuation: typeof SESSION_POLL_CONTINUATION | null;
71
+ };
72
+ polls: SessionPollProjection[];
73
+ }
74
+
75
+ export type SessionPollsProjection = SessionPollsProjectionV1 | SessionPollsProjectionV2;
76
+
77
+ type Row = Record<string, unknown>;
78
+
79
+ function fail(path: string, message: string): never {
80
+ throw new Error(`${path}: ${message}`);
81
+ }
82
+
83
+ function record(value: unknown, path: string): Row {
84
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail(path, "expected object");
85
+ return value as Row;
86
+ }
87
+
88
+ function exactKeys(value: Row, keys: readonly string[], path: string): void {
89
+ const actual = Object.keys(value).sort();
90
+ const expected = [...keys].sort();
91
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
92
+ fail(path, `expected exactly ${expected.join(", ")}`);
93
+ }
94
+ }
95
+
96
+ function boundedString(value: unknown, path: string, max: number): string {
97
+ if (typeof value !== "string" || value.length === 0 || value.length > max) {
98
+ fail(path, `expected non-empty string <= ${max} characters`);
99
+ }
100
+ return value;
101
+ }
102
+
103
+ function uuid(value: unknown, path: string): string {
104
+ const parsed = boundedString(value, path, 64);
105
+ if (!UUID_RE.test(parsed)) fail(path, "expected UUID");
106
+ return parsed;
107
+ }
108
+
109
+ function integer(value: unknown, path: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number {
110
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
111
+ fail(path, `expected integer ${minimum}..${maximum}`);
112
+ }
113
+ return value;
114
+ }
115
+
116
+ function boolean(value: unknown, path: string): boolean {
117
+ if (typeof value !== "boolean") fail(path, "expected boolean");
118
+ return value;
119
+ }
120
+
121
+ function option(value: unknown, path: string): SessionPollOptionProjection {
122
+ const row = record(value, path);
123
+ exactKeys(row, ["label", "human_vote_count", "percent", "voters"], path);
124
+ if (!Array.isArray(row.voters) || row.voters.length > 32) fail(`${path}.voters`, "expected <= 32 voter names");
125
+ return {
126
+ label: boundedString(row.label, `${path}.label`, SESSION_POLL_MAX_LABEL_CHARS),
127
+ human_vote_count: integer(row.human_vote_count, `${path}.human_vote_count`),
128
+ percent: integer(row.percent, `${path}.percent`, 0, 100),
129
+ voters: row.voters.map((name, index) => boundedString(name, `${path}.voters[${index}]`, SESSION_POLL_MAX_VOTER_CHARS)),
130
+ };
131
+ }
132
+
133
+ function poll(value: unknown, path: string): SessionPollProjection {
134
+ const row = record(value, path);
135
+ exactKeys(row, [
136
+ "id", "question", "status", "revision", "multi_select", "allow_write_in",
137
+ "recommendation", "human_voter_count", "winning_labels", "options",
138
+ ], path);
139
+ if (row.status !== "active" && row.status !== "ended") fail(`${path}.status`, "expected active or ended");
140
+ if (!Array.isArray(row.winning_labels) || row.winning_labels.length > SESSION_POLL_MAX_OPTIONS) {
141
+ fail(`${path}.winning_labels`, `expected <= ${SESSION_POLL_MAX_OPTIONS} labels`);
142
+ }
143
+ if (!Array.isArray(row.options) || row.options.length < 2 || row.options.length > SESSION_POLL_MAX_OPTIONS) {
144
+ fail(`${path}.options`, `expected 2..${SESSION_POLL_MAX_OPTIONS} options`);
145
+ }
146
+ const recommendation = row.recommendation === null
147
+ ? null
148
+ : boundedString(row.recommendation, `${path}.recommendation`, SESSION_POLL_MAX_LABEL_CHARS);
149
+ return {
150
+ id: uuid(row.id, `${path}.id`),
151
+ question: boundedString(row.question, `${path}.question`, SESSION_POLL_MAX_QUESTION_CHARS),
152
+ status: row.status,
153
+ revision: integer(row.revision, `${path}.revision`, 1),
154
+ multi_select: boolean(row.multi_select, `${path}.multi_select`),
155
+ allow_write_in: boolean(row.allow_write_in, `${path}.allow_write_in`),
156
+ recommendation,
157
+ human_voter_count: integer(row.human_voter_count, `${path}.human_voter_count`),
158
+ winning_labels: row.winning_labels.map((label, index) =>
159
+ boundedString(label, `${path}.winning_labels[${index}]`, SESSION_POLL_MAX_LABEL_CHARS)),
160
+ options: row.options.map((entry, index) => option(entry, `${path}.options[${index}]`)),
161
+ };
162
+ }
163
+
164
+ function pollTextChars(polls: readonly SessionPollProjection[]): number {
165
+ return polls.reduce((sum, item) => sum +
166
+ item.question.length +
167
+ (item.recommendation?.length ?? 0) +
168
+ item.winning_labels.reduce((n, label) => n + label.length, 0) +
169
+ item.options.reduce((n, option) => n + option.label.length + option.voters.reduce((v, name) => v + name.length, 0), 0), 0);
170
+ }
171
+
172
+ /** Parse a complete strict v1 or v2 inventory or throw without producing a partial projection. */
173
+ export function parseSessionPollsProjection(value: unknown, path = "session_polls"): SessionPollsProjection {
174
+ const row = record(value, path);
175
+ exactKeys(row, ["version", "advisory", "authority_note", "inventory", "polls"], path);
176
+ if (row.version !== SESSION_POLL_PROJECTION_VERSION && row.version !== SESSION_POLL_PROJECTION_VERSION_2) {
177
+ fail(`${path}.version`, "unknown projection version");
178
+ }
179
+ if (row.advisory !== true) fail(`${path}.advisory`, "projection must be advisory");
180
+ if (row.authority_note !== SESSION_POLL_AUTHORITY_NOTE) fail(`${path}.authority_note`, "unknown authority note");
181
+ if (row.version === SESSION_POLL_PROJECTION_VERSION_2) return parseV2(row, path);
182
+ const inventory = record(row.inventory, `${path}.inventory`);
183
+ exactKeys(inventory, ["active_returned", "ended_returned", "truncated_ended"], `${path}.inventory`);
184
+ const maxPolls = SESSION_POLL_MAX_ACTIVE + SESSION_POLL_MAX_ENDED;
185
+ if (!Array.isArray(row.polls) || row.polls.length < 1 || row.polls.length > maxPolls) {
186
+ fail(`${path}.polls`, `expected 1..${maxPolls} polls`);
187
+ }
188
+ const polls = row.polls.map((entry, index) => poll(entry, `${path}.polls[${index}]`));
189
+ const activeReturned = integer(inventory.active_returned, `${path}.inventory.active_returned`, 0, SESSION_POLL_MAX_ACTIVE);
190
+ const endedReturned = integer(inventory.ended_returned, `${path}.inventory.ended_returned`, 0, SESSION_POLL_MAX_ENDED);
191
+ const truncatedEnded = boolean(inventory.truncated_ended, `${path}.inventory.truncated_ended`);
192
+ const active = polls.filter((item) => item.status === "active").length;
193
+ const ended = polls.filter((item) => item.status === "ended").length;
194
+ if (active !== activeReturned || ended !== endedReturned) fail(`${path}.inventory`, "inventory must match compact polls");
195
+ if (pollTextChars(polls) > SESSION_POLL_MAX_TOTAL_TEXT_CHARS) fail(`${path}.polls`, "poll projection text exceeds safe bound");
196
+ return {
197
+ version: SESSION_POLL_PROJECTION_VERSION,
198
+ advisory: true,
199
+ authority_note: SESSION_POLL_AUTHORITY_NOTE,
200
+ inventory: {
201
+ active_returned: activeReturned,
202
+ ended_returned: endedReturned,
203
+ truncated_ended: truncatedEnded,
204
+ },
205
+ polls,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * v2's counts must tell the truth about what was left out: truncated exactly when
211
+ * fewer were returned than exist, and the continuation exactly when anything was,
212
+ * so a partial list can never pass for a complete one.
213
+ */
214
+ function parseV2(row: Row, path: string): SessionPollsProjectionV2 {
215
+ const inventory = record(row.inventory, `${path}.inventory`);
216
+ exactKeys(inventory, [
217
+ "active_total", "active_returned", "truncated_active",
218
+ "ended_total", "ended_returned", "truncated_ended", "continuation",
219
+ ], `${path}.inventory`);
220
+ if (!Array.isArray(row.polls)) fail(`${path}.polls`, "expected an array");
221
+ const polls = row.polls.map((entry, index) => poll(entry, `${path}.polls[${index}]`));
222
+ if (new Set(polls.map((item) => item.id)).size !== polls.length) fail(`${path}.polls`, "duplicate poll id");
223
+ const activeTotal = integer(inventory.active_total, `${path}.inventory.active_total`);
224
+ const activeReturned = integer(inventory.active_returned, `${path}.inventory.active_returned`, 0, activeTotal);
225
+ const endedTotal = integer(inventory.ended_total, `${path}.inventory.ended_total`);
226
+ const endedReturned = integer(inventory.ended_returned, `${path}.inventory.ended_returned`, 0, Math.min(endedTotal, SESSION_POLL_MAX_ENDED));
227
+ const truncatedActive = boolean(inventory.truncated_active, `${path}.inventory.truncated_active`);
228
+ const truncatedEnded = boolean(inventory.truncated_ended, `${path}.inventory.truncated_ended`);
229
+ if (activeTotal + endedTotal === 0) fail(`${path}.inventory`, "an inventory with no polls is omitted, not sent");
230
+ if (truncatedActive !== activeReturned < activeTotal || truncatedEnded !== endedReturned < endedTotal) {
231
+ fail(`${path}.inventory`, "truncation flags must match the totals");
232
+ }
233
+ const continuation = (truncatedActive || truncatedEnded) ? SESSION_POLL_CONTINUATION : null;
234
+ if (inventory.continuation !== continuation) fail(`${path}.inventory.continuation`, "continuation must be present exactly when polls were left out");
235
+ const active = polls.filter((item) => item.status === "active").length;
236
+ const ended = polls.filter((item) => item.status === "ended").length;
237
+ if (active !== activeReturned || ended !== endedReturned) fail(`${path}.inventory`, "inventory must match compact polls");
238
+ if (pollTextChars(polls) > SESSION_POLL_MAX_TOTAL_TEXT_CHARS) fail(`${path}.polls`, "poll projection text exceeds safe bound");
239
+ return {
240
+ version: SESSION_POLL_PROJECTION_VERSION_2,
241
+ advisory: true,
242
+ authority_note: SESSION_POLL_AUTHORITY_NOTE,
243
+ inventory: {
244
+ active_total: activeTotal,
245
+ active_returned: activeReturned,
246
+ truncated_active: truncatedActive,
247
+ ended_total: endedTotal,
248
+ ended_returned: endedReturned,
249
+ truncated_ended: truncatedEnded,
250
+ continuation,
251
+ },
252
+ polls,
253
+ };
254
+ }
255
+
256
+ /** Compact deterministic delivery-tail serialization. Votes are never commands. */
257
+ export function serializeSessionPollsProjection(projection: SessionPollsProjection | null): string {
258
+ if (!projection) return "";
259
+ if (projection.version === SESSION_POLL_PROJECTION_VERSION_2) return serializeV2(projection);
260
+ if (projection.polls.length === 0) return "";
261
+ const lines = ["== SESSION POLLS ==", projection.authority_note];
262
+ for (const item of projection.polls) lines.push(...pollLines(item));
263
+ if (projection.inventory.truncated_ended) {
264
+ lines.push("Ended history is newest-first and bounded; call manage_poll list for the rest.");
265
+ }
266
+ return lines.join("\n");
267
+ }
268
+
269
+ /** The same text the server gives its own model for v2 (item 66bd18f6: one reducer). */
270
+ function serializeV2(projection: SessionPollsProjectionV2): string {
271
+ const inv = projection.inventory;
272
+ const lines = ["== SESSION POLLS ==", projection.authority_note];
273
+ for (const item of projection.polls) lines.push(...pollLines(item));
274
+ if (inv.truncated_active) lines.push(`Showing the ${inv.active_returned} newest of ${inv.active_total} active polls.`);
275
+ if (inv.truncated_ended) lines.push(`Showing the ${inv.ended_returned} most recently ended of ${inv.ended_total} ended polls.`);
276
+ if (inv.continuation) lines.push(inv.continuation);
277
+ return lines.join("\n");
278
+ }
279
+
280
+ function pollLines(item: SessionPollProjection): string[] {
281
+ const optionText = item.options
282
+ .map((option) => `${option.label}: ${option.human_vote_count}${option.voters.length ? ` (${option.voters.join(", ")})` : ""}`)
283
+ .join("; ");
284
+ const rec = item.recommendation ? ` | recommendation=${item.recommendation}` : "";
285
+ const winners = item.status === "ended" && item.winning_labels.length > 0
286
+ ? ` | ${item.winning_labels.length > 1 ? "tied" : "winner"}=${item.winning_labels.join(", ")}`
287
+ : "";
288
+ return [
289
+ `- "${item.question}" | poll_id=${item.id} | revision=${item.revision} | ${item.status}${item.multi_select ? " | multi-select" : ""}${item.allow_write_in ? " | write-in" : ""} | voters=${item.human_voter_count}${rec}${winners}`,
290
+ ` ${optionText}`,
291
+ ];
292
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * DevSpec Pi — package settings (persisted at <pi-agent-dir>/devspec/settings.json).
3
+ *
4
+ * autoBond (default true): interactive sessions bond as DevSpec capacity on
5
+ * launch — the zero-command behavior (VISION.md principle 6). Headless modes
6
+ * never auto-bond regardless; scripts and one-shot runs stay explicit.
7
+ */
8
+
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { devspecDir } from "./config.ts";
12
+
13
+ export interface DevspecSettings {
14
+ autoBond: boolean;
15
+ /** Owner messages abort a running turn (item f5bbc8f8). Default on. */
16
+ preemptOwner: boolean;
17
+ }
18
+
19
+ const DEFAULTS: DevspecSettings = { autoBond: true, preemptOwner: true };
20
+
21
+ function settingsPath(): string {
22
+ return join(devspecDir(), "settings.json");
23
+ }
24
+
25
+ export async function loadSettings(): Promise<DevspecSettings> {
26
+ try {
27
+ const raw = JSON.parse(await readFile(settingsPath(), "utf8")) as Partial<DevspecSettings>;
28
+ return { ...DEFAULTS, ...raw };
29
+ } catch {
30
+ return { ...DEFAULTS };
31
+ }
32
+ }
33
+
34
+ export async function saveSettings(patch: Partial<DevspecSettings>): Promise<DevspecSettings> {
35
+ const next = { ...(await loadSettings()), ...patch };
36
+ await mkdir(devspecDir(), { recursive: true, mode: 0o700 });
37
+ await writeFile(settingsPath(), `${JSON.stringify(next, null, 2)}\n`);
38
+ return next;
39
+ }