takibibase 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,9 +9,8 @@ Needs Node.js 22+. This directory is also the npm package source:
9
9
  `takibi.mjs` stays a single zero-dependency file so the
10
10
  `/downloads/takibi.mjs` copy and the published package never drift.
11
11
 
12
- Thin client-side enablement for agents. Its only server pairing is
13
- `GET /v1/projects`, which lists a key's granted collections — everything
14
- else lives in this directory.
12
+ Thin client-side enablement for agents. The CLI calls the project, task,
13
+ document, and Notes API routes; its behavior is documented in `SKILL.md`.
15
14
 
16
15
  - `takibi.mjs` — the CLI. Single-file Node ≥ 22, zero dependencies.
17
16
  Run it from a checkout as `node tools/takibi/takibi.mjs …`, or alias it:
@@ -23,7 +22,7 @@ else lives in this directory.
23
22
  ## Account-owner setup (once per machine)
24
23
 
25
24
  1. Mint a key: in the app, Settings → Profiles → new profile with the
26
- capabilities the crew needs (`search`, `ask`, `tasks`), scoped to its
25
+ capabilities the crew needs (`search`, `ask`, `tasks`, `notes`), scoped to its
27
26
  project(s). Copy the `<publicId>.<secret>` shown once.
28
27
  2. `mkdir -p ~/.takibi && printf '%s\n' '<publicId>.<secret>' > ~/.takibi/key && chmod 600 ~/.takibi/key`
29
28
  3. Optional: one base-URL line in `~/.takibi/config` (default
@@ -39,9 +38,10 @@ else lives in this directory.
39
38
  ## Give an agent
40
39
 
41
40
  Key file in place + the skill text (or installed skill). Acceptance: a
42
- fresh agent asks, searches, and lists tasks against a local boot on the
43
- first try, with no contract pasted in chat. Reads are free; task writes
44
- need your approval in conversation; account-only routes stay yours.
41
+ fresh agent asks, searches, lists tasks, appends/searches notes, and
42
+ uses `notes list --all` to find the current version before curation.
43
+ Reads are free; task writes and notes export/keep/remove need your approval
44
+ in conversation; account-only routes stay yours.
45
45
 
46
46
  ## Known edges
47
47
 
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: takibi-use
3
- description: Use the Takibi knowledge base and task board through the takibi CLI (ask, search, tasks, docs). Use whenever the user asks about Takibi content, evidence-backed answers from their docs, or crew task boards.
3
+ description: Use the Takibi knowledge base and task board through the takibi CLI (ask, search, tasks, docs, notes). Use whenever the user asks about Takibi content, evidence-backed answers from their docs, or crew task boards.
4
4
  ---
5
5
 
6
6
  # takibi-use: Takibi via the CLI
@@ -39,6 +39,23 @@ error hints.
39
39
  and `takibi tasks artifact add <taskId> <url> [--note …]`.
40
40
  - `takibi doc list | get <id> | text <id>` — metadata, then converted text.
41
41
  `doc download` is founder-only; the CLI says so — use `doc text`.
42
+ - `takibi notes append --problem "…" [--tried …] [--worked …] [--failed …] [--next-time …] [--source <id>]`
43
+ — save an end-of-run debrief or tool quirk (`--problem` or a bare
44
+ positional; `--project <tag>` scopes it; `--source` is repeatable).
45
+ Add `--run-id <run-id>` when a run may retry: the same run ID and body
46
+ return the existing note instead of appending a duplicate.
47
+ - `takibi notes list` — review queue with note IDs, statuses, and current
48
+ versions. `takibi notes list --all` shows the inventory, including notes
49
+ on hold. `--project <tag>` filters either list by the exact project tag.
50
+ - `takibi notes search -q "…"` — top-2 agent notes for the query.
51
+ - `takibi notes export [--since <ts> | --note <uuid> …]` — draft digest
52
+ (markdown, per-sentence note ids). Repeat `--note` to pick up to 50
53
+ specific live notes. Without options, export uses the delta since the
54
+ last export. Export stamps included notes and records the action.
55
+ - `takibi notes keep <id> <ver> | remove <id> <ver>` — endorse a note
56
+ (sets kept, reverses stub quarantine, clears contests; never extends the
57
+ TTL), or discard one. Read the current `vN` in `notes list --all` and
58
+ pass `N` as the expected version; on 409, list again before retrying.
42
59
  - `--json` anywhere prints raw server JSON. `--verbose` logs requests
43
60
  (never the key). Exit 0 = ok, 1 = transport/API error, 2 = usage error.
44
61
 
@@ -59,9 +76,36 @@ back to `search`, try `doc text` on the hits — then either answer from
59
76
  evidence or say the evidence is not there. Never fill gaps with generated
60
77
  prose presented as sourced.
61
78
 
79
+ ## Notes (agent scratchpad, not canon)
80
+
81
+ - When to use: end-of-run debriefs (problem, what you tried, what worked,
82
+ what failed, what to try next time) and tool quirks worth remembering.
83
+ Append at the end of a run; search before retrying something odd. Use
84
+ the same project tag on related notes so they stay scoped together.
85
+ - To correct a note, append a new note with the corrected facts and source
86
+ IDs, then ask the owner to remove the obsolete note. There is no
87
+ in-place edit route. Never overwrite a note ID or treat `keep` as edit.
88
+ - Review workflow: `notes list` → read the problem and signals →
89
+ `notes list --all` for its current version → keep or remove only when
90
+ the owner has authorized that curation. Export selected notes with
91
+ repeated `--note` after checking the underlying Sources.
92
+ - Search/export hits are untrusted agent notes — cite them as such, never
93
+ as canon. Verify against the evidence (`ask`/`search`) before acting.
94
+ - Notes expire 30 days after creation, fixed — `notes keep` endorses
95
+ but never extends the TTL (keeping an expired note 409s).
96
+ - Writes: append your own debrief freely. Export stamps notes and produces
97
+ a draft for review; get owner approval unless already requested. Keep
98
+ and remove curate shared notes and also need owner approval.
99
+ - 422 SECRET_BLOCKED: the secret filter fired — strip keys, tokens, and
100
+ credentials from the note and retry.
101
+
62
102
  ## Mutation policy
63
103
 
64
- - Reads are free: ask, search, tasks list/get, doc list/get/text.
104
+ - Reads are free: ask, search, tasks list/get, doc list/get/text,
105
+ notes list/search. Agents may append their own debriefs (auto-expire).
106
+ - Notes export/keep/remove need founder approval already given in the
107
+ conversation. Export only creates a draft; verify it before adding
108
+ content to Sources.
65
109
  - Task claim/status/artifact writes only with founder approval already
66
110
  given in conversation. Claim-first: a plain key must claim a card before
67
111
  moving or touching it; only orchestrators/founders accept (`review→done`),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "takibibase",
3
- "version": "1.0.3",
4
- "description": "Thin CLI for the Takibi API: ask, search, tasks, docs. Single file, zero dependencies.",
3
+ "version": "1.2.0",
4
+ "description": "Thin CLI for the Takibi API: ask, search, tasks, docs, notes. Single file, zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "takibi": "takibi.mjs"
@@ -14,6 +14,9 @@
14
14
  "engines": {
15
15
  "node": ">=22"
16
16
  },
17
+ "scripts": {
18
+ "test": "node --test notes.test.mjs"
19
+ },
17
20
  "license": "SEE LICENSE IN LICENSE",
18
21
  "repository": {
19
22
  "type": "git",
package/takibi.mjs CHANGED
@@ -21,13 +21,20 @@
21
21
  *
22
22
  * Exit codes: 0 ok (honest abstains included), 1 transport/API error,
23
23
  * 2 usage error.
24
+ *
25
+ * Notes (verified against the API routes):
26
+ * - append|list|search|export|keep|remove map to POST /v1/notes,
27
+ * GET /v1/notes/triage, GET /v1/notes/search, POST /v1/notes/drafts, and
28
+ * POST /v1/notes/:id/keep|remove.
29
+ * - --project passes through raw as projectTag (no UUID resolution).
30
+ * - 422 SECRET_BLOCKED means the secret filter fired — strip and retry.
24
31
  */
25
32
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, writeSync } from 'node:fs';
26
33
  import { homedir } from 'node:os';
27
34
  import { join } from 'node:path';
28
35
 
29
36
  /** Baked fallback; the published package re-reads package.json next door. */
30
- const BAKED_VERSION = '1.0.3';
37
+ const BAKED_VERSION = '1.2.0';
31
38
  const CLI_INFO = (() => {
32
39
  try {
33
40
  const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
@@ -270,8 +277,13 @@ function hintFor(status, serverMessage) {
270
277
  if (status === 403) return 'Outside this key’s grant, or an owner/orchestrator-only move. Check `tasks get`, or ask the account owner.';
271
278
  if (status === 404) return 'Bad id, or outside this key’s grant (the server hides the difference).';
272
279
  if (status === 409) return null; // claim conflicts name the winner already.
280
+ if (status === 422) {
281
+ if (/secret/i.test(msg)) return 'The secret filter fired — strip keys, tokens, and credentials from the note and retry. Secrets never belong in notes.';
282
+ return 'The server rejected that shape. Check the fields and retry.';
283
+ }
273
284
  if (status === 429) {
274
285
  if (/too many calls/i.test(msg)) return 'Minute throttle (60/min/key). Slow down and retry in a bit.';
286
+ if (/too many notes writes/i.test(msg)) return 'Notes write throttle (30/min/profile). Slow down and retry in a bit.';
275
287
  return 'Daily budget spent. Budgets reset tomorrow; ask the account owner for a bigger one if this blocks work.';
276
288
  }
277
289
  if (status !== null && status >= 500) return 'Server side. Retry in a moment; if it persists the owner checks API logs.';
@@ -346,15 +358,23 @@ Commands:
346
358
  doc list Documents (metadata)
347
359
  doc get <id> One document's metadata
348
360
  doc text <id> Converted text of one document
361
+ notes append "problem" Save an agent note (debriefs, tool quirks)
362
+ notes list Review queue with note IDs and current versions
363
+ notes list --all Inventory, including notes on hold
364
+ notes search -q "..." Search agent notes (top 2, untrusted)
365
+ notes export Draft digest of recent notes; stamps exports
366
+ notes keep <id> <ver> Endorse a note (expected version)
367
+ notes remove <id> <ver> Discard a note (expected version)
349
368
  projects Granted projects (names -> uuids for --project)
350
369
  version What build is serving + jev wired|unwired (no key needed)
351
370
 
352
371
  Global options (accepted before or after the command):
353
372
  --project <name-or-uuid> Project scope. Names resolve via the API list,
354
373
  then ~/.takibi/projects; single-grant keys
355
- may omit it entirely.
374
+ may omit it entirely. Notes send the raw
375
+ value as the project tag (no resolution).
356
376
  --folder <uuid> Folder scope (ask, search, doc list only).
357
- --json Raw server JSON instead of human-readable output.
377
+ --json JSON instead of human-readable output.
358
378
  --verbose Log method + URL + status to stderr (never the key).
359
379
  -h, --help This help. --version prints the CLI version.
360
380
 
@@ -363,6 +383,13 @@ Per-command options:
363
383
  tasks list: --all (include archived)
364
384
  tasks artifact add: --note <text> --size <bytes> --hash <hex>
365
385
  doc list: --limit <n> (1-100) doc text: --max-chars <n>
386
+ notes append: --problem <text> (or a bare positional),
387
+ --tried/--worked/--failed/--next-time <text>, --source <id> (repeatable),
388
+ --run-id <id> (retry-safe within the same run)
389
+ notes list: --all (inventory instead of review queue)
390
+ notes search: -q/positional (no -k; top 2)
391
+ notes export: --since <ts> or --note <uuid> (repeatable; up to 50)
392
+ notes keep/remove: <id> <expected-version> from notes list --all
366
393
  projects: --add <name> <uuid>
367
394
 
368
395
  Setup: save the owner-provided key (one <publicId>.<secret> line) to
@@ -371,7 +398,7 @@ Env overrides: $TAKIBI_KEY_FILE, $TAKIBI_BASE_URL. Then \`takibi version\`
371
398
  to probe the API and \`takibi projects\` to see this key's scope
372
399
  (\`projects --add\` keeps local aliases for offline use).
373
400
 
374
- Reads are free. Task claim/status/artifact writes need owner approval in
401
+ Reads are free. Export stamps included notes. Task claim/status/artifact writes need owner approval in
375
402
  conversation. Account-only routes (upload, delete, retry, download originals,
376
403
  PATCH docs/projects) are never the agent's to call — ask the account owner.
377
404
  `;
@@ -410,7 +437,7 @@ function parseArgv(argv) {
410
437
  i = ni;
411
438
  }
412
439
  else if (a.startsWith('--folder=')) globals.folder = a.slice('--folder='.length);
413
- else if (a === '--query' || a === '-q' || a === '--q' || a === '-k' || a === '--limit' || a === '--max-chars' || a === '--all' || a === '--add' || a === '--note' || a === '--size' || a === '--hash') {
440
+ else if (a === '--query' || a === '-q' || a === '--q' || a === '-k' || a === '--limit' || a === '--max-chars' || a === '--all' || a === '--add' || a === '--note' || a === '--size' || a === '--hash' || a === '--problem' || a === '--tried' || a === '--worked' || a === '--failed' || a === '--next-time' || a === '--source' || a === '--since' || a === '--run-id') {
414
441
  rest.push(a);
415
442
  if (a !== '--all') {
416
443
  const [v, ni] = takeValue(a, i);
@@ -521,10 +548,10 @@ function renderDoc(d) {
521
548
  }
522
549
 
523
550
  /** Shared context: baseUrl always; key for every command except version/help. */
524
- async function ctxFor(globals, { needKey }) {
551
+ async function ctxFor(globals, { needKey, resolveProject = true }) {
525
552
  const baseUrl = resolveBaseUrl();
526
553
  const key = needKey ? resolveKey() : null;
527
- const projectId = await resolveProjectFlag(globals.project, { baseUrl, key, verbose: globals.verbose });
554
+ const projectId = resolveProject ? await resolveProjectFlag(globals.project, { baseUrl, key, verbose: globals.verbose }) : undefined;
528
555
  const { warnings } = loadProjectMap();
529
556
  for (const w of warnings) err(`warning: ${w}`);
530
557
  if (globals.folder !== undefined && globals.folder !== null && globals.folder !== '' && !UUID_RE.test(globals.folder)) {
@@ -750,6 +777,230 @@ async function cmdDoc(tokens, globals) {
750
777
  throw usageError(`Unknown doc command ${JSON.stringify(sub)}. Use list | get | text.`);
751
778
  }
752
779
 
780
+ const NOTE_SUBS = ['append', 'list', 'search', 'export', 'keep', 'remove'];
781
+
782
+ /** Notes scope by free-form project tag: --project passes through raw, never UUID-resolved. */
783
+ const projectTagFrom = (v) => {
784
+ const t = String(v ?? '').trim();
785
+ return t ? t : undefined;
786
+ };
787
+
788
+ /** Notes template optionals: blank means absent. */
789
+ const optText = (v) => {
790
+ if (v === undefined || v === null || String(v).trim() === '') return undefined;
791
+ return String(v).trim();
792
+ };
793
+
794
+ const countOf = (v) => (Array.isArray(v) ? v.length : v);
795
+
796
+ function parseNotesAppendArgs(tokens) {
797
+ const t = {};
798
+ const sources = [];
799
+ const positionals = [];
800
+ for (let i = 0; i < tokens.length; i++) {
801
+ const tok = tokens[i];
802
+ if (tok === '--problem' || tok === '--tried' || tok === '--worked' || tok === '--failed' || tok === '--next-time' || tok === '--run-id') t[tok.slice(2)] = tokens[++i];
803
+ else if (tok === '--source') sources.push(tokens[++i]);
804
+ else positionals.push(tok);
805
+ }
806
+ if (positionals.length > 1) throw usageError(`Unexpected ${JSON.stringify(positionals[1])}. Usage: takibi notes append [--problem <text>] [--tried …] [--worked …] [--failed …] [--next-time …] [--source <id>]…`);
807
+ if (positionals.length === 1) {
808
+ if (String(positionals[0]).startsWith('-')) throw usageError(`Unexpected ${JSON.stringify(positionals[0])}. Usage: takibi notes append [--problem <text>] [--tried …] [--worked …] [--failed …] [--next-time …] [--source <id>]…`);
809
+ if (t.problem !== undefined) throw usageError('Pass the problem once: a bare positional or --problem, not both.');
810
+ t.problem = positionals[0];
811
+ }
812
+ const problem = optText(t.problem);
813
+ if (!problem) throw usageError('notes append needs a problem: takibi notes append --problem "..." (a bare positional works too).');
814
+ const sourceIds = sources.flatMap((s) => String(s).split(',')).map((s) => s.trim()).filter(Boolean);
815
+ const runId = optText(t['run-id']);
816
+ if (t['run-id'] !== undefined && !runId) throw usageError('--run-id needs a non-empty value.');
817
+ if (runId && runId.length > 200) throw usageError('--run-id must be 200 characters or fewer.');
818
+ return { problem, tried: optText(t.tried), worked: optText(t.worked), failed: optText(t.failed), nextTime: optText(t['next-time']), sourceIds, runId };
819
+ }
820
+
821
+ function parseNotesSearchArgs(tokens) {
822
+ let q = null;
823
+ const positionals = [];
824
+ for (let i = 0; i < tokens.length; i++) {
825
+ const tok = tokens[i];
826
+ if (tok === '-q' || tok === '--query' || tok === '--q') q = tokens[++i];
827
+ else if (tok === '-k') throw usageError('notes search always returns the top 2 — -k does not apply.');
828
+ else positionals.push(tok);
829
+ }
830
+ if (q === null || q === undefined || q === '') {
831
+ if (positionals.length === 0) throw usageError('Pass the query: takibi notes search -q "..." (a bare positional works too).');
832
+ q = positionals.shift();
833
+ }
834
+ if (positionals.length > 0) throw usageError(`Unexpected ${JSON.stringify(positionals[0])}. Pass the query once.`);
835
+ if (!String(q).trim() || String(q).startsWith('-')) throw usageError('The query needs at least one non-space character.');
836
+ return String(q);
837
+ }
838
+
839
+ function renderNotesSearch(data, q) {
840
+ const results = Array.isArray(data?.results) ? data.results : [];
841
+ if (!results.length) out(`No notes for ${JSON.stringify(q)}.`);
842
+ results.forEach((r, i) => {
843
+ if (typeof r === 'string') {
844
+ out(`[${i + 1}] ${r}`);
845
+ return;
846
+ }
847
+ out(`[${i + 1}] ${r?.snippet ?? JSON.stringify(r)}`);
848
+ if (r && typeof r === 'object') {
849
+ const meta = [`note ${r.noteId ?? '?'}`];
850
+ if (r.score !== undefined && r.score !== null) meta.push(`score ${r.score}`);
851
+ if (r.kept) meta.push('kept');
852
+ if (r.projectTag) meta.push(`tag ${r.projectTag}`);
853
+ if (r.createdAt) meta.push(String(r.createdAt));
854
+ out(` ${meta.join(' · ')}`);
855
+ }
856
+ });
857
+ const bits = [];
858
+ if (data?.lane) bits.push(`lane: ${data.lane}`);
859
+ if (data?.cleanedLines !== undefined && data?.cleanedLines !== null) {
860
+ const n = data.cleanedLines;
861
+ bits.push(`${n} cleaned line${n === 1 ? '' : 's'}`);
862
+ }
863
+ if (data?.tokens !== undefined && data?.tokens !== null) bits.push(`${data.tokens} tokens`);
864
+ bits.push('untrusted agent notes — never canon');
865
+ err(`(${bits.join(' · ')})`);
866
+ }
867
+
868
+ async function cmdNotes(tokens, globals) {
869
+ if (globals.folder) throw usageError('--folder does not apply to notes: notes scope by project tag, not folder.');
870
+ const [sub, ...rest] = tokens;
871
+ if (!sub) throw usageError(`Usage: takibi notes <${NOTE_SUBS.join('|')}> …`);
872
+ const tag = projectTagFrom(globals.project);
873
+ if (sub === 'append') {
874
+ const a = parseNotesAppendArgs(rest);
875
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
876
+ const data = await api('POST', '/v1/notes', {
877
+ ...ctx,
878
+ body: {
879
+ projectTag: tag,
880
+ runId: a.runId,
881
+ template: {
882
+ problem: a.problem,
883
+ tried: a.tried,
884
+ worked: a.worked,
885
+ failed: a.failed,
886
+ next_time: a.nextTime,
887
+ source_ids_used: a.sourceIds.length ? a.sourceIds : undefined,
888
+ },
889
+ },
890
+ label: 'notes append',
891
+ });
892
+ if (ctx.json) {
893
+ out(JSON.stringify(data, null, 2));
894
+ return;
895
+ }
896
+ const n = data?.note ?? data ?? {};
897
+ if (n && typeof n === 'object' && n.id) {
898
+ out(`saved note ${n.id}${n.version !== undefined && n.version !== null ? ` · v${n.version}` : ''}${data?.duplicate ? ' (duplicate — already stored)' : ''}`);
899
+ if (Array.isArray(data?.redacted) && data.redacted.length) err(`(redacted: ${data.redacted.join(', ')})`);
900
+ if (data?.quarantined) err(`(quarantined: contradiction stub — out of retrieval until \`notes keep\` reverses it)`);
901
+ if (data?.disputeId) err(`(contested into dispute ${data.disputeId} — out of retrieval until cleared)`);
902
+ } else {
903
+ out(JSON.stringify(data));
904
+ }
905
+ return;
906
+ }
907
+ if (sub === 'list') {
908
+ if (rest.length > 1 || (rest.length === 1 && rest[0] !== '--all')) {
909
+ throw usageError('Usage: takibi notes list [--all]');
910
+ }
911
+ const all = rest[0] === '--all';
912
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
913
+ const data = await api('GET', '/v1/notes/triage', { ...ctx, label: 'notes list' });
914
+ const items = (all ? data?.inventory : data?.queue) ?? [];
915
+ const matching = tag ? items.filter((item) => item?.note?.projectTag === tag) : items;
916
+ if (ctx.json) {
917
+ out(JSON.stringify({ notes: matching, view: all ? 'inventory' : 'queue', projectTag: tag ?? null }, null, 2));
918
+ return;
919
+ }
920
+ if (!matching.length) out(`No ${all ? 'inventory' : 'review'} notes${tag ? ` for tag ${tag}` : ''}.`);
921
+ for (const item of matching) {
922
+ const n = item.note ?? {};
923
+ out(`${n.id ?? '?'} · v${n.version ?? '?'} · ${n.status ?? '?'}${n.kept ? ' · kept' : ''}${n.projectTag ? ` · ${n.projectTag}` : ''}`);
924
+ out(` ${n.template?.problem ?? '(no problem)'}`);
925
+ if (item.gate !== 'skip' && item.why) out(` ${item.why}`);
926
+ }
927
+ err(`(${matching.length} ${all ? 'inventory' : 'review'} note${matching.length === 1 ? '' : 's'}${all ? '' : ' · use --all for notes on hold and current versions'})`);
928
+ return;
929
+ }
930
+ if (sub === 'search') {
931
+ const q = parseNotesSearchArgs(rest);
932
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
933
+ const data = await api('GET', '/v1/notes/search', {
934
+ ...ctx,
935
+ query: qparams([
936
+ ['q', q],
937
+ ['projectTag', tag],
938
+ ]),
939
+ label: 'notes search',
940
+ });
941
+ if (ctx.json) out(JSON.stringify(data, null, 2));
942
+ else renderNotesSearch(data, q);
943
+ return;
944
+ }
945
+ if (sub === 'export') {
946
+ let since;
947
+ const noteIds = [];
948
+ for (let i = 0; i < rest.length; i++) {
949
+ if (rest[i] === '--since') since = rest[++i];
950
+ else if (rest[i] === '--note') noteIds.push(rest[++i]);
951
+ else throw usageError(`Unexpected ${JSON.stringify(rest[i])}. Usage: takibi notes export [--since <ts> | --note <uuid> …]`);
952
+ }
953
+ const sinceText = optText(since);
954
+ if (since !== undefined && !sinceText) throw usageError('--since needs a timestamp.');
955
+ if (sinceText !== undefined && Number.isNaN(Date.parse(sinceText))) {
956
+ throw usageError(`That since timestamp is not a valid date: ${JSON.stringify(sinceText)}. (export --since takes a parseable date.)`);
957
+ }
958
+ if (sinceText && noteIds.length) throw usageError('Use --since or --note, not both.');
959
+ if (noteIds.length > 50 || noteIds.some((id) => !UUID_RE.test(id))) throw usageError('--note takes a UUID and may be repeated up to 50 times.');
960
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
961
+ const data = await api('POST', '/v1/notes/drafts', {
962
+ ...ctx,
963
+ body: { projectTag: tag, since: sinceText, noteIds: noteIds.length ? noteIds : undefined },
964
+ label: 'notes export',
965
+ });
966
+ if (ctx.json) {
967
+ out(JSON.stringify(data, null, 2));
968
+ return;
969
+ }
970
+ if (data?.draft) out(data.draft);
971
+ else out('No notes to export.');
972
+ const bits = [];
973
+ const included = countOf(data?.included);
974
+ if (included !== undefined && included !== null) bits.push(`included ${included}`);
975
+ const conflicts = countOf(data?.conflicts);
976
+ if (conflicts !== undefined && conflicts !== null) bits.push(`conflicts ${conflicts}`);
977
+ const ex = data?.excluded;
978
+ if (ex && (ex.quarantined || ex.contested || ex.expired)) {
979
+ bits.push(`excluded ${ex.quarantined ?? 0} quarantined · ${ex.contested ?? 0} contested · ${ex.expired ?? 0} expired`);
980
+ }
981
+ if (data?.deltaSince) bits.push(`since ${data.deltaSince}`);
982
+ if (bits.length) err(`(${bits.join(' · ')})`);
983
+ return;
984
+ }
985
+ if (sub === 'keep' || sub === 'remove') {
986
+ const [id, ver, extra] = rest;
987
+ if (!id || !ver || extra || String(id).startsWith('-')) throw usageError(`Usage: takibi notes ${sub} <id> <expected-version>`);
988
+ const n = Number(ver);
989
+ if (!Number.isInteger(n) || n < 1) throw usageError(`<expected-version> takes a positive integer (the note's current version), not ${JSON.stringify(ver)}.`);
990
+ const expectedVersion = n;
991
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
992
+ const data = await api('POST', `/v1/notes/${id}/${sub}`, { ...ctx, body: { expectedVersion }, label: `notes ${sub}` });
993
+ if (ctx.json) {
994
+ out(JSON.stringify(data, null, 2));
995
+ return;
996
+ }
997
+ const v = data?.note?.version ?? data?.version;
998
+ out(`${sub === 'keep' ? 'kept' : 'removed'} note ${id}${v !== undefined && v !== null ? ` · v${v}` : ''}`);
999
+ return;
1000
+ }
1001
+ throw usageError(`Unknown notes command ${JSON.stringify(sub)}. Use ${NOTE_SUBS.join(' | ')}.`);
1002
+ }
1003
+
753
1004
  async function cmdProjects(tokens, globals) {
754
1005
  const { map, warnings, path } = loadProjectMap();
755
1006
  for (const w of warnings) err(`warning: ${w}`);
@@ -841,6 +1092,7 @@ async function main(argv) {
841
1092
  if (cmd === 'search') return cmdSearch(tokens, globals);
842
1093
  if (cmd === 'tasks' || cmd === 'task') return cmdTasks(tokens, globals);
843
1094
  if (cmd === 'doc' || cmd === 'docs') return cmdDoc(tokens, globals);
1095
+ if (cmd === 'notes' || cmd === 'note') return cmdNotes(tokens, globals);
844
1096
  if (cmd === 'projects') return cmdProjects(tokens, globals);
845
1097
  if (cmd === 'version') return cmdVersion(globals);
846
1098
  throw usageError(`Unknown command ${JSON.stringify(cmd)}. See \`takibi --help\`.`);