instar 0.28.83 → 0.28.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dashboard/index.html +239 -0
  2. package/dist/cli.js +10 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/intent.d.ts.map +1 -1
  5. package/dist/commands/intent.js +29 -2
  6. package/dist/commands/intent.js.map +1 -1
  7. package/dist/commands/memoryBackfillEvidence.d.ts +69 -0
  8. package/dist/commands/memoryBackfillEvidence.d.ts.map +1 -0
  9. package/dist/commands/memoryBackfillEvidence.js +220 -0
  10. package/dist/commands/memoryBackfillEvidence.js.map +1 -0
  11. package/dist/commands/server.d.ts.map +1 -1
  12. package/dist/commands/server.js +86 -1
  13. package/dist/commands/server.js.map +1 -1
  14. package/dist/core/DecisionJournal.d.ts +66 -3
  15. package/dist/core/DecisionJournal.d.ts.map +1 -1
  16. package/dist/core/DecisionJournal.js +116 -3
  17. package/dist/core/DecisionJournal.js.map +1 -1
  18. package/dist/core/DispatchExecutor.d.ts +74 -1
  19. package/dist/core/DispatchExecutor.d.ts.map +1 -1
  20. package/dist/core/DispatchExecutor.js +120 -1
  21. package/dist/core/DispatchExecutor.js.map +1 -1
  22. package/dist/core/EvolutionManager.d.ts +133 -0
  23. package/dist/core/EvolutionManager.d.ts.map +1 -1
  24. package/dist/core/EvolutionManager.js +494 -0
  25. package/dist/core/EvolutionManager.js.map +1 -1
  26. package/dist/core/InitiativeTracker.d.ts +82 -15
  27. package/dist/core/InitiativeTracker.d.ts.map +1 -1
  28. package/dist/core/InitiativeTracker.js +502 -31
  29. package/dist/core/InitiativeTracker.js.map +1 -1
  30. package/dist/core/LearnSkillBridge.d.ts +119 -0
  31. package/dist/core/LearnSkillBridge.d.ts.map +1 -0
  32. package/dist/core/LearnSkillBridge.js +160 -0
  33. package/dist/core/LearnSkillBridge.js.map +1 -0
  34. package/dist/core/SharedStateLedger.d.ts.map +1 -1
  35. package/dist/core/SharedStateLedger.js +4 -0
  36. package/dist/core/SharedStateLedger.js.map +1 -1
  37. package/dist/core/types.d.ts +124 -1
  38. package/dist/core/types.d.ts.map +1 -1
  39. package/dist/core/types.js +8 -0
  40. package/dist/core/types.js.map +1 -1
  41. package/dist/memory/EvidenceRenderer.d.ts +64 -0
  42. package/dist/memory/EvidenceRenderer.d.ts.map +1 -0
  43. package/dist/memory/EvidenceRenderer.js +119 -0
  44. package/dist/memory/EvidenceRenderer.js.map +1 -0
  45. package/dist/memory/SemanticMemory.d.ts +61 -1
  46. package/dist/memory/SemanticMemory.d.ts.map +1 -1
  47. package/dist/memory/SemanticMemory.js +381 -11
  48. package/dist/memory/SemanticMemory.js.map +1 -1
  49. package/dist/server/AgentServer.d.ts +4 -0
  50. package/dist/server/AgentServer.d.ts.map +1 -1
  51. package/dist/server/AgentServer.js +2 -0
  52. package/dist/server/AgentServer.js.map +1 -1
  53. package/dist/server/routes.d.ts +8 -0
  54. package/dist/server/routes.d.ts.map +1 -1
  55. package/dist/server/routes.js +500 -14
  56. package/dist/server/routes.js.map +1 -1
  57. package/dist/tasks/DivergenceChecker.d.ts +89 -0
  58. package/dist/tasks/DivergenceChecker.d.ts.map +1 -0
  59. package/dist/tasks/DivergenceChecker.js +255 -0
  60. package/dist/tasks/DivergenceChecker.js.map +1 -0
  61. package/dist/tasks/LruCache.d.ts +33 -0
  62. package/dist/tasks/LruCache.d.ts.map +1 -0
  63. package/dist/tasks/LruCache.js +88 -0
  64. package/dist/tasks/LruCache.js.map +1 -0
  65. package/dist/tasks/RateLimiter.d.ts +61 -0
  66. package/dist/tasks/RateLimiter.d.ts.map +1 -0
  67. package/dist/tasks/RateLimiter.js +108 -0
  68. package/dist/tasks/RateLimiter.js.map +1 -0
  69. package/dist/tasks/TaskFlowDueWaker.d.ts +29 -0
  70. package/dist/tasks/TaskFlowDueWaker.d.ts.map +1 -0
  71. package/dist/tasks/TaskFlowDueWaker.js +73 -0
  72. package/dist/tasks/TaskFlowDueWaker.js.map +1 -0
  73. package/dist/tasks/TaskFlowMaintenanceSweeper.d.ts +40 -0
  74. package/dist/tasks/TaskFlowMaintenanceSweeper.d.ts.map +1 -0
  75. package/dist/tasks/TaskFlowMaintenanceSweeper.js +131 -0
  76. package/dist/tasks/TaskFlowMaintenanceSweeper.js.map +1 -0
  77. package/dist/tasks/TaskFlowRegistry.d.ts +180 -0
  78. package/dist/tasks/TaskFlowRegistry.d.ts.map +1 -0
  79. package/dist/tasks/TaskFlowRegistry.js +791 -0
  80. package/dist/tasks/TaskFlowRegistry.js.map +1 -0
  81. package/dist/tasks/ThreadlineFlowBridge.d.ts +54 -0
  82. package/dist/tasks/ThreadlineFlowBridge.d.ts.map +1 -0
  83. package/dist/tasks/ThreadlineFlowBridge.js +129 -0
  84. package/dist/tasks/ThreadlineFlowBridge.js.map +1 -0
  85. package/dist/tasks/task-flow-registry.store.sqlite.d.ts +77 -0
  86. package/dist/tasks/task-flow-registry.store.sqlite.d.ts.map +1 -0
  87. package/dist/tasks/task-flow-registry.store.sqlite.js +350 -0
  88. package/dist/tasks/task-flow-registry.store.sqlite.js.map +1 -0
  89. package/dist/tasks/task-flow-types.d.ts +229 -0
  90. package/dist/tasks/task-flow-types.d.ts.map +1 -0
  91. package/dist/tasks/task-flow-types.js +128 -0
  92. package/dist/tasks/task-flow-types.js.map +1 -0
  93. package/dist/threadline/ThreadlineMCPServer.d.ts +32 -0
  94. package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
  95. package/dist/threadline/ThreadlineMCPServer.js +82 -5
  96. package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
  97. package/dist/threadline/mcp-stdio-entry.js +56 -0
  98. package/dist/threadline/mcp-stdio-entry.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/data/builtin-manifest.json +78 -78
  101. package/upgrades/0.28.84.md +89 -0
  102. package/upgrades/0.28.85.md +64 -0
  103. package/upgrades/side-effects/0.28.83.md +27 -0
  104. package/upgrades/side-effects/taskflow-phase1.md +130 -0
  105. package/upgrades/side-effects/taskflow-phase2.md +110 -0
  106. package/upgrades/side-effects/taskflow-phase3a.md +138 -0
  107. package/upgrades/side-effects/taskflow-phase4.md +377 -0
  108. package/upgrades/side-effects/taskflow-phase5.md +234 -0
  109. package/upgrades/side-effects/threadline-discover-relay-and-trust.md +122 -0
  110. package/upgrades/side-effects/wikiclaim-evidence-phase1.md +138 -0
  111. package/upgrades/side-effects/wikiclaim-evidence-phase2.md +148 -0
  112. package/upgrades/side-effects/wikiclaim-evidence-phase3.md +125 -0
  113. package/upgrades/side-effects/wikiclaim-evidence-phase4.md +129 -0
  114. package/upgrades/side-effects/wikiclaim-evidence-phase5.md +285 -0
@@ -5,26 +5,99 @@
5
5
  * scheduled Jobs (recurring cron task). An Initiative represents a
6
6
  * bounded-but-multi-week effort with phases, each advancing in order.
7
7
  *
8
- * Persistence: `.instar/initiatives.json` (atomic rename pattern).
8
+ * Storage:
9
+ * - When TaskFlow is wired (`setTaskFlowRegistry`), TaskFlow is the
10
+ * **single source of truth** (per OPENCLAW-IMPORT-TASKFLOW-SPEC.md
11
+ * § Phase 4, lines 645–648). Each initiative is one TaskFlow record:
12
+ * `controllerId="InitiativeTracker"`, `ownerKey="initiative:<id>"`,
13
+ * `idempotencyKey="initiative:<id>"`. The full Initiative shape is
14
+ * persisted in `stateJson`. The active phase id maps to `currentStep`;
15
+ * `needsUser` / blockers map to `setFlowWaiting({kind:"human-review"})`.
16
+ * - When TaskFlow is NOT wired, behavior falls back to legacy
17
+ * `.instar/initiatives.json`. This keeps the disabled-feature path
18
+ * working for installs where `taskFlow.enabled` is false.
19
+ *
20
+ * Migration:
21
+ * - On startup, the server calls `migrateExistingToTaskFlow()` once when
22
+ * TaskFlow is enabled. It backfills any initiatives present in the
23
+ * legacy JSON file into TaskFlow. Idempotent via `findIdempotent` on
24
+ * `(controllerId, ownerKey, idempotencyKey)` — running twice produces
25
+ * no duplicates.
26
+ *
27
+ * API shape:
28
+ * - All public mutators are `async` to allow TaskFlow's promise-based
29
+ * write API to complete before returning. Reads are `sync` (TaskFlow's
30
+ * read API uses `better-sqlite3` synchronously through the in-memory
31
+ * cache).
32
+ *
9
33
  * Consumers: HTTP routes (`/initiatives/*`), dashboard "Initiatives" tab,
10
34
  * daily digest job (alerts when initiatives go stale / need user input /
11
35
  * are ready to advance).
12
36
  */
13
37
  import fs from 'node:fs';
14
38
  import path from 'node:path';
39
+ import { TaskFlowError } from '../tasks/task-flow-types.js';
15
40
  /**
16
41
  * Staleness threshold for the digest scan (7 days without an update on an
17
42
  * active initiative triggers a 'stale' flag).
18
43
  */
19
44
  export const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
45
+ /**
46
+ * TaskFlow controller identity for InitiativeTracker (Phase 4 migration).
47
+ * Every initiative's flow is owned by this controllerId; the registry uses
48
+ * it for OCC scope checks. Single instance per server process.
49
+ */
50
+ export const INITIATIVE_TASKFLOW_CONTROLLER_ID = 'InitiativeTracker';
51
+ function ownerKeyForInitiative(initiativeId) {
52
+ return `initiative:${initiativeId}`;
53
+ }
54
+ function idempotencyKeyForInitiative(initiativeId) {
55
+ return `initiative:${initiativeId}`;
56
+ }
20
57
  export class InitiativeTracker {
21
58
  filePath;
59
+ /** In-process cache. Authoritative when TaskFlow is not wired; otherwise
60
+ * a read-side projection of TaskFlow's stateJson. */
22
61
  initiatives = new Map();
62
+ // ── TaskFlow Phase 4 wiring ────────────────────────────────────
63
+ taskFlowRegistry = null;
64
+ taskFlowControllerInstanceId = null;
23
65
  constructor(stateDir) {
24
66
  this.filePath = path.join(stateDir, 'initiatives.json');
25
- this.load();
67
+ this.loadFromDisk();
68
+ }
69
+ /**
70
+ * Wire a TaskFlow registry to make TaskFlow the source of truth.
71
+ * Idempotent — safe to call multiple times. After wiring, all reads come
72
+ * from TaskFlow and all writes go through TaskFlow APIs. The legacy JSON
73
+ * file is no longer written.
74
+ *
75
+ * Callers are expected to call `migrateExistingToTaskFlow()` after this
76
+ * to backfill any initiatives that were loaded from the legacy file.
77
+ */
78
+ setTaskFlowRegistry(registry, controllerInstanceId) {
79
+ this.taskFlowRegistry = registry;
80
+ this.taskFlowControllerInstanceId = controllerInstanceId;
81
+ // Layer existing TaskFlow records over legacy-loaded data without
82
+ // clearing the cache: any legacy initiatives still need to be backfilled
83
+ // via `migrateExistingToTaskFlow()`. TaskFlow records win on collision.
84
+ this.layerCacheFromTaskFlow();
85
+ }
86
+ /** True when TaskFlow is wired and authoritative. */
87
+ isTaskFlowEnabled() {
88
+ return this.taskFlowRegistry !== null && this.taskFlowControllerInstanceId !== null;
89
+ }
90
+ taskFlowPrincipal() {
91
+ if (!this.taskFlowRegistry || !this.taskFlowControllerInstanceId)
92
+ return null;
93
+ return {
94
+ scope: 'controller',
95
+ controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
96
+ controllerInstanceId: this.taskFlowControllerInstanceId,
97
+ };
26
98
  }
27
- load() {
99
+ // ── Legacy JSON storage (used until TaskFlow is wired) ─────────
100
+ loadFromDisk() {
28
101
  try {
29
102
  if (!fs.existsSync(this.filePath))
30
103
  return;
@@ -41,7 +114,9 @@ export class InitiativeTracker {
41
114
  console.error(`[initiatives] Failed to load: ${err instanceof Error ? err.message : err}`);
42
115
  }
43
116
  }
44
- save() {
117
+ saveToDisk() {
118
+ if (this.isTaskFlowEnabled())
119
+ return; // TaskFlow's SQLite is the durable store.
45
120
  const dir = path.dirname(this.filePath);
46
121
  fs.mkdirSync(dir, { recursive: true });
47
122
  const payload = { initiatives: Array.from(this.initiatives.values()) };
@@ -49,24 +124,362 @@ export class InitiativeTracker {
49
124
  fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
50
125
  fs.renameSync(tmp, this.filePath);
51
126
  }
127
+ // ── TaskFlow ↔ Initiative serialization ─────────────────────────
128
+ initiativeFromFlow(flow) {
129
+ const state = flow.stateJson;
130
+ if (!state || typeof state !== 'object' || !state.initiative)
131
+ return null;
132
+ return state.initiative;
133
+ }
134
+ refreshCacheFromTaskFlow() {
135
+ if (!this.taskFlowRegistry)
136
+ return;
137
+ const flows = this.taskFlowRegistry.findByControllerId(INITIATIVE_TASKFLOW_CONTROLLER_ID);
138
+ this.initiatives.clear();
139
+ for (const f of flows) {
140
+ const init = this.initiativeFromFlow(f);
141
+ // Tombstone-removed initiatives are filtered out so callers can't
142
+ // see them via list() / get() after remove().
143
+ if (init && !this.isTombstoned(f)) {
144
+ this.initiatives.set(init.id, init);
145
+ }
146
+ }
147
+ }
148
+ /**
149
+ * Layer TaskFlow data on top of the existing cache without clearing it.
150
+ * Used during `setTaskFlowRegistry` so legacy-loaded initiatives are
151
+ * preserved as backfill candidates while any pre-existing TaskFlow
152
+ * records take precedence.
153
+ */
154
+ layerCacheFromTaskFlow() {
155
+ if (!this.taskFlowRegistry)
156
+ return;
157
+ const flows = this.taskFlowRegistry.findByControllerId(INITIATIVE_TASKFLOW_CONTROLLER_ID);
158
+ for (const f of flows) {
159
+ const init = this.initiativeFromFlow(f);
160
+ if (init && !this.isTombstoned(f)) {
161
+ this.initiatives.set(init.id, init);
162
+ }
163
+ else if (this.isTombstoned(f) && init) {
164
+ // Tombstoned flows shadow legacy entries.
165
+ this.initiatives.delete(init.id);
166
+ }
167
+ }
168
+ }
169
+ /**
170
+ * A flow is "tombstoned" when remove() marked it as removed before
171
+ * cancelling. The marker lives in stateJson._removed and is the
172
+ * single signal that a flow's owning Initiative should be hidden from
173
+ * list() / get(). We can't delete TaskFlow records from this layer, so
174
+ * the marker is the durable equivalent of deletion.
175
+ */
176
+ isTombstoned(flow) {
177
+ const state = flow.stateJson;
178
+ return state?._removed === true;
179
+ }
180
+ waitJsonForInitiative(init) {
181
+ const blocked = init.needsUser || init.blockers.length > 0;
182
+ if (!blocked)
183
+ return null;
184
+ const reason = init.needsUser
185
+ ? init.needsUserReason ?? 'Initiative needs user decision'
186
+ : init.blockers.join('; ');
187
+ const question = (reason || 'Initiative blocked').slice(0, 2000);
188
+ return { kind: 'human-review', question };
189
+ }
190
+ /** Determine the desired TaskFlow status for an Initiative. */
191
+ desiredFlowStatus(init) {
192
+ const phase = init.phases[init.currentPhaseIndex];
193
+ const step = phase ? phase.id : 'unknown';
194
+ if (init.status === 'completed')
195
+ return { kind: 'succeeded', step };
196
+ if (init.status === 'abandoned')
197
+ return { kind: 'failed', step };
198
+ if (init.status === 'archived')
199
+ return { kind: 'cancelled', step };
200
+ const wait = this.waitJsonForInitiative(init);
201
+ if (wait)
202
+ return { kind: 'waiting', step, waitJson: wait };
203
+ return { kind: 'running', step };
204
+ }
205
+ /**
206
+ * Persist an Initiative through TaskFlow. Walks the flow state machine
207
+ * to the desired target (running / waiting / succeeded / failed /
208
+ * cancelled), updating `stateJson` along the way so reads see the latest
209
+ * shape. Returns the Initiative as projected from TaskFlow after
210
+ * transitions settle.
211
+ */
212
+ async persistThroughTaskFlow(init) {
213
+ const registry = this.taskFlowRegistry;
214
+ const principal = this.taskFlowPrincipal();
215
+ if (!registry || !principal || principal.scope !== 'controller')
216
+ return init;
217
+ const ownerKey = ownerKeyForInitiative(init.id);
218
+ const idemKey = idempotencyKeyForInitiative(init.id);
219
+ const desired = this.desiredFlowStatus(init);
220
+ const stateJson = { initiative: init };
221
+ let existing = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKey, idemKey);
222
+ if (!existing) {
223
+ const r = await registry.createFlow({
224
+ controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
225
+ controllerInstanceId: principal.controllerInstanceId,
226
+ ownerKey,
227
+ idempotencyKey: idemKey,
228
+ goal: init.title.slice(0, 1024),
229
+ currentStep: desired.step,
230
+ stateJson,
231
+ });
232
+ existing = r.flow;
233
+ }
234
+ let cur = registry.getFlow(existing.flowId, { bypassCache: true }) ?? existing;
235
+ try {
236
+ // Already terminal? Accept and stop — terminal flows are immutable
237
+ // per TaskFlow contract. The Initiative's local terminal state stays
238
+ // in sync via the cache, but we never re-mutate the flow.
239
+ if (cur.status === 'succeeded' ||
240
+ cur.status === 'failed' ||
241
+ cur.status === 'cancelled' ||
242
+ cur.status === 'lost') {
243
+ return this.initiativeFromFlow(cur) ?? init;
244
+ }
245
+ // queued → running (always; a fresh create lands in queued).
246
+ if (cur.status === 'queued') {
247
+ const r = await registry.startStep({
248
+ flowId: cur.flowId,
249
+ expectedRevision: cur.revision,
250
+ principal,
251
+ currentStep: desired.step,
252
+ });
253
+ cur = r.flow;
254
+ }
255
+ // waiting → running (resume), in case desired wait is different or
256
+ // we're moving to running/terminal. resumeFlow accepts statePatch so
257
+ // we update stateJson here too.
258
+ if (cur.status === 'waiting') {
259
+ if (cur.waitInstanceId) {
260
+ const r = await registry.resumeFlow({
261
+ flowId: cur.flowId,
262
+ expectedRevision: cur.revision,
263
+ principal,
264
+ waitInstanceId: cur.waitInstanceId,
265
+ currentStep: desired.step,
266
+ statePatch: stateJson,
267
+ });
268
+ cur = r.flow;
269
+ }
270
+ }
271
+ // Now cur.status === 'running'. Drive to the desired terminal state.
272
+ if (cur.status === 'running') {
273
+ if (desired.kind === 'running') {
274
+ // Re-startStep when currentStep changed; this updates the step.
275
+ if (cur.currentStep !== desired.step) {
276
+ const r = await registry.startStep({
277
+ flowId: cur.flowId,
278
+ expectedRevision: cur.revision,
279
+ principal,
280
+ currentStep: desired.step,
281
+ });
282
+ cur = r.flow;
283
+ }
284
+ // Always patch stateJson to reflect the latest Initiative shape.
285
+ cur = await this.patchStateJson(cur, stateJson);
286
+ }
287
+ else if (desired.kind === 'waiting') {
288
+ const r = await registry.setFlowWaiting({
289
+ flowId: cur.flowId,
290
+ expectedRevision: cur.revision,
291
+ principal,
292
+ waitJson: desired.waitJson,
293
+ currentStep: desired.step,
294
+ statePatch: stateJson,
295
+ });
296
+ cur = r.flow;
297
+ }
298
+ else if (desired.kind === 'succeeded') {
299
+ cur = await this.patchStateJson(cur, stateJson);
300
+ const r = await registry.finishFlow({
301
+ flowId: cur.flowId,
302
+ expectedRevision: cur.revision,
303
+ principal,
304
+ });
305
+ cur = r.flow;
306
+ }
307
+ else if (desired.kind === 'failed') {
308
+ cur = await this.patchStateJson(cur, stateJson);
309
+ const r = await registry.failFlow({
310
+ flowId: cur.flowId,
311
+ expectedRevision: cur.revision,
312
+ principal,
313
+ failureReason: 'abandoned',
314
+ });
315
+ cur = r.flow;
316
+ }
317
+ else if (desired.kind === 'cancelled') {
318
+ cur = await this.patchStateJson(cur, stateJson);
319
+ const cr = await registry.requestFlowCancel({
320
+ flowId: cur.flowId,
321
+ expectedRevision: cur.revision,
322
+ requesterOrigin: { kind: 'system', id: 'InitiativeTracker' },
323
+ });
324
+ const r = await registry.cancelFlow({
325
+ flowId: cur.flowId,
326
+ expectedRevision: cr.flow.revision,
327
+ principal,
328
+ });
329
+ cur = r.flow;
330
+ }
331
+ }
332
+ }
333
+ catch (err) {
334
+ this.logTaskFlowError('persist', init.id, err);
335
+ }
336
+ const fresh = registry.getFlow(cur.flowId, { bypassCache: true }) ?? cur;
337
+ const persisted = this.initiativeFromFlow(fresh) ?? init;
338
+ this.initiatives.set(persisted.id, persisted);
339
+ return persisted;
340
+ }
341
+ /**
342
+ * Patch a running flow's stateJson without changing its observable
343
+ * status. Uses a brief setFlowWaiting → resumeFlow round-trip (the only
344
+ * atomic state-bearing transitions for `running` flows that accept
345
+ * `statePatch`). The wait kind `'human-review'` with a sentinel question
346
+ * is used; the wait is consumed in the same call, so no external
347
+ * observer sees `waiting`.
348
+ */
349
+ async patchStateJson(flow, stateJson) {
350
+ const registry = this.taskFlowRegistry;
351
+ const principal = this.taskFlowPrincipal();
352
+ if (!registry || !principal || principal.scope !== 'controller')
353
+ return flow;
354
+ if (flow.status !== 'running')
355
+ return flow;
356
+ try {
357
+ const w = await registry.setFlowWaiting({
358
+ flowId: flow.flowId,
359
+ expectedRevision: flow.revision,
360
+ principal,
361
+ waitJson: { kind: 'human-review', question: '__statePatch__' },
362
+ statePatch: stateJson,
363
+ });
364
+ const r = await registry.resumeFlow({
365
+ flowId: w.flow.flowId,
366
+ expectedRevision: w.flow.revision,
367
+ principal,
368
+ waitInstanceId: w.flow.waitInstanceId,
369
+ statePatch: stateJson,
370
+ });
371
+ return r.flow;
372
+ }
373
+ catch (err) {
374
+ this.logTaskFlowError('patchStateJson', flow.flowId, err);
375
+ return flow;
376
+ }
377
+ }
378
+ findFlowIdForInitiative(id) {
379
+ const registry = this.taskFlowRegistry;
380
+ if (!registry)
381
+ return null;
382
+ const f = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKeyForInitiative(id), idempotencyKeyForInitiative(id));
383
+ return f?.flowId ?? null;
384
+ }
385
+ logTaskFlowError(op, key, err) {
386
+ if (err instanceof TaskFlowError) {
387
+ console.warn(`[InitiativeTracker] taskflow ${op} for ${key} skipped: ${err.code} (${err.message})`);
388
+ }
389
+ else {
390
+ console.warn(`[InitiativeTracker] taskflow ${op} for ${key} unexpected error:`, err instanceof Error ? err.message : err);
391
+ }
392
+ }
393
+ /**
394
+ * Backfill all initiatives currently in the in-memory cache (loaded from
395
+ * legacy JSON in the constructor) into TaskFlow. Idempotent via
396
+ * `findIdempotent`. Safe to call multiple times.
397
+ */
398
+ async migrateExistingToTaskFlow() {
399
+ const registry = this.taskFlowRegistry;
400
+ const principal = this.taskFlowPrincipal();
401
+ if (!registry || !principal || principal.scope !== 'controller') {
402
+ return { created: 0, alreadyExisted: 0, advanced: 0, skipped: 0 };
403
+ }
404
+ let created = 0;
405
+ let alreadyExisted = 0;
406
+ let advanced = 0;
407
+ let skipped = 0;
408
+ const candidates = Array.from(this.initiatives.values());
409
+ for (const init of candidates) {
410
+ try {
411
+ const ownerKey = ownerKeyForInitiative(init.id);
412
+ const idemKey = idempotencyKeyForInitiative(init.id);
413
+ const existing = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKey, idemKey);
414
+ if (existing) {
415
+ alreadyExisted++;
416
+ }
417
+ else {
418
+ await registry.createFlow({
419
+ controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
420
+ controllerInstanceId: principal.controllerInstanceId,
421
+ ownerKey,
422
+ idempotencyKey: idemKey,
423
+ goal: init.title.slice(0, 1024),
424
+ currentStep: init.phases[init.currentPhaseIndex]?.id ?? 'start',
425
+ stateJson: { initiative: init },
426
+ });
427
+ created++;
428
+ }
429
+ const persisted = await this.persistThroughTaskFlow(init);
430
+ if (persisted)
431
+ advanced++;
432
+ }
433
+ catch (err) {
434
+ skipped++;
435
+ this.logTaskFlowError('migrate', init.id, err);
436
+ }
437
+ }
438
+ this.refreshCacheFromTaskFlow();
439
+ return { created, alreadyExisted, advanced, skipped };
440
+ }
441
+ // ── Public API ─────────────────────────────────────────────────
52
442
  list(filter) {
443
+ if (this.isTaskFlowEnabled())
444
+ this.refreshCacheFromTaskFlow();
53
445
  const all = Array.from(this.initiatives.values());
54
446
  const filtered = filter?.status ? all.filter((i) => i.status === filter.status) : all;
55
447
  return filtered.sort((a, b) => b.lastTouchedAt.localeCompare(a.lastTouchedAt));
56
448
  }
57
449
  get(id) {
450
+ if (this.isTaskFlowEnabled()) {
451
+ const fid = this.findFlowIdForInitiative(id);
452
+ if (!fid)
453
+ return undefined;
454
+ const f = this.taskFlowRegistry.getFlow(fid, { bypassCache: true });
455
+ if (!f)
456
+ return undefined;
457
+ if (this.isTombstoned(f)) {
458
+ this.initiatives.delete(id);
459
+ return undefined;
460
+ }
461
+ const init = this.initiativeFromFlow(f);
462
+ if (init)
463
+ this.initiatives.set(id, init);
464
+ return init ?? undefined;
465
+ }
58
466
  return this.initiatives.get(id);
59
467
  }
60
- create(input) {
61
- if (this.initiatives.has(input.id)) {
62
- throw new Error(`Initiative "${input.id}" already exists`);
63
- }
468
+ async create(input) {
64
469
  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(input.id)) {
65
470
  throw new Error('Initiative id must be lowercase kebab-case, 1–63 chars');
66
471
  }
67
472
  if (!input.phases.length) {
68
473
  throw new Error('Initiative must have at least one phase');
69
474
  }
475
+ if (this.isTaskFlowEnabled()) {
476
+ if (this.findFlowIdForInitiative(input.id)) {
477
+ throw new Error(`Initiative "${input.id}" already exists`);
478
+ }
479
+ }
480
+ else if (this.initiatives.has(input.id)) {
481
+ throw new Error(`Initiative "${input.id}" already exists`);
482
+ }
70
483
  const now = new Date().toISOString();
71
484
  const phases = input.phases.map((p) => ({
72
485
  id: p.id,
@@ -74,7 +487,6 @@ export class InitiativeTracker {
74
487
  summary: p.summary,
75
488
  status: p.status ?? 'pending',
76
489
  }));
77
- // currentPhaseIndex: first non-'done' phase, else last phase.
78
490
  const firstOpen = phases.findIndex((p) => p.status !== 'done');
79
491
  const currentPhaseIndex = firstOpen === -1 ? phases.length - 1 : firstOpen;
80
492
  const allDone = phases.every((p) => p.status === 'done');
@@ -95,11 +507,14 @@ export class InitiativeTracker {
95
507
  updatedAt: now,
96
508
  };
97
509
  this.initiatives.set(initiative.id, initiative);
98
- this.save();
510
+ if (this.isTaskFlowEnabled()) {
511
+ return await this.persistThroughTaskFlow(initiative);
512
+ }
513
+ this.saveToDisk();
99
514
  return initiative;
100
515
  }
101
- update(id, input) {
102
- const existing = this.initiatives.get(id);
516
+ async update(id, input) {
517
+ const existing = this.get(id);
103
518
  if (!existing)
104
519
  throw new Error(`Initiative "${id}" not found`);
105
520
  const now = new Date().toISOString();
@@ -123,17 +538,14 @@ export class InitiativeTracker {
123
538
  if (input.links !== undefined)
124
539
  next.links = input.links;
125
540
  this.initiatives.set(id, next);
126
- this.save();
541
+ if (this.isTaskFlowEnabled()) {
542
+ return await this.persistThroughTaskFlow(next);
543
+ }
544
+ this.saveToDisk();
127
545
  return next;
128
546
  }
129
- /**
130
- * Transition a phase's status. Updates currentPhaseIndex to point to the
131
- * earliest non-done phase; marks the whole initiative 'completed' when
132
- * all phases are 'done'. Sets startedAt/completedAt the first time a
133
- * phase reaches 'in-progress' / 'done' respectively.
134
- */
135
- setPhaseStatus(id, phaseId, status) {
136
- const existing = this.initiatives.get(id);
547
+ async setPhaseStatus(id, phaseId, status) {
548
+ const existing = this.get(id);
137
549
  if (!existing)
138
550
  throw new Error(`Initiative "${id}" not found`);
139
551
  const phases = existing.phases.map((p) => ({ ...p }));
@@ -157,21 +569,84 @@ export class InitiativeTracker {
157
569
  lastTouchedAt: now,
158
570
  };
159
571
  this.initiatives.set(id, next);
160
- this.save();
572
+ if (this.isTaskFlowEnabled()) {
573
+ return await this.persistThroughTaskFlow(next);
574
+ }
575
+ this.saveToDisk();
161
576
  return next;
162
577
  }
163
- remove(id) {
578
+ async remove(id) {
579
+ if (this.isTaskFlowEnabled()) {
580
+ const fid = this.findFlowIdForInitiative(id);
581
+ if (!fid) {
582
+ return this.initiatives.delete(id);
583
+ }
584
+ const registry = this.taskFlowRegistry;
585
+ const principal = this.taskFlowPrincipal();
586
+ let flow = registry.getFlow(fid, { bypassCache: true });
587
+ if (flow && principal && principal.scope === 'controller') {
588
+ try {
589
+ if (flow.status === 'queued') {
590
+ const r = await registry.startStep({
591
+ flowId: flow.flowId,
592
+ expectedRevision: flow.revision,
593
+ principal,
594
+ currentStep: flow.currentStep ?? 'remove',
595
+ });
596
+ flow = r.flow;
597
+ }
598
+ if (flow.status === 'waiting' && flow.waitInstanceId) {
599
+ const r = await registry.resumeFlow({
600
+ flowId: flow.flowId,
601
+ expectedRevision: flow.revision,
602
+ principal,
603
+ waitInstanceId: flow.waitInstanceId,
604
+ });
605
+ flow = r.flow;
606
+ }
607
+ // Stamp the tombstone marker into stateJson so subsequent reads
608
+ // hide the initiative. Done before cancel because terminal flows
609
+ // are immutable.
610
+ const init = this.initiativeFromFlow(flow);
611
+ if (flow.status === 'running' && init) {
612
+ const tomb = await this.patchStateJson(flow, { initiative: init, _removed: true });
613
+ flow = tomb;
614
+ }
615
+ if (flow.status !== 'succeeded' &&
616
+ flow.status !== 'failed' &&
617
+ flow.status !== 'cancelled' &&
618
+ flow.status !== 'lost') {
619
+ const cr = await registry.requestFlowCancel({
620
+ flowId: flow.flowId,
621
+ expectedRevision: flow.revision,
622
+ requesterOrigin: { kind: 'system', id: 'InitiativeTracker' },
623
+ });
624
+ await registry.cancelFlow({
625
+ flowId: flow.flowId,
626
+ expectedRevision: cr.flow.revision,
627
+ principal,
628
+ });
629
+ }
630
+ }
631
+ catch (err) {
632
+ this.logTaskFlowError('remove', id, err);
633
+ }
634
+ }
635
+ this.initiatives.delete(id);
636
+ return true;
637
+ }
164
638
  const removed = this.initiatives.delete(id);
165
639
  if (removed)
166
- this.save();
640
+ this.saveToDisk();
167
641
  return removed;
168
642
  }
169
643
  /**
170
- * Scan active initiatives for anything actionable. The digest job uses
171
- * this to decide whether to send a push notification. Empty items[]
172
- * means "quiet day, don't spam the user."
644
+ * Scan active initiatives for anything actionable. Empty items[] means
645
+ * "quiet day, don't spam the user."
173
646
  */
174
647
  digest(now = new Date()) {
648
+ if (this.isTaskFlowEnabled())
649
+ this.refreshCacheFromTaskFlow();
175
650
  const items = [];
176
651
  const nowMs = now.getTime();
177
652
  for (const initiative of this.initiatives.values()) {
@@ -198,10 +673,6 @@ export class InitiativeTracker {
198
673
  continue;
199
674
  }
200
675
  }
201
- // Ready-to-advance: the previous phase is done AND the current
202
- // phase is still untouched ('pending'). Once the current phase
203
- // is 'in-progress' or 'blocked', the advance has already begun,
204
- // so we stop emitting this signal.
205
676
  const current = initiative.phases[initiative.currentPhaseIndex];
206
677
  const previous = initiative.currentPhaseIndex > 0
207
678
  ? initiative.phases[initiative.currentPhaseIndex - 1]