instar 0.28.84 → 0.28.86

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 (48) hide show
  1. package/dist/commands/intent.d.ts.map +1 -1
  2. package/dist/commands/intent.js +29 -2
  3. package/dist/commands/intent.js.map +1 -1
  4. package/dist/commands/server.d.ts.map +1 -1
  5. package/dist/commands/server.js +14 -0
  6. package/dist/commands/server.js.map +1 -1
  7. package/dist/core/DecisionJournal.d.ts +66 -3
  8. package/dist/core/DecisionJournal.d.ts.map +1 -1
  9. package/dist/core/DecisionJournal.js +116 -3
  10. package/dist/core/DecisionJournal.js.map +1 -1
  11. package/dist/core/DispatchExecutor.d.ts +74 -1
  12. package/dist/core/DispatchExecutor.d.ts.map +1 -1
  13. package/dist/core/DispatchExecutor.js +120 -1
  14. package/dist/core/DispatchExecutor.js.map +1 -1
  15. package/dist/core/EvolutionManager.d.ts +60 -0
  16. package/dist/core/EvolutionManager.d.ts.map +1 -1
  17. package/dist/core/EvolutionManager.js +118 -0
  18. package/dist/core/EvolutionManager.js.map +1 -1
  19. package/dist/core/InitiativeTracker.d.ts +256 -14
  20. package/dist/core/InitiativeTracker.d.ts.map +1 -1
  21. package/dist/core/InitiativeTracker.js +826 -34
  22. package/dist/core/InitiativeTracker.js.map +1 -1
  23. package/dist/core/LearnSkillBridge.d.ts +119 -0
  24. package/dist/core/LearnSkillBridge.d.ts.map +1 -0
  25. package/dist/core/LearnSkillBridge.js +160 -0
  26. package/dist/core/LearnSkillBridge.js.map +1 -0
  27. package/dist/core/types.d.ts +34 -0
  28. package/dist/core/types.d.ts.map +1 -1
  29. package/dist/core/types.js.map +1 -1
  30. package/dist/server/routes.d.ts.map +1 -1
  31. package/dist/server/routes.js +131 -14
  32. package/dist/server/routes.js.map +1 -1
  33. package/dist/threadline/ThreadlineMCPServer.d.ts +32 -0
  34. package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
  35. package/dist/threadline/ThreadlineMCPServer.js +82 -5
  36. package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
  37. package/dist/threadline/mcp-stdio-entry.js +56 -0
  38. package/dist/threadline/mcp-stdio-entry.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/data/builtin-manifest.json +47 -47
  41. package/upgrades/0.28.85.md +64 -0
  42. package/upgrades/0.28.86.md +86 -0
  43. package/upgrades/side-effects/0.28.83.md +27 -0
  44. package/upgrades/side-effects/project-scope-phase1a-pr1.md +232 -0
  45. package/upgrades/side-effects/taskflow-phase4.md +377 -0
  46. package/upgrades/side-effects/threadline-discover-relay-and-trust.md +122 -0
  47. package/upgrades/side-effects/wikiclaim-evidence-phase2.md +148 -0
  48. package/upgrades/side-effects/wikiclaim-evidence-phase3.md +125 -0
@@ -5,68 +5,591 @@
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';
40
+ /**
41
+ * Thrown by `update()` when the caller supplied `ifMatch` and it didn't
42
+ * equal the current `version`. The HTTP layer (Phase 1.3) translates this
43
+ * to a 409 response with body `{ currentVersion }`.
44
+ */
45
+ export class OccVersionMismatchError extends Error {
46
+ currentVersion;
47
+ constructor(message, currentVersion) {
48
+ super(message);
49
+ this.name = 'OccVersionMismatchError';
50
+ this.currentVersion = currentVersion;
51
+ }
52
+ }
53
+ /**
54
+ * Thrown by `update()` when the caller attempted to change `kind`. The
55
+ * `kind` field is set at creation time and never changes thereafter.
56
+ */
57
+ export class KindImmutableError extends Error {
58
+ constructor(message = '`kind` is immutable after initiative creation') {
59
+ super(message);
60
+ this.name = 'KindImmutableError';
61
+ }
62
+ }
63
+ /**
64
+ * Thrown by `update()` when setting `parentProjectId` and the named parent
65
+ * either doesn't exist, isn't a `kind: 'project'` initiative, or doesn't
66
+ * list this child in any of its `rounds[].itemIds`. Bidirectional check
67
+ * keeps the parent/child relationship internally consistent.
68
+ */
69
+ export class InvalidParentProjectError extends Error {
70
+ constructor(message) {
71
+ super(message);
72
+ this.name = 'InvalidParentProjectError';
73
+ }
74
+ }
15
75
  /**
16
76
  * Staleness threshold for the digest scan (7 days without an update on an
17
77
  * active initiative triggers a 'stale' flag).
18
78
  */
19
79
  export const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
80
+ /**
81
+ * TaskFlow controller identity for InitiativeTracker (Phase 4 migration).
82
+ * Every initiative's flow is owned by this controllerId; the registry uses
83
+ * it for OCC scope checks. Single instance per server process.
84
+ */
85
+ export const INITIATIVE_TASKFLOW_CONTROLLER_ID = 'InitiativeTracker';
86
+ /**
87
+ * JSON.stringify replacer that omits any property whose value is exactly
88
+ * `undefined`. Native JSON.stringify already drops undefined object values,
89
+ * but using an explicit replacer makes intent visible AND ensures we never
90
+ * accidentally serialize a `null` for an optional field — the runtime
91
+ * guards in `update()` already reject `null`, so this is belt-and-braces.
92
+ *
93
+ * Round-trip stability: a record loaded and saved without mutation
94
+ * produces byte-identical output (asserted by the unit tests).
95
+ */
96
+ function omitUndefinedReplacer(_key, value) {
97
+ return value === undefined ? undefined : value;
98
+ }
99
+ /**
100
+ * Reject `null` for any of the project-scope optional fields when supplied
101
+ * via `InitiativeCreateInput`. (Update inputs are field-by-field; null
102
+ * means "clear" for nullable fields and "reject" for the rest, enforced
103
+ * inline in `update()`.)
104
+ */
105
+ function rejectNullCreateInput(input) {
106
+ const forbiddenNullKeys = [
107
+ 'kind',
108
+ 'parentProjectId',
109
+ 'pipelineStage',
110
+ 'specPath',
111
+ 'prNumber',
112
+ 'mergeCommitOid',
113
+ 'ciCheckedAt',
114
+ 'driftCheck',
115
+ 'rounds',
116
+ 'sourceDocs',
117
+ 'autoAdvance',
118
+ 'telegramTopicId',
119
+ 'ownerMachineId',
120
+ 'targetRepoPath',
121
+ 'driftPromptTemplateVersion',
122
+ ];
123
+ for (const k of forbiddenNullKeys) {
124
+ if (input[k] === null) {
125
+ throw new Error(`Initiative create input field "${k}" must not be null`);
126
+ }
127
+ }
128
+ }
129
+ function ownerKeyForInitiative(initiativeId) {
130
+ return `initiative:${initiativeId}`;
131
+ }
132
+ function idempotencyKeyForInitiative(initiativeId) {
133
+ return `initiative:${initiativeId}`;
134
+ }
20
135
  export class InitiativeTracker {
21
136
  filePath;
137
+ /** In-process cache. Authoritative when TaskFlow is not wired; otherwise
138
+ * a read-side projection of TaskFlow's stateJson. */
22
139
  initiatives = new Map();
140
+ // ── TaskFlow Phase 4 wiring ────────────────────────────────────
141
+ taskFlowRegistry = null;
142
+ taskFlowControllerInstanceId = null;
143
+ // ── Digest cache invalidator hook (Phase 1.1) ──────────────────
144
+ // PR 3 wires the real invalidator (clears the project-scope digest cache
145
+ // so the next read recomputes). Until then this is a no-op. The hook
146
+ // fires after every successful mutating call.
147
+ digestCacheInvalidator = () => { };
23
148
  constructor(stateDir) {
24
149
  this.filePath = path.join(stateDir, 'initiatives.json');
25
- this.load();
150
+ this.loadFromDisk();
151
+ }
152
+ /**
153
+ * Register a callback invoked once after every successful mutating call
154
+ * (`create`, `update`, `setPhaseStatus`, `remove`). The default is a
155
+ * no-op so legacy callers see no change. PR 3 wires the real cache
156
+ * invalidator.
157
+ */
158
+ setDigestCacheInvalidator(fn) {
159
+ this.digestCacheInvalidator = typeof fn === 'function' ? fn : () => { };
160
+ }
161
+ /**
162
+ * Wire a TaskFlow registry to make TaskFlow the source of truth.
163
+ * Idempotent — safe to call multiple times. After wiring, all reads come
164
+ * from TaskFlow and all writes go through TaskFlow APIs. The legacy JSON
165
+ * file is no longer written.
166
+ *
167
+ * Callers are expected to call `migrateExistingToTaskFlow()` after this
168
+ * to backfill any initiatives that were loaded from the legacy file.
169
+ */
170
+ setTaskFlowRegistry(registry, controllerInstanceId) {
171
+ this.taskFlowRegistry = registry;
172
+ this.taskFlowControllerInstanceId = controllerInstanceId;
173
+ // Layer existing TaskFlow records over legacy-loaded data without
174
+ // clearing the cache: any legacy initiatives still need to be backfilled
175
+ // via `migrateExistingToTaskFlow()`. TaskFlow records win on collision.
176
+ this.layerCacheFromTaskFlow();
177
+ }
178
+ /** True when TaskFlow is wired and authoritative. */
179
+ isTaskFlowEnabled() {
180
+ return this.taskFlowRegistry !== null && this.taskFlowControllerInstanceId !== null;
26
181
  }
27
- load() {
182
+ taskFlowPrincipal() {
183
+ if (!this.taskFlowRegistry || !this.taskFlowControllerInstanceId)
184
+ return null;
185
+ return {
186
+ scope: 'controller',
187
+ controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
188
+ controllerInstanceId: this.taskFlowControllerInstanceId,
189
+ };
190
+ }
191
+ // ── Legacy JSON storage (used until TaskFlow is wired) ─────────
192
+ loadFromDisk() {
28
193
  try {
29
194
  if (!fs.existsSync(this.filePath))
30
195
  return;
31
196
  const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
32
197
  if (Array.isArray(raw?.initiatives)) {
198
+ let backfilled = 0;
33
199
  for (const item of raw.initiatives) {
34
200
  if (item && typeof item.id === 'string') {
201
+ // Phase 1.1 idempotent backfill: legacy records (missing `kind`)
202
+ // get `kind: 'task'` + `schemaVersion: 1`. Records that already
203
+ // carry `kind` are untouched. Second load is a no-op because
204
+ // nothing changes after the first pass.
205
+ if (item.kind === undefined) {
206
+ item.kind = 'task';
207
+ if (item.schemaVersion === undefined)
208
+ item.schemaVersion = 1;
209
+ backfilled++;
210
+ }
35
211
  this.initiatives.set(item.id, item);
36
212
  }
37
213
  }
214
+ // Rewrite the file exactly once if any record was backfilled. The
215
+ // rewrite uses the same omit-undefined replacer as `saveToDisk()`
216
+ // so the file is stable on a subsequent load.
217
+ if (backfilled > 0) {
218
+ this.saveToDisk();
219
+ }
38
220
  }
39
221
  }
40
222
  catch (err) {
41
223
  console.error(`[initiatives] Failed to load: ${err instanceof Error ? err.message : err}`);
42
224
  }
43
225
  }
44
- save() {
226
+ saveToDisk() {
227
+ if (this.isTaskFlowEnabled())
228
+ return; // TaskFlow's SQLite is the durable store.
45
229
  const dir = path.dirname(this.filePath);
46
230
  fs.mkdirSync(dir, { recursive: true });
47
231
  const payload = { initiatives: Array.from(this.initiatives.values()) };
48
232
  const tmp = `${this.filePath}.${process.pid}.tmp`;
49
- fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
233
+ fs.writeFileSync(tmp, JSON.stringify(payload, omitUndefinedReplacer, 2));
50
234
  fs.renameSync(tmp, this.filePath);
51
235
  }
236
+ // ── TaskFlow ↔ Initiative serialization ─────────────────────────
237
+ initiativeFromFlow(flow) {
238
+ const state = flow.stateJson;
239
+ if (!state || typeof state !== 'object' || !state.initiative)
240
+ return null;
241
+ return state.initiative;
242
+ }
243
+ refreshCacheFromTaskFlow() {
244
+ if (!this.taskFlowRegistry)
245
+ return;
246
+ const flows = this.taskFlowRegistry.findByControllerId(INITIATIVE_TASKFLOW_CONTROLLER_ID);
247
+ this.initiatives.clear();
248
+ for (const f of flows) {
249
+ const init = this.initiativeFromFlow(f);
250
+ // Tombstone-removed initiatives are filtered out so callers can't
251
+ // see them via list() / get() after remove().
252
+ if (init && !this.isTombstoned(f)) {
253
+ this.initiatives.set(init.id, init);
254
+ }
255
+ }
256
+ }
257
+ /**
258
+ * Layer TaskFlow data on top of the existing cache without clearing it.
259
+ * Used during `setTaskFlowRegistry` so legacy-loaded initiatives are
260
+ * preserved as backfill candidates while any pre-existing TaskFlow
261
+ * records take precedence.
262
+ */
263
+ layerCacheFromTaskFlow() {
264
+ if (!this.taskFlowRegistry)
265
+ return;
266
+ const flows = this.taskFlowRegistry.findByControllerId(INITIATIVE_TASKFLOW_CONTROLLER_ID);
267
+ for (const f of flows) {
268
+ const init = this.initiativeFromFlow(f);
269
+ if (init && !this.isTombstoned(f)) {
270
+ this.initiatives.set(init.id, init);
271
+ }
272
+ else if (this.isTombstoned(f) && init) {
273
+ // Tombstoned flows shadow legacy entries.
274
+ this.initiatives.delete(init.id);
275
+ }
276
+ }
277
+ }
278
+ /**
279
+ * A flow is "tombstoned" when remove() marked it as removed before
280
+ * cancelling. The marker lives in stateJson._removed and is the
281
+ * single signal that a flow's owning Initiative should be hidden from
282
+ * list() / get(). We can't delete TaskFlow records from this layer, so
283
+ * the marker is the durable equivalent of deletion.
284
+ */
285
+ isTombstoned(flow) {
286
+ const state = flow.stateJson;
287
+ return state?._removed === true;
288
+ }
289
+ waitJsonForInitiative(init) {
290
+ const blocked = init.needsUser || init.blockers.length > 0;
291
+ if (!blocked)
292
+ return null;
293
+ const reason = init.needsUser
294
+ ? init.needsUserReason ?? 'Initiative needs user decision'
295
+ : init.blockers.join('; ');
296
+ const question = (reason || 'Initiative blocked').slice(0, 2000);
297
+ return { kind: 'human-review', question };
298
+ }
299
+ /** Determine the desired TaskFlow status for an Initiative. */
300
+ desiredFlowStatus(init) {
301
+ const phase = init.phases[init.currentPhaseIndex];
302
+ const step = phase ? phase.id : 'unknown';
303
+ if (init.status === 'completed')
304
+ return { kind: 'succeeded', step };
305
+ if (init.status === 'abandoned')
306
+ return { kind: 'failed', step };
307
+ if (init.status === 'archived')
308
+ return { kind: 'cancelled', step };
309
+ const wait = this.waitJsonForInitiative(init);
310
+ if (wait)
311
+ return { kind: 'waiting', step, waitJson: wait };
312
+ return { kind: 'running', step };
313
+ }
314
+ /**
315
+ * Persist an Initiative through TaskFlow. Walks the flow state machine
316
+ * to the desired target (running / waiting / succeeded / failed /
317
+ * cancelled), updating `stateJson` along the way so reads see the latest
318
+ * shape. Returns the Initiative as projected from TaskFlow after
319
+ * transitions settle.
320
+ */
321
+ async persistThroughTaskFlow(init) {
322
+ const registry = this.taskFlowRegistry;
323
+ const principal = this.taskFlowPrincipal();
324
+ if (!registry || !principal || principal.scope !== 'controller')
325
+ return init;
326
+ const ownerKey = ownerKeyForInitiative(init.id);
327
+ const idemKey = idempotencyKeyForInitiative(init.id);
328
+ const desired = this.desiredFlowStatus(init);
329
+ const stateJson = { initiative: init };
330
+ let existing = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKey, idemKey);
331
+ if (!existing) {
332
+ const r = await registry.createFlow({
333
+ controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
334
+ controllerInstanceId: principal.controllerInstanceId,
335
+ ownerKey,
336
+ idempotencyKey: idemKey,
337
+ goal: init.title.slice(0, 1024),
338
+ currentStep: desired.step,
339
+ stateJson,
340
+ });
341
+ existing = r.flow;
342
+ }
343
+ let cur = registry.getFlow(existing.flowId, { bypassCache: true }) ?? existing;
344
+ try {
345
+ // Already terminal? Accept and stop — terminal flows are immutable
346
+ // per TaskFlow contract. The Initiative's local terminal state stays
347
+ // in sync via the cache, but we never re-mutate the flow.
348
+ if (cur.status === 'succeeded' ||
349
+ cur.status === 'failed' ||
350
+ cur.status === 'cancelled' ||
351
+ cur.status === 'lost') {
352
+ return this.initiativeFromFlow(cur) ?? init;
353
+ }
354
+ // queued → running (always; a fresh create lands in queued).
355
+ if (cur.status === 'queued') {
356
+ const r = await registry.startStep({
357
+ flowId: cur.flowId,
358
+ expectedRevision: cur.revision,
359
+ principal,
360
+ currentStep: desired.step,
361
+ });
362
+ cur = r.flow;
363
+ }
364
+ // waiting → running (resume), in case desired wait is different or
365
+ // we're moving to running/terminal. resumeFlow accepts statePatch so
366
+ // we update stateJson here too.
367
+ if (cur.status === 'waiting') {
368
+ if (cur.waitInstanceId) {
369
+ const r = await registry.resumeFlow({
370
+ flowId: cur.flowId,
371
+ expectedRevision: cur.revision,
372
+ principal,
373
+ waitInstanceId: cur.waitInstanceId,
374
+ currentStep: desired.step,
375
+ statePatch: stateJson,
376
+ });
377
+ cur = r.flow;
378
+ }
379
+ }
380
+ // Now cur.status === 'running'. Drive to the desired terminal state.
381
+ if (cur.status === 'running') {
382
+ if (desired.kind === 'running') {
383
+ // Re-startStep when currentStep changed; this updates the step.
384
+ if (cur.currentStep !== desired.step) {
385
+ const r = await registry.startStep({
386
+ flowId: cur.flowId,
387
+ expectedRevision: cur.revision,
388
+ principal,
389
+ currentStep: desired.step,
390
+ });
391
+ cur = r.flow;
392
+ }
393
+ // Always patch stateJson to reflect the latest Initiative shape.
394
+ cur = await this.patchStateJson(cur, stateJson);
395
+ }
396
+ else if (desired.kind === 'waiting') {
397
+ const r = await registry.setFlowWaiting({
398
+ flowId: cur.flowId,
399
+ expectedRevision: cur.revision,
400
+ principal,
401
+ waitJson: desired.waitJson,
402
+ currentStep: desired.step,
403
+ statePatch: stateJson,
404
+ });
405
+ cur = r.flow;
406
+ }
407
+ else if (desired.kind === 'succeeded') {
408
+ cur = await this.patchStateJson(cur, stateJson);
409
+ const r = await registry.finishFlow({
410
+ flowId: cur.flowId,
411
+ expectedRevision: cur.revision,
412
+ principal,
413
+ });
414
+ cur = r.flow;
415
+ }
416
+ else if (desired.kind === 'failed') {
417
+ cur = await this.patchStateJson(cur, stateJson);
418
+ const r = await registry.failFlow({
419
+ flowId: cur.flowId,
420
+ expectedRevision: cur.revision,
421
+ principal,
422
+ failureReason: 'abandoned',
423
+ });
424
+ cur = r.flow;
425
+ }
426
+ else if (desired.kind === 'cancelled') {
427
+ cur = await this.patchStateJson(cur, stateJson);
428
+ const cr = await registry.requestFlowCancel({
429
+ flowId: cur.flowId,
430
+ expectedRevision: cur.revision,
431
+ requesterOrigin: { kind: 'system', id: 'InitiativeTracker' },
432
+ });
433
+ const r = await registry.cancelFlow({
434
+ flowId: cur.flowId,
435
+ expectedRevision: cr.flow.revision,
436
+ principal,
437
+ });
438
+ cur = r.flow;
439
+ }
440
+ }
441
+ }
442
+ catch (err) {
443
+ this.logTaskFlowError('persist', init.id, err);
444
+ }
445
+ const fresh = registry.getFlow(cur.flowId, { bypassCache: true }) ?? cur;
446
+ const persisted = this.initiativeFromFlow(fresh) ?? init;
447
+ this.initiatives.set(persisted.id, persisted);
448
+ return persisted;
449
+ }
450
+ /**
451
+ * Patch a running flow's stateJson without changing its observable
452
+ * status. Uses a brief setFlowWaiting → resumeFlow round-trip (the only
453
+ * atomic state-bearing transitions for `running` flows that accept
454
+ * `statePatch`). The wait kind `'human-review'` with a sentinel question
455
+ * is used; the wait is consumed in the same call, so no external
456
+ * observer sees `waiting`.
457
+ */
458
+ async patchStateJson(flow, stateJson) {
459
+ const registry = this.taskFlowRegistry;
460
+ const principal = this.taskFlowPrincipal();
461
+ if (!registry || !principal || principal.scope !== 'controller')
462
+ return flow;
463
+ if (flow.status !== 'running')
464
+ return flow;
465
+ try {
466
+ const w = await registry.setFlowWaiting({
467
+ flowId: flow.flowId,
468
+ expectedRevision: flow.revision,
469
+ principal,
470
+ waitJson: { kind: 'human-review', question: '__statePatch__' },
471
+ statePatch: stateJson,
472
+ });
473
+ const r = await registry.resumeFlow({
474
+ flowId: w.flow.flowId,
475
+ expectedRevision: w.flow.revision,
476
+ principal,
477
+ waitInstanceId: w.flow.waitInstanceId,
478
+ statePatch: stateJson,
479
+ });
480
+ return r.flow;
481
+ }
482
+ catch (err) {
483
+ this.logTaskFlowError('patchStateJson', flow.flowId, err);
484
+ return flow;
485
+ }
486
+ }
487
+ findFlowIdForInitiative(id) {
488
+ const registry = this.taskFlowRegistry;
489
+ if (!registry)
490
+ return null;
491
+ const f = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKeyForInitiative(id), idempotencyKeyForInitiative(id));
492
+ return f?.flowId ?? null;
493
+ }
494
+ logTaskFlowError(op, key, err) {
495
+ if (err instanceof TaskFlowError) {
496
+ console.warn(`[InitiativeTracker] taskflow ${op} for ${key} skipped: ${err.code} (${err.message})`);
497
+ }
498
+ else {
499
+ console.warn(`[InitiativeTracker] taskflow ${op} for ${key} unexpected error:`, err instanceof Error ? err.message : err);
500
+ }
501
+ }
502
+ /**
503
+ * Backfill all initiatives currently in the in-memory cache (loaded from
504
+ * legacy JSON in the constructor) into TaskFlow. Idempotent via
505
+ * `findIdempotent`. Safe to call multiple times.
506
+ */
507
+ async migrateExistingToTaskFlow() {
508
+ const registry = this.taskFlowRegistry;
509
+ const principal = this.taskFlowPrincipal();
510
+ if (!registry || !principal || principal.scope !== 'controller') {
511
+ return { created: 0, alreadyExisted: 0, advanced: 0, skipped: 0 };
512
+ }
513
+ let created = 0;
514
+ let alreadyExisted = 0;
515
+ let advanced = 0;
516
+ let skipped = 0;
517
+ const candidates = Array.from(this.initiatives.values());
518
+ for (const init of candidates) {
519
+ try {
520
+ const ownerKey = ownerKeyForInitiative(init.id);
521
+ const idemKey = idempotencyKeyForInitiative(init.id);
522
+ const existing = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKey, idemKey);
523
+ if (existing) {
524
+ alreadyExisted++;
525
+ }
526
+ else {
527
+ await registry.createFlow({
528
+ controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
529
+ controllerInstanceId: principal.controllerInstanceId,
530
+ ownerKey,
531
+ idempotencyKey: idemKey,
532
+ goal: init.title.slice(0, 1024),
533
+ currentStep: init.phases[init.currentPhaseIndex]?.id ?? 'start',
534
+ stateJson: { initiative: init },
535
+ });
536
+ created++;
537
+ }
538
+ const persisted = await this.persistThroughTaskFlow(init);
539
+ if (persisted)
540
+ advanced++;
541
+ }
542
+ catch (err) {
543
+ skipped++;
544
+ this.logTaskFlowError('migrate', init.id, err);
545
+ }
546
+ }
547
+ this.refreshCacheFromTaskFlow();
548
+ return { created, alreadyExisted, advanced, skipped };
549
+ }
550
+ // ── Public API ─────────────────────────────────────────────────
52
551
  list(filter) {
552
+ if (this.isTaskFlowEnabled())
553
+ this.refreshCacheFromTaskFlow();
53
554
  const all = Array.from(this.initiatives.values());
54
555
  const filtered = filter?.status ? all.filter((i) => i.status === filter.status) : all;
55
556
  return filtered.sort((a, b) => b.lastTouchedAt.localeCompare(a.lastTouchedAt));
56
557
  }
57
558
  get(id) {
559
+ if (this.isTaskFlowEnabled()) {
560
+ const fid = this.findFlowIdForInitiative(id);
561
+ if (!fid)
562
+ return undefined;
563
+ const f = this.taskFlowRegistry.getFlow(fid, { bypassCache: true });
564
+ if (!f)
565
+ return undefined;
566
+ if (this.isTombstoned(f)) {
567
+ this.initiatives.delete(id);
568
+ return undefined;
569
+ }
570
+ const init = this.initiativeFromFlow(f);
571
+ if (init)
572
+ this.initiatives.set(id, init);
573
+ return init ?? undefined;
574
+ }
58
575
  return this.initiatives.get(id);
59
576
  }
60
- create(input) {
61
- if (this.initiatives.has(input.id)) {
62
- throw new Error(`Initiative "${input.id}" already exists`);
63
- }
577
+ async create(input) {
64
578
  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(input.id)) {
65
579
  throw new Error('Initiative id must be lowercase kebab-case, 1–63 chars');
66
580
  }
67
581
  if (!input.phases.length) {
68
582
  throw new Error('Initiative must have at least one phase');
69
583
  }
584
+ rejectNullCreateInput(input);
585
+ if (this.isTaskFlowEnabled()) {
586
+ if (this.findFlowIdForInitiative(input.id)) {
587
+ throw new Error(`Initiative "${input.id}" already exists`);
588
+ }
589
+ }
590
+ else if (this.initiatives.has(input.id)) {
591
+ throw new Error(`Initiative "${input.id}" already exists`);
592
+ }
70
593
  const now = new Date().toISOString();
71
594
  const phases = input.phases.map((p) => ({
72
595
  id: p.id,
@@ -74,7 +597,6 @@ export class InitiativeTracker {
74
597
  summary: p.summary,
75
598
  status: p.status ?? 'pending',
76
599
  }));
77
- // currentPhaseIndex: first non-'done' phase, else last phase.
78
600
  const firstOpen = phases.findIndex((p) => p.status !== 'done');
79
601
  const currentPhaseIndex = firstOpen === -1 ? phases.length - 1 : firstOpen;
80
602
  const allDone = phases.every((p) => p.status === 'done');
@@ -93,15 +615,76 @@ export class InitiativeTracker {
93
615
  links: input.links ?? [],
94
616
  createdAt: now,
95
617
  updatedAt: now,
618
+ // Project-scope: default kind to 'task' and start version at 1. Both
619
+ // are persisted from create-time so subsequent backfill is a no-op.
620
+ kind: input.kind ?? 'task',
621
+ schemaVersion: 1,
622
+ version: 1,
96
623
  };
624
+ // Optional project-layer fields are passed through if present. Stored
625
+ // as-is; runtime validation (parentProjectId existence, etc.) happens
626
+ // on subsequent updates that mutate the relationship.
627
+ if (input.parentProjectId !== undefined)
628
+ initiative.parentProjectId = input.parentProjectId;
629
+ if (input.pipelineStage !== undefined)
630
+ initiative.pipelineStage = input.pipelineStage;
631
+ if (input.specPath !== undefined)
632
+ initiative.specPath = input.specPath;
633
+ if (input.prNumber !== undefined)
634
+ initiative.prNumber = input.prNumber;
635
+ if (input.mergeCommitOid !== undefined)
636
+ initiative.mergeCommitOid = input.mergeCommitOid;
637
+ if (input.ciCheckedAt !== undefined)
638
+ initiative.ciCheckedAt = input.ciCheckedAt;
639
+ if (input.driftCheck !== undefined)
640
+ initiative.driftCheck = input.driftCheck;
641
+ if (input.rounds !== undefined)
642
+ initiative.rounds = input.rounds;
643
+ if (input.sourceDocs !== undefined)
644
+ initiative.sourceDocs = input.sourceDocs;
645
+ if (input.autoAdvance !== undefined)
646
+ initiative.autoAdvance = input.autoAdvance;
647
+ if (input.telegramTopicId !== undefined)
648
+ initiative.telegramTopicId = input.telegramTopicId;
649
+ if (input.ownerMachineId !== undefined)
650
+ initiative.ownerMachineId = input.ownerMachineId;
651
+ if (input.targetRepoPath !== undefined)
652
+ initiative.targetRepoPath = input.targetRepoPath;
653
+ if (input.driftPromptTemplateVersion !== undefined) {
654
+ initiative.driftPromptTemplateVersion = input.driftPromptTemplateVersion;
655
+ }
97
656
  this.initiatives.set(initiative.id, initiative);
98
- this.save();
99
- return initiative;
657
+ let result;
658
+ if (this.isTaskFlowEnabled()) {
659
+ result = await this.persistThroughTaskFlow(initiative);
660
+ }
661
+ else {
662
+ this.saveToDisk();
663
+ result = initiative;
664
+ }
665
+ this.digestCacheInvalidator();
666
+ return result;
100
667
  }
101
- update(id, input) {
102
- const existing = this.initiatives.get(id);
668
+ async update(id, input) {
669
+ const existing = this.get(id);
103
670
  if (!existing)
104
671
  throw new Error(`Initiative "${id}" not found`);
672
+ // ── kind immutability check (Phase 1.1) ────────────────────────
673
+ // The `kind` field is set at creation and never mutates. We treat the
674
+ // current kind as 'task' when undefined (legacy records pre-backfill).
675
+ if (input.kind !== undefined) {
676
+ const currentKind = existing.kind ?? 'task';
677
+ if (input.kind !== currentKind) {
678
+ throw new KindImmutableError(`Cannot change kind from "${currentKind}" to "${input.kind}"`);
679
+ }
680
+ }
681
+ // ── OCC version check (Phase 1.1) ──────────────────────────────
682
+ // Only enforce when caller supplied ifMatch — preserves backward
683
+ // compatibility for legacy callers that never knew about versioning.
684
+ const currentVersion = existing.version ?? 1;
685
+ if (input.ifMatch !== undefined && input.ifMatch !== currentVersion) {
686
+ throw new OccVersionMismatchError(`Initiative "${id}" version mismatch: expected ${input.ifMatch}, current ${currentVersion}`, currentVersion);
687
+ }
105
688
  const now = new Date().toISOString();
106
689
  const next = { ...existing, updatedAt: now, lastTouchedAt: now };
107
690
  if (input.title !== undefined)
@@ -122,18 +705,113 @@ export class InitiativeTracker {
122
705
  next.blockers = input.blockers;
123
706
  if (input.links !== undefined)
124
707
  next.links = input.links;
708
+ // ── Project-scope field updates ────────────────────────────────
709
+ // Bidirectional parentProjectId validation: when setting (not clearing),
710
+ // the named parent must exist as kind:'project' AND list this child in
711
+ // rounds[].itemIds. Clearing (null) skips validation.
712
+ if (input.parentProjectId !== undefined) {
713
+ if (input.parentProjectId === null) {
714
+ next.parentProjectId = undefined;
715
+ }
716
+ else {
717
+ this.assertValidParentProject(input.parentProjectId, id);
718
+ next.parentProjectId = input.parentProjectId;
719
+ }
720
+ }
721
+ if (input.pipelineStage !== undefined)
722
+ next.pipelineStage = input.pipelineStage;
723
+ if (input.specPath !== undefined) {
724
+ next.specPath = input.specPath === null ? undefined : input.specPath;
725
+ }
726
+ if (input.prNumber !== undefined) {
727
+ next.prNumber = input.prNumber === null ? undefined : input.prNumber;
728
+ }
729
+ if (input.mergeCommitOid !== undefined) {
730
+ next.mergeCommitOid = input.mergeCommitOid === null ? undefined : input.mergeCommitOid;
731
+ }
732
+ if (input.ciCheckedAt !== undefined) {
733
+ next.ciCheckedAt = input.ciCheckedAt === null ? undefined : input.ciCheckedAt;
734
+ }
735
+ if (input.skippedAt !== undefined) {
736
+ next.skippedAt = input.skippedAt === null ? undefined : input.skippedAt;
737
+ }
738
+ if (input.skippedBy !== undefined) {
739
+ next.skippedBy = input.skippedBy === null ? undefined : input.skippedBy;
740
+ }
741
+ if (input.skippedReason !== undefined) {
742
+ next.skippedReason = input.skippedReason === null ? undefined : input.skippedReason;
743
+ }
744
+ if (input.unskippedAt !== undefined) {
745
+ next.unskippedAt = input.unskippedAt === null ? undefined : input.unskippedAt;
746
+ }
747
+ if (input.driftCheck !== undefined)
748
+ next.driftCheck = input.driftCheck;
749
+ if (input.rounds !== undefined)
750
+ next.rounds = input.rounds;
751
+ if (input.sourceDocs !== undefined)
752
+ next.sourceDocs = input.sourceDocs;
753
+ if (input.autoAdvance !== undefined)
754
+ next.autoAdvance = input.autoAdvance;
755
+ if (input.telegramTopicId !== undefined) {
756
+ next.telegramTopicId = input.telegramTopicId === null ? undefined : input.telegramTopicId;
757
+ }
758
+ if (input.ownerMachineId !== undefined) {
759
+ next.ownerMachineId = input.ownerMachineId === null ? undefined : input.ownerMachineId;
760
+ }
761
+ if (input.targetRepoPath !== undefined)
762
+ next.targetRepoPath = input.targetRepoPath;
763
+ if (input.unacknowledgedAdvanceCount !== undefined) {
764
+ next.unacknowledgedAdvanceCount = input.unacknowledgedAdvanceCount;
765
+ }
766
+ if (input.firstLaunchAckAt !== undefined) {
767
+ next.firstLaunchAckAt =
768
+ input.firstLaunchAckAt === null ? undefined : input.firstLaunchAckAt;
769
+ }
770
+ if (input.lastAckedRoundIndex !== undefined) {
771
+ next.lastAckedRoundIndex = input.lastAckedRoundIndex;
772
+ }
773
+ if (input.awaitingReconciliation !== undefined) {
774
+ next.awaitingReconciliation = input.awaitingReconciliation;
775
+ }
776
+ if (input.driftPromptTemplateVersion !== undefined) {
777
+ next.driftPromptTemplateVersion = input.driftPromptTemplateVersion;
778
+ }
779
+ // Bump version on every successful write.
780
+ next.version = currentVersion + 1;
125
781
  this.initiatives.set(id, next);
126
- this.save();
127
- return next;
782
+ let result;
783
+ if (this.isTaskFlowEnabled()) {
784
+ result = await this.persistThroughTaskFlow(next);
785
+ }
786
+ else {
787
+ this.saveToDisk();
788
+ result = next;
789
+ }
790
+ this.digestCacheInvalidator();
791
+ return result;
128
792
  }
129
793
  /**
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.
794
+ * Verify that `parentId` names an existing `kind: 'project'` initiative
795
+ * AND that one of its rounds lists `childId` in itemIds. Throws
796
+ * `InvalidParentProjectError` on any failure. Used by `update()` when
797
+ * a child's `parentProjectId` is set to a non-null value.
134
798
  */
135
- setPhaseStatus(id, phaseId, status) {
136
- const existing = this.initiatives.get(id);
799
+ assertValidParentProject(parentId, childId) {
800
+ const parent = this.get(parentId);
801
+ if (!parent) {
802
+ throw new InvalidParentProjectError(`Parent project "${parentId}" not found`);
803
+ }
804
+ if ((parent.kind ?? 'task') !== 'project') {
805
+ throw new InvalidParentProjectError(`Initiative "${parentId}" is not a project (kind="${parent.kind ?? 'task'}")`);
806
+ }
807
+ const rounds = parent.rounds ?? [];
808
+ const listed = rounds.some((r) => Array.isArray(r.itemIds) && r.itemIds.includes(childId));
809
+ if (!listed) {
810
+ throw new InvalidParentProjectError(`Project "${parentId}" does not list child "${childId}" in any round`);
811
+ }
812
+ }
813
+ async setPhaseStatus(id, phaseId, status) {
814
+ const existing = this.get(id);
137
815
  if (!existing)
138
816
  throw new Error(`Initiative "${id}" not found`);
139
817
  const phases = existing.phases.map((p) => ({ ...p }));
@@ -155,23 +833,141 @@ export class InitiativeTracker {
155
833
  status: allDone ? 'completed' : existing.status === 'completed' ? 'active' : existing.status,
156
834
  updatedAt: now,
157
835
  lastTouchedAt: now,
836
+ version: (existing.version ?? 1) + 1,
158
837
  };
159
838
  this.initiatives.set(id, next);
160
- this.save();
161
- return next;
839
+ let result;
840
+ if (this.isTaskFlowEnabled()) {
841
+ result = await this.persistThroughTaskFlow(next);
842
+ }
843
+ else {
844
+ this.saveToDisk();
845
+ result = next;
846
+ }
847
+ this.digestCacheInvalidator();
848
+ return result;
162
849
  }
163
- remove(id) {
850
+ async remove(id) {
851
+ if (this.isTaskFlowEnabled()) {
852
+ const fid = this.findFlowIdForInitiative(id);
853
+ if (!fid) {
854
+ const removed = this.initiatives.delete(id);
855
+ if (removed)
856
+ this.digestCacheInvalidator();
857
+ return removed;
858
+ }
859
+ const registry = this.taskFlowRegistry;
860
+ const principal = this.taskFlowPrincipal();
861
+ let flow = registry.getFlow(fid, { bypassCache: true });
862
+ if (flow && principal && principal.scope === 'controller') {
863
+ try {
864
+ if (flow.status === 'queued') {
865
+ const r = await registry.startStep({
866
+ flowId: flow.flowId,
867
+ expectedRevision: flow.revision,
868
+ principal,
869
+ currentStep: flow.currentStep ?? 'remove',
870
+ });
871
+ flow = r.flow;
872
+ }
873
+ if (flow.status === 'waiting' && flow.waitInstanceId) {
874
+ const r = await registry.resumeFlow({
875
+ flowId: flow.flowId,
876
+ expectedRevision: flow.revision,
877
+ principal,
878
+ waitInstanceId: flow.waitInstanceId,
879
+ });
880
+ flow = r.flow;
881
+ }
882
+ // Stamp the tombstone marker into stateJson so subsequent reads
883
+ // hide the initiative. Done before cancel because terminal flows
884
+ // are immutable.
885
+ const init = this.initiativeFromFlow(flow);
886
+ if (flow.status === 'running' && init) {
887
+ const tomb = await this.patchStateJson(flow, { initiative: init, _removed: true });
888
+ flow = tomb;
889
+ }
890
+ if (flow.status !== 'succeeded' &&
891
+ flow.status !== 'failed' &&
892
+ flow.status !== 'cancelled' &&
893
+ flow.status !== 'lost') {
894
+ const cr = await registry.requestFlowCancel({
895
+ flowId: flow.flowId,
896
+ expectedRevision: flow.revision,
897
+ requesterOrigin: { kind: 'system', id: 'InitiativeTracker' },
898
+ });
899
+ await registry.cancelFlow({
900
+ flowId: flow.flowId,
901
+ expectedRevision: cr.flow.revision,
902
+ principal,
903
+ });
904
+ }
905
+ }
906
+ catch (err) {
907
+ this.logTaskFlowError('remove', id, err);
908
+ }
909
+ }
910
+ this.initiatives.delete(id);
911
+ this.digestCacheInvalidator();
912
+ return true;
913
+ }
164
914
  const removed = this.initiatives.delete(id);
165
- if (removed)
166
- this.save();
915
+ if (removed) {
916
+ this.saveToDisk();
917
+ this.digestCacheInvalidator();
918
+ }
167
919
  return removed;
168
920
  }
169
921
  /**
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."
922
+ * One-time backfill helper for TaskFlow-enabled installs: scans all
923
+ * records, identifies those missing `kind`, and updates them through
924
+ * TaskFlow. Idempotent — running twice produces no change after the
925
+ * first pass. The legacy-JSON path runs an equivalent backfill inside
926
+ * `loadFromDisk()` on first load.
927
+ *
928
+ * Returns counts so callers can log/observe what happened.
929
+ */
930
+ async backfillKindAndSchema() {
931
+ if (!this.isTaskFlowEnabled()) {
932
+ // Legacy path is backfilled during load. A no-op here is correct
933
+ // because the in-memory cache already reflects the backfill.
934
+ let backfilled = 0;
935
+ for (const init of this.initiatives.values()) {
936
+ if (init.kind === undefined) {
937
+ init.kind = 'task';
938
+ if (init.schemaVersion === undefined)
939
+ init.schemaVersion = 1;
940
+ backfilled++;
941
+ }
942
+ }
943
+ if (backfilled > 0)
944
+ this.saveToDisk();
945
+ return { backfilled, scanned: this.initiatives.size };
946
+ }
947
+ this.refreshCacheFromTaskFlow();
948
+ let backfilled = 0;
949
+ const records = Array.from(this.initiatives.values());
950
+ for (const init of records) {
951
+ if (init.kind !== undefined)
952
+ continue;
953
+ const patched = {
954
+ ...init,
955
+ kind: 'task',
956
+ schemaVersion: init.schemaVersion ?? 1,
957
+ };
958
+ this.initiatives.set(init.id, patched);
959
+ await this.persistThroughTaskFlow(patched);
960
+ backfilled++;
961
+ }
962
+ return { backfilled, scanned: records.length };
963
+ }
964
+ /**
965
+ * Scan active initiatives for anything actionable. Empty items[] means
966
+ * "quiet day, don't spam the user."
173
967
  */
174
968
  digest(now = new Date()) {
969
+ if (this.isTaskFlowEnabled())
970
+ this.refreshCacheFromTaskFlow();
175
971
  const items = [];
176
972
  const nowMs = now.getTime();
177
973
  for (const initiative of this.initiatives.values()) {
@@ -198,10 +994,6 @@ export class InitiativeTracker {
198
994
  continue;
199
995
  }
200
996
  }
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
997
  const current = initiative.phases[initiative.currentPhaseIndex];
206
998
  const previous = initiative.currentPhaseIndex > 0
207
999
  ? initiative.phases[initiative.currentPhaseIndex - 1]