Build a human-authored task handoff with decisions, unresolved checks, and verified file references.

<img src="assets/icon.png" width="128" alt="Task Capsules: a compact capsule holding a task card" />
Experimental 0.1.0. Prepare one small, human-edited handoff for a separate task or fresh session: a goal, decisions, relative file references, and unresolved checks. Export portable JSON explicitly, then import and review it in the intended project. No conversation is captured or summarized automatically.
/plugin marketplace add GadDev/claude-mods
/plugin install task-capsules@gaddev-mods
/reload-plugins
/task-capsules
Use the pane to edit the goal, add or edit decisions and unresolved checks, and capture file text fingerprints. File reference cards show the last explicit review status in words. Opening or rendering the pane performs no file reads. Goal and item edits are committed when you submit the field; unfinished typing is not exported.
The command equivalents are:
/task-capsules goal Finish authentication cleanup
/task-capsules decision Keep the existing public API
/task-capsules check Run the authentication tests
/task-capsules file src/auth.ts
/task-capsules preview
goal edits the existing goal while retaining the other fields. To start an unrelated capsule, export the current one first, then use clear. This MVP holds one active capsule per session; separate JSON files organize separate tasks.
/task-capsules export.handoffs/auth-cleanup.capsule.json. The command includes an explanatory message before the JSON; copy only the JSON object. The pane shows a complete JSON code block. The mod does not write this file or autosave./task-capsules import handoffs/auth-cleanup.capsule.json
/task-capsules preview
/task-capsules draft
Import explicitly replaces the current capsule only after validation succeeds. Malformed imports retain previous authored work. Import reads the selected JSON file, not its referenced sources. Every imported reference starts unverified, regardless of what a previous session checked.
draft verifies all file references again and appends the handoff to the current prompt draft. It leaves existing draft text intact and does not submit a message or launch a session. Read the draft before sending it. If the host has no prompt editor, use preview and copy the handoff. Source files can change after verification; regenerate the handoff if needed. Already inserted text is ordinary draft content and is not retracted automatically.
| Status | Meaning |
|---|---|
unverified | No explicit review since import or editing |
matching | Decoded file text matches the recorded SHA-256 fingerprint at review time |
changed | Current decoded text has a different fingerprint |
unavailable | The source is missing, unreadable, binary, oversized, outside the workspace, changing during a read, or the root cannot be resolved |
workspace-changed | The working directory differs from the one this session's capsule was bound to |
Changed/unavailable/workspace-changed references block draft insertion and preserve the capsule's authored data. Review the source, then explicitly capture its current version or remove the reference:
/task-capsules refresh 1
/task-capsules remove file 1
/task-capsules edit decision 1 Preserve the API contract
/task-capsules edit check 1 Run the focused authentication tests
/task-capsules remove decision 1
/task-capsules remove check 1
Import again in the intended project after changing workspaces. Relative paths and matching hashes do not prove repository identity, commit provenance, correctness, or trust. An imported hash is authored data, not a signature. Source hashes compare decoded text, not arbitrary binary file bytes. Verification uses best-effort stat/read/stat checks, not an atomic filesystem snapshot.
Export remains available with stale references so unresolved work can be carried forward. It retains the original hashes, reports how many references need review, and does not persist freshness statuses. No automatic refresh hides source changes. All checklist entries remain unresolved and unverified by the mod. It runs no commands or tests and never marks an authored claim as a verified result.
The version 1 format has exactly these fields:
{
"kind": "gaddev.task-capsule",
"schemaVersion": 1,
"goal": "Finish authentication cleanup",
"decisions": ["Keep the existing public API"],
"files": [],
"checks": ["Run the authentication tests"]
}
File entries have exactly path and sha256 fields. Prefer capturing them with the mod; manually changing a fingerprint can hide a change from the comparison. Unknown fields and versions, invalid hashes, duplicate paths, and malformed data are rejected. JSON import is data parsing only; notes and paths are never executed.
.json and be inside the current working directory.Human-written notes and paths may still contain private information. Review before saving, committing, or sharing; this is not a secret scanner. All in-memory data clears at session end. Nothing is saved unless you copy the export yourself.
Native empty and populated panes are tested on terminal, Desktop, and VS Code, including item editing. This is a Claude Code plugin, not a claude.ai browser integration. It registers no model-facing tool, reads no conversation history, makes no model/network calls, writes no files, and starts no agents or sessions. A shorter handoff may be useful, but token savings remain unmeasured.
hooks/register.ts 283 lines1import type { EngineInterface, Register } from 'claude-code';
2import { Capsules } from '../src/capsule.ts';
3
4const NAME = 'task-capsules';
5const HELP =
6 '/task-capsules goal <text> | decision <text> | check <text> | file <path> | edit <decision|check> <index> <text> | remove <decision|check|file> <index> | refresh <file-index> | import <capsule.json> | preview | export | draft | clear';
7interface State {
8 capsules: Capsules;
9 text: string;
10 format: 'json' | 'markdown';
11 message: string;
12 queue: Promise<void>;
13}
14const files = ($: EngineInterface) => ({
15 cwd: () => $.session.cwd(),
16 stat: (path: string) => $.fs.stat(path, { resolve: true }),
17 read: (path: string) => $.fs.read(path),
18});
19const schedule = <T>(state: State, work: () => Promise<T>): Promise<T> => {
20 const result = state.queue.then(work);
21 state.queue = result.then(
22 () => undefined,
23 () => undefined,
24 );
25 return result;
26};
27const act = ($: EngineInterface, state: State, kind: string, value = '') =>
28 schedule(state, async () => {
29 state.text = '';
30 try {
31 if (['preview', 'export', 'draft', 'clear'].includes(kind) && value.trim())
32 throw new Error(`${kind} takes no arguments.`);
33 if (kind === 'goal') {
34 state.capsules.goal(value);
35 state.message =
36 'Goal updated; existing decisions, references, and unresolved checks retained.';
37 } else if (kind === 'decision' || kind === 'check') {
38 state.capsules.add(kind, value);
39 state.message =
40 kind === 'decision'
41 ? 'Authored decision added.'
42 : 'Unresolved check added. This mod does not run or verify checks.';
43 } else if (kind === 'edit') {
44 const match = /^(decision|check)\s+(\d+)\s+([\s\S]+)$/.exec(value);
45 if (!match) throw new Error('Use edit <decision|check> <index> <text>.');
46 state.capsules.edit(match[1] as 'decision' | 'check', Number(match[2]), match[3] ?? '');
47 state.message = 'Authored item updated.';
48 } else if (kind === 'remove') {
49 const match = /^(decision|check|file)\s+(\d+)$/.exec(value);
50 if (!match) throw new Error('Use remove <decision|check|file> <index>.');
51 state.capsules.remove(match[1] as 'decision' | 'check' | 'file', Number(match[2]));
52 state.message = 'Item removed.';
53 } else if (kind === 'file') {
54 await state.capsules.file(files($), value);
55 state.message =
56 'File text fingerprint captured. No source content is included in the capsule.';
57 } else if (kind === 'refresh') {
58 await state.capsules.refresh(files($), Number(value));
59 state.message =
60 'Current file text fingerprint captured explicitly. Review your decisions against this version.';
61 } else if (kind === 'import') {
62 await state.capsules.importFile(files($), value);
63 state.message =
64 'Capsule imported. Notes are authored data; file references and unresolved checks are unverified.';
65 } else if (kind === 'clear') {
66 state.capsules.clear();
67 state.message = 'Current capsule cleared. Export before clearing if you need to keep it.';
68 } else if (kind === 'draft') {
69 const handoff = await state.capsules.draft(files($));
70 const result = await $.prompt.fill({ text: `\n\n${handoff}\n`, mode: 'append' });
71 if (!result.isFilled)
72 throw new Error(
73 'No prompt editor is available. Use preview and copy the handoff instead.',
74 );
75 state.text = handoff;
76 state.format = 'markdown';
77 state.message =
78 'Appended to the current draft. Review it before sending. No session was opened or message submitted.';
79 } else if (kind === 'preview' || kind === 'export') {
80 const review = await state.capsules.inspect(files($));
81 state.text = kind === 'export' ? review.json : review.handoff;
82 state.format = kind === 'export' ? 'json' : 'markdown';
83 const flagged = review.files.filter((file) => file.status !== 'matching').length;
84 state.message =
85 kind === 'export'
86 ? `Copy this complete JSON to a local .json file; no file was written. ${flagged} reference(s) need review. Freshness is not carried into another session.`
87 : `Reference check completed: ${flagged} need review. All checklist items remain unresolved. Results are point-in-time only.`;
88 } else throw new Error(HELP);
89 return {
90 text: kind === 'export' ? `${state.message}\n\n${state.text}` : state.text || state.message,
91 };
92 } catch (error) {
93 state.message =
94 error instanceof Error
95 ? error.message
96 : 'Capsule action failed. Existing authored content is retained.';
97 return { text: `Task Capsules: ${state.message}` };
98 } finally {
99 $.ui.invalidate('ui.render');
100 }
101 });
102export const register: Register = (on) => {
103 const state: State = {
104 capsules: new Capsules(),
105 text: '',
106 format: 'markdown',
107 message:
108 'Build one small handoff. Export JSON before leaving the session; nothing is saved automatically.',
109 queue: Promise.resolve(),
110 };
111 on('session.start', async ($, e, next) => {
112 await schedule(state, async () => {
113 state.capsules.clear();
114 state.text = '';
115 });
116 await $.command.register({
117 name: NAME,
118 description: 'Prepare and review a small human-authored task handoff',
119 argumentHint:
120 '[goal|decision|check|file|edit|remove|refresh|import|preview|export|draft|clear]',
121 });
122 return next(e);
123 });
124 on('session.end', async (_$, e, next) => {
125 await schedule(state, async () => {
126 state.capsules.clear();
127 state.text = '';
128 state.message = 'Session capsule cleared.';
129 });
130 return next(e);
131 });
132 on('command.run', { command: NAME }, async ($, e) => {
133 if (!e.args.trim()) {
134 await $.ui.open({ id: NAME, title: 'Task Capsules', focus: true, closeOnEscape: true });
135 return {};
136 }
137 const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(e.args.trim());
138 return act($, state, match?.[1] ?? '', match?.[2] ?? '');
139 }).catch(() => ({
140 text: 'Task Capsules could not complete the action. No message was submitted.',
141 }));
142 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
143 if (
144 e.requestId !== NAME ||
145 (e.surface !== 'terminal' && e.surface !== 'desktop' && e.surface !== 'vscode')
146 )
147 return next(e);
148 const { Box, Text, Input, Button, Code } = $.ui.resolve(e);
149 const capsule = state.capsules.current;
150 const run = (kind: string, value = '') => {
151 void act($, state, kind, value);
152 };
153 return Box({
154 flexDirection: 'column',
155 gap: 1,
156 children: [
157 Text({ bold: true, children: ['TASK CAPSULES'] }),
158 Text({
159 children: [
160 'One focused handoff · Authored notes · No automatic capture, saving, or model calls',
161 ],
162 }),
163 Input({
164 key: 'goal',
165 label: 'Task goal',
166 value: capsule?.goal ?? '',
167 placeholder: 'What should the next session accomplish?',
168 submitLabel: 'Save goal',
169 onSubmit: (value) => run('goal', value),
170 }),
171 ...(capsule
172 ? [
173 Text({ bold: true, children: [`DECISIONS · ${capsule.decisions.length}/8`] }),
174 ...capsule.decisions.flatMap((item, index) => [
175 Input({
176 key: `decision-${index + 1}`,
177 label: `Decision ${index + 1}`,
178 value: item,
179 submitLabel: 'Update',
180 onSubmit: (value) => run('edit', `decision ${index + 1} ${value}`),
181 }),
182 Button({
183 key: `remove-decision-${index + 1}`,
184 label: `Remove decision ${index + 1}`,
185 onPress: () => run('remove', `decision ${index + 1}`),
186 }),
187 ]),
188 Input({
189 key: 'add-decision',
190 label: 'Add decision',
191 placeholder: 'A decision worth carrying forward',
192 submitLabel: 'Add',
193 onSubmit: (value) => run('decision', value),
194 }),
195 Text({ bold: true, children: [`UNRESOLVED CHECKS · ${capsule.checks.length}/8`] }),
196 ...capsule.checks.flatMap((item, index) => [
197 Input({
198 key: `check-${index + 1}`,
199 label: `Unresolved check ${index + 1}`,
200 value: item,
201 submitLabel: 'Update',
202 onSubmit: (value) => run('edit', `check ${index + 1} ${value}`),
203 }),
204 Button({
205 key: `remove-check-${index + 1}`,
206 label: `Remove unresolved check ${index + 1}`,
207 onPress: () => run('remove', `check ${index + 1}`),
208 }),
209 ]),
210 Input({
211 key: 'add-check',
212 label: 'Add unresolved check',
213 placeholder: 'What still needs checking?',
214 submitLabel: 'Add',
215 onSubmit: (value) => run('check', value),
216 }),
217 Text({ bold: true, children: [`FILE REFERENCES · ${capsule.files.length}/8`] }),
218 Text({
219 dimColor: true,
220 children: [
221 'Statuses below are from the last explicit review, not continuous monitoring.',
222 ],
223 }),
224 ...capsule.files.flatMap((file, index) => [
225 Text({
226 children: [
227 `${index + 1}. ${file.path} · ${state.capsules.review?.files[index]?.status ?? 'unverified'} · ${file.sha256.slice(0, 12)}`,
228 ],
229 }),
230 Button({
231 key: `refresh-${index + 1}`,
232 label: `Capture current version of file ${index + 1}`,
233 onPress: () => run('refresh', String(index + 1)),
234 }),
235 Button({
236 key: `remove-file-${index + 1}`,
237 label: `Remove file ${index + 1}`,
238 onPress: () => run('remove', `file ${index + 1}`),
239 }),
240 ]),
241 Input({
242 key: 'add-file',
243 label: 'Add relative file reference',
244 placeholder: 'src/auth.ts',
245 submitLabel: 'Capture hash',
246 onSubmit: (value) => run('file', value),
247 }),
248 Button({
249 key: 'preview',
250 label: 'Verify files and preview handoff',
251 onPress: () => run('preview'),
252 }),
253 Button({
254 key: 'export',
255 label: 'Export JSON for saving',
256 onPress: () => run('export'),
257 }),
258 Button({
259 key: 'draft',
260 label: 'Append verified handoff to current draft',
261 onPress: () => run('draft'),
262 }),
263 ]
264 : []),
265 Input({
266 key: 'import',
267 label: 'Import JSON (replaces current capsule)',
268 placeholder: 'handoffs/auth-cleanup.capsule.json',
269 submitLabel: 'Import',
270 onSubmit: (value) => run('import', value),
271 }),
272 Button({
273 key: 'clear',
274 label: 'Clear current capsule (export first to keep it)',
275 onPress: () => run('clear'),
276 }),
277 Text({ children: [state.message] }),
278 ...(state.text ? [Code({ source: state.text, language: state.format, wrap: 'wrap' })] : []),
279 ],
280 });
281 });
282};
283src/capsule.ts 325 lines1export const LIMITS = {
2 goal: 1000,
3 item: 400,
4 items: 8,
5 files: 8,
6 path: 240,
7 jsonBytes: 32_768,
8 sourceBytes: 262_144,
9} as const;
10export interface FileReference {
11 path: string;
12 sha256: string;
13}
14export interface Capsule {
15 kind: 'gaddev.task-capsule';
16 schemaVersion: 1;
17 goal: string;
18 decisions: string[];
19 files: FileReference[];
20 checks: string[];
21}
22export interface Stat {
23 kind: string;
24 size: number;
25 mtimeMs: number;
26 realPath?: string;
27}
28export interface Files {
29 cwd(): Promise<string>;
30 stat(path: string): Promise<Stat>;
31 read(path: string): Promise<string>;
32}
33export interface FileCheck {
34 path: string;
35 status: 'matching' | 'changed' | 'unavailable' | 'workspace-changed';
36}
37export interface Review {
38 files: FileCheck[];
39 json: string;
40 handoff: string;
41}
42
43const bytes = (text: string) => new TextEncoder().encode(text).length;
44function record(value: unknown): value is Record<string, unknown> {
45 return typeof value === 'object' && value !== null && !Array.isArray(value);
46}
47function keys(value: Record<string, unknown>, expected: string[]): void {
48 if (
49 Object.keys(value).length !== expected.length ||
50 expected.some((key) => !Object.hasOwn(value, key))
51 )
52 throw new Error('Unexpected or missing capsule fields.');
53}
54function text(value: unknown, max: number): string {
55 if (typeof value !== 'string' || !value.trim() || value.length > max)
56 throw new Error(`Enter nonempty text of at most ${max} characters.`);
57 /* oxlint-disable no-control-regex -- Reject terminal and binary controls in authored capsule fields. */
58 if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value))
59 throw new Error('Control characters are excluded from capsule fields.');
60 /* oxlint-enable no-control-regex */
61 return value.trim();
62}
63export function validatePath(value: unknown): string {
64 const path = text(value, LIMITS.path);
65 /* oxlint-disable no-control-regex -- Keep paths printable and relative. */
66 if (path.startsWith('/') || /[\\:\x00-\x1f\x7f]/.test(path))
67 throw new Error('Use a relative path with forward slashes.');
68 /* oxlint-enable no-control-regex */
69 const parts = path.split('/');
70 if (
71 parts.some((part) => !part || part === '.' || part === '..' || part.startsWith('.')) ||
72 /(?:^|\/)(?:credentials|secrets?)(?:[./]|$)/i.test(path)
73 )
74 throw new Error('Hidden paths, traversal, and obvious secret filenames are excluded.');
75 return path;
76}
77function list(value: unknown): string[] {
78 if (!Array.isArray(value) || value.length > LIMITS.items)
79 throw new Error('Use at most eight decisions or unresolved checks.');
80 return value.map((item) => text(item, LIMITS.item));
81}
82export function parseCapsule(raw: string): Capsule {
83 if (bytes(raw) > LIMITS.jsonBytes) throw new Error('Capsule JSON exceeds 32 KiB.');
84 let value: unknown;
85 try {
86 value = JSON.parse(raw);
87 } catch {
88 throw new Error('Invalid capsule JSON.');
89 }
90 if (!record(value)) throw new Error('Capsule JSON must be an object.');
91 keys(value, ['kind', 'schemaVersion', 'goal', 'decisions', 'files', 'checks']);
92 if (value.kind !== 'gaddev.task-capsule' || value.schemaVersion !== 1)
93 throw new Error('Unsupported capsule kind or schema version.');
94 if (!Array.isArray(value.files) || value.files.length > LIMITS.files)
95 throw new Error('Use at most eight file references.');
96 const files = value.files.map((item: unknown): FileReference => {
97 if (!record(item)) throw new Error('Invalid file reference.');
98 keys(item, ['path', 'sha256']);
99 const path = validatePath(item.path);
100 if (typeof item.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(item.sha256))
101 throw new Error('Each file needs a lowercase SHA-256 text fingerprint.');
102 return { path, sha256: item.sha256 };
103 });
104 if (new Set(files.map((file) => file.path)).size !== files.length)
105 throw new Error('Duplicate file paths are excluded.');
106 return {
107 kind: 'gaddev.task-capsule',
108 schemaVersion: 1,
109 goal: text(value.goal, LIMITS.goal),
110 decisions: list(value.decisions),
111 files,
112 checks: list(value.checks),
113 };
114}
115export function serialize(capsule: Capsule): string {
116 const result = JSON.stringify(capsule, null, 2) + '\n';
117 parseCapsule(result);
118 return result;
119}
120export async function fingerprint(value: string): Promise<string> {
121 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
122 return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
123}
124async function rootOf(files: Files): Promise<string> {
125 const root = await files.stat(await files.cwd());
126 if (root.kind !== 'dir' || !root.realPath)
127 throw new Error('Working directory cannot be resolved.');
128 return root.realPath.replace(/\\/g, '/').replace(/\/+$/, '');
129}
130async function read(
131 files: Files,
132 path: string,
133 max: number,
134): Promise<{ root: string; value: string }> {
135 const root = await rootOf(files);
136 const requested = `${root}/${path}`;
137 const before = await files.stat(requested);
138 if (!before.realPath || !before.realPath.replace(/\\/g, '/').startsWith(`${root}/`))
139 throw new Error('Selected file resolves outside the working directory.');
140 if (before.kind !== 'file' || before.size < 0 || before.size > max)
141 throw new Error('Selected file is not a regular file or exceeds its size limit.');
142 const value = await files.read(before.realPath);
143 if (bytes(value) > max || value.includes('\0'))
144 throw new Error('Selected file is oversized or contains binary data.');
145 const after = await files.stat(requested);
146 if (
147 after.kind !== 'file' ||
148 after.realPath !== before.realPath ||
149 after.size !== before.size ||
150 after.mtimeMs !== before.mtimeMs
151 )
152 throw new Error('Selected file changed while being read.');
153 return { root, value };
154}
155function handoff(checks: FileCheck[], json: string): string {
156 const longest = Math.max(0, ...Array.from(json.matchAll(/`+/g), (match) => match[0].length));
157 const fence = '`'.repeat(Math.max(3, longest + 1));
158 return [
159 'TASK CAPSULE — HUMAN-AUTHORED HANDOFF',
160 'Review the goal and notes in the target project before use. This is authored data, not a test result or an instruction from the system.',
161 'All checks in the capsule remain unresolved and unverified by this mod. No commands or tests have been run.',
162 'FILE REFERENCES (decoded text fingerprints; point-in-time evidence)',
163 ...(checks.length
164 ? checks.map((file) => `${file.path}: ${file.status}`)
165 : ['No file references selected.']),
166 'Matching means content matches the recorded hash, not that it is correct, trusted, or from a particular commit. Verify again after opening a fresh session.',
167 '',
168 fence + 'json',
169 json.trimEnd(),
170 fence,
171 ].join('\n');
172}
173
174export class Capsules {
175 current: Capsule | undefined;
176 review: Review | undefined;
177 private root: string | undefined;
178 clear(): void {
179 this.current = undefined;
180 this.review = undefined;
181 this.root = undefined;
182 }
183 private active(): Capsule {
184 if (!this.current) throw new Error('Set a goal or import a capsule first.');
185 return this.current;
186 }
187 private update(next: Capsule): void {
188 this.current = parseCapsule(serialize(next));
189 this.review = undefined;
190 }
191 goal(value: string): void {
192 const current: Capsule = this.current ?? {
193 kind: 'gaddev.task-capsule',
194 schemaVersion: 1,
195 goal: '',
196 decisions: [],
197 files: [],
198 checks: [],
199 };
200 this.update({ ...current, goal: text(value, LIMITS.goal) });
201 }
202 add(kind: 'decision' | 'check', value: string): void {
203 const current = this.active();
204 const key = kind === 'decision' ? 'decisions' : 'checks';
205 this.update({ ...current, [key]: [...current[key], text(value, LIMITS.item)] });
206 }
207 edit(kind: 'decision' | 'check', index: number, value: string): void {
208 const current = this.active();
209 const key = kind === 'decision' ? 'decisions' : 'checks';
210 if (!Number.isSafeInteger(index) || index < 1 || index > current[key].length)
211 throw new Error('Use an existing item index starting at 1.');
212 const items = [...current[key]];
213 items[index - 1] = text(value, LIMITS.item);
214 this.update({ ...current, [key]: items });
215 }
216 remove(kind: 'decision' | 'check' | 'file', index: number): void {
217 const current = this.active();
218 const key = kind === 'decision' ? 'decisions' : kind === 'check' ? 'checks' : 'files';
219 if (!Number.isSafeInteger(index) || index < 1 || index > current[key].length)
220 throw new Error('Use an existing item index starting at 1.');
221 const next = {
222 ...current,
223 decisions: [...current.decisions],
224 checks: [...current.checks],
225 files: [...current.files],
226 };
227 next[key].splice(index - 1, 1);
228 this.update(next);
229 }
230 private async assertWorkspace(files: Files): Promise<void> {
231 if (this.root !== undefined && (await rootOf(files)) !== this.root)
232 throw new Error(
233 'Working directory changed. Import the capsule in the intended project to bind it again.',
234 );
235 }
236 async file(files: Files, value: string): Promise<void> {
237 this.review = undefined;
238 const current = this.active();
239 if (current.files.length >= LIMITS.files) throw new Error('Use at most eight file references.');
240 const path = validatePath(value);
241 if (current.files.some((file) => file.path === path))
242 throw new Error('File path is already selected.');
243 await this.assertWorkspace(files);
244 const source = await read(files, path, LIMITS.sourceBytes);
245 if (this.root !== undefined && this.root !== source.root)
246 throw new Error(
247 'Working directory changed. Import the capsule in the intended project to bind it again.',
248 );
249 this.update({
250 ...current,
251 files: [...current.files, { path, sha256: await fingerprint(source.value) }],
252 });
253 this.root = source.root;
254 }
255 async refresh(files: Files, index: number): Promise<void> {
256 this.review = undefined;
257 const current = this.active();
258 if (!Number.isSafeInteger(index) || index < 1 || index > current.files.length)
259 throw new Error('Use an existing file index starting at 1.');
260 const reference = current.files[index - 1];
261 if (!reference) throw new Error('File reference unavailable.');
262 await this.assertWorkspace(files);
263 const source = await read(files, reference.path, LIMITS.sourceBytes);
264 if (this.root !== source.root)
265 throw new Error(
266 'Working directory changed. Import the capsule in the intended project to bind it again.',
267 );
268 const refs = [...current.files];
269 refs[index - 1] = { path: reference.path, sha256: await fingerprint(source.value) };
270 this.update({ ...current, files: refs });
271 }
272 async importFile(files: Files, value: string): Promise<void> {
273 this.review = undefined;
274 const path = validatePath(value);
275 if (!path.endsWith('.json')) throw new Error('Select a JSON capsule file.');
276 const source = await read(files, path, LIMITS.jsonBytes);
277 const capsule = parseCapsule(source.value);
278 this.update(capsule);
279 this.root = source.root;
280 }
281 async inspect(files: Files): Promise<Review> {
282 this.review = undefined;
283 const current = this.active();
284 const json = serialize(current);
285 const statuses: FileCheck[] = [];
286 let root: string | undefined;
287 if (current.files.length) {
288 try {
289 root = await rootOf(files);
290 } catch {
291 /* Every reference stays unavailable below. */
292 }
293 }
294 for (const reference of current.files) {
295 let status: FileCheck['status'] = 'unavailable';
296 if (root !== undefined && root !== this.root) status = 'workspace-changed';
297 else if (root !== undefined) {
298 try {
299 const source = await read(files, reference.path, LIMITS.sourceBytes);
300 status =
301 source.root !== this.root
302 ? 'workspace-changed'
303 : (await fingerprint(source.value)) === reference.sha256
304 ? 'matching'
305 : 'changed';
306 } catch {
307 /* Missing, oversized, binary, outward links, and read races all stay unavailable. */
308 }
309 }
310 statuses.push({ path: reference.path, status });
311 }
312 const result = { json, files: statuses, handoff: handoff(statuses, json) };
313 this.review = result;
314 return result;
315 }
316 async draft(files: Files): Promise<string> {
317 const review = await this.inspect(files);
318 if (review.files.some((file) => file.status !== 'matching'))
319 throw new Error(
320 'File references need review. Refresh or remove changed/unavailable references before draft insertion; re-import after changing workspaces.',
321 );
322 return review.handoff;
323 }
324}
325