@quolu/lattice 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/todo-cli.mjs CHANGED
@@ -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 { selectIndependenceGuidance } from './todo-independence-guidance.mjs';
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 } 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 !== currentBaseSha
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,6 +827,9 @@ async function independence({ repoRoot, requestedPlanKey }) {
809
827
  // planを読みに来た人にも、着手する人と同じ文言を返す(ADR 0130 Decision 1)。
810
828
  guidance: selectIndependenceGuidance({
811
829
  coverage: projected.coverage,
830
+ // 旧契約markerはreadyが空でも「対象なし」へ隠さず、superseded guidanceを返す。
831
+ readyCount: isTodoIndependenceLegacyMarker(artifact) ? null : ready.length,
832
+ contractSuperseded: isTodoIndependenceLegacyMarker(artifact),
812
833
  taskDeclared: projected.frontier.unknown
813
834
  .every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
814
835
  taskStale: projected.frontier.unknown
@@ -826,6 +847,185 @@ async function independence({ repoRoot, requestedPlanKey }) {
826
847
  return result;
827
848
  }
828
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
+ });
897
+ const [sensorEvidence, proposalEvidence] = await Promise.all([
898
+ collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
899
+ collectSeamProposalEvidenceBundle({ cwd: repoRoot, querySet }),
900
+ ]);
901
+ const artifact = compileSeamProposalArtifact({
902
+ independenceArtifact,
903
+ witnessSet,
904
+ plan: member.plan,
905
+ compiledAt: new Date().toISOString(),
906
+ sensorEvidence,
907
+ evidence: proposalEvidence.evidence,
908
+ rawCollected: proposalEvidence.raw_collected,
909
+ });
910
+ const { ref } = await writeTodoSeamProposalArtifact({ repoRoot, artifact });
911
+ const verdictCounts = {
912
+ seam_candidate: artifact.decisions.filter(({ verdict }) => verdict === 'seam_candidate').length,
913
+ intentional_serial: artifact.decisions
914
+ .filter(({ verdict }) => verdict === 'intentional_serial').length,
915
+ unknown_requires_evidence: artifact.decisions
916
+ .filter(({ verdict }) => verdict === 'unknown_requires_evidence').length,
917
+ };
918
+ const result = {
919
+ schema: 'lattice.seam_proposal_compile_result.v1',
920
+ project_id: artifact.project_id,
921
+ plan_key: artifact.plan_key,
922
+ plan_version: artifact.source_binding.plan_version,
923
+ base_sha: artifact.source_binding.base_sha,
924
+ artifact_ref: ref,
925
+ component_count: artifact.decisions.length,
926
+ conflict_resource_count: artifact.decisions
927
+ .reduce((count, decision) => count + decision.conflicts.length, 0),
928
+ verdict_counts: verdictCounts,
929
+ result_digest: '',
930
+ };
931
+ result.result_digest = todoSelfDigest(result, 'result_digest');
932
+ return result;
933
+ }
934
+
935
+ function summarizeSeamProposalDecision(decision) {
936
+ return {
937
+ component_id: decision.component_id,
938
+ verdict: decision.verdict,
939
+ task_ids: decision.task_ids,
940
+ conflicts: decision.conflicts.map(({
941
+ resource_id: resourceId, kind, target, task_pairs: taskPairs,
942
+ }) => ({
943
+ resource_id: resourceId,
944
+ kind,
945
+ target,
946
+ task_pairs: taskPairs,
947
+ })),
948
+ proposed_surfaces: decision.seam_candidate?.proposed_surfaces ?? [],
949
+ affected_tests: decision.seam_candidate?.affected_tests ?? [],
950
+ limits: decision.seam_candidate?.limits ?? [],
951
+ reasons: decision.reasons,
952
+ unknowns: decision.unknowns,
953
+ };
954
+ }
955
+
956
+ async function seamProposal({ repoRoot, requestedPlanKey }) {
957
+ const store = await readTodoStore({ repoRoot });
958
+ if (requestedPlanKey !== null
959
+ && !store.members.some(({ descriptor }) => descriptor.plan_key === requestedPlanKey)) {
960
+ throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, {
961
+ plan_key: requestedPlanKey,
962
+ });
963
+ }
964
+ const frontier = computeReadyFrontier(store);
965
+ const readyPlanKeys = [...new Set(frontier.map(({ plan_key: key }) => key))].sort();
966
+ const candidatePlanKeys = readyPlanKeys.length > 0
967
+ ? readyPlanKeys
968
+ : store.members.map(({ descriptor }) => descriptor.plan_key).sort();
969
+ if (requestedPlanKey === null && candidatePlanKeys.length > 1) {
970
+ throw new TodoStoreError('SEAM_PROPOSAL_PLAN_AMBIGUOUS', 'plan_selection_ambiguous', undefined, {
971
+ plan_keys: candidatePlanKeys,
972
+ ready_plan_keys: readyPlanKeys,
973
+ next_action: 'rerun_with_plan_flag',
974
+ });
975
+ }
976
+ const planKey = requestedPlanKey ?? candidatePlanKeys[0] ?? null;
977
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
978
+ const currentBaseSha = currentHeadSha(repoRoot);
979
+ const independenceArtifact = member === undefined
980
+ ? null : await readTodoIndependenceArtifact({ repoRoot, store, planKey });
981
+ const artifact = member === undefined
982
+ ? null : await readTodoSeamProposalArtifact({ repoRoot, store, planKey });
983
+
984
+ let coverage = 'verified';
985
+ if (artifact === null) coverage = 'missing';
986
+ else {
987
+ const binding = artifact.source_binding;
988
+ const independenceMatches = independenceArtifact !== null
989
+ && validateTodoIndependence(independenceArtifact)
990
+ && independenceArtifact.schema === binding.independence_schema
991
+ && independenceArtifact.result_digest === binding.independence_result_digest
992
+ && independenceArtifact.witness_set_digest === binding.witness_set_digest
993
+ && independenceArtifact.plan_version === binding.plan_version
994
+ && independenceArtifact.topology_digest === binding.topology_digest
995
+ && independenceArtifact.base_sha === binding.base_sha;
996
+ const planMatches = member !== undefined
997
+ && member.plan.plan_version === binding.plan_version
998
+ && member.plan.topology_digest === binding.topology_digest;
999
+ if (!independenceMatches || !planMatches) coverage = 'superseded';
1000
+ else if (binding.base_sha !== currentBaseSha) coverage = 'stale';
1001
+ }
1002
+
1003
+ const components = artifact?.decisions.map(summarizeSeamProposalDecision) ?? [];
1004
+ const result = {
1005
+ schema: SEAM_PROPOSAL_PROJECTION_SCHEMA,
1006
+ project_id: store.project_id,
1007
+ plan_key: planKey,
1008
+ coverage,
1009
+ compiled_base_sha: artifact?.source_binding.base_sha ?? null,
1010
+ current_base_sha: currentBaseSha,
1011
+ plan_version: artifact?.source_binding.plan_version ?? null,
1012
+ topology_digest: artifact?.source_binding.topology_digest ?? null,
1013
+ independence_result_digest: artifact?.source_binding.independence_result_digest ?? null,
1014
+ compiled_at: artifact?.compiled_at ?? null,
1015
+ guidance: selectSeamProposalGuidance({ coverage }),
1016
+ component_count: artifact === null ? null : components.length,
1017
+ conflict_resource_count: artifact === null ? null : components
1018
+ .reduce((count, component) => count + component.conflicts.length, 0),
1019
+ components,
1020
+ result_digest: '',
1021
+ };
1022
+ result.result_digest = todoSelfDigest(result, 'result_digest');
1023
+ if (!validateSeamProposalProjection(result)) {
1024
+ throw new TodoStoreError('SEAM_PROPOSAL_PROJECTION_INVALID', 'seam_proposal_projection_invalid');
1025
+ }
1026
+ return result;
1027
+ }
1028
+
829
1029
  async function readNarrative(repoRoot, ref) {
830
1030
  const canonicalRoot = await realpath(repoRoot);
831
1031
  const source = parseTodoSourceRef(ref);
@@ -1007,7 +1207,10 @@ function parseGanttDescriptor(bytes, descriptorRef) {
1007
1207
  * 一つの関数だけが組む。
1008
1208
  */
1009
1209
  export async function ganttLiveHeadDigest({ repoRoot, store }) {
1010
- const independence = await independenceForGantt({ repoRoot, store });
1210
+ const [independence, seamProposals] = await Promise.all([
1211
+ independenceForGantt({ repoRoot, store }),
1212
+ seamProposalsForGantt({ repoRoot, store }),
1213
+ ]);
1011
1214
  return digestTodoArtifact({
1012
1215
  schema: 'lattice.todo_gantt_live_head.v1',
1013
1216
  manifest_digest: store.manifest.manifest_digest,
@@ -1016,6 +1219,11 @@ export async function ganttLiveHeadDigest({ repoRoot, store }) {
1016
1219
  coverage: entry.coverage,
1017
1220
  frontier_digest: digestTodoArtifact(entry.frontier),
1018
1221
  })),
1222
+ seam_proposals: seamProposals.map((entry) => ({
1223
+ plan_key: entry.plan_key,
1224
+ coverage: entry.coverage,
1225
+ projection_digest: digestTodoArtifact(entry),
1226
+ })),
1019
1227
  });
1020
1228
  }
1021
1229
 
@@ -1030,7 +1238,7 @@ async function independenceForGantt({ repoRoot, store }) {
1030
1238
  if (artifact === null) continue;
1031
1239
  // 記録があるplanが1つでもあれば鮮度の判定にHEADが要る。
1032
1240
  if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
1033
- const changedPaths = artifact.base_sha !== currentBaseSha
1241
+ const changedPaths = artifact.base_sha !== null && artifact.base_sha !== currentBaseSha
1034
1242
  ? changedPathsSince(repoRoot, artifact.base_sha) : null;
1035
1243
  const projected = projectIndependenceFrontier({
1036
1244
  artifact,
@@ -1052,6 +1260,59 @@ async function independenceForGantt({ repoRoot, store }) {
1052
1260
  return projections.length === 0 ? null : projections;
1053
1261
  }
1054
1262
 
1263
+ /**
1264
+ * 図が描く全planのseam提案記録を読む。生成は行わず、記録が無いplanもmissing guidanceを
1265
+ * 持つ投影として残すので、「提案対象なし」と「まだ生成していない」を混同しない。
1266
+ */
1267
+ async function seamProposalsForGantt({ repoRoot, store }) {
1268
+ let currentBaseSha = null;
1269
+ const projections = [];
1270
+ for (const member of store.members) {
1271
+ const planKey = member.plan.plan_key;
1272
+ const artifact = await readTodoSeamProposalArtifact({ repoRoot, store, planKey });
1273
+ if (artifact === null) {
1274
+ projections.push({
1275
+ project_id: member.plan.project_id,
1276
+ plan_key: planKey,
1277
+ coverage: 'missing',
1278
+ guidance: selectSeamProposalGuidance({ coverage: 'missing' }),
1279
+ component_count: null,
1280
+ conflict_resource_count: null,
1281
+ components: [],
1282
+ });
1283
+ continue;
1284
+ }
1285
+
1286
+ if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
1287
+ const independenceArtifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
1288
+ const binding = artifact.source_binding;
1289
+ const independenceMatches = independenceArtifact !== null
1290
+ && validateTodoIndependence(independenceArtifact)
1291
+ && independenceArtifact.schema === binding.independence_schema
1292
+ && independenceArtifact.result_digest === binding.independence_result_digest
1293
+ && independenceArtifact.witness_set_digest === binding.witness_set_digest
1294
+ && independenceArtifact.plan_version === binding.plan_version
1295
+ && independenceArtifact.topology_digest === binding.topology_digest
1296
+ && independenceArtifact.base_sha === binding.base_sha;
1297
+ const planMatches = member.plan.plan_version === binding.plan_version
1298
+ && member.plan.topology_digest === binding.topology_digest;
1299
+ const coverage = !independenceMatches || !planMatches ? 'superseded'
1300
+ : binding.base_sha !== currentBaseSha ? 'stale' : 'verified';
1301
+ const components = artifact.decisions.map(summarizeSeamProposalDecision);
1302
+ projections.push({
1303
+ project_id: member.plan.project_id,
1304
+ plan_key: planKey,
1305
+ coverage,
1306
+ guidance: selectSeamProposalGuidance({ coverage }),
1307
+ component_count: components.length,
1308
+ conflict_resource_count: components
1309
+ .reduce((count, component) => count + component.conflicts.length, 0),
1310
+ components,
1311
+ });
1312
+ }
1313
+ return projections;
1314
+ }
1315
+
1055
1316
  export async function renderTodoGanttForProject({
1056
1317
  repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
1057
1318
  scope = DEFAULT_GANTT_SCOPE,
@@ -1064,12 +1325,17 @@ export async function renderTodoGanttForProject({
1064
1325
  const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
1065
1326
  const topology = mergedTopology(store);
1066
1327
  const chain = projectTodoChainV1(topology);
1067
- const independence = await independenceForGantt({ repoRoot, store });
1068
- const layout = layoutTodoGantt(store, chain, { scope, independence });
1328
+ const [independence, seamProposals] = await Promise.all([
1329
+ independenceForGantt({ repoRoot, store }),
1330
+ seamProposalsForGantt({ repoRoot, store }),
1331
+ ]);
1332
+ const layout = layoutTodoGantt(store, chain, { scope, independence, seamProposals });
1069
1333
  // When the diagram hides history, the page also carries the full diagram so
1070
1334
  // the reader can bring it back in place. Nothing is hidden under `all`.
1071
1335
  const expandedLayout = layout.scope.folded_task_count === 0
1072
- ? null : layoutTodoGantt(store, chain, { scope: 'all', independence });
1336
+ ? null : layoutTodoGantt(store, chain, {
1337
+ scope: 'all', independence, seamProposals,
1338
+ });
1073
1339
  const narrative = await loadNarratives(store, repoRoot);
1074
1340
  const anchorOutcomes = verifyNarrativeAnchors({
1075
1341
  readModel: store,
@@ -1362,8 +1628,18 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
1362
1628
  action = (repoRoot) => independence({ repoRoot, requestedPlanKey: null });
1363
1629
  } else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'independence'
1364
1630
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
1365
- && (argv.length === 3 || argv[3] === '--json')) {
1631
+ && (argv.length === 3 || argv[3] === '--json')) {
1366
1632
  action = (repoRoot) => independence({ repoRoot, requestedPlanKey: argv[2] });
1633
+ } else if (argv.length === 4 && argv[0] === 'seam-proposal' && argv[1] === 'compile'
1634
+ && argv[2] === '--plan' && isTodoIdentifier(argv[3])) {
1635
+ action = (repoRoot) => seamProposalCompile({ repoRoot, planKey: argv[3] });
1636
+ } else if ((argv.length === 1 && argv[0] === 'seam-proposal')
1637
+ || (argv.length === 2 && argv[0] === 'seam-proposal' && argv[1] === '--json')) {
1638
+ action = (repoRoot) => seamProposal({ repoRoot, requestedPlanKey: null });
1639
+ } else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'seam-proposal'
1640
+ && argv[1] === '--plan' && isTodoIdentifier(argv[2])
1641
+ && (argv.length === 3 || argv[3] === '--json')) {
1642
+ action = (repoRoot) => seamProposal({ repoRoot, requestedPlanKey: argv[2] });
1367
1643
  } else if ((argv.length === 1 && argv[0] === 'verify')
1368
1644
  || (argv.length === 2 && argv[0] === 'verify' && argv[1] === '--json')) {
1369
1645
  action = (repoRoot) => verify({ repoRoot, requestedPlanKey: null });
@@ -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