takibibase 1.8.0 → 1.11.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.
Files changed (3) hide show
  1. package/SKILL.md +29 -7
  2. package/package.json +2 -2
  3. package/takibi.mjs +116 -9
package/SKILL.md CHANGED
@@ -38,8 +38,26 @@ error hints.
38
38
  `-k 1-12`, `--json`.
39
39
  - `takibi search -q "…"` — ranked chunks (snippets + metadata, not full
40
40
  text). Same flags, `-k 1-20`.
41
- - `takibi tasks list | get <id> | claim <id> | status <id> <todo|in_progress|review|done>`
42
- and `takibi tasks artifact add <taskId> <url> [--note …]`.
41
+ - `takibi tasks list | get <id> | claim <id> | status <id> <todo|in_progress|review|done> [--note …]`
42
+ and `takibi tasks artifact add <taskId> <url> [--note …]`. The status
43
+ `--note` is the review handoff note (max 1000 chars); leaving review
44
+ clears it.
45
+ - `takibi tasks handoff <id> <profile> [--review] [--note "…"]` — hand a
46
+ card you hold to another profile on the same board (name or UUID; an
47
+ unknown name errors with the eligible names — never guess). Use
48
+ `--review` to move it to review in the same call and `--note` to leave
49
+ the review note with it: after the handoff you no longer hold the card,
50
+ so a follow-up `status` call would be refused. You cannot release a card
51
+ to nobody or hand on done/archived cards.
52
+ - `@name` in a task names the handoff target: when your part is
53
+ finished, run `tasks handoff <id> name` (usually with `--review
54
+ --note`). The mention itself assigns nothing — the handoff does.
55
+ - `takibi tasks escalate <id> --reason "…"` — ask the Founder for help
56
+ on a card you hold (reason required, max 500 chars; raising never
57
+ unassigns you). The flag clears on done, archive, or assignee change;
58
+ `takibi tasks deescalate <id>` retracts your flag ("never mind").
59
+ An orchestrator key can also clear any flag (audited) — treat that as
60
+ acknowledgment, not retraction.
43
61
  - `takibi doc list | get <id> | text <id>` — metadata, then converted text.
44
62
  `doc download` is account-only; the CLI says so — use `doc text`.
45
63
  - `takibi doc upload <file> [--project …] [--folder <uuid>]` — file one
@@ -128,10 +146,11 @@ abstains honestly instead of answering halfway.
128
146
  - Notes export/keep/remove need owner approval already given in the
129
147
  conversation. Export only creates a draft; verify it before adding
130
148
  content to Sources.
131
- - Task claim/status/artifact writes only with owner approval already
132
- given in conversation. Claim-first: a plain key must claim a card before
149
+ - Task claim/status/handoff/artifact/escalation writes only with owner approval
150
+ already given in conversation. Claim-first: a plain key must claim a card before
133
151
  moving or touching it; only orchestrators/owners accept (`review→done`),
134
- assign others, or archive. Title/body edits need the create cap —
152
+ assign or release cards they do not hold, or archive. A holder may hand
153
+ its own card to another eligible profile (`tasks handoff`). Title/body edits need the create cap —
135
154
  claim-only keys can drive a card but cannot rewrite its text.
136
155
  - Boards need a whole-collection grant: folder-only or tag-only keys 403
137
156
  on every task route (the full-collection server message says so verbatim
@@ -153,8 +172,11 @@ abstains honestly instead of answering halfway.
153
172
  capability (notes are opt-in), or folder-/tag-only key on a board route.
154
173
  404: bad id (the server hides grant gaps as 404 too).
155
174
  - 409 on claim: someone already holds the card — the message names them.
175
+ - Handoff 403: you do not hold the card (claim it, or ask an orchestrator)
176
+ or you tried to release it to nobody. 422: the target lacks a
177
+ whole-collection grant on this board, or the card is done/archived.
156
178
  - 422 SECRET_BLOCKED: the secret filter fired — it covers task
157
- title/body/blockedReason/artifact text as well as notes. Strip keys,
158
- tokens, and credentials and retry.
179
+ title/body/blockedReason/reviewNote/escalationReason/artifact text as
180
+ well as notes. Strip keys, tokens, and credentials and retry.
159
181
  - 429: minute throttle (slow down) or daily budget spent (resets tomorrow).
160
182
  - Cannot-reach errors: check the network and service status; `takibi version` probes it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takibibase",
3
- "version": "1.8.0",
3
+ "version": "1.11.0",
4
4
  "description": "Thin CLI for the Takibi API: ask, search, tasks, docs, notes. Single file, zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "node": ">=22"
16
16
  },
17
17
  "scripts": {
18
- "test": "node --test notes.test.mjs skill.test.mjs upload.test.mjs"
18
+ "test": "node --test notes.test.mjs skill.test.mjs tasks.test.mjs upload.test.mjs"
19
19
  },
20
20
  "license": "SEE LICENSE IN LICENSE",
21
21
  "repository": {
package/takibi.mjs CHANGED
@@ -37,7 +37,7 @@ import { homedir } from 'node:os';
37
37
  import { basename, join } from 'node:path';
38
38
 
39
39
  /** Baked fallback; the published package re-reads package.json next door. */
40
- const BAKED_VERSION = '1.8.0';
40
+ const BAKED_VERSION = '1.11.0';
41
41
  const CLI_INFO = (() => {
42
42
  try {
43
43
  const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
@@ -362,7 +362,10 @@ Commands:
362
362
  tasks list Board cards for the project
363
363
  tasks get <id> One card with body + artifacts
364
364
  tasks claim <id> Claim an open to-do card (atomic, one winner)
365
- tasks status <id> <st> Move a card: todo | in_progress | review | done
365
+ tasks status <id> <st> Move a card: todo | in_progress | review | done [--note <text>]
366
+ tasks handoff <id> <profile> Hand a card you hold to another profile [--review --note <text>]
367
+ tasks escalate <id> Ask the Founder for help on a held card [--reason <text>]
368
+ tasks deescalate <id> Clear your escalation ("never mind")
366
369
  tasks artifact add <taskId> <url> Attach a link-only artifact
367
370
  doc list Documents (metadata)
368
371
  doc get <id> One document's metadata
@@ -394,6 +397,10 @@ Global options (accepted before or after the command):
394
397
  Per-command options:
395
398
  ask/search: -q, --query <text> (or a bare positional), -k <n> (ask 1-12, search 1-20)
396
399
  tasks list: --all (include archived)
400
+ tasks status: --note <text> (review handoff note; cleared when the card leaves review)
401
+ tasks handoff: <profile> is a name or UUID on this board; --review moves the card to
402
+ review in the same call, --note <text> leaves the review note with it
403
+ tasks escalate: --reason <text> (required, max 500 chars — what do you need from the Founder?)
397
404
  tasks artifact add: --note <text> --size <bytes> --hash <hex>
398
405
  doc list: --limit <n> (1-100) doc text: --max-chars <n>
399
406
  doc upload: <file> (pdf, md, txt, docx, max 10MB)
@@ -417,7 +424,7 @@ to probe the API and \`takibi projects\` to see this key's scope
417
424
  Agents: \`takibi skill --install\` puts the takibi-use skill where
418
425
  assistants look for it.
419
426
 
420
- Reads are free. Export stamps included notes. Task claim/status/artifact writes and doc uploads
427
+ Reads are free. Export stamps included notes. Task claim/status/handoff/artifact/escalation writes and doc uploads
421
428
  need owner approval in conversation (uploads also need the Uploads grant on the key).
422
429
  Account-only routes (delete, retry, download originals, PATCH docs/projects)
423
430
  are never the agent's to call — ask the account owner.
@@ -457,9 +464,9 @@ function parseArgv(argv) {
457
464
  i = ni;
458
465
  }
459
466
  else if (a.startsWith('--folder=')) globals.folder = a.slice('--folder='.length);
460
- 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' || a === '--install' || a === '--force' || a === '--agents' || a === '--claude' || a === '--codex' || a === '--dir') {
467
+ else if (a === '--query' || a === '-q' || a === '--q' || a === '-k' || a === '--limit' || a === '--max-chars' || a === '--all' || a === '--review' || a === '--add' || a === '--note' || a === '--reason' || a === '--size' || a === '--hash' || a === '--problem' || a === '--tried' || a === '--worked' || a === '--failed' || a === '--next-time' || a === '--source' || a === '--since' || a === '--run-id' || a === '--install' || a === '--force' || a === '--agents' || a === '--claude' || a === '--codex' || a === '--dir') {
461
468
  rest.push(a);
462
- if (a !== '--all' && a !== '--install' && a !== '--force' && a !== '--agents' && a !== '--claude' && a !== '--codex') {
469
+ if (a !== '--all' && a !== '--review' && a !== '--install' && a !== '--force' && a !== '--agents' && a !== '--claude' && a !== '--codex') {
463
470
  const [v, ni] = takeValue(a, i);
464
471
  rest.push(v);
465
472
  i = ni;
@@ -533,6 +540,7 @@ function renderSearch(results, q) {
533
540
 
534
541
  const taskLine = (t) => {
535
542
  const bits = [t.id, `· ${t.status}`, `· ${t.title}`, `· ${t.assigneeName ?? 'unassigned'}`];
543
+ if (t.escalationReason) bits.push(`· escalated: ${t.escalationReason}`);
536
544
  if (t.blocked) bits.push(`· blocked: ${t.blockedReason || 'no reason given'}`);
537
545
  if (t.artifacts?.length) bits.push(`· ${t.artifacts.length} artifact${t.artifacts.length === 1 ? '' : 's'}`);
538
546
  if (t.archivedAt) bits.push('· archived');
@@ -542,6 +550,7 @@ const taskLine = (t) => {
542
550
 
543
551
  function renderTask(t) {
544
552
  out(taskLine(t));
553
+ if (t.reviewNote) out(`review: ${t.reviewNote}`);
545
554
  if (t.body) out(`\n${t.body}`);
546
555
  if (t.artifacts?.length) {
547
556
  out('');
@@ -634,6 +643,32 @@ async function listTasks(ctx, includeArchived) {
634
643
  return data.tasks ?? [];
635
644
  }
636
645
 
646
+ /**
647
+ * Handoff target → {id}. UUIDs pass straight through (the server checks
648
+ * eligibility); names resolve exactly against GET /v1/tasks/assignees for
649
+ * this board — same exact-match-or-error rule as --project names.
650
+ */
651
+ async function resolveAssignee(value, ctx) {
652
+ const v = String(value).trim();
653
+ if (UUID_RE.test(v)) return { id: v };
654
+ const data = await api('GET', '/v1/tasks/assignees', {
655
+ ...ctx,
656
+ query: qparams([['projectId', ctx.projectId]]),
657
+ label: 'tasks handoff',
658
+ });
659
+ const eligible = data?.assignees ?? [];
660
+ const matches = eligible.filter((a) => a.name === v);
661
+ if (matches.length > 1) {
662
+ throw new CliError(`More than one profile on this board is named ${JSON.stringify(v)}.`, {
663
+ hint: `Pass the profile UUID instead: ${matches.map((a) => a.id).join(', ')}.`,
664
+ });
665
+ }
666
+ if (matches[0]) return matches[0];
667
+ throw new CliError(`No profile named ${JSON.stringify(v)} can take cards on this board.`, {
668
+ hint: eligible.length > 0 ? `Eligible: ${eligible.map((a) => a.name).join(', ')}.` : 'No profile holds this whole board yet — ask the account owner.',
669
+ });
670
+ }
671
+
637
672
  async function cmdTasks(tokens, globals) {
638
673
  if (globals.folder) throw usageError('--folder does not apply to tasks: cards are project-wide, folder grants stay a KB-read concept.');
639
674
  const [sub, ...rest] = tokens;
@@ -678,20 +713,58 @@ async function cmdTasks(tokens, globals) {
678
713
  return;
679
714
  }
680
715
  if (sub === 'status') {
681
- const [id, to, extra] = rest;
682
- if (!id || !to || extra) throw usageError('Usage: takibi tasks status <id> <todo|in_progress|review|done>');
716
+ const [id, to, ...flagTokens] = rest;
717
+ if (!id || !to) throw usageError('Usage: takibi tasks status <id> <todo|in_progress|review|done> [--note <text>]');
683
718
  if (!TASK_STATUSES.includes(to)) throw usageError(`Status is one of ${TASK_STATUSES.join(' | ')}, not ${JSON.stringify(to)}.`);
719
+ let note;
720
+ for (let i = 0; i < flagTokens.length; i++) {
721
+ const f = flagTokens[i];
722
+ if (f === '--note') note = flagTokens[++i];
723
+ else throw usageError(`Unexpected ${JSON.stringify(f)}. Usage: takibi tasks status <id> <todo|in_progress|review|done> [--note <text>]`);
724
+ }
725
+ if (note !== undefined && note === '') throw usageError('--note needs non-empty text.');
684
726
  const ctx = await ctxFor(globals, { needKey: true });
685
727
  const data = await api('PATCH', `/v1/tasks/${id}`, {
686
728
  ...ctx,
687
729
  query: qparams([['projectId', ctx.projectId]]),
688
- body: { status: to },
730
+ body: { status: to, reviewNote: note },
689
731
  label: 'tasks status',
690
732
  });
691
733
  if (ctx.json) out(JSON.stringify(data, null, 2));
692
734
  else renderTask(data.task);
693
735
  return;
694
736
  }
737
+ if (sub === 'handoff') {
738
+ const usage = 'Usage: takibi tasks handoff <id> <profile-name-or-uuid> [--review] [--note <text>]';
739
+ const [id, target, ...flagTokens] = rest;
740
+ if (!id || !target || target.startsWith('--')) throw usageError(usage);
741
+ let review = false;
742
+ let note;
743
+ for (let i = 0; i < flagTokens.length; i++) {
744
+ const f = flagTokens[i];
745
+ if (f === '--review') review = true;
746
+ else if (f === '--note') note = flagTokens[++i];
747
+ else throw usageError(`Unexpected ${JSON.stringify(f)}. ${usage}`);
748
+ }
749
+ if (note !== undefined && note === '') throw usageError('--note needs non-empty text.');
750
+ // After the handoff this key no longer holds the card, so the note can
751
+ // only ride the same call — and only onto a card moving to review.
752
+ if (note !== undefined && !review) throw usageError('--note rides with --review: the handoff and the review note go in one call.');
753
+ const ctx = await ctxFor(globals, { needKey: true });
754
+ const assignee = await resolveAssignee(target, ctx);
755
+ const body = { assigneeProfileId: assignee.id };
756
+ if (review) body.status = 'review';
757
+ if (note !== undefined) body.reviewNote = note;
758
+ const data = await api('PATCH', `/v1/tasks/${id}`, {
759
+ ...ctx,
760
+ query: qparams([['projectId', ctx.projectId]]),
761
+ body,
762
+ label: 'tasks handoff',
763
+ });
764
+ if (ctx.json) out(JSON.stringify(data, null, 2));
765
+ else renderTask(data.task);
766
+ return;
767
+ }
695
768
  if (sub === 'artifact' || sub === 'artifacts') {
696
769
  const [verb, taskId, url, ...flagTokens] = rest;
697
770
  if (verb !== 'add' || !taskId || !url) throw usageError('Usage: takibi tasks artifact add <taskId> <url> [--note <t> --size <n> --hash <h>]');
@@ -721,7 +794,41 @@ async function cmdTasks(tokens, globals) {
721
794
  }
722
795
  return;
723
796
  }
724
- throw usageError(`Unknown tasks command ${JSON.stringify(sub)}. Use list | get | claim | status | artifact add.`);
797
+ if (sub === 'escalate') {
798
+ const [id, ...flagTokens] = rest;
799
+ if (!id) throw usageError('Usage: takibi tasks escalate <id> --reason <text>');
800
+ let reason;
801
+ for (let i = 0; i < flagTokens.length; i++) {
802
+ const f = flagTokens[i];
803
+ if (f === '--reason') reason = flagTokens[++i];
804
+ else throw usageError(`Unexpected ${JSON.stringify(f)}. Usage: takibi tasks escalate <id> --reason <text>`);
805
+ }
806
+ if (reason === undefined || reason === '') throw usageError('--reason needs non-empty text: what do you need from the Founder?');
807
+ const ctx = await ctxFor(globals, { needKey: true });
808
+ const data = await api('POST', `/v1/tasks/${id}/escalate`, {
809
+ ...ctx,
810
+ query: qparams([['projectId', ctx.projectId]]),
811
+ body: { reason },
812
+ label: 'tasks escalate',
813
+ });
814
+ if (ctx.json) out(JSON.stringify(data, null, 2));
815
+ else renderTask(data.task);
816
+ return;
817
+ }
818
+ if (sub === 'deescalate') {
819
+ const [id, extra] = rest;
820
+ if (!id || extra) throw usageError('Usage: takibi tasks deescalate <id>');
821
+ const ctx = await ctxFor(globals, { needKey: true });
822
+ const data = await api('DELETE', `/v1/tasks/${id}/escalation`, {
823
+ ...ctx,
824
+ query: qparams([['projectId', ctx.projectId]]),
825
+ label: 'tasks deescalate',
826
+ });
827
+ if (ctx.json) out(JSON.stringify(data, null, 2));
828
+ else renderTask(data.task);
829
+ return;
830
+ }
831
+ throw usageError(`Unknown tasks command ${JSON.stringify(sub)}. Use list | get | claim | status | handoff | escalate | deescalate | artifact add.`);
725
832
  }
726
833
 
727
834
  async function cmdDoc(tokens, globals) {