create-claude-cabinet 0.20.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
@@ -121,6 +121,39 @@ function setupOmega() {
121
121
  results.push('Downloaded cross-encoder reranker model');
122
122
  }
123
123
  } catch { /* non-fatal */ }
124
+
125
+ // Ensure MCP server extra is installed (added in v0.21)
126
+ try {
127
+ execSync(`"${VENV_PYTHON}" -c "import mcp"`, { stdio: 'pipe' });
128
+ } catch {
129
+ console.log(' Installing MCP server support...');
130
+ try {
131
+ execSync(`"${VENV_PYTHON}" -m pip install --quiet "omega-memory[server]"`, {
132
+ stdio: 'pipe', timeout: 120000,
133
+ });
134
+ results.push('Installed MCP server support');
135
+ } catch { /* non-fatal */ }
136
+ }
137
+
138
+ // Ensure omega MCP server is registered in global settings
139
+ try {
140
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
141
+ let settings = {};
142
+ if (fs.existsSync(settingsPath)) {
143
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
144
+ }
145
+ if (!settings.mcpServers) settings.mcpServers = {};
146
+ if (!settings.mcpServers.omega) {
147
+ settings.mcpServers.omega = {
148
+ command: path.join(VENV_DIR, 'bin', 'omega'),
149
+ args: ['serve'],
150
+ };
151
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
152
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
153
+ results.push('Registered omega MCP server (global settings)');
154
+ }
155
+ } catch { /* non-fatal */ }
156
+
124
157
  return results;
125
158
  } catch {
126
159
  // Venv is broken — nuke and rebuild (D5)
@@ -133,13 +166,13 @@ function setupOmega() {
133
166
  execSync(`"${pythonPath}" -m venv "${VENV_DIR}"`, { stdio: 'pipe' });
134
167
  results.push('Created venv at ~/.claude-cabinet/omega-venv/');
135
168
 
136
- // 4. Install omega-memory
169
+ // 4. Install omega-memory (with MCP server support)
137
170
  console.log(' Installing omega-memory...');
138
- execSync(`"${VENV_PYTHON}" -m pip install --quiet omega-memory`, {
171
+ execSync(`"${VENV_PYTHON}" -m pip install --quiet "omega-memory[server]"`, {
139
172
  stdio: 'pipe',
140
173
  timeout: 120000,
141
174
  });
142
- results.push('Installed omega-memory');
175
+ results.push('Installed omega-memory (with MCP server)');
143
176
 
144
177
  // 5. Download embedding model at install time (D4)
145
178
  console.log(' Downloading embedding model...');
@@ -179,6 +212,41 @@ function setupOmega() {
179
212
  results.push('Omega hooks setup skipped (run `omega hooks setup` manually)');
180
213
  }
181
214
 
215
+ // 8. Register omega MCP server in global settings
216
+ // This enables omega_store(), omega_query(), etc. as MCP tools
217
+ // available to Claude Code directly (not just via hooks).
218
+ console.log(' Registering omega MCP server...');
219
+ try {
220
+ // Smoke-test: verify `omega serve` can start (requires mcp package)
221
+ execSync(
222
+ `echo '{}' | "${path.join(VENV_DIR, 'bin', 'omega')}" serve 2>&1 | head -1`,
223
+ { stdio: 'pipe', timeout: 10000 }
224
+ );
225
+
226
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
227
+ let settings = {};
228
+ if (fs.existsSync(settingsPath)) {
229
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
230
+ }
231
+ if (!settings.mcpServers) settings.mcpServers = {};
232
+
233
+ // Only add if not already configured
234
+ if (!settings.mcpServers.omega) {
235
+ settings.mcpServers.omega = {
236
+ command: path.join(VENV_DIR, 'bin', 'omega'),
237
+ args: ['serve'],
238
+ };
239
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
240
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
241
+ results.push('Registered omega MCP server (global settings)');
242
+ } else {
243
+ results.push('Omega MCP server already registered');
244
+ }
245
+ } catch {
246
+ // Non-fatal — MCP tools are a convenience, hooks still work
247
+ results.push('Omega MCP server registration skipped (run `omega serve` manually to test)');
248
+ }
249
+
182
250
  return results;
183
251
  }
184
252
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.20.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.
@@ -27,14 +27,12 @@ if [ -z "$FILE_PATH" ]; then
27
27
  fi
28
28
 
29
29
  # Only care about memory directory paths
30
- case "$FILE_PATH" in
31
- *.claude/memory/*|*.claude/projects/*/memory/*)
32
- ;;
33
- *)
34
- echo '{"decision":"allow"}'
35
- exit 0
36
- ;;
37
- esac
30
+ # Note: case patterns with * don't cross / boundaries in some shells,
31
+ # so we use [[ ]] substring matching for absolute path compatibility.
32
+ if [[ "$FILE_PATH" != *"/.claude/memory/"* ]] && [[ "$FILE_PATH" != *"/.claude/projects/"*"/memory/"* ]]; then
33
+ echo '{"decision":"allow"}'
34
+ exit 0
35
+ fi
38
36
 
39
37
  # Allow MEMORY.md index files (structural, not memory content)
40
38
  BASENAME=$(basename "$FILE_PATH")
@@ -56,3 +56,24 @@ verbally, or a constraint discovered through external research).
56
56
  Over-capturing degrades retrieval quality. The test: *"Would a future
57
57
  session benefit from knowing this?"* If yes, capture it. If it's just
58
58
  noise or ephemera, skip it.
59
+
60
+ ## Known Limitation: Auto-Memory System Prompt Conflict
61
+
62
+ Claude Code's built-in auto-memory system prompt describes a file-based
63
+ `.md` memory system (`/Users/<user>/.claude/projects/<project>/memory/`).
64
+ When omega is active, this conflicts — the system prompt tells Claude to
65
+ write `.md` files while CLAUDE.md and this rules file tell it to use
66
+ omega. The system prompt's instructions are strong and may override
67
+ project-level rules in some sessions.
68
+
69
+ **Mitigations:**
70
+ - The `omega-memory-guard` PreToolUse hook blocks flat markdown writes
71
+ when omega is available (structural enforcement, ~100% reliable)
72
+ - This rules file and CLAUDE.md omega instructions provide prompt-level
73
+ guidance (~80% reliable)
74
+ - If Claude creates a `.md` memory file despite these, the guard will
75
+ block it and redirect to `omega_store()`
76
+
77
+ This is a platform limitation — the auto-memory system prompt cannot
78
+ be suppressed from project configuration. The guard hook is the
79
+ primary defense.
@@ -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
+ }