@quolu/lattice 0.50.0 → 0.51.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.
Files changed (42) hide show
  1. package/bin/lattice-work-order-adapter.mjs +20 -0
  2. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -0
  3. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -0
  4. package/package.json +5 -2
  5. package/src/boundary-observation-compiler-v2.mjs +1 -1
  6. package/src/cli-help.mjs +29 -2
  7. package/src/rc3-actual-dogfood.mjs +6 -2
  8. package/src/rc3-scripted-campaign.mjs +37 -10
  9. package/src/rc4-stage1-dogfood.mjs +6 -2
  10. package/src/runtime-adapter-registry.mjs +21 -7
  11. package/src/runtime-cli.mjs +476 -34
  12. package/src/runtime-contracts.mjs +59 -13
  13. package/src/runtime-controller-protocol.mjs +48 -3
  14. package/src/runtime-decision-verifier.mjs +70 -0
  15. package/src/runtime-diff-observer.mjs +66 -4
  16. package/src/runtime-direct-os-observer.mjs +25 -8
  17. package/src/runtime-driver-state.mjs +162 -0
  18. package/src/runtime-engine.mjs +37 -6
  19. package/src/runtime-front-end.mjs +39 -1
  20. package/src/runtime-managed-supervisor.mjs +80 -14
  21. package/src/runtime-multi-epoch-store.mjs +87 -14
  22. package/src/runtime-pull-intake.mjs +1188 -0
  23. package/src/runtime-work-order-contracts.mjs +91 -0
  24. package/src/runtime-work-order-controller.mjs +1167 -0
  25. package/src/seam-proposal-queries.mjs +1 -1
  26. package/src/todo-cli.mjs +199 -5
  27. package/src/todo-contracts.mjs +4 -1
  28. package/src/todo-gantt-html-independence.mjs +3 -2
  29. package/src/todo-gantt-html-shared.mjs +1 -2
  30. package/src/todo-gantt-html-style.mjs +13 -0
  31. package/src/todo-gantt-html.mjs +15 -2
  32. package/src/todo-gantt-layout.mjs +71 -1
  33. package/src/todo-gantt-live.mjs +12 -1
  34. package/src/todo-gantt-nested.mjs +243 -0
  35. package/src/todo-gantt-svg.mjs +80 -5
  36. package/src/todo-independence-contracts.mjs +73 -7
  37. package/src/todo-independence-guidance.mjs +30 -1
  38. package/src/todo-independence.mjs +89 -7
  39. package/src/todo-revision.mjs +1 -1
  40. package/src/todo-split.mjs +472 -0
  41. package/src/todo-store-git-transaction.mjs +418 -0
  42. package/src/todo-store.mjs +54 -0
@@ -5,7 +5,7 @@ import { SENSOR_QUERY_OPERATIONS } from './runtime-contracts.mjs';
5
5
  import { collectSensorEvidence, portableSensorOutcome } from './sensor-adapter.mjs';
6
6
  import { todoSelfDigest } from './todo-contracts.mjs';
7
7
 
8
- const CONFLICT_KINDS = new Set(['symbol', 'path', 'state', 'effect']);
8
+ const CONFLICT_KINDS = new Set(['symbol', 'path', 'state', 'effect', 'line']);
9
9
  const QUERYABLE_KINDS = new Set(['symbol', 'path']);
10
10
  const SYMBOL_OPERATIONS = Object.freeze(['query', 'callers', 'callees', 'impact']);
11
11
  const SENSOR_OPERATIONS = new Set(SENSOR_QUERY_OPERATIONS);
package/src/todo-cli.mjs CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  readTodoIndependenceArtifact,
51
51
  readTodoSeamProposalArtifact,
52
52
  readTodoStore,
53
+ resolveTodoStartRetractionBinding,
53
54
  readTodoWitnessSet,
54
55
  todoWitnessRef,
55
56
  writeTodoWitnessSet,
@@ -60,6 +61,7 @@ import {
60
61
  verifyEffectivePhaseTodoRevisionSources,
61
62
  verifyTodoRevisionSources,
62
63
  } from './todo-store.mjs';
64
+ import { withStartRetractionGuard } from './runtime-pull-intake.mjs';
63
65
  import {
64
66
  appendTodoExtraction,
65
67
  compileTodoExtraction,
@@ -116,6 +118,10 @@ import {
116
118
  parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
117
119
  validateTodoRevision, validateTodoRevisionSet,
118
120
  } from './todo-revision.mjs';
121
+ import {
122
+ compileTodoSplit,
123
+ prepareTodoSplitWitnessMigration,
124
+ } from './todo-split.mjs';
119
125
  import {
120
126
  appendTodoNote,
121
127
  readTodoNoteContext,
@@ -124,6 +130,7 @@ import {
124
130
  readTodoPlanNotesForStatus,
125
131
  } from './todo-note-store.mjs';
126
132
  import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
133
+ import { commitTodoStoreMutation } from './todo-store-git-transaction.mjs';
127
134
 
128
135
  const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
129
136
  const DEFAULT_GANTT_SCOPE = 'live';
@@ -177,7 +184,7 @@ function typedFailure(stderr, error) {
177
184
  const TODO_COMMAND_NAMES = Object.freeze([
178
185
  'status', 'show', 'note', 'bindings', 'independence', 'seam-profile', 'seam-proposal',
179
186
  'verify', 'snapshot', 'gantt', 'dashboard', 'phase', 'migrate', 'start', 'block',
180
- 'unblock', 'done', 'reopen', 'evidence', 'revise', 'revise-phase', 'revise-set',
187
+ 'unblock', 'done', 'reopen', 'evidence', 'split', 'revise', 'revise-phase', 'revise-set',
181
188
  ]);
182
189
 
183
190
  function typedArgumentFailure(stderr, code, message, detail) {
@@ -186,6 +193,30 @@ function typedArgumentFailure(stderr, code, message, detail) {
186
193
  return 2;
187
194
  }
188
195
 
196
+ function supportsAtomicStoreCommit(argv) {
197
+ const command = argv[0];
198
+ if (command === 'note') return argv[1] !== 'list';
199
+ if (command === 'independence') {
200
+ return argv[1] === 'mode'
201
+ || (argv[1] === 'witness' && ['migrate', 'scaffold'].includes(argv[2]));
202
+ }
203
+ if (command === 'snapshot') return argv[1] === '--rebuild';
204
+ if (command === 'migrate') return !argv.includes('--dry-run') && !argv.includes('--schema');
205
+ if (['revise', 'split', 'revise-set', 'revise-phase', 'start', 'retract', 'block',
206
+ 'unblock', 'done', 'reopen'].includes(command)) return true;
207
+ if (command === 'evidence') return argv[1] === 'promote';
208
+ if (command === 'phase') return argv[1] !== 'status';
209
+ return false;
210
+ }
211
+
212
+ function atomicStoreCommitUnsupported(stderr, argv) {
213
+ return typedArgumentFailure(stderr, 'STORE_COMMIT_UNSUPPORTED',
214
+ 'todo_command_does_not_mutate_only_the_store', {
215
+ command: argv.slice(0, 3),
216
+ next_action: 'remove_--commit-store_or_use_a_supported_todo_write_command',
217
+ });
218
+ }
219
+
189
220
  function resolveRepoRoot(cwd) {
190
221
  try {
191
222
  return execFileSync('git', ['rev-parse', '--show-toplevel'], {
@@ -786,6 +817,23 @@ async function startTask({
786
817
  payload: { override_reason: overrideReason }, evidenceRef: null, advisory, noteContext });
787
818
  }
788
819
 
820
+ async function retractStart({ repoRoot, env, planKey, taskId, reason }) {
821
+ const actor = mutationActor(env);
822
+ const store = await readTodoStore({ repoRoot });
823
+ const binding = resolveTodoStartRetractionBinding(store, { planKey, taskId, actor });
824
+ return withStartRetractionGuard({
825
+ repoRoot,
826
+ planKey,
827
+ taskId: binding.task_id,
828
+ activationEventDigest: binding.activation_event_digest,
829
+ action: () => mutate({
830
+ repoRoot, env, planKey, taskId: binding.task_id, kind: 'start_retracted',
831
+ payload: { reason, target_start_digest: binding.activation_event_digest },
832
+ evidenceRef: null,
833
+ }),
834
+ });
835
+ }
836
+
789
837
  function validatePhaseDecisionInput(value, outcome) {
790
838
  const keys = outcome === 'accept'
791
839
  ? ['schema', 'review_event_digest', 'decision_evidence', 'evidence_slots', 'input_digest']
@@ -1322,6 +1370,69 @@ async function revisePhase({ repoRoot, env, planKey, inputRef }) {
1322
1370
  revision, actor: mutationActor(env), recordedAt: new Date().toISOString() });
1323
1371
  }
1324
1372
 
1373
+ async function splitTodo({ repoRoot, env, planKey, inputRef }) {
1374
+ const proposal = await readMigrationInput(repoRoot, inputRef, { requireValid: false });
1375
+ const store = await readTodoStore({ repoRoot });
1376
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
1377
+ if (member === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active');
1378
+ const witnessSet = await readTodoWitnessSet({ repoRoot, planKey });
1379
+ if (witnessSet === null) {
1380
+ throw new TodoStoreError('WITNESS_MIGRATION_UNAVAILABLE', 'witness_set_absent', undefined, {
1381
+ witness_ref: todoWitnessRef(planKey),
1382
+ next_action: `lattice todo independence witness scaffold --plan ${planKey} --input <draft>`,
1383
+ });
1384
+ }
1385
+ const compiled = await compileTodoSplit({ repoRoot, member, proposal });
1386
+ const actor = mutationActor(env);
1387
+ const recordedAt = new Date().toISOString();
1388
+ // splitのmigrationは既存taskへのidentity写像だけである。宣言を純粋に移行・検査し、
1389
+ // 同じcanonical bytesをwitness先へ書けることまでapply前に確定する。これにより、
1390
+ // witness失敗をrevision適用後に返してplan/sourceだけ進んだ状態を作らない。
1391
+ const preparedWitness = prepareTodoSplitWitnessMigration({
1392
+ witnessSet, revision: compiled.revision,
1393
+ });
1394
+ const { ref: witnessRef } = await writeTodoWitnessSet({
1395
+ repoRoot, witnessSet: preparedWitness.witnessSet,
1396
+ });
1397
+ const witnessMigration = {
1398
+ schema: 'lattice.todo_witness_migrate_result.v1',
1399
+ project_id: store.project_id,
1400
+ plan_key: planKey,
1401
+ plan_version: compiled.revision.desired_plan.plan_version,
1402
+ witness_ref: witnessRef,
1403
+ migrated_count: preparedWitness.migrated_count,
1404
+ removed_count: preparedWitness.removed_count,
1405
+ unchanged_count: preparedWitness.unchanged_count,
1406
+ witness_set_digest: preparedWitness.witnessSet.witness_set_digest,
1407
+ result_digest: '',
1408
+ };
1409
+ witnessMigration.result_digest = todoSelfDigest(witnessMigration, 'result_digest');
1410
+ const receipt = compiled.revision.schema === 'lattice.phase_todo_revision.v3'
1411
+ ? await applyPhaseTodoRevision({
1412
+ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
1413
+ revision: compiled.revision, actor, recordedAt,
1414
+ })
1415
+ : await applyTodoRevision({
1416
+ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
1417
+ revision: compiled.revision, actor, recordedAt,
1418
+ });
1419
+ const result = {
1420
+ schema: 'lattice.todo_split_result.v1',
1421
+ project_id: store.project_id,
1422
+ plan_key: planKey,
1423
+ predecessor_task_id: proposal.task_id,
1424
+ residual_task_id: proposal.task_id,
1425
+ extracted_task_ids: compiled.extracted_task_ids,
1426
+ plan_version: compiled.revision.desired_plan.plan_version,
1427
+ revision_digest: compiled.revision.revision_digest,
1428
+ revision_receipt_digest: receipt.receipt_digest ?? receipt.result_digest,
1429
+ witness_migration_result_digest: witnessMigration.result_digest,
1430
+ result_digest: '',
1431
+ };
1432
+ result.result_digest = todoSelfDigest(result, 'result_digest');
1433
+ return result;
1434
+ }
1435
+
1325
1436
  async function status({ repoRoot }) {
1326
1437
  const store = await readTodoStore({ repoRoot });
1327
1438
  return projectTodoStatus(store, {
@@ -1530,12 +1641,18 @@ async function independenceCompile({ repoRoot, planKey, inputRef }) {
1530
1641
  const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
1531
1642
  if (!member) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, { plan_key: planKey });
1532
1643
 
1644
+ // 前回artifactを渡して膨張の履歴を継ぐ。**例外を握り潰さない。**
1645
+ // `readTodoIndependenceArtifact` は「欠落だけnull・旧版はlegacy marker・壊れた記録は
1646
+ // INDEPENDENCE_ARTIFACT_INVALIDでtyped fail」を既に区別している。ここでcatchすると
1647
+ // **corrupt/permission/I-Oまで「初回」へ化けて履歴が黙って切れる**(suzune の監査で実測・room [1148])。
1648
+ const previousArtifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
1533
1649
  const artifact = compileTodoIndependence({
1534
1650
  witnessSet,
1535
1651
  plan: member.plan,
1536
1652
  baseSha,
1537
1653
  compiledAt: new Date().toISOString(),
1538
1654
  sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
1655
+ previousArtifact,
1539
1656
  });
1540
1657
  const { ref } = await writeTodoIndependenceArtifact({ repoRoot, artifact });
1541
1658
 
@@ -2218,7 +2335,35 @@ async function independenceForGantt({ repoRoot, store }) {
2218
2335
  const projections = [];
2219
2336
  for (const member of store.members) {
2220
2337
  const planKey = member.plan.plan_key;
2221
- const artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
2338
+ let artifact = null;
2339
+ let unreadableReason = null;
2340
+ try {
2341
+ artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
2342
+ } catch (error) {
2343
+ if (!(error instanceof TodoStoreError)) throw error;
2344
+ unreadableReason = `${error.code}:${error.detail?.reason ?? error.message}`;
2345
+ }
2346
+ if (unreadableReason !== null) {
2347
+ if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
2348
+ const projected = projectIndependenceFrontier({
2349
+ artifact: null,
2350
+ readyTaskIds: frontier.filter((task) => task.plan_key === planKey)
2351
+ .map(({ task_id: taskId }) => taskId),
2352
+ activeTaskIds: status.active_set.filter((task) => task.plan_key === planKey)
2353
+ .map(({ task_id: taskId }) => taskId),
2354
+ plan: member.plan,
2355
+ currentBaseSha,
2356
+ changedPaths: null,
2357
+ });
2358
+ projections.push({
2359
+ project_id: member.plan.project_id,
2360
+ plan_key: planKey,
2361
+ coverage: 'unreadable',
2362
+ unreadable_reason: unreadableReason,
2363
+ frontier: projected.frontier,
2364
+ });
2365
+ continue;
2366
+ }
2222
2367
  if (artifact === null) continue;
2223
2368
  // 記録があるplanが1つでもあれば鮮度の判定にHEADが要る。
2224
2369
  if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
@@ -2238,6 +2383,7 @@ async function independenceForGantt({ repoRoot, store }) {
2238
2383
  project_id: member.plan.project_id,
2239
2384
  plan_key: planKey,
2240
2385
  coverage: projected.coverage,
2386
+ unreadable_reason: null,
2241
2387
  frontier: projected.frontier,
2242
2388
  });
2243
2389
  }
@@ -2253,12 +2399,29 @@ async function seamProposalsForGantt({ repoRoot, store }) {
2253
2399
  const projections = [];
2254
2400
  for (const member of store.members) {
2255
2401
  const planKey = member.plan.plan_key;
2256
- const artifact = await readTodoSeamProposalArtifact({ repoRoot, store, planKey });
2402
+ let artifact = null;
2403
+ try {
2404
+ artifact = await readTodoSeamProposalArtifact({ repoRoot, store, planKey });
2405
+ } catch (error) {
2406
+ if (!(error instanceof TodoStoreError)) throw error;
2407
+ projections.push({
2408
+ project_id: member.plan.project_id,
2409
+ plan_key: planKey,
2410
+ coverage: 'superseded',
2411
+ unreadable_reason: `${error.code}:${error.detail?.reason ?? error.message}`,
2412
+ guidance: selectSeamProposalGuidance({ coverage: 'superseded' }),
2413
+ component_count: null,
2414
+ conflict_resource_count: null,
2415
+ components: [],
2416
+ });
2417
+ continue;
2418
+ }
2257
2419
  if (artifact === null) {
2258
2420
  projections.push({
2259
2421
  project_id: member.plan.project_id,
2260
2422
  plan_key: planKey,
2261
2423
  coverage: 'missing',
2424
+ unreadable_reason: null,
2262
2425
  guidance: selectSeamProposalGuidance({ coverage: 'missing' }),
2263
2426
  component_count: null,
2264
2427
  conflict_resource_count: null,
@@ -2268,7 +2431,14 @@ async function seamProposalsForGantt({ repoRoot, store }) {
2268
2431
  }
2269
2432
 
2270
2433
  if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
2271
- const independenceArtifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
2434
+ let independenceArtifact = null;
2435
+ let unreadableReason = null;
2436
+ try {
2437
+ independenceArtifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
2438
+ } catch (error) {
2439
+ if (!(error instanceof TodoStoreError)) throw error;
2440
+ unreadableReason = `${error.code}:${error.detail?.reason ?? error.message}`;
2441
+ }
2272
2442
  const binding = artifact.source_binding;
2273
2443
  const independenceMatches = independenceArtifact !== null
2274
2444
  && validateTodoIndependence(independenceArtifact)
@@ -2287,6 +2457,7 @@ async function seamProposalsForGantt({ repoRoot, store }) {
2287
2457
  project_id: member.plan.project_id,
2288
2458
  plan_key: planKey,
2289
2459
  coverage,
2460
+ unreadable_reason: unreadableReason,
2290
2461
  guidance: selectSeamProposalGuidance({ coverage }),
2291
2462
  component_count: components.length,
2292
2463
  conflict_resource_count: components
@@ -2538,6 +2709,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2538
2709
  throw new TypeError('runTodoCli optionsが不正');
2539
2710
  }
2540
2711
 
2712
+ const atomicCommit = argv.at(-1) === '--commit-store';
2713
+ if (atomicCommit) argv = argv.slice(0, -1);
2714
+ if (atomicCommit && ((argv[1] === '--schema' && argv[2] === '--json')
2715
+ || (argv[0] === 'dashboard' && argv[1] === 'remove'))) {
2716
+ return atomicStoreCommitUnsupported(stderr, argv);
2717
+ }
2718
+
2541
2719
  if (argv[0] === 'migrate' && argv[1] === '--input'
2542
2720
  && typeof argv[2] === 'string' && path.isAbsolute(argv[2])) {
2543
2721
  return typedArgumentFailure(stderr, 'INPUT_OUTSIDE_REPOSITORY', 'absolute_input_path_rejected', {
@@ -2731,6 +2909,10 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2731
2909
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2732
2910
  && argv[3] === '--input' && isTodoRef(argv[4])) {
2733
2911
  action = (repoRoot) => revise({ repoRoot, env, planKey: argv[2], inputRef: argv[4] });
2912
+ } else if (argv.length === 5 && argv[0] === 'split'
2913
+ && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2914
+ && argv[3] === '--input' && isTodoRef(argv[4])) {
2915
+ action = (repoRoot) => splitTodo({ repoRoot, env, planKey: argv[2], inputRef: argv[4] });
2734
2916
  } else if (argv.length === 3 && argv[0] === 'revise-set'
2735
2917
  && argv[1] === '--input' && isTodoRef(argv[2])) {
2736
2918
  action = (repoRoot) => reviseSet({ repoRoot, env, inputRef: argv[2] });
@@ -2784,6 +2966,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2784
2966
  action = (repoRoot) => startTask({ repoRoot, env, planKey: argv[2], taskId: argv[4],
2785
2967
  overrideReason, parallelFrontier: argv.length === 6,
2786
2968
  serialConfirmed: argv.length === 8 });
2969
+ } else if (argv.length === 7 && argv[0] === 'retract'
2970
+ && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2971
+ && argv[3] === '--task' && isTodoIdentifier(argv[4])
2972
+ && argv[5] === '--reason' && argv[6].length > 0) {
2973
+ action = (repoRoot) => retractStart({
2974
+ repoRoot, env, planKey: argv[2], taskId: argv[4], reason: argv[6],
2975
+ });
2787
2976
  } else if (argv.length === 7 && argv[0] === 'block'
2788
2977
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
2789
2978
  && argv[3] === '--task' && isTodoIdentifier(argv[4])
@@ -2831,6 +3020,9 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2831
3020
  command, next_action: argumentHelp,
2832
3021
  });
2833
3022
  }
3023
+ if (atomicCommit && !supportsAtomicStoreCommit(argv)) {
3024
+ return atomicStoreCommitUnsupported(stderr, argv);
3025
+ }
2834
3026
 
2835
3027
  try {
2836
3028
  const repoRoot = resolveRepoRoot(cwd);
@@ -2840,7 +3032,9 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
2840
3032
  if (!ganttCommand && !dashboardAdopt && !migrationDryRun) {
2841
3033
  await ensureActiveProjectDashboard({ repoRoot, env });
2842
3034
  }
2843
- const result = await action(repoRoot);
3035
+ const result = atomicCommit
3036
+ ? await commitTodoStoreMutation({ repoRoot, argv, action, env })
3037
+ : await action(repoRoot);
2844
3038
  if (result !== null) stdout.write(`${JSON.stringify(result)}\n`);
2845
3039
  return 0;
2846
3040
  } catch (error) {
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { isCanonicalUtcTimestamp } from './timestamp-contract.mjs';
3
3
 
4
4
  export const TODO_EVENT_KINDS = Object.freeze([
5
- 'plan_genesis', 'start', 'block', 'unblock', 'done', 'reopen',
5
+ 'plan_genesis', 'start', 'start_retracted', 'block', 'unblock', 'done', 'reopen',
6
6
  'phase_review', 'phase_accept', 'phase_reject', 'phase_reopen',
7
7
  // ADR 0148: 監査していない歴史を「監査なしで閉じた」として明示的に閉じるための専用kind。
8
8
  // phase_review/accept/reject/reopenと同じv3 tail event shape(phase_id持ち)に収め、
@@ -507,6 +507,9 @@ function validPayload(event) {
507
507
  && nullableText(payload.reason) && payload.reason !== null;
508
508
  }
509
509
  if (event.kind === 'start') return exactRecord(payload, ['override_reason']) && nullableText(payload.override_reason);
510
+ if (event.kind === 'start_retracted') return exactRecord(payload, ['reason', 'target_start_digest'])
511
+ && nullableText(payload.reason) && payload.reason !== null
512
+ && isTodoDigest(payload.target_start_digest);
510
513
  if (event.kind === 'block') return exactRecord(payload, ['reason']) && nullableText(payload.reason) && payload.reason !== null;
511
514
  if (event.kind === 'unblock') return exactRecord(payload, []);
512
515
  if (event.kind === 'done' && payload?.done_mode === 'authored') {
@@ -139,7 +139,8 @@ export function renderRightPane(
139
139
  ) {
140
140
  const lookup = presentationLookup(presentation);
141
141
  const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
142
- const nodeByKey = new Map(layout.nodes.map((node) => [refKey(node.ref), node]));
142
+ const semanticNodes = [...layout.nodes, ...(layout.hierarchy_nodes ?? [])];
143
+ const nodeByKey = new Map(semanticNodes.map((node) => [refKey(node.ref), node]));
143
144
  const folds = foldIndex(layout);
144
145
  const incoming = new Map(sections.map((section) => [refKey(section.ref), []]));
145
146
  const outgoing = new Map(sections.map((section) => [refKey(section.ref), []]));
@@ -163,7 +164,7 @@ export function renderRightPane(
163
164
  const counts = { pending: 0, 'in-progress': 0, blocked: 0, done: 0 };
164
165
  for (const section of sections) counts[section.state.status] += 1;
165
166
  const active = sections.filter((section) => section.state.status === 'in-progress');
166
- const ready = layout.nodes.filter((node) => node.visibility.next_ready);
167
+ const ready = semanticNodes.filter((node) => node.visibility.next_ready);
167
168
  const independenceSummary = summarizeIndependence(layout);
168
169
  const readyHeadline = ready.length > 1
169
170
  ? `<p class="readiness-note"><strong>同時dispatch推奨:</strong> ${ready.length}工程。${escapeHtmlText(dispatchBasis(independenceSummary))}</p>`
@@ -90,8 +90,7 @@ export function presentationLookup(presentation) {
90
90
  }
91
91
 
92
92
  export function taskReference(section, lookup) {
93
- const number = lookup.taskNumbers.get(refKey(section.ref));
94
- return number === undefined ? `ID ${section.task.task_id}` : `工程 ${number.display_number}`;
93
+ return `工程 ${section.task.task_id}`;
95
94
  }
96
95
 
97
96
  export function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new Set()) {
@@ -116,3 +116,16 @@ button.fold-chip[aria-expanded="true"]{border-color:var(--text-primary)}
116
116
  .lane-dimmed{opacity:.35}
117
117
  @media(max-width:900px){body{display:block;height:auto}.shell{display:block}.pane-divider{display:none}.gantt-pane,.narrative-pane{height:70vh}.gantt-pane{border-bottom:1px solid var(--border)}}
118
118
  `;
119
+
120
+ // 階層を持つplanだけが読み込む。親無しplanのHTML/CSS bytesを変えないため、基底CSSへは混ぜない。
121
+ export const NESTED_CSS = `
122
+ .nested-task-panel{filter:drop-shadow(0 4px 12px rgba(11,11,11,.18))}
123
+ .nested-task-surface{fill:var(--surface-1);stroke:var(--text-secondary);stroke-width:1.5}
124
+ .nested-task-label{fill:var(--text-primary);font-size:12px;font-weight:650}
125
+ .nested-task-link{fill:none;stroke:var(--text-secondary);stroke-width:1.5;stroke-dasharray:4 3}
126
+ .nested-task-diagram{outline:1px solid var(--border);background:var(--surface-1)}
127
+ .nested-task-toggle{cursor:pointer}
128
+ .nested-task-toggle rect{fill:var(--surface-1);stroke:var(--text-secondary);stroke-width:1.5}
129
+ .nested-task-toggle text{fill:var(--text-primary);font-size:14px;font-weight:650}
130
+ .nested-task-toggle:focus rect{stroke:var(--text-primary);stroke-width:2.5}
131
+ `;
@@ -5,7 +5,7 @@ import { serializeJsonForScript } from './todo-markdown-renderer.mjs';
5
5
  import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
6
6
  import { renderDiagramLegend, renderRightPane } from './todo-gantt-html-independence.mjs';
7
7
  import { escapeHtmlAttribute, escapeHtmlText, refKey } from './todo-gantt-html-shared.mjs';
8
- import { CSS } from './todo-gantt-html-style.mjs';
8
+ import { CSS, NESTED_CSS } from './todo-gantt-html-style.mjs';
9
9
 
10
10
  export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v19';
11
11
  export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
@@ -188,6 +188,18 @@ const CONTROLLER = `
188
188
  })();
189
189
  `;
190
190
 
191
+ const NESTED_CONTROLLER = `
192
+ (()=>{
193
+ const root=document.querySelector('[data-gantt-root]');if(!root)return;
194
+ const toggles=[...root.querySelectorAll('[data-nested-toggle-for]')];
195
+ const panels=[...root.querySelectorAll('[data-nested-panel-for]')];
196
+ const panelFor=(key)=>panels.find(panel=>panel.dataset.nestedPanelFor===key);
197
+ const toggle=(control)=>{const key=control.dataset.nestedToggleFor;const panel=panelFor(key);if(!panel)return;const open=panel.hasAttribute('hidden');panel.toggleAttribute('hidden',!open);const link=[...root.querySelectorAll('[data-nested-link-for]')].find(candidate=>candidate.dataset.nestedLinkFor===key);link?.toggleAttribute('hidden',!open);control.setAttribute('aria-expanded',String(open));const mark=control.querySelector('text');if(mark)mark.textContent=open?'−':'+';};
198
+ root.addEventListener('click',event=>{const control=event.target.closest('[data-nested-toggle-for]');if(!control||!root.contains(control))return;event.preventDefault();event.stopPropagation();toggle(control);});
199
+ root.addEventListener('keydown',event=>{const control=event.target.closest('[data-nested-toggle-for]');if(!control||!root.contains(control)||(event.key!=='Enter'&&event.key!==' '))return;event.preventDefault();event.stopPropagation();toggle(control);});
200
+ })();
201
+ `;
202
+
191
203
  export function renderTodoGanttHtml({
192
204
  readModel, layout, narratives = [], anchorOutcomes = [], presentation = null, metadata = {},
193
205
  expandedLayout = null, noteContexts = null, noteWarnings = [],
@@ -208,6 +220,7 @@ export function renderTodoGanttHtml({
208
220
  }
209
221
  const normalized = normalizeSections(readModel, narratives, anchorOutcomes, noteContexts);
210
222
  const displayName = projectDisplayName(readModel, metadata);
223
+ const hasHierarchy = layout?.hierarchy?.schema === 'lattice.todo_gantt_hierarchy.v1';
211
224
  const svg = renderTodoGanttSvg(layout, { presentation });
212
225
  // The expanded diagram travels with the page so the badge can bring the
213
226
  // history back without a round trip. A file:// artifact has nowhere to ask.
@@ -224,7 +237,7 @@ export function renderTodoGanttHtml({
224
237
  metadata,
225
238
  presentation,
226
239
  });
227
- const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong>${renderAuditPendingChip(readModel)}<button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
240
+ const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}${hasHierarchy ? NESTED_CSS : ''}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong>${renderAuditPendingChip(readModel)}<button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}${hasHierarchy ? NESTED_CONTROLLER : ''}</script></body></html>`;
228
241
  const htmlBytes = Buffer.byteLength(html, 'utf8');
229
242
  if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
230
243
  throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
@@ -1,4 +1,5 @@
1
1
  import { TODO_GANTT_SCOPES, projectTodoGanttScope } from './todo-gantt-scope.mjs';
2
+ import { buildTodoGanttHierarchy } from './todo-gantt-nested.mjs';
2
3
 
3
4
  const TASK_LIMIT = 2_000;
4
5
  const EDGE_LIMIT = 8_000;
@@ -521,7 +522,7 @@ function normalizeSeamProposals(value) {
521
522
  return { summary: { plans } };
522
523
  }
523
524
 
524
- export function layoutTodoGantt(readModel, chainProjection, options = {}) {
525
+ function layoutTodoGanttFlat(readModel, chainProjection, options = {}) {
525
526
  const scope = options.scope ?? 'live';
526
527
  if (!TODO_GANTT_SCOPES.includes(scope)) {
527
528
  fail('TODO_LAYOUT_INVALID_INPUT', `scope must be one of ${TODO_GANTT_SCOPES.join(', ')}`);
@@ -898,3 +899,72 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
898
899
  },
899
900
  };
900
901
  }
902
+
903
+ function applyHierarchySemantics(level, semanticByKey) {
904
+ const layout = {
905
+ ...level.layout,
906
+ nodes: level.layout.nodes.map((node) => {
907
+ const semantic = semanticByKey.get(refKey(node.ref));
908
+ return semantic === undefined ? node : { ...node, visibility: { ...semantic.visibility } };
909
+ }),
910
+ };
911
+ return {
912
+ layout,
913
+ children: level.children.map((child) => ({
914
+ ...child,
915
+ level: applyHierarchySemantics(child.level, semanticByKey),
916
+ })),
917
+ };
918
+ }
919
+
920
+ function descendantNodes(level) {
921
+ return level.children.flatMap((child) => [
922
+ ...child.level.layout.nodes,
923
+ ...descendantNodes(child.level),
924
+ ]);
925
+ }
926
+
927
+ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
928
+ const fullLayout = layoutTodoGanttFlat(readModel, chainProjection, options);
929
+ let nested;
930
+ try {
931
+ const visibleTaskKeys = (options.scope ?? 'live') === 'all' ? null
932
+ : new Set(fullLayout.nodes.map((node) => refKey(node.ref)));
933
+ nested = buildTodoGanttHierarchy(
934
+ readModel, options, layoutTodoGanttFlat, visibleTaskKeys,
935
+ );
936
+ } catch (error) {
937
+ if (error?.code !== 'TODO_LAYOUT_INVALID_HIERARCHY') throw error;
938
+ fail(error.code, error.message, error.detail);
939
+ }
940
+ if (nested === null) return fullLayout;
941
+
942
+ // 階層ごとの縮約graphは座標だけを決める。ready/最長鎖/独立性まで縮約graphで
943
+ // 再計算すると、未完の子を持つdone親が後続をreadyへ進めるなど、実graphと違う判断を描く。
944
+ const semanticLayout = options.scope === 'all'
945
+ ? fullLayout : layoutTodoGanttFlat(readModel, chainProjection, { ...options, scope: 'all' });
946
+ const semanticByKey = new Map(semanticLayout.nodes.map((node) => [refKey(node.ref), node]));
947
+ const semanticRoot = applyHierarchySemantics(nested.root, semanticByKey);
948
+ const rootLayout = semanticRoot.layout;
949
+ return {
950
+ ...rootLayout,
951
+ full_edges: fullLayout.full_edges,
952
+ groups: fullLayout.groups,
953
+ scope: fullLayout.scope,
954
+ folded: fullLayout.folded,
955
+ hierarchy_nodes: descendantNodes(semanticRoot),
956
+ metrics: {
957
+ ...rootLayout.metrics,
958
+ visible_node_count: nested.metrics.visibleNodeCount,
959
+ visible_edge_count: nested.metrics.visibleEdgeCount,
960
+ task_count: fullLayout.metrics.task_count,
961
+ edge_count: fullLayout.metrics.edge_count,
962
+ },
963
+ hierarchy: {
964
+ schema: 'lattice.todo_gantt_hierarchy.v1',
965
+ children: semanticRoot.children,
966
+ maximum_depth: nested.metrics.maximumDepth,
967
+ task_count: nested.metrics.taskCount,
968
+ },
969
+ };
970
+ }
@@ -4,6 +4,9 @@ import { TODO_DASHBOARD_CODE_VERSION } from './todo-dashboard-registry.mjs';
4
4
 
5
5
  const LOOPBACK = '127.0.0.1';
6
6
  const POLL_MS = 500;
7
+ const SSE_HEARTBEAT_MS = 25_000;
8
+ const SSE_STALE_MS = 62_500;
9
+ const SSE_WATCHDOG_MS = 12_500;
7
10
  const HTTP_ERROR_SCHEMA = 'lattice.todo_gantt_http_error.v1';
8
11
  const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
9
12
 
@@ -58,7 +61,7 @@ function withExternalPane(html, pane) {
58
61
  }
59
62
 
60
63
  function liveHtml(html, headDigest, eventsPath, externalPane = null) {
61
- const controller = `<script>(()=>{const badge=document.createElement('div');badge.setAttribute('role','status');badge.style.cssText='position:fixed;right:12px;bottom:12px;z-index:99;padding:6px 10px;border:1px solid #d9d8d4;border-radius:4px;background:#fcfcfb;font:600 12px system-ui';badge.textContent='進捗: 接続中';document.body.append(badge);let head=${JSON.stringify(headDigest)};const stream=new EventSource(${JSON.stringify(eventsPath)});stream.addEventListener('state',event=>{const next=JSON.parse(event.data);badge.textContent='進捗: 最新';if(next.head_digest!==head){badge.textContent='進捗: 更新を反映中';location.reload();}});stream.addEventListener('lattice-error',event=>{const detail=JSON.parse(event.data);badge.textContent='進捗: エラー '+detail.code;badge.style.borderColor='#d03b3b';});stream.onerror=()=>{badge.textContent='進捗: 再接続中';};})();</script>`;
64
+ const controller = `<script>(()=>{const badge=document.createElement('div');badge.setAttribute('role','status');badge.style.cssText='position:fixed;right:12px;bottom:12px;z-index:99;padding:6px 10px;border:1px solid #d9d8d4;border-radius:4px;background:#fcfcfb;font:600 12px system-ui';badge.textContent='進捗: 接続中';document.body.append(badge);const head=${JSON.stringify(headDigest)};let lastReceipt=Date.now();let stream=null;const receive=event=>{const next=JSON.parse(event.data);if(typeof next.head_digest!=='string')return;lastReceipt=Date.now();badge.textContent='進捗: 最新';if(next.head_digest!==head){badge.textContent='進捗: 更新を反映中';location.reload();}};const connect=()=>{if(stream)stream.close();badge.textContent='進捗: 接続中';stream=new EventSource(${JSON.stringify(eventsPath)});stream.onopen=()=>{lastReceipt=Date.now();};stream.addEventListener('state',receive);stream.addEventListener('ping',receive);stream.addEventListener('lattice-error',event=>{lastReceipt=Date.now();const detail=JSON.parse(event.data);badge.textContent='進捗: エラー '+detail.code;badge.style.borderColor='#d03b3b';});stream.onerror=()=>{badge.textContent='進捗: 再接続中';};};connect();setInterval(()=>{if(Date.now()-lastReceipt>${SSE_STALE_MS})connect();},${SSE_WATCHDOG_MS});})();</script>`;
62
65
  const publicMetadata = '<meta name="description" content="Latticeで管理しているプロジェクトの依存工程と進捗を確認できます。"><meta name="robots" content="noindex, nofollow"><meta name="theme-color" content="#f7f3ea">';
63
66
  const publicStyle = '<style>body[data-gantt-root]{grid-template-rows:auto minmax(0,1fr)}.lattice-live-brand{z-index:10;display:flex;align-items:center;gap:8px;min-width:0;padding:10px 16px;border-bottom:1px solid #d8d0c5;background:#f7f3ea;color:#6c655d;font:600 12px/1.5 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}.lattice-live-brand a{color:#201d19;text-decoration:none}.lattice-live-brand a:hover{color:#315cbe}.lattice-live-brand strong{color:#201d19}.lattice-live-brand-note{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.lattice-live-back{margin-left:auto!important;color:#315cbe!important;font-weight:750}@media(max-width:560px){.lattice-live-brand{padding:9px 12px}.lattice-live-brand-note{display:none}}</style>';
64
67
  const publicHeader = '<header class="lattice-live-brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong><span class="lattice-live-brand-note">公開工程表</span><a class="lattice-live-back" href="/projects/">一覧へ戻る</a></header>';
@@ -184,6 +187,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
184
187
  || typeof registry.list !== 'function') throw new TypeError('registry required');
185
188
  const clientsByProject = new Map();
186
189
  const lastHeads = new Map();
190
+ const lastHeartbeats = new Map();
187
191
  let eventId = 0;
188
192
  let checking = false;
189
193
  let closed = false;
@@ -194,6 +198,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
194
198
  for (const client of clients) client.end();
195
199
  clientsByProject.delete(projectId);
196
200
  lastHeads.delete(projectId);
201
+ lastHeartbeats.delete(projectId);
197
202
  }
198
203
  };
199
204
  const unsubscribe = typeof registry.subscribe === 'function'
@@ -255,6 +260,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
255
260
  try {
256
261
  const head = await project.readHead();
257
262
  lastHeads.set(project.projectId, head);
263
+ if (!lastHeartbeats.has(project.projectId)) lastHeartbeats.set(project.projectId, Date.now());
258
264
  sendEvent(response, 'state', ++eventId, { head_digest: head });
259
265
  } catch (error) {
260
266
  sendEvent(response, 'lattice-error', ++eventId, { code: error?.code ?? 'STORE_READ_FAILED' });
@@ -301,6 +307,11 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
301
307
  lastHeads.set(project.projectId, head);
302
308
  for (const client of clients) sendEvent(client, 'state', ++eventId, { head_digest: head });
303
309
  }
310
+ const now = Date.now();
311
+ if (now - (lastHeartbeats.get(project.projectId) ?? now) >= SSE_HEARTBEAT_MS) {
312
+ lastHeartbeats.set(project.projectId, now);
313
+ for (const client of clients) sendEvent(client, 'ping', ++eventId, { head_digest: head });
314
+ }
304
315
  } catch (error) {
305
316
  for (const client of clients) sendEvent(client, 'lattice-error', ++eventId,
306
317
  { code: error?.code ?? 'STORE_READ_FAILED' });