@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/README.ja.md +29 -0
- package/README.md +25 -0
- package/bin/lattice-dashboard.mjs +2 -2
- package/package.json +1 -1
- package/src/cli-help.mjs +17 -1
- package/src/todo-cli.mjs +373 -12
- package/src/todo-contracts.mjs +80 -3
- package/src/todo-gantt-html-independence.mjs +29 -3
- package/src/todo-gantt-html-style.mjs +6 -0
- package/src/todo-gantt-html.mjs +28 -6
- package/src/todo-note-store.mjs +492 -0
- package/src/todo-store.mjs +40 -12
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:
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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])
|