@ikenga/pkg-tasks 0.2.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.
@@ -0,0 +1,140 @@
1
+ // MCP Apps SDK bridge — the canonical iframe⇄host protocol Ikenga uses.
2
+ //
3
+ // Pattern from @modelcontextprotocol/ext-apps Quickstart + the shell's
4
+ // pkg-iframe-host.tsx implementation:
5
+ // 1. new App(...) — register handlers before connect
6
+ // 2. await app.connect() — runs ui/initialize handshake automatically
7
+ // 3. app.getHostContext() — read theme / styles / supabase / royaltiAuth
8
+ // 4. app.callServerTool({ name: 'host.<x>', arguments }) — invoke host tools
9
+ // (shell intercepts `host.*` names in dispatchHostCall; everything else
10
+ // proxies to pkg MCP servers if any).
11
+ //
12
+ // The host re-emits hostContext on theme change via onhostcontextchanged.
13
+
14
+ // Use the bundled `app-with-deps` build — the default entry pulls
15
+ // `zod/v4` as a peer-via-esm.sh and dependency resolution sometimes
16
+ // produces a Zod build missing `.custom()`. The bundled variant
17
+ // inlines its deps so it works regardless of esm.sh's resolver state.
18
+ // NOTE: we deliberately do NOT import the SDK's applyDocumentTheme /
19
+ // applyHostStyleVariables / applyHostFonts helpers. Theme is owned by app.js's
20
+ // parent-<html> mirror (the artifact pattern). applyDocumentTheme in particular
21
+ // clobbers our workspace `data-theme` (A/B/C) with 'light'|'dark', breaking the
22
+ // bundled @ikenga/tokens palette — so the bridge stays out of theming entirely.
23
+ import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1.7.1/app-with-deps';
24
+
25
+ let app = null;
26
+
27
+ export async function connectBridge({ name, version, onContextChange }) {
28
+ app = new App({ name, version }, {
29
+ // Capabilities the pkg advertises to the host. Keep minimal — declare only
30
+ // what we actually use.
31
+ tools: { listChanged: false },
32
+ });
33
+
34
+ app.onerror = (err) => console.error('[tasks] bridge error', err);
35
+ // Theme is NOT applied here — app.js mirrors it from the parent <html>.
36
+ // We still forward context so live Supabase/auth updates reach the app.
37
+ app.onhostcontextchanged = (ctx) => {
38
+ onContextChange?.(ctx);
39
+ };
40
+ app.onteardown = async () => ({});
41
+
42
+ await app.connect();
43
+ return app.getHostContext();
44
+ }
45
+
46
+ /** Navigate the focused shell pane (cross-pkg or in-pkg sub-route). */
47
+ export async function hostNavigate(path) {
48
+ if (!app) throw new Error('bridge not connected');
49
+ return app.callServerTool({
50
+ name: 'host.navigate',
51
+ arguments: { path },
52
+ });
53
+ }
54
+
55
+ /** Open an external link via the host. */
56
+ export async function openLink(url) {
57
+ if (!app) throw new Error('bridge not connected');
58
+ return app.openLink({ url });
59
+ }
60
+
61
+ /** Publish the pkg's sidebar menu to the shell. PkgMode renders these items in
62
+ * the left side panel when this pkg's pane is focused (normal AppMode). Item
63
+ * clicks come back as hostContext changes via `royaltiSuite.activeFeature` —
64
+ * listen via onContextChange in connectBridge.
65
+ * items: [{ id, label, icon?, badge? }] */
66
+ export async function setMenu(items) {
67
+ if (!app) throw new Error('bridge not connected');
68
+ return app.callServerTool({
69
+ name: 'host.pkg.setMenu',
70
+ arguments: { items },
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Seed a user turn into the shell's active Claude session. This is how the
76
+ * Tasks pkg "creates" work: anon RLS only grants UPDATE of status/completed_at
77
+ * (never INSERT), so a new task can't be written client-side. Instead we
78
+ * dispatch a natural-language request to the agent, which creates the task via
79
+ * its privileged path. Verb confirmed in shell/src/components/pkg/
80
+ * pkg-iframe-host.tsx (`host.sendToActiveSession`).
81
+ *
82
+ * prompt: string — the instruction shown as the user turn
83
+ * source?: string — provenance tag (defaults to the pkg id)
84
+ */
85
+ export async function hostSendToActiveSession(prompt, source = 'com.ikenga.tasks') {
86
+ if (!app) throw new Error('bridge not connected');
87
+ return app.callServerTool({
88
+ name: 'host.sendToActiveSession',
89
+ arguments: { prompt, source },
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Dispatch a structured PA action through the host. Kept as a thin alias for
95
+ * forward-compat: if/when the shell exposes a dedicated `host.paActionsRun`
96
+ * verb, point this at it. Today the shell does NOT expose that verb (only
97
+ * host.navigate / host.sendToActiveSession / host.openSessionDialog /
98
+ * host.pkg.setMenu exist), so the create path uses hostSendToActiveSession.
99
+ */
100
+ export async function hostPaActionsRun(args) {
101
+ if (!app) throw new Error('bridge not connected');
102
+ return app.callServerTool({
103
+ name: 'host.paActionsRun',
104
+ arguments: args,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Read the local `pa.db` via the host's `host.dbQuery` verb (WP-04 read-swap).
110
+ * SELECT/WITH only — the shell rejects writes and gates this on the pkg
111
+ * declaring `capabilities.sqlite`. Returns the row array
112
+ * (`structuredContent.rows`); throws on a closed/failed bridge so callers can
113
+ * surface the error in the query layer. Requires a connected bridge — there is
114
+ * no standalone fallback (reads no longer go through supabase-js).
115
+ *
116
+ * sql: string — a single SELECT/WITH statement with `?` params
117
+ * params: SqlValue[] — positional bind values
118
+ */
119
+ export async function hostDbQuery(sql, params = []) {
120
+ if (!app) throw new Error('[tasks] bridge not connected — db_query unavailable');
121
+ const res = await app.callServerTool({
122
+ name: 'host.dbQuery',
123
+ arguments: { sql, params },
124
+ });
125
+ const sc = res?.structuredContent;
126
+ if (!sc || sc.ok !== true) {
127
+ throw new Error(sc?.error ?? res?.content?.[0]?.text ?? 'host.dbQuery failed');
128
+ }
129
+ return Array.isArray(sc.rows) ? sc.rows : [];
130
+ }
131
+
132
+ /** Read the current hostContext snapshot. */
133
+ export function getContext() {
134
+ return app?.getHostContext() ?? null;
135
+ }
136
+
137
+ /** Detect standalone-dev (no parent shell). */
138
+ export function isStandalone() {
139
+ return typeof window !== 'undefined' && window.parent === window;
140
+ }
@@ -0,0 +1,39 @@
1
+ // Dev-only ambient declarations so `tsc -p tsconfig.dev.json --checkJs` can
2
+ // resolve the https://esm.sh/* import URLs (TypeScript cannot fetch them).
3
+ // NEVER referenced by the publish path — the browser resolves these natively.
4
+
5
+ declare module 'https://esm.sh/react@19.0.0' {
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ const React: any;
8
+ export = React;
9
+ }
10
+ declare module 'https://esm.sh/react-dom@19.0.0/client' {
11
+ export const createRoot: (el: Element | null) => { render: (node: unknown) => void };
12
+ }
13
+ declare module 'https://esm.sh/htm@3.1.1' {
14
+ const htm: { bind: (h: unknown) => (...args: unknown[]) => unknown };
15
+ export default htm;
16
+ }
17
+ declare module 'https://esm.sh/@tanstack/react-query@5?deps=react@19.0.0' {
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ export const QueryClient: any;
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ export const QueryClientProvider: any;
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ export const useQuery: any;
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ export const useMutation: any;
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ export const useQueryClient: any;
28
+ }
29
+ declare module 'https://esm.sh/@supabase/supabase-js@2' {
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ export const createClient: any;
32
+ }
33
+ declare module 'https://esm.sh/@modelcontextprotocol/ext-apps@1.7.1/app-with-deps' {
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ export const App: any;
36
+ export const applyDocumentTheme: (theme: unknown) => void;
37
+ export const applyHostStyleVariables: (vars: unknown) => void;
38
+ export const applyHostFonts: (fonts: unknown) => void;
39
+ }
@@ -0,0 +1,193 @@
1
+ // Query layer — ported from src/lib/queries/tasks.ts. JSDoc carries the TS
2
+ // types. Schema verified against the local pa.db `tasks` table (shell
3
+ // migration 0025_tasks_domain.sql): every selected column exists.
4
+ //
5
+ // WP-04 read-swap: reads go through the host's `host.dbQuery` verb (local
6
+ // pa.db) instead of an in-iframe supabase-js client. The status-update WRITE
7
+ // (task-detail-pane.js) still uses supabase-js — moving it needs a host write
8
+ // verb that does not exist yet (follow-up WP).
9
+
10
+ import { hostDbQuery } from './bridge.js';
11
+ import { queryKeys } from './query-keys.js';
12
+
13
+ // pa.db stores former Postgres array/json columns as TEXT (the Pg→SQLite
14
+ // down-map, shell migration 0025). `tags` arrives as a string, not a JS array
15
+ // — normalize it back so the Task shape matches the JSDoc + the detail pane's
16
+ // `.map`/`.length` usage. JSON first (the canonical ETL encoding), then a
17
+ // comma split as a tolerant fallback, then [].
18
+ /** @param {any} row */
19
+ function normalizeTaskRow(row) {
20
+ if (!row || typeof row !== 'object') return row;
21
+ const t = row.tags;
22
+ if (typeof t === 'string') {
23
+ const s = t.trim();
24
+ if (!s) {
25
+ row.tags = null;
26
+ } else {
27
+ try {
28
+ const parsed = JSON.parse(s);
29
+ row.tags = Array.isArray(parsed) ? parsed : [String(parsed)];
30
+ } catch {
31
+ row.tags = s.split(',').map((x) => x.trim()).filter(Boolean);
32
+ }
33
+ }
34
+ }
35
+ return row;
36
+ }
37
+
38
+ /** @typedef {'pending'|'in_progress'|'completed'|'cancelled'|'blocked'} TaskStatus */
39
+ /** @typedef {'critical'|'high'|'medium'|'low'} TaskPriority */
40
+ /** @typedef {'human'|'agent'} AssigneeType */
41
+
42
+ /**
43
+ * @typedef {Object} Task
44
+ * @property {string} id
45
+ * @property {string} title
46
+ * @property {string|null} description
47
+ * @property {TaskStatus} status
48
+ * @property {TaskPriority} priority
49
+ * @property {string|null} assigned_to
50
+ * @property {AssigneeType|null} assignee_type
51
+ * @property {string|null} category
52
+ * @property {string[]|null} tags
53
+ * @property {string|null} due_date
54
+ * @property {string|null} completed_at
55
+ * @property {string} created_at
56
+ * @property {string} updated_at
57
+ * @property {number|null} progress_pct
58
+ * @property {string|null} outcome_notes
59
+ * @property {string|null} parent_task_id
60
+ * @property {string|null} blocked_by_task_id
61
+ * @property {string|null} source_email_id
62
+ * @property {string|null} agent_source
63
+ * @property {string|null} initiative_id
64
+ * @property {string|null} risk_id
65
+ * @property {string|null} effort_estimate
66
+ * @property {'autonomous'|'report'|'approval_required'|null} execution_mode
67
+ * @property {string|null} task_result
68
+ * @property {string|null} claude_session_id
69
+ * @property {string|null} working_dir
70
+ */
71
+
72
+ export const TASKS_LIST_COLUMNS =
73
+ 'id, title, description, status, priority, assigned_to, assignee_type, category, due_date, created_at, updated_at, progress_pct, outcome_notes, execution_mode';
74
+
75
+ /** @type {readonly TaskStatus[]} */
76
+ const ACTIVE_STATUSES = ['pending', 'in_progress', 'blocked'];
77
+ const STALE_DAYS = 7;
78
+
79
+ /**
80
+ * @typedef {Object} TriageCounts
81
+ * @property {number} overdue
82
+ * @property {number} stale
83
+ * @property {number} unassigned
84
+ * @property {number} blocked
85
+ * @property {number} needsAttention Deduplicated overdue-OR-unassigned badge total.
86
+ */
87
+
88
+ /**
89
+ * Server-side health counts for the Triage badge (R16-followup: server-side,
90
+ * so the badge is correct independent of the list's filter + 200-row cap).
91
+ * Four `head:true` count() selects, run in parallel (+ the deduped total).
92
+ */
93
+ export function triageCountsQuery() {
94
+ return {
95
+ queryKey: queryKeys.tasks.triageCounts(),
96
+ /** @returns {Promise<TriageCounts>} */
97
+ queryFn: async () => {
98
+ // "Overdue" = due before the start of today, matching the app's own
99
+ // convention (groupTasks / dueLabel treat a task due *today* as "Today",
100
+ // not overdue, until the day rolls over).
101
+ const startOfTodayIso = (() => {
102
+ const d = new Date();
103
+ d.setHours(0, 0, 0, 0);
104
+ return d.toISOString();
105
+ })();
106
+ const staleIso = new Date(
107
+ Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000,
108
+ ).toISOString();
109
+ // `?,?,?` placeholders for the active-status set, reused per count.
110
+ const activePlaceholders = ACTIVE_STATUSES.map(() => '?').join(',');
111
+ const countOne = async (where, params) => {
112
+ const rows = await hostDbQuery(
113
+ `SELECT count(*) AS n FROM tasks WHERE ${where}`,
114
+ params,
115
+ );
116
+ return Number(rows[0]?.n ?? 0);
117
+ };
118
+
119
+ const [overdue, stale, unassigned, blocked, needsAttention] = await Promise.all([
120
+ countOne(`status IN (${activePlaceholders}) AND due_date < ?`, [
121
+ ...ACTIVE_STATUSES,
122
+ startOfTodayIso,
123
+ ]),
124
+ countOne(`status IN (${activePlaceholders}) AND updated_at < ?`, [
125
+ ...ACTIVE_STATUSES,
126
+ staleIso,
127
+ ]),
128
+ countOne(`status IN (${activePlaceholders}) AND assigned_to IS NULL`, [
129
+ ...ACTIVE_STATUSES,
130
+ ]),
131
+ countOne(`status = ?`, ['blocked']),
132
+ // overdue OR unassigned, counted once (the deduplicated badge total).
133
+ countOne(
134
+ `status IN (${activePlaceholders}) AND (due_date < ? OR assigned_to IS NULL)`,
135
+ [...ACTIVE_STATUSES, startOfTodayIso],
136
+ ),
137
+ ]);
138
+
139
+ return {
140
+ overdue,
141
+ stale,
142
+ unassigned,
143
+ blocked,
144
+ needsAttention,
145
+ };
146
+ },
147
+ };
148
+ }
149
+
150
+ /** @param {string} id */
151
+ export function taskDetailQuery(id) {
152
+ return {
153
+ queryKey: queryKeys.tasks.detail(id),
154
+ /** @returns {Promise<Task|null>} */
155
+ queryFn: async () => {
156
+ const rows = await hostDbQuery('SELECT * FROM tasks WHERE id = ? LIMIT 1', [id]);
157
+ if (rows.length === 0) return null;
158
+ return /** @type {Task} */ (normalizeTaskRow(rows[0]));
159
+ },
160
+ };
161
+ }
162
+
163
+ /** @param {string} parentId */
164
+ export function subtasksQuery(parentId) {
165
+ return {
166
+ queryKey: queryKeys.tasks.subtasks(parentId),
167
+ /** @returns {Promise<Task[]>} */
168
+ queryFn: async () => {
169
+ const rows = await hostDbQuery(
170
+ 'SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC',
171
+ [parentId],
172
+ );
173
+ return /** @type {Task[]} */ (rows.map(normalizeTaskRow));
174
+ },
175
+ };
176
+ }
177
+
178
+ /** @param {string|null} blockingId */
179
+ export function blockingTaskQuery(blockingId) {
180
+ return {
181
+ queryKey: queryKeys.tasks.detail(blockingId ?? 'none'),
182
+ /** @returns {Promise<Task|null>} */
183
+ queryFn: async () => {
184
+ if (!blockingId) return null;
185
+ const rows = await hostDbQuery('SELECT * FROM tasks WHERE id = ? LIMIT 1', [
186
+ blockingId,
187
+ ]);
188
+ if (rows.length === 0) return null;
189
+ return /** @type {Task} */ (normalizeTaskRow(rows[0]));
190
+ },
191
+ enabled: !!blockingId,
192
+ };
193
+ }
@@ -0,0 +1,14 @@
1
+ // TanStack Query cache keys — ported from src/lib/query-keys.ts.
2
+
3
+ export const queryKeys = {
4
+ tasks: {
5
+ all: ['tasks'],
6
+ /** @param {string} filter */
7
+ list: (filter) => [...queryKeys.tasks.all, 'list', filter],
8
+ /** @param {string} id */
9
+ detail: (id) => [...queryKeys.tasks.all, 'detail', id],
10
+ /** @param {string} parentId */
11
+ subtasks: (parentId) => [...queryKeys.tasks.all, 'subtasks', parentId],
12
+ triageCounts: () => [...queryKeys.tasks.all, 'triage-counts'],
13
+ },
14
+ };