takibibase 1.11.0 → 1.14.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 +25 -7
  2. package/package.json +1 -1
  3. package/takibi.mjs +156 -14
package/SKILL.md CHANGED
@@ -41,14 +41,31 @@ error hints.
41
41
  - `takibi tasks list | get <id> | claim <id> | status <id> <todo|in_progress|review|done> [--note …]`
42
42
  and `takibi tasks artifact add <taskId> <url> [--note …]`. The status
43
43
  `--note` is the review handoff note (max 1000 chars); leaving review
44
- clears it.
44
+ clears it. A sent-back card keeps the last handoff as `previousReviewNote`
45
+ — read it for the last round's feedback without it being pasted into the body.
46
+ - `takibi tasks artifact add <taskId> --markdown <file|-> [--title …] [--note …]`
47
+ — attach an inline markdown note (content from a file or stdin `-`,
48
+ max 50000 chars; title max 140 chars);
49
+ `takibi tasks artifact show <taskId> <artifactId>` prints one note's raw
50
+ markdown. Prefer inline markdown artifacts for text deliverables —
51
+ briefs, drafts, debriefs; external Drive/URLs only for sheets, slides,
52
+ or binary files.
45
53
  - `takibi tasks handoff <id> <profile> [--review] [--note "…"]` — hand a
46
54
  card you hold to another profile on the same board (name or UUID; an
47
55
  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.
56
+ `--review` to submit your work for review in the same call (the inbox
57
+ credits you as the finisher) and `--note` to leave the review note with
58
+ it: after the handoff you no longer hold the card, so a follow-up
59
+ `status` call would be refused. You cannot release a card to nobody or
60
+ hand on done/archived cards.
61
+ - `takibi tasks follow-up <id> --title "…"` — spin a fresh linked todo
62
+ off any card, including a finished one (`--body`, `--due <date>`,
63
+ `--assignee` for an exact board name or UUID — unknown names error
64
+ with the eligible list, and naming anyone other than yourself needs
65
+ Orchestrator). The new card lands in the source's project at To do,
66
+ unassigned unless `--assignee` names someone; the source keeps its
67
+ status, holder, and history, and nothing carries over. Never move a
68
+ done card back to To do to continue it — follow up instead.
52
69
  - `@name` in a task names the handoff target: when your part is
53
70
  finished, run `tasks handoff <id> name` (usually with `--review
54
71
  --note`). The mention itself assigns nothing — the handoff does.
@@ -146,12 +163,13 @@ abstains honestly instead of answering halfway.
146
163
  - Notes export/keep/remove need owner approval already given in the
147
164
  conversation. Export only creates a draft; verify it before adding
148
165
  content to Sources.
149
- - Task claim/status/handoff/artifact/escalation writes only with owner approval
166
+ - Task claim/status/handoff/artifact/escalation/follow-up writes only with owner approval
150
167
  already given in conversation. Claim-first: a plain key must claim a card before
151
168
  moving or touching it; only orchestrators/owners accept (`review→done`),
152
169
  assign or release cards they do not hold, or archive. A holder may hand
153
170
  its own card to another eligible profile (`tasks handoff`). Title/body edits need the create cap —
154
- claim-only keys can drive a card but cannot rewrite its text.
171
+ claim-only keys can drive a card but cannot rewrite its text. Follow-ups need
172
+ the create cap too, and any granted creator may spin one off a card it does not hold.
155
173
  - Boards need a whole-collection grant: folder-only or tag-only keys 403
156
174
  on every task route (the full-collection server message says so verbatim
157
175
  when the key holds the required cap; keys lacking the cap get the generic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takibibase",
3
- "version": "1.11.0",
3
+ "version": "1.14.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": {
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.11.0';
40
+ const BAKED_VERSION = '1.14.0';
41
41
  const CLI_INFO = (() => {
42
42
  try {
43
43
  const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
@@ -364,9 +364,12 @@ Commands:
364
364
  tasks claim <id> Claim an open to-do card (atomic, one winner)
365
365
  tasks status <id> <st> Move a card: todo | in_progress | review | done [--note <text>]
366
366
  tasks handoff <id> <profile> Hand a card you hold to another profile [--review --note <text>]
367
+ tasks follow-up <id> Spin a fresh linked todo off any card [--title/--body/--assignee/--due]
367
368
  tasks escalate <id> Ask the Founder for help on a held card [--reason <text>]
368
369
  tasks deescalate <id> Clear your escalation ("never mind")
369
- tasks artifact add <taskId> <url> Attach a link-only artifact
370
+ tasks artifact add <taskId> <url> Attach a link artifact [--note/--size/--hash]
371
+ tasks artifact add <taskId> --markdown <file|-> Attach an inline markdown note [--title/--note]
372
+ tasks artifact show <taskId> <artifactId> Print one markdown note's content
370
373
  doc list Documents (metadata)
371
374
  doc get <id> One document's metadata
372
375
  doc text <id> Converted text of one document
@@ -398,10 +401,15 @@ Per-command options:
398
401
  ask/search: -q, --query <text> (or a bare positional), -k <n> (ask 1-12, search 1-20)
399
402
  tasks list: --all (include archived)
400
403
  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
404
+ tasks handoff: <profile> is a name or UUID on this board; --review submits your work
405
+ for review in the same call, --note <text> leaves the review note with it
403
406
  tasks escalate: --reason <text> (required, max 500 chars — what do you need from the Founder?)
404
- tasks artifact add: --note <text> --size <bytes> --hash <hex>
407
+ tasks follow-up: --title <text> (required, max 300 chars), --body <text> (max 20000 chars),
408
+ --assignee <name-or-uuid>, --due <date>; the source card is never modified
409
+ tasks get: sent-back cards carry previousReviewNote, the last handoff note
410
+ tasks artifact add <url>: --note <text> --size <bytes> --hash <hex> (links only)
411
+ tasks artifact add --markdown <file|->: --title <text> --note <text> (content from file or stdin -, max 50000 chars; title max 140 chars)
412
+ tasks artifact show: prints the raw markdown (links carry no content)
405
413
  doc list: --limit <n> (1-100) doc text: --max-chars <n>
406
414
  doc upload: <file> (pdf, md, txt, docx, max 10MB)
407
415
  notes append: --problem <text> (or a bare positional),
@@ -424,7 +432,7 @@ to probe the API and \`takibi projects\` to see this key's scope
424
432
  Agents: \`takibi skill --install\` puts the takibi-use skill where
425
433
  assistants look for it.
426
434
 
427
- Reads are free. Export stamps included notes. Task claim/status/handoff/artifact/escalation writes and doc uploads
435
+ Reads are free. Export stamps included notes. Task claim/status/handoff/artifact/escalation/follow-up writes and doc uploads
428
436
  need owner approval in conversation (uploads also need the Uploads grant on the key).
429
437
  Account-only routes (delete, retry, download originals, PATCH docs/projects)
430
438
  are never the agent's to call — ask the account owner.
@@ -464,7 +472,7 @@ function parseArgv(argv) {
464
472
  i = ni;
465
473
  }
466
474
  else if (a.startsWith('--folder=')) globals.folder = a.slice('--folder='.length);
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') {
475
+ 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 === '--markdown' || a === '--title' || a === '--body' || a === '--assignee' || a === '--due' || 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') {
468
476
  rest.push(a);
469
477
  if (a !== '--all' && a !== '--review' && a !== '--install' && a !== '--force' && a !== '--agents' && a !== '--claude' && a !== '--codex') {
470
478
  const [v, ni] = takeValue(a, i);
@@ -543,18 +551,48 @@ const taskLine = (t) => {
543
551
  if (t.escalationReason) bits.push(`· escalated: ${t.escalationReason}`);
544
552
  if (t.blocked) bits.push(`· blocked: ${t.blockedReason || 'no reason given'}`);
545
553
  if (t.artifacts?.length) bits.push(`· ${t.artifacts.length} artifact${t.artifacts.length === 1 ? '' : 's'}`);
554
+ if (t.followups?.length) bits.push(`· ${t.followups.length} follow-up${t.followups.length === 1 ? '' : 's'}`);
546
555
  if (t.archivedAt) bits.push('· archived');
547
556
  if (t.dueAt) bits.push(`· due ${t.dueAt}`);
548
557
  return bits.join(' ');
549
558
  };
550
559
 
560
+ /**
561
+ * Display title for a task artifact: the explicit title when set, else
562
+ * the content's first ATX heading, else a fixed fallback. Local duplicate
563
+ * of artifactTitle in apps/web/src/tasks/artifacts.ts — this file ships
564
+ * standalone and cannot import TS.
565
+ */
566
+ function artifactTitle(title, content) {
567
+ const explicit = title?.trim() ?? '';
568
+ if (explicit !== '') return explicit;
569
+ for (const line of (content ?? '').split('\n')) {
570
+ const text = /^#{1,6}\s+(.*)$/.exec(line.trim())?.[1] ?? '';
571
+ if (text !== '') return text;
572
+ }
573
+ return 'Untitled note';
574
+ }
575
+
576
+ /** One artifact row: links print the URL exactly as before; markdown
577
+ * notes (null URL) print the display title — never the literal null. */
578
+ const artifactRow = (a) =>
579
+ a.kind === 'markdown' || a.url == null
580
+ ? `${artifactTitle(a.title, a.content ?? null)}${a.note ? ` — ${a.note}` : ''} [${a.id}]`
581
+ : `${a.url}${a.note ? ` — ${a.note}` : ''} [${a.id}]`;
582
+
551
583
  function renderTask(t) {
552
584
  out(taskLine(t));
553
585
  if (t.reviewNote) out(`review: ${t.reviewNote}`);
586
+ if (t.previousReviewNote) out(`previous review note: ${t.previousReviewNote}`);
554
587
  if (t.body) out(`\n${t.body}`);
588
+ if (t.followsUpOn) out(`follow-up of: ${t.followsUpOn.title} [${t.followsUpOn.id}]`);
589
+ if (t.followups?.length) {
590
+ out('follow-ups:');
591
+ for (const f of t.followups) out(` - ${f.title} · ${f.status}${f.assigneeName ? ` · ${f.assigneeName}` : ''}${f.archivedAt ? ' · archived' : ''} [${f.id}]`);
592
+ }
555
593
  if (t.artifacts?.length) {
556
594
  out('');
557
- for (const a of t.artifacts) out(` - ${a.url}${a.note ? ` — ${a.note}` : ''} [${a.id}]`);
595
+ for (const a of t.artifacts) out(` - ${artifactRow(a)}`);
558
596
  }
559
597
  err(`created ${t.createdAt} · updated ${t.updatedAt}`);
560
598
  }
@@ -648,13 +686,13 @@ async function listTasks(ctx, includeArchived) {
648
686
  * eligibility); names resolve exactly against GET /v1/tasks/assignees for
649
687
  * this board — same exact-match-or-error rule as --project names.
650
688
  */
651
- async function resolveAssignee(value, ctx) {
689
+ async function resolveAssignee(value, ctx, label = 'tasks handoff') {
652
690
  const v = String(value).trim();
653
691
  if (UUID_RE.test(v)) return { id: v };
654
692
  const data = await api('GET', '/v1/tasks/assignees', {
655
693
  ...ctx,
656
694
  query: qparams([['projectId', ctx.projectId]]),
657
- label: 'tasks handoff',
695
+ label,
658
696
  });
659
697
  const eligible = data?.assignees ?? [];
660
698
  const matches = eligible.filter((a) => a.name === v);
@@ -766,13 +804,73 @@ async function cmdTasks(tokens, globals) {
766
804
  return;
767
805
  }
768
806
  if (sub === 'artifact' || sub === 'artifacts') {
769
- const [verb, taskId, url, ...flagTokens] = rest;
770
- if (verb !== 'add' || !taskId || !url) throw usageError('Usage: takibi tasks artifact add <taskId> <url> [--note <t> --size <n> --hash <h>]');
807
+ const LINK_USAGE = 'Usage: takibi tasks artifact add <taskId> <url> [--note <t> --size <n> --hash <h>]';
808
+ const MD_USAGE = 'Usage: takibi tasks artifact add <taskId> --markdown <file|-> [--title <t>] [--note <t>]';
809
+ const [verb, taskId, ...after] = rest;
810
+ if (verb === 'show') {
811
+ const [artifactId, extra] = after;
812
+ if (!taskId || !artifactId || extra) throw usageError('Usage: takibi tasks artifact show <taskId> <artifactId>');
813
+ const ctx = await ctxFor(globals, { needKey: true });
814
+ const data = await api('GET', `/v1/tasks/${taskId}/artifacts/${artifactId}`, {
815
+ ...ctx,
816
+ query: qparams([['projectId', ctx.projectId]]),
817
+ label: 'tasks artifact show',
818
+ });
819
+ // --json prints the server envelope even for links (content null);
820
+ // human output refuses instead of printing an empty page.
821
+ if (ctx.json) {
822
+ out(JSON.stringify(data, null, 2));
823
+ return;
824
+ }
825
+ if (data.artifact.content == null) {
826
+ throw new CliError('That artifact is a link — it carries no stored content to print.');
827
+ }
828
+ out(data.artifact.content);
829
+ return;
830
+ }
831
+ const [url, ...flagTokens] = after;
832
+ if (verb !== 'add' || !taskId || !url) throw usageError(LINK_USAGE);
833
+ if (url === '--markdown') {
834
+ const [file, ...mdFlags] = flagTokens;
835
+ if (!file) throw usageError(MD_USAGE);
836
+ const md = {};
837
+ for (let i = 0; i < mdFlags.length; i++) {
838
+ const f = mdFlags[i];
839
+ if (f === '--title' || f === '--note') md[f.slice(2)] = mdFlags[++i];
840
+ else throw usageError(`Unexpected ${JSON.stringify(f)}. ${MD_USAGE}`);
841
+ }
842
+ if (md.title !== undefined && md.title === '') throw usageError(`--title needs non-empty text. ${MD_USAGE}`);
843
+ if (md.note !== undefined && md.note === '') throw usageError(`--note needs non-empty text. ${MD_USAGE}`);
844
+ let content;
845
+ if (file === '-') {
846
+ content = readFileSync(0, 'utf8');
847
+ } else {
848
+ try {
849
+ content = readFileSync(file, 'utf8');
850
+ } catch {
851
+ throw usageError(`No such file: ${file}.`);
852
+ }
853
+ }
854
+ if (!content.trim()) throw usageError(`${file === '-' ? 'That stdin' : file} is empty — nothing to attach.`);
855
+ const ctx = await ctxFor(globals, { needKey: true });
856
+ const data = await api('POST', `/v1/tasks/${taskId}/artifacts`, {
857
+ ...ctx,
858
+ query: qparams([['projectId', ctx.projectId]]),
859
+ body: { kind: 'markdown', title: md.title, content, note: md.note },
860
+ label: 'tasks artifact add',
861
+ });
862
+ if (ctx.json) out(JSON.stringify(data, null, 2));
863
+ else {
864
+ out(`attached ${data.artifact.id}`);
865
+ out(` ${artifactTitle(data.artifact.title, content)}${data.artifact.note ? ` — ${data.artifact.note}` : ''}`);
866
+ }
867
+ return;
868
+ }
771
869
  const flags = {};
772
870
  for (let i = 0; i < flagTokens.length; i++) {
773
871
  const f = flagTokens[i];
774
872
  if (f === '--note' || f === '--size' || f === '--hash') flags[f.slice(2)] = flagTokens[++i];
775
- else throw usageError(`Unexpected ${JSON.stringify(f)}. Usage: takibi tasks artifact add <taskId> <url> [--note <t> --size <n> --hash <h>]`);
873
+ else throw usageError(`Unexpected ${JSON.stringify(f)}. ${LINK_USAGE}`);
776
874
  }
777
875
  if (flags.note !== undefined && flags.note === '') throw usageError('--note needs non-empty text.');
778
876
  if (flags.size !== undefined) {
@@ -828,7 +926,51 @@ async function cmdTasks(tokens, globals) {
828
926
  else renderTask(data.task);
829
927
  return;
830
928
  }
831
- throw usageError(`Unknown tasks command ${JSON.stringify(sub)}. Use list | get | claim | status | handoff | escalate | deescalate | artifact add.`);
929
+ if (sub === 'follow-up') {
930
+ const usage = 'Usage: takibi tasks follow-up <id> --title <text> [--body <text>] [--assignee <name-or-uuid>] [--due <date>]';
931
+ const [id, ...flagTokens] = rest;
932
+ if (!id || id.startsWith('--')) throw usageError(usage);
933
+ let title;
934
+ let body;
935
+ let assignee;
936
+ let due;
937
+ for (let i = 0; i < flagTokens.length; i++) {
938
+ const f = flagTokens[i];
939
+ if (f === '--title') title = flagTokens[++i];
940
+ else if (f === '--body') body = flagTokens[++i];
941
+ else if (f === '--assignee') assignee = flagTokens[++i];
942
+ else if (f === '--due') due = flagTokens[++i];
943
+ else throw usageError(`Unexpected ${JSON.stringify(f)}. ${usage}`);
944
+ }
945
+ // Validate everything before touching the key or network, mirroring
946
+ // the server limits so failures name the flag instead of 400ing.
947
+ if (title === undefined || title.trim() === '') throw usageError('--title needs non-empty text.');
948
+ if (title.length > 300) throw usageError('--title is max 300 chars.');
949
+ if (body !== undefined && body.length > 20000) throw usageError('--body is max 20000 chars.');
950
+ if (due !== undefined && due.trim() === '') throw usageError('--due needs a date.');
951
+ if (assignee !== undefined && String(assignee).trim() === '') throw usageError('--assignee needs a profile name or UUID.');
952
+ // No --project: the endpoint scopes by the source card's own project.
953
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
954
+ if (assignee !== undefined) {
955
+ const v = String(assignee).trim();
956
+ if (!UUID_RE.test(v)) {
957
+ // Names resolve against the source card's own board.
958
+ const src = await api('GET', `/v1/tasks/${id}`, { ...ctx, label: 'tasks follow-up' });
959
+ assignee = (await resolveAssignee(v, { ...ctx, projectId: src.task.projectId }, 'tasks follow-up')).id;
960
+ } else {
961
+ assignee = v;
962
+ }
963
+ }
964
+ const data = await api('POST', `/v1/tasks/${id}/follow-ups`, {
965
+ ...ctx,
966
+ body: { title: title.trim(), body, assigneeProfileId: assignee, dueAt: due },
967
+ label: 'tasks follow-up',
968
+ });
969
+ if (ctx.json) out(JSON.stringify(data, null, 2));
970
+ else renderTask(data.task);
971
+ return;
972
+ }
973
+ throw usageError(`Unknown tasks command ${JSON.stringify(sub)}. Use list | get | claim | status | handoff | follow-up | escalate | deescalate | artifact add | artifact show.`);
832
974
  }
833
975
 
834
976
  async function cmdDoc(tokens, globals) {