@quolu/lattice 0.50.1 → 0.52.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.
Files changed (42) hide show
  1. package/bin/lattice-work-order-adapter.mjs +20 -0
  2. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -0
  3. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -0
  4. package/package.json +5 -2
  5. package/src/boundary-observation-compiler-v2.mjs +1 -1
  6. package/src/cli-help.mjs +33 -2
  7. package/src/rc3-actual-dogfood.mjs +6 -2
  8. package/src/rc3-scripted-campaign.mjs +37 -10
  9. package/src/rc4-stage1-dogfood.mjs +6 -2
  10. package/src/runtime-adapter-registry.mjs +21 -7
  11. package/src/runtime-cli.mjs +476 -34
  12. package/src/runtime-contracts.mjs +59 -13
  13. package/src/runtime-controller-protocol.mjs +48 -3
  14. package/src/runtime-decision-verifier.mjs +70 -0
  15. package/src/runtime-diff-observer.mjs +66 -4
  16. package/src/runtime-direct-os-observer.mjs +25 -8
  17. package/src/runtime-driver-state.mjs +162 -0
  18. package/src/runtime-engine.mjs +37 -6
  19. package/src/runtime-front-end.mjs +39 -1
  20. package/src/runtime-managed-supervisor.mjs +80 -14
  21. package/src/runtime-multi-epoch-store.mjs +87 -14
  22. package/src/runtime-pull-intake.mjs +1188 -0
  23. package/src/runtime-work-order-contracts.mjs +91 -0
  24. package/src/runtime-work-order-controller.mjs +1167 -0
  25. package/src/seam-proposal-queries.mjs +1 -1
  26. package/src/todo-cli.mjs +273 -7
  27. package/src/todo-contracts.mjs +19 -2
  28. package/src/todo-gantt-html-independence.mjs +3 -2
  29. package/src/todo-gantt-html-shared.mjs +1 -2
  30. package/src/todo-gantt-html-style.mjs +13 -0
  31. package/src/todo-gantt-html.mjs +15 -2
  32. package/src/todo-gantt-layout.mjs +75 -1
  33. package/src/todo-gantt-nested.mjs +263 -0
  34. package/src/todo-gantt-svg.mjs +80 -5
  35. package/src/todo-independence-contracts.mjs +73 -7
  36. package/src/todo-independence-guidance.mjs +30 -1
  37. package/src/todo-independence.mjs +89 -7
  38. package/src/todo-revision.mjs +1 -1
  39. package/src/todo-split.mjs +472 -0
  40. package/src/todo-status.mjs +10 -1
  41. package/src/todo-store-git-transaction.mjs +418 -0
  42. package/src/todo-store.mjs +144 -4
@@ -0,0 +1,418 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import {
5
+ cp, mkdir, mkdtemp, open, readFile, rename, rm, writeFile,
6
+ } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { promisify } from 'node:util';
9
+
10
+ import { selfDigest } from './runtime-contracts.mjs';
11
+ import {
12
+ RuntimeLifecycleLockError,
13
+ acquireRuntimeLifecycleLock,
14
+ } from './runtime-lifecycle-lock.mjs';
15
+
16
+ const execFileAsync = promisify(execFile);
17
+ const STORE_REF = '.lattice/todo';
18
+ const LOCK_NAME = 'lattice-todo-store-commit.lock';
19
+
20
+ export class TodoStoreGitTransactionError extends Error {
21
+ constructor(code, message, detail = {}) {
22
+ super(message);
23
+ this.name = 'TodoStoreGitTransactionError';
24
+ this.code = code;
25
+ this.detail = detail;
26
+ }
27
+ }
28
+
29
+ function fail(code, message, detail = {}) {
30
+ throw new TodoStoreGitTransactionError(code, message, detail);
31
+ }
32
+
33
+ async function git(repoRoot, args, { env = process.env, allowExitCodes = [0] } = {}) {
34
+ try {
35
+ const result = await execFileAsync('git', args, {
36
+ cwd: repoRoot,
37
+ env,
38
+ encoding: 'buffer',
39
+ maxBuffer: 16 * 1024 * 1024,
40
+ });
41
+ return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 };
42
+ } catch (error) {
43
+ const exitCode = Number.isInteger(error?.code) ? error.code : null;
44
+ if (exitCode !== null && allowExitCodes.includes(exitCode)) {
45
+ return {
46
+ stdout: Buffer.isBuffer(error.stdout) ? error.stdout : Buffer.from(error.stdout ?? ''),
47
+ stderr: Buffer.isBuffer(error.stderr) ? error.stderr : Buffer.from(error.stderr ?? ''),
48
+ exitCode,
49
+ };
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ function text(buffer) { return buffer.toString('utf8').trim(); }
56
+ function fields(buffer) {
57
+ return buffer.toString('utf8').split('\0').filter((value) => value.length > 0);
58
+ }
59
+
60
+ function failureSummary(error) {
61
+ return {
62
+ code: typeof error?.code === 'string' ? error.code : null,
63
+ type: error?.constructor?.name ?? 'Error',
64
+ message: typeof error?.message === 'string' ? error.message.slice(0, 1_024) : null,
65
+ };
66
+ }
67
+
68
+ function repositoryGitEnvironment(env) {
69
+ const result = { ...env };
70
+ for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) {
71
+ delete result[key];
72
+ }
73
+ return result;
74
+ }
75
+
76
+ async function commonGitDir(repoRoot, env) {
77
+ const result = await git(repoRoot,
78
+ ['rev-parse', '--path-format=absolute', '--git-common-dir'], { env });
79
+ const resolved = text(result.stdout);
80
+ if (!path.isAbsolute(resolved)) fail('STORE_COMMIT_GIT_INVALID', 'git_common_dir_not_absolute');
81
+ return resolved;
82
+ }
83
+
84
+ export async function resolveTodoStoreCommitLockPath({ repoRoot, env = process.env }) {
85
+ return path.join(await commonGitDir(repoRoot, env), LOCK_NAME);
86
+ }
87
+
88
+ async function head(repoRoot, env) {
89
+ const result = await git(repoRoot, ['rev-parse', '--verify', 'HEAD'], {
90
+ env, allowExitCodes: [0, 128],
91
+ });
92
+ if (result.exitCode !== 0) {
93
+ fail('STORE_COMMIT_HEAD_UNBORN', 'todo_store_commit_requires_head');
94
+ }
95
+ return text(result.stdout);
96
+ }
97
+
98
+ async function headTarget(repoRoot, env) {
99
+ const symbolic = await git(repoRoot, ['symbolic-ref', '-q', 'HEAD'], {
100
+ env, allowExitCodes: [0, 1],
101
+ });
102
+ return symbolic.exitCode === 0 ? text(symbolic.stdout) : 'HEAD';
103
+ }
104
+
105
+ async function storeStatus(repoRoot, env) {
106
+ const result = await git(repoRoot,
107
+ ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', STORE_REF], { env });
108
+ return fields(result.stdout);
109
+ }
110
+
111
+ async function actualIndexPath(repoRoot, env) {
112
+ const result = await git(repoRoot,
113
+ ['rev-parse', '--path-format=absolute', '--git-path', 'index'], { env });
114
+ const resolved = text(result.stdout);
115
+ if (!path.isAbsolute(resolved)) fail('STORE_COMMIT_GIT_INVALID', 'git_index_path_not_absolute');
116
+ return resolved;
117
+ }
118
+
119
+ async function syncPath(target) {
120
+ const handle = await open(target, fsConstants.O_RDONLY);
121
+ try { await handle.sync(); } finally { await handle.close(); }
122
+ }
123
+
124
+ async function prepareSharedIndex({ repoRoot, commitSha, env }) {
125
+ const indexPath = await actualIndexPath(repoRoot, env);
126
+ const lockPath = `${indexPath}.lock`;
127
+ let lockHandle;
128
+ try {
129
+ // 元indexを開いてからlockを作ると、その隙に別processがindexを置換し得る。
130
+ // wxでlock所有を先に確定し、その後に安定した元index bytesを複製する。
131
+ lockHandle = await open(lockPath,
132
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
133
+ } catch (error) {
134
+ if (error?.code === 'EEXIST') {
135
+ fail('STORE_COMMIT_INDEX_BUSY', 'git_index_lock_already_exists', { lock_path: lockPath });
136
+ }
137
+ throw error;
138
+ }
139
+ try {
140
+ await lockHandle.writeFile(await readFile(indexPath));
141
+ await lockHandle.sync();
142
+ await lockHandle.close();
143
+ lockHandle = null;
144
+ const isolatedEnv = { ...env, GIT_INDEX_FILE: lockPath };
145
+ await git(repoRoot, ['reset', '--quiet', commitSha, '--', STORE_REF], { env: isolatedEnv });
146
+ const indexCompared = await git(repoRoot,
147
+ ['diff', '--quiet', '--cached', commitSha, '--', STORE_REF], {
148
+ env: isolatedEnv, allowExitCodes: [0, 1],
149
+ });
150
+ const worktreeCompared = await git(repoRoot,
151
+ ['diff', '--quiet', commitSha, '--', STORE_REF], {
152
+ env: isolatedEnv, allowExitCodes: [0, 1],
153
+ });
154
+ if (indexCompared.exitCode !== 0 || worktreeCompared.exitCode !== 0) {
155
+ fail('STORE_COMMIT_INDEX_DIRTY', 'prepared_todo_store_index_not_clean', {
156
+ index_matches_commit: indexCompared.exitCode === 0,
157
+ worktree_matches_commit: worktreeCompared.exitCode === 0,
158
+ });
159
+ }
160
+ await syncPath(lockPath);
161
+ return {
162
+ indexPath,
163
+ lockPath,
164
+ async commit() {
165
+ await rename(lockPath, indexPath);
166
+ },
167
+ async rollback() {
168
+ await rm(lockPath, { force: true });
169
+ },
170
+ };
171
+ } catch (error) {
172
+ if (lockHandle !== null) await lockHandle.close().catch(() => {});
173
+ await rm(lockPath, { force: true });
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ async function restoreStore({ repoRoot, backupStore, env }) {
179
+ const storeRoot = path.join(repoRoot, STORE_REF);
180
+ await rm(storeRoot, { recursive: true, force: true });
181
+ await mkdir(path.dirname(storeRoot), { recursive: true });
182
+ await cp(backupStore, storeRoot, { recursive: true, force: false, errorOnExist: true });
183
+ const dirty = await storeStatus(repoRoot, env);
184
+ if (dirty.length > 0) {
185
+ fail('STORE_COMMIT_ROLLBACK_FAILED', 'todo_store_rollback_not_clean', { paths: dirty });
186
+ }
187
+ }
188
+
189
+ function commitIdentity(argv) {
190
+ const firstFlag = argv.findIndex((value) => typeof value === 'string' && value.startsWith('--'));
191
+ const operation = argv.slice(0, firstFlag < 0 ? argv.length : firstFlag).join('-');
192
+ const valueAfter = (flag) => {
193
+ const index = argv.indexOf(flag);
194
+ return index >= 0 && typeof argv[index + 1] === 'string' ? argv[index + 1] : null;
195
+ };
196
+ const plan = valueAfter('--plan');
197
+ const task = valueAfter('--task');
198
+ const phase = valueAfter('--phase');
199
+ const labels = [operation, plan === null ? null : `plan=${plan}`,
200
+ task === null ? null : `task=${task}`, phase === null ? null : `phase=${phase}`]
201
+ .filter(Boolean);
202
+ return {
203
+ operation: operation || 'write',
204
+ message: `Lattice ToDo状態更新: ${labels.join(' ')}`,
205
+ };
206
+ }
207
+
208
+ async function buildDetachedCommit({ repoRoot, commonDir, headBefore, message, env }) {
209
+ const adminDir = await mkdtemp(path.join(commonDir, 'lattice-todo-commit-'));
210
+ const indexPath = path.join(adminDir, 'index');
211
+ const messagePath = path.join(adminDir, 'message');
212
+ try {
213
+ await mkdir(path.join(adminDir, 'logs'), { recursive: true });
214
+ await writeFile(path.join(adminDir, 'commondir'), '..\n', { flag: 'wx' });
215
+ await writeFile(path.join(adminDir, 'HEAD'), `${headBefore}\n`, { flag: 'wx' });
216
+ await writeFile(messagePath, `${message}\n`, { flag: 'wx' });
217
+ const isolatedEnv = {
218
+ ...env,
219
+ GIT_DIR: adminDir,
220
+ GIT_WORK_TREE: repoRoot,
221
+ GIT_INDEX_FILE: indexPath,
222
+ };
223
+ await git(repoRoot, ['read-tree', headBefore], { env: isolatedEnv });
224
+ await git(repoRoot, ['add', '-A', '--', STORE_REF], { env: isolatedEnv });
225
+ const changed = fields((await git(repoRoot,
226
+ ['diff', '--cached', '--name-only', '-z', '--', STORE_REF], { env: isolatedEnv })).stdout);
227
+ if (changed.length === 0) fail('STORE_COMMIT_NO_CHANGES', 'todo_store_mutation_changed_no_paths');
228
+ if (changed.some((ref) => ref !== STORE_REF && !ref.startsWith(`${STORE_REF}/`))) {
229
+ fail('STORE_COMMIT_SCOPE_VIOLATION', 'todo_store_commit_contains_outside_path', {
230
+ paths: changed,
231
+ });
232
+ }
233
+ await git(repoRoot, ['commit', '--quiet', '--no-status', '--file', messagePath], {
234
+ env: isolatedEnv,
235
+ });
236
+ const commitSha = text((await git(repoRoot, ['rev-parse', 'HEAD'], { env: isolatedEnv })).stdout);
237
+ return { commitSha, changed };
238
+ } finally {
239
+ await rm(adminDir, { recursive: true, force: true });
240
+ }
241
+ }
242
+
243
+ async function updateHead({ repoRoot, target, commitSha, headBefore, env }) {
244
+ try {
245
+ await git(repoRoot, ['update-ref', target, commitSha, headBefore], { env });
246
+ } catch (error) {
247
+ const current = await head(repoRoot, env).catch(() => null);
248
+ if (current !== headBefore) {
249
+ fail('STORE_COMMIT_HEAD_CONFLICT', 'head_changed_before_store_commit', {
250
+ expected_head: headBefore,
251
+ actual_head: current,
252
+ });
253
+ }
254
+ throw error;
255
+ }
256
+ }
257
+
258
+ function receipt({ operationResult, commitSha, headBefore, changed, message }) {
259
+ const result = {
260
+ schema: 'lattice.todo_store_atomic_commit_result.v1',
261
+ operation_result: operationResult,
262
+ commit: {
263
+ schema: 'lattice.todo_store_commit_receipt.v1',
264
+ commit_sha: commitSha,
265
+ parent_sha: headBefore,
266
+ paths: changed,
267
+ message,
268
+ },
269
+ result_digest: '',
270
+ };
271
+ result.result_digest = selfDigest(result, 'result_digest');
272
+ return result;
273
+ }
274
+
275
+ /**
276
+ * Todo store mutationをcommon Git-dir lockの内側で実行し、store pathだけをcommitする。
277
+ * sourceのworking treeと、store以外の共有index entryには触れない。
278
+ */
279
+ export async function commitTodoStoreMutation({
280
+ repoRoot,
281
+ argv,
282
+ action,
283
+ env = process.env,
284
+ lockTimeoutMs = 0,
285
+ } = {}) {
286
+ if (typeof repoRoot !== 'string' || !path.isAbsolute(repoRoot)
287
+ || !Array.isArray(argv) || typeof action !== 'function'
288
+ || env === null || typeof env !== 'object' || Array.isArray(env)
289
+ || !Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 0) {
290
+ throw new TypeError('commitTodoStoreMutation optionsが不正');
291
+ }
292
+ const gitEnv = repositoryGitEnvironment(env);
293
+ const commonDir = await commonGitDir(repoRoot, gitEnv);
294
+ const { operation, message } = commitIdentity(argv);
295
+ const nonce = randomUUID();
296
+ const lockPath = path.join(commonDir, LOCK_NAME);
297
+ let lock;
298
+ try {
299
+ lock = await acquireRuntimeLifecycleLock({
300
+ lockPath,
301
+ sessionNonceDigest: createHash('sha256').update(nonce).digest('hex'),
302
+ operation: `todo-${operation}`.slice(0, 128),
303
+ requestId: `todo-${process.pid}-${nonce.replaceAll('-', '').slice(0, 16)}`,
304
+ timeoutMs: lockTimeoutMs,
305
+ });
306
+ } catch (error) {
307
+ if (error instanceof RuntimeLifecycleLockError) {
308
+ fail(error.code, 'todo_store_commit_lock_failed', {
309
+ lock_path: lockPath,
310
+ reason: error.detail,
311
+ });
312
+ }
313
+ throw error;
314
+ }
315
+ let transactionDir = null;
316
+ let backupStore = null;
317
+ let refUpdated = false;
318
+ let commitSha = null;
319
+ let backupReady = false;
320
+ let preparedIndex = null;
321
+ let primaryFailure = null;
322
+ try {
323
+ try {
324
+ transactionDir = await mkdtemp(path.join(commonDir, 'lattice-todo-rollback-'));
325
+ backupStore = path.join(transactionDir, 'todo');
326
+ const dirty = await storeStatus(repoRoot, gitEnv);
327
+ if (dirty.length > 0) {
328
+ fail('STORE_COMMIT_DIRTY', 'todo_store_dirty_at_atomic_entry', { paths: dirty });
329
+ }
330
+ const storeRoot = path.join(repoRoot, STORE_REF);
331
+ await cp(storeRoot, backupStore, { recursive: true, force: false, errorOnExist: true });
332
+ backupReady = true;
333
+ const headBefore = await head(repoRoot, gitEnv);
334
+ const target = await headTarget(repoRoot, gitEnv);
335
+ const operationResult = await action(repoRoot);
336
+ const built = await buildDetachedCommit({
337
+ repoRoot, commonDir, headBefore, message, env: gitEnv,
338
+ });
339
+ commitSha = built.commitSha;
340
+ preparedIndex = await prepareSharedIndex({ repoRoot, commitSha, env: gitEnv });
341
+ await updateHead({ repoRoot, target, commitSha, headBefore, env: gitEnv });
342
+ refUpdated = true;
343
+ try {
344
+ // 共有index lockを保持したまま、store entryだけ整合済みのindexへatomic renameする。
345
+ await preparedIndex.commit();
346
+ preparedIndex = null;
347
+ } catch (error) {
348
+ try {
349
+ await updateHead({
350
+ repoRoot, target, commitSha: headBefore, headBefore: commitSha, env: gitEnv,
351
+ });
352
+ refUpdated = false;
353
+ } catch (recoveryError) {
354
+ fail('STORE_COMMIT_RECOVERY_REQUIRED', 'todo_store_index_finalize_and_ref_rollback_failed', {
355
+ commit_sha: commitSha,
356
+ index_lock_path: preparedIndex?.lockPath ?? null,
357
+ cause: recoveryError?.code ?? recoveryError?.constructor?.name ?? 'Error',
358
+ });
359
+ }
360
+ if (error instanceof TodoStoreGitTransactionError) throw error;
361
+ fail('STORE_COMMIT_FINALIZE_FAILED', 'todo_store_index_finalize_failed', {
362
+ commit_sha: commitSha,
363
+ });
364
+ }
365
+ return receipt({
366
+ operationResult, commitSha, headBefore, changed: built.changed, message,
367
+ });
368
+ } catch (error) {
369
+ if (!refUpdated) {
370
+ try {
371
+ if (backupReady) await restoreStore({ repoRoot, backupStore, env: gitEnv });
372
+ } catch (rollbackError) {
373
+ if (rollbackError instanceof TodoStoreGitTransactionError) throw rollbackError;
374
+ fail('STORE_COMMIT_ROLLBACK_FAILED', 'todo_store_rollback_failed', {
375
+ cause: rollbackError?.constructor?.name ?? 'Error',
376
+ });
377
+ }
378
+ }
379
+ if (error instanceof TypeError || error instanceof TodoStoreGitTransactionError
380
+ || (typeof error?.code === 'string' && error.detail !== null
381
+ && typeof error.detail === 'object')) throw error;
382
+ fail('STORE_COMMIT_FAILED', 'todo_store_git_commit_failed', {
383
+ cause: error?.constructor?.name ?? 'Error',
384
+ commit_sha: commitSha,
385
+ });
386
+ }
387
+ } catch (error) {
388
+ primaryFailure = error;
389
+ throw error;
390
+ } finally {
391
+ const cleanupFailures = [];
392
+ const indexLockPath = preparedIndex?.lockPath ?? null;
393
+ if (preparedIndex !== null) {
394
+ try { await preparedIndex.rollback(); }
395
+ catch (error) { cleanupFailures.push(error); }
396
+ }
397
+ if (transactionDir !== null) {
398
+ try { await rm(transactionDir, { recursive: true, force: true }); }
399
+ catch (error) { cleanupFailures.push(error); }
400
+ }
401
+ try { await lock.release(); }
402
+ catch (error) { cleanupFailures.push(error); }
403
+ if (cleanupFailures.length > 0) {
404
+ throw new TodoStoreGitTransactionError(
405
+ refUpdated ? 'STORE_COMMIT_POST_COMMIT_CLEANUP_FAILED' : 'STORE_COMMIT_CLEANUP_FAILED',
406
+ refUpdated ? 'todo_store_commit_succeeded_but_cleanup_failed' : 'todo_store_cleanup_failed',
407
+ {
408
+ commit_sha: refUpdated ? commitSha : null,
409
+ index_lock_path: indexLockPath,
410
+ primary: primaryFailure === null ? null : failureSummary(primaryFailure),
411
+ failures: cleanupFailures.map((error) => (
412
+ typeof error?.code === 'string' ? error.code : error?.constructor?.name ?? 'Error'
413
+ )),
414
+ },
415
+ );
416
+ }
417
+ }
418
+ }
@@ -363,6 +363,32 @@ export function projectTodoCoordination(events) {
363
363
  };
364
364
  }
365
365
 
366
+ /**
367
+ * active plan群へ接続済みのplan跨ぎ依存線を投影する。
368
+ *
369
+ * eventはconsumer(to)planのplan-scoped chainへ積まれる。自動発見も暗黙補完もせず、
370
+ * 記録された線だけを返す。順序はtask identityで固定し、status/Ganttが同じ入力を見る。
371
+ */
372
+ export function projectTodoCrossPlanDependencies(members) {
373
+ const dependencies = [];
374
+ for (const member of members) for (const event of member.plan_scoped?.events ?? []) {
375
+ if (event.kind !== 'cross_plan_dependency') continue;
376
+ dependencies.push({
377
+ from: event.payload.from,
378
+ to: event.payload.to,
379
+ reason: event.payload.reason,
380
+ connected_by: event.actor,
381
+ connected_at: event.recorded_at,
382
+ event_digest: event.event_digest,
383
+ });
384
+ }
385
+ return dependencies.sort((left, right) => {
386
+ const key = (entry) => `${entry.from.project_id}\0${entry.from.plan_key}\0${entry.from.task_id}`
387
+ + `\0${entry.to.project_id}\0${entry.to.plan_key}\0${entry.to.task_id}\0${entry.event_digest}`;
388
+ return key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0;
389
+ });
390
+ }
391
+
366
392
  function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
367
393
  const state = phaseStates.get(phaseId);
368
394
  // ADR 0148: closed_unauditedも他の終端状態と同じく確定済みとして扱う。ここへ足さないと、
@@ -442,6 +468,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
442
468
  const phaseStates = new Map(phasesOf(plan).map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
443
469
  const doneDigest = new Map();
444
470
  const completion = new Map();
471
+ const authoredStartBindings = new Map();
445
472
  const importedGenesis = events[0]?.payload.historical_import === true;
446
473
  let previousTime = null;
447
474
  for (const event of events) {
@@ -608,7 +635,31 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
608
635
  fail('STORE_INCONSISTENT', 'invalid_start_transition');
609
636
  }
610
637
  state.status = 'in-progress'; state.started_at = event.recorded_at;
638
+ authoredStartBindings.set(event.task_id, {
639
+ actor: structuredClone(event.actor), event_digest: event.event_digest,
640
+ });
641
+ }
642
+ } else if (event.kind === 'start_retracted') {
643
+ const binding = authoredStartBindings.get(event.task_id);
644
+ const sameActor = binding !== undefined
645
+ && binding.actor.host === event.actor.host
646
+ && binding.actor.session === event.actor.session
647
+ && binding.actor.agent === event.actor.agent;
648
+ if (state.status !== 'in-progress') {
649
+ fail('START_RETRACTION_INVALID', 'start_retraction_requires_in_progress', {
650
+ task_id: event.task_id, status: state.status,
651
+ });
652
+ }
653
+ if (binding === undefined || binding.event_digest !== event.payload.target_start_digest) {
654
+ fail('START_RETRACTION_INVALID', 'authored_start_binding_missing', {
655
+ task_id: event.task_id,
656
+ });
611
657
  }
658
+ if (!sameActor) {
659
+ fail('START_RETRACTION_INVALID', 'start_actor_mismatch', { task_id: event.task_id });
660
+ }
661
+ Object.assign(state, taskState(event.task_id));
662
+ authoredStartBindings.delete(event.task_id);
612
663
  } else if (event.kind === 'block') {
613
664
  if (state.status !== 'in-progress') fail('STORE_INCONSISTENT', 'invalid_block_transition');
614
665
  state.status = 'blocked'; state.blocked_reason = event.payload.reason;
@@ -647,6 +698,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
647
698
  completion.set(event.task_id, { mode: 'evidence_promotion', completed_at: current.completed_at });
648
699
  }
649
700
  doneDigest.set(event.task_id, event.event_digest);
701
+ authoredStartBindings.delete(event.task_id);
650
702
  } else if (event.kind === 'reopen') {
651
703
  if (state.status !== 'done' || doneDigest.get(event.task_id) !== event.payload.target_done_digest) {
652
704
  fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
@@ -674,6 +726,34 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
674
726
  return [...states.values()].sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0);
675
727
  }
676
728
 
729
+ /** 最新のauthored startだけを撤回対象として返す。readとappendの双方で同じbindingを検証する。 */
730
+ export function resolveTodoStartRetractionBinding(store, { planKey, taskId, actor }) {
731
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
732
+ if (member === undefined) fail('STORE_INCONSISTENT', 'plan_not_active');
733
+ const canonicalTaskId = resolveCanonicalTaskId(member.plan, taskId);
734
+ const task = member.tasks.find(({ task_id: candidate }) => candidate === canonicalTaskId);
735
+ if (task === undefined) fail('STORE_INCONSISTENT', 'event_task_missing');
736
+ if (task.status !== 'in-progress') {
737
+ fail('START_RETRACTION_INVALID', 'start_retraction_requires_in_progress', {
738
+ task_id: canonicalTaskId, status: task.status,
739
+ });
740
+ }
741
+ let binding = null;
742
+ for (const event of member.journal.events) {
743
+ if (event.task_id !== canonicalTaskId) continue;
744
+ if (event.kind === 'start' && event.payload.start_mode !== 'historical_import') binding = event;
745
+ else if (event.kind === 'done' || event.kind === 'start_retracted') binding = null;
746
+ }
747
+ if (binding === null) {
748
+ fail('START_RETRACTION_INVALID', 'authored_start_binding_missing', { task_id: canonicalTaskId });
749
+ }
750
+ if (binding.actor.host !== actor.host || binding.actor.session !== actor.session
751
+ || binding.actor.agent !== actor.agent) {
752
+ fail('START_RETRACTION_INVALID', 'start_actor_mismatch', { task_id: canonicalTaskId });
753
+ }
754
+ return { task_id: canonicalTaskId, activation_event_digest: binding.event_digest };
755
+ }
756
+
677
757
  function snapshotFor(plan, events, tasks) {
678
758
  const head = events.at(-1);
679
759
  // snapshot artifactの形式は変えない(store canonical形式の非目標)。既存on-disk snapshot
@@ -694,7 +774,7 @@ function snapshotFor(plan, events, tasks) {
694
774
  return snapshot;
695
775
  }
696
776
 
697
- function validateMergedGraph(members) {
777
+ function validateMergedGraph(members, crossPlanDependencies = []) {
698
778
  const tasks = new Map();
699
779
  for (const member of members) for (const task of member.plan.tasks) {
700
780
  tasks.set(`${member.plan.project_id}\0${member.plan.plan_key}\0${task.task_id}`, member.plan.topology_digest);
@@ -762,6 +842,15 @@ function validateMergedGraph(members) {
762
842
  }
763
843
  }
764
844
  }
845
+ for (const dependency of crossPlanDependencies) {
846
+ const owner = members.find(({ plan }) => plan.project_id === dependency.to.project_id
847
+ && plan.plan_key === dependency.to.plan_key);
848
+ if (owner === undefined) fail('STORE_INCONSISTENT', 'cross_plan_dependency_owner_missing');
849
+ const from = bind(dependency.from, owner.plan);
850
+ const to = bind(dependency.to, owner.plan);
851
+ if (from === to) fail('STORE_INCONSISTENT', 'self_edge');
852
+ adjacency.get(from).push(to);
853
+ }
765
854
  const colors = new Map();
766
855
  const visit = (node) => {
767
856
  if (colors.get(node) === 1) fail('STORE_INCONSISTENT', 'merged_cycle');
@@ -771,6 +860,48 @@ function validateMergedGraph(members) {
771
860
  for (const node of adjacency.keys()) visit(node);
772
861
  }
773
862
 
863
+ function crossPlanDependencyTask(store, ref) {
864
+ const member = store.members.find(({ plan }) => plan.project_id === ref.project_id
865
+ && plan.plan_key === ref.plan_key);
866
+ if (member === undefined) fail('DEPENDENCY_INVALID', 'dependency_plan_not_found', { ref });
867
+ if (member.plan.topology_digest !== ref.expected_topology_digest) {
868
+ fail('DEPENDENCY_STALE', 'dependency_topology_stale', {
869
+ ref, actual_topology_digest: member.plan.topology_digest,
870
+ });
871
+ }
872
+ const task = member.tasks.find(({ task_id: taskId }) => taskId === ref.task_id);
873
+ if (task === undefined) fail('DEPENDENCY_INVALID', 'dependency_task_not_found', { ref });
874
+ return { member, task };
875
+ }
876
+
877
+ function validateCrossPlanDependencyTransition(store, owner, input) {
878
+ const { from, to } = input.payload;
879
+ if (from.project_id !== store.project_id || to.project_id !== store.project_id) {
880
+ fail('DEPENDENCY_INVALID', 'dependency_project_mismatch');
881
+ }
882
+ if (from.plan_key === to.plan_key) fail('DEPENDENCY_INVALID', 'dependency_must_cross_plans');
883
+ if (to.plan_key !== owner.plan.plan_key || to.project_id !== owner.plan.project_id) {
884
+ fail('DEPENDENCY_INVALID', 'dependency_owner_mismatch');
885
+ }
886
+ const source = crossPlanDependencyTask(store, from);
887
+ const target = crossPlanDependencyTask(store, to);
888
+ if (source.task.status === 'done') fail('DEPENDENCY_INVALID', 'dependency_source_terminal');
889
+ if (target.task.status === 'done') fail('DEPENDENCY_INVALID', 'dependency_target_terminal');
890
+ const existing = projectTodoCrossPlanDependencies(store.members);
891
+ if (existing.some((dependency) => mergedTaskKey(dependency.from) === mergedTaskKey(from)
892
+ && mergedTaskKey(dependency.to) === mergedTaskKey(to))) {
893
+ fail('DEPENDENCY_EXISTS', 'cross_plan_dependency_duplicate', { from, to });
894
+ }
895
+ try {
896
+ validateMergedGraph(store.members, [...existing, { from, to }]);
897
+ } catch (error) {
898
+ if (error instanceof TodoStoreError && error.detail?.reason === 'merged_cycle') {
899
+ fail('DEPENDENCY_CYCLE', 'cross_plan_dependency_cycle', { from, to });
900
+ }
901
+ throw error;
902
+ }
903
+ }
904
+
774
905
  function mergedTaskKey(refValue) {
775
906
  return `${refValue.project_id}\0${refValue.plan_key}\0${refValue.task_id}`;
776
907
  }
@@ -835,6 +966,9 @@ function mergedPredecessorKeys(store, targetKey) {
835
966
  }
836
967
  }
837
968
  }
969
+ for (const dependency of projectTodoCrossPlanDependencies(store.members)) {
970
+ if (mergedTaskKey(dependency.to) === targetKey) result.push(mergedTaskKey(dependency.from));
971
+ }
838
972
  return [...new Set(result)];
839
973
  }
840
974
 
@@ -850,6 +984,9 @@ function mergedSuccessorKeys(store, targetKey) {
850
984
  }
851
985
  }
852
986
  }
987
+ for (const dependency of projectTodoCrossPlanDependencies(store.members)) {
988
+ if (mergedTaskKey(dependency.from) === targetKey) result.push(mergedTaskKey(dependency.to));
989
+ }
853
990
  return [...new Set(result)];
854
991
  }
855
992
 
@@ -1221,7 +1358,7 @@ export async function readTodoStore(options = {}) {
1221
1358
  snapshot: snapshotStale ? expectedSnapshot : snapshot,
1222
1359
  tasks, phases, coordination, snapshot_stale: snapshotStale });
1223
1360
  }
1224
- validateMergedGraph(loaded);
1361
+ validateMergedGraph(loaded, projectTodoCrossPlanDependencies(loaded));
1225
1362
  return {
1226
1363
  schema: 'lattice.todo_store_read.v1', project_id: manifest.project_id, manifest,
1227
1364
  members: loaded, snapshot_stale: loaded.some((member) => member.snapshot_stale),
@@ -1437,10 +1574,13 @@ export async function appendTodoEvent(options = {}) {
1437
1574
  const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
1438
1575
  const member = store.members.find(({ descriptor }) => descriptor.plan_key === options.planKey);
1439
1576
  if (!member) fail('STORE_INCONSISTENT', 'plan_not_active');
1440
- // planへ帰属するeventは別chainへ積む(ob03)。task状態もPhase状態も動かさないので、
1577
+ // planへ帰属するeventは別chainへ積む。task状態もPhase状態も直接は動かさないので、
1441
1578
  // replay・snapshot・manifestへは触れない——lifecycle journalのheadを進めるのは
1442
- // 「作業が進んだ」の意味であり、方式を選んだだけでそこを動かすと意味がずれる。
1579
+ // 「作業が進んだ」の意味であり、方式選択や依存接続でそこを動かすと意味がずれる。
1443
1580
  if (TODO_PLAN_SCOPED_EVENT_KINDS.includes(options.event.kind)) {
1581
+ if (options.event.kind === 'cross_plan_dependency') {
1582
+ validateCrossPlanDependencyTransition(store, member, options.event);
1583
+ }
1444
1584
  return appendPlanScopedEvent({
1445
1585
  repoRoot, member,
1446
1586
  input: { ...options.event, recorded_at: options.event.recorded_at ?? new Date().toISOString() },