@davvidbaker/flambe-cli 0.1.1 → 0.1.2

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.
@@ -5,8 +5,8 @@ import { DatabaseSync } from 'node:sqlite';
5
5
 
6
6
  import { pickName, usableName } from './agents.mjs';
7
7
 
8
- const LIFECYCLE_PHASES = ['B', 'E', 'S', 'R'];
9
- const OPEN_PHASES = ['B', 'R'];
8
+ const LIFECYCLE_PHASES = ['B', 'E', 'S', 'R', 'X', 'J', 'V'];
9
+ const OPEN_PHASES = ['B', 'R', 'X'];
10
10
 
11
11
  const SCHEMA = `
12
12
  PRAGMA foreign_keys = ON;
@@ -60,6 +60,10 @@ CREATE TABLE IF NOT EXISTS activities (
60
60
  weight INTEGER,
61
61
  agent_id TEXT,
62
62
  agent_name TEXT,
63
+ scheduled_start_ms INTEGER,
64
+ scheduled_end_ms INTEGER,
65
+ proposed_by_agent_id TEXT,
66
+ proposed_by_agent_name TEXT,
63
67
  export_id TEXT NOT NULL UNIQUE
64
68
  );
65
69
 
@@ -79,13 +83,6 @@ CREATE TABLE IF NOT EXISTS events (
79
83
  export_id TEXT NOT NULL UNIQUE
80
84
  );
81
85
 
82
- CREATE TABLE IF NOT EXISTS todos (
83
- id INTEGER PRIMARY KEY,
84
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
85
- name TEXT NOT NULL,
86
- description TEXT
87
- );
88
-
89
86
  CREATE TABLE IF NOT EXISTS mantras (
90
87
  id INTEGER PRIMARY KEY,
91
88
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -142,11 +139,25 @@ export const EXPORT_FORMAT = 'flambe-local-export';
142
139
  export const EXPORT_VERSION = 1;
143
140
 
144
141
  export const DEFAULT_CATEGORIES = [
145
- { name: 'coding', color_background: '#efc360', color_text: '#000000' },
146
- { name: 'investigation', color_background: '#60a5fa', color_text: '#000000' },
147
- { name: 'review', color_background: '#a78bfa', color_text: '#ffffff' },
148
- { name: 'operations', color_background: '#34d399', color_text: '#000000' },
149
- { name: 'failure', color_background: '#fb7185', color_text: '#000000' },
142
+ { name: 'bug fixing', color_background: '#ff4747', color_text: '#000000' },
143
+ { name: 'research', color_background: '#cd94f1', color_text: '#000000' },
144
+ { name: 'cleaning', color_background: '#ffbd69', color_text: '#000000' },
145
+ { name: 'bug hunting', color_background: '#ef60ab', color_text: '#000000' },
146
+ { name: 'design', color_background: '#dce7ea', color_text: '#000000' },
147
+ { name: 'writing tests', color_background: '#50f035', color_text: '#000000' },
148
+ { name: 'toil', color_background: '#8b6125', color_text: '#ffffff' },
149
+ { name: 'analytics', color_background: '#f9f9f9', color_text: '#4598d6' },
150
+ { name: 'oss', color_background: '#60ef6c', color_text: '#000000' },
151
+ { name: 'enhancements', color_background: '#55defb', color_text: '#ffffff' },
152
+ { name: 'refactoring components', color_background: '#ffd368', color_text: '#000000' },
153
+ { name: 'Fundamentals', color_background: '#acefdb', color_text: '#000000' },
154
+ { name: 'jarring ui', color_background: '#f22d6a', color_text: '#ffffff' },
155
+ { name: 'writing', color_background: '#ffefce', color_text: '#000000' },
156
+ { name: 'risky', color_background: '#ff2a22', color_text: '#ffffff' },
157
+ { name: 'dependency upgrades', color_background: '#b58e38', color_text: '#ffffff' },
158
+ { name: 'shiny', color_background: '#f5ff86', color_text: '#000000' },
159
+ { name: 'performance', color_background: '#60efc7', color_text: '#000000' },
160
+ { name: 'learning', color_background: '#d3f791', color_text: '#000000' },
150
161
  ];
151
162
 
152
163
  export function hashToken(rawToken) {
@@ -169,6 +180,17 @@ export class LocalStore {
169
180
  this.db = new DatabaseSync(dbPath);
170
181
  this.db.exec('PRAGMA foreign_keys = ON');
171
182
  this.db.exec(SCHEMA);
183
+ this.db.exec('DROP TABLE IF EXISTS todos');
184
+ for (const column of [
185
+ 'scheduled_start_ms INTEGER',
186
+ 'scheduled_end_ms INTEGER',
187
+ 'proposed_by_agent_id TEXT',
188
+ 'proposed_by_agent_name TEXT',
189
+ ]) {
190
+ const name = column.split(' ')[0];
191
+ const exists = this.db.prepare('PRAGMA table_info(activities)').all().some(row => row.name === name);
192
+ if (!exists) this.db.exec(`ALTER TABLE activities ADD COLUMN ${column}`);
193
+ }
172
194
  this.#seed();
173
195
  }
174
196
 
@@ -317,6 +339,7 @@ export class LocalStore {
317
339
  }
318
340
 
319
341
  listCategories(userId) {
342
+ this.#ensureDefaultCategories(userId);
320
343
  return this.db.prepare(
321
344
  'SELECT id, name, color_background, color_text FROM categories WHERE user_id = ? ORDER BY id',
322
345
  ).all(userId);
@@ -383,7 +406,7 @@ export class LocalStore {
383
406
  .all(userId);
384
407
  }
385
408
 
386
- /** Newest activity in the trace whose latest lifecycle event is B/R, optionally for one agent or thread. */
409
+ /** Newest running activity in the trace, optionally for one agent or thread. */
387
410
  newestActiveActivity(traceId, { agentId, threadId } = {}) {
388
411
  const placeholders = LIFECYCLE_PHASES.map(() => '?').join(',');
389
412
  const open = OPEN_PHASES.map(() => '?').join(',');
@@ -471,8 +494,11 @@ export class LocalStore {
471
494
  : { agent_id: null, agent_name: null };
472
495
 
473
496
  const inserted = this.db.prepare(`
474
- INSERT INTO activities (thread_id, parent_id, name, description, weight, agent_id, agent_name, export_id)
475
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
497
+ INSERT INTO activities (
498
+ thread_id, parent_id, name, description, weight, agent_id, agent_name,
499
+ scheduled_start_ms, scheduled_end_ms, proposed_by_agent_id, proposed_by_agent_name, export_id
500
+ )
501
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
476
502
  `).run(
477
503
  thread.id,
478
504
  parentId,
@@ -481,6 +507,10 @@ export class LocalStore {
481
507
  activity.weight ?? null,
482
508
  agentColumns.agent_id,
483
509
  agentColumns.agent_name,
510
+ activity.scheduled_start_ms ?? null,
511
+ activity.scheduled_end_ms ?? null,
512
+ activity.proposed_by_agent_id ?? agentColumns.agent_id,
513
+ activity.proposed_by_agent_name ?? agentColumns.agent_name,
484
514
  randomUUID(),
485
515
  );
486
516
  const activityId = Number(inserted.lastInsertRowid);
@@ -510,6 +540,38 @@ export class LocalStore {
510
540
  };
511
541
  }
512
542
 
543
+ createUnstarted(userId, { traceId, name, description, weight, agent, scheduledStart, scheduledEnd }) {
544
+ const thread = this.#defaultThread(Number(traceId));
545
+ if (!thread) return { error: 'not_found' };
546
+ const trace = this.db.prepare('SELECT id FROM traces WHERE id = ? AND user_id = ?').get(thread.trace_id, userId);
547
+ if (!trace) return { error: 'not_found' };
548
+ const inserted = this.db.prepare(`
549
+ INSERT INTO activities (
550
+ thread_id, parent_id, name, description, weight, agent_id, agent_name,
551
+ scheduled_start_ms, scheduled_end_ms, proposed_by_agent_id, proposed_by_agent_name, export_id
552
+ ) VALUES (?, NULL, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)
553
+ `).run(
554
+ thread.id, name, description ?? null, weight ?? null,
555
+ scheduledStart ?? null, scheduledEnd ?? null,
556
+ agent?.agent_id ?? null, agent?.name ?? null, randomUUID(),
557
+ );
558
+ return { activity: { id: Number(inserted.lastInsertRowid), name, thread_id: thread.id, parent_id: null } };
559
+ }
560
+
561
+ listUnstarted(traceId) {
562
+ return this.db.prepare(`
563
+ SELECT activities.* FROM activities
564
+ JOIN threads ON threads.id = activities.thread_id
565
+ WHERE threads.trace_id = ?
566
+ AND NOT EXISTS (
567
+ SELECT 1 FROM events
568
+ WHERE events.activity_id = activities.id
569
+ AND events.phase IN ('B','R','X','S','E','J','V')
570
+ )
571
+ ORDER BY activities.id
572
+ `).all(Number(traceId));
573
+ }
574
+
513
575
  #defaultThread(traceId) {
514
576
  return this.db.prepare('SELECT id, trace_id FROM threads WHERE trace_id = ? ORDER BY rank, id LIMIT 1').get(traceId) ?? null;
515
577
  }
@@ -530,6 +592,8 @@ export class LocalStore {
530
592
  weight: row.weight,
531
593
  parent_id: row.parent_id,
532
594
  thread_id: row.thread_id,
595
+ agent_id: row.agent_id ?? null,
596
+ agent_name: row.agent_name ?? null,
533
597
  };
534
598
  }
535
599
 
@@ -545,6 +609,13 @@ export class LocalStore {
545
609
  WHERE id = ?
546
610
  `).run(attrs.name ?? null, attrs.description ?? null, attrs.weight ?? null, attrs.parent_id ?? null, current.id);
547
611
 
612
+ if (Object.prototype.hasOwnProperty.call(attrs, 'agent_id')) {
613
+ const agentId = attrs.agent_id == null || attrs.agent_id === '' ? null : String(attrs.agent_id);
614
+ const agentName = agentId == null ? null : this.#activityAgentName(userId, agentId, attrs);
615
+ this.db.prepare('UPDATE activities SET agent_id = ?, agent_name = ? WHERE id = ?')
616
+ .run(agentId, agentName, current.id);
617
+ }
618
+
548
619
  if (Array.isArray(attrs.category_ids)) {
549
620
  this.db.prepare('DELETE FROM activities_categories WHERE activity_id = ?').run(current.id);
550
621
  for (const categoryId of attrs.category_ids) {
@@ -628,7 +699,6 @@ export class LocalStore {
628
699
  username: user.username,
629
700
  traces: this.listTraces(userId),
630
701
  categories: this.listCategories(userId),
631
- todos: this.db.prepare('SELECT id, name, description FROM todos WHERE user_id = ?').all(userId),
632
702
  mantras: this.db.prepare('SELECT id, name, timestamp_ms FROM mantras WHERE user_id = ? ORDER BY id').all(userId)
633
703
  .map(row => ({ id: row.id, name: row.name, timestamp: isoFromMs(row.timestamp_ms) })),
634
704
  attentionShifts: this.db.prepare(
@@ -654,17 +724,14 @@ export class LocalStore {
654
724
  timestamp: isoFromMs(row.timestamp_ms),
655
725
  })),
656
726
  observations: this.listObservations(userId),
727
+ agents: this.listAgents(userId).map(agent => ({
728
+ agent_id: agent.agent_id,
729
+ name: agent.name,
730
+ platform: agent.platform ?? null,
731
+ })),
657
732
  };
658
733
  }
659
734
 
660
- createTodo(userId, attrs) {
661
- const result = this.db.prepare(
662
- 'INSERT INTO todos (user_id, name, description) VALUES (?, ?, ?)',
663
- ).run(userId, attrs.name, attrs.description ?? null);
664
- return this.db.prepare('SELECT id, name, description FROM todos WHERE id = ?')
665
- .get(Number(result.lastInsertRowid));
666
- }
667
-
668
735
  createMantra(userId, attrs) {
669
736
  const timestamp = Number(attrs.timestamp_integer ?? Date.now());
670
737
  const result = this.db.prepare(
@@ -792,6 +859,24 @@ export class LocalStore {
792
859
  };
793
860
  }
794
861
 
862
+ #activityAgentName(userId, agentId, attrs) {
863
+ const known = this.db.prepare(
864
+ 'SELECT name FROM agents WHERE user_id = ? AND agent_id = ?',
865
+ ).get(userId, agentId);
866
+ if (known?.name) return known.name;
867
+ const snapshot = this.db.prepare(`
868
+ SELECT activities.agent_name FROM activities
869
+ JOIN threads ON threads.id = activities.thread_id
870
+ JOIN traces ON traces.id = threads.trace_id
871
+ WHERE traces.user_id = ? AND activities.agent_id = ?
872
+ AND activities.agent_name IS NOT NULL AND activities.agent_name != ''
873
+ LIMIT 1
874
+ `).get(userId, agentId);
875
+ if (snapshot?.agent_name) return snapshot.agent_name;
876
+ if (typeof attrs.agent_name === 'string' && attrs.agent_name !== '') return attrs.agent_name;
877
+ return agentId;
878
+ }
879
+
795
880
  #eventPayload(event) {
796
881
  const categoryIds = event.activity_id == null
797
882
  ? []
@@ -830,15 +915,16 @@ export class LocalStore {
830
915
  }
831
916
 
832
917
  #ensureDefaultCategories(userId) {
833
- const existing = this.db.prepare(
834
- 'SELECT COUNT(*) AS count FROM categories WHERE user_id = ?',
835
- ).get(userId);
836
- if (existing.count > 0) return;
837
-
918
+ const have = new Set(
919
+ this.db.prepare('SELECT lower(name) AS name FROM categories WHERE user_id = ?')
920
+ .all(userId)
921
+ .map(row => row.name),
922
+ );
838
923
  const insert = this.db.prepare(
839
924
  'INSERT INTO categories (user_id, name, color_background, color_text, export_id) VALUES (?, ?, ?, ?, ?)',
840
925
  );
841
926
  for (const category of DEFAULT_CATEGORIES) {
927
+ if (have.has(category.name.toLowerCase())) continue;
842
928
  insert.run(
843
929
  userId,
844
930
  category.name,
@@ -0,0 +1,48 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ // A question the reducer asked and the worker has not answered yet (ADR-014).
7
+ // One open question per trace. The CLI reminds; it never refuses the next command.
8
+
9
+ export function defaultQuestionsPath() {
10
+ return join(homedir(), '.flambe', 'open-questions.json');
11
+ }
12
+
13
+ export function readOpenQuestion(traceId, path = defaultQuestionsPath()) {
14
+ if (!traceId) return undefined;
15
+ const stored = readAll(path)[String(traceId)];
16
+ if (!stored || typeof stored.reply !== 'string' || !stored.reply.trim()) return undefined;
17
+ return { activityId: stored.activityId, reply: stored.reply };
18
+ }
19
+
20
+ export function rememberQuestion(traceId, { activityId, reply }, path = defaultQuestionsPath()) {
21
+ if (!traceId || !reply) return;
22
+ const all = { ...readAll(path), [String(traceId)]: { activityId, reply } };
23
+ writeAll(path, all);
24
+ }
25
+
26
+ export function clearQuestion(traceId, path = defaultQuestionsPath()) {
27
+ if (!traceId) return;
28
+ const all = readAll(path);
29
+ if (!(String(traceId) in all)) return;
30
+ delete all[String(traceId)];
31
+ writeAll(path, all);
32
+ }
33
+
34
+ function readAll(path) {
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
37
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function writeAll(path, questions) {
44
+ mkdirSync(dirname(path), { recursive: true });
45
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
46
+ writeFileSync(tmp, `${JSON.stringify(questions, null, 2)}\n`);
47
+ renameSync(tmp, path);
48
+ }
package/src/queue.mjs CHANGED
@@ -28,14 +28,19 @@ function emptyState() {
28
28
  }
29
29
 
30
30
  export class FlambeQueue {
31
- constructor({ baseUrl, traceId, path = defaultQueuePath() }) {
31
+ constructor({ baseUrl, traceId, agentId, path = defaultQueuePath() }) {
32
32
  this.baseUrl = baseUrl;
33
33
  this.traceId = Number(traceId);
34
+ this.agentId = agentId;
34
35
  this.path = path;
35
36
  }
36
37
 
37
38
  matches(entry) {
38
- return entry.baseUrl === this.baseUrl && entry.traceId === this.traceId;
39
+ return entry.baseUrl === this.baseUrl
40
+ && entry.traceId === this.traceId
41
+ // Version-1 entries created before identity was recorded remain replayable.
42
+ // New entries may only be replayed by the worker that queued them.
43
+ && (entry.agentId === undefined || entry.agentId === this.agentId);
39
44
  }
40
45
 
41
46
  async read() {
@@ -70,6 +75,7 @@ export class FlambeQueue {
70
75
  type,
71
76
  baseUrl: this.baseUrl,
72
77
  traceId: this.traceId,
78
+ ...(this.agentId ? { agentId: this.agentId } : {}),
73
79
  ...(localId ? { localId } : {}),
74
80
  ...payload,
75
81
  });
@@ -107,7 +113,13 @@ export class FlambeQueue {
107
113
  } else if (entry.type === 'end' || entry.type === 'suspend' || entry.type === 'resume') {
108
114
  const activityId = state.aliases[entry.activityId] ?? entry.activityId;
109
115
  const post = { end, suspend, resume }[entry.type];
110
- await post({ activityId, message: entry.message, timestamp: entry.timestamp });
116
+ await post({
117
+ activityId,
118
+ message: entry.message,
119
+ timestamp: entry.timestamp,
120
+ force: entry.force ?? false,
121
+ phase: entry.phase,
122
+ });
111
123
  if (entry.type === 'end') delete state.aliases[entry.activityId];
112
124
  } else if (entry.type === 'observe') {
113
125
  await observe(entry.input);
@@ -0,0 +1,23 @@
1
+ /** Explicit transport boundary; never infer local-only intent from a URL. */
2
+ export function runtimeConfig(env) {
3
+ const mode = env.FLAMBE_RUNTIME_MODE?.trim();
4
+ if (!mode) return {};
5
+ if (!['cloud', 'local_self_contained'].includes(mode)) {
6
+ throw new Error('FLAMBE_RUNTIME_MODE must be cloud or local_self_contained');
7
+ }
8
+ return { runtimeMode: mode };
9
+ }
10
+
11
+ export function assertRuntimeUrl(value, mode) {
12
+ if (mode !== 'local_self_contained') return;
13
+ let url;
14
+ try { url = new URL(value); } catch {
15
+ throw new Error('Self-contained mode requires a loopback HTTP URL');
16
+ }
17
+ // Numeric addresses avoid DNS resolution changing the trust boundary.
18
+ if (!['http:', 'https:'].includes(url.protocol)
19
+ || !['127.0.0.1', '[::1]'].includes(url.hostname)
20
+ || url.username || url.password) {
21
+ throw new Error('Self-contained mode requires 127.0.0.1 or [::1]; remote destinations are disabled');
22
+ }
23
+ }