@quolu/lattice 0.14.0 → 0.16.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/package.json +2 -2
- package/src/cli-help.mjs +3 -0
- package/src/project-cli.mjs +2 -0
- package/src/seam-proposal-contracts.mjs +427 -0
- package/src/seam-proposal-queries.mjs +508 -0
- package/src/seam-proposal.mjs +1982 -0
- package/src/todo-cli.mjs +286 -10
- package/src/todo-gantt-html.mjs +60 -1
- package/src/todo-gantt-layout.mjs +76 -0
- package/src/todo-independence-contracts.mjs +141 -12
- package/src/todo-independence-guidance.mjs +41 -1
- package/src/todo-independence.mjs +71 -10
- package/src/todo-store.mjs +103 -2
package/src/todo-cli.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
createTodoStoreWriter,
|
|
38
38
|
TodoStoreError,
|
|
39
39
|
readTodoIndependenceArtifact,
|
|
40
|
+
readTodoSeamProposalArtifact,
|
|
40
41
|
readTodoStore,
|
|
41
42
|
readTodoWitnessSet,
|
|
42
43
|
todoWitnessRef,
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
readTodoStoreStable,
|
|
45
46
|
rebuildTodoSnapshot,
|
|
46
47
|
writeTodoIndependenceArtifact,
|
|
48
|
+
writeTodoSeamProposalArtifact,
|
|
47
49
|
verifyEffectivePhaseTodoRevisionSources,
|
|
48
50
|
verifyTodoRevisionSources,
|
|
49
51
|
} from './todo-store.mjs';
|
|
@@ -59,6 +61,8 @@ import {
|
|
|
59
61
|
import {
|
|
60
62
|
TODO_INDEPENDENCE_PROJECTION_SCHEMA,
|
|
61
63
|
explainTodoWitnessSet,
|
|
64
|
+
isTodoIndependenceLegacyMarker,
|
|
65
|
+
validateTodoIndependence,
|
|
62
66
|
validateTodoIndependenceProjection,
|
|
63
67
|
} from './todo-independence-contracts.mjs';
|
|
64
68
|
import {
|
|
@@ -67,7 +71,19 @@ import {
|
|
|
67
71
|
migrateWitnessSetTaskIds,
|
|
68
72
|
projectIndependenceFrontier,
|
|
69
73
|
} from './todo-independence.mjs';
|
|
70
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
selectIndependenceGuidance,
|
|
76
|
+
selectSeamProposalGuidance,
|
|
77
|
+
} from './todo-independence-guidance.mjs';
|
|
78
|
+
import {
|
|
79
|
+
buildSeamProposalQuerySet,
|
|
80
|
+
collectSeamProposalEvidenceBundle,
|
|
81
|
+
} from './seam-proposal-queries.mjs';
|
|
82
|
+
import {
|
|
83
|
+
SEAM_PROPOSAL_PROJECTION_SCHEMA,
|
|
84
|
+
validateSeamProposalProjection,
|
|
85
|
+
} from './seam-proposal-contracts.mjs';
|
|
86
|
+
import { compileSeamProposalArtifact, declaredConcernSymbols } from './seam-proposal.mjs';
|
|
71
87
|
import {
|
|
72
88
|
parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
|
|
73
89
|
validateTodoRevision, validateTodoRevisionSet,
|
|
@@ -404,7 +420,7 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
|
|
|
404
420
|
// 記録があるなら鮮度の判定にHEADが要る。ここで読めないのは判定不能であり、
|
|
405
421
|
// 助言なしで通してよい状態ではない。
|
|
406
422
|
const currentBaseSha = currentHeadSha(repoRoot);
|
|
407
|
-
const changedPaths = artifact.base_sha !== currentBaseSha
|
|
423
|
+
const changedPaths = artifact.base_sha !== null && artifact.base_sha !== currentBaseSha
|
|
408
424
|
? changedPathsSince(repoRoot, artifact.base_sha) : null;
|
|
409
425
|
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
410
426
|
const projected = projectIndependenceFrontier({
|
|
@@ -442,6 +458,7 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
|
|
|
442
458
|
coverage: projected.coverage,
|
|
443
459
|
taskDeclared: declared,
|
|
444
460
|
taskStale: selfUnknowns.some(({ kind }) => kind === 'record_stale'),
|
|
461
|
+
contractSuperseded: isTodoIndependenceLegacyMarker(artifact),
|
|
445
462
|
conflictWithActive: conflictsWithActive[0]?.severability ?? null,
|
|
446
463
|
conflictBetweenReady: readyConflict?.severability ?? null,
|
|
447
464
|
}),
|
|
@@ -782,7 +799,8 @@ async function independence({ repoRoot, requestedPlanKey }) {
|
|
|
782
799
|
const active = projectTodoStatus(store).active_set
|
|
783
800
|
.filter((task) => task.plan_key === planKey);
|
|
784
801
|
// HEADが進んでいる時だけdiffを取る。一致していれば宣言境界を見るまでもない。
|
|
785
|
-
const changedPaths = artifact !== null && artifact.base_sha !==
|
|
802
|
+
const changedPaths = artifact !== null && artifact.base_sha !== null
|
|
803
|
+
&& artifact.base_sha !== currentBaseSha
|
|
786
804
|
? changedPathsSince(repoRoot, artifact.base_sha) : null;
|
|
787
805
|
|
|
788
806
|
const projected = projectIndependenceFrontier({
|
|
@@ -809,7 +827,9 @@ async function independence({ repoRoot, requestedPlanKey }) {
|
|
|
809
827
|
// planを読みに来た人にも、着手する人と同じ文言を返す(ADR 0130 Decision 1)。
|
|
810
828
|
guidance: selectIndependenceGuidance({
|
|
811
829
|
coverage: projected.coverage,
|
|
812
|
-
|
|
830
|
+
// 旧契約markerはreadyが空でも「対象なし」へ隠さず、superseded guidanceを返す。
|
|
831
|
+
readyCount: isTodoIndependenceLegacyMarker(artifact) ? null : ready.length,
|
|
832
|
+
contractSuperseded: isTodoIndependenceLegacyMarker(artifact),
|
|
813
833
|
taskDeclared: projected.frontier.unknown
|
|
814
834
|
.every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
|
|
815
835
|
taskStale: projected.frontier.unknown
|
|
@@ -827,6 +847,186 @@ async function independence({ repoRoot, requestedPlanKey }) {
|
|
|
827
847
|
return result;
|
|
828
848
|
}
|
|
829
849
|
|
|
850
|
+
async function seamProposalCompile({ repoRoot, planKey }) {
|
|
851
|
+
requireCleanWorktree(repoRoot);
|
|
852
|
+
const currentBaseSha = currentHeadSha(repoRoot);
|
|
853
|
+
const store = await readTodoStore({ repoRoot });
|
|
854
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
855
|
+
if (!member) {
|
|
856
|
+
throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, {
|
|
857
|
+
plan_key: planKey,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
const independenceArtifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
861
|
+
if (independenceArtifact === null || !validateTodoIndependence(independenceArtifact)) {
|
|
862
|
+
throw new TodoStoreError(
|
|
863
|
+
'SEAM_PROPOSAL_COMPILE_UNAVAILABLE',
|
|
864
|
+
independenceArtifact === null ? 'independence_artifact_absent' : 'independence_artifact_superseded',
|
|
865
|
+
undefined,
|
|
866
|
+
{ next_action: 'compile_independence' },
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
if (independenceArtifact.outcome !== 'compiled') {
|
|
870
|
+
throw new TodoStoreError('SEAM_PROPOSAL_COMPILE_UNAVAILABLE', 'independence_outcome_not_compiled', undefined, {
|
|
871
|
+
outcome: independenceArtifact.outcome,
|
|
872
|
+
next_action: 'recompile_independence',
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
if (independenceArtifact.base_sha !== currentBaseSha) {
|
|
876
|
+
throw new TodoStoreError('SEAM_PROPOSAL_COMPILE_UNAVAILABLE', 'independence_artifact_stale', undefined, {
|
|
877
|
+
independence_base_sha: independenceArtifact.base_sha,
|
|
878
|
+
current_base_sha: currentBaseSha,
|
|
879
|
+
next_action: 'recompile_independence',
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
const witnessSet = await readTodoWitnessSet({ repoRoot, planKey });
|
|
883
|
+
if (witnessSet === null) {
|
|
884
|
+
throw new TodoStoreError('SEAM_PROPOSAL_COMPILE_UNAVAILABLE', 'witness_set_absent', undefined, {
|
|
885
|
+
next_action: 'declare_witness_set_then_compile_independence',
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
if (witnessSet.witness_set_digest !== independenceArtifact.witness_set_digest) {
|
|
889
|
+
throw new TodoStoreError('SEAM_PROPOSAL_COMPILE_UNAVAILABLE', 'witness_set_changed', undefined, {
|
|
890
|
+
next_action: 'recompile_independence',
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const { query_set: querySet } = buildSeamProposalQuerySet({
|
|
895
|
+
conflictResources: independenceArtifact.conflict_resources,
|
|
896
|
+
concernSymbols: declaredConcernSymbols(witnessSet.manual_witness),
|
|
897
|
+
});
|
|
898
|
+
const [sensorEvidence, proposalEvidence] = await Promise.all([
|
|
899
|
+
collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
|
|
900
|
+
collectSeamProposalEvidenceBundle({ cwd: repoRoot, querySet }),
|
|
901
|
+
]);
|
|
902
|
+
const artifact = compileSeamProposalArtifact({
|
|
903
|
+
independenceArtifact,
|
|
904
|
+
witnessSet,
|
|
905
|
+
plan: member.plan,
|
|
906
|
+
compiledAt: new Date().toISOString(),
|
|
907
|
+
sensorEvidence,
|
|
908
|
+
evidence: proposalEvidence.evidence,
|
|
909
|
+
rawCollected: proposalEvidence.raw_collected,
|
|
910
|
+
});
|
|
911
|
+
const { ref } = await writeTodoSeamProposalArtifact({ repoRoot, artifact });
|
|
912
|
+
const verdictCounts = {
|
|
913
|
+
seam_candidate: artifact.decisions.filter(({ verdict }) => verdict === 'seam_candidate').length,
|
|
914
|
+
intentional_serial: artifact.decisions
|
|
915
|
+
.filter(({ verdict }) => verdict === 'intentional_serial').length,
|
|
916
|
+
unknown_requires_evidence: artifact.decisions
|
|
917
|
+
.filter(({ verdict }) => verdict === 'unknown_requires_evidence').length,
|
|
918
|
+
};
|
|
919
|
+
const result = {
|
|
920
|
+
schema: 'lattice.seam_proposal_compile_result.v1',
|
|
921
|
+
project_id: artifact.project_id,
|
|
922
|
+
plan_key: artifact.plan_key,
|
|
923
|
+
plan_version: artifact.source_binding.plan_version,
|
|
924
|
+
base_sha: artifact.source_binding.base_sha,
|
|
925
|
+
artifact_ref: ref,
|
|
926
|
+
component_count: artifact.decisions.length,
|
|
927
|
+
conflict_resource_count: artifact.decisions
|
|
928
|
+
.reduce((count, decision) => count + decision.conflicts.length, 0),
|
|
929
|
+
verdict_counts: verdictCounts,
|
|
930
|
+
result_digest: '',
|
|
931
|
+
};
|
|
932
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
933
|
+
return result;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function summarizeSeamProposalDecision(decision) {
|
|
937
|
+
return {
|
|
938
|
+
component_id: decision.component_id,
|
|
939
|
+
verdict: decision.verdict,
|
|
940
|
+
task_ids: decision.task_ids,
|
|
941
|
+
conflicts: decision.conflicts.map(({
|
|
942
|
+
resource_id: resourceId, kind, target, task_pairs: taskPairs,
|
|
943
|
+
}) => ({
|
|
944
|
+
resource_id: resourceId,
|
|
945
|
+
kind,
|
|
946
|
+
target,
|
|
947
|
+
task_pairs: taskPairs,
|
|
948
|
+
})),
|
|
949
|
+
proposed_surfaces: decision.seam_candidate?.proposed_surfaces ?? [],
|
|
950
|
+
affected_tests: decision.seam_candidate?.affected_tests ?? [],
|
|
951
|
+
limits: decision.seam_candidate?.limits ?? [],
|
|
952
|
+
reasons: decision.reasons,
|
|
953
|
+
unknowns: decision.unknowns,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function seamProposal({ repoRoot, requestedPlanKey }) {
|
|
958
|
+
const store = await readTodoStore({ repoRoot });
|
|
959
|
+
if (requestedPlanKey !== null
|
|
960
|
+
&& !store.members.some(({ descriptor }) => descriptor.plan_key === requestedPlanKey)) {
|
|
961
|
+
throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, {
|
|
962
|
+
plan_key: requestedPlanKey,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
const frontier = computeReadyFrontier(store);
|
|
966
|
+
const readyPlanKeys = [...new Set(frontier.map(({ plan_key: key }) => key))].sort();
|
|
967
|
+
const candidatePlanKeys = readyPlanKeys.length > 0
|
|
968
|
+
? readyPlanKeys
|
|
969
|
+
: store.members.map(({ descriptor }) => descriptor.plan_key).sort();
|
|
970
|
+
if (requestedPlanKey === null && candidatePlanKeys.length > 1) {
|
|
971
|
+
throw new TodoStoreError('SEAM_PROPOSAL_PLAN_AMBIGUOUS', 'plan_selection_ambiguous', undefined, {
|
|
972
|
+
plan_keys: candidatePlanKeys,
|
|
973
|
+
ready_plan_keys: readyPlanKeys,
|
|
974
|
+
next_action: 'rerun_with_plan_flag',
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
const planKey = requestedPlanKey ?? candidatePlanKeys[0] ?? null;
|
|
978
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
979
|
+
const currentBaseSha = currentHeadSha(repoRoot);
|
|
980
|
+
const independenceArtifact = member === undefined
|
|
981
|
+
? null : await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
982
|
+
const artifact = member === undefined
|
|
983
|
+
? null : await readTodoSeamProposalArtifact({ repoRoot, store, planKey });
|
|
984
|
+
|
|
985
|
+
let coverage = 'verified';
|
|
986
|
+
if (artifact === null) coverage = 'missing';
|
|
987
|
+
else {
|
|
988
|
+
const binding = artifact.source_binding;
|
|
989
|
+
const independenceMatches = independenceArtifact !== null
|
|
990
|
+
&& validateTodoIndependence(independenceArtifact)
|
|
991
|
+
&& independenceArtifact.schema === binding.independence_schema
|
|
992
|
+
&& independenceArtifact.result_digest === binding.independence_result_digest
|
|
993
|
+
&& independenceArtifact.witness_set_digest === binding.witness_set_digest
|
|
994
|
+
&& independenceArtifact.plan_version === binding.plan_version
|
|
995
|
+
&& independenceArtifact.topology_digest === binding.topology_digest
|
|
996
|
+
&& independenceArtifact.base_sha === binding.base_sha;
|
|
997
|
+
const planMatches = member !== undefined
|
|
998
|
+
&& member.plan.plan_version === binding.plan_version
|
|
999
|
+
&& member.plan.topology_digest === binding.topology_digest;
|
|
1000
|
+
if (!independenceMatches || !planMatches) coverage = 'superseded';
|
|
1001
|
+
else if (binding.base_sha !== currentBaseSha) coverage = 'stale';
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const components = artifact?.decisions.map(summarizeSeamProposalDecision) ?? [];
|
|
1005
|
+
const result = {
|
|
1006
|
+
schema: SEAM_PROPOSAL_PROJECTION_SCHEMA,
|
|
1007
|
+
project_id: store.project_id,
|
|
1008
|
+
plan_key: planKey,
|
|
1009
|
+
coverage,
|
|
1010
|
+
compiled_base_sha: artifact?.source_binding.base_sha ?? null,
|
|
1011
|
+
current_base_sha: currentBaseSha,
|
|
1012
|
+
plan_version: artifact?.source_binding.plan_version ?? null,
|
|
1013
|
+
topology_digest: artifact?.source_binding.topology_digest ?? null,
|
|
1014
|
+
independence_result_digest: artifact?.source_binding.independence_result_digest ?? null,
|
|
1015
|
+
compiled_at: artifact?.compiled_at ?? null,
|
|
1016
|
+
guidance: selectSeamProposalGuidance({ coverage }),
|
|
1017
|
+
component_count: artifact === null ? null : components.length,
|
|
1018
|
+
conflict_resource_count: artifact === null ? null : components
|
|
1019
|
+
.reduce((count, component) => count + component.conflicts.length, 0),
|
|
1020
|
+
components,
|
|
1021
|
+
result_digest: '',
|
|
1022
|
+
};
|
|
1023
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
1024
|
+
if (!validateSeamProposalProjection(result)) {
|
|
1025
|
+
throw new TodoStoreError('SEAM_PROPOSAL_PROJECTION_INVALID', 'seam_proposal_projection_invalid');
|
|
1026
|
+
}
|
|
1027
|
+
return result;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
830
1030
|
async function readNarrative(repoRoot, ref) {
|
|
831
1031
|
const canonicalRoot = await realpath(repoRoot);
|
|
832
1032
|
const source = parseTodoSourceRef(ref);
|
|
@@ -1008,7 +1208,10 @@ function parseGanttDescriptor(bytes, descriptorRef) {
|
|
|
1008
1208
|
* 一つの関数だけが組む。
|
|
1009
1209
|
*/
|
|
1010
1210
|
export async function ganttLiveHeadDigest({ repoRoot, store }) {
|
|
1011
|
-
const independence = await
|
|
1211
|
+
const [independence, seamProposals] = await Promise.all([
|
|
1212
|
+
independenceForGantt({ repoRoot, store }),
|
|
1213
|
+
seamProposalsForGantt({ repoRoot, store }),
|
|
1214
|
+
]);
|
|
1012
1215
|
return digestTodoArtifact({
|
|
1013
1216
|
schema: 'lattice.todo_gantt_live_head.v1',
|
|
1014
1217
|
manifest_digest: store.manifest.manifest_digest,
|
|
@@ -1017,6 +1220,11 @@ export async function ganttLiveHeadDigest({ repoRoot, store }) {
|
|
|
1017
1220
|
coverage: entry.coverage,
|
|
1018
1221
|
frontier_digest: digestTodoArtifact(entry.frontier),
|
|
1019
1222
|
})),
|
|
1223
|
+
seam_proposals: seamProposals.map((entry) => ({
|
|
1224
|
+
plan_key: entry.plan_key,
|
|
1225
|
+
coverage: entry.coverage,
|
|
1226
|
+
projection_digest: digestTodoArtifact(entry),
|
|
1227
|
+
})),
|
|
1020
1228
|
});
|
|
1021
1229
|
}
|
|
1022
1230
|
|
|
@@ -1031,7 +1239,7 @@ async function independenceForGantt({ repoRoot, store }) {
|
|
|
1031
1239
|
if (artifact === null) continue;
|
|
1032
1240
|
// 記録があるplanが1つでもあれば鮮度の判定にHEADが要る。
|
|
1033
1241
|
if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
|
|
1034
|
-
const changedPaths = artifact.base_sha !== currentBaseSha
|
|
1242
|
+
const changedPaths = artifact.base_sha !== null && artifact.base_sha !== currentBaseSha
|
|
1035
1243
|
? changedPathsSince(repoRoot, artifact.base_sha) : null;
|
|
1036
1244
|
const projected = projectIndependenceFrontier({
|
|
1037
1245
|
artifact,
|
|
@@ -1053,6 +1261,59 @@ async function independenceForGantt({ repoRoot, store }) {
|
|
|
1053
1261
|
return projections.length === 0 ? null : projections;
|
|
1054
1262
|
}
|
|
1055
1263
|
|
|
1264
|
+
/**
|
|
1265
|
+
* 図が描く全planのseam提案記録を読む。生成は行わず、記録が無いplanもmissing guidanceを
|
|
1266
|
+
* 持つ投影として残すので、「提案対象なし」と「まだ生成していない」を混同しない。
|
|
1267
|
+
*/
|
|
1268
|
+
async function seamProposalsForGantt({ repoRoot, store }) {
|
|
1269
|
+
let currentBaseSha = null;
|
|
1270
|
+
const projections = [];
|
|
1271
|
+
for (const member of store.members) {
|
|
1272
|
+
const planKey = member.plan.plan_key;
|
|
1273
|
+
const artifact = await readTodoSeamProposalArtifact({ repoRoot, store, planKey });
|
|
1274
|
+
if (artifact === null) {
|
|
1275
|
+
projections.push({
|
|
1276
|
+
project_id: member.plan.project_id,
|
|
1277
|
+
plan_key: planKey,
|
|
1278
|
+
coverage: 'missing',
|
|
1279
|
+
guidance: selectSeamProposalGuidance({ coverage: 'missing' }),
|
|
1280
|
+
component_count: null,
|
|
1281
|
+
conflict_resource_count: null,
|
|
1282
|
+
components: [],
|
|
1283
|
+
});
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
|
|
1288
|
+
const independenceArtifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
1289
|
+
const binding = artifact.source_binding;
|
|
1290
|
+
const independenceMatches = independenceArtifact !== null
|
|
1291
|
+
&& validateTodoIndependence(independenceArtifact)
|
|
1292
|
+
&& independenceArtifact.schema === binding.independence_schema
|
|
1293
|
+
&& independenceArtifact.result_digest === binding.independence_result_digest
|
|
1294
|
+
&& independenceArtifact.witness_set_digest === binding.witness_set_digest
|
|
1295
|
+
&& independenceArtifact.plan_version === binding.plan_version
|
|
1296
|
+
&& independenceArtifact.topology_digest === binding.topology_digest
|
|
1297
|
+
&& independenceArtifact.base_sha === binding.base_sha;
|
|
1298
|
+
const planMatches = member.plan.plan_version === binding.plan_version
|
|
1299
|
+
&& member.plan.topology_digest === binding.topology_digest;
|
|
1300
|
+
const coverage = !independenceMatches || !planMatches ? 'superseded'
|
|
1301
|
+
: binding.base_sha !== currentBaseSha ? 'stale' : 'verified';
|
|
1302
|
+
const components = artifact.decisions.map(summarizeSeamProposalDecision);
|
|
1303
|
+
projections.push({
|
|
1304
|
+
project_id: member.plan.project_id,
|
|
1305
|
+
plan_key: planKey,
|
|
1306
|
+
coverage,
|
|
1307
|
+
guidance: selectSeamProposalGuidance({ coverage }),
|
|
1308
|
+
component_count: components.length,
|
|
1309
|
+
conflict_resource_count: components
|
|
1310
|
+
.reduce((count, component) => count + component.conflicts.length, 0),
|
|
1311
|
+
components,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
return projections;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1056
1317
|
export async function renderTodoGanttForProject({
|
|
1057
1318
|
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
1058
1319
|
scope = DEFAULT_GANTT_SCOPE,
|
|
@@ -1065,12 +1326,17 @@ export async function renderTodoGanttForProject({
|
|
|
1065
1326
|
const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
|
|
1066
1327
|
const topology = mergedTopology(store);
|
|
1067
1328
|
const chain = projectTodoChainV1(topology);
|
|
1068
|
-
const independence = await
|
|
1069
|
-
|
|
1329
|
+
const [independence, seamProposals] = await Promise.all([
|
|
1330
|
+
independenceForGantt({ repoRoot, store }),
|
|
1331
|
+
seamProposalsForGantt({ repoRoot, store }),
|
|
1332
|
+
]);
|
|
1333
|
+
const layout = layoutTodoGantt(store, chain, { scope, independence, seamProposals });
|
|
1070
1334
|
// When the diagram hides history, the page also carries the full diagram so
|
|
1071
1335
|
// the reader can bring it back in place. Nothing is hidden under `all`.
|
|
1072
1336
|
const expandedLayout = layout.scope.folded_task_count === 0
|
|
1073
|
-
? null : layoutTodoGantt(store, chain, {
|
|
1337
|
+
? null : layoutTodoGantt(store, chain, {
|
|
1338
|
+
scope: 'all', independence, seamProposals,
|
|
1339
|
+
});
|
|
1074
1340
|
const narrative = await loadNarratives(store, repoRoot);
|
|
1075
1341
|
const anchorOutcomes = verifyNarrativeAnchors({
|
|
1076
1342
|
readModel: store,
|
|
@@ -1363,8 +1629,18 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
1363
1629
|
action = (repoRoot) => independence({ repoRoot, requestedPlanKey: null });
|
|
1364
1630
|
} else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'independence'
|
|
1365
1631
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1366
|
-
|
|
1632
|
+
&& (argv.length === 3 || argv[3] === '--json')) {
|
|
1367
1633
|
action = (repoRoot) => independence({ repoRoot, requestedPlanKey: argv[2] });
|
|
1634
|
+
} else if (argv.length === 4 && argv[0] === 'seam-proposal' && argv[1] === 'compile'
|
|
1635
|
+
&& argv[2] === '--plan' && isTodoIdentifier(argv[3])) {
|
|
1636
|
+
action = (repoRoot) => seamProposalCompile({ repoRoot, planKey: argv[3] });
|
|
1637
|
+
} else if ((argv.length === 1 && argv[0] === 'seam-proposal')
|
|
1638
|
+
|| (argv.length === 2 && argv[0] === 'seam-proposal' && argv[1] === '--json')) {
|
|
1639
|
+
action = (repoRoot) => seamProposal({ repoRoot, requestedPlanKey: null });
|
|
1640
|
+
} else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'seam-proposal'
|
|
1641
|
+
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1642
|
+
&& (argv.length === 3 || argv[3] === '--json')) {
|
|
1643
|
+
action = (repoRoot) => seamProposal({ repoRoot, requestedPlanKey: argv[2] });
|
|
1368
1644
|
} else if ((argv.length === 1 && argv[0] === 'verify')
|
|
1369
1645
|
|| (argv.length === 2 && argv[0] === 'verify' && argv[1] === '--json')) {
|
|
1370
1646
|
action = (repoRoot) => verify({ repoRoot, requestedPlanKey: null });
|
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -282,6 +282,54 @@ function renderIndependenceNote(ref, node, summary) {
|
|
|
282
282
|
return `<p class="readiness-note"><strong>並列可否:</strong> 要直列です。</p><ul class="independence-conflicts">${items}</ul>`;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
function renderSeamComponent(component) {
|
|
286
|
+
const conflicts = component.conflicts.map((conflict) => {
|
|
287
|
+
const pairs = conflict.task_pairs
|
|
288
|
+
.map(([left, right]) => `<span class="seam-task-pair"><code>${escapeHtmlText(left)}</code><span aria-hidden="true"> ↔ </span><code>${escapeHtmlText(right)}</code></span>`)
|
|
289
|
+
.join('');
|
|
290
|
+
return `<li class="seam-conflict"><strong class="seam-target">${escapeHtmlText(conflict.target)}</strong><span class="seam-conflict-kind"><code>${escapeHtmlText(conflict.kind)}</code></span><span class="seam-pairs">${pairs}</span></li>`;
|
|
291
|
+
}).join('');
|
|
292
|
+
const unknowns = component.unknowns.length === 0 ? '' : `<section class="seam-evidence-needed"><h4>次に必要な証拠</h4><ul>${component.unknowns.map((unknown) => {
|
|
293
|
+
const reference = component.task_ids.includes(unknown.ref)
|
|
294
|
+
? `ToDo <code>${escapeHtmlText(unknown.ref)}</code>`
|
|
295
|
+
: `ref <code>${escapeHtmlText(unknown.ref)}</code>`;
|
|
296
|
+
return `<li><code>${escapeHtmlText(unknown.kind)}</code><span>${reference}</span></li>`;
|
|
297
|
+
}).join('')}</ul></section>`;
|
|
298
|
+
const reasons = component.reasons.length === 0 ? '' : `<section class="seam-reasons"><h4>判定理由</h4><ul>${component.reasons.map((reason) => `<li><code>${escapeHtmlText(reason.code)}</code><span>${escapeHtmlText(reason.detail)}</span></li>`).join('')}</ul></section>`;
|
|
299
|
+
const proposed = component.proposed_surfaces.length === 0 ? '' : `<section class="seam-surfaces"><h4>提案する所有境界</h4><ul>${component.proposed_surfaces.map((surface) => `<li><strong>${escapeHtmlText(surface.target)}</strong><span><code>${escapeHtmlText(surface.kind)}</code> / <code>${escapeHtmlText(surface.role)}</code> / owner ${surface.owner_task_ids.map((taskId) => `<code>${escapeHtmlText(taskId)}</code>`).join(', ') || '—'}</span></li>`).join('')}</ul></section>`;
|
|
300
|
+
const affectedTests = component.affected_tests.length === 0 ? ''
|
|
301
|
+
: `<p class="seam-tests"><strong>影響test:</strong> ${component.affected_tests.map((testRef) => `<code>${escapeHtmlText(testRef)}</code>`).join(', ')}</p>`;
|
|
302
|
+
return `<article class="seam-component verdict-${escapeHtmlAttribute(component.verdict)}"><header><span>Seam判定</span><code>${escapeHtmlText(component.verdict)}</code></header><ul class="seam-conflicts">${conflicts}</ul>${unknowns}${reasons}${proposed}${affectedTests}</article>`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function renderSeamPlan(plan, { compact = false } = {}) {
|
|
306
|
+
const components = plan.components.map(renderSeamComponent).join('');
|
|
307
|
+
const count = plan.component_count === null ? '—' : String(plan.component_count);
|
|
308
|
+
const nextAction = plan.guidance.next_action === 'none' ? ''
|
|
309
|
+
: `<p class="seam-next-action"><strong>次の一歩:</strong> <code>${escapeHtmlText(plan.guidance.next_action)}</code></p>`;
|
|
310
|
+
return `<section class="seam-plan${compact ? ' seam-plan-compact' : ''}" data-seam-plan="${escapeHtmlAttribute(plan.plan_key)}"><header><code>${escapeHtmlText(plan.plan_key)}</code><span class="seam-coverage coverage-${escapeHtmlAttribute(plan.coverage)}">${escapeHtmlText(plan.guidance.code)}</span><span class="seam-component-count">component ${escapeHtmlText(count)}件</span></header><p class="seam-guidance">${escapeHtmlText(plan.guidance.message)}</p>${nextAction}${components}</section>`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* componentがあるplanを先に展開し、0件planはcoverageごとに畳む。
|
|
315
|
+
* 実データのunknownと係争資源を、未生成planの列より先に視認できるようにする。
|
|
316
|
+
*/
|
|
317
|
+
function renderSeamProposalOverview(layout) {
|
|
318
|
+
const plans = layout.seam_proposals?.plans;
|
|
319
|
+
if (!Array.isArray(plans)) return '';
|
|
320
|
+
const withComponents = plans.filter((plan) => plan.components.length > 0);
|
|
321
|
+
const emptyByGuidance = new Map();
|
|
322
|
+
for (const plan of plans.filter((entry) => entry.components.length === 0)) {
|
|
323
|
+
if (!emptyByGuidance.has(plan.guidance.code)) emptyByGuidance.set(plan.guidance.code, []);
|
|
324
|
+
emptyByGuidance.get(plan.guidance.code).push(plan);
|
|
325
|
+
}
|
|
326
|
+
const decisions = withComponents.map((plan) => renderSeamPlan(plan)).join('');
|
|
327
|
+
const emptyGroups = [...emptyByGuidance.entries()].sort(([left], [right]) => compareText(left, right))
|
|
328
|
+
.map(([code, grouped]) => `<details class="seam-empty-group"><summary><code>${escapeHtmlText(code)}</code><span>${grouped.length} plan</span></summary>${grouped.map((plan) => renderSeamPlan(plan, { compact: true })).join('')}</details>`)
|
|
329
|
+
.join('');
|
|
330
|
+
return `<section class="seam-overview"><h2>Seam提案</h2>${decisions}${emptyGroups}</section>`;
|
|
331
|
+
}
|
|
332
|
+
|
|
285
333
|
function renderRightPane(sections, layout, presentation, readModel) {
|
|
286
334
|
const lookup = presentationLookup(presentation);
|
|
287
335
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
@@ -323,7 +371,7 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
323
371
|
const dispatchSummary = `${readyHeadline}${independenceNote}`;
|
|
324
372
|
const activeLinks = active.length === 0 ? '<p>作業中の工程はありません。</p>'
|
|
325
373
|
: `<ul class="active-list">${active.map((section) => `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(refKey(section.ref))}">${escapeHtmlText(taskReference(section, lookup))} — ${escapeHtmlText(section.task.title)}</button></li>`).join('')}</ul>`;
|
|
326
|
-
const overview = `<section class="right-overview" data-right-panel="overview"><h1>工程を選択してください</h1><p>左の依存工程図から工程を選ぶと、題名・状態・前提・後続を表示します。</p><div class="status-summary"><span>☐ 未着手 ${counts.pending}</span><span>▶ 作業中 ${counts['in-progress']}</span><span>✅ 完了 ${counts.done}</span><span>⛔ ブロック中 ${counts.blocked}</span></div>${dispatchSummary}${renderPhaseProgress(readModel)}<h2>作業中</h2>${activeLinks}</section>`;
|
|
374
|
+
const overview = `<section class="right-overview" data-right-panel="overview"><h1>工程を選択してください</h1><p>左の依存工程図から工程を選ぶと、題名・状態・前提・後続を表示します。</p><div class="status-summary"><span>☐ 未着手 ${counts.pending}</span><span>▶ 作業中 ${counts['in-progress']}</span><span>✅ 完了 ${counts.done}</span><span>⛔ ブロック中 ${counts.blocked}</span></div>${dispatchSummary}${renderSeamProposalOverview(layout)}${renderPhaseProgress(readModel)}<h2>作業中</h2>${activeLinks}</section>`;
|
|
327
375
|
const details = sections.map((section) => {
|
|
328
376
|
const key = refKey(section.ref);
|
|
329
377
|
const node = nodeByKey.get(key);
|
|
@@ -418,6 +466,17 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
418
466
|
.right-overview h1,.task-detail h1{margin:0 0 16px;font-size:19px;font-weight:650;line-height:1.45}
|
|
419
467
|
.right-overview h2,.task-detail h2{margin:24px 0 8px;font-size:16px;font-weight:600}
|
|
420
468
|
.status-summary{display:flex;flex-wrap:wrap;gap:8px 16px;margin:16px 0;padding:12px;background:var(--surface-2)}
|
|
469
|
+
.seam-overview{margin:24px 0}.seam-overview>h2{margin-bottom:8px}
|
|
470
|
+
.seam-plan{margin:8px 0;padding:12px;border:1px solid var(--border);border-left:4px solid var(--accent);background:var(--surface-2)}
|
|
471
|
+
.seam-plan>header{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px}.seam-plan>header>code{font-weight:650}.seam-component-count{margin-left:auto;color:var(--text-secondary);font-size:12px}
|
|
472
|
+
.seam-coverage{padding:1px 7px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-1);font-size:11px;font-weight:650}.seam-coverage.coverage-missing,.seam-coverage.coverage-stale,.seam-coverage.coverage-superseded{border-color:var(--critical);color:var(--critical)}
|
|
473
|
+
.seam-guidance,.seam-next-action{margin:6px 0 0;color:var(--text-secondary);font-size:12px}.seam-next-action code{color:var(--text-primary);font-weight:650}
|
|
474
|
+
.seam-component{margin-top:10px;padding:10px;border:1px solid var(--border);border-left:4px solid var(--text-secondary);background:var(--surface-1)}.seam-component.verdict-seam_candidate{border-left-color:var(--good)}.seam-component.verdict-intentional_serial{border-left-color:var(--critical)}.seam-component.verdict-unknown_requires_evidence{border-left-color:var(--accent)}
|
|
475
|
+
.seam-component>header{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;font-weight:650}.seam-component>header code{overflow-wrap:anywhere}
|
|
476
|
+
.seam-conflicts,.seam-evidence-needed ul,.seam-reasons ul,.seam-surfaces ul{margin:8px 0 0;padding:0;list-style:none}.seam-conflict{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;padding:8px;border:1px solid var(--border)}.seam-conflict+.seam-conflict{margin-top:6px}
|
|
477
|
+
.seam-target{font-size:13.5px;overflow-wrap:anywhere}.seam-conflict-kind{color:var(--text-secondary);font-size:11px}.seam-pairs{grid-column:1 / -1;display:flex;flex-wrap:wrap;gap:4px 10px}.seam-task-pair{font-weight:650}
|
|
478
|
+
.seam-evidence-needed,.seam-reasons,.seam-surfaces{margin-top:10px}.seam-evidence-needed h4,.seam-reasons h4,.seam-surfaces h4{margin:0;font-size:12px}.seam-evidence-needed li,.seam-reasons li,.seam-surfaces li{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 12px;padding:5px 8px;background:var(--surface-2)}.seam-evidence-needed li+li,.seam-reasons li+li,.seam-surfaces li+li{margin-top:4px}.seam-evidence-needed li>code{font-weight:650}.seam-evidence-needed li>span,.seam-reasons li>span,.seam-surfaces li>span{color:var(--text-secondary)}
|
|
479
|
+
.seam-tests{margin:8px 0 0;color:var(--text-secondary);font-size:12px}.seam-empty-group{margin-top:8px;border-top:1px solid var(--border)}.seam-empty-group>summary{display:flex;gap:10px;padding:8px 0;cursor:pointer;color:var(--text-secondary)}.seam-empty-group>summary span{margin-left:auto}.seam-plan-compact{border-left-width:1px}
|
|
421
480
|
.phase-overview>p{color:var(--text-secondary)}.phase-overview>ol{display:grid;gap:8px;margin:0;padding:0;list-style:none}.phase-progress{padding:10px 12px;border:1px solid var(--border);border-left-width:4px;background:var(--surface-2)}.phase-progress>header{display:flex;justify-content:space-between;gap:12px}.phase-progress>p{margin:4px 0;color:var(--text-secondary);font-size:12px}.phase-progress progress{display:block;width:100%}.phase-progress.status-accepted{border-left-color:var(--good)}.phase-progress.status-reviewing,.phase-progress.status-gate_ready{border-left-color:var(--accent)}.phase-progress.status-rejected{border-left-color:var(--critical)}
|
|
422
481
|
.active-list,.relation-list{margin:0;padding:0;list-style:none}.active-list li+li,.relation-list li+li{margin-top:8px}
|
|
423
482
|
.active-list button{width:100%}.anchor-status,.readiness-note,.category-description,.relation-empty{color:var(--text-secondary)}
|
|
@@ -438,6 +438,79 @@ function normalizeIndependence(value, nodesByKey) {
|
|
|
438
438
|
return { stateByKey, summary: { plans } };
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
/**
|
|
442
|
+
* seam proposalの公開投影を、描画に必要なhuman-facing fieldだけへ畳む。
|
|
443
|
+
* resource_idは記録のidentityであって表示名ではないため、layoutへ持ち込まない。
|
|
444
|
+
*/
|
|
445
|
+
function normalizeSeamProposals(value) {
|
|
446
|
+
if (value === null || value === undefined) return { summary: null };
|
|
447
|
+
if (!Array.isArray(value)) {
|
|
448
|
+
fail('TODO_LAYOUT_INVALID_INPUT', 'seamProposals must be an array of plan projections');
|
|
449
|
+
}
|
|
450
|
+
const plans = value.map((projection) => {
|
|
451
|
+
if (!plain(projection) || typeof projection.project_id !== 'string'
|
|
452
|
+
|| typeof projection.plan_key !== 'string'
|
|
453
|
+
|| !['missing', 'superseded', 'stale', 'verified'].includes(projection.coverage)
|
|
454
|
+
|| !plain(projection.guidance)
|
|
455
|
+
|| typeof projection.guidance.code !== 'string'
|
|
456
|
+
|| typeof projection.guidance.message !== 'string'
|
|
457
|
+
|| typeof projection.guidance.next_action !== 'string'
|
|
458
|
+
|| !Array.isArray(projection.components)) {
|
|
459
|
+
fail('TODO_LAYOUT_INVALID_INPUT', 'seam proposal entry has an invalid projection shape');
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
project_id: projection.project_id,
|
|
463
|
+
plan_key: projection.plan_key,
|
|
464
|
+
coverage: projection.coverage,
|
|
465
|
+
guidance: { ...projection.guidance },
|
|
466
|
+
component_count: projection.component_count,
|
|
467
|
+
conflict_resource_count: projection.conflict_resource_count,
|
|
468
|
+
components: projection.components.map((component) => {
|
|
469
|
+
if (!plain(component) || typeof component.component_id !== 'string'
|
|
470
|
+
|| typeof component.verdict !== 'string'
|
|
471
|
+
|| !Array.isArray(component.task_ids)
|
|
472
|
+
|| !Array.isArray(component.conflicts)
|
|
473
|
+
|| !Array.isArray(component.proposed_surfaces)
|
|
474
|
+
|| !Array.isArray(component.affected_tests)
|
|
475
|
+
|| !Array.isArray(component.limits)
|
|
476
|
+
|| !Array.isArray(component.reasons)
|
|
477
|
+
|| !Array.isArray(component.unknowns)) {
|
|
478
|
+
fail('TODO_LAYOUT_INVALID_INPUT', 'seam proposal component has an invalid shape');
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
component_id: component.component_id,
|
|
482
|
+
verdict: component.verdict,
|
|
483
|
+
task_ids: [...component.task_ids],
|
|
484
|
+
conflicts: component.conflicts.map((conflict) => {
|
|
485
|
+
if (!plain(conflict) || typeof conflict.kind !== 'string'
|
|
486
|
+
|| typeof conflict.target !== 'string' || !Array.isArray(conflict.task_pairs)) {
|
|
487
|
+
fail('TODO_LAYOUT_INVALID_INPUT', 'seam proposal conflict has an invalid shape');
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
kind: conflict.kind,
|
|
491
|
+
target: conflict.target,
|
|
492
|
+
task_pairs: conflict.task_pairs.map((pair) => [...pair]),
|
|
493
|
+
};
|
|
494
|
+
}),
|
|
495
|
+
proposed_surfaces: component.proposed_surfaces.map((surface) => ({ ...surface,
|
|
496
|
+
owner_task_ids: [...surface.owner_task_ids] })),
|
|
497
|
+
affected_tests: [...component.affected_tests],
|
|
498
|
+
limits: [...component.limits],
|
|
499
|
+
reasons: component.reasons.map((reason) => ({ ...reason })),
|
|
500
|
+
unknowns: component.unknowns.map((unknown) => ({ ...unknown })),
|
|
501
|
+
};
|
|
502
|
+
}),
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
plans.sort((left, right) => {
|
|
506
|
+
const leftHasDecisions = left.components.length > 0;
|
|
507
|
+
const rightHasDecisions = right.components.length > 0;
|
|
508
|
+
return leftHasDecisions === rightHasDecisions
|
|
509
|
+
? compareText(left.plan_key, right.plan_key) : leftHasDecisions ? -1 : 1;
|
|
510
|
+
});
|
|
511
|
+
return { summary: { plans } };
|
|
512
|
+
}
|
|
513
|
+
|
|
441
514
|
export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
442
515
|
const scope = options.scope ?? 'live';
|
|
443
516
|
if (!TODO_GANTT_SCOPES.includes(scope)) {
|
|
@@ -467,6 +540,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
467
540
|
}));
|
|
468
541
|
const readyKeys = readyTaskKeys(readModel, full.nodes, full.nodesByKey, fullWaves.incoming);
|
|
469
542
|
const independence = normalizeIndependence(options.independence ?? null, full.nodesByKey);
|
|
543
|
+
const seamProposals = normalizeSeamProposals(options.seamProposals ?? null);
|
|
470
544
|
|
|
471
545
|
// Only the geometry stage below sees the narrowed graph.
|
|
472
546
|
const projected = scope === 'all'
|
|
@@ -777,6 +851,8 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
777
851
|
nodes: projectedNodes,
|
|
778
852
|
// 図の外が語るための投影。カードはバッジで状態だけを示し、相手と理由は右ペインが持つ。
|
|
779
853
|
independence: independence.summary,
|
|
854
|
+
// seam提案は図形を増やさず、右ペインで係争資源・ToDo組・判定・不足証拠をまとめて示す。
|
|
855
|
+
seam_proposals: seamProposals.summary,
|
|
780
856
|
edges: projectedEdges,
|
|
781
857
|
// Every dependency in the plan, before folding contracted any of them away.
|
|
782
858
|
// The diagram draws `edges`; anything that describes a ToDo in words — the
|