@quolu/lattice 0.36.1 → 0.38.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.
package/src/todo-cli.mjs CHANGED
@@ -104,6 +104,12 @@ import {
104
104
  parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
105
105
  validateTodoRevision, validateTodoRevisionSet,
106
106
  } from './todo-revision.mjs';
107
+ import {
108
+ appendTodoNote,
109
+ readTodoNoteContext,
110
+ readTodoNoteContextsForPlan,
111
+ readTodoNoteEvents,
112
+ } from './todo-note-store.mjs';
107
113
 
108
114
  const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
109
115
  const DEFAULT_GANTT_REF = '.lattice/generated/gantt.html';
@@ -111,6 +117,7 @@ const GANTT_DESCRIPTOR_SUFFIX = '.status.json';
111
117
  const MAX_GANTT_DESCRIPTOR_BYTES = 65_536;
112
118
  const DEFAULT_GANTT_SCOPE = 'live';
113
119
  const MAX_MIGRATION_INPUT_BYTES = 8_388_608;
120
+ const MAX_NOTE_INPUT_BYTES = 16_384;
114
121
  const ACTOR_ENV_KEYS = Object.freeze([
115
122
  'LATTICE_TODO_ACTOR_HOST',
116
123
  'LATTICE_TODO_ACTOR_SESSION',
@@ -183,6 +190,43 @@ function selectMembers(store, requestedPlanKey) {
183
190
  return [member];
184
191
  }
185
192
 
193
+ function selectNoteTask(member, requestedTaskId) {
194
+ const exact = member.plan.tasks.find(({ task_id: taskId }) => taskId === requestedTaskId);
195
+ if (exact !== undefined) return exact;
196
+ const folded = requestedTaskId.toLowerCase();
197
+ const matches = member.plan.tasks.filter(({ task_id: taskId }) => taskId.toLowerCase() === folded);
198
+ if (matches.length !== 1) {
199
+ throw new TodoStoreError('NOTE_TASK_NOT_FOUND', 'note_task_not_active', undefined, {
200
+ plan_key: member.plan.plan_key, task_id: requestedTaskId,
201
+ });
202
+ }
203
+ return matches[0];
204
+ }
205
+
206
+ async function readNoteTextInput(repoRoot, inputRef) {
207
+ if (!isTodoRef(inputRef)) throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo');
208
+ const canonicalRoot = await realpath(repoRoot);
209
+ const absolute = path.resolve(canonicalRoot, inputRef);
210
+ if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) {
211
+ throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo');
212
+ }
213
+ let metadata;
214
+ try { metadata = await lstat(absolute); } catch {
215
+ throw new TodoStoreError('INPUT_UNREADABLE', 'input_missing');
216
+ }
217
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_NOTE_INPUT_BYTES) {
218
+ throw new TodoStoreError('INPUT_UNREADABLE', 'unsafe_or_oversized_note_input');
219
+ }
220
+ const resolved = await realpath(absolute);
221
+ if (resolved !== absolute || !within(canonicalRoot, resolved)) {
222
+ throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_alias_or_escape');
223
+ }
224
+ const bytes = await readFile(resolved);
225
+ if (bytes.length > MAX_NOTE_INPUT_BYTES) throw new TodoStoreError('INPUT_TOO_LARGE', 'note_input_too_large');
226
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
227
+ catch { throw new TodoStoreError('INPUT_UNREADABLE', 'note_input_invalid_utf8'); }
228
+ }
229
+
186
230
  function taskRef(plan, taskId) {
187
231
  return { project_id: plan.project_id, plan_key: plan.plan_key, task_id: taskId };
188
232
  }
@@ -416,6 +460,26 @@ function mutationActor(env) {
416
460
  return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
417
461
  }
418
462
 
463
+ /**
464
+ * gate_readyの札が出た理由と次の一歩をtypedに言う(ADR 0148裁定8)。
465
+ *
466
+ * 0.36.0で入れた終端監査gate(ADR 0147)は、これから終わる工程だけでなく過去に完了した
467
+ * 工程まで監査待ちにしてしまっていた——原因を言わないgateは、なぜ止まっているかを
468
+ * 説明しないのと同じなので、ここでその経緯を明示する。次の一歩は2択で書く:
469
+ * 今から監査する(review→accept)か、監査せず歴史として閉じる(close-unaudited、
470
+ * 複数plan一括ならbaseline)か。
471
+ */
472
+ function auditGateGuidance(planKey, phaseId) {
473
+ return '全ToDoがdoneになり監査待ち(gate_ready)。0.36.0で入れた終端監査gateは、'
474
+ + 'これから終わる工程だけでなく過去に完了した工程まで監査待ちにしてしまっていた'
475
+ + '(ADR 0148で修正)。今から監査するなら: '
476
+ + `todo phase review --plan ${planKey} --phase ${phaseId} --reason <text> → `
477
+ + `todo phase accept --plan ${planKey} --phase ${phaseId} --input <file>。`
478
+ + '監査せず歴史として閉じるなら: '
479
+ + `todo phase close-unaudited --plan ${planKey} --phase ${phaseId} --reason <text>`
480
+ + '(複数planをまとめて畳むなら todo phase baseline --reason <text> [--except <plan_key>]...)。';
481
+ }
482
+
419
483
  /**
420
484
  * phase無しplanで、この変異の結果terminal-audit Phaseがgate_ready(全task done・未監査)に
421
485
  * なっていれば助言を返す(ADR 0147)。doneの結果だけを見て機械的に判定するので、既にreview
@@ -429,14 +493,13 @@ function terminalAuditDoneAdvisory(plan, phases) {
429
493
  if (phase?.status !== 'gate_ready') return null;
430
494
  return {
431
495
  terminal_audit_required: true, phase_id: TERMINAL_AUDIT_PHASE_ID, status: phase.status,
432
- guidance: '全taskがdoneになった。このplanはphaseを持たないため、終端の重監査'
433
- + '(todo phase review --plan <key> --phase terminal-audit → todo phase accept)を'
434
- + '経るまで「閉じた」ことにはならない。',
496
+ guidance: auditGateGuidance(plan.plan_key, TERMINAL_AUDIT_PHASE_ID),
435
497
  };
436
498
  }
437
499
 
438
500
  async function mutate({
439
501
  repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
502
+ noteContext = null,
440
503
  }) {
441
504
  const actor = mutationActor(env);
442
505
  const evidence = evidenceRef === null ? null : await readEvidenceInput(repoRoot, evidenceRef);
@@ -457,8 +520,12 @@ async function mutate({
457
520
  // Phase状態はsnapshot(v1にはphasesキーが無い)でなく、appendTodoEventが別途返す
458
521
  // 導出ビュー`phases`から読む。
459
522
  const resolvedAdvisory = advisory ?? (kind === 'done' ? terminalAuditDoneAdvisory(plan, phases) : null);
523
+ const includesNoteContext = kind === 'start';
524
+ if (includesNoteContext && noteContext === null) {
525
+ throw new TypeError('start mutation requires note context');
526
+ }
460
527
  const result = {
461
- schema: 'lattice.todo_mutation_result.v2',
528
+ schema: includesNoteContext ? 'lattice.todo_mutation_result.v3' : 'lattice.todo_mutation_result.v2',
462
529
  project_id: event.project_id,
463
530
  plan_key: event.plan_key,
464
531
  plan_version: event.plan_version,
@@ -470,6 +537,7 @@ async function mutate({
470
537
  snapshot_digest: snapshot.snapshot_digest,
471
538
  status: task.status,
472
539
  advisory: resolvedAdvisory,
540
+ ...(includesNoteContext ? { note_context: noteContext } : {}),
473
541
  result_digest: '',
474
542
  };
475
543
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -637,13 +705,33 @@ async function startTask({
637
705
  });
638
706
  }
639
707
  }
640
- const resolvedTaskId = readyTask?.task_id ?? taskId;
708
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
709
+ if (member === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active');
710
+ const taskMatches = member.plan.tasks.filter(({ task_id: candidate }) => (
711
+ candidate.toLowerCase() === taskId.toLowerCase()
712
+ ));
713
+ if (taskMatches.length === 0) {
714
+ throw new TodoStoreError('TASK_NOT_FOUND', 'task_not_found', undefined, {
715
+ requested_task_id: taskId,
716
+ });
717
+ }
718
+ if (taskMatches.length > 1) {
719
+ throw new TodoStoreError('TASK_ID_AMBIGUOUS', 'task_id_case_ambiguous', undefined, {
720
+ requested_task_id: taskId,
721
+ matching_task_ids: taskMatches.map(({ task_id: candidate }) => candidate).sort(),
722
+ });
723
+ }
724
+ const resolvedTaskId = readyTask?.task_id ?? taskMatches[0].task_id;
725
+ // noteはjournal appendより前に読む。読めなければstart自体を止め、部分進行を作らない。
726
+ const { context: noteContext } = await readTodoNoteContext({
727
+ repoRoot, store, planKey, taskId: resolvedTaskId,
728
+ });
641
729
  // 助言はjournalへ書く前に確定させる。計算できないならstart自体を止める。
642
730
  const advisory = await startAdvisory({
643
731
  repoRoot, store, projection, planKey, taskId: resolvedTaskId,
644
732
  });
645
733
  return mutate({ repoRoot, env, planKey, taskId: resolvedTaskId, kind: 'start',
646
- payload: { override_reason: overrideReason }, evidenceRef: null, advisory });
734
+ payload: { override_reason: overrideReason }, evidenceRef: null, advisory, noteContext });
647
735
  }
648
736
 
649
737
  function validatePhaseDecisionInput(value, outcome) {
@@ -705,11 +793,110 @@ async function phaseStatus({ repoRoot, planKey }) {
705
793
  // 無いのでsnapshot.phasesは直接読まない)。ここでPHASE_UNAVAILABLEへ拒否せず、その暗黙Phase
706
794
  // をそのまま返す——`implicit`で機械可読に「宣言されたPhaseではない」ことを示す。
707
795
  const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
796
+ // ADR 0148裁定8: gate_readyのPhaseには、なぜ監査待ちになったかと次の一歩をここでも言う
797
+ // (todo doneのadvisoryと同じ文言)。implicit(暗黙のterminal-audit Phase)に限らず、
798
+ // v4/v5の実Phaseがgate_readyになった場合も同じ理由で同じ案内が要る。
799
+ const phases = member.phases.map((phase) => ({
800
+ ...phase,
801
+ guidance: phase.status === 'gate_ready'
802
+ ? auditGateGuidance(member.plan.plan_key, phase.phase_id) : null,
803
+ }));
708
804
  const result = {
709
805
  schema: 'lattice.phase_status_result.v1', project_id: store.project_id,
710
806
  plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
711
807
  journal_head_digest: member.journal.events.at(-1).event_digest,
712
- implicit, phases: member.phases, result_digest: '',
808
+ implicit, phases, result_digest: '',
809
+ };
810
+ result.result_digest = todoSelfDigest(result, 'result_digest');
811
+ return result;
812
+ }
813
+
814
+ /**
815
+ * `phase baseline --reason <text> [--except <plan_key>]...`の可変長`--except`を解析する。
816
+ * 他のargvはすべて固定位置(argv.length + 位置一致)で判定しているが、0回以上繰り返せる
817
+ * flagだけは固定位置で表現できないため、並び全体を見る専用ヘルパへ切り出す
818
+ * (bridge-cliの`parseOptions`と同じ発想)。不正な並びはnullを返し、呼び出し側の
819
+ * 分岐条件がそのままusageFailureへ落ちる。
820
+ */
821
+ function parseBaselineExceptFlags(rest) {
822
+ if (rest.length % 2 !== 0) return null;
823
+ const planKeys = [];
824
+ for (let index = 0; index < rest.length; index += 2) {
825
+ if (rest[index] !== '--except' || !isTodoIdentifier(rest[index + 1])) return null;
826
+ planKeys.push(rest[index + 1]);
827
+ }
828
+ return planKeys;
829
+ }
830
+
831
+ /**
832
+ * `phase baseline`——現在gate_readyかつphase eventを1つも持たないPhaseを一括で
833
+ * closed_unauditedへ宣言する(ADR 0148裁定6)。「phase eventを1つも持たない」は
834
+ * derivedStatusだけでは判定できない——一度reviewしてからreopenしたPhaseは、構造的には
835
+ * また`gate_ready`に戻り得るが、既に監査に触れている以上は対象外である。journalの実event
836
+ * 履歴(phase_id別に`phase_`で始まるkindがあるか)を直接見て区別する。
837
+ *
838
+ * `--except`はplan単位の除外(ADR 0148裁定7)。ServerManagerの26 ToDoのような
839
+ * 「最近の作業でコードも生きている」campaignを基準線で流さないための口であり、
840
+ * Phase単位では絞れない(そのplanのPhaseは丸ごと対象外になる)。
841
+ */
842
+ async function phaseBaseline({ repoRoot, env, reason, exceptPlanKeys }) {
843
+ const store = await readTodoStore({ repoRoot });
844
+ const knownPlanKeys = new Set(store.members.map(({ descriptor }) => descriptor.plan_key));
845
+ const unknownExcept = exceptPlanKeys.filter((planKey) => !knownPlanKeys.has(planKey));
846
+ if (unknownExcept.length > 0) {
847
+ throw new TodoStoreError('PHASE_BASELINE_INVALID', 'except_plan_key_unknown', undefined, {
848
+ unknown_plan_keys: unknownExcept,
849
+ });
850
+ }
851
+ const exceptSet = new Set(exceptPlanKeys);
852
+ const applied = [];
853
+ const excluded = [];
854
+ const notApplicable = [];
855
+ const failed = [];
856
+ for (const member of store.members) {
857
+ const planKey = member.descriptor.plan_key;
858
+ const touchedPhaseIds = new Set(member.journal.events
859
+ .filter((event) => event.kind.startsWith('phase_'))
860
+ .map((event) => event.phase_id));
861
+ for (const phase of member.phases) {
862
+ const entry = { plan_key: planKey, phase_id: phase.phase_id, status: phase.status };
863
+ // 「対象外だったもの(既にaccepted等)も区別して返す」——まだgate_readyに到達していない
864
+ // (locked/active)のと、既に監査の決着が付いている(accepted/rejected/closed_unaudited)のは
865
+ // 原因が違うので、causeを分けて機械可読にする。
866
+ if (['accepted', 'rejected', 'closed_unaudited'].includes(phase.status)) {
867
+ notApplicable.push({ ...entry, cause: `already_${phase.status}` });
868
+ } else if (phase.status !== 'gate_ready') {
869
+ notApplicable.push({ ...entry, cause: 'not_gate_ready' });
870
+ } else if (touchedPhaseIds.has(phase.phase_id)) {
871
+ notApplicable.push({ ...entry, cause: 'already_has_phase_event' });
872
+ } else if (exceptSet.has(planKey)) {
873
+ excluded.push({ ...entry, cause: 'excepted' });
874
+ } else {
875
+ // ADR 0148裁定6の非目標: journalはappend-onlyで、一度書いた宣言を後から取り消す
876
+ // 手段が無い。1件失敗しても既に書けた他件を無かったことにはできない以上、
877
+ // 全件一括のtransactionは新設せず、appendTodoEvent単位(1 event = 1排他書込み)の
878
+ // 独立実行を続ける。各書込みは呼ぶたびにstoreを読み直してgate_ready前提を
879
+ // 再検証するため、他項目の成否とは無関係に安全である——1件の失敗で残りを
880
+ // 止めず、続行して全件の結果をtypedで返す。
881
+ try {
882
+ const result = await phaseMutation({
883
+ repoRoot, env, planKey, phaseId: phase.phase_id,
884
+ kind: 'phase_close_unaudited', payload: { reason },
885
+ });
886
+ applied.push({ ...entry, event_digest: result.event_digest,
887
+ journal_head_digest: result.journal_head_digest });
888
+ } catch (error) {
889
+ failed.push({ ...entry, code: error?.code ?? 'INTERNAL_FAILURE',
890
+ message: typeof error?.message === 'string' ? error.message : null });
891
+ }
892
+ }
893
+ }
894
+ }
895
+ const result = {
896
+ schema: 'lattice.phase_baseline_result.v1',
897
+ reason, except_plan_keys: [...exceptSet].sort(),
898
+ applied, excluded, not_applicable: notApplicable, failed,
899
+ result_digest: '',
713
900
  };
714
901
  result.result_digest = todoSelfDigest(result, 'result_digest');
715
902
  return result;
@@ -811,6 +998,93 @@ async function status({ repoRoot }) {
811
998
  return projectTodoStatus(await readTodoStore({ repoRoot }));
812
999
  }
813
1000
 
1001
+ async function todoDetail({ repoRoot, planKey, taskId }) {
1002
+ const store = await readTodoStore({ repoRoot });
1003
+ const [member] = selectMembers(store, planKey);
1004
+ const task = selectNoteTask(member, taskId);
1005
+ const state = member.tasks.find(({ task_id: current }) => current === task.task_id);
1006
+ if (state === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'task_state_missing');
1007
+ const { context } = await readTodoNoteContext({
1008
+ repoRoot, store, planKey, taskId: task.task_id,
1009
+ });
1010
+ const result = {
1011
+ schema: 'lattice.todo_detail_result.v1',
1012
+ project_id: store.project_id,
1013
+ plan_key: planKey,
1014
+ plan_version: member.plan.plan_version,
1015
+ task,
1016
+ state,
1017
+ note_context: context,
1018
+ result_digest: '',
1019
+ };
1020
+ result.result_digest = todoSelfDigest(result, 'result_digest');
1021
+ return result;
1022
+ }
1023
+
1024
+ async function appendNote({ repoRoot, env, planKey, taskId, message, inputRef, supersedes }) {
1025
+ const store = await readTodoStore({ repoRoot });
1026
+ const [member] = selectMembers(store, planKey);
1027
+ const task = selectNoteTask(member, taskId);
1028
+ const body = inputRef === null ? message : await readNoteTextInput(repoRoot, inputRef);
1029
+ const projectedBeforeAppend = await readTodoNoteContext({
1030
+ repoRoot, store, planKey, taskId: task.task_id,
1031
+ });
1032
+ const event = await appendTodoNote({
1033
+ repoRoot,
1034
+ projectId: store.project_id,
1035
+ planKey,
1036
+ planVersion: member.plan.plan_version,
1037
+ taskId: task.task_id,
1038
+ actor: mutationActor(env),
1039
+ recordedAt: new Date().toISOString(),
1040
+ body,
1041
+ supersedes,
1042
+ eligibleSupersedes: projectedBeforeAppend.history.map(({ event_digest: digest }) => digest),
1043
+ });
1044
+ const { context } = await readTodoNoteContext({ repoRoot, store, planKey, taskId: task.task_id });
1045
+ const result = {
1046
+ schema: 'lattice.todo_note_append_result.v1',
1047
+ project_id: store.project_id,
1048
+ plan_key: planKey,
1049
+ task_id: task.task_id,
1050
+ event,
1051
+ note_context: context,
1052
+ result_digest: '',
1053
+ };
1054
+ result.result_digest = todoSelfDigest(result, 'result_digest');
1055
+ return result;
1056
+ }
1057
+
1058
+ async function listNotes({ repoRoot, planKey, taskId }) {
1059
+ const store = await readTodoStore({ repoRoot });
1060
+ const [member] = selectMembers(store, planKey);
1061
+ const chain = await readTodoNoteEvents({ repoRoot, planKey });
1062
+ let notes = chain.events;
1063
+ let archived = [];
1064
+ let resolvedTaskId = null;
1065
+ if (taskId !== null) {
1066
+ const task = selectNoteTask(member, taskId);
1067
+ resolvedTaskId = task.task_id;
1068
+ const projected = await readTodoNoteContext({
1069
+ repoRoot, store, planKey, taskId: task.task_id,
1070
+ });
1071
+ notes = projected.history;
1072
+ archived = projected.archived;
1073
+ }
1074
+ const result = {
1075
+ schema: 'lattice.todo_note_list_result.v1',
1076
+ project_id: store.project_id,
1077
+ plan_key: planKey,
1078
+ requested_task_id: resolvedTaskId,
1079
+ notes,
1080
+ archived,
1081
+ note_head_digest: chain.head_digest,
1082
+ result_digest: '',
1083
+ };
1084
+ result.result_digest = todoSelfDigest(result, 'result_digest');
1085
+ return result;
1086
+ }
1087
+
814
1088
  async function bindings({ repoRoot, requestedPlanKey }) {
815
1089
  return projectTodoBindings(await readTodoStore({ repoRoot }), { requestedPlanKey });
816
1090
  }
@@ -1604,12 +1878,13 @@ function parseGanttDescriptor(bytes, descriptorRef) {
1604
1878
  * 一つの関数だけが組む。
1605
1879
  */
1606
1880
  export async function ganttLiveHeadDigest({ repoRoot, store }) {
1607
- const [independence, seamProposals] = await Promise.all([
1881
+ const [independence, seamProposals, noteHeads] = await Promise.all([
1608
1882
  independenceForGantt({ repoRoot, store }),
1609
1883
  seamProposalsForGantt({ repoRoot, store }),
1884
+ noteHeadsForGantt({ repoRoot, store }),
1610
1885
  ]);
1611
1886
  return digestTodoArtifact({
1612
- schema: 'lattice.todo_gantt_live_head.v1',
1887
+ schema: 'lattice.todo_gantt_live_head.v2',
1613
1888
  manifest_digest: store.manifest.manifest_digest,
1614
1889
  independence: independence === null ? null : independence.map((entry) => ({
1615
1890
  plan_key: entry.plan_key,
@@ -1621,9 +1896,46 @@ export async function ganttLiveHeadDigest({ repoRoot, store }) {
1621
1896
  coverage: entry.coverage,
1622
1897
  projection_digest: digestTodoArtifact(entry),
1623
1898
  })),
1899
+ note_heads: noteHeads,
1624
1900
  });
1625
1901
  }
1626
1902
 
1903
+ async function noteHeadsForGantt({ repoRoot, store }) {
1904
+ const bindings = [];
1905
+ for (const member of store.members) {
1906
+ try {
1907
+ const chain = await readTodoNoteEvents({ repoRoot, planKey: member.plan.plan_key });
1908
+ bindings.push({ plan_key: member.plan.plan_key, note_head_digest: chain.head_digest, error: null });
1909
+ } catch (error) {
1910
+ if (typeof error?.code !== 'string' || !error.code.startsWith('NOTE_')) throw error;
1911
+ bindings.push({ plan_key: member.plan.plan_key, note_head_digest: null, error: error.code });
1912
+ }
1913
+ }
1914
+ return bindings;
1915
+ }
1916
+
1917
+ async function notesForGantt({ repoRoot, store }) {
1918
+ const contexts = [];
1919
+ const warnings = [];
1920
+ const headBindings = [];
1921
+ for (const member of store.members) {
1922
+ try {
1923
+ const projected = await readTodoNoteContextsForPlan({
1924
+ repoRoot, store, planKey: member.plan.plan_key,
1925
+ });
1926
+ contexts.push(...projected.contexts);
1927
+ headBindings.push({
1928
+ plan_key: member.plan.plan_key, note_head_digest: projected.note_head_digest,
1929
+ });
1930
+ } catch (error) {
1931
+ if (typeof error?.code !== 'string' || !error.code.startsWith('NOTE_')) throw error;
1932
+ warnings.push({ plan_key: member.plan.plan_key, code: error.code, message: error.message });
1933
+ headBindings.push({ plan_key: member.plan.plan_key, note_head_digest: null });
1934
+ }
1935
+ }
1936
+ return { contexts, warnings, headBindings };
1937
+ }
1938
+
1627
1939
  async function independenceForGantt({ repoRoot, store }) {
1628
1940
  const frontier = computeReadyFrontier(store);
1629
1941
  const status = projectTodoStatus(store);
@@ -1712,7 +2024,7 @@ async function seamProposalsForGantt({ repoRoot, store }) {
1712
2024
 
1713
2025
  export async function renderTodoGanttForProject({
1714
2026
  repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
1715
- scope = DEFAULT_GANTT_SCOPE,
2027
+ scope = DEFAULT_GANTT_SCOPE, includeNotes = true,
1716
2028
  }) {
1717
2029
  const store = readModel
1718
2030
  ?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
@@ -1722,9 +2034,11 @@ export async function renderTodoGanttForProject({
1722
2034
  const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
1723
2035
  const topology = mergedTopology(store);
1724
2036
  const chain = projectTodoChainV1(topology);
1725
- const [independence, seamProposals] = await Promise.all([
2037
+ const [independence, seamProposals, notes] = await Promise.all([
1726
2038
  independenceForGantt({ repoRoot, store }),
1727
2039
  seamProposalsForGantt({ repoRoot, store }),
2040
+ includeNotes ? notesForGantt({ repoRoot, store })
2041
+ : { contexts: null, warnings: [], headBindings: [] },
1728
2042
  ]);
1729
2043
  const layout = layoutTodoGantt(store, chain, { scope, independence, seamProposals });
1730
2044
  // When the diagram hides history, the page also carries the full diagram so
@@ -1758,6 +2072,7 @@ export async function renderTodoGanttForProject({
1758
2072
  presentation_digest: presentation.presentation_digest,
1759
2073
  chain_digest: digestTodoArtifact(chain),
1760
2074
  layout_digest: digestTodoArtifact(layout),
2075
+ note_bindings_digest: digestTodoArtifact(notes.headBindings),
1761
2076
  renderer_version: TODO_GANTT_RENDERER_VERSION,
1762
2077
  project_display_name: identity.displayName,
1763
2078
  folded_task_count: layout.scope.folded_task_count,
@@ -1770,10 +2085,17 @@ export async function renderTodoGanttForProject({
1770
2085
  anchorOutcomes,
1771
2086
  presentation,
1772
2087
  metadata,
2088
+ noteContexts: notes.contexts,
2089
+ noteWarnings: notes.warnings,
1773
2090
  });
1774
2091
  return { store, metadata, memberBindings, rendered };
1775
2092
  }
1776
2093
 
2094
+ /** 公開配信面は呼び出し側の既定値忘れに依存せず、常にnote本文を除外する。 */
2095
+ export async function renderPublicTodoGanttForProject(options = {}) {
2096
+ return renderTodoGanttForProject({ ...options, includeNotes: false });
2097
+ }
2098
+
1777
2099
  async function gantt({ repoRoot, outputRef, env, scope = DEFAULT_GANTT_SCOPE }) {
1778
2100
  const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({
1779
2101
  repoRoot, env, scope,
@@ -1858,7 +2180,7 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
1858
2180
  port,
1859
2181
  render: async () => {
1860
2182
  const store = await readTodoStoreStable({ repoRoot });
1861
- const { rendered } = await renderTodoGanttForProject({
2183
+ const { rendered } = await renderPublicTodoGanttForProject({
1862
2184
  repoRoot, stable: true, displayName: identity.displayName, scope, readModel: store,
1863
2185
  });
1864
2186
  return { html: rendered.html, head_digest: await ganttLiveHeadDigest({ repoRoot, store }) };
@@ -1903,6 +2225,10 @@ async function ensureActiveProjectDashboard({ repoRoot, env }) {
1903
2225
  async function verify({ repoRoot, requestedPlanKey }) {
1904
2226
  const store = await readTodoStore({ repoRoot });
1905
2227
  const members = selectMembers(store, requestedPlanKey);
2228
+ // note chainはlifecycle storeと独立だが、verifyは両方を検査する。破損を空扱いしない。
2229
+ for (const member of members) {
2230
+ await readTodoNoteEvents({ repoRoot, planKey: member.plan.plan_key });
2231
+ }
1906
2232
  const verifiedSourceInventories = new Map();
1907
2233
  for (const member of members) {
1908
2234
  const unverified = member.tasks.find((task) => task.evidence_unverified);
@@ -2019,6 +2345,10 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2019
2345
  if ((argv.length === 1 && argv[0] === 'status')
2020
2346
  || (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
2021
2347
  action = (repoRoot) => status({ repoRoot });
2348
+ } else if (argv.length === 6 && argv[0] === 'show'
2349
+ && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2350
+ && argv[3] === '--task' && isTodoIdentifier(argv[4]) && argv[5] === '--json') {
2351
+ action = (repoRoot) => todoDetail({ repoRoot, planKey: argv[2], taskId: argv[4] });
2022
2352
  } else if ((argv.length === 1 && argv[0] === 'bindings')
2023
2353
  || (argv.length === 2 && argv[0] === 'bindings' && argv[1] === '--json')) {
2024
2354
  action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: null });
@@ -2026,6 +2356,26 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2026
2356
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2027
2357
  && (argv.length === 3 || argv[3] === '--json')) {
2028
2358
  action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: argv[2] });
2359
+ } else if ((argv.length === 7 || argv.length === 9) && argv[0] === 'note'
2360
+ && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2361
+ && argv[3] === '--task' && isTodoIdentifier(argv[4])
2362
+ && ['--message', '--input'].includes(argv[5])
2363
+ && ((argv[5] === '--message' && argv[6].length > 0)
2364
+ || (argv[5] === '--input' && isTodoRef(argv[6])))
2365
+ && (argv.length === 7 || (argv[7] === '--supersedes' && isTodoDigest(argv[8])))) {
2366
+ action = (repoRoot) => appendNote({
2367
+ repoRoot, env, planKey: argv[2], taskId: argv[4],
2368
+ message: argv[5] === '--message' ? argv[6] : null,
2369
+ inputRef: argv[5] === '--input' ? argv[6] : null,
2370
+ supersedes: argv[8] ?? null,
2371
+ });
2372
+ } else if (argv.length === 5 && argv[0] === 'note' && argv[1] === 'list'
2373
+ && argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--json') {
2374
+ action = (repoRoot) => listNotes({ repoRoot, planKey: argv[3], taskId: null });
2375
+ } else if (argv.length === 7 && argv[0] === 'note' && argv[1] === 'list'
2376
+ && argv[2] === '--plan' && isTodoIdentifier(argv[3])
2377
+ && argv[4] === '--task' && isTodoIdentifier(argv[5]) && argv[6] === '--json') {
2378
+ action = (repoRoot) => listNotes({ repoRoot, planKey: argv[3], taskId: argv[5] });
2029
2379
  } else if (argv.length === 5 && argv[0] === 'independence' && argv[1] === 'witness'
2030
2380
  && argv[2] === 'migrate' && argv[3] === '--plan' && isTodoIdentifier(argv[4])) {
2031
2381
  action = (repoRoot) => independenceWitnessMigrate({ repoRoot, planKey: argv[4] });
@@ -2148,6 +2498,17 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2148
2498
  && (argv.length === 8 || (argv[8] === '--override-reason' && argv[9].length > 0))) {
2149
2499
  action = (repoRoot) => phaseMutation({ repoRoot, env, planKey: argv[3], phaseId: argv[5],
2150
2500
  kind: 'phase_reopen', payload: { reason: argv[7], override_reason: argv[9] ?? null } });
2501
+ } else if (argv.length === 8 && argv[0] === 'phase' && argv[1] === 'close-unaudited'
2502
+ && argv[2] === '--plan' && isTodoIdentifier(argv[3])
2503
+ && argv[4] === '--phase' && isTodoIdentifier(argv[5])
2504
+ && argv[6] === '--reason' && argv[7].length > 0) {
2505
+ action = (repoRoot) => phaseMutation({ repoRoot, env, planKey: argv[3], phaseId: argv[5],
2506
+ kind: 'phase_close_unaudited', payload: { reason: argv[7] } });
2507
+ } else if (argv.length >= 4 && argv[0] === 'phase' && argv[1] === 'baseline'
2508
+ && argv[2] === '--reason' && argv[3].length > 0
2509
+ && parseBaselineExceptFlags(argv.slice(4)) !== null) {
2510
+ const exceptPlanKeys = parseBaselineExceptFlags(argv.slice(4));
2511
+ action = (repoRoot) => phaseBaseline({ repoRoot, env, reason: argv[3], exceptPlanKeys });
2151
2512
  } else if ((argv.length === 5 || argv.length === 6 || argv.length === 7 || argv.length === 8)
2152
2513
  && argv[0] === 'start'
2153
2514
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])