takibibase 1.0.3 → 1.3.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 (4) hide show
  1. package/README.md +18 -12
  2. package/SKILL.md +73 -18
  3. package/package.json +5 -2
  4. package/takibi.mjs +406 -16
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,12 +22,15 @@ 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
27
- project(s). Copy the `<publicId>.<secret>` shown once.
25
+ capabilities the crew needs (`search`, `ask`, `tasks`, plus `task:create`
26
+ for authors, `task:assign` for orchestrators, and `notes` / `notes:append` /
27
+ `notes:export` for observers), scoped to its project(s). Task boards need
28
+ a whole-collection grant — folder-only or tag-only seats cannot use them.
29
+ Copy the `<publicId>.<secret>` shown once.
28
30
  2. `mkdir -p ~/.takibi && printf '%s\n' '<publicId>.<secret>' > ~/.takibi/key && chmod 600 ~/.takibi/key`
29
- 3. Optional: one base-URL line in `~/.takibi/config` (default
30
- `http://localhost:3849`). Env overrides: `$TAKIBI_KEY_FILE`,
31
- `$TAKIBI_BASE_URL`.
31
+ 3. Only for local setups: one base-URL line in `~/.takibi/config`
32
+ (default `https://app.takibibase.com`). Env overrides:
33
+ `$TAKIBI_KEY_FILE`, `$TAKIBI_BASE_URL`.
32
34
  4. `takibi projects` — lists the projects this key can reach, straight
33
35
  from the API. Names resolve in `--project` (UUIDs work too, from
34
36
  Settings → Projects); single-grant keys may omit it. Only for offline
@@ -38,10 +40,14 @@ else lives in this directory.
38
40
 
39
41
  ## Give an agent
40
42
 
41
- 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.
43
+ Key file in place + `takibi skill --install` (puts the `takibi-use` skill
44
+ into the detected agent skills dirs; `--dir` overrides, `--force`
45
+ overwrites). Zero-install alternative: `takibi skill` prints the skill —
46
+ paste it into the agent's first prompt. Acceptance: a fresh agent asks,
47
+ searches, lists tasks, appends/searches notes, and uses `notes list --all`
48
+ to find the current version before curation. Reads are free; task writes
49
+ and notes export/keep/remove need your approval in conversation;
50
+ account-only routes stay yours.
45
51
 
46
52
  ## Known edges
47
53
 
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
@@ -12,21 +12,20 @@ error hints.
12
12
  ## Setup (once)
13
13
 
14
14
  - CLI: `npx takibibase …` (zero-install), `takibi …` (global install),
15
- or `node tools/takibi/takibi.mjs …` from the takibi-base checkout
15
+ or `node <path>/takibi.mjs …` for the single-file download
16
16
  (use the alias when it exists).
17
- - Key: the founder saves one `<publicId>.<secret>` line to `~/.takibi/key`
17
+ - Key: the owner saves one `<publicId>.<secret>` line to `~/.takibi/key`
18
18
  (`chmod 600`). The key is never printed, never pasted in chat, never
19
19
  committed. If a command says the key is missing, stop and ask.
20
- - Base URL defaults to `http://localhost:3849` (local boot). Override with
21
- `$TAKIBI_BASE_URL` or one URL line in `~/.takibi/config`.
20
+ - Base URL defaults to Takibi's servers (`https://app.takibibase.com`).
21
+ Only local setups override with `$TAKIBI_BASE_URL` or one URL line in
22
+ `~/.takibi/config`.
22
23
  - Projects: `takibi projects` lists what this key can reach. `--project`
23
24
  takes a name or a UUID; single-grant keys may omit it.
24
25
  `takibi projects --add <name> <uuid>` keeps local aliases for offline use.
25
26
  - First probe: `takibi version` (needs no key; shows build + `jev` status).
26
27
  - Update notice: the CLI polls the npm registry once a day and nudges on
27
- stderr when behind (never on `--json`). Silence it with
28
- `TAKIBI_NO_UPDATE_CHECK=1`; point it at a mirror with
29
- `TAKIBI_REGISTRY_URL`.
28
+ stderr when behind (never on `--json`).
30
29
 
31
30
  ## Commands
32
31
 
@@ -38,7 +37,24 @@ error hints.
38
37
  - `takibi tasks list | get <id> | claim <id> | status <id> <todo|in_progress|review|done>`
39
38
  and `takibi tasks artifact add <taskId> <url> [--note …]`.
40
39
  - `takibi doc list | get <id> | text <id>` — metadata, then converted text.
41
- `doc download` is founder-only; the CLI says so — use `doc text`.
40
+ `doc download` is account-only; the CLI says so — use `doc text`.
41
+ - `takibi notes append --problem "…" [--tried …] [--worked …] [--failed …] [--next-time …] [--source <id>]`
42
+ — save an end-of-run debrief or tool quirk (`--problem` or a bare
43
+ positional; `--project <tag>` scopes it; `--source` is repeatable).
44
+ Add `--run-id <run-id>` when a run may retry: the same run ID and body
45
+ return the existing note instead of appending a duplicate.
46
+ - `takibi notes list` — review queue with note IDs, statuses, and current
47
+ versions. `takibi notes list --all` shows the inventory, including notes
48
+ on hold. `--project <tag>` filters either list by the exact project tag.
49
+ - `takibi notes search -q "…"` — top-2 agent notes for the query.
50
+ - `takibi notes export [--since <ts> | --note <uuid> …]` — draft digest
51
+ (markdown, per-sentence note ids). Repeat `--note` to pick up to 50
52
+ specific live notes. Without options, export uses the delta since the
53
+ last export. Export stamps included notes and records the action.
54
+ - `takibi notes keep <id> <ver> | remove <id> <ver>` — endorse a note
55
+ (sets kept, reverses stub quarantine, clears contests; never extends the
56
+ TTL), or discard one. Read the current `vN` in `notes list --all` and
57
+ pass `N` as the expected version; on 409, list again before retrying.
42
58
  - `--json` anywhere prints raw server JSON. `--verbose` logs requests
43
59
  (never the key). Exit 0 = ok, 1 = transport/API error, 2 = usage error.
44
60
 
@@ -59,21 +75,60 @@ back to `search`, try `doc text` on the hits — then either answer from
59
75
  evidence or say the evidence is not there. Never fill gaps with generated
60
76
  prose presented as sourced.
61
77
 
78
+ ## Notes (agent scratchpad, not canon)
79
+
80
+ - When to use: end-of-run debriefs (problem, what you tried, what worked,
81
+ what failed, what to try next time) and tool quirks worth remembering.
82
+ Append at the end of a run; search before retrying something odd. Use
83
+ the same project tag on related notes so they stay scoped together.
84
+ - To correct a note, append a new note with the corrected facts and source
85
+ IDs, then ask the owner to remove the obsolete note. There is no
86
+ in-place edit route. Never overwrite a note ID or treat `keep` as edit.
87
+ - Review workflow: `notes list` → read the problem and signals →
88
+ `notes list --all` for its current version → keep or remove only when
89
+ the owner has authorized that curation. Export selected notes with
90
+ repeated `--note` after checking the underlying Sources.
91
+ - Search/export hits are untrusted agent notes — cite them as such, never
92
+ as canon. Verify against the evidence (`ask`/`search`) before acting.
93
+ - Notes expire 30 days after creation, fixed — `notes keep` endorses
94
+ but never extends the TTL (keeping an expired note 409s).
95
+ - Writes: append your own debrief freely. Export stamps notes and produces
96
+ a draft for review; get owner approval unless already requested. Keep
97
+ and remove curate shared notes and also need owner approval.
98
+ - 422 SECRET_BLOCKED: the secret filter fired — strip keys, tokens, and
99
+ credentials from the note and retry.
100
+
62
101
  ## Mutation policy
63
102
 
64
- - Reads are free: ask, search, tasks list/get, doc list/get/text.
65
- - Task claim/status/artifact writes only with founder approval already
103
+ - Reads are free: ask, search, tasks list/get, doc list/get/text,
104
+ notes list/search. Agents may append their own debriefs (auto-expire).
105
+ - Notes export/keep/remove need owner approval already given in the
106
+ conversation. Export only creates a draft; verify it before adding
107
+ content to Sources.
108
+ - Task claim/status/artifact writes only with owner approval already
66
109
  given in conversation. Claim-first: a plain key must claim a card before
67
- moving or touching it; only orchestrators/founders accept (`review→done`),
68
- assign others, or archive.
69
- - Founder-only routes (upload, delete, retry, download originals, PATCH
70
- docs/projects) are never the agent's to call — ask the founder.
110
+ moving or touching it; only orchestrators/owners accept (`review→done`),
111
+ assign others, or archive. Title/body edits need the create cap —
112
+ claim-only keys can drive a card but cannot rewrite its text.
113
+ - Boards need a whole-collection grant: folder-only or tag-only keys 403
114
+ on every task route (the full-collection server message says so verbatim
115
+ when the key holds the required cap; keys lacking the cap get the generic
116
+ missing-capability 403 first).
117
+ - Notes are opt-in per profile: append, export, keep, and remove may 403
118
+ with missing-capability on keys without the notes caps — ask the owner
119
+ to enable them in the profile editor.
120
+ - Account-only routes (upload, delete, retry, download originals, PATCH
121
+ docs/projects) are never the agent's to call — ask the account owner.
71
122
 
72
123
  ## Failure table
73
124
 
74
- - 401: key wrong/missing/revoked/disabled — or a founder-only route
75
- (the CLI names it). 403: outside the grant or orchestrator-only.
125
+ - 401: key wrong/missing/revoked/disabled — or an account-only route
126
+ (the CLI names it). 403: outside the grant, orchestrator-only, missing
127
+ capability (notes are opt-in), or folder-/tag-only key on a board route.
76
128
  404: bad id (the server hides grant gaps as 404 too).
77
129
  - 409 on claim: someone already holds the card — the message names them.
130
+ - 422 SECRET_BLOCKED: the secret filter fired — it covers task
131
+ title/body/blockedReason/artifact text as well as notes. Strip keys,
132
+ tokens, and credentials and retry.
78
133
  - 429: minute throttle (slow down) or daily budget spent (resets tomorrow).
79
- - Cannot-reach errors: the API is not booted; `takibi version` probes it.
134
+ - Cannot-reach errors: check the network and service status; `takibi version` probes it.
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.3.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 skill.test.mjs"
19
+ },
17
20
  "license": "SEE LICENSE IN LICENSE",
18
21
  "repository": {
19
22
  "type": "git",
package/takibi.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * not in output, errors, --verbose, or exit traces (see scrub()).
9
9
  * - Origin: never sent. Absent Origin on a Bearer call is correct.
10
10
  * - Ask/search take `q`, never `question`.
11
- * - Base URL: $TAKIBI_BASE_URL or ~/.takibi/config, else localhost:3849.
11
+ * - Base URL: $TAKIBI_BASE_URL or ~/.takibi/config, else Takibi's servers.
12
12
  *
13
13
  * Pure-client deviations the server forces (no apps/api changes allowed):
14
14
  * - Keys list their own granted scope via GET /v1/projects, so `projects`
@@ -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.3.0';
31
38
  const CLI_INFO = (() => {
32
39
  try {
33
40
  const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
@@ -40,7 +47,7 @@ const CLI_INFO = (() => {
40
47
  return { version: BAKED_VERSION, singleFile: true };
41
48
  })();
42
49
  const CLI_VERSION = CLI_INFO.version;
43
- const DEFAULT_BASE_URL = 'http://localhost:3849';
50
+ const DEFAULT_BASE_URL = 'https://app.takibibase.com';
44
51
  const REQUEST_TIMEOUT_MS = 90_000;
45
52
  const USER_AGENT = `takibibase/${CLI_VERSION}`;
46
53
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -90,11 +97,10 @@ function versionBehind(current, latest) {
90
97
  /** Once-a-day latest-version poll. Stderr only (stdout stays clean),
91
98
  * best-effort, never fails the command. Failures are cached too, so an
92
99
  * unreachable registry costs one slow run a day, not every invocation.
93
- * Skipped for --json (errors there are a single JSON object), in CI, and
94
- * with TAKIBI_NO_UPDATE_CHECK=1. */
100
+ * Skipped for --json (errors there are a single JSON object) and in CI. */
95
101
  async function maybeNotifyUpdate({ json = false } = {}) {
96
102
  try {
97
- if (json || process.env.CI || process.env.TAKIBI_NO_UPDATE_CHECK === '1') return;
103
+ if (json || process.env.CI) return;
98
104
  const path = join(takibiDir(), 'update-check');
99
105
  let cached = null;
100
106
  try {
@@ -267,11 +273,21 @@ function hintFor(status, serverMessage) {
267
273
  if (/disabled/i.test(msg)) return 'That profile is disabled. Ask the account owner to enable it.';
268
274
  return 'The key is missing or wrong. Check ~/.takibi/key (one <publicId>.<secret> line).';
269
275
  }
270
- if (status === 403) return 'Outside this key’s grant, or an owner/orchestrator-only move. Check `tasks get`, or ask the account owner.';
276
+ if (status === 403) {
277
+ // The full-collection message is displayable verbatim (contract §11) —
278
+ // it already prints as the error, so no generic hint on top of it.
279
+ if (/full collection/i.test(msg)) return null;
280
+ return 'Outside this key’s grant, or an owner/orchestrator-only move. Check `tasks get`, or ask the account owner.';
281
+ }
271
282
  if (status === 404) return 'Bad id, or outside this key’s grant (the server hides the difference).';
272
283
  if (status === 409) return null; // claim conflicts name the winner already.
284
+ if (status === 422) {
285
+ if (/secret/i.test(msg)) return 'The secret filter fired — strip keys, tokens, and credentials and retry. Secrets never belong in notes or task text.';
286
+ return 'The server rejected that shape. Check the fields and retry.';
287
+ }
273
288
  if (status === 429) {
274
289
  if (/too many calls/i.test(msg)) return 'Minute throttle (60/min/key). Slow down and retry in a bit.';
290
+ if (/too many notes writes/i.test(msg)) return 'Notes write throttle (30/min/profile). Slow down and retry in a bit.';
275
291
  return 'Daily budget spent. Budgets reset tomorrow; ask the account owner for a bigger one if this blocks work.';
276
292
  }
277
293
  if (status !== null && status >= 500) return 'Server side. Retry in a moment; if it persists the owner checks API logs.';
@@ -301,7 +317,7 @@ async function api(method, path, { query = null, body = null, key = null, baseUr
301
317
  } catch (e) {
302
318
  const why = e?.name === 'TimeoutError' ? `timed out after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s` : (e?.message ?? e);
303
319
  throw new CliError(`Cannot reach the API at ${baseUrl} (${why}).`, {
304
- hint: 'Is the API booted? `takibi version` probes it without needing a key.',
320
+ hint: 'Check the network and service status; `takibi version` probes it without needing a key.',
305
321
  });
306
322
  }
307
323
  if (verbose) err(`← ${res.status} in ${Date.now() - started}ms`);
@@ -346,15 +362,25 @@ Commands:
346
362
  doc list Documents (metadata)
347
363
  doc get <id> One document's metadata
348
364
  doc text <id> Converted text of one document
365
+ notes append "problem" Save an agent note (debriefs, tool quirks)
366
+ notes list Review queue with note IDs and current versions
367
+ notes list --all Inventory, including notes on hold
368
+ notes search -q "..." Search agent notes (top 2, untrusted)
369
+ notes export Draft digest of recent notes; stamps exports
370
+ notes keep <id> <ver> Endorse a note (expected version)
371
+ notes remove <id> <ver> Discard a note (expected version)
349
372
  projects Granted projects (names -> uuids for --project)
350
373
  version What build is serving + jev wired|unwired (no key needed)
374
+ skill Print the takibi-use agent skill (pipe it to an agent)
375
+ skill --install Install the skill into agent skills dirs
351
376
 
352
377
  Global options (accepted before or after the command):
353
378
  --project <name-or-uuid> Project scope. Names resolve via the API list,
354
379
  then ~/.takibi/projects; single-grant keys
355
- may omit it entirely.
380
+ may omit it entirely. Notes send the raw
381
+ value as the project tag (no resolution).
356
382
  --folder <uuid> Folder scope (ask, search, doc list only).
357
- --json Raw server JSON instead of human-readable output.
383
+ --json JSON instead of human-readable output.
358
384
  --verbose Log method + URL + status to stderr (never the key).
359
385
  -h, --help This help. --version prints the CLI version.
360
386
 
@@ -363,15 +389,26 @@ Per-command options:
363
389
  tasks list: --all (include archived)
364
390
  tasks artifact add: --note <text> --size <bytes> --hash <hex>
365
391
  doc list: --limit <n> (1-100) doc text: --max-chars <n>
392
+ notes append: --problem <text> (or a bare positional),
393
+ --tried/--worked/--failed/--next-time <text>, --source <id> (repeatable),
394
+ --run-id <id> (retry-safe within the same run)
395
+ notes list: --all (inventory instead of review queue)
396
+ notes search: -q/positional (no -k; top 2)
397
+ notes export: --since <ts> or --note <uuid> (repeatable; up to 50)
398
+ notes keep/remove: <id> <expected-version> from notes list --all
366
399
  projects: --add <name> <uuid>
400
+ skill: --install [--force] [--agents] [--claude] [--codex] [--dir <skills-root>]
367
401
 
368
402
  Setup: save the owner-provided key (one <publicId>.<secret> line) to
369
- ~/.takibi/key (chmod 600), optional base URL line to ~/.takibi/config.
403
+ ~/.takibi/key (chmod 600). The API defaults to Takibi's servers; only
404
+ local setups need a base URL line in ~/.takibi/config.
370
405
  Env overrides: $TAKIBI_KEY_FILE, $TAKIBI_BASE_URL. Then \`takibi version\`
371
406
  to probe the API and \`takibi projects\` to see this key's scope
372
407
  (\`projects --add\` keeps local aliases for offline use).
408
+ Agents: \`takibi skill --install\` puts the takibi-use skill where
409
+ assistants look for it.
373
410
 
374
- Reads are free. Task claim/status/artifact writes need owner approval in
411
+ Reads are free. Export stamps included notes. Task claim/status/artifact writes need owner approval in
375
412
  conversation. Account-only routes (upload, delete, retry, download originals,
376
413
  PATCH docs/projects) are never the agent's to call — ask the account owner.
377
414
  `;
@@ -410,9 +447,9 @@ function parseArgv(argv) {
410
447
  i = ni;
411
448
  }
412
449
  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') {
450
+ 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') {
414
451
  rest.push(a);
415
- if (a !== '--all') {
452
+ if (a !== '--all' && a !== '--install' && a !== '--force' && a !== '--agents' && a !== '--claude' && a !== '--codex') {
416
453
  const [v, ni] = takeValue(a, i);
417
454
  rest.push(v);
418
455
  i = ni;
@@ -521,10 +558,10 @@ function renderDoc(d) {
521
558
  }
522
559
 
523
560
  /** Shared context: baseUrl always; key for every command except version/help. */
524
- async function ctxFor(globals, { needKey }) {
561
+ async function ctxFor(globals, { needKey, resolveProject = true }) {
525
562
  const baseUrl = resolveBaseUrl();
526
563
  const key = needKey ? resolveKey() : null;
527
- const projectId = await resolveProjectFlag(globals.project, { baseUrl, key, verbose: globals.verbose });
564
+ const projectId = resolveProject ? await resolveProjectFlag(globals.project, { baseUrl, key, verbose: globals.verbose }) : undefined;
528
565
  const { warnings } = loadProjectMap();
529
566
  for (const w of warnings) err(`warning: ${w}`);
530
567
  if (globals.folder !== undefined && globals.folder !== null && globals.folder !== '' && !UUID_RE.test(globals.folder)) {
@@ -750,6 +787,230 @@ async function cmdDoc(tokens, globals) {
750
787
  throw usageError(`Unknown doc command ${JSON.stringify(sub)}. Use list | get | text.`);
751
788
  }
752
789
 
790
+ const NOTE_SUBS = ['append', 'list', 'search', 'export', 'keep', 'remove'];
791
+
792
+ /** Notes scope by free-form project tag: --project passes through raw, never UUID-resolved. */
793
+ const projectTagFrom = (v) => {
794
+ const t = String(v ?? '').trim();
795
+ return t ? t : undefined;
796
+ };
797
+
798
+ /** Notes template optionals: blank means absent. */
799
+ const optText = (v) => {
800
+ if (v === undefined || v === null || String(v).trim() === '') return undefined;
801
+ return String(v).trim();
802
+ };
803
+
804
+ const countOf = (v) => (Array.isArray(v) ? v.length : v);
805
+
806
+ function parseNotesAppendArgs(tokens) {
807
+ const t = {};
808
+ const sources = [];
809
+ const positionals = [];
810
+ for (let i = 0; i < tokens.length; i++) {
811
+ const tok = tokens[i];
812
+ if (tok === '--problem' || tok === '--tried' || tok === '--worked' || tok === '--failed' || tok === '--next-time' || tok === '--run-id') t[tok.slice(2)] = tokens[++i];
813
+ else if (tok === '--source') sources.push(tokens[++i]);
814
+ else positionals.push(tok);
815
+ }
816
+ if (positionals.length > 1) throw usageError(`Unexpected ${JSON.stringify(positionals[1])}. Usage: takibi notes append [--problem <text>] [--tried …] [--worked …] [--failed …] [--next-time …] [--source <id>]…`);
817
+ if (positionals.length === 1) {
818
+ 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>]…`);
819
+ if (t.problem !== undefined) throw usageError('Pass the problem once: a bare positional or --problem, not both.');
820
+ t.problem = positionals[0];
821
+ }
822
+ const problem = optText(t.problem);
823
+ if (!problem) throw usageError('notes append needs a problem: takibi notes append --problem "..." (a bare positional works too).');
824
+ const sourceIds = sources.flatMap((s) => String(s).split(',')).map((s) => s.trim()).filter(Boolean);
825
+ const runId = optText(t['run-id']);
826
+ if (t['run-id'] !== undefined && !runId) throw usageError('--run-id needs a non-empty value.');
827
+ if (runId && runId.length > 200) throw usageError('--run-id must be 200 characters or fewer.');
828
+ return { problem, tried: optText(t.tried), worked: optText(t.worked), failed: optText(t.failed), nextTime: optText(t['next-time']), sourceIds, runId };
829
+ }
830
+
831
+ function parseNotesSearchArgs(tokens) {
832
+ let q = null;
833
+ const positionals = [];
834
+ for (let i = 0; i < tokens.length; i++) {
835
+ const tok = tokens[i];
836
+ if (tok === '-q' || tok === '--query' || tok === '--q') q = tokens[++i];
837
+ else if (tok === '-k') throw usageError('notes search always returns the top 2 — -k does not apply.');
838
+ else positionals.push(tok);
839
+ }
840
+ if (q === null || q === undefined || q === '') {
841
+ if (positionals.length === 0) throw usageError('Pass the query: takibi notes search -q "..." (a bare positional works too).');
842
+ q = positionals.shift();
843
+ }
844
+ if (positionals.length > 0) throw usageError(`Unexpected ${JSON.stringify(positionals[0])}. Pass the query once.`);
845
+ if (!String(q).trim() || String(q).startsWith('-')) throw usageError('The query needs at least one non-space character.');
846
+ return String(q);
847
+ }
848
+
849
+ function renderNotesSearch(data, q) {
850
+ const results = Array.isArray(data?.results) ? data.results : [];
851
+ if (!results.length) out(`No notes for ${JSON.stringify(q)}.`);
852
+ results.forEach((r, i) => {
853
+ if (typeof r === 'string') {
854
+ out(`[${i + 1}] ${r}`);
855
+ return;
856
+ }
857
+ out(`[${i + 1}] ${r?.snippet ?? JSON.stringify(r)}`);
858
+ if (r && typeof r === 'object') {
859
+ const meta = [`note ${r.noteId ?? '?'}`];
860
+ if (r.score !== undefined && r.score !== null) meta.push(`score ${r.score}`);
861
+ if (r.kept) meta.push('kept');
862
+ if (r.projectTag) meta.push(`tag ${r.projectTag}`);
863
+ if (r.createdAt) meta.push(String(r.createdAt));
864
+ out(` ${meta.join(' · ')}`);
865
+ }
866
+ });
867
+ const bits = [];
868
+ if (data?.lane) bits.push(`lane: ${data.lane}`);
869
+ if (data?.cleanedLines !== undefined && data?.cleanedLines !== null) {
870
+ const n = data.cleanedLines;
871
+ bits.push(`${n} cleaned line${n === 1 ? '' : 's'}`);
872
+ }
873
+ if (data?.tokens !== undefined && data?.tokens !== null) bits.push(`${data.tokens} tokens`);
874
+ bits.push('untrusted agent notes — never canon');
875
+ err(`(${bits.join(' · ')})`);
876
+ }
877
+
878
+ async function cmdNotes(tokens, globals) {
879
+ if (globals.folder) throw usageError('--folder does not apply to notes: notes scope by project tag, not folder.');
880
+ const [sub, ...rest] = tokens;
881
+ if (!sub) throw usageError(`Usage: takibi notes <${NOTE_SUBS.join('|')}> …`);
882
+ const tag = projectTagFrom(globals.project);
883
+ if (sub === 'append') {
884
+ const a = parseNotesAppendArgs(rest);
885
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
886
+ const data = await api('POST', '/v1/notes', {
887
+ ...ctx,
888
+ body: {
889
+ projectTag: tag,
890
+ runId: a.runId,
891
+ template: {
892
+ problem: a.problem,
893
+ tried: a.tried,
894
+ worked: a.worked,
895
+ failed: a.failed,
896
+ next_time: a.nextTime,
897
+ source_ids_used: a.sourceIds.length ? a.sourceIds : undefined,
898
+ },
899
+ },
900
+ label: 'notes append',
901
+ });
902
+ if (ctx.json) {
903
+ out(JSON.stringify(data, null, 2));
904
+ return;
905
+ }
906
+ const n = data?.note ?? data ?? {};
907
+ if (n && typeof n === 'object' && n.id) {
908
+ out(`saved note ${n.id}${n.version !== undefined && n.version !== null ? ` · v${n.version}` : ''}${data?.duplicate ? ' (duplicate — already stored)' : ''}`);
909
+ if (Array.isArray(data?.redacted) && data.redacted.length) err(`(redacted: ${data.redacted.join(', ')})`);
910
+ if (data?.quarantined) err(`(quarantined: contradiction stub — out of retrieval until \`notes keep\` reverses it)`);
911
+ if (data?.disputeId) err(`(contested into dispute ${data.disputeId} — out of retrieval until cleared)`);
912
+ } else {
913
+ out(JSON.stringify(data));
914
+ }
915
+ return;
916
+ }
917
+ if (sub === 'list') {
918
+ if (rest.length > 1 || (rest.length === 1 && rest[0] !== '--all')) {
919
+ throw usageError('Usage: takibi notes list [--all]');
920
+ }
921
+ const all = rest[0] === '--all';
922
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
923
+ const data = await api('GET', '/v1/notes/triage', { ...ctx, label: 'notes list' });
924
+ const items = (all ? data?.inventory : data?.queue) ?? [];
925
+ const matching = tag ? items.filter((item) => item?.note?.projectTag === tag) : items;
926
+ if (ctx.json) {
927
+ out(JSON.stringify({ notes: matching, view: all ? 'inventory' : 'queue', projectTag: tag ?? null }, null, 2));
928
+ return;
929
+ }
930
+ if (!matching.length) out(`No ${all ? 'inventory' : 'review'} notes${tag ? ` for tag ${tag}` : ''}.`);
931
+ for (const item of matching) {
932
+ const n = item.note ?? {};
933
+ out(`${n.id ?? '?'} · v${n.version ?? '?'} · ${n.status ?? '?'}${n.kept ? ' · kept' : ''}${n.projectTag ? ` · ${n.projectTag}` : ''}`);
934
+ out(` ${n.template?.problem ?? '(no problem)'}`);
935
+ if (item.gate !== 'skip' && item.why) out(` ${item.why}`);
936
+ }
937
+ err(`(${matching.length} ${all ? 'inventory' : 'review'} note${matching.length === 1 ? '' : 's'}${all ? '' : ' · use --all for notes on hold and current versions'})`);
938
+ return;
939
+ }
940
+ if (sub === 'search') {
941
+ const q = parseNotesSearchArgs(rest);
942
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
943
+ const data = await api('GET', '/v1/notes/search', {
944
+ ...ctx,
945
+ query: qparams([
946
+ ['q', q],
947
+ ['projectTag', tag],
948
+ ]),
949
+ label: 'notes search',
950
+ });
951
+ if (ctx.json) out(JSON.stringify(data, null, 2));
952
+ else renderNotesSearch(data, q);
953
+ return;
954
+ }
955
+ if (sub === 'export') {
956
+ let since;
957
+ const noteIds = [];
958
+ for (let i = 0; i < rest.length; i++) {
959
+ if (rest[i] === '--since') since = rest[++i];
960
+ else if (rest[i] === '--note') noteIds.push(rest[++i]);
961
+ else throw usageError(`Unexpected ${JSON.stringify(rest[i])}. Usage: takibi notes export [--since <ts> | --note <uuid> …]`);
962
+ }
963
+ const sinceText = optText(since);
964
+ if (since !== undefined && !sinceText) throw usageError('--since needs a timestamp.');
965
+ if (sinceText !== undefined && Number.isNaN(Date.parse(sinceText))) {
966
+ throw usageError(`That since timestamp is not a valid date: ${JSON.stringify(sinceText)}. (export --since takes a parseable date.)`);
967
+ }
968
+ if (sinceText && noteIds.length) throw usageError('Use --since or --note, not both.');
969
+ 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.');
970
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
971
+ const data = await api('POST', '/v1/notes/drafts', {
972
+ ...ctx,
973
+ body: { projectTag: tag, since: sinceText, noteIds: noteIds.length ? noteIds : undefined },
974
+ label: 'notes export',
975
+ });
976
+ if (ctx.json) {
977
+ out(JSON.stringify(data, null, 2));
978
+ return;
979
+ }
980
+ if (data?.draft) out(data.draft);
981
+ else out('No notes to export.');
982
+ const bits = [];
983
+ const included = countOf(data?.included);
984
+ if (included !== undefined && included !== null) bits.push(`included ${included}`);
985
+ const conflicts = countOf(data?.conflicts);
986
+ if (conflicts !== undefined && conflicts !== null) bits.push(`conflicts ${conflicts}`);
987
+ const ex = data?.excluded;
988
+ if (ex && (ex.quarantined || ex.contested || ex.expired)) {
989
+ bits.push(`excluded ${ex.quarantined ?? 0} quarantined · ${ex.contested ?? 0} contested · ${ex.expired ?? 0} expired`);
990
+ }
991
+ if (data?.deltaSince) bits.push(`since ${data.deltaSince}`);
992
+ if (bits.length) err(`(${bits.join(' · ')})`);
993
+ return;
994
+ }
995
+ if (sub === 'keep' || sub === 'remove') {
996
+ const [id, ver, extra] = rest;
997
+ if (!id || !ver || extra || String(id).startsWith('-')) throw usageError(`Usage: takibi notes ${sub} <id> <expected-version>`);
998
+ const n = Number(ver);
999
+ if (!Number.isInteger(n) || n < 1) throw usageError(`<expected-version> takes a positive integer (the note's current version), not ${JSON.stringify(ver)}.`);
1000
+ const expectedVersion = n;
1001
+ const ctx = await ctxFor(globals, { needKey: true, resolveProject: false });
1002
+ const data = await api('POST', `/v1/notes/${id}/${sub}`, { ...ctx, body: { expectedVersion }, label: `notes ${sub}` });
1003
+ if (ctx.json) {
1004
+ out(JSON.stringify(data, null, 2));
1005
+ return;
1006
+ }
1007
+ const v = data?.note?.version ?? data?.version;
1008
+ out(`${sub === 'keep' ? 'kept' : 'removed'} note ${id}${v !== undefined && v !== null ? ` · v${v}` : ''}`);
1009
+ return;
1010
+ }
1011
+ throw usageError(`Unknown notes command ${JSON.stringify(sub)}. Use ${NOTE_SUBS.join(' | ')}.`);
1012
+ }
1013
+
753
1014
  async function cmdProjects(tokens, globals) {
754
1015
  const { map, warnings, path } = loadProjectMap();
755
1016
  for (const w of warnings) err(`warning: ${w}`);
@@ -824,6 +1085,132 @@ async function cmdVersion(globals) {
824
1085
  else out(`${data.service ?? 'takibi-api'} ${data.version ?? '?'} · jev ${data.jev ?? '?'} · via ${baseUrl}`);
825
1086
  }
826
1087
 
1088
+ const SKILL_NAME = 'takibi-use';
1089
+ const SKILL_FILE = 'SKILL.md';
1090
+ const DOCS_AGENTS_URL = 'https://takibibase.com/docs/agents';
1091
+
1092
+ /** Test hook: redirect ~ for skill installs only (key resolution always uses the real home). */
1093
+ const skillHome = () => process.env.TAKIBI_HOME || homedir();
1094
+
1095
+ /** The skill text travelling next to the CLI in the published package; null in the lone-file copy. */
1096
+ function skillText() {
1097
+ try {
1098
+ return readFileSync(new URL('./SKILL.md', import.meta.url), 'utf8');
1099
+ } catch {
1100
+ return null;
1101
+ }
1102
+ }
1103
+
1104
+ const SKILL_TARGETS = [
1105
+ { flag: '--agents', harness: '.agents', dir: '.agents/skills' },
1106
+ { flag: '--claude', harness: '.claude', dir: '.claude/skills' },
1107
+ { flag: '--codex', harness: '.codex', dir: '.codex/skills' },
1108
+ ];
1109
+
1110
+ const SKILL_USAGE = 'Usage: takibi skill [--install [--force] [--agents] [--claude] [--codex] [--dir <skills-root>]]';
1111
+
1112
+ /** First-run skill nudge. Stderr only (stdout stays clean), best-effort,
1113
+ * never fails the command. Skipped for --json, in CI, with
1114
+ * TAKIBI_NO_SKILL_NUDGE=1, when any standard skills dir already holds the
1115
+ * skill, and within a day of the last nudge. */
1116
+ function maybeNudgeSkill({ json = false } = {}) {
1117
+ try {
1118
+ if (json || process.env.CI || process.env.TAKIBI_NO_SKILL_NUDGE === '1') return;
1119
+ const home = skillHome();
1120
+ if (SKILL_TARGETS.some((t) => existsSync(join(home, t.dir, SKILL_NAME, SKILL_FILE)))) return;
1121
+ const path = join(home, '.takibi', 'skill-nudge');
1122
+ let lastAt = 0;
1123
+ try {
1124
+ const cached = JSON.parse(readFileSync(path, 'utf8'));
1125
+ if (typeof cached?.at === 'number') lastAt = cached.at;
1126
+ } catch {
1127
+ // First run: no marker yet.
1128
+ }
1129
+ if (Date.now() - lastAt < UPDATE_CHECK_TTL_MS) return;
1130
+ try {
1131
+ mkdirSync(join(home, '.takibi'), { recursive: true });
1132
+ writeFileSync(path, JSON.stringify({ at: Date.now() }));
1133
+ } catch {
1134
+ // Read-only home: nudge once per process run instead of daily.
1135
+ }
1136
+ err('takibi: no takibi-use skill installed for your agents.');
1137
+ err('hint: `takibi skill --install` puts it where assistants look.');
1138
+ } catch {
1139
+ // Nudges never fail commands.
1140
+ }
1141
+ }
1142
+
1143
+ function cmdSkill(tokens, globals) {
1144
+ let install = false;
1145
+ let force = false;
1146
+ let customRoot = null;
1147
+ const picked = new Set();
1148
+ for (let i = 0; i < tokens.length; i++) {
1149
+ const t = tokens[i];
1150
+ if (t === '--install') install = true;
1151
+ else if (t === '--force') force = true;
1152
+ else if (t === '--dir') {
1153
+ customRoot = tokens[++i];
1154
+ if (!customRoot) throw usageError('`--dir` needs a skills root. ' + SKILL_USAGE);
1155
+ } else if (t === '--agents' || t === '--claude' || t === '--codex') picked.add(t);
1156
+ else throw usageError(`Unexpected ${JSON.stringify(t)}. ${SKILL_USAGE}`);
1157
+ }
1158
+ if (!install && (force || picked.size > 0 || customRoot)) {
1159
+ throw usageError(`Those flags need \`--install\`. ${SKILL_USAGE}`);
1160
+ }
1161
+ const text = skillText();
1162
+ if (!install) {
1163
+ if (text !== null) {
1164
+ out(text.trimEnd());
1165
+ return;
1166
+ }
1167
+ out(`# ${SKILL_NAME} — not bundled in this single-file copy\n\nInstall the package and retry:\n npm i -g takibibase && takibi skill --install\nDocs: ${DOCS_AGENTS_URL}`);
1168
+ return;
1169
+ }
1170
+ if (text === null) {
1171
+ throw new CliError('This single-file copy carries no SKILL.md to install.', {
1172
+ hint: `Install the package first (\`npm i -g takibibase\`), then \`takibi skill --install\`. Docs: ${DOCS_AGENTS_URL}.`,
1173
+ });
1174
+ }
1175
+ const home = skillHome();
1176
+ let targets;
1177
+ if (customRoot && picked.size === 0) targets = [];
1178
+ else if (picked.size === 0) {
1179
+ const detected = SKILL_TARGETS.filter((t) => existsSync(join(home, t.harness)));
1180
+ targets = detected.length > 0 ? detected : [SKILL_TARGETS[0]];
1181
+ } else {
1182
+ targets = SKILL_TARGETS.filter((t) => picked.has(t.flag));
1183
+ }
1184
+ if (customRoot) targets = [...targets, { flag: '--dir', custom: true }];
1185
+ const results = [];
1186
+ for (const t of targets) {
1187
+ const dir = t.custom ? join(customRoot, SKILL_NAME) : join(home, t.dir, SKILL_NAME);
1188
+ const dest = join(dir, SKILL_FILE);
1189
+ if (existsSync(dest)) {
1190
+ let same = false;
1191
+ try {
1192
+ same = readFileSync(dest, 'utf8') === text;
1193
+ } catch {
1194
+ same = false;
1195
+ }
1196
+ if (same) {
1197
+ results.push({ path: dest, status: 'already-installed' });
1198
+ continue;
1199
+ }
1200
+ if (!force) {
1201
+ throw new CliError(`${dest} already holds a different skill file.`, {
1202
+ hint: 'Re-run with --force to overwrite it.',
1203
+ });
1204
+ }
1205
+ }
1206
+ mkdirSync(dir, { recursive: true });
1207
+ writeFileSync(dest, text);
1208
+ results.push({ path: dest, status: 'installed' });
1209
+ }
1210
+ if (globals.json) out(JSON.stringify({ skill: SKILL_NAME, results }, null, 2));
1211
+ else for (const r of results) out(`${r.status === 'installed' ? 'installed' : 'already installed'} ${r.path}`);
1212
+ }
1213
+
827
1214
  async function main(argv) {
828
1215
  const { globals, rest } = parseArgv(argv);
829
1216
  if (globals.version) {
@@ -837,12 +1224,15 @@ async function main(argv) {
837
1224
  out(HELP);
838
1225
  return;
839
1226
  }
1227
+ if (cmd !== 'skill') maybeNudgeSkill({ json: globals.json });
840
1228
  if (cmd === 'ask') return cmdAsk(tokens, globals);
841
1229
  if (cmd === 'search') return cmdSearch(tokens, globals);
842
1230
  if (cmd === 'tasks' || cmd === 'task') return cmdTasks(tokens, globals);
843
1231
  if (cmd === 'doc' || cmd === 'docs') return cmdDoc(tokens, globals);
1232
+ if (cmd === 'notes' || cmd === 'note') return cmdNotes(tokens, globals);
844
1233
  if (cmd === 'projects') return cmdProjects(tokens, globals);
845
1234
  if (cmd === 'version') return cmdVersion(globals);
1235
+ if (cmd === 'skill') return cmdSkill(tokens, globals);
846
1236
  throw usageError(`Unknown command ${JSON.stringify(cmd)}. See \`takibi --help\`.`);
847
1237
  }
848
1238