create-claude-cabinet 0.21.0 → 0.22.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
@@ -178,6 +178,7 @@ source code.
178
178
  ├── skills/ # orient, debrief, plan, execute, audit, etc.
179
179
  │ └── cabinet-*/ # 31 cabinet member definitions
180
180
  ├── cabinet/ # committees, lifecycle, composition patterns
181
+ │ # (incl. pib-db-access.md, pib-db-triggers.md)
181
182
  ├── briefing/ # project briefing templates
182
183
  ├── hooks/ # git guardrails, telemetry
183
184
  ├── rules/ # enforcement pipeline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -16,3 +16,23 @@ How this project tracks planned work. Skills that manage work items
16
16
  *How to create, update, and close items.*
17
17
  *Example: `POST /api/tasks` with JSON body*
18
18
  *Example: `gh issue create --title "..." --body "..."`*
19
+
20
+ ## Deferring work
21
+
22
+ *When to defer plainly vs. attach a trigger condition.*
23
+
24
+ Plain deferral (`status='deferred'` on actions, `status='someday'`
25
+ on projects) is for work that's blocked by something else you're
26
+ already tracking. It sits quietly until you unblock it yourself.
27
+
28
+ **Trigger-gated deferral** is for work waiting on an identifiable
29
+ external condition — a dependency landing, a stack decision
30
+ finalizing, a referenced file appearing. Every session, orient
31
+ re-evaluates each trigger against the current session's context
32
+ and surfaces items whose conditions have fired. Use
33
+ `pib_defer_with_trigger` (or `defer-with-trigger` CLI). See
34
+ `cabinet/pib-db-triggers.md` for the full convention.
35
+
36
+ Rule of thumb: if you can write one sentence describing what would
37
+ have to be true for this item to matter again, that sentence is
38
+ the trigger.
@@ -25,18 +25,32 @@ Check: are pib_* MCP tools available?
25
25
 
26
26
  ## Available Operations
27
27
 
28
- | MCP Tool | CLI Equivalent | Description |
29
- | ---------------------- | --------------------------------------- | ------------------------------ |
30
- | pib_create_project | create-project "name" --area X | Create a project |
31
- | pib_list_projects | list-projects | List active projects |
32
- | pib_create_action | create-action "text" --notes X | Create an action (work item) |
33
- | pib_list_actions | list-actions [--status X] | List actions |
34
- | pib_update_action | update-action fid --status X | Update action fields |
35
- | pib_complete_action | complete-action fid | Mark action done |
36
- | pib_ingest_findings | ingest-findings run-dir | Ingest audit findings |
37
- | pib_triage | triage finding-id status [notes] | Triage a finding |
38
- | pib_triage_history | triage-history | Get suppression list |
39
- | pib_query | query "SQL" | Run arbitrary SQL |
28
+ | MCP Tool | CLI Equivalent | Description |
29
+ | -------------------------- | ----------------------------------------------- | ------------------------------ |
30
+ | pib_create_project | create-project "name" --area X | Create a project |
31
+ | pib_list_projects | list-projects | List active projects |
32
+ | pib_create_action | create-action "text" --notes X | Create an action (work item) |
33
+ | pib_list_actions | list-actions [--status X] | List actions |
34
+ | pib_update_action | update-action fid --status X | Update action fields |
35
+ | pib_complete_action | complete-action fid | Mark action done |
36
+ | pib_defer_with_trigger | defer-with-trigger fid --trigger "<text>" | Defer with a return condition |
37
+ | pib_list_triggered | list-triggered [--include-done] | List items waiting on triggers |
38
+ | pib_mark_trigger_checked | mark-trigger-checked fid --result <value> | Record a trigger evaluation |
39
+ | pib_ingest_findings | ingest-findings run-dir | Ingest audit findings |
40
+ | pib_triage | triage finding-id status [notes] | Triage a finding |
41
+ | pib_triage_history | triage-history | Get suppression list |
42
+ | pib_query | query "SQL" | Run arbitrary SQL |
43
+
44
+ ## Deferred triggers
45
+
46
+ When deferring an item that waits on a specific identifiable
47
+ condition — a dependency landing, a stack decision finalizing, a
48
+ referenced file appearing — use `pib_defer_with_trigger` instead of
49
+ plain `status='deferred'`. Orient re-evaluates each trigger every
50
+ session and surfaces items whose conditions have become true. See
51
+ [pib-db-triggers.md](pib-db-triggers.md) for the full convention:
52
+ result vocabulary, cascade semantics for projects, migration
53
+ guarantees, and known limitations.
40
54
 
41
55
  ## Surface Area Validation
42
56
 
@@ -0,0 +1,192 @@
1
+ # pib-db Deferred Triggers
2
+
3
+ How to defer work items with structured return conditions and how
4
+ orient re-evaluates them each session.
5
+
6
+ ## Purpose
7
+
8
+ Big ideas tend to rot. Someone raises an infrastructure proposal, a
9
+ platform-auth gap, or a stack-choice pivot — the idea is real, but it
10
+ cannot be acted on today. Without structure, it lands in `feedback/`
11
+ or a `status='deferred'` row and sits there forever. Nobody re-reads
12
+ `feedback/`. Nobody scans deferred items.
13
+
14
+ Deferred triggers turn those items into structurally-encoded work:
15
+ each one carries a natural-language condition describing what would
16
+ have to change to reactivate it. Orient surfaces them every session
17
+ and evaluates the condition against the current session's context.
18
+ The item sits in the queue, not on a reminder list.
19
+
20
+ ## Schema
21
+
22
+ - `actions.trigger_condition TEXT` — natural-language predicate.
23
+ `NULL` means the action is not trigger-gated. Non-null means the
24
+ action is waiting on a specific condition.
25
+ - `projects.trigger_condition TEXT` — same semantics for projects.
26
+ - `trigger_checks` table — append-only history of evaluations.
27
+ Fields: `id`, `target_table`, `target_fid`, `checked_at`, `result`,
28
+ `notes`. No foreign key back to `actions`/`projects`, so history
29
+ is preserved even if the item is deleted.
30
+
31
+ ## API
32
+
33
+ Three operations. Prefer MCP tools; fall back to CLI.
34
+
35
+ ### `pib_defer_with_trigger(fid, triggerCondition, cascade?)`
36
+
37
+ Use when deferring with a specific return condition. Prefer over
38
+ `pib_update_action` with `status='deferred'` when the deferral is
39
+ conditional on something identifiable.
40
+
41
+ - Action: sets `status='deferred'` and writes `trigger_condition`.
42
+ - Project: sets `status='someday'` and writes `trigger_condition`.
43
+ - If a project has open child actions, `cascade: true` is required.
44
+ See [Cascade semantics](#cascade-semantics-for-projects).
45
+
46
+ CLI:
47
+ ```bash
48
+ node scripts/pib-db.mjs defer-with-trigger <fid> --trigger "<text>" [--cascade]
49
+ ```
50
+
51
+ ### `pib_list_triggered({includeDone?})`
52
+
53
+ Returns items with `trigger_condition` set. By default excludes
54
+ completed items; pass `includeDone: true` to include them.
55
+
56
+ CLI:
57
+ ```bash
58
+ node scripts/pib-db.mjs list-triggered [--include-done]
59
+ ```
60
+
61
+ ### `pib_mark_trigger_checked(fid, result, notes?)`
62
+
63
+ Records an evaluation outcome into `trigger_checks`. Does not change
64
+ the item's `status` or `trigger_condition` — reactivation is a
65
+ separate explicit action taken by the user or orient.
66
+
67
+ CLI:
68
+ ```bash
69
+ node scripts/pib-db.mjs mark-trigger-checked <fid> --result <value> [--notes "<text>"]
70
+ ```
71
+
72
+ ## Result vocabulary
73
+
74
+ Four values. The CHECK constraint on `trigger_checks.result` rejects
75
+ anything else.
76
+
77
+ - `triggered` — the condition is now met; the item is ready to
78
+ reactivate. Surface in Attention Items. Do not auto-reopen —
79
+ leave the decision to the user.
80
+ - `still-waiting` — condition checked, not yet met. Normal idle state.
81
+ - `needs-info` — cannot evaluate from current session context. Flag
82
+ for the user; do not guess `triggered`. When in doubt, pick this.
83
+ - `condition-obsolete` — the condition no longer makes sense. Example:
84
+ "when we add Postgres support" but Postgres was dropped from the
85
+ roadmap. Triggers a review of whether to drop the item entirely or
86
+ rewrite the trigger.
87
+
88
+ ## When to use vs plain deferred
89
+
90
+ | Situation | Mechanism |
91
+ |---|---|
92
+ | Blocked by something else on your plate right now | `status='deferred'` (no trigger) |
93
+ | Waiting for a specific external condition, needs monitoring | `trigger_condition` |
94
+ | Vague future intent, no specific signal | project `status='someday'` (no trigger) |
95
+ | "Someday, specifically when X happens" | project `trigger_condition` |
96
+
97
+ Rule of thumb: if you can write one sentence describing what would
98
+ have to be true for the item to matter again, that sentence is the
99
+ trigger. If you can't, it's plain deferred/someday.
100
+
101
+ ## Cascade semantics for projects
102
+
103
+ Deferring a project with open child actions requires `cascade: true`.
104
+ The cascade:
105
+
106
+ 1. Sets each open child action to `status='deferred'`.
107
+ 2. Appends an inheritance line to each child's notes:
108
+ `_Deferred alongside parent prj:abc (trigger: <text>)_`
109
+ 3. Does NOT write `trigger_condition` to children. The parent's
110
+ trigger is the single source of truth.
111
+
112
+ When the parent reopens (user flips status back to `active`, or
113
+ orient surfaces it as `triggered` and the user accepts):
114
+
115
+ - Children remain `deferred`.
116
+ - The user decides which children to reopen. Reopening everything
117
+ automatically often resurrects stale subgoals that the deferral
118
+ period should have retired.
119
+
120
+ ## Orient integration
121
+
122
+ The `deferred-check.md` phase fires after `work-scan.md`, before
123
+ `auto-maintenance.md`. Every session:
124
+
125
+ 1. Orient calls `pib_list_triggered` (or CLI fallback).
126
+ 2. For each item, evaluates the trigger text against the current
127
+ session's context (new dependencies installed? a referenced file
128
+ now exists? a stack decision was finalized?).
129
+ 3. Records each evaluation via `pib_mark_trigger_checked`. Prefer
130
+ `needs-info` over guessing `triggered`.
131
+ 4. Items that evaluate to `triggered` appear in the briefing's
132
+ **Attention Items** section. Orient does not auto-reopen — the
133
+ user decides.
134
+
135
+ Cost control: cap the phase at 30 seconds. If more than 10 items are
136
+ triggered, evaluate only the 5 least-recently-checked.
137
+
138
+ ## Migration guarantees
139
+
140
+ - **Additive-only.** New columns and new tables are allowed through
141
+ the `migrate()` path. Destructive changes (dropping columns,
142
+ changing types, removing constraints) require a new versioned
143
+ migration with explicit approval — they do not belong in the
144
+ default path.
145
+ - **Gated on every startup.** Migrations run on every MCP startup
146
+ and every CLI invocation, gated by `PRAGMA user_version`. Only
147
+ pending migrations apply; the path is idempotent.
148
+ - **Per-worktree.** Each worktree's local `pib.db` migrates
149
+ independently. Migrations run against whichever DB the process
150
+ opens. This is normal SQLite worktree behavior.
151
+ - **Schema parity invariant.** Any new `trigger_*` column added to
152
+ `actions` MUST also be added to `projects` in the same migration.
153
+ The two tables mirror each other for trigger semantics.
154
+
155
+ ## Index placement rule
156
+
157
+ If any future index references a column added through the `migrate()`
158
+ path, the index must also be created via `migrate()`, NOT in the
159
+ `SCHEMA` block's `CREATE INDEX IF NOT EXISTS` stanza.
160
+
161
+ Reason: the `SCHEMA` block runs before ALTER TABLE migrations.
162
+ Indexing a yet-to-exist column errors on existing databases. The
163
+ rule is mechanical — add the column in migrate(), add the index in
164
+ migrate(), in that order.
165
+
166
+ ## CLI equivalents
167
+
168
+ ```bash
169
+ node scripts/pib-db.mjs defer-with-trigger <fid> --trigger "<text>" [--cascade]
170
+ node scripts/pib-db.mjs list-triggered [--include-done]
171
+ node scripts/pib-db.mjs mark-trigger-checked <fid> --result <value> [--notes "<text>"]
172
+ ```
173
+
174
+ All three map 1:1 to the library functions in `pib-db-lib.mjs`. Use
175
+ MCP tools when available; the CLI is the fallback for non-MCP
176
+ contexts.
177
+
178
+ ## Known limitations
179
+
180
+ - **Trigger evaluation is LLM-semantic, not deterministic.** Two
181
+ sessions may evaluate the same trigger differently. The
182
+ `trigger_checks` history is the audit trail — read it to see how
183
+ past sessions interpreted the same condition.
184
+ - **No forcing function between sessions.** If a trigger sits
185
+ unchecked across many sessions (no orient runs), nothing forces
186
+ evaluation. Worst case: a `triggered` item sits unnoticed for a
187
+ week. Acceptable soft limit; the alternative (scheduled jobs) adds
188
+ infrastructure we don't want.
189
+ - **Concurrent migration race, theoretical.** SQLite handles locking,
190
+ but a race on `PRAGMA user_version` is theoretically possible if
191
+ two MCP processes open the DB at the same instant. In practice,
192
+ MCP servers are per-session and the window is microseconds.
@@ -23,24 +23,68 @@ function today() {
23
23
  }
24
24
 
25
25
  // ---------------------------------------------------------------------------
26
- // Initcreate tables from schema
26
+ // Migrationsgated by PRAGMA user_version
27
+ // ---------------------------------------------------------------------------
28
+ // SCHEMA_VERSION history:
29
+ // 1 — added actions.status CHECK constraint
30
+ // 2 — added actions.tags
31
+ // 3 — added trigger_condition on actions/projects + trigger_checks history
32
+ // 4 — composite index on trigger_checks(target_fid, checked_at DESC) for listTriggered
33
+ export const SCHEMA_VERSION = 4;
34
+
35
+ // Each entry: { version, sql }. A single version may have multiple SQL
36
+ // statements (e.g. column add + index). Statements run in array order;
37
+ // each is wrapped in try/catch so re-running on a DB that already has
38
+ // the column/table is a no-op. The user_version pragma is the primary
39
+ // gate — try/catch is a safety net for pre-pragma DBs.
40
+ const MIGRATIONS = [
41
+ { version: 1, sql: "ALTER TABLE actions ADD COLUMN status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','in-progress','blocked','deferred','done'))" },
42
+ { version: 2, sql: "ALTER TABLE actions ADD COLUMN tags TEXT NOT NULL DEFAULT ''" },
43
+ { version: 3, sql: "ALTER TABLE actions ADD COLUMN trigger_condition TEXT" },
44
+ { version: 3, sql: "ALTER TABLE projects ADD COLUMN trigger_condition TEXT" },
45
+ { version: 3, sql: `CREATE TABLE IF NOT EXISTS trigger_checks (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ target_table TEXT NOT NULL CHECK(target_table IN ('actions','projects')),
48
+ target_fid TEXT NOT NULL,
49
+ checked_at TEXT NOT NULL,
50
+ result TEXT NOT NULL CHECK(result IN ('triggered','still-waiting','needs-info','condition-obsolete')),
51
+ notes TEXT
52
+ )` },
53
+ { version: 3, sql: "CREATE INDEX IF NOT EXISTS idx_trigger_checks_fid ON trigger_checks(target_fid)" },
54
+ { version: 4, sql: "CREATE INDEX IF NOT EXISTS idx_trigger_checks_target_time ON trigger_checks(target_fid, checked_at DESC)" },
55
+ ];
56
+
57
+ export function migrate(db) {
58
+ const current = db.pragma('user_version', { simple: true });
59
+ if (current >= SCHEMA_VERSION) return { from: current, to: current, applied: 0 };
60
+
61
+ // Wrap in a transaction so a real mid-migration failure (disk full,
62
+ // locked DB, constraint violation) rolls back user_version along with
63
+ // the partial DDL. Only swallow "already exists" errors from legacy
64
+ // pre-pragma DBs where columns may have been added before versioning.
65
+ const tx = db.transaction(() => {
66
+ let applied = 0;
67
+ for (const m of MIGRATIONS) {
68
+ if (m.version <= current) continue;
69
+ try { db.exec(m.sql); applied++; }
70
+ catch (e) {
71
+ if (!/already exists|duplicate column/i.test(e.message || '')) throw e;
72
+ }
73
+ }
74
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
75
+ return applied;
76
+ });
77
+ const applied = tx();
78
+ return { from: current, to: SCHEMA_VERSION, applied };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Init — create tables from schema, then migrate
27
83
  // ---------------------------------------------------------------------------
28
84
  export function init(db, { schemaPath }) {
29
85
  const schema = readFileSync(schemaPath, 'utf-8');
30
86
  db.exec(schema);
31
-
32
- // Migrate existing DBs — add columns that may not exist yet
33
- const migrations = [
34
- { table: 'actions', column: 'status', sql: "ALTER TABLE actions ADD COLUMN status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','in-progress','blocked','deferred','done'))" },
35
- { table: 'actions', column: 'tags', sql: "ALTER TABLE actions ADD COLUMN tags TEXT NOT NULL DEFAULT ''" },
36
- ];
37
- for (const m of migrations) {
38
- const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();
39
- if (!cols.some(c => c.name === m.column)) {
40
- try { db.exec(m.sql); } catch { /* column may already exist */ }
41
- }
42
- }
43
-
87
+ migrate(db);
44
88
  return { message: `Database initialized` };
45
89
  }
46
90
 
@@ -291,3 +335,132 @@ export function triageHistory(db) {
291
335
  deferredFingerprints: deferred.map(r => ({ 'cabinet-member': r.cabinet_member, title: r.title })),
292
336
  };
293
337
  }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Deferred triggers
341
+ // ---------------------------------------------------------------------------
342
+ // Items (actions or projects) with a trigger_condition are waiting on a
343
+ // specific condition. The orient skill re-evaluates them each session and
344
+ // records each check in trigger_checks (append-only history).
345
+
346
+ export const TRIGGER_RESULT_VOCABULARY = ['triggered', 'still-waiting', 'needs-info', 'condition-obsolete'];
347
+ export const FID_PATTERN = /^(act|prj):[a-f0-9]{8}$/;
348
+ const TRIGGER_CONDITION_MAX_LENGTH = 2000;
349
+
350
+ function validateFid(fid) {
351
+ if (!fid || typeof fid !== 'string') {
352
+ return { error: 'missing_fid', message: 'fid is required' };
353
+ }
354
+ if (!FID_PATTERN.test(fid)) {
355
+ return { error: 'invalid_fid_format', message: `fid must match ${FID_PATTERN}, got "${fid}"` };
356
+ }
357
+ return null;
358
+ }
359
+
360
+ function tableForFid(fid) {
361
+ return fid.startsWith('prj:') ? 'projects' : 'actions';
362
+ }
363
+
364
+ export function deferWithTrigger(db, { fid, triggerCondition, cascade = false } = {}) {
365
+ const fidError = validateFid(fid);
366
+ if (fidError) return { error: fidError };
367
+ if (!triggerCondition || typeof triggerCondition !== 'string' || triggerCondition.trim() === '') {
368
+ return { error: { error: 'missing_trigger_condition', message: 'triggerCondition must be a non-empty string' } };
369
+ }
370
+ if (triggerCondition.length > TRIGGER_CONDITION_MAX_LENGTH) {
371
+ return { error: { error: 'trigger_condition_too_long', message: `triggerCondition must be ≤${TRIGGER_CONDITION_MAX_LENGTH} chars, got ${triggerCondition.length}` } };
372
+ }
373
+
374
+ const table = tableForFid(fid);
375
+ const row = db.prepare(`SELECT status, ${table === 'actions' ? 'completed' : "'0' as completed"} FROM ${table} WHERE fid = ? AND deleted_at IS NULL`).get(fid);
376
+ if (!row) return { error: { error: 'not_found', message: `No ${table} row with fid ${fid}` } };
377
+ if (row.status === 'done' || row.completed === 1) {
378
+ return { error: { error: 'already_done', message: `${fid} is already done; cannot defer` } };
379
+ }
380
+
381
+ let cascaded = 0;
382
+ if (table === 'projects') {
383
+ // Children with their own trigger_condition already carry their own
384
+ // return condition; cascade leaves them alone so the parent's trigger
385
+ // doesn't overwrite their independent wait state.
386
+ const openChildren = db.prepare(`SELECT fid FROM actions WHERE project_fid = ? AND status NOT IN ('done','deferred') AND trigger_condition IS NULL AND deleted_at IS NULL`).all(fid);
387
+ if (openChildren.length > 0 && !cascade) {
388
+ return {
389
+ error: {
390
+ error: 'has_open_children',
391
+ message: `Project ${fid} has ${openChildren.length} open action(s) without their own trigger. Pass cascade: true to defer them alongside.`,
392
+ openChildren: openChildren.map(c => c.fid),
393
+ },
394
+ };
395
+ }
396
+ if (cascade) {
397
+ const appendNote = `\n\n_Deferred alongside parent ${fid} (trigger: ${triggerCondition})_`;
398
+ const stmt = db.prepare(`UPDATE actions SET status = 'deferred', notes = notes || ? WHERE fid = ?`);
399
+ for (const child of openChildren) stmt.run(appendNote, child.fid);
400
+ cascaded = openChildren.length;
401
+ }
402
+ }
403
+
404
+ const newStatus = table === 'projects' ? 'someday' : 'deferred';
405
+ db.prepare(`UPDATE ${table} SET status = ?, trigger_condition = ? WHERE fid = ?`).run(newStatus, triggerCondition, fid);
406
+
407
+ return { fid, table, triggerCondition, status: newStatus, cascaded, message: `Deferred ${fid} with trigger` };
408
+ }
409
+
410
+ export function listTriggered(db, { includeDone = false } = {}) {
411
+ const actionsWhere = includeDone
412
+ ? 'a.trigger_condition IS NOT NULL AND a.deleted_at IS NULL'
413
+ : "a.trigger_condition IS NOT NULL AND a.deleted_at IS NULL AND a.status != 'done' AND (a.completed IS NULL OR a.completed = 0)";
414
+ const projectsWhere = includeDone
415
+ ? 'p.trigger_condition IS NOT NULL AND p.deleted_at IS NULL'
416
+ : "p.trigger_condition IS NOT NULL AND p.deleted_at IS NULL AND p.status != 'done'";
417
+
418
+ const actions = db.prepare(`
419
+ SELECT a.fid, a.text, a.trigger_condition, a.status, p.name AS project_name,
420
+ (SELECT checked_at FROM trigger_checks WHERE target_fid = a.fid ORDER BY checked_at DESC LIMIT 1) AS last_checked,
421
+ (SELECT result FROM trigger_checks WHERE target_fid = a.fid ORDER BY checked_at DESC LIMIT 1) AS last_result
422
+ FROM actions a
423
+ LEFT JOIN projects p ON a.project_fid = p.fid
424
+ WHERE ${actionsWhere}
425
+ ORDER BY last_checked IS NOT NULL, last_checked ASC
426
+ `).all();
427
+
428
+ const projects = db.prepare(`
429
+ SELECT p.fid, p.name, p.trigger_condition, p.status,
430
+ (SELECT checked_at FROM trigger_checks WHERE target_fid = p.fid ORDER BY checked_at DESC LIMIT 1) AS last_checked,
431
+ (SELECT result FROM trigger_checks WHERE target_fid = p.fid ORDER BY checked_at DESC LIMIT 1) AS last_result
432
+ FROM projects p
433
+ WHERE ${projectsWhere}
434
+ ORDER BY last_checked IS NOT NULL, last_checked ASC
435
+ `).all();
436
+
437
+ return { actions, projects };
438
+ }
439
+
440
+ export function markTriggerChecked(db, { fid, result, notes } = {}) {
441
+ const fidError = validateFid(fid);
442
+ if (fidError) return { error: fidError };
443
+ if (!TRIGGER_RESULT_VOCABULARY.includes(result)) {
444
+ return {
445
+ error: {
446
+ error: 'invalid_result',
447
+ message: `result must be one of: ${TRIGGER_RESULT_VOCABULARY.join(', ')}`,
448
+ got: result,
449
+ },
450
+ };
451
+ }
452
+ const table = tableForFid(fid);
453
+ const row = db.prepare(`SELECT status, ${table === 'actions' ? 'completed' : "'0' as completed"} FROM ${table} WHERE fid = ? AND deleted_at IS NULL`).get(fid);
454
+ if (!row) return { error: { error: 'not_found', message: `No ${table} row with fid ${fid}` } };
455
+ if (row.status === 'done' || row.completed === 1) {
456
+ return { error: { error: 'already_done', message: `${fid} is already done; recording a trigger check on a completed item is not allowed` } };
457
+ }
458
+
459
+ const checkedAt = new Date().toISOString();
460
+ db.prepare(`
461
+ INSERT INTO trigger_checks (target_table, target_fid, checked_at, result, notes)
462
+ VALUES (?, ?, ?, ?, ?)
463
+ `).run(table, fid, checkedAt, result, notes || null);
464
+
465
+ return { fid, checkedAt, result, message: `Recorded trigger check for ${fid}: ${result}` };
466
+ }
@@ -71,6 +71,12 @@ function getDb() {
71
71
  db = new Database(DB_PATH);
72
72
  db.pragma('journal_mode = WAL');
73
73
  db.pragma('foreign_keys = ON');
74
+
75
+ // Ensure schema exists, then apply pending migrations. Both are cheap:
76
+ // the schema file is idempotent (CREATE TABLE IF NOT EXISTS) and migrate()
77
+ // short-circuits when PRAGMA user_version is already current.
78
+ const schemaPath = join(__dirname, 'pib-db-schema.sql');
79
+ lib.init(db, { schemaPath });
74
80
  return db;
75
81
  }
76
82
 
@@ -191,6 +197,46 @@ const TOOLS = [
191
197
  description: 'Get the triage suppression list (rejected and deferred findings).',
192
198
  inputSchema: { type: 'object', properties: {} },
193
199
  },
200
+ {
201
+ name: 'pib_defer_with_trigger',
202
+ description: 'Defer an action or project with a free-text trigger condition describing what would reactivate it. Orient re-evaluates triggers each session. Use this INSTEAD of pib_update_action with status=deferred when the deferral is waiting for a specific condition.',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ fid: { type: 'string', description: 'Action or project fid (e.g., act:abc12345 or prj:abc12345)' },
207
+ triggerCondition: { type: 'string', description: 'Natural-language predicate describing when to reactivate (e.g., "3+ projects calling Claude API directly")' },
208
+ cascade: { type: 'boolean', description: 'When deferring a project with open child actions, also defer them. Required for projects with open children.' },
209
+ },
210
+ required: ['fid', 'triggerCondition'],
211
+ },
212
+ },
213
+ {
214
+ name: 'pib_list_triggered',
215
+ description: 'List all items (actions and projects) with an active trigger_condition. Returns two arrays: actions and projects, each with fid, trigger text, last check result, and days since last check.',
216
+ inputSchema: {
217
+ type: 'object',
218
+ properties: {
219
+ includeDone: { type: 'boolean', description: 'Include items already marked done. Default false.' },
220
+ },
221
+ },
222
+ },
223
+ {
224
+ name: 'pib_mark_trigger_checked',
225
+ description: 'Record that a trigger was evaluated. Use after orient\'s deferred-check phase evaluates triggers against session context. Result must be one of: triggered | still-waiting | needs-info | condition-obsolete.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ fid: { type: 'string', description: 'Action or project fid' },
230
+ result: {
231
+ type: 'string',
232
+ enum: ['triggered', 'still-waiting', 'needs-info', 'condition-obsolete'],
233
+ description: 'Outcome of this evaluation',
234
+ },
235
+ notes: { type: 'string', description: 'Optional reasoning or context for the evaluation' },
236
+ },
237
+ required: ['fid', 'result'],
238
+ },
239
+ },
194
240
  {
195
241
  name: 'pib_query',
196
242
  description: 'Run an arbitrary SQL query against the pib database.',
@@ -209,14 +255,6 @@ const TOOLS = [
209
255
  // ---------------------------------------------------------------------------
210
256
  function handleToolCall(name, args) {
211
257
  const d = getDb();
212
- const schemaPath = join(__dirname, 'pib-db-schema.sql');
213
-
214
- // Auto-init: ensure tables exist
215
- try {
216
- d.prepare('SELECT 1 FROM projects LIMIT 0').run();
217
- } catch {
218
- lib.init(d, { schemaPath });
219
- }
220
258
 
221
259
  switch (name) {
222
260
  case 'pib_create_project':
@@ -239,6 +277,12 @@ function handleToolCall(name, args) {
239
277
  return lib.triage(d, args);
240
278
  case 'pib_triage_history':
241
279
  return lib.triageHistory(d);
280
+ case 'pib_defer_with_trigger':
281
+ return lib.deferWithTrigger(d, args);
282
+ case 'pib_list_triggered':
283
+ return lib.listTriggered(d, args);
284
+ case 'pib_mark_trigger_checked':
285
+ return lib.markTriggerChecked(d, args);
242
286
  case 'pib_query':
243
287
  return lib.query(d, args);
244
288
  default:
@@ -7,34 +7,36 @@
7
7
  -- Query: node scripts/pib-db.mjs query "SELECT ..."
8
8
 
9
9
  CREATE TABLE IF NOT EXISTS projects (
10
- fid TEXT PRIMARY KEY CHECK(fid GLOB 'prj:*'),
11
- name TEXT NOT NULL,
12
- area TEXT,
13
- status TEXT NOT NULL DEFAULT 'active'
14
- CHECK(status IN ('active','paused','done','dropped','someday')),
15
- notes TEXT NOT NULL DEFAULT '',
16
- created TEXT NOT NULL CHECK(created GLOB '????-??-??'),
17
- completed_at TEXT,
18
- due TEXT,
19
- deleted_at TEXT
10
+ fid TEXT PRIMARY KEY CHECK(fid GLOB 'prj:*'),
11
+ name TEXT NOT NULL,
12
+ area TEXT,
13
+ status TEXT NOT NULL DEFAULT 'active'
14
+ CHECK(status IN ('active','paused','done','dropped','someday')),
15
+ notes TEXT NOT NULL DEFAULT '',
16
+ created TEXT NOT NULL CHECK(created GLOB '????-??-??'),
17
+ completed_at TEXT,
18
+ due TEXT,
19
+ deleted_at TEXT,
20
+ trigger_condition TEXT
20
21
  );
21
22
 
22
23
  CREATE TABLE IF NOT EXISTS actions (
23
- fid TEXT PRIMARY KEY CHECK(fid GLOB 'act:*'),
24
- text TEXT NOT NULL,
25
- area TEXT,
26
- project_fid TEXT REFERENCES projects(fid) ON DELETE SET NULL,
27
- due TEXT,
28
- flagged INTEGER NOT NULL DEFAULT 0 CHECK(flagged IN (0, 1)),
29
- completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0, 1)),
30
- completed_at TEXT,
31
- status TEXT NOT NULL DEFAULT 'open'
32
- CHECK(status IN ('open','in-progress','blocked','deferred','done')),
33
- tags TEXT NOT NULL DEFAULT '',
34
- sort_order INTEGER NOT NULL DEFAULT 0,
35
- created TEXT NOT NULL CHECK(created GLOB '????-??-??'),
36
- notes TEXT NOT NULL DEFAULT '',
37
- deleted_at TEXT
24
+ fid TEXT PRIMARY KEY CHECK(fid GLOB 'act:*'),
25
+ text TEXT NOT NULL,
26
+ area TEXT,
27
+ project_fid TEXT REFERENCES projects(fid) ON DELETE SET NULL,
28
+ due TEXT,
29
+ flagged INTEGER NOT NULL DEFAULT 0 CHECK(flagged IN (0, 1)),
30
+ completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0, 1)),
31
+ completed_at TEXT,
32
+ status TEXT NOT NULL DEFAULT 'open'
33
+ CHECK(status IN ('open','in-progress','blocked','deferred','done')),
34
+ tags TEXT NOT NULL DEFAULT '',
35
+ sort_order INTEGER NOT NULL DEFAULT 0,
36
+ created TEXT NOT NULL CHECK(created GLOB '????-??-??'),
37
+ notes TEXT NOT NULL DEFAULT '',
38
+ deleted_at TEXT,
39
+ trigger_condition TEXT
38
40
  );
39
41
 
40
42
  CREATE TABLE IF NOT EXISTS audit_runs (
@@ -66,3 +68,16 @@ CREATE TABLE IF NOT EXISTS audit_findings (
66
68
  triaged_at TEXT,
67
69
  fix_description TEXT
68
70
  );
71
+
72
+ -- Append-only history of trigger-condition evaluations.
73
+ -- No foreign key to actions/projects: if the target row is later deleted,
74
+ -- we want the historical record preserved (orphan rows are acceptable).
75
+ CREATE TABLE IF NOT EXISTS trigger_checks (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ target_table TEXT NOT NULL CHECK(target_table IN ('actions','projects')),
78
+ target_fid TEXT NOT NULL,
79
+ checked_at TEXT NOT NULL,
80
+ result TEXT NOT NULL CHECK(result IN ('triggered','still-waiting','needs-info','condition-obsolete')),
81
+ notes TEXT
82
+ );
83
+ CREATE INDEX IF NOT EXISTS idx_trigger_checks_fid ON trigger_checks(target_fid);