@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/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, 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 !== 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,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
- readyCount: ready.length,
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 independenceForGantt({ repoRoot, store });
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 independenceForGantt({ repoRoot, store });
1069
- const layout = layoutTodoGantt(store, chain, { scope, independence });
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, { scope: 'all', independence });
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
- && (argv.length === 3 || argv[3] === '--json')) {
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 });
@@ -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