@quolu/lattice 0.34.2 → 0.36.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 +54 -4
- package/README.md +8 -0
- package/bin/lattice.mjs +15 -3
- package/docs/bridge-setup.md +13 -0
- package/package.json +1 -1
- package/src/cli-help.mjs +20 -9
- package/src/project-cli.mjs +126 -3
- package/src/todo-cli.mjs +224 -22
- package/src/todo-contracts.mjs +2 -2
- package/src/todo-dispatch-shape.mjs +190 -0
- package/src/todo-gantt-html-shared.mjs +2 -1
- package/src/todo-gantt-layout.mjs +13 -3
- package/src/todo-gantt-live.mjs +21 -2
- package/src/todo-gantt-scope.mjs +27 -2
- package/src/todo-migration.mjs +108 -0
- package/src/todo-revision.mjs +568 -2
- package/src/todo-status.mjs +5 -2
- package/src/todo-store.mjs +155 -33
package/src/todo-cli.mjs
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
applyTodoRevisionSet,
|
|
38
38
|
createTodoStoreWriter,
|
|
39
39
|
TodoStoreError,
|
|
40
|
+
isPhaselessTodoPlanSchema,
|
|
41
|
+
TERMINAL_AUDIT_PHASE_ID,
|
|
40
42
|
readTodoIndependenceArtifact,
|
|
41
43
|
readTodoSeamProposalArtifact,
|
|
42
44
|
readTodoStore,
|
|
@@ -52,8 +54,13 @@ import {
|
|
|
52
54
|
} from './todo-store.mjs';
|
|
53
55
|
import {
|
|
54
56
|
appendTodoExtraction,
|
|
57
|
+
explainTodoExtraction,
|
|
55
58
|
validateTodoExtraction,
|
|
56
59
|
} from './todo-migration.mjs';
|
|
60
|
+
import {
|
|
61
|
+
assertTodoDispatchShapeReviewed,
|
|
62
|
+
computeTodoDispatchShapeForPlan,
|
|
63
|
+
} from './todo-dispatch-shape.mjs';
|
|
57
64
|
import {
|
|
58
65
|
computeReadyFrontier,
|
|
59
66
|
projectTodoBindings,
|
|
@@ -93,6 +100,7 @@ import {
|
|
|
93
100
|
validateWitnessDraft,
|
|
94
101
|
} from './witness-scaffold.mjs';
|
|
95
102
|
import {
|
|
103
|
+
explainPhaseTodoRevision, explainTodoRevision, explainTodoRevisionSet,
|
|
96
104
|
parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
|
|
97
105
|
validateTodoRevision, validateTodoRevisionSet,
|
|
98
106
|
} from './todo-revision.mjs';
|
|
@@ -109,6 +117,31 @@ const ACTOR_ENV_KEYS = Object.freeze([
|
|
|
109
117
|
'LATTICE_TODO_ACTOR_AGENT',
|
|
110
118
|
]);
|
|
111
119
|
|
|
120
|
+
/**
|
|
121
|
+
* `revise` / `revise-set` / `revise-phase` / `migrate`が実際に受理する最新契約のJSON
|
|
122
|
+
* Schemaを配布物から読む入口(`project-cli.mjs`の`runPlanCreateSchema`と同じ作法)。
|
|
123
|
+
*
|
|
124
|
+
* schemaを取る手段がCLIに無いと、AIはsrcを読んで必須keyを数えるしかなくなる
|
|
125
|
+
* (実運用で`phase_todo_revision.v3`の必須12 keyを試行錯誤で当てた)。storeは読まない
|
|
126
|
+
* ——`plan create --schema`と同じく決定的な出力・exit 0にする。
|
|
127
|
+
*/
|
|
128
|
+
const TODO_SCHEMA_COMMANDS = Object.freeze({
|
|
129
|
+
revise: { title: 'lattice.todo_revision.v2', file: 'lattice.todo_revision.v2.schema.json' },
|
|
130
|
+
'revise-set': { title: 'lattice.todo_revision_set.v3', file: 'lattice.todo_revision_set.v3.schema.json' },
|
|
131
|
+
'revise-phase': {
|
|
132
|
+
title: 'lattice.phase_todo_revision.v3', file: 'lattice.phase_todo_revision.v3.schema.json',
|
|
133
|
+
},
|
|
134
|
+
migrate: { title: 'lattice.todo_extraction.v2', file: 'lattice.todo_extraction.v2.schema.json' },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
async function runTodoSchemaCommand(command, stdout) {
|
|
138
|
+
const spec = TODO_SCHEMA_COMMANDS[command];
|
|
139
|
+
const schemaUrl = new URL(`../docs/schemas/${spec.file}`, import.meta.url);
|
|
140
|
+
const schema = JSON.parse(await readFile(schemaUrl, 'utf8'));
|
|
141
|
+
if (schema?.title !== spec.title) throw new TypeError(`bundled ${command} schema invalid`);
|
|
142
|
+
stdout.write(`${JSON.stringify(schema)}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
112
145
|
function usageFailure(stderr, argv) {
|
|
113
146
|
const received = argv.length === 0 ? '(none)' : argv.join(' ').replace(/[\r\n]/gu, ' ');
|
|
114
147
|
stderr.write(`lattice todo: unsupported command or arguments: ${received}\n`);
|
|
@@ -219,7 +252,11 @@ async function readMigrationInput(repoRoot, inputRef) {
|
|
|
219
252
|
throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
|
|
220
253
|
}
|
|
221
254
|
if (!validateTodoExtraction(extraction)) {
|
|
222
|
-
|
|
255
|
+
// 「schema_invalid」だけでは何のfieldがどう壊れているか分からない(ADR 0130の案内規律)。
|
|
256
|
+
// explainは可否判定を変えず、診断だけを追加する。
|
|
257
|
+
const explained = explainTodoExtraction(extraction);
|
|
258
|
+
throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid', undefined,
|
|
259
|
+
explained.valid ? undefined : { violation_reason: explained.reason, violation_path: explained.path });
|
|
223
260
|
}
|
|
224
261
|
return extraction;
|
|
225
262
|
}
|
|
@@ -228,6 +265,7 @@ async function readRevisionInput(repoRoot, inputRef, {
|
|
|
228
265
|
validate = validateTodoRevision,
|
|
229
266
|
invalidCode = 'REVISION_INVALID',
|
|
230
267
|
invalidReason = 'revision_schema_or_digest_invalid',
|
|
268
|
+
explain = explainTodoRevision,
|
|
231
269
|
} = {}) {
|
|
232
270
|
const canonicalRoot = await realpath(repoRoot);
|
|
233
271
|
const absolute = path.resolve(canonicalRoot, inputRef);
|
|
@@ -264,7 +302,16 @@ async function readRevisionInput(repoRoot, inputRef, {
|
|
|
264
302
|
try { revision = JSON.parse(text.slice(0, -1)); } catch {
|
|
265
303
|
throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
|
|
266
304
|
}
|
|
267
|
-
if (!validate(revision))
|
|
305
|
+
if (!validate(revision)) {
|
|
306
|
+
// 「schema_or_digest_invalid」だけでは何のfieldがどう壊れているか分からない
|
|
307
|
+
// (ADR 0130の案内規律)。explainは可否判定を変えず、診断だけを追加する。
|
|
308
|
+
// 呼び出し元がexplainを渡さない(phase decision入力等)場合はdetail無しのまま。
|
|
309
|
+
const explained = explain === null ? null : explain(revision);
|
|
310
|
+
throw new TodoStoreError(invalidCode, invalidReason, undefined,
|
|
311
|
+
explained === null || explained.valid ? undefined : {
|
|
312
|
+
violation_reason: explained.reason, violation_path: explained.path,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
268
315
|
if (text !== `${canonicalizeTodoArtifact(revision)}\n`) {
|
|
269
316
|
throw new TodoStoreError(invalidCode, 'non_canonical_revision_bytes');
|
|
270
317
|
}
|
|
@@ -369,6 +416,25 @@ function mutationActor(env) {
|
|
|
369
416
|
return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
|
|
370
417
|
}
|
|
371
418
|
|
|
419
|
+
/**
|
|
420
|
+
* phase無しplanで、この変異の結果terminal-audit Phaseがgate_ready(全task done・未監査)に
|
|
421
|
+
* なっていれば助言を返す(ADR 0147)。doneの結果だけを見て機械的に判定するので、既にreview
|
|
422
|
+
* まで進んでいれば`gate_ready`ではなくなり、二重に案内しない。phase付きplanや、まだ
|
|
423
|
+
* pending taskが残っているplanではterminal-audit Phase自体が無い/gate_readyでないので、
|
|
424
|
+
* このヘルパはnullを返し既存の`advisory: null`の挙動を変えない。
|
|
425
|
+
*/
|
|
426
|
+
function terminalAuditDoneAdvisory(plan, phases) {
|
|
427
|
+
if (!isPhaselessTodoPlanSchema(plan.schema)) return null;
|
|
428
|
+
const phase = phases.find(({ phase_id }) => phase_id === TERMINAL_AUDIT_PHASE_ID);
|
|
429
|
+
if (phase?.status !== 'gate_ready') return null;
|
|
430
|
+
return {
|
|
431
|
+
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
|
+
+ '経るまで「閉じた」ことにはならない。',
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
372
438
|
async function mutate({
|
|
373
439
|
repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
|
|
374
440
|
}) {
|
|
@@ -379,13 +445,18 @@ async function mutate({
|
|
|
379
445
|
if (kind === 'done' && payload === 'evidence_promotion') {
|
|
380
446
|
eventPayload = { done_mode: 'evidence_promotion', imported: true, evidence };
|
|
381
447
|
}
|
|
382
|
-
const { event, snapshot } = await appendTodoEvent({
|
|
448
|
+
const { event, snapshot, plan, phases } = await appendTodoEvent({
|
|
383
449
|
repoRoot,
|
|
384
450
|
writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
|
|
385
451
|
planKey,
|
|
386
452
|
event: { kind, task_id: taskId, actor, payload: eventPayload },
|
|
387
453
|
});
|
|
388
454
|
const task = snapshot.tasks.find(({ task_id: current }) => current === event.task_id);
|
|
455
|
+
// advisoryは呼び出し側(startTask)がstart用に既に組んでいればそれを尊重し、無ければ
|
|
456
|
+
// done時だけ終端監査の要否を調べる。block/unblock/reopenはnullのまま(既存挙動を変えない)。
|
|
457
|
+
// Phase状態はsnapshot(v1にはphasesキーが無い)でなく、appendTodoEventが別途返す
|
|
458
|
+
// 導出ビュー`phases`から読む。
|
|
459
|
+
const resolvedAdvisory = advisory ?? (kind === 'done' ? terminalAuditDoneAdvisory(plan, phases) : null);
|
|
389
460
|
const result = {
|
|
390
461
|
schema: 'lattice.todo_mutation_result.v2',
|
|
391
462
|
project_id: event.project_id,
|
|
@@ -398,7 +469,7 @@ async function mutate({
|
|
|
398
469
|
journal_head_digest: event.event_digest,
|
|
399
470
|
snapshot_digest: snapshot.snapshot_digest,
|
|
400
471
|
status: task.status,
|
|
401
|
-
advisory,
|
|
472
|
+
advisory: resolvedAdvisory,
|
|
402
473
|
result_digest: '',
|
|
403
474
|
};
|
|
404
475
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
@@ -477,7 +548,32 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
|
|
|
477
548
|
};
|
|
478
549
|
}
|
|
479
550
|
|
|
480
|
-
|
|
551
|
+
// 直列化の理由として認めない定型句。
|
|
552
|
+
// worker数・セッション構成・作業者の都合は「並列にできない根拠」ではない。
|
|
553
|
+
// 根拠になるのは実際の干渉だけ(同一fileへの書込衝突・外部資源の排他・順序依存)。
|
|
554
|
+
// 単一プロセスのagentが「自分は1人だから」と直列へ逃げる事例が実運用で出たため、
|
|
555
|
+
// frontierの既定(all_ready_parallel_by_default)を宣言だけでなく機構で守る。
|
|
556
|
+
const SERIAL_NON_REASONS = [
|
|
557
|
+
/単一(?:の)?(?:セッション|エージェント|worker|ワーカー|プロセス|スレッド)/u,
|
|
558
|
+
/(?:逐次|順次|直列|シリアル)(?:実行|処理|化|に|で)/u,
|
|
559
|
+
/(?:一人|1人|ひとり|1名|単独)(?:で|の|しか)/u,
|
|
560
|
+
/(?:サブ)?エージェント(?:が|は)?(?:居ない|いない|使わない|使えない)/u,
|
|
561
|
+
/single[-\s]?(?:session|agent|worker|process|thread)/iu,
|
|
562
|
+
/\b(?:sequential|serial)(?:ly)?\s*(?:execution|processing|run|dispatch)?\b/iu,
|
|
563
|
+
/one[-\s]at[-\s]a[-\s]time|\bsolo\b|\bby myself\b/iu,
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 直列化理由が「実際の干渉」を述べているかを検査する。
|
|
568
|
+
* worker数・セッション構成を述べただけの理由は根拠にならないので拒否する。
|
|
569
|
+
*/
|
|
570
|
+
function serialReasonNonInterference(reason) {
|
|
571
|
+
return SERIAL_NON_REASONS.find((pattern) => pattern.test(reason)) ?? null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function startTask({
|
|
575
|
+
repoRoot, env, planKey, taskId, overrideReason, parallelFrontier, serialConfirmed = false,
|
|
576
|
+
}) {
|
|
481
577
|
const store = await readTodoStore({ repoRoot });
|
|
482
578
|
const projection = projectTodoStatus(store);
|
|
483
579
|
const readyTask = projection.next_ready.find((task) => (
|
|
@@ -487,16 +583,60 @@ async function startTask({ repoRoot, env, planKey, taskId, overrideReason, paral
|
|
|
487
583
|
if (parallelFrontier && !targetReady) {
|
|
488
584
|
throw new TodoStoreError('PARALLEL_DISPATCH_INVALID', 'parallel_frontier_not_applicable');
|
|
489
585
|
}
|
|
490
|
-
|
|
491
|
-
&&
|
|
586
|
+
const frontierContested = targetReady && projection.active_set.length === 0
|
|
587
|
+
&& projection.next_ready.length > 1;
|
|
588
|
+
if (frontierContested && overrideReason === null && !parallelFrontier) {
|
|
492
589
|
throw new TodoStoreError('PARALLEL_DISPATCH_REQUIRED', 'parallel_frontier_requires_declaration',
|
|
493
590
|
undefined, {
|
|
494
591
|
ready_count: projection.next_ready.length,
|
|
592
|
+
ready_task_ids: projection.next_ready.map((task) => task.task_id),
|
|
495
593
|
frontier_digest: projection.dispatch_frontier.frontier_digest,
|
|
496
594
|
parallel_start_flag: projection.dispatch_frontier.parallel_start_flag,
|
|
497
595
|
serial_reason_flag: '--override-reason',
|
|
596
|
+
default_policy: projection.dispatch_frontier.policy,
|
|
597
|
+
guidance: '既定は全ready分の同時dispatch。並列で始めるなら --parallel-frontier を使う。'
|
|
598
|
+
+ '--override-reason は「なぜ並列にできないか」を書く欄であり、'
|
|
599
|
+
+ 'worker数・セッション構成・作業者の都合は根拠にならない'
|
|
600
|
+
+ '(同一fileへの書込衝突・外部資源の排他・順序依存だけが根拠になる)。',
|
|
498
601
|
});
|
|
499
602
|
}
|
|
603
|
+
if (frontierContested && overrideReason !== null) {
|
|
604
|
+
if (serialReasonNonInterference(overrideReason) !== null) {
|
|
605
|
+
throw new TodoStoreError('PARALLEL_DISPATCH_INVALID', 'serial_reason_is_not_an_interference',
|
|
606
|
+
undefined, {
|
|
607
|
+
ready_count: projection.next_ready.length,
|
|
608
|
+
ready_task_ids: projection.next_ready.map((task) => task.task_id),
|
|
609
|
+
rejected_reason: overrideReason,
|
|
610
|
+
default_policy: projection.dispatch_frontier.policy,
|
|
611
|
+
parallel_start_flag: projection.dispatch_frontier.parallel_start_flag,
|
|
612
|
+
guidance: 'worker数・セッション構成・作業者の都合は直列化の根拠にならない。'
|
|
613
|
+
+ 'readyが複数あるなら既定は同時dispatchであり、実行主体が1つしか無いことは'
|
|
614
|
+
+ '並列にできない理由ではない(必要ならworkerを増やす)。'
|
|
615
|
+
+ '直列にするなら、並列で走らせたときに実際に起きる干渉'
|
|
616
|
+
+ '(同一fileへの書込衝突・外部資源の排他・順序依存)を書く。',
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
// 直列の申告は一度突き返して並列を再検討させる。
|
|
620
|
+
// 規則を書くだけでは読み飛ばされるため、再考をコマンドの往復で強制する。
|
|
621
|
+
if (!serialConfirmed) {
|
|
622
|
+
throw new TodoStoreError('PARALLEL_DISPATCH_RECONSIDER', 'consider_parallel_before_serial',
|
|
623
|
+
undefined, {
|
|
624
|
+
ready_count: projection.next_ready.length,
|
|
625
|
+
ready_task_ids: projection.next_ready.map((task) => task.task_id),
|
|
626
|
+
declared_reason: overrideReason,
|
|
627
|
+
default_policy: projection.dispatch_frontier.policy,
|
|
628
|
+
parallel_start_flag: projection.dispatch_frontier.parallel_start_flag,
|
|
629
|
+
serial_confirm_flag: '--serial-confirmed',
|
|
630
|
+
guidance: `並列を検討しなさい。ready ${projection.next_ready.length} 件は同時に着手できる`
|
|
631
|
+
+ '前提で並んでおり、既定は全件同時dispatchである。'
|
|
632
|
+
+ `まず ${projection.dispatch_frontier.parallel_start_flag} で全readyを起こし、`
|
|
633
|
+
+ 'それぞれ別のworkerへ渡すことを検討する'
|
|
634
|
+
+ '(実行主体が足りないなら増やす。増やせないことは並列にできない理由ではない)。'
|
|
635
|
+
+ '検討した上でなお直列にするなら、同じ --override-reason に'
|
|
636
|
+
+ ' --serial-confirmed を付けて再実行する。',
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
500
640
|
const resolvedTaskId = readyTask?.task_id ?? taskId;
|
|
501
641
|
// 助言はjournalへ書く前に確定させる。計算できないならstart自体を止める。
|
|
502
642
|
const advisory = await startAdvisory({
|
|
@@ -526,6 +666,8 @@ async function phaseDecision({ repoRoot, env, planKey, phaseId, outcome, inputRe
|
|
|
526
666
|
const input = await readRevisionInput(repoRoot, inputRef, {
|
|
527
667
|
validate: (value) => validatePhaseDecisionInput(value, outcome),
|
|
528
668
|
invalidCode: 'PHASE_DECISION_INVALID', invalidReason: 'phase_decision_schema_or_digest_invalid',
|
|
669
|
+
// phase decision入力はrevision契約と別形状。既定のrevision explainを誤って当てない。
|
|
670
|
+
explain: null,
|
|
529
671
|
});
|
|
530
672
|
const payload = outcome === 'accept'
|
|
531
673
|
? { review_event_digest: input.review_event_digest, decision_evidence: input.decision_evidence,
|
|
@@ -536,11 +678,13 @@ async function phaseDecision({ repoRoot, env, planKey, phaseId, outcome, inputRe
|
|
|
536
678
|
}
|
|
537
679
|
|
|
538
680
|
async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload }) {
|
|
539
|
-
const { event, snapshot } = await appendTodoEvent({
|
|
681
|
+
const { event, snapshot, phases } = await appendTodoEvent({
|
|
540
682
|
repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), planKey,
|
|
541
683
|
event: { kind, phase_id: phaseId, actor: mutationActor(env), payload },
|
|
542
684
|
});
|
|
543
|
-
|
|
685
|
+
// snapshot.phasesはv1(phase無しplan)には存在しない。導出ビュー`phases`を見る
|
|
686
|
+
// (これは暗黙のterminal-audit Phaseにも常に埋まっている)。
|
|
687
|
+
const phase = phases.find(({ phase_id: current }) => current === phaseId);
|
|
544
688
|
if (phase === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'phase_not_active');
|
|
545
689
|
const result = {
|
|
546
690
|
schema: 'lattice.phase_mutation_result.v1', project_id: event.project_id,
|
|
@@ -556,23 +700,49 @@ async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload })
|
|
|
556
700
|
async function phaseStatus({ repoRoot, planKey }) {
|
|
557
701
|
const store = await readTodoStore({ repoRoot });
|
|
558
702
|
const [member] = selectMembers(store, planKey);
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
703
|
+
// ADR 0147以降、phase無しplan(v1/v2/v3)もreadTodoStoreが導出済みの暗黙terminal-audit
|
|
704
|
+
// Phaseをmember.phasesへ積んでいる(snapshot artifactの形式は変えない・v1にはphasesキーが
|
|
705
|
+
// 無いのでsnapshot.phasesは直接読まない)。ここでPHASE_UNAVAILABLEへ拒否せず、その暗黙Phase
|
|
706
|
+
// をそのまま返す——`implicit`で機械可読に「宣言されたPhaseではない」ことを示す。
|
|
707
|
+
const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
|
|
562
708
|
const result = {
|
|
563
709
|
schema: 'lattice.phase_status_result.v1', project_id: store.project_id,
|
|
564
710
|
plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
|
|
565
711
|
journal_head_digest: member.journal.events.at(-1).event_digest,
|
|
566
|
-
phases: member.
|
|
712
|
+
implicit, phases: member.phases, result_digest: '',
|
|
567
713
|
};
|
|
568
714
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
569
715
|
return result;
|
|
570
716
|
}
|
|
571
717
|
|
|
572
|
-
async function migrate({ repoRoot, inputRef }) {
|
|
718
|
+
async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
|
|
573
719
|
const extraction = await readMigrationInput(repoRoot, inputRef);
|
|
574
|
-
|
|
720
|
+
// dispatch_shapeのgateはappendTodoExtraction(store書込み)より前に判定する必要がある
|
|
721
|
+
// (拒否時にstoreへ何も書かないため、再考後の再実行がplan_key_already_existsで
|
|
722
|
+
// 詰まらない)。unresolved/空集合の2つの早期gateは、compileTodoExtraction内部の
|
|
723
|
+
// 同名gateをここでも先に通しておくことで、既存のエラー優先順位
|
|
724
|
+
// (unresolved・空集合を直列度より先に報告する)を変えない。
|
|
725
|
+
const unresolvedTaskIds = extraction.tasks
|
|
726
|
+
.filter(({ disposition }) => disposition === 'unknown_requires_evidence')
|
|
727
|
+
.map(({ task_id: taskId }) => taskId);
|
|
728
|
+
if (unresolvedTaskIds.length > 0) {
|
|
729
|
+
throw new TodoStoreError('MIGRATION_UNRESOLVED', 'unknown_requires_evidence', undefined, {
|
|
730
|
+
task_ids: unresolvedTaskIds,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
575
733
|
const registered = extraction.tasks.filter(({ disposition }) => disposition.startsWith('register_'));
|
|
734
|
+
if (registered.length === 0) throw new TodoStoreError('MIGRATION_EMPTY', 'no_registered_tasks');
|
|
735
|
+
|
|
736
|
+
const dispatchShape = computeTodoDispatchShapeForPlan({
|
|
737
|
+
projectId: extraction.project_id,
|
|
738
|
+
planKey: extraction.plan_key,
|
|
739
|
+
taskIds: registered.map(({ task_id: taskId }) => taskId),
|
|
740
|
+
hardDependencies: extraction.hard_dependencies,
|
|
741
|
+
joins: extraction.joins,
|
|
742
|
+
});
|
|
743
|
+
assertTodoDispatchShapeReviewed({ shape: dispatchShape, reviewed: serializationReviewed });
|
|
744
|
+
|
|
745
|
+
const imported = await appendTodoExtraction({ repoRoot, extraction });
|
|
576
746
|
const result = {
|
|
577
747
|
schema: 'lattice.todo_migrate_result.v1',
|
|
578
748
|
project_id: imported.plan.project_id,
|
|
@@ -586,6 +756,16 @@ async function migrate({ repoRoot, inputRef }) {
|
|
|
586
756
|
snapshot_ref: imported.descriptor.snapshot_ref,
|
|
587
757
|
topology_digest: imported.plan.topology_digest,
|
|
588
758
|
journal_head_digest: imported.events.at(-1).event_digest,
|
|
759
|
+
dispatch_shape: {
|
|
760
|
+
task_count: dispatchShape.task_count,
|
|
761
|
+
critical_path_length: dispatchShape.critical_path_length,
|
|
762
|
+
max_frontier_width: dispatchShape.max_frontier_width,
|
|
763
|
+
serialization_ratio: dispatchShape.serialization_ratio,
|
|
764
|
+
},
|
|
765
|
+
// ADR 0147裁定3: phase無しplanの作成は拒否せず、終端監査が要ることを結果へ明示するに
|
|
766
|
+
// 留める。extraction経由のmigrateは常にphase無しplan(todo_plan.v2)を作るが、将来の
|
|
767
|
+
// 拡張に備えisPhaselessTodoPlanSchemaで動的に判定する。
|
|
768
|
+
terminal_audit_required: isPhaselessTodoPlanSchema(imported.plan.schema),
|
|
589
769
|
result_digest: '',
|
|
590
770
|
};
|
|
591
771
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
@@ -608,6 +788,7 @@ async function reviseSet({ repoRoot, env, inputRef }) {
|
|
|
608
788
|
validate: validateTodoRevisionSet,
|
|
609
789
|
invalidCode: 'REVISION_SET_INVALID',
|
|
610
790
|
invalidReason: 'revision_set_schema_invalid',
|
|
791
|
+
explain: explainTodoRevisionSet,
|
|
611
792
|
});
|
|
612
793
|
return applyTodoRevisionSet({
|
|
613
794
|
repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), revisionSet,
|
|
@@ -619,6 +800,7 @@ async function revisePhase({ repoRoot, env, planKey, inputRef }) {
|
|
|
619
800
|
const revision = await readRevisionInput(repoRoot, inputRef, {
|
|
620
801
|
validate: validatePhaseTodoRevision, invalidCode: 'REVISION_INVALID',
|
|
621
802
|
invalidReason: 'phase_revision_schema_or_digest_invalid',
|
|
803
|
+
explain: explainPhaseTodoRevision,
|
|
622
804
|
});
|
|
623
805
|
if (revision.plan_key !== planKey) throw new TodoStoreError('REVISION_INVALID', 'requested_plan_mismatch');
|
|
624
806
|
return applyPhaseTodoRevision({ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
|
|
@@ -1819,6 +2001,20 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
1819
2001
|
throw new TypeError('runTodoCli optionsが不正');
|
|
1820
2002
|
}
|
|
1821
2003
|
|
|
2004
|
+
// `--schema --json`はstoreを読まない決定的な出力(`plan create --schema`と同じ規律)。
|
|
2005
|
+
// 通常dispatchより前に処理し、repoRoot解決やdashboard daemon起動を経由させない。
|
|
2006
|
+
if (argv.length === 3 && argv[1] === '--schema' && argv[2] === '--json'
|
|
2007
|
+
&& Object.hasOwn(TODO_SCHEMA_COMMANDS, argv[0])) {
|
|
2008
|
+
try {
|
|
2009
|
+
await runTodoSchemaCommand(argv[0], stdout);
|
|
2010
|
+
return 0;
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
return typedFailure(stderr, {
|
|
2013
|
+
code: 'INTERNAL_FAILURE', message: error?.constructor?.name ?? 'Error',
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
1822
2018
|
let action = null;
|
|
1823
2019
|
if ((argv.length === 1 && argv[0] === 'status')
|
|
1824
2020
|
|| (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
|
|
@@ -1913,9 +2109,11 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
1913
2109
|
action = (repoRoot) => serveGantt({
|
|
1914
2110
|
repoRoot, port: Number(argv[3]), stdout, env, scope: argv[5],
|
|
1915
2111
|
});
|
|
1916
|
-
} else if (argv.length === 3 && argv[0] === 'migrate' && argv[1] === '--input'
|
|
1917
|
-
&& isTodoRef(argv[2])) {
|
|
1918
|
-
action = (repoRoot) => migrate({
|
|
2112
|
+
} else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'migrate' && argv[1] === '--input'
|
|
2113
|
+
&& isTodoRef(argv[2]) && (argv.length === 3 || argv[3] === '--serialization-reviewed')) {
|
|
2114
|
+
action = (repoRoot) => migrate({
|
|
2115
|
+
repoRoot, inputRef: argv[2], serializationReviewed: argv.length === 4,
|
|
2116
|
+
});
|
|
1919
2117
|
} else if (argv.length === 5 && argv[0] === 'revise'
|
|
1920
2118
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1921
2119
|
&& argv[3] === '--input' && isTodoRef(argv[4])) {
|
|
@@ -1950,14 +2148,18 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
1950
2148
|
&& (argv.length === 8 || (argv[8] === '--override-reason' && argv[9].length > 0))) {
|
|
1951
2149
|
action = (repoRoot) => phaseMutation({ repoRoot, env, planKey: argv[3], phaseId: argv[5],
|
|
1952
2150
|
kind: 'phase_reopen', payload: { reason: argv[7], override_reason: argv[9] ?? null } });
|
|
1953
|
-
} else if ((argv.length === 5 || argv.length === 6 || argv.length === 7
|
|
2151
|
+
} else if ((argv.length === 5 || argv.length === 6 || argv.length === 7 || argv.length === 8)
|
|
2152
|
+
&& argv[0] === 'start'
|
|
1954
2153
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1955
2154
|
&& argv[3] === '--task' && isTodoIdentifier(argv[4])
|
|
1956
2155
|
&& (argv.length === 5 || (argv.length === 6 && argv[5] === '--parallel-frontier')
|
|
1957
|
-
|| (argv.length === 7
|
|
1958
|
-
|
|
2156
|
+
|| ((argv.length === 7 || argv.length === 8)
|
|
2157
|
+
&& argv[5] === '--override-reason' && argv[6].length > 0
|
|
2158
|
+
&& (argv.length === 7 || argv[7] === '--serial-confirmed')))) {
|
|
2159
|
+
const overrideReason = argv.length >= 7 ? argv[6] : null;
|
|
1959
2160
|
action = (repoRoot) => startTask({ repoRoot, env, planKey: argv[2], taskId: argv[4],
|
|
1960
|
-
overrideReason, parallelFrontier: argv.length === 6
|
|
2161
|
+
overrideReason, parallelFrontier: argv.length === 6,
|
|
2162
|
+
serialConfirmed: argv.length === 8 });
|
|
1961
2163
|
} else if (argv.length === 7 && argv[0] === 'block'
|
|
1962
2164
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1963
2165
|
&& argv[3] === '--task' && isTodoIdentifier(argv[4])
|
package/src/todo-contracts.mjs
CHANGED
|
@@ -375,8 +375,8 @@ function validStateMigration(value) {
|
|
|
375
375
|
'from_task_id', 'to_task_id', 'state_policy', 'state',
|
|
376
376
|
]) && isTodoIdentifier(entry.from_task_id)
|
|
377
377
|
&& (entry.to_task_id === 'removed' || isTodoIdentifier(entry.to_task_id))
|
|
378
|
-
&& ['carry', 'carry_reconciled_metadata', 'reset_pending', 'removed'].includes(entry.state_policy)
|
|
379
|
-
&& ((['carry', 'carry_reconciled_metadata'].includes(entry.state_policy)
|
|
378
|
+
&& ['carry', 'carry_reconciled_metadata', 'reset_pending', 'removed', 'acquire_phase'].includes(entry.state_policy)
|
|
379
|
+
&& ((['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(entry.state_policy)
|
|
380
380
|
&& entry.to_task_id !== 'removed' && validCarriedState(entry.state))
|
|
381
381
|
|| (entry.state_policy === 'reset_pending' && entry.to_task_id !== 'removed' && entry.state === null)
|
|
382
382
|
|| (entry.state_policy === 'removed' && entry.to_task_id === 'removed' && entry.state === null)))
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { TodoStoreError } from './todo-store.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 依存グラフの「直列度」を判定する既定閾値。
|
|
5
|
+
*
|
|
6
|
+
* `serialization_ratio = critical_path_length / task_count` がこれを超えると、
|
|
7
|
+
* 依存連鎖が全task数の半分を超えて連なっている=大半のtaskが並列候補ではなく
|
|
8
|
+
* 一本の鎖に押し込まれていることを意味する。`todo start`側が既に持つ
|
|
9
|
+
* `all_ready_parallel_by_default`方針をplan作成時点まで前倒しする出発点として
|
|
10
|
+
* 0.5を採る。実測(parent-child-repair: task 26, critical path約20,
|
|
11
|
+
* ratio≈0.77)を確実に超える一方、緩やかな分岐を持つ通常のplanまでは拾わない
|
|
12
|
+
* 水準として選んだ。
|
|
13
|
+
*/
|
|
14
|
+
export const DISPATCH_SHAPE_SERIALIZATION_THRESHOLD = 0.5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 閾値判定の対象にする最小task数。
|
|
18
|
+
* 3〜5 task程度の一直線planを毎回突き返しても再考の余地がなく
|
|
19
|
+
* (並列化する意味のある規模でない)機構がノイズになるだけなので、
|
|
20
|
+
* 6 task未満は常に素通りさせる。
|
|
21
|
+
*/
|
|
22
|
+
export const DISPATCH_SHAPE_MIN_TASK_COUNT_FOR_GATE = 6;
|
|
23
|
+
|
|
24
|
+
export const DISPATCH_SHAPE_SERIALIZATION_REVIEWED_FLAG = '--serialization-reviewed';
|
|
25
|
+
|
|
26
|
+
function compareText(left, right) {
|
|
27
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* task_id集合と(既にこの集合の内側だけへ絞り込まれた)依存辺から、
|
|
32
|
+
* dispatch形状を計算する。
|
|
33
|
+
*
|
|
34
|
+
* 呼び出し側の責務: `edges`は「このtask集合の内側だけ」の task_id 対で渡すこと
|
|
35
|
+
* (cross-plan/既存planへの依存や、joinの`after→before`展開は呼び出し側で
|
|
36
|
+
* 行い、範囲外の参照はここへ渡さない)。範囲外の参照が混入した場合は
|
|
37
|
+
* 呼び出し側の実装誤りとして typed error で止める。
|
|
38
|
+
*
|
|
39
|
+
* 循環検出は専用のロジックを別途書き足すのではなく、最長path計算
|
|
40
|
+
* (Kahn法によるtopological order)が全nodeを消化できないことの自然な帰結
|
|
41
|
+
* として行う。この関数はplan作成/migrateがstoreへ書き込む前(拒否時に
|
|
42
|
+
* 何も書かない設計)に呼ばれるため、store側の`validateMergedGraph`による
|
|
43
|
+
* cycle拒否より前に走る——結果として、循環を含む入力はここで先に
|
|
44
|
+
* `DISPATCH_SHAPE_INVALID`として止まる(従来store書込み時に出ていた
|
|
45
|
+
* `STORE_INCONSISTENT`/`merged_cycle`より手前で検出されるようになるという、
|
|
46
|
+
* 観測可能だが意図した違いがある)。
|
|
47
|
+
*/
|
|
48
|
+
export function computeTodoDispatchShape({ taskIds, edges }) {
|
|
49
|
+
if (!Array.isArray(taskIds) || taskIds.length === 0
|
|
50
|
+
|| !taskIds.every((id) => typeof id === 'string' && id.length > 0)) {
|
|
51
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_task_ids_invalid');
|
|
52
|
+
}
|
|
53
|
+
const idSet = new Set(taskIds);
|
|
54
|
+
if (idSet.size !== taskIds.length) {
|
|
55
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_task_ids_duplicate');
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(edges) || edges.some((edge) => edge === null || typeof edge !== 'object'
|
|
58
|
+
|| typeof edge.from !== 'string' || typeof edge.to !== 'string'
|
|
59
|
+
|| !idSet.has(edge.from) || !idSet.has(edge.to))) {
|
|
60
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_edge_out_of_scope');
|
|
61
|
+
}
|
|
62
|
+
if (edges.some((edge) => edge.from === edge.to)) {
|
|
63
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_self_edge');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const successors = new Map([...idSet].map((id) => [id, []]));
|
|
67
|
+
const indegree = new Map([...idSet].map((id) => [id, 0]));
|
|
68
|
+
const edgeKeys = new Set();
|
|
69
|
+
for (const { from, to } of edges) {
|
|
70
|
+
const key = `${from}\0${to}`;
|
|
71
|
+
if (edgeKeys.has(key)) continue; // hard_dependenciesとjoin由来で同じ辺が重複しても一度だけ数える
|
|
72
|
+
edgeKeys.add(key);
|
|
73
|
+
successors.get(from).push(to);
|
|
74
|
+
indegree.set(to, indegree.get(to) + 1);
|
|
75
|
+
}
|
|
76
|
+
for (const list of successors.values()) list.sort(compareText);
|
|
77
|
+
|
|
78
|
+
// Kahn法によるtopological order。dist[node] = nodeで終わる最長path長(辺数)は、
|
|
79
|
+
// 「nodeを、その全先行taskが処理済みになった時点で処理する」という不変条件から
|
|
80
|
+
// 標準的なlongest-path-in-DAG漸化式(dist[v] = max(dist[v], dist[u]+1))として導かれる。
|
|
81
|
+
const dist = new Map([...idSet].map((id) => [id, 0]));
|
|
82
|
+
const predecessor = new Map();
|
|
83
|
+
const queue = [...idSet].filter((id) => indegree.get(id) === 0).sort(compareText);
|
|
84
|
+
const order = [];
|
|
85
|
+
while (queue.length > 0) {
|
|
86
|
+
queue.sort(compareText);
|
|
87
|
+
const node = queue.shift();
|
|
88
|
+
order.push(node);
|
|
89
|
+
for (const successor of successors.get(node)) {
|
|
90
|
+
if (dist.get(node) + 1 > dist.get(successor)) {
|
|
91
|
+
dist.set(successor, dist.get(node) + 1);
|
|
92
|
+
predecessor.set(successor, node);
|
|
93
|
+
}
|
|
94
|
+
indegree.set(successor, indegree.get(successor) - 1);
|
|
95
|
+
if (indegree.get(successor) === 0) queue.push(successor);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (order.length !== idSet.size) {
|
|
99
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_dependency_cycle');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const taskCount = idSet.size;
|
|
103
|
+
const maxDist = Math.max(...order.map((id) => dist.get(id)));
|
|
104
|
+
const criticalPathLength = maxDist + 1;
|
|
105
|
+
const widthByDist = new Map();
|
|
106
|
+
for (const id of idSet) widthByDist.set(dist.get(id), (widthByDist.get(dist.get(id)) ?? 0) + 1);
|
|
107
|
+
const maxFrontierWidth = Math.max(...widthByDist.values());
|
|
108
|
+
|
|
109
|
+
// critical path(人向けヒント)の復元: distが最大のnodeから、決定的に選んだpredecessorを
|
|
110
|
+
// 遡って根まで辿る。表示専用でありdigest対象ではないため、決定性は再現性のためだけに要る。
|
|
111
|
+
const deepest = [...idSet].filter((id) => dist.get(id) === maxDist).sort(compareText)[0];
|
|
112
|
+
const criticalPathTaskIds = [];
|
|
113
|
+
for (let cursor = deepest; cursor !== undefined; cursor = predecessor.get(cursor)) {
|
|
114
|
+
criticalPathTaskIds.push(cursor);
|
|
115
|
+
}
|
|
116
|
+
criticalPathTaskIds.reverse();
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
task_count: taskCount,
|
|
120
|
+
critical_path_length: criticalPathLength,
|
|
121
|
+
max_frontier_width: maxFrontierWidth,
|
|
122
|
+
// canonical digest(todoSelfDigest→digestTodoArtifact)はsafe integer以外の数値を
|
|
123
|
+
// TypeErrorで拒否する(todo-contracts.mjsのcanonicalPart)。dispatch_shapeは
|
|
124
|
+
// plan create/migrateの結果にそのまま埋め込まれ digest対象になるため、比率は
|
|
125
|
+
// 固定小数のstringで持つ(判定側はNumber()で復元する)。
|
|
126
|
+
serialization_ratio: (criticalPathLength / taskCount).toFixed(4),
|
|
127
|
+
critical_path_task_ids: criticalPathTaskIds,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** dispatch_shapeが、再考なしで直列のまま通してよい規模・度合いかを判定する。 */
|
|
132
|
+
export function isTodoDispatchShapeSerializationExcessive(shape) {
|
|
133
|
+
return shape.task_count >= DISPATCH_SHAPE_MIN_TASK_COUNT_FOR_GATE
|
|
134
|
+
&& Number(shape.serialization_ratio) > DISPATCH_SHAPE_SERIALIZATION_THRESHOLD;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* dispatch_shapeが直列に寄りすぎているplanを、再考なしでは通さない。
|
|
139
|
+
*
|
|
140
|
+
* `todo start`の`PARALLEL_DISPATCH_RECONSIDER`と同じ二段階(一度突き返し、
|
|
141
|
+
* 再考を経たflagが無ければ通さない)をplan作成時点へ前倒しする。ここで
|
|
142
|
+
* 拒否する場合、呼び出し側はまだstoreへ何も書いていないこと(初期化/追加を
|
|
143
|
+
* この呼び出しより後で行うこと)。
|
|
144
|
+
*/
|
|
145
|
+
export function assertTodoDispatchShapeReviewed({ shape, reviewed }) {
|
|
146
|
+
if (!isTodoDispatchShapeSerializationExcessive(shape) || reviewed) return;
|
|
147
|
+
throw new TodoStoreError('PARALLEL_DISPATCH_RECONSIDER', 'plan_shape_too_serial', undefined, {
|
|
148
|
+
task_count: shape.task_count,
|
|
149
|
+
critical_path_length: shape.critical_path_length,
|
|
150
|
+
max_frontier_width: shape.max_frontier_width,
|
|
151
|
+
serialization_ratio: shape.serialization_ratio,
|
|
152
|
+
critical_path_task_ids: shape.critical_path_task_ids,
|
|
153
|
+
default_policy: 'all_ready_parallel_by_default',
|
|
154
|
+
serialization_reviewed_flag: DISPATCH_SHAPE_SERIALIZATION_REVIEWED_FLAG,
|
|
155
|
+
guidance: `並列を検討しなさい。task ${shape.task_count}件のうちcritical pathが`
|
|
156
|
+
+ `${shape.critical_path_length}段(serialization_ratio ${shape.serialization_ratio})で、`
|
|
157
|
+
+ '大半のtaskが並列候補ではなく一本の依存鎖に押し込まれている。'
|
|
158
|
+
+ 'critical_path_task_idsに沿った依存のうち、実際には干渉しない組を'
|
|
159
|
+
+ 'hard_dependencies/joinsから外せないか見直す'
|
|
160
|
+
+ '(実行主体が足りないなら増やす。増やせないことは直列化の理由にならない)。'
|
|
161
|
+
+ `検討した上でなお直列でよいなら ${DISPATCH_SHAPE_SERIALIZATION_REVIEWED_FLAG} を付けて再実行する。`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* plan/extractionの`tasks`・`hard_dependencies`・`joins`(nodeRef形式)から、
|
|
167
|
+
* 「このproject_id/plan_keyの内側だけ」に絞ったdispatch形状を計算する高レベル入口。
|
|
168
|
+
*
|
|
169
|
+
* cross-plan参照やdanglingな参照はここで検証しない(既存のstore書込み経路が
|
|
170
|
+
* 別途検証する)。単に形状計算の対象から外すだけであり、それらの妥当性判断は
|
|
171
|
+
* 呼び出し側の後続処理(実際のstore書込み)に委ねる。
|
|
172
|
+
*/
|
|
173
|
+
export function computeTodoDispatchShapeForPlan({
|
|
174
|
+
projectId, planKey, taskIds, hardDependencies, joins,
|
|
175
|
+
}) {
|
|
176
|
+
const idSet = new Set(taskIds);
|
|
177
|
+
const isLocal = (ref) => ref?.project_id === projectId && ref?.plan_key === planKey
|
|
178
|
+
&& idSet.has(ref?.task_id);
|
|
179
|
+
const edges = [];
|
|
180
|
+
for (const edge of hardDependencies ?? []) {
|
|
181
|
+
if (isLocal(edge.from) && isLocal(edge.to)) edges.push({ from: edge.from.task_id, to: edge.to.task_id });
|
|
182
|
+
}
|
|
183
|
+
for (const join of joins ?? []) {
|
|
184
|
+
if (!isLocal(join.before)) continue;
|
|
185
|
+
for (const after of join.after) {
|
|
186
|
+
if (isLocal(after)) edges.push({ from: after.task_id, to: join.before.task_id });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return computeTodoDispatchShape({ taskIds, edges });
|
|
190
|
+
}
|
|
@@ -117,7 +117,8 @@ export function renderPhaseProgress(readModel) {
|
|
|
117
117
|
const settledRows = [];
|
|
118
118
|
for (const member of readModel.members) {
|
|
119
119
|
if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) continue;
|
|
120
|
-
|
|
120
|
+
// snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)。
|
|
121
|
+
const phases = new Map(member.phases.map((phase) => [phase.phase_id, phase]));
|
|
121
122
|
for (const phase of member.plan.phases) {
|
|
122
123
|
const tasks = member.plan.tasks.filter((task) => task.phase_id === phase.phase_id);
|
|
123
124
|
const states = new Map(member.tasks.map((task) => [task.task_id, task.status]));
|