toolroll 0.9.36 → 0.9.37

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 (165) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/assignment-adapters.d.ts +12 -48
  3. package/dist/assignment-adapters.js +33 -43
  4. package/dist/build-review.d.ts +1 -1
  5. package/dist/build-review.js +2 -1
  6. package/dist/chat-acceptance.d.ts +1 -1
  7. package/dist/chat-actions.d.ts +18 -12
  8. package/dist/chat-actions.js +15 -65
  9. package/dist/chat-ask.js +5 -5
  10. package/dist/chat-decide.d.ts +14 -1
  11. package/dist/chat-decide.js +12 -6
  12. package/dist/chat-delivery-state.d.ts +12 -70
  13. package/dist/chat-delivery-state.js +17 -135
  14. package/dist/chat-delivery.js +58 -30
  15. package/dist/chat-flow.js +8 -6
  16. package/dist/chat-inbox.js +1 -1
  17. package/dist/coding-context.d.ts +2 -39
  18. package/dist/coding-context.js +2 -1
  19. package/dist/coding-handoff.d.ts +6 -25
  20. package/dist/coding-handoff.js +11 -5
  21. package/dist/coding-types.d.ts +2 -26
  22. package/dist/coding-workspace.d.ts +2 -0
  23. package/dist/coding-workspace.js +50 -14
  24. package/dist/contracts/acceptance-terms.d.ts +14 -0
  25. package/dist/contracts/acceptance-terms.js +16 -0
  26. package/dist/contracts/chat-actions.d.ts +328 -0
  27. package/dist/contracts/chat-actions.js +133 -0
  28. package/dist/contracts/chat-callback-rows.d.ts +59 -0
  29. package/dist/contracts/chat-callback-rows.js +46 -0
  30. package/dist/contracts/chat-content.d.ts +276 -0
  31. package/dist/contracts/chat-content.js +127 -0
  32. package/dist/contracts/chat-tables.d.ts +6 -0
  33. package/dist/contracts/chat-tables.js +136 -0
  34. package/dist/contracts/coding-context.d.ts +97 -0
  35. package/dist/contracts/coding-context.js +60 -0
  36. package/dist/contracts/coding-handoff.d.ts +44 -0
  37. package/dist/contracts/coding-handoff.js +47 -0
  38. package/dist/contracts/coding-workspace.d.ts +165 -0
  39. package/dist/contracts/coding-workspace.js +71 -0
  40. package/dist/contracts/contract.d.ts +7 -2
  41. package/dist/contracts/contract.js +21 -12
  42. package/dist/contracts/decision.d.ts +45 -0
  43. package/dist/contracts/decision.js +45 -0
  44. package/dist/contracts/discord-callback.d.ts +45 -0
  45. package/dist/contracts/discord-callback.js +34 -0
  46. package/dist/contracts/flow-card.d.ts +50 -0
  47. package/dist/contracts/flow-card.js +46 -0
  48. package/dist/contracts/flow-send.d.ts +119 -0
  49. package/dist/contracts/flow-send.js +112 -0
  50. package/dist/contracts/flow.d.ts +5 -0
  51. package/dist/contracts/flow.js +5 -0
  52. package/dist/contracts/gateway-tools.d.ts +374 -0
  53. package/dist/contracts/gateway-tools.js +94 -0
  54. package/dist/contracts/lead-context.d.ts +165 -0
  55. package/dist/contracts/lead-context.js +73 -0
  56. package/dist/contracts/lead-tools.d.ts +1905 -0
  57. package/dist/contracts/lead-tools.js +265 -0
  58. package/dist/contracts/memory-pass.d.ts +158 -0
  59. package/dist/contracts/memory-pass.js +102 -0
  60. package/dist/contracts/project-knowledge.d.ts +126 -0
  61. package/dist/contracts/project-knowledge.js +93 -0
  62. package/dist/contracts/project-memory.d.ts +117 -0
  63. package/dist/contracts/project-memory.js +51 -0
  64. package/dist/contracts/project-skills.d.ts +121 -0
  65. package/dist/contracts/project-skills.js +76 -0
  66. package/dist/contracts/review-findings.d.ts +46 -0
  67. package/dist/contracts/review-findings.js +48 -0
  68. package/dist/contracts/route.d.ts +484 -0
  69. package/dist/contracts/route.js +128 -0
  70. package/dist/contracts/scope.d.ts +105 -0
  71. package/dist/contracts/scope.js +114 -0
  72. package/dist/contracts/scout-report.d.ts +234 -0
  73. package/dist/contracts/scout-report.js +141 -0
  74. package/dist/contracts/slack-callback.d.ts +43 -0
  75. package/dist/contracts/slack-callback.js +35 -0
  76. package/dist/contracts/sort-answer.d.ts +83 -0
  77. package/dist/contracts/sort-answer.js +77 -0
  78. package/dist/contracts/stage-output.d.ts +127 -0
  79. package/dist/contracts/stage-output.js +166 -0
  80. package/dist/contracts/task-sizing.d.ts +24 -0
  81. package/dist/contracts/task-sizing.js +28 -0
  82. package/dist/contracts/teammate-turn.d.ts +43 -0
  83. package/dist/contracts/teammate-turn.js +51 -0
  84. package/dist/contracts/teams-callback.d.ts +14 -0
  85. package/dist/contracts/teams-callback.js +14 -0
  86. package/dist/contracts/telegram-callback.d.ts +120 -0
  87. package/dist/contracts/telegram-callback.js +97 -0
  88. package/dist/decision.d.ts +11 -20
  89. package/dist/decision.js +86 -167
  90. package/dist/desktop-update.d.ts +2 -2
  91. package/dist/discord-chat.js +31 -20
  92. package/dist/flow-engine.js +14 -17
  93. package/dist/flow-items.d.ts +4 -13
  94. package/dist/flow-items.js +1 -15
  95. package/dist/flow-send.d.ts +6 -37
  96. package/dist/flow-send.js +18 -14
  97. package/dist/flow-share.js +7 -1
  98. package/dist/flow-sort.d.ts +7 -26
  99. package/dist/flow-sort.js +25 -20
  100. package/dist/flow-steps.js +3 -2
  101. package/dist/flows.d.ts +2 -4
  102. package/dist/flows.js +9 -5
  103. package/dist/invoke.js +4 -4
  104. package/dist/lead-context.d.ts +4 -2
  105. package/dist/lead-context.js +24 -17
  106. package/dist/mate-tools.d.ts +4 -0
  107. package/dist/mate-tools.js +108 -275
  108. package/dist/mcp-connect.d.ts +24 -4
  109. package/dist/mcp-connect.js +78 -15
  110. package/dist/mcp.js +84 -216
  111. package/dist/memory-pass.d.ts +17 -42
  112. package/dist/memory-pass.js +54 -26
  113. package/dist/phase-routing.d.ts +19 -72
  114. package/dist/phase-routing.js +34 -164
  115. package/dist/project-knowledge.d.ts +16 -35
  116. package/dist/project-knowledge.js +42 -21
  117. package/dist/project-memory.d.ts +2 -28
  118. package/dist/project-memory.js +28 -13
  119. package/dist/project-skills.d.ts +16 -38
  120. package/dist/project-skills.js +41 -32
  121. package/dist/project-tools.d.ts +1 -0
  122. package/dist/project-tools.js +4 -4
  123. package/dist/provider.d.ts +2 -0
  124. package/dist/provider.js +4 -16
  125. package/dist/result-shots.d.ts +1 -1
  126. package/dist/result-shots.js +2 -1
  127. package/dist/review-switch.d.ts +3 -8
  128. package/dist/reviewer.d.ts +8 -12
  129. package/dist/reviewer.js +11 -35
  130. package/dist/scope.d.ts +21 -43
  131. package/dist/scope.js +42 -128
  132. package/dist/scout-report.d.ts +9 -194
  133. package/dist/scout-report.js +113 -264
  134. package/dist/scout.js +15 -11
  135. package/dist/serve.js +9 -2
  136. package/dist/setup-guide.d.ts +2 -2
  137. package/dist/slack-chat.js +26 -20
  138. package/dist/slack-state.js +2 -1
  139. package/dist/store.d.ts +9 -32
  140. package/dist/store.js +18 -15
  141. package/dist/structured-output.js +2 -1
  142. package/dist/subscription-chat.d.ts +4 -0
  143. package/dist/subscription-chat.js +4 -1
  144. package/dist/task-review-brief.d.ts +1 -7
  145. package/dist/task-review-brief.js +6 -6
  146. package/dist/task-sizing.d.ts +3 -27
  147. package/dist/task-sizing.js +11 -26
  148. package/dist/task-text.d.ts +0 -5
  149. package/dist/task-text.js +1 -3
  150. package/dist/teammate-question.js +7 -5
  151. package/dist/teammate-work.js +9 -5
  152. package/dist/teammates.d.ts +13 -64
  153. package/dist/teammates.js +32 -33
  154. package/dist/teams-chat.js +20 -11
  155. package/dist/telegram-flow.js +4 -3
  156. package/dist/telegram-mate.d.ts +4 -14
  157. package/dist/telegram-mate.js +8 -7
  158. package/dist/telegram-team.d.ts +2 -19
  159. package/dist/telegram.d.ts +1 -1
  160. package/dist/telegram.js +52 -42
  161. package/dist/text-limits.d.ts +142 -1
  162. package/dist/text-limits.js +144 -1
  163. package/dist/tools-ui.d.ts +2 -1
  164. package/dist/tools-ui.js +7 -4
  165. package/package.json +1 -1
@@ -5,6 +5,9 @@ import { currentActor } from './actor.js';
5
5
  import { decisionLines, getDecision, listDecisions } from './project-memory.js';
6
6
  import { scanForSecrets } from './evidence.js';
7
7
  import { repositoryContextRead } from './repository-context.js';
8
+ import { TEXT_LIMITS } from './text-limits.js';
9
+ import { contractError } from './contracts/contract.js';
10
+ import { KNOWLEDGE_COUNTS, KNOWLEDGE_SELECTION_VERSION, KNOWLEDGE_VERSION, knowledgeSchema, knowledgeSelectionSchema, readKnowledge, readKnowledgeSelection } from './contracts/project-knowledge.js';
8
11
  export const KNOWLEDGE_SCHEMA = `
9
12
  CREATE TABLE IF NOT EXISTS project_knowledge (
10
13
  repo TEXT PRIMARY KEY, identity TEXT NOT NULL, revision INTEGER NOT NULL, payload TEXT NOT NULL, sha TEXT NOT NULL
@@ -49,10 +52,21 @@ export const STALE_KNOWLEDGE = "This project's saved knowledge is from before a
49
52
  const STALE_OMITTED = { title: 'Saved project knowledge', reason: 'Saved knowledge is from before a change; it was not applied' };
50
53
  /** Revisions under any identity this project accepts, as a bound JSON list. */
51
54
  const IN_ACCEPTED = 'identity IN (SELECT value FROM json_each(?))';
55
+ /** The stored bytes are checked against their digest first; only then are they parsed, and upgraded in memory. */
52
56
  function decode(payload, sha) {
53
57
  if (learningSha(payload) !== sha)
54
58
  throw Error('Project knowledge could not be verified.');
55
- return JSON.parse(payload);
59
+ const read = readKnowledge(JSON.parse(payload));
60
+ if (!read.ok)
61
+ throw Error('Project knowledge could not be verified.');
62
+ return read.value;
63
+ }
64
+ /** Knowledge (or a selection) as it is written: through its schema, so a refusal names the field. */
65
+ function encode(schema, value, what) {
66
+ const read = schema.safeParse(value, { reportInput: true });
67
+ if (!read.success)
68
+ throw Error(`${what} could not be saved: ${contractError(read.error).join('; ')}`);
69
+ return JSON.stringify(read.data);
56
70
  }
57
71
  /** Saved knowledge under another identity is stale: it is returned for review, never applied. */
58
72
  function current(store, repo, accepted) {
@@ -73,9 +87,9 @@ function document(repo, revision, path) {
73
87
  if (!match)
74
88
  throw Error('This reference must be a committed text file, not a folder or link.');
75
89
  const sha = match[1];
76
- if (Number(git(repo, ['cat-file', '-s', sha])) > 12000)
90
+ if (Number(git(repo, ['cat-file', '-s', sha])) > TEXT_LIMITS.knowledgeReferenceBytes)
77
91
  throw Error('Choose a shorter reference (up to 12 KB).');
78
- return { content: clean(git(repo, ['cat-file', 'blob', sha]), 12000), sha };
92
+ return { content: clean(git(repo, ['cat-file', 'blob', sha]), TEXT_LIMITS.knowledgeReferenceBytes), sha };
79
93
  }
80
94
  export function knowledgeView(store, repo, actor) {
81
95
  const { identity, accepted } = admission(store, repo, actor), { stale, ...value } = current(store, repo, accepted);
@@ -128,7 +142,7 @@ export function changeKnowledge(store, args, now = new Date()) {
128
142
  throw Error('Knowledge changed in another window. Review your draft below before saving again.');
129
143
  let knowledge = existing.knowledge;
130
144
  if (args.action === 'instructions')
131
- knowledge.instructions = clean(args.draft.instructions ?? '', 4000);
145
+ knowledge.instructions = clean(args.draft.instructions ?? '', TEXT_LIMITS.knowledgeInstructionsBytes);
132
146
  else if (args.action === 'restore') {
133
147
  const row = store.handle.prepare(`SELECT payload,sha FROM knowledge_change WHERE repo=? AND ${IN_ACCEPTED} AND revision=?`).get(args.repo, JSON.stringify([...accepted]), args.restore ?? -1);
134
148
  if (!row)
@@ -141,26 +155,26 @@ export function changeKnowledge(store, args, now = new Date()) {
141
155
  knowledge.references = knowledge.references.filter(r => r.id !== args.draft.id);
142
156
  }
143
157
  else if (args.action === 'save') {
144
- const title = clean(args.draft.title ?? '', 120);
158
+ const title = clean(args.draft.title ?? '', TEXT_LIMITS.knowledgeTitleBytes);
145
159
  if (!title)
146
160
  throw Error('Give this reference a short name.');
147
161
  const id = args.draft.id || learningSha(`${identity}:${existing.revision + 1}:${title}`).slice(0, 20);
148
162
  if (args.draft.id && !knowledge.references.some(r => r.id === id))
149
163
  throw Error('That reference is no longer available.');
150
- const path = clean(args.draft.path ?? '', 300) || null;
164
+ const path = clean(args.draft.path ?? '', TEXT_LIMITS.knowledgePathBytes) || null;
151
165
  const sourceRevision = path ? git(args.repo, ['rev-parse', 'HEAD']) : null;
152
166
  const source = path ? document(args.repo, sourceRevision, path) : null;
153
- const content = source?.content ?? clean(args.draft.content ?? '', 12000);
167
+ const content = source?.content ?? clean(args.draft.content ?? '', TEXT_LIMITS.knowledgeReferenceBytes);
154
168
  if (!content)
155
169
  throw Error('Add reference text or select a committed project document.');
156
170
  const ref = { id, title, content, path, sourceSha: source?.sha ?? null, sourceRevision };
157
171
  knowledge.references = [...knowledge.references.filter(r => r.id !== id), ref];
158
- if (knowledge.references.length > 12)
172
+ if (knowledge.references.length > KNOWLEDGE_COUNTS.references)
159
173
  throw Error('Keep up to 12 focused references. Remove one before adding another.');
160
174
  }
161
175
  else
162
176
  throw Error('Choose a supported knowledge action.');
163
- const payload = JSON.stringify(knowledge), sha = learningSha(payload), revision = existing.revision + 1;
177
+ const payload = encode(knowledgeSchema, { version: KNOWLEDGE_VERSION, ...knowledge }, 'Project knowledge'), sha = learningSha(payload), revision = existing.revision + 1;
164
178
  store.handle.prepare('INSERT INTO knowledge_change(repo,identity,revision,actor,at,payload,sha) VALUES (?,?,?,?,?,?,?)').run(args.repo, identity, revision, args.actor, now.toISOString(), payload, sha);
165
179
  store.handle.prepare('INSERT INTO project_knowledge VALUES (?,?,?,?,?) ON CONFLICT(repo) DO UPDATE SET identity=excluded.identity,revision=excluded.revision,payload=excluded.payload,sha=excluded.sha').run(args.repo, identity, revision, payload, sha);
166
180
  });
@@ -191,7 +205,7 @@ function select(store, repo, accepted, query, head) {
191
205
  const ranked = knowledge.references.map(ref => ({ ref, score: [...tokens(`${ref.title} ${ref.path ?? ''}`)].reduce((n, t) => n + (q.has(t) ? 3 : 0), 0) + [...tokens(ref.content)].reduce((n, t) => n + (q.has(t) ? 1 : 0), 0) })).sort((a, b) => b.score - a.score || a.ref.id.localeCompare(b.ref.id));
192
206
  const references = [];
193
207
  for (const { ref, score } of ranked) {
194
- let reason = score === 0 ? 'Not relevant to this task' : references.length >= 3 ? 'More relevant references selected' : '';
208
+ let reason = score === 0 ? 'Not relevant to this task' : references.length >= KNOWLEDGE_COUNTS.selected ? 'More relevant references selected' : '';
195
209
  if (ref.path) {
196
210
  try {
197
211
  if (document(repo, head, ref.path).sha !== ref.sourceSha)
@@ -201,7 +215,7 @@ function select(store, repo, accepted, query, head) {
201
215
  reason = 'Source unavailable; refresh this reference';
202
216
  }
203
217
  }
204
- if (!reason && Buffer.byteLength(JSON.stringify({ instructions: knowledge.instructions, references: [...references, ref] })) > 24000)
218
+ if (!reason && Buffer.byteLength(JSON.stringify({ instructions: knowledge.instructions, references: [...references, ref] })) > TEXT_LIMITS.knowledgeContextBytes)
205
219
  reason = 'Context size limit';
206
220
  if (reason)
207
221
  omitted.push({ title: ref.title, reason });
@@ -210,8 +224,8 @@ function select(store, repo, accepted, query, head) {
210
224
  }
211
225
  // Settled decisions ride the same frozen record, one line each; the why
212
226
  // loads on demand by id, so nothing is repeated between brief and store.
213
- const decisions = decisionLines(store, repo, query, { limit: 8, bytes: Math.max(0, Math.min(1500, 24000 - Buffer.byteLength(JSON.stringify({ instructions: knowledge.instructions, references })) - 200)) });
214
- return { version: 1, revision, instructions: knowledge.instructions, references, omitted, inheritedFrom: null, ...(decisions.length ? { decisions } : {}) };
227
+ const decisions = decisionLines(store, repo, query, { limit: KNOWLEDGE_COUNTS.decisions, bytes: Math.max(0, Math.min(TEXT_LIMITS.knowledgeDecisionsBytes, TEXT_LIMITS.knowledgeContextBytes - Buffer.byteLength(JSON.stringify({ instructions: knowledge.instructions, references })) - 200)) });
228
+ return { version: KNOWLEDGE_SELECTION_VERSION, revision, instructions: knowledge.instructions, references, omitted, inheritedFrom: null, ...(decisions.length ? { decisions } : {}) };
215
229
  }
216
230
  /** Read the same bounded, source-checked selection without inventing a worker run. */
217
231
  export function selectProjectKnowledge(store, args) {
@@ -227,10 +241,17 @@ export function readKnowledgeSnapshot(store, runId) {
227
241
  if (!row)
228
242
  return null;
229
243
  const run = store.getRun(runId), repo = run && store.refForId(run.taskRef)?.repo;
230
- const selection = JSON.parse(String(row['payload']));
244
+ const unverified = () => Error('The context saved for this run could not be verified.');
245
+ // The stored bytes are checked against their digest before they are parsed.
246
+ if (!repo || row['repo'] !== repo || learningSha(String(row['payload'])) !== row['sha'])
247
+ throw unverified();
248
+ const read = readKnowledgeSelection(JSON.parse(String(row['payload'])));
249
+ if (!read.ok)
250
+ throw unverified();
251
+ const selection = read.value;
231
252
  const empty = row['identity'] === 'unconfigured' && unconfigured(selection);
232
- if (!repo || row['repo'] !== repo || (!empty && !acceptedIdentities(store, repo).has(String(row['identity']))) || learningSha(String(row['payload'])) !== row['sha'])
233
- throw Error('The context saved for this run could not be verified.');
253
+ if (!empty && !acceptedIdentities(store, repo).has(String(row['identity'])))
254
+ throw unverified();
234
255
  return selection;
235
256
  }
236
257
  /** Freeze at provider admission. Reviewers see the builder's exact project context. */
@@ -259,18 +280,18 @@ export function knowledgeContext(store, runId, cacheRoot, validatedBuilderBase)
259
280
  const configured = !!store.handle.prepare('SELECT 1 FROM project_knowledge WHERE repo=?').get(repo);
260
281
  // A legacy source without a snapshot did not receive this feature's
261
282
  // context. Never give its reviewer newly configured project guidance.
262
- selection = inherited ? { ...inherited, inheritedFrom: parent.id } : !configured || parent ? { version: 1, revision: 0, instructions: '', references: [], omitted: [], inheritedFrom: parent?.id ?? null } : select(store, repo, acceptedIdentities(store, repo), `${store.getTask(ref.externalId)?.title ?? ''} ${scope?.goal ?? ''} ${(scope?.touches ?? []).join(' ')}`, contextBase || git(repo, ['rev-parse', 'HEAD']));
283
+ selection = inherited ? { ...inherited, inheritedFrom: parent.id } : !configured || parent ? { version: KNOWLEDGE_SELECTION_VERSION, revision: 0, instructions: '', references: [], omitted: [], inheritedFrom: parent?.id ?? null } : select(store, repo, acceptedIdentities(store, repo), `${store.getTask(ref.externalId)?.title ?? ''} ${scope?.goal ?? ''} ${(scope?.touches ?? []).join(' ')}`, contextBase || git(repo, ['rev-parse', 'HEAD']));
263
284
  // Optional source selection is captured once from this crew's actual
264
285
  // checkout. It is reused with the immutable run snapshot on resume.
265
286
  // Index/source failure stays context metadata, never an admission gate.
266
287
  if (!parent && run.worktree && contextBase) {
267
- const available = 24000 - Buffer.byteLength(JSON.stringify(selection)) - 30;
268
- if (available >= 3000)
269
- selection.repository = repositoryContextRead({ repo: run.worktree, project: repo, baseRevision: contextBase, audience: 'crew', maxBytes: Math.min(6000, available), ...(cacheRoot === undefined ? {} : { cacheRoot }), query: `${store.getTask(ref.externalId)?.title ?? ''} ${scope?.goal ?? ''} ${(scope?.touches ?? []).join(' ')}` });
288
+ const available = TEXT_LIMITS.knowledgeContextBytes - Buffer.byteLength(JSON.stringify(selection)) - 30;
289
+ if (available >= TEXT_LIMITS.knowledgeRepositoryMinBytes)
290
+ selection.repository = repositoryContextRead({ repo: run.worktree, project: repo, baseRevision: contextBase, audience: 'crew', maxBytes: Math.min(TEXT_LIMITS.knowledgeRepositoryBytes, available), ...(cacheRoot === undefined ? {} : { cacheRoot }), query: `${store.getTask(ref.externalId)?.title ?? ''} ${scope?.goal ?? ''} ${(scope?.touches ?? []).join(' ')}` });
270
291
  else
271
292
  selection.omitted.push({ title: 'Repository context', reason: 'Context size limit' });
272
293
  }
273
- const payload = JSON.stringify(selection);
294
+ const payload = encode(knowledgeSelectionSchema, selection, 'Project context');
274
295
  store.handle.prepare('INSERT INTO knowledge_snapshot VALUES (?,?,?,?,?)').run(runId, repo, unconfigured(selection) ? 'unconfigured' : learningIdentity(repo), payload, learningSha(payload));
275
296
  }
276
297
  // JSON escapes ordinary newlines, but not Unicode line separators. Keep
@@ -1,28 +1,7 @@
1
1
  import type { Store } from './store.js';
2
+ import { type Decision, type DecisionDraft, type DecisionLine } from './contracts/project-memory.js';
3
+ export type { Decision, DecisionDraft, DecisionLine };
2
4
  export declare const MEMORY_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_decision (\n id INTEGER PRIMARY KEY, repo TEXT NOT NULL, identity TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,\n claim TEXT NOT NULL, why TEXT NOT NULL,\n status TEXT NOT NULL CHECK(status IN ('active','superseded','retired')),\n supersedes INTEGER REFERENCES project_decision(id),\n decided_by TEXT NOT NULL, decided_at TEXT NOT NULL,\n source_kind TEXT NOT NULL CHECK(source_kind IN ('conversation','task','result','manual','backward-pass')),\n source_ref TEXT, recorded_by TEXT NOT NULL, sha TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS project_decision_repo ON project_decision(repo, status, id);\nCREATE TABLE IF NOT EXISTS decision_change (\n id INTEGER PRIMARY KEY, decision INTEGER NOT NULL REFERENCES project_decision(id), revision INTEGER NOT NULL,\n actor TEXT NOT NULL, at TEXT NOT NULL, action TEXT NOT NULL, payload TEXT NOT NULL, sha TEXT NOT NULL,\n UNIQUE(decision, revision)\n);\nCREATE TRIGGER IF NOT EXISTS decision_change_no_update BEFORE UPDATE ON decision_change BEGIN SELECT RAISE(ABORT,'Decision history is immutable'); END;\nCREATE TRIGGER IF NOT EXISTS decision_change_no_delete BEFORE DELETE ON decision_change BEGIN SELECT RAISE(ABORT,'Decision history is immutable'); END;\nCREATE TRIGGER IF NOT EXISTS project_decision_no_delete BEFORE DELETE ON project_decision BEGIN SELECT RAISE(ABORT,'Decisions are retired, never deleted'); END;\nCREATE VIRTUAL TABLE IF NOT EXISTS memory_search USING fts5(kind UNINDEXED, ref UNINDEXED, repo UNINDEXED, scope UNINDEXED, title, body, tokenize='unicode61');\n-- The backward pass (memory-pass.ts): sessions analysed once per surface\n-- version, gaps that accumulate corroboration across runs, and the\n-- proposals a person accepts or rejects. Rejections are remembered.\nCREATE TABLE IF NOT EXISTS memory_session (\n id TEXT PRIMARY KEY, repo TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('claude','codex','lead','crew')),\n source TEXT NOT NULL, seen_at TEXT NOT NULL, surface TEXT NOT NULL, trace_sha TEXT NOT NULL,\n analyzed_at TEXT, verdict TEXT, problem TEXT\n);\nCREATE INDEX IF NOT EXISTS memory_session_repo ON memory_session(repo, seen_at);\nCREATE TABLE IF NOT EXISTS memory_gap (\n id INTEGER PRIMARY KEY, repo TEXT NOT NULL, key TEXT NOT NULL, mistake TEXT NOT NULL, proposed TEXT NOT NULL,\n domain TEXT NOT NULL CHECK(domain IN ('project','orchestration')), first_seen TEXT NOT NULL, last_seen TEXT NOT NULL,\n retired_at TEXT, retired_reason TEXT, UNIQUE(repo, key)\n);\nCREATE TABLE IF NOT EXISTS memory_sighting (\n gap INTEGER NOT NULL REFERENCES memory_gap(id), session TEXT NOT NULL REFERENCES memory_session(id),\n at TEXT NOT NULL, quote TEXT NOT NULL, PRIMARY KEY(gap, session)\n);\nCREATE TABLE IF NOT EXISTS memory_proposal (\n id INTEGER PRIMARY KEY, repo TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('instruction-add','instruction-remove','decision-add')),\n fingerprint TEXT NOT NULL, title TEXT NOT NULL, rationale TEXT NOT NULL, before_text TEXT, after_text TEXT NOT NULL,\n evidence TEXT NOT NULL, sessions INTEGER NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','accepted','rejected','stale')),\n created_at TEXT NOT NULL, decided_by TEXT, decided_at TEXT, surface TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS memory_proposal_pending ON memory_proposal(repo, status, fingerprint);\nCREATE TABLE IF NOT EXISTS memory_rejection (\n repo TEXT NOT NULL, fingerprint TEXT NOT NULL, rejected_at TEXT NOT NULL, rejected_by TEXT NOT NULL, sessions INTEGER NOT NULL, PRIMARY KEY(repo, fingerprint)\n);\n";
3
- export type Decision = {
4
- id: number;
5
- repo: string;
6
- revision: number;
7
- claim: string;
8
- why: string;
9
- status: 'active' | 'superseded' | 'retired';
10
- supersedes: number | null;
11
- decidedBy: string;
12
- decidedAt: string;
13
- sourceKind: 'conversation' | 'task' | 'result' | 'manual' | 'backward-pass';
14
- sourceRef: string | null;
15
- recordedBy: string;
16
- };
17
- export type DecisionDraft = {
18
- claim: string;
19
- why: string;
20
- decidedBy?: string;
21
- decidedAt?: string;
22
- sourceKind?: Decision['sourceKind'];
23
- sourceRef?: string | null;
24
- supersedes?: number | null;
25
- };
26
5
  export declare function listDecisions(store: Store, repo: string, actor: string, options?: {
27
6
  status?: Decision['status'] | 'all';
28
7
  limit?: number;
@@ -47,11 +26,6 @@ export declare function decisionHistory(store: Store, repo: string, actor: strin
47
26
  at: string;
48
27
  action: string;
49
28
  }[];
50
- export type DecisionLine = {
51
- id: number;
52
- claim: string;
53
- decidedAt: string;
54
- };
55
29
  /**
56
30
  * The decisions worth one line each in a brief: relevant to the query first,
57
31
  * then newest, within a byte budget. Bodies (the why) load on demand by id,
@@ -10,6 +10,9 @@
10
10
  import { createHash } from 'node:crypto';
11
11
  import { scanForSecrets } from './evidence.js';
12
12
  import { learningIdentity } from './project-learning.js';
13
+ import { TEXT_LIMITS } from './text-limits.js';
14
+ import { contractError } from './contracts/contract.js';
15
+ import { DECISION_CHANGE_VERSION, decisionChangeSchema, decisionRecordSchema, decisionSchema } from './contracts/project-memory.js';
13
16
  export const MEMORY_SCHEMA = `
14
17
  CREATE TABLE IF NOT EXISTS project_decision (
15
18
  id INTEGER PRIMARY KEY, repo TEXT NOT NULL, identity TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
@@ -75,18 +78,29 @@ function admission(store, repo, actor, write = false) {
75
78
  throw Error('Project memory is outside your access.');
76
79
  return learningIdentity(repo);
77
80
  }
81
+ /** A decision row through its schema; null when the row does not read as one (it is then unverifiable). */
78
82
  function decisionOf(row) {
79
- return { id: Number(row['id']), repo: String(row['repo']), revision: Number(row['revision']), claim: String(row['claim']), why: String(row['why']),
83
+ const read = decisionSchema.safeParse({ id: Number(row['id']), repo: String(row['repo']), revision: Number(row['revision']), claim: String(row['claim']), why: String(row['why']),
80
84
  status: row['status'], supersedes: row['supersedes'] === null ? null : Number(row['supersedes']),
81
85
  decidedBy: String(row['decided_by']), decidedAt: String(row['decided_at']), sourceKind: row['source_kind'],
82
- sourceRef: row['source_ref'] === null ? null : String(row['source_ref']), recordedBy: String(row['recorded_by']) };
86
+ sourceRef: row['source_ref'] === null ? null : String(row['source_ref']), recordedBy: String(row['recorded_by']) });
87
+ return read.success ? read.data : null;
83
88
  }
84
89
  const digestOf = (d) => sha(JSON.stringify([d.repo, d.revision, d.claim, d.why, d.status, d.supersedes, d.decidedBy, d.decidedAt, d.sourceKind, d.sourceRef, d.recordedBy]));
90
+ /** A record or history entry through its schema before it is written; a refusal names the field. */
91
+ function checked(schema, value) {
92
+ const read = schema.safeParse(value);
93
+ if (!read.success)
94
+ throw Error(`This decision could not be saved: ${contractError(read.error).join('; ')}`);
95
+ return read.data;
96
+ }
97
+ const history = (payload) => JSON.stringify(checked(decisionChangeSchema, { version: DECISION_CHANGE_VERSION, ...payload }));
85
98
  export function listDecisions(store, repo, actor, options = {}) {
86
99
  admission(store, repo, actor);
87
100
  const status = options.status ?? 'active', limit = Math.min(200, Math.max(1, options.limit ?? 50));
88
101
  return store.handle.prepare(`SELECT * FROM project_decision WHERE repo=? AND (?='all' OR status=?) ORDER BY id DESC LIMIT ?`).all(repo, status, status, limit)
89
- .map(row => decisionOf(row)).filter(d => digestOf(d) === String(store.handle.prepare('SELECT sha FROM project_decision WHERE id=?').get(d.id)['sha']));
102
+ .flatMap(row => { const d = decisionOf(row); return d === null ? [] : [d]; })
103
+ .filter(d => digestOf(d) === String(store.handle.prepare('SELECT sha FROM project_decision WHERE id=?').get(d.id)['sha']));
90
104
  }
91
105
  export function getDecision(store, repo, actor, id) {
92
106
  admission(store, repo, actor);
@@ -94,20 +108,20 @@ export function getDecision(store, repo, actor, id) {
94
108
  if (!row)
95
109
  return null;
96
110
  const d = decisionOf(row);
97
- if (digestOf(d) !== String(row['sha']))
111
+ if (d === null || digestOf(d) !== String(row['sha']))
98
112
  throw Error('This decision could not be verified.');
99
113
  return d;
100
114
  }
101
115
  /** Record one settled choice. A superseding decision retires the older one in the same transaction. */
102
116
  export function recordDecision(store, args, now = new Date()) {
103
117
  const identity = admission(store, args.repo, args.actor, true);
104
- const claim = clean(args.draft.claim, 240, 'The decision'), why = clean(args.draft.why, 2000, 'The reason');
105
- const decidedBy = args.draft.decidedBy === undefined ? args.actor : clean(args.draft.decidedBy, 80, 'Who decided');
118
+ const claim = clean(args.draft.claim, TEXT_LIMITS.decisionClaimBytes, 'The decision'), why = clean(args.draft.why, TEXT_LIMITS.decisionWhyBytes, 'The reason');
119
+ const decidedBy = args.draft.decidedBy === undefined ? args.actor : clean(args.draft.decidedBy, TEXT_LIMITS.decisionByBytes, 'Who decided');
106
120
  const decidedAt = args.draft.decidedAt ?? now.toISOString();
107
121
  if (!/^\d{4}-\d{2}-\d{2}T/.test(decidedAt))
108
122
  throw Error('When it was decided must be an ISO timestamp.');
109
123
  const sourceKind = args.draft.sourceKind ?? 'manual';
110
- const sourceRef = args.draft.sourceRef === undefined || args.draft.sourceRef === null ? null : clean(String(args.draft.sourceRef), 200, 'The source');
124
+ const sourceRef = args.draft.sourceRef === undefined || args.draft.sourceRef === null ? null : clean(String(args.draft.sourceRef), TEXT_LIMITS.decisionSourceBytes, 'The source');
111
125
  return store.transact(() => {
112
126
  let supersedes = null;
113
127
  if (args.draft.supersedes !== undefined && args.draft.supersedes !== null) {
@@ -116,11 +130,11 @@ export function recordDecision(store, args, now = new Date()) {
116
130
  throw Error('The decision being replaced is not active.');
117
131
  supersedes = older.id;
118
132
  }
119
- const record = { repo: args.repo, revision: 1, claim, why, status: 'active', supersedes, decidedBy, decidedAt, sourceKind, sourceRef, recordedBy: args.actor };
133
+ const record = checked(decisionRecordSchema, { repo: args.repo, revision: 1, claim, why, status: 'active', supersedes, decidedBy, decidedAt, sourceKind, sourceRef, recordedBy: args.actor });
120
134
  const inserted = store.handle.prepare(`INSERT INTO project_decision(repo,identity,revision,claim,why,status,supersedes,decided_by,decided_at,source_kind,source_ref,recorded_by,sha)
121
135
  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(args.repo, identity, 1, claim, why, 'active', supersedes, decidedBy, decidedAt, sourceKind, sourceRef, args.actor, digestOf(record));
122
136
  const id = Number(inserted.lastInsertRowid);
123
- store.handle.prepare('INSERT INTO decision_change(decision,revision,actor,at,action,payload,sha) VALUES (?,?,?,?,?,?,?)').run(id, 1, args.actor, now.toISOString(), 'record', JSON.stringify(record), digestOf(record));
137
+ store.handle.prepare('INSERT INTO decision_change(decision,revision,actor,at,action,payload,sha) VALUES (?,?,?,?,?,?,?)').run(id, 1, args.actor, now.toISOString(), 'record', history(record), digestOf(record));
124
138
  if (supersedes !== null)
125
139
  changeStatus(store, args.repo, args.actor, supersedes, 'superseded', `Replaced by decision ${id}.`, now);
126
140
  reindexProjectMemory(store, args.repo);
@@ -133,15 +147,16 @@ function changeStatus(store, repo, actor, id, status, reason, now) {
133
147
  throw Error('That decision is unavailable.');
134
148
  if (existing.status !== 'active')
135
149
  throw Error('That decision is no longer active.');
136
- const next = { ...existing, revision: existing.revision + 1, status };
150
+ const { id: _id, ...was } = existing;
151
+ const next = { ...was, revision: existing.revision + 1, status };
137
152
  store.handle.prepare('UPDATE project_decision SET revision=?,status=?,sha=? WHERE id=?').run(next.revision, status, digestOf(next), id);
138
- store.handle.prepare('INSERT INTO decision_change(decision,revision,actor,at,action,payload,sha) VALUES (?,?,?,?,?,?,?)').run(id, next.revision, actor, now.toISOString(), status, JSON.stringify({ reason }), digestOf(next));
153
+ store.handle.prepare('INSERT INTO decision_change(decision,revision,actor,at,action,payload,sha) VALUES (?,?,?,?,?,?,?)').run(id, next.revision, actor, now.toISOString(), status, history({ reason }), digestOf(next));
139
154
  return { id, ...next };
140
155
  }
141
156
  /** Retire a decision that no longer holds; history keeps it. */
142
157
  export function retireDecision(store, args, now = new Date()) {
143
158
  admission(store, args.repo, args.actor, true);
144
- const reason = clean(args.reason, 500, 'The reason');
159
+ const reason = clean(args.reason, TEXT_LIMITS.decisionRetireBytes, 'The reason');
145
160
  return store.transact(() => { const d = changeStatus(store, args.repo, args.actor, args.id, 'retired', reason, now); reindexProjectMemory(store, args.repo); return d; });
146
161
  }
147
162
  export function decisionHistory(store, repo, actor, id) {
@@ -166,7 +181,7 @@ export function decisionLines(store, repo, query, options = {}) {
166
181
  return { line: { id: Number(row['id']), claim, decidedAt: String(row['decided_at']) }, score };
167
182
  })
168
183
  .sort((a, b) => b.score - a.score || b.line.id - a.line.id);
169
- const limit = options.limit ?? 8, bytes = options.bytes ?? 1500, out = [];
184
+ const limit = options.limit ?? 8, bytes = options.bytes ?? TEXT_LIMITS.knowledgeDecisionsBytes, out = [];
170
185
  for (const { line } of ranked) {
171
186
  if (out.length >= limit)
172
187
  break;
@@ -1,48 +1,27 @@
1
1
  import type { Store } from "./store.js";
2
+ import { SKILL_LIMITS, type SavedSkill, type SkillFile, type SkillPackage, type SkillSelection, type SkillsSnapshot } from "./contracts/project-skills.js";
3
+ export { SKILL_LIMITS };
4
+ export type { SavedSkill, SkillFile, SkillPackage, SkillsSnapshot };
2
5
  export declare const SKILLS_SCHEMA: string;
3
- export type SkillFile = {
4
- path: string;
5
- base64: string;
6
- };
7
- export type SkillPackage = {
8
- name: string;
9
- description: string;
10
- source: string;
11
- requirements: string;
12
- warnings: string[];
13
- files: SkillFile[];
14
- };
15
- export type SavedSkill = SkillPackage & {
16
- sha: string;
17
- };
18
- type Choice = {
19
- sha: string;
20
- enabled: boolean;
21
- };
22
- type Selection = Record<string, Choice>;
23
- export type SkillsSnapshot = {
24
- version: 1;
25
- revision: number;
26
- identity: string;
27
- inheritedFrom: number | null;
28
- test: boolean;
29
- packages: SavedSkill[];
30
- };
31
- export declare const SKILL_LIMITS: {
32
- files: number;
33
- packageBytes: number;
34
- fileBytes: number;
35
- bodyBytes: number;
36
- enabled: number;
37
- selectionBytes: number;
38
- };
6
+ type Selection = SkillSelection;
39
7
  export declare function validateSkill(files: SkillFile[], source: string): SkillPackage;
40
8
  export declare function skillsView(store: Store, repo: string, actor: string): {
41
9
  revision: number;
42
10
  selection: Selection;
43
11
  repo: string;
44
12
  identity: string;
45
- library: SavedSkill[];
13
+ library: {
14
+ name: string;
15
+ description: string;
16
+ source: string;
17
+ requirements: string;
18
+ warnings: string[];
19
+ files: {
20
+ path: string;
21
+ base64: string;
22
+ }[];
23
+ sha: string;
24
+ }[];
46
25
  history: {
47
26
  revision: number;
48
27
  actor: string;
@@ -159,4 +138,3 @@ export declare function githubSkill(url: string, fetcher?: typeof fetch): Promis
159
138
  files: SkillFile[];
160
139
  source: string;
161
140
  }>;
162
- export {};
@@ -6,6 +6,10 @@ import { parseDocument } from "yaml";
6
6
  import { acceptedIdentities, learningIdentity, learningSha } from "./project-learning.js";
7
7
  import { scanForSecrets } from "./evidence.js";
8
8
  import { fileTaskProposal } from "./proposal.js";
9
+ import { TEXT_LIMITS } from "./text-limits.js";
10
+ import { contractError } from "./contracts/contract.js";
11
+ import { SKILL_COUNTS, SKILL_LIMITS, readSkillsSnapshotPayload, savedSkillPackageSchema, savedSkillsSnapshotSchema, skillPackageSchema, skillSelectionSchema, } from "./contracts/project-skills.js";
12
+ export { SKILL_LIMITS };
9
13
  export const SKILLS_SCHEMA = `
10
14
  CREATE TABLE IF NOT EXISTS skill_package (sha TEXT PRIMARY KEY, payload TEXT NOT NULL);
11
15
  CREATE TABLE IF NOT EXISTS skill_owner (sha TEXT NOT NULL REFERENCES skill_package(sha), actor TEXT NOT NULL, at TEXT NOT NULL, PRIMARY KEY(sha,actor));
@@ -14,14 +18,6 @@ CREATE TABLE IF NOT EXISTS skill_snapshot (run INTEGER PRIMARY KEY REFERENCES ru
14
18
  CREATE TABLE IF NOT EXISTS skill_test (task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id), package TEXT NOT NULL REFERENCES skill_package(sha), actor TEXT NOT NULL, request_sha TEXT NOT NULL, sample TEXT NOT NULL, source_run INTEGER REFERENCES run(id), feedback TEXT, identity TEXT NOT NULL);
15
19
  ${["skill_package", "skill_owner", "project_skill_change", "skill_snapshot", "skill_test"].map((t) => `CREATE TRIGGER IF NOT EXISTS ${t}_no_update BEFORE UPDATE ON ${t} BEGIN SELECT RAISE(ABORT,'Skill history is immutable'); END; CREATE TRIGGER IF NOT EXISTS ${t}_no_delete BEFORE DELETE ON ${t} BEGIN SELECT RAISE(ABORT,'Skill history is immutable'); END;`).join("\n")}
16
20
  `;
17
- export const SKILL_LIMITS = {
18
- files: 64,
19
- packageBytes: 1024 * 1024,
20
- fileBytes: 256 * 1024,
21
- bodyBytes: 24 * 1024,
22
- enabled: 8,
23
- selectionBytes: 2 * 1024 * 1024,
24
- };
25
21
  const controls = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufffd]/u;
26
22
  const text = (value, max, label) => {
27
23
  if (typeof value !== "string" ||
@@ -44,7 +40,7 @@ export function validateSkill(files, source) {
44
40
  .map((file) => {
45
41
  if (!file ||
46
42
  typeof file.path !== "string" ||
47
- file.path.length > 240 ||
43
+ file.path.length > TEXT_LIMITS.skillPath ||
48
44
  !/^[a-zA-Z0-9_. -]+(?:\/[a-zA-Z0-9_. -]+)*$/.test(file.path) ||
49
45
  file.path
50
46
  .split("/")
@@ -91,13 +87,13 @@ export function validateSkill(files, source) {
91
87
  }
92
88
  if (!front || typeof front !== "object" || Array.isArray(front))
93
89
  throw Error("Skill metadata must contain a name and description.");
94
- const name = text(front["name"], 64, "Skill name");
90
+ const name = text(front["name"], TEXT_LIMITS.skillNameBytes, "Skill name");
95
91
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
96
92
  throw Error("Use a lowercase skill name with hyphens between words.");
97
- const description = text(front["description"], 1024, "Description");
93
+ const description = text(front["description"], TEXT_LIMITS.skillDescriptionBytes, "Description");
98
94
  const requirements = front["compatibility"] === undefined
99
95
  ? ""
100
- : text(front["compatibility"], 500, "Requirements");
96
+ : text(front["compatibility"], TEXT_LIMITS.skillRequirementsBytes, "Requirements");
101
97
  const extensions = Object.keys(front).filter((k) => !["name", "description", "license", "compatibility", "metadata"].includes(k));
102
98
  const warnings = [];
103
99
  if (extensions.length || safe.some((f) => f.path === "agents/openai.yaml"))
@@ -106,14 +102,21 @@ export function validateSkill(files, source) {
106
102
  warnings.push("Dynamic shell substitutions are not evaluated by the skill manager.");
107
103
  if (safe.some((f) => f.path.startsWith("scripts/")))
108
104
  warnings.push("Includes scripts. Agents may run them only within their task permissions.");
109
- return {
105
+ return written(skillPackageSchema, {
110
106
  name,
111
107
  description,
112
- source: text(source, 500, "Source"),
108
+ source: text(source, TEXT_LIMITS.skillSourceBytes, "Source"),
113
109
  requirements,
114
110
  warnings,
115
111
  files: safe,
116
- };
112
+ });
113
+ }
114
+ /** A payload through its schema before it is written; a refusal names the field. */
115
+ function written(schema, value) {
116
+ const read = schema.safeParse(value, { reportInput: true });
117
+ if (!read.success)
118
+ throw Error(`This skill could not be saved: ${contractError(read.error).join("; ")}`);
119
+ return read.data;
117
120
  }
118
121
  function admit(store, repo, actor, write = false) {
119
122
  if (!store.schemaCurrent() ||
@@ -125,18 +128,24 @@ function admit(store, repo, actor, write = false) {
125
128
  /** Revisions under any identity this project accepts, as a bound JSON list. */
126
129
  const IN_ACCEPTED = "identity IN (SELECT value FROM json_each(?))";
127
130
  const listOf = (accepted) => JSON.stringify([...accepted]);
128
- function unpack(payload, sha) {
131
+ /** Saved bytes are checked against their digest first; only then are they parsed through their schema. */
132
+ function unpack(payload, sha, read) {
129
133
  if (typeof payload !== "string" || learningSha(payload) !== sha)
130
134
  throw Error("Saved skills could not be verified.");
131
- return JSON.parse(payload);
135
+ const value = read(JSON.parse(payload));
136
+ if (value === null)
137
+ throw Error("Saved skills could not be verified.");
138
+ return value;
132
139
  }
140
+ const through = (schema) => (input) => { const read = schema.safeParse(input); return read.success ? read.data : null; };
141
+ const unpackSelection = (payload, sha) => unpack(payload, sha, through(skillSelectionSchema));
133
142
  function packageOf(store, sha) {
134
143
  const row = store.handle
135
144
  .prepare("SELECT payload FROM skill_package WHERE sha=?")
136
145
  .get(sha);
137
146
  if (!row)
138
147
  throw Error("That skill version is unavailable.");
139
- return { ...unpack(row["payload"], sha), sha };
148
+ return { ...unpack(row["payload"], sha, through(savedSkillPackageSchema)), sha };
140
149
  }
141
150
  function current(store, repo, accepted) {
142
151
  const row = store.handle
@@ -148,7 +157,7 @@ function current(store, repo, accepted) {
148
157
  throw Error("The project changed. Its previous skills have not been applied.");
149
158
  return {
150
159
  revision: Number(row["revision"]),
151
- selection: unpack(row["payload"], row["sha"]),
160
+ selection: unpackSelection(row["payload"], row["sha"]),
152
161
  };
153
162
  }
154
163
  export function skillsView(store, repo, actor) {
@@ -173,7 +182,7 @@ export function skillsView(store, repo, actor) {
173
182
  }
174
183
  }
175
184
  if (row)
176
- for (const c of Object.values(unpack(row["payload"], row["sha"])))
185
+ for (const c of Object.values(unpackSelection(row["payload"], row["sha"])))
177
186
  visible.add(c.sha);
178
187
  }
179
188
  for (const c of Object.values(value.selection))
@@ -192,7 +201,7 @@ export function skillsView(store, repo, actor) {
192
201
  revision: Number(r["revision"]),
193
202
  actor: String(r["actor"]),
194
203
  at: String(r["at"]),
195
- enabled: Object.entries(unpack(r["payload"], r["sha"]))
204
+ enabled: Object.entries(unpackSelection(r["payload"], r["sha"]))
196
205
  .filter(([, c]) => c.enabled)
197
206
  .map(([name, c]) => ({ name, version: c.sha.slice(0, 10) })),
198
207
  }));
@@ -215,7 +224,7 @@ export function skillsVersion(store, repo, actor, revision) {
215
224
  const row = store.handle.prepare(`SELECT payload,sha FROM project_skill_change WHERE repo=? AND ${IN_ACCEPTED} AND revision=?`).get(repo, listOf(accepted), revision);
216
225
  if (!row)
217
226
  throw Error("That skills version is unavailable.");
218
- const selection = unpack(row["payload"], row["sha"]);
227
+ const selection = unpackSelection(row["payload"], row["sha"]);
219
228
  for (const choice of Object.values(selection))
220
229
  packageOf(store, choice.sha);
221
230
  return selection;
@@ -229,7 +238,7 @@ export function importSkill(store, repo, actor, files, source, now = new Date())
229
238
  .get(sha, actor) &&
230
239
  Number(store.handle
231
240
  .prepare("SELECT COUNT(*) AS n FROM skill_owner WHERE actor=?")
232
- .get(actor)?.["n"]) >= 100)
241
+ .get(actor)?.["n"]) >= SKILL_COUNTS.library)
233
242
  throw Error("Your library has 100 saved versions. Reuse an existing version.");
234
243
  store.handle
235
244
  .prepare("INSERT OR IGNORE INTO skill_package VALUES (?,?)")
@@ -255,7 +264,7 @@ export function changeSkills(store, args, now = new Date()) {
255
264
  .get(args.repo, listOf(accepted), args.restore ?? -1);
256
265
  if (!row)
257
266
  throw Error("That version is unavailable.");
258
- selection = unpack(row["payload"], row["sha"]);
267
+ selection = unpackSelection(row["payload"], row["sha"]);
259
268
  }
260
269
  else {
261
270
  const skill = view.library.find((p) => p.sha === args.sha);
@@ -274,7 +283,7 @@ export function changeSkills(store, args, now = new Date()) {
274
283
  if (enabled.length > SKILL_LIMITS.enabled ||
275
284
  Buffer.byteLength(JSON.stringify(enabled)) > SKILL_LIMITS.selectionBytes)
276
285
  throw Error("Enable up to 8 skills, totaling at most 2 MB.");
277
- const payload = JSON.stringify(selection);
286
+ const payload = JSON.stringify(written(skillSelectionSchema, selection));
278
287
  store.handle
279
288
  .prepare("INSERT INTO project_skill_change VALUES (?,?,?,?,?,?,?)")
280
289
  .run(args.repo, identity, view.revision + 1, args.actor, now.toISOString(), payload, learningSha(payload));
@@ -316,7 +325,7 @@ export function readSkillsSnapshot(store, runId) {
316
325
  const run = store.getRun(runId);
317
326
  if (!run || store.refForId(run.taskRef)?.repo !== row["repo"])
318
327
  throw Error("Saved skills belong to another project.");
319
- const saved = unpack(row["payload"], row["sha"]);
328
+ const saved = unpack(row["payload"], row["sha"], (input) => { const read = readSkillsSnapshotPayload(input); return read.ok ? read.value : null; });
320
329
  const { packageShas, ...facts } = saved;
321
330
  if (packageShas.length &&
322
331
  !acceptedIdentities(store, String(row["repo"])).has(facts.identity))
@@ -401,10 +410,10 @@ export function freezeSkills(store, runId) {
401
410
  }
402
411
  }
403
412
  const { packages, ...facts } = snapshot;
404
- const payload = JSON.stringify({
413
+ const payload = JSON.stringify(written(savedSkillsSnapshotSchema, {
405
414
  ...facts,
406
415
  packageShas: packages.map((p) => p.sha),
407
- });
416
+ }));
408
417
  store.handle
409
418
  .prepare("INSERT INTO skill_snapshot VALUES (?,?,?,?)")
410
419
  .run(runId, ref.repo, payload, learningSha(payload));
@@ -474,11 +483,11 @@ export function testSkill(store, args, now = new Date()) {
474
483
  const skill = view.library.find((p) => p.sha === args.sha);
475
484
  if (!skill)
476
485
  throw Error("Choose an available skill.");
477
- const sample = text(args.sample, 4000, "Sample request");
478
- if (sample.length > 800)
486
+ const sample = text(args.sample, TEXT_LIMITS.skillSampleBytes, "Sample request");
487
+ if (sample.length > TEXT_LIMITS.skillSample)
479
488
  throw Error("Keep the sample request to 800 characters.");
480
- const feedback = args.feedback === undefined ? null : text(args.feedback, 2000, "Feedback");
481
- if (feedback && feedback.length > 500)
489
+ const feedback = args.feedback === undefined ? null : text(args.feedback, TEXT_LIMITS.skillFeedbackBytes, "Feedback");
490
+ if (feedback && feedback.length > TEXT_LIMITS.skillFeedback)
482
491
  throw Error("Keep feedback to 500 characters.");
483
492
  if (args.sourceRun !== undefined) {
484
493
  const source = skillTestResult(store, args.sourceRun, args.actor);
@@ -201,6 +201,7 @@ export declare function prepareRunTools(store: Pick<Store, "projectTools" | "get
201
201
  now: Date;
202
202
  includeModel: boolean;
203
203
  readOnly?: Readonly<Record<string, readonly string[]>>;
204
+ withheld?: Readonly<Record<string, string>>;
204
205
  }): ToolLaunchArgs;
205
206
  /** When a launch's tools cannot be prepared, it still gets none of anything else's: the same isolation with an empty list. */
206
207
  export declare function noToolsArgs(provider: "claude" | "codex" | "openrouter" | "gemini", includeModel: boolean): ToolLaunchArgs;
@@ -694,7 +694,7 @@ export function toolLaunchFor(store, runId, home = homedir()) {
694
694
  * with `argv`/`env` and calls `cleanup` when the process is done.
695
695
  */
696
696
  export function prepareRunTools(store, runId, provider, options) {
697
- const launch = limitedTo(toolLaunchFor(store, runId, options.home), options.readOnly);
697
+ const launch = limitedTo(toolLaunchFor(store, runId, options.home), options.readOnly, options.withheld);
698
698
  const enabledTools = options.readOnly === undefined ? {} : { enabledTools: options.readOnly };
699
699
  const prepared = provider === "claude" ? claudeToolArgs(launch)
700
700
  : provider === "codex" ? codexToolArgs(launch, { includeModel: options.includeModel, ...enabledTools })
@@ -710,14 +710,14 @@ export function prepareRunTools(store, runId, provider, options) {
710
710
  }), options.now);
711
711
  return prepared;
712
712
  }
713
- /** A research step's launch: only the servers it may read, each limited to its read-only actions; the rest left out, saying why. */
714
- function limitedTo(launch, readOnly) {
713
+ /** A research step's launch: only the servers it may read, each limited to its read-only actions; the rest left out, saying why (`withheld`, when a service has its own reason). */
714
+ function limitedTo(launch, readOnly, withheld = {}) {
715
715
  if (readOnly === undefined)
716
716
  return launch;
717
717
  const readable = (name) => (readOnly[name] ?? []).length > 0;
718
718
  return {
719
719
  tools: launch.tools.filter(one => readable(one.spec.name)),
720
- skipped: [...launch.skipped, ...launch.tools.filter(one => !readable(one.spec.name)).map(one => ({ name: one.spec.name, reason: "research reads only connected services' read-only actions" }))],
720
+ skipped: [...launch.skipped, ...launch.tools.filter(one => !readable(one.spec.name)).map(one => ({ name: one.spec.name, reason: withheld[one.spec.name] ?? "research reads only connected services' read-only actions" }))],
721
721
  };
722
722
  }
723
723
  /** When a launch's tools cannot be prepared, it still gets none of anything else's: the same isolation with an empty list. */