SLOPSHOPPER

task-capsules

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

newpanecommand
v1.0.0MITupdated 2026-10-23GadDev/claude-mods/mods/task-capsules
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-capsules
│ ┃ Task Capsules ✕ › fix the failing auth test and add an audit log call │ ┃ TASK CAPSULES │ ┃ ⏺ Read(src/auth.ts) │ ┃ One focused handoff · Authored notes · No ⎿ Read 6 lines │ ┃ automatic capture, saving, or model calls ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Task goal: What should the next session acco ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Import JSON (replaces current capsule): hand │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Clear current capsule (export first to kee │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Build one small handoff. Export JSON before │ ┃ leaving the session; nothing is saved › /task-capsules │ ┃ automatically. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Task Capsules
TASK CAPSULES One focused handoff · Authored notes · No automatic capture, saving, or model calls Task goal: What should the next session accomplish? ⏎ Save g Import JSON (replaces current capsule): handoffs/auth-cleanu [ Clear current capsule (export first to keep it) ] Build one small handoff. Export JSON before leaving the session; nothing is saved automatically.
README

Task Capsules

<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.

Install and build a capsule

/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.

Carry it to a fresh session

  1. Choose Verify files and preview handoff. Review authored notes and the reference statuses.
  2. Choose Export JSON for saving, or run /task-capsules export.
  3. Copy the complete JSON body to a local file such as 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.
  4. Start a fresh Claude Code session in the intended project with the mod installed.
  5. Import and review:
/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.

Stale references and unresolved work

StatusMeaning
unverifiedNo explicit review since import or editing
matchingDecoded file text matches the recorded SHA-256 fingerprint at review time
changedCurrent decoded text has a different fingerprint
unavailableThe source is missing, unreadable, binary, oversized, outside the workspace, changing during a read, or the root cannot be resolved
workspace-changedThe 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.

JSON contract and bounds

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.

  • Goal: 1,000 characters; up to eight decisions and eight checks, 400 characters each.
  • Up to eight file references; relative paths at most 240 characters; ordinary spaces are supported.
  • Source text: at most 256 KiB per file. Source content is read only on explicit capture, refresh, preview, export, or draft; it is not stored in the capsule.
  • JSON import/export: at most 32 KiB in UTF-8. The entire JSON is shown; no silent truncation or pagination can create an incomplete export.
  • Hidden paths, traversal, absolute/Windows paths, outward symlinks, binary data, and obvious secret filenames are excluded. JSON capsule files must end in .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.

Source 2 files
hooks/register.ts 283 lines
1import 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};
283
src/capsule.ts 325 lines
1export 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