@quolu/lattice 0.47.0 → 0.48.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.
@@ -4,7 +4,7 @@ import { stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
 
6
6
  import { readTodoStoreStable } from '../src/todo-store.mjs';
7
- import { projectTodoStatus } from '../src/todo-status.mjs';
7
+ import { TODO_STATUS_DISPATCH_ONLY, projectTodoStatus } from '../src/todo-status.mjs';
8
8
  import { ganttLiveHeadDigest, renderTodoGanttForProject } from '../src/todo-cli.mjs';
9
9
  import {
10
10
  forgetTodoDashboardDaemonRecord,
@@ -61,7 +61,7 @@ async function synchronize() {
61
61
  projectHasActiveRun: async (entry) => {
62
62
  try {
63
63
  const store = await readCachedStore(entry.repo_root);
64
- const active = projectTodoStatus(store).active_set.length > 0
64
+ const active = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY).active_set.length > 0
65
65
  || store.members.some(todoDashboardMemberNeedsVisibility);
66
66
  reportedStoreReadFailures.delete(entry.project_id);
67
67
  return active;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
package/src/cli-help.mjs CHANGED
@@ -75,7 +75,7 @@ Read commands:
75
75
 
76
76
  Write commands:
77
77
  dashboard adopt --json # 衝突したproject_idの配信元rootを現在repoへ明示的に移す
78
- note --plan <key> --task <id> (--message <text>|--input <file>)
78
+ note --plan <key> [--task <id>] (--message <text>|--input <file>)
79
79
  # ToDoへ作業継続に必要な方針・調査結果・注意をappend-onlyで追記する
80
80
  migrate --input <extraction.json> [--serialization-reviewed]
81
81
  migrate --input <extraction.json> --dry-run --json [--serialization-reviewed]
@@ -190,7 +190,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
190
190
  'event verify': 'event verify --run .lattice/runs/<id>',
191
191
  'todo status': 'todo status [--json]',
192
192
  'todo show': 'todo show --plan <key> --task <id> --json',
193
- 'todo note': 'todo note --plan <key> --task <id> (--message <text>|--input <file>) | list --plan <key> [--task <id>] --json',
193
+ 'todo note': 'todo note --plan <key> [--task <id>] (--message <text>|--input <file>) | list --plan <key> [--task <id>] --json',
194
194
  'todo note list': 'todo note list --plan <key> [--task <id>] --json',
195
195
  'todo bindings': 'todo bindings [--plan <key>] [--json]',
196
196
  'todo independence': 'todo independence [--plan <key>] [--json] | compile --plan <key> --input <file> | witness migrate --plan <key>',
@@ -16,7 +16,9 @@ import {
16
16
  todoSelfDigest,
17
17
  } from './todo-contracts.mjs';
18
18
  import { projectTodoStatus } from './todo-status.mjs';
19
+ import { readTodoPlanNotesForStatus } from './todo-note-store.mjs';
19
20
  import { projectIndependenceFrontier } from './todo-independence.mjs';
21
+ import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
20
22
  import { isTodoIndependenceLegacyMarker } from './todo-independence-contracts.mjs';
21
23
  import { selectIndependenceGuidance } from './todo-independence-guidance.mjs';
22
24
  import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
@@ -179,6 +181,7 @@ async function summarizeIndependence({ repoRoot, store, todo }) {
179
181
  .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
180
182
  conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
181
183
  conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
184
+ coordinationMode: member.coordination?.mode ?? null,
182
185
  }),
183
186
  unreadable_reason: null,
184
187
  parallel_groups: projected.frontier.parallel_groups.map(({ task_ids: ids }) => [...ids]),
@@ -265,7 +268,10 @@ async function resolveProjectState({ cwd, cliVersion }) {
265
268
  }
266
269
  try {
267
270
  const store = await readTodoStore({ repoRoot });
268
- const todo = projectTodoStatus(store);
271
+ const todo = projectTodoStatus(store, {
272
+ planNotes: await readTodoPlanNotesForStatus({ repoRoot, store }),
273
+ parallelCandidates: await readTodoParallelCandidatesForStatus({ repoRoot, store, gitHead }),
274
+ });
269
275
  const activeRuns = todo.active_set.map((entry) => ({
270
276
  plan_key: entry.plan_key, task_id: entry.task_id, label: entry.label,
271
277
  }));
package/src/todo-cli.mjs CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { parseTree } from 'jsonc-parser';
9
9
 
10
10
  import {
11
+ TODO_COORDINATION_MODES,
11
12
  TODO_DESIGN_MEMO_PROMPT,
12
13
  canonicalizeTodoArtifact,
13
14
  digestTodoArtifact,
@@ -72,6 +73,7 @@ import {
72
73
  import {
73
74
  computeReadyFrontier,
74
75
  projectTodoBindings,
76
+ TODO_STATUS_DISPATCH_ONLY,
75
77
  projectTodoStatus,
76
78
  } from './todo-status.mjs';
77
79
  import {
@@ -118,7 +120,9 @@ import {
118
120
  readTodoNoteContext,
119
121
  readTodoNoteContextsForPlan,
120
122
  readTodoNoteEvents,
123
+ readTodoPlanNotesForStatus,
121
124
  } from './todo-note-store.mjs';
125
+ import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
122
126
 
123
127
  const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
124
128
  const DEFAULT_GANTT_SCOPE = 'live';
@@ -594,6 +598,9 @@ function designMemoProjection(task) {
594
598
  */
595
599
  async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
596
600
  const artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
601
+ // 調整方式の宣言(ob03)。未宣言はnullで、「まだ選んでいない」を意味する。
602
+ const coordinationMode = store.members
603
+ .find(({ descriptor }) => descriptor.plan_key === planKey)?.coordination?.mode ?? null;
597
604
  if (artifact === null) {
598
605
  // 記録が無ければ鮮度を語る相手がいない。HEADを要求すると、commitがまだ無いrepoで
599
606
  // 「判定できない」でなく「startできない」になってしまう。
@@ -605,7 +612,7 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
605
612
  .filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
606
613
  self_unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
607
614
  guidance: selectIndependenceGuidance({
608
- coverage: 'missing', taskDeclared: false, taskStale: false,
615
+ coverage: 'missing', taskDeclared: false, taskStale: false, coordinationMode,
609
616
  }),
610
617
  };
611
618
  }
@@ -654,6 +661,7 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
654
661
  conflictWithActive: conflictsWithActive[0]?.severability ?? null,
655
662
  conflictBetweenReady: readyConflict?.severability ?? null,
656
663
  verdictsAbsent: selfUnknowns.some(({ kind }) => kind === 'plan_verdicts_absent'),
664
+ coordinationMode,
657
665
  }),
658
666
  };
659
667
  }
@@ -685,7 +693,8 @@ async function startTask({
685
693
  repoRoot, env, planKey, taskId, overrideReason, parallelFrontier, serialConfirmed = false,
686
694
  }) {
687
695
  const store = await readTodoStore({ repoRoot });
688
- const projection = projectTodoStatus(store);
696
+ // startはready判定にしかprojectionを使わず、resultを出力しない。
697
+ const projection = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY);
689
698
  const readyTask = projection.next_ready.find((task) => (
690
699
  task.plan_key === planKey && task.task_id.toLowerCase() === taskId.toLowerCase()
691
700
  ));
@@ -827,6 +836,33 @@ async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload })
827
836
  return result;
828
837
  }
829
838
 
839
+ /**
840
+ * 調整方式を宣言する(ob03・オーナー裁定C①)。
841
+ *
842
+ * witnessが全planの暗黙義務だった時、「誰がやるか」が誰にも属さず、正確な案内が素通りされた。
843
+ * 起票後にこのコマンドで明示選択させ、eventのactorへ帰属を残す。宣言はdispatchを変えない
844
+ * ——未宣言でもready frontierは通常どおり出る(ADR 0160・ob04のProtected behavior)。
845
+ */
846
+ async function independenceMode({ repoRoot, env, planKey, mode, reason }) {
847
+ const { event } = await appendTodoEvent({
848
+ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), planKey,
849
+ event: { kind: 'coordination_mode', actor: mutationActor(env), payload: { mode, reason } },
850
+ });
851
+ const result = {
852
+ schema: 'lattice.todo_coordination_mode_result.v1', project_id: event.project_id,
853
+ plan_key: event.plan_key, plan_version: event.plan_version,
854
+ mode: event.payload.mode, reason: event.payload.reason,
855
+ declared_by: event.actor, declared_at: event.recorded_at,
856
+ // 宣言はplan-scoped chainのheadを進める。lifecycle journalのheadは動かない——
857
+ // 「作業が進んだ」の意味をここへ混ぜないため、journal_head_digestは返さない。
858
+ sequence: event.sequence, event_digest: event.event_digest,
859
+ plan_scoped_head_digest: event.event_digest,
860
+ result_digest: '',
861
+ };
862
+ result.result_digest = todoSelfDigest(result, 'result_digest');
863
+ return result;
864
+ }
865
+
830
866
  async function phaseStatus({ repoRoot, planKey }) {
831
867
  const store = await readTodoStore({ repoRoot });
832
868
  const [member] = selectMembers(store, planKey);
@@ -973,7 +1009,8 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
973
1009
 
974
1010
  const imported = await appendTodoExtraction({ repoRoot, extraction });
975
1011
  const result = {
976
- schema: 'lattice.todo_migrate_result.v2',
1012
+ // ob03: 調整方式の案内をv3で足す。ADR 0054のとおり既存versionへのin-place追加はしない。
1013
+ schema: 'lattice.todo_migrate_result.v3',
977
1014
  project_id: imported.plan.project_id,
978
1015
  plan_key: imported.plan.plan_key,
979
1016
  plan_version: imported.plan.plan_version,
@@ -1002,6 +1039,13 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
1002
1039
  required_state_policy: 'acquire_phase',
1003
1040
  next_action: `lattice todo revise-phase --plan ${imported.plan.plan_key} --input <phase-revision.json>`,
1004
1041
  } : null,
1042
+ // ob03: 起票直後のplanは必ず調整方式が未宣言である。ここで案内しないと、選ぶ機会が
1043
+ // 「誰も呼ぶ動機の無いdrilldown」にしか無くなる——前campaignの監査待ちと同じ形になる。
1044
+ coordination_guidance: {
1045
+ mode: null,
1046
+ modes: [...TODO_COORDINATION_MODES],
1047
+ next_action: `lattice todo independence mode --plan ${imported.plan.plan_key} --set <witness|conversation> --reason <text>`,
1048
+ },
1005
1049
  result_digest: '',
1006
1050
  };
1007
1051
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -1278,7 +1322,13 @@ async function revisePhase({ repoRoot, env, planKey, inputRef }) {
1278
1322
  }
1279
1323
 
1280
1324
  async function status({ repoRoot }) {
1281
- return projectTodoStatus(await readTodoStore({ repoRoot }));
1325
+ const store = await readTodoStore({ repoRoot });
1326
+ return projectTodoStatus(store, {
1327
+ planNotes: await readTodoPlanNotesForStatus({ repoRoot, store }),
1328
+ parallelCandidates: await readTodoParallelCandidatesForStatus({
1329
+ repoRoot, store, gitHead: currentHeadSha, changedPathsSince,
1330
+ }),
1331
+ });
1282
1332
  }
1283
1333
 
1284
1334
  async function adoptDashboardRoot({ repoRoot, env }) {
@@ -1324,32 +1374,44 @@ async function todoDetail({ repoRoot, planKey, taskId }) {
1324
1374
  return result;
1325
1375
  }
1326
1376
 
1377
+ /**
1378
+ * `taskId === null`はplan単位note。task noteと違い宛先taskが無いので、
1379
+ * 訂正できる相手はplan noteだけ、返すcontextも特定taskのものにできない。
1380
+ */
1327
1381
  async function appendNote({ repoRoot, env, planKey, taskId, message, inputRef, supersedes }) {
1328
1382
  const store = await readTodoStore({ repoRoot });
1329
1383
  const [member] = selectMembers(store, planKey);
1330
- const task = selectNoteTask(member, taskId);
1384
+ const task = taskId === null ? null : selectNoteTask(member, taskId);
1331
1385
  const body = inputRef === null ? message : await readNoteTextInput(repoRoot, inputRef);
1332
- const projectedBeforeAppend = await readTodoNoteContext({
1333
- repoRoot, store, planKey, taskId: task.task_id,
1334
- });
1386
+ // 訂正はscopeを跨げない。plan noteはplan chainの中だけ、task noteは自分のtaskの中だけを
1387
+ // 訂正できる。contextはplan noteも載せるので、task側はscopeで絞らないと跨げてしまう。
1388
+ const eligibleSupersedes = task === null
1389
+ ? (await readTodoNoteEvents({ repoRoot, planKey, scope: 'plan' })).events
1390
+ .map(({ event_digest: digest }) => digest)
1391
+ : (await readTodoNoteContext({ repoRoot, store, planKey, taskId: task.task_id }))
1392
+ .history.filter(({ scope }) => scope === 'task').map(({ event_digest: digest }) => digest);
1335
1393
  const event = await appendTodoNote({
1336
1394
  repoRoot,
1337
1395
  projectId: store.project_id,
1338
1396
  planKey,
1339
1397
  planVersion: member.plan.plan_version,
1340
- taskId: task.task_id,
1398
+ taskId: task?.task_id ?? null,
1341
1399
  actor: mutationActor(env),
1342
1400
  recordedAt: new Date().toISOString(),
1343
1401
  body,
1344
1402
  supersedes,
1345
- eligibleSupersedes: projectedBeforeAppend.history.map(({ event_digest: digest }) => digest),
1403
+ eligibleSupersedes,
1346
1404
  });
1347
- const { context } = await readTodoNoteContext({ repoRoot, store, planKey, taskId: task.task_id });
1405
+ // plan noteはどのtaskのcontextにも載るので、1つを選んで返すと嘘になる。全部読むなら
1406
+ // `note list --plan <k>`。書けたことの証拠はeventそのものが持つ。
1407
+ const context = task === null
1408
+ ? null : (await readTodoNoteContext({ repoRoot, store, planKey, taskId: task.task_id })).context;
1348
1409
  const result = {
1349
- schema: 'lattice.todo_note_append_result.v1',
1410
+ schema: 'lattice.todo_note_append_result.v2',
1350
1411
  project_id: store.project_id,
1351
1412
  plan_key: planKey,
1352
- task_id: task.task_id,
1413
+ scope: task === null ? 'plan' : 'task',
1414
+ task_id: task?.task_id ?? null,
1353
1415
  event,
1354
1416
  note_context: context,
1355
1417
  result_digest: '',
@@ -1362,7 +1424,10 @@ async function listNotes({ repoRoot, planKey, taskId }) {
1362
1424
  const store = await readTodoStore({ repoRoot });
1363
1425
  const [member] = selectMembers(store, planKey);
1364
1426
  const chain = await readTodoNoteEvents({ repoRoot, planKey });
1365
- let notes = chain.events;
1427
+ const planChain = await readTodoNoteEvents({ repoRoot, planKey, scope: 'plan' });
1428
+ // plan全体の一覧は両chainを返す。`full_history_command`がこの形を指す以上、片方でも
1429
+ // 落とせば「full」と名乗りながら全部を取りに行けない。並びはcontextと同じくplanが先。
1430
+ let notes = [...planChain.events, ...chain.events];
1366
1431
  let archived = [];
1367
1432
  let resolvedTaskId = null;
1368
1433
  if (taskId !== null) {
@@ -1371,17 +1436,20 @@ async function listNotes({ repoRoot, planKey, taskId }) {
1371
1436
  const projected = await readTodoNoteContext({
1372
1437
  repoRoot, store, planKey, taskId: task.task_id,
1373
1438
  });
1374
- notes = projected.history;
1439
+ // `--task`は「そのtaskのnote」を問う形。plan noteはtaskのものではないので混ぜない
1440
+ // ——欲しければ`--task`を外す。
1441
+ notes = projected.history.filter(({ scope }) => scope === 'task');
1375
1442
  archived = projected.archived;
1376
1443
  }
1377
1444
  const result = {
1378
- schema: 'lattice.todo_note_list_result.v1',
1445
+ schema: 'lattice.todo_note_list_result.v2',
1379
1446
  project_id: store.project_id,
1380
1447
  plan_key: planKey,
1381
1448
  requested_task_id: resolvedTaskId,
1382
1449
  notes,
1383
1450
  archived,
1384
1451
  note_head_digest: chain.head_digest,
1452
+ plan_note_head_digest: planChain.head_digest,
1385
1453
  result_digest: '',
1386
1454
  };
1387
1455
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -1567,7 +1635,7 @@ async function independence({ repoRoot, requestedPlanKey }) {
1567
1635
  ? undefined : store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
1568
1636
  const artifact = member === undefined
1569
1637
  ? null : await readTodoIndependenceArtifact({ repoRoot, store, planKey });
1570
- const active = projectTodoStatus(store).active_set
1638
+ const active = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY).active_set
1571
1639
  .filter((task) => task.plan_key === planKey);
1572
1640
  // HEADが進んでいる時だけdiffを取る。一致していれば宣言境界を見るまでもない。
1573
1641
  const changedPaths = artifact !== null && artifact.base_sha !== null
@@ -1607,6 +1675,9 @@ async function independence({ repoRoot, requestedPlanKey }) {
1607
1675
  .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
1608
1676
  conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
1609
1677
  conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
1678
+ // 案内の正本は1つ(ADR 0130 Decision 1)。着手する人と読みに来た人が同じ状況について
1679
+ // 違う文言を受け取らないよう、調整方式もここへ渡す。
1680
+ coordinationMode: member?.coordination?.mode ?? null,
1610
1681
  verdictsAbsent: projected.frontier.unknown
1611
1682
  .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'plan_verdicts_absent')),
1612
1683
  }),
@@ -2141,7 +2212,7 @@ async function notesForGantt({ repoRoot, store }) {
2141
2212
 
2142
2213
  async function independenceForGantt({ repoRoot, store }) {
2143
2214
  const frontier = computeReadyFrontier(store);
2144
- const status = projectTodoStatus(store);
2215
+ const status = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY);
2145
2216
  let currentBaseSha = null;
2146
2217
  const projections = [];
2147
2218
  for (const member of store.members) {
@@ -2528,6 +2599,20 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2528
2599
  inputRef: argv[5] === '--input' ? argv[6] : null,
2529
2600
  supersedes: argv[8] ?? null,
2530
2601
  });
2602
+ } else if ((argv.length === 5 || argv.length === 7) && argv[0] === 'note'
2603
+ && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2604
+ && ['--message', '--input'].includes(argv[3])
2605
+ && ((argv[3] === '--message' && argv[4].length > 0)
2606
+ || (argv[3] === '--input' && isTodoRef(argv[4])))
2607
+ && (argv.length === 5 || (argv[5] === '--supersedes' && isTodoDigest(argv[6])))) {
2608
+ // `--task`省略でplan単位note。工程レベルの義務(順序制約・一度きりの観測が在ること)は
2609
+ // 特定のtaskに属さない。
2610
+ action = (repoRoot) => appendNote({
2611
+ repoRoot, env, planKey: argv[2], taskId: null,
2612
+ message: argv[3] === '--message' ? argv[4] : null,
2613
+ inputRef: argv[3] === '--input' ? argv[4] : null,
2614
+ supersedes: argv[6] ?? null,
2615
+ });
2531
2616
  } else if (argv.length === 5 && argv[0] === 'note' && argv[1] === 'list'
2532
2617
  && argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--json') {
2533
2618
  action = (repoRoot) => listNotes({ repoRoot, planKey: argv[3], taskId: null });
@@ -2538,6 +2623,12 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2538
2623
  } else if (argv.length === 5 && argv[0] === 'independence' && argv[1] === 'witness'
2539
2624
  && argv[2] === 'migrate' && argv[3] === '--plan' && isTodoIdentifier(argv[4])) {
2540
2625
  action = (repoRoot) => independenceWitnessMigrate({ repoRoot, planKey: argv[4] });
2626
+ } else if (argv.length === 8 && argv[0] === 'independence' && argv[1] === 'mode'
2627
+ && argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--set'
2628
+ && TODO_COORDINATION_MODES.includes(argv[5]) && argv[6] === '--reason' && argv[7].length > 0) {
2629
+ action = (repoRoot) => independenceMode({
2630
+ repoRoot, env, planKey: argv[3], mode: argv[5], reason: argv[7],
2631
+ });
2541
2632
  } else if (argv.length === 6 && argv[0] === 'independence' && argv[1] === 'compile'
2542
2633
  && argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--input') {
2543
2634
  action = (repoRoot) => independenceCompile({
@@ -8,9 +8,35 @@ export const TODO_EVENT_KINDS = Object.freeze([
8
8
  // phase_review/accept/reject/reopenと同じv3 tail event shape(phase_id持ち)に収め、
9
9
  // 新しいevent schema版は作らない。
10
10
  'phase_close_unaudited',
11
+ // ob03: 調整方式(witness検証で並列するか、会話調整で行くか)の宣言。planに帰属する事実で
12
+ // taskにもPhaseにも属さないため、task_idもphase_idも持たない最初のkindになる。actorが
13
+ // 「誰が選んだか」の帰属を持つ——witnessが全planの暗黙義務だった時に帰属が無く、正確な
14
+ // 案内が素通りされたことへの是正である(オーナー裁定C①)。
15
+ 'coordination_mode',
11
16
  ]);
17
+
18
+ /** planへ帰属し、taskにもPhaseにも属さないevent kind。 */
19
+ export const TODO_PLAN_SCOPED_EVENT_KINDS = Object.freeze(['coordination_mode']);
20
+
21
+ /** 調整方式。witness=独立性を宣言し検証して並列する/conversation=会話で調整する。 */
22
+ export const TODO_COORDINATION_MODES = Object.freeze(['witness', 'conversation']);
12
23
  export const TODO_NOTE_EVENT_SCHEMA = 'lattice.todo_note_event.v1';
13
- export const TODO_NOTE_CONTEXT_SCHEMA = 'lattice.todo_note_context.v1';
24
+ /**
25
+ * plan単位のnote event。工程レベルの義務(順序制約・一度きりの観測が在ること)は特定のtaskに
26
+ * 属さないので、v1の`task_id`必須では書けない。v1は書き換えない——既存chainはhash連鎖で
27
+ * 固定済みであり、taskノートの挙動も digest も動かさない。
28
+ *
29
+ * v2は**別のchain file**(`plan-active.jsonl`)へ積む。同じchainへ混ぜると、旧CLIは
30
+ * 1 eventずつのbyte検証で chain全体を壊れと読み、noteの読みを前提条件とする`todo start`が
31
+ * 落ちる。しかもstoreへ書いたものは戻せない。分離すれば旧CLIはfileの存在に気づかず、
32
+ * task noteの読み書きは1バイトも変わらない。
33
+ */
34
+ export const TODO_NOTE_EVENT_V2_SCHEMA = 'lattice.todo_note_event.v2';
35
+ /**
36
+ * v2でnoteの`scope`とplan noteを載せる。v1のままでは`task_id`が identifier 必須・
37
+ * `full_history_command`が`--task <id>`込みの文字列と完全一致で、どちらもplan noteを表せない。
38
+ */
39
+ export const TODO_NOTE_CONTEXT_SCHEMA = 'lattice.todo_note_context.v2';
14
40
  export const TODO_LIMITS = Object.freeze({
15
41
  tasksPerPlan: 512,
16
42
  edgesPerPlan: 2_048,
@@ -20,6 +46,9 @@ export const TODO_LIMITS = Object.freeze({
20
46
  narrativeSectionBytes: 262_144,
21
47
  noteBodyBytes: 16_384,
22
48
  noteContextBytes: 65_536,
49
+ // statusのplan_notes entryが載せる最新noteの件数。本文を載せない代わりに
50
+ // 「誰がいつ置いたか」だけを新しい順で数件出す(中身はnote listが持つ)。
51
+ statusPlanNoteLatest: 3,
23
52
  });
24
53
 
25
54
  const DIGEST = /^[0-9a-f]{64}$/;
@@ -100,15 +129,27 @@ const noteBody = (value) => typeof value === 'string' && value.length > 0
100
129
  && Buffer.byteLength(value, 'utf8') <= TODO_LIMITS.noteBodyBytes
101
130
  && !NOTE_FORBIDDEN_CONTROL.test(value);
102
131
 
103
- /** lifecycle journalとは独立したtask note event v1を検証する。 */
132
+ /**
133
+ * lifecycle journalとは独立したnote eventを検証する。v1(task note)とv2(plan note)は
134
+ * 別chainへ積まれるが、検証器は両方を受ける——どちらのchainを読んでいるかはpathが決め、
135
+ * chainへ他scopeが混ざっていないことは`readTodoNoteEvents`が読み出し時に落とす。
136
+ * v1は`scope`を持たない——定義上taskであり、投影の側で明示`scope`へ正規化する。
137
+ */
104
138
  export function validateTodoNoteEvent(value) {
105
139
  try {
106
- return exactRecord(value, [
140
+ const keys = [
107
141
  'schema', 'project_id', 'plan_key', 'task_id', 'plan_version', 'sequence',
108
142
  'previous_digest', 'actor', 'recorded_at', 'body', 'supersedes', 'event_digest',
109
- ]) && value.schema === TODO_NOTE_EVENT_SCHEMA
143
+ ];
144
+ const planScoped = value?.schema === TODO_NOTE_EVENT_V2_SCHEMA;
145
+ return exactRecord(value, planScoped ? [...keys, 'scope'] : keys)
146
+ && (planScoped
147
+ // v2は今のところplan scopeだけを表す。phase scopeは配達面(audit_pending entry)を
148
+ // 持つtaskと同じ波で足す——書けるが届かない面を作らないため。
149
+ ? value.scope === 'plan' && value.task_id === null
150
+ : value.schema === TODO_NOTE_EVENT_SCHEMA && isTodoIdentifier(value.task_id))
110
151
  && isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
111
- && isTodoIdentifier(value.task_id) && isTodoIdentifier(value.plan_version)
152
+ && isTodoIdentifier(value.plan_version)
112
153
  && Number.isSafeInteger(value.sequence) && value.sequence >= 1
113
154
  && (value.sequence === 1 ? value.previous_digest === null : isTodoDigest(value.previous_digest))
114
155
  && actor(value.actor) && isStrictTodoTimestamp(value.recorded_at) && noteBody(value.body)
@@ -120,12 +161,20 @@ export function validateTodoNoteEvent(value) {
120
161
  }
121
162
  }
122
163
 
164
+ /**
165
+ * `scope`は投影が必ず埋める。読み手はここでv1/v2の区別を持たないので、`origin_task_id`が
166
+ * nullであることから「plan単位だ」を推論させない——型で表現していない区別は、exact検証を
167
+ * 通り抜けて受け手の解釈に落ちる。
168
+ */
123
169
  function noteContextEntry(value) {
124
170
  return exactRecord(value, [
125
- 'event_digest', 'origin_plan_version', 'origin_task_id', 'actor', 'recorded_at',
171
+ 'event_digest', 'origin_plan_version', 'scope', 'origin_task_id', 'actor', 'recorded_at',
126
172
  'body', 'supersedes', 'superseded_by', 'correction_state',
127
173
  ]) && isTodoDigest(value.event_digest) && isTodoIdentifier(value.origin_plan_version)
128
- && isTodoIdentifier(value.origin_task_id) && actor(value.actor)
174
+ && ['plan', 'task'].includes(value.scope)
175
+ && (value.scope === 'plan'
176
+ ? value.origin_task_id === null : isTodoIdentifier(value.origin_task_id))
177
+ && actor(value.actor)
129
178
  && isStrictTodoTimestamp(value.recorded_at) && noteBody(value.body)
130
179
  && (value.supersedes === null || isTodoDigest(value.supersedes))
131
180
  && (value.superseded_by === null || isTodoDigest(value.superseded_by))
@@ -139,17 +188,29 @@ export function validateTodoNoteContext(value) {
139
188
  try {
140
189
  if (!exactRecord(value, [
141
190
  'schema', 'project_id', 'plan_key', 'task_id', 'notes', 'note_head_digest',
142
- 'overflow_count', 'full_history_command', 'context_digest',
191
+ 'plan_note_head_digest', 'overflow_count', 'full_history_command', 'context_digest',
143
192
  ]) || value.schema !== TODO_NOTE_CONTEXT_SCHEMA
144
193
  || !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
145
194
  || !isTodoIdentifier(value.task_id) || !Array.isArray(value.notes)
146
195
  || value.notes.length > TODO_LIMITS.tasksPerPlan || !value.notes.every(noteContextEntry)
147
196
  || !(value.note_head_digest === null || isTodoDigest(value.note_head_digest))
197
+ || !(value.plan_note_head_digest === null || isTodoDigest(value.plan_note_head_digest))
148
198
  || !isNonNegativeSafeInteger(value.overflow_count)
149
- || value.full_history_command !== `lattice todo note list --plan ${value.plan_key} --task ${value.task_id} --json`
199
+ // contextはplan noteも載せるので、案内するのはplan全体を返す形でなければならない。
200
+ // `--task <id>`形はplan noteを落とすため、fullと名乗りながら全部を取りに行けなくなる。
201
+ || value.full_history_command !== `lattice todo note list --plan ${value.plan_key} --json`
150
202
  || !isTodoDigest(value.context_digest)
151
203
  || value.context_digest !== todoSelfDigest(value, 'context_digest')) return false;
152
- if ((value.notes.length === 0) !== (value.note_head_digest === null)) return false;
204
+ // headはchainごとなので、同値もscopeで切る。task noteが空でplan noteが在る時に
205
+ // 「notesが非空なのにheadがnull」を壊れと読まないため。overflowで本文が落ちても
206
+ // headはchainの実在を述べ続ける——同値はnotesではなくoverflow込みの母集合で見る。
207
+ const scoped = (scope) => value.notes.some((note) => note.scope === scope);
208
+ if (value.overflow_count === 0) {
209
+ if (scoped('task') !== (value.note_head_digest !== null)) return false;
210
+ if (scoped('plan') !== (value.plan_note_head_digest !== null)) return false;
211
+ } else if (value.note_head_digest === null && value.plan_note_head_digest === null) {
212
+ return false;
213
+ }
153
214
  return value.notes.reduce((bytes, note) => bytes + Buffer.byteLength(note.body, 'utf8'), 0)
154
215
  <= TODO_LIMITS.noteContextBytes;
155
216
  } catch {
@@ -440,6 +501,11 @@ function validPayload(event) {
440
501
  && (payload.started_at === 'unknown_requires_evidence' || isStrictTodoTimestamp(payload.started_at))
441
502
  && validateTodoImportSource(payload.evidence);
442
503
  }
504
+ if (event.kind === 'coordination_mode') {
505
+ return exactRecord(payload, ['mode', 'reason'])
506
+ && TODO_COORDINATION_MODES.includes(payload.mode)
507
+ && nullableText(payload.reason) && payload.reason !== null;
508
+ }
443
509
  if (event.kind === 'start') return exactRecord(payload, ['override_reason']) && nullableText(payload.override_reason);
444
510
  if (event.kind === 'block') return exactRecord(payload, ['reason']) && nullableText(payload.reason) && payload.reason !== null;
445
511
  if (event.kind === 'unblock') return exactRecord(payload, []);
@@ -561,17 +627,21 @@ export function validateTodoEvent(value) {
561
627
  && validPhaseStateMigration(value.phase_state_migration);
562
628
  const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
563
629
  .includes(value?.kind);
630
+ // planへ帰属するkindは、plan_genesisと同じくtask_idを持たない(v3/v4ではphase_idも持たない)。
631
+ // plan_genesisと違うのはjournalの途中に何度でも積めることで、最後の1件が現在の宣言になる。
632
+ const planScopedKind = TODO_PLAN_SCOPED_EVENT_KINDS.includes(value?.kind);
633
+ const planLevel = value?.kind === 'plan_genesis' || planScopedKind;
564
634
  return (v1 || v2 || v3 || v4) && isTodoIdentifier(value.project_id)
565
635
  && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.plan_version)
566
636
  && isNonNegativeSafeInteger(value.sequence) && nullableDigest(value.previous_digest)
567
637
  && TODO_EVENT_KINDS.includes(value.kind)
568
638
  && (v3 || v4
569
- ? ((value.kind === 'plan_genesis' && value.task_id === null && value.phase_id === null)
639
+ ? ((planLevel && value.task_id === null && value.phase_id === null)
570
640
  || (phaseKind && value.task_id === null && isTodoIdentifier(value.phase_id))
571
- || (!phaseKind && value.kind !== 'plan_genesis' && isTodoIdentifier(value.task_id)
641
+ || (!phaseKind && !planLevel && isTodoIdentifier(value.task_id)
572
642
  && value.phase_id === null))
573
- : !phaseKind && ((value.kind === 'plan_genesis' && value.task_id === null)
574
- || (value.kind !== 'plan_genesis' && isTodoIdentifier(value.task_id))))
643
+ : !phaseKind && ((planLevel && value.task_id === null)
644
+ || (!planLevel && isTodoIdentifier(value.task_id))))
575
645
  && actor(value.actor) && isStrictTodoTimestamp(value.recorded_at) && provenance(value.provenance)
576
646
  && validPayload(value) && isTodoDigest(value.event_digest)
577
647
  && value.event_digest === todoSelfDigest(value, 'event_digest');
@@ -10,6 +10,11 @@
10
10
 
11
11
  export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
12
12
  'independence_no_ready_frontier',
13
+ // ob03: 調整方式の宣言に関する案内。witnessが全planの暗黙義務だった時、未compileの督促は
14
+ // 誰の受入条件でもない作業を指しており、正確なまま素通りされた。まず「どちらで行くか」を
15
+ // 選ばせ、選択の後にだけ督促する。
16
+ 'coordination_mode_undeclared',
17
+ 'coordination_conversation',
13
18
  'independence_unrecorded',
14
19
  'independence_task_undeclared',
15
20
  'independence_contract_superseded',
@@ -63,6 +68,14 @@ const CATALOG = Object.freeze({
63
68
  message: '着手候補が無いため、並列可否を述べる対象が無い。',
64
69
  next_action: 'none',
65
70
  }),
71
+ coordination_mode_undeclared: Object.freeze({
72
+ message: 'このplanは調整方式をまだ選んでいない。witness検証で並列するか、会話で調整するかが決まっていない。',
73
+ next_action: 'declare_coordination_mode',
74
+ }),
75
+ coordination_conversation: Object.freeze({
76
+ message: 'このplanは会話調整を選んでいる。並列可否は宣言と判定ではなく、卓の合意が持つ。',
77
+ next_action: 'none',
78
+ }),
66
79
  independence_unrecorded: Object.freeze({
67
80
  message: 'このplanの並列可否はまだ判定していない。競合が無いのではなく、記録が存在しない。',
68
81
  next_action: 'declare_witness_set_then_compile',
@@ -170,6 +183,7 @@ export function todoIndependenceGuidance(code, { severability = null } = {}) {
170
183
  export function selectIndependenceGuidance({
171
184
  coverage, taskDeclared, taskStale, conflictWithActive = null, conflictBetweenReady = null,
172
185
  contractSuperseded = false, readyCount = null, verdictsAbsent = false,
186
+ coordinationMode = 'witness',
173
187
  }) {
174
188
  // 着手候補が無いなら述べる対象が無い。ここを通さないと、readyが空のとき
175
189
  // 「未検査taskが1件も無い」が空虚に真になり、記録が古くても検証済みへ倒れる。
@@ -187,7 +201,16 @@ export function selectIndependenceGuidance({
187
201
  if (contractSuperseded) {
188
202
  return todoIndependenceGuidance('independence_contract_superseded');
189
203
  }
190
- if (coverage === 'missing') return todoIndependenceGuidance('independence_unrecorded');
204
+ // 記録が無い時に何を言うかは、planがどちらの方式を選んだかで変わる(ob03・裁定C①)。
205
+ // 会話調整を選んだplanへ未compileを督促するのは、選択を尊重しないことになる。未宣言の
206
+ // planへ督促するのは、誰の受入条件でもない作業を指すことになる——それが8件で素通りされた
207
+ // 当のものである。督促が一級で出るのはwitnessを選んだplanだけとする。
208
+ // 既定を`witness`にしてあるのは、宣言を渡さない既存の呼び出し側の挙動を変えないためである。
209
+ if (coverage === 'missing') {
210
+ if (coordinationMode === 'conversation') return todoIndependenceGuidance('coordination_conversation');
211
+ if (coordinationMode === null) return todoIndependenceGuidance('coordination_mode_undeclared');
212
+ return todoIndependenceGuidance('independence_unrecorded');
213
+ }
191
214
  if (coverage === 'superseded') return todoIndependenceGuidance('independence_superseded');
192
215
  if (!taskDeclared) return todoIndependenceGuidance('independence_task_undeclared');
193
216
  if (taskStale) return todoIndependenceGuidance('independence_stale_for_task');