@quolu/lattice 0.35.0 → 0.36.1
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 +28 -1
- package/README.md +8 -0
- package/bin/lattice.mjs +9 -1
- package/docs/bridge-setup.md +13 -0
- package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +312 -0
- package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -0
- package/docs/schemas/lattice.todo_revision.v2.schema.json +220 -0
- package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -0
- package/package.json +5 -1
- package/src/cli-help.mjs +12 -7
- package/src/project-cli.mjs +103 -1
- package/src/todo-cli.mjs +103 -10
- package/src/todo-contracts.mjs +2 -2
- package/src/todo-gantt-html-shared.mjs +2 -1
- package/src/todo-gantt-layout.mjs +13 -3
- 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,6 +54,7 @@ import {
|
|
|
52
54
|
} from './todo-store.mjs';
|
|
53
55
|
import {
|
|
54
56
|
appendTodoExtraction,
|
|
57
|
+
explainTodoExtraction,
|
|
55
58
|
validateTodoExtraction,
|
|
56
59
|
} from './todo-migration.mjs';
|
|
57
60
|
import {
|
|
@@ -97,6 +100,7 @@ import {
|
|
|
97
100
|
validateWitnessDraft,
|
|
98
101
|
} from './witness-scaffold.mjs';
|
|
99
102
|
import {
|
|
103
|
+
explainPhaseTodoRevision, explainTodoRevision, explainTodoRevisionSet,
|
|
100
104
|
parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
|
|
101
105
|
validateTodoRevision, validateTodoRevisionSet,
|
|
102
106
|
} from './todo-revision.mjs';
|
|
@@ -113,6 +117,31 @@ const ACTOR_ENV_KEYS = Object.freeze([
|
|
|
113
117
|
'LATTICE_TODO_ACTOR_AGENT',
|
|
114
118
|
]);
|
|
115
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
|
+
|
|
116
145
|
function usageFailure(stderr, argv) {
|
|
117
146
|
const received = argv.length === 0 ? '(none)' : argv.join(' ').replace(/[\r\n]/gu, ' ');
|
|
118
147
|
stderr.write(`lattice todo: unsupported command or arguments: ${received}\n`);
|
|
@@ -223,7 +252,11 @@ async function readMigrationInput(repoRoot, inputRef) {
|
|
|
223
252
|
throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
|
|
224
253
|
}
|
|
225
254
|
if (!validateTodoExtraction(extraction)) {
|
|
226
|
-
|
|
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 });
|
|
227
260
|
}
|
|
228
261
|
return extraction;
|
|
229
262
|
}
|
|
@@ -232,6 +265,7 @@ async function readRevisionInput(repoRoot, inputRef, {
|
|
|
232
265
|
validate = validateTodoRevision,
|
|
233
266
|
invalidCode = 'REVISION_INVALID',
|
|
234
267
|
invalidReason = 'revision_schema_or_digest_invalid',
|
|
268
|
+
explain = explainTodoRevision,
|
|
235
269
|
} = {}) {
|
|
236
270
|
const canonicalRoot = await realpath(repoRoot);
|
|
237
271
|
const absolute = path.resolve(canonicalRoot, inputRef);
|
|
@@ -268,7 +302,16 @@ async function readRevisionInput(repoRoot, inputRef, {
|
|
|
268
302
|
try { revision = JSON.parse(text.slice(0, -1)); } catch {
|
|
269
303
|
throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
|
|
270
304
|
}
|
|
271
|
-
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
|
+
}
|
|
272
315
|
if (text !== `${canonicalizeTodoArtifact(revision)}\n`) {
|
|
273
316
|
throw new TodoStoreError(invalidCode, 'non_canonical_revision_bytes');
|
|
274
317
|
}
|
|
@@ -373,6 +416,25 @@ function mutationActor(env) {
|
|
|
373
416
|
return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
|
|
374
417
|
}
|
|
375
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
|
+
|
|
376
438
|
async function mutate({
|
|
377
439
|
repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
|
|
378
440
|
}) {
|
|
@@ -383,13 +445,18 @@ async function mutate({
|
|
|
383
445
|
if (kind === 'done' && payload === 'evidence_promotion') {
|
|
384
446
|
eventPayload = { done_mode: 'evidence_promotion', imported: true, evidence };
|
|
385
447
|
}
|
|
386
|
-
const { event, snapshot } = await appendTodoEvent({
|
|
448
|
+
const { event, snapshot, plan, phases } = await appendTodoEvent({
|
|
387
449
|
repoRoot,
|
|
388
450
|
writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
|
|
389
451
|
planKey,
|
|
390
452
|
event: { kind, task_id: taskId, actor, payload: eventPayload },
|
|
391
453
|
});
|
|
392
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);
|
|
393
460
|
const result = {
|
|
394
461
|
schema: 'lattice.todo_mutation_result.v2',
|
|
395
462
|
project_id: event.project_id,
|
|
@@ -402,7 +469,7 @@ async function mutate({
|
|
|
402
469
|
journal_head_digest: event.event_digest,
|
|
403
470
|
snapshot_digest: snapshot.snapshot_digest,
|
|
404
471
|
status: task.status,
|
|
405
|
-
advisory,
|
|
472
|
+
advisory: resolvedAdvisory,
|
|
406
473
|
result_digest: '',
|
|
407
474
|
};
|
|
408
475
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
@@ -599,6 +666,8 @@ async function phaseDecision({ repoRoot, env, planKey, phaseId, outcome, inputRe
|
|
|
599
666
|
const input = await readRevisionInput(repoRoot, inputRef, {
|
|
600
667
|
validate: (value) => validatePhaseDecisionInput(value, outcome),
|
|
601
668
|
invalidCode: 'PHASE_DECISION_INVALID', invalidReason: 'phase_decision_schema_or_digest_invalid',
|
|
669
|
+
// phase decision入力はrevision契約と別形状。既定のrevision explainを誤って当てない。
|
|
670
|
+
explain: null,
|
|
602
671
|
});
|
|
603
672
|
const payload = outcome === 'accept'
|
|
604
673
|
? { review_event_digest: input.review_event_digest, decision_evidence: input.decision_evidence,
|
|
@@ -609,11 +678,13 @@ async function phaseDecision({ repoRoot, env, planKey, phaseId, outcome, inputRe
|
|
|
609
678
|
}
|
|
610
679
|
|
|
611
680
|
async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload }) {
|
|
612
|
-
const { event, snapshot } = await appendTodoEvent({
|
|
681
|
+
const { event, snapshot, phases } = await appendTodoEvent({
|
|
613
682
|
repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), planKey,
|
|
614
683
|
event: { kind, phase_id: phaseId, actor: mutationActor(env), payload },
|
|
615
684
|
});
|
|
616
|
-
|
|
685
|
+
// snapshot.phasesはv1(phase無しplan)には存在しない。導出ビュー`phases`を見る
|
|
686
|
+
// (これは暗黙のterminal-audit Phaseにも常に埋まっている)。
|
|
687
|
+
const phase = phases.find(({ phase_id: current }) => current === phaseId);
|
|
617
688
|
if (phase === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'phase_not_active');
|
|
618
689
|
const result = {
|
|
619
690
|
schema: 'lattice.phase_mutation_result.v1', project_id: event.project_id,
|
|
@@ -629,14 +700,16 @@ async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload })
|
|
|
629
700
|
async function phaseStatus({ repoRoot, planKey }) {
|
|
630
701
|
const store = await readTodoStore({ repoRoot });
|
|
631
702
|
const [member] = selectMembers(store, planKey);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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);
|
|
635
708
|
const result = {
|
|
636
709
|
schema: 'lattice.phase_status_result.v1', project_id: store.project_id,
|
|
637
710
|
plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
|
|
638
711
|
journal_head_digest: member.journal.events.at(-1).event_digest,
|
|
639
|
-
phases: member.
|
|
712
|
+
implicit, phases: member.phases, result_digest: '',
|
|
640
713
|
};
|
|
641
714
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
642
715
|
return result;
|
|
@@ -689,6 +762,10 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
|
|
|
689
762
|
max_frontier_width: dispatchShape.max_frontier_width,
|
|
690
763
|
serialization_ratio: dispatchShape.serialization_ratio,
|
|
691
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),
|
|
692
769
|
result_digest: '',
|
|
693
770
|
};
|
|
694
771
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
@@ -711,6 +788,7 @@ async function reviseSet({ repoRoot, env, inputRef }) {
|
|
|
711
788
|
validate: validateTodoRevisionSet,
|
|
712
789
|
invalidCode: 'REVISION_SET_INVALID',
|
|
713
790
|
invalidReason: 'revision_set_schema_invalid',
|
|
791
|
+
explain: explainTodoRevisionSet,
|
|
714
792
|
});
|
|
715
793
|
return applyTodoRevisionSet({
|
|
716
794
|
repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), revisionSet,
|
|
@@ -722,6 +800,7 @@ async function revisePhase({ repoRoot, env, planKey, inputRef }) {
|
|
|
722
800
|
const revision = await readRevisionInput(repoRoot, inputRef, {
|
|
723
801
|
validate: validatePhaseTodoRevision, invalidCode: 'REVISION_INVALID',
|
|
724
802
|
invalidReason: 'phase_revision_schema_or_digest_invalid',
|
|
803
|
+
explain: explainPhaseTodoRevision,
|
|
725
804
|
});
|
|
726
805
|
if (revision.plan_key !== planKey) throw new TodoStoreError('REVISION_INVALID', 'requested_plan_mismatch');
|
|
727
806
|
return applyPhaseTodoRevision({ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
|
|
@@ -1922,6 +2001,20 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
1922
2001
|
throw new TypeError('runTodoCli optionsが不正');
|
|
1923
2002
|
}
|
|
1924
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
|
+
|
|
1925
2018
|
let action = null;
|
|
1926
2019
|
if ((argv.length === 1 && argv[0] === 'status')
|
|
1927
2020
|
|| (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
|
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)))
|
|
@@ -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]));
|
|
@@ -153,7 +153,9 @@ function normalizeInput(readModel, chainProjection) {
|
|
|
153
153
|
}
|
|
154
154
|
const { plan } = member;
|
|
155
155
|
const statusByTask = new Map(member.tasks.map((task) => [task.task_id, task]));
|
|
156
|
-
|
|
156
|
+
// snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
|
|
157
|
+
// (readTodoStoreが常にmember.phasesとして埋める。ADR 0147)。
|
|
158
|
+
const statusByPhase = new Map((member.phases ?? [])
|
|
157
159
|
.map((phase) => [phase.phase_id, phase.status]));
|
|
158
160
|
for (const task of plan.tasks) {
|
|
159
161
|
if (!plain(task) || typeof task.task_id !== 'string' || typeof task.lane !== 'string') {
|
|
@@ -174,7 +176,14 @@ function normalizeInput(readModel, chainProjection) {
|
|
|
174
176
|
status: state.status,
|
|
175
177
|
plan_schema: plan.schema ?? null,
|
|
176
178
|
phase_id: task.phase_id ?? null,
|
|
177
|
-
|
|
179
|
+
// ADR 0147: phase無しplan(task.phase_idが無い世代)も、終端に暗黙のterminal-audit
|
|
180
|
+
// Phaseを1つ持つ(todo-store.mjsのphasesOf/TERMINAL_AUDIT_PHASE_ID)。ここで素通しせず
|
|
181
|
+
// nullのままにすると、gantt scope側(todo-gantt-scope.mjs)が「監査未了のplanを畳まない」
|
|
182
|
+
// 判定に使える材料を一切受け取れない。'terminal-audit'はTERMINAL_AUDIT_PHASE_IDと同じ
|
|
183
|
+
// 予約IDで、phase_readyの判定(v4だけを見る既存分岐)には影響しない。
|
|
184
|
+
phase_status: task.phase_id === undefined
|
|
185
|
+
? statusByPhase.get('terminal-audit') ?? null
|
|
186
|
+
: statusByPhase.get(task.phase_id) ?? null,
|
|
178
187
|
phase_ready: plan.schema !== 'lattice.todo_plan.v4'
|
|
179
188
|
|| statusByPhase.get(task.phase_id) === 'active',
|
|
180
189
|
});
|
|
@@ -236,7 +245,8 @@ function readyTaskKeys(readModel, nodes, nodesByKey, incoming) {
|
|
|
236
245
|
const phaseStatuses = new Map();
|
|
237
246
|
const phaseAcceptIncoming = new Map(nodes.map(({ key }) => [key, new Set()]));
|
|
238
247
|
for (const member of readModel.members) {
|
|
239
|
-
|
|
248
|
+
// snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)。
|
|
249
|
+
for (const phase of member.phases ?? []) {
|
|
240
250
|
phaseStatuses.set(JSON.stringify([
|
|
241
251
|
member.plan.project_id, member.plan.plan_key, phase.phase_id,
|
|
242
252
|
]), phase.status);
|
package/src/todo-gantt-scope.mjs
CHANGED
|
@@ -38,10 +38,35 @@ function compareText(left, right) {
|
|
|
38
38
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* ADR 0147: phase無しplan(v1/v2/v3)は終端の暗黙Phase(terminal-audit)がacceptedになるまで
|
|
43
|
+
* 「閉じた」ことにならない。全taskがdoneでも監査未了なら、そのplanのToDoは生きた作業と同じ
|
|
44
|
+
* 扱い(distance 0)にして畳ませない——畳んでしまうと監査待ちであることが図から消え、
|
|
45
|
+
* ADR 0147が塞ごうとした「一度も重監査を通らず完走した」事故と外形が同じになる。
|
|
46
|
+
*
|
|
47
|
+
* v4/v5(phaseを宣言したplan)はこの判定の対象外にする——既存のPhase gateが重監査を担って
|
|
48
|
+
* おり、ここで同じ規律を足すとPhase単位の既存fold挙動を変えてしまう(非目標)。
|
|
49
|
+
* `phase_status`はレイアウト層(todo-gantt-layout.mjs)がplanの世代を問わず埋める。
|
|
50
|
+
* phase無しplanのtaskにはphase_idフィールド自体が無いため、そこでは暗黙Phaseの状態を
|
|
51
|
+
* 埋める。フィールドが無い/nullの入力(既存test・素のnode)は従来どおり対象外(false)になる。
|
|
52
|
+
*
|
|
53
|
+
* 対象は`gate_ready`(全task doneで監査待ち)・`reviewing`(監査中)・`rejected`
|
|
54
|
+
* (監査が通らず要フォロー)の3状態だけに絞る。`active`(一部taskがまだpending)は、
|
|
55
|
+
* 他のtaskが図に残っている限りplanが未完了だと分かるので対象にしない——ここまで
|
|
56
|
+
* 広げると、完走していない枝の通常foldまで止めてしまい既存挙動を変える。
|
|
57
|
+
*/
|
|
58
|
+
const AUDIT_PENDING_PHASE_STATUSES = new Set(['gate_ready', 'reviewing', 'rejected']);
|
|
59
|
+
function auditPending(node) {
|
|
60
|
+
if (!AUDIT_PENDING_PHASE_STATUSES.has(node.phase_status ?? null)) return false;
|
|
61
|
+
const schema = node.plan_schema ?? null;
|
|
62
|
+
return schema !== 'lattice.todo_plan.v4' && schema !== 'lattice.todo_plan.v5';
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
/**
|
|
42
66
|
* Forward distance from each node to the nearest live (non-done) node, over the
|
|
43
67
|
* dependency DAG. A live node is at distance 0; a node with no live descendant
|
|
44
|
-
* is at Infinity.
|
|
68
|
+
* is at Infinity. A done node whose plan's terminal audit (ADR 0147) has not
|
|
69
|
+
* been accepted is also pinned at distance 0 — it must not fold away silently.
|
|
45
70
|
*
|
|
46
71
|
* Edges always increase the wave (`assignWaves` is a longest-path layering), so
|
|
47
72
|
* visiting nodes in descending wave order guarantees every successor is settled
|
|
@@ -54,7 +79,7 @@ function distanceToLive(nodes, edges, wave) {
|
|
|
54
79
|
const ordered = [...nodes].sort((left, right) => wave.get(right.key) - wave.get(left.key)
|
|
55
80
|
|| compareText(right.key, left.key));
|
|
56
81
|
for (const node of ordered) {
|
|
57
|
-
if (node.status !== 'done') {
|
|
82
|
+
if (node.status !== 'done' || auditPending(node)) {
|
|
58
83
|
distance.set(node.key, 0);
|
|
59
84
|
continue;
|
|
60
85
|
}
|
package/src/todo-migration.mjs
CHANGED
|
@@ -126,6 +126,114 @@ function validateJoins(value) {
|
|
|
126
126
|
&& sortedStrictly(value, (join) => join.id);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
const reject = (reason, path = '') => ({ valid: false, reason, path });
|
|
130
|
+
|
|
131
|
+
/** value自体がexactRecordの対象になれる素朴なobjectかどうか(配列・nullを除く)。 */
|
|
132
|
+
function plainObject(value) {
|
|
133
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 期待keyとの過不足を1件ずつ言い当てる。missing/unexpectedのどちらが先に見つかっても
|
|
138
|
+
* そこで止め、複数の欠落を一度に説明しない——`exactRecord`のbooleanと違い、
|
|
139
|
+
* 最初に見つかった違反fieldをpathへ刻む。
|
|
140
|
+
*/
|
|
141
|
+
function explainKeys(value, requiredKeys, at) {
|
|
142
|
+
if (!plainObject(value) || Object.getPrototypeOf(value) !== Object.prototype) {
|
|
143
|
+
return reject('not_an_object', at);
|
|
144
|
+
}
|
|
145
|
+
const actualKeys = new Set(Object.keys(value));
|
|
146
|
+
for (const key of requiredKeys) {
|
|
147
|
+
if (!actualKeys.has(key)) return reject('missing_required_key', `${at}/${key}`);
|
|
148
|
+
}
|
|
149
|
+
const requiredSet = new Set(requiredKeys);
|
|
150
|
+
for (const key of actualKeys) {
|
|
151
|
+
if (!requiredSet.has(key)) return reject('unexpected_key', `${at}/${key}`);
|
|
152
|
+
}
|
|
153
|
+
return { valid: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function explainSortedStrictly(values, key, at) {
|
|
157
|
+
for (let index = 1; index < values.length; index += 1) {
|
|
158
|
+
if (!(key(values[index - 1]) < key(values[index]))) {
|
|
159
|
+
return reject('unsorted_or_duplicate_collection', `${at}/${index}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { valid: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `lattice.todo_extraction.v1/v2`を、既存の`validateTodoExtraction`の可否は変えずに
|
|
167
|
+
* 診断する(ADR 0130の案内規律をmigration入口へ拡張)。
|
|
168
|
+
*
|
|
169
|
+
* 深いgraph整合(親task解決・edge/join local参照解決)はここでは個別に言い当てず、
|
|
170
|
+
* 単一のreasonへ丸める——既知の実運用の詰まりどころ(必須key欠落・task/edge/joinの
|
|
171
|
+
* ソート違反・digest不一致)を優先して解消する。それでも掴めない違反は
|
|
172
|
+
* `diagnosis_incomplete`として正直に返す。
|
|
173
|
+
*/
|
|
174
|
+
export function explainTodoExtraction(value) {
|
|
175
|
+
try {
|
|
176
|
+
if (!plainObject(value)) return reject('not_an_object', '');
|
|
177
|
+
const schema = value.schema;
|
|
178
|
+
if (![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2].includes(schema)) {
|
|
179
|
+
return reject('schema_mismatch', '/schema');
|
|
180
|
+
}
|
|
181
|
+
const v2 = schema === TODO_EXTRACTION_SCHEMA_V2;
|
|
182
|
+
const topKeys = [
|
|
183
|
+
'schema', 'project_id', 'plan_key', 'plan_version', 'actor', 'recorded_at',
|
|
184
|
+
'tasks', 'hard_dependencies', 'joins', 'extraction_digest',
|
|
185
|
+
];
|
|
186
|
+
const topKeyCheck = explainKeys(value, topKeys, '');
|
|
187
|
+
if (!topKeyCheck.valid) return topKeyCheck;
|
|
188
|
+
if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
|
|
189
|
+
if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
|
|
190
|
+
if (!isTodoIdentifier(value.plan_version)) return reject('invalid_identifier', '/plan_version');
|
|
191
|
+
if (!actor(value.actor)) return reject('invalid_actor', '/actor');
|
|
192
|
+
if (!isStrictTodoTimestamp(value.recorded_at)) return reject('invalid_timestamp', '/recorded_at');
|
|
193
|
+
if (!Array.isArray(value.tasks) || value.tasks.length === 0
|
|
194
|
+
|| value.tasks.length > TODO_LIMITS.tasksPerPlan) {
|
|
195
|
+
return reject('bounded_collection_violation', '/tasks');
|
|
196
|
+
}
|
|
197
|
+
const taskKeys = v2
|
|
198
|
+
? ['task_id', 'title', 'lane', 'narrative_ref', 'compile_binding', 'disposition',
|
|
199
|
+
'start', 'completion', 'source', 'migration_context']
|
|
200
|
+
: ['task_id', 'title', 'lane', 'narrative_ref', 'compile_binding', 'disposition',
|
|
201
|
+
'completion', 'source', 'migration_context'];
|
|
202
|
+
for (const [index, task] of value.tasks.entries()) {
|
|
203
|
+
const taskKeyCheck = explainKeys(task, taskKeys, `/tasks/${index}`);
|
|
204
|
+
if (!taskKeyCheck.valid) return taskKeyCheck;
|
|
205
|
+
if (!extractionTask(task, schema)) {
|
|
206
|
+
return reject('task_shape_invalid', `/tasks/${index}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const sortCheck = explainSortedStrictly(value.tasks, (task) => task.task_id, '/tasks');
|
|
210
|
+
if (!sortCheck.valid) return sortCheck;
|
|
211
|
+
if (new Set(value.tasks.map(({ task_id: taskId }) => taskId)).size !== value.tasks.length) {
|
|
212
|
+
return reject('duplicate_task_id', '/tasks');
|
|
213
|
+
}
|
|
214
|
+
if (!validateEdges(value.hard_dependencies)) return reject('hard_dependencies_invalid', '/hard_dependencies');
|
|
215
|
+
if (!validateJoins(value.joins)) return reject('joins_invalid', '/joins');
|
|
216
|
+
if (!isTodoDigest(value.extraction_digest)) return reject('invalid_digest', '/extraction_digest');
|
|
217
|
+
const expectedDigest = todoSelfDigest(value, 'extraction_digest');
|
|
218
|
+
if (value.extraction_digest !== expectedDigest) {
|
|
219
|
+
return reject('extraction_digest_mismatch', '/extraction_digest');
|
|
220
|
+
}
|
|
221
|
+
const taskIds = new Set(value.tasks.map(({ task_id: taskId }) => taskId));
|
|
222
|
+
const badParent = value.tasks.find((task) => task.source.parent_task_id === task.task_id
|
|
223
|
+
|| (task.source.parent_task_id !== null && !taskIds.has(task.source.parent_task_id)));
|
|
224
|
+
if (badParent !== undefined) {
|
|
225
|
+
return reject('parent_task_id_unresolved',
|
|
226
|
+
`/tasks/${value.tasks.indexOf(badParent)}/source/parent_task_id`);
|
|
227
|
+
}
|
|
228
|
+
if (!localRefsResolve(value)) return reject('local_ref_unresolved', '');
|
|
229
|
+
// ここまでの個別検査を全て通過したのに`validateTodoExtraction`がfalseを返す状況は、
|
|
230
|
+
// このexplainがまだ言い当てられない違反があるということ。捏造せず未特定と申告する。
|
|
231
|
+
return { valid: true };
|
|
232
|
+
} catch {
|
|
233
|
+
return reject('diagnosis_failed', '');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
129
237
|
function registeredTaskIds(value) {
|
|
130
238
|
return new Set(value.tasks
|
|
131
239
|
.filter(({ disposition }) => disposition.startsWith('register_'))
|