@quolu/lattice 0.12.34 → 0.14.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/bin/lattice-dashboard.mjs +8 -3
- package/bin/lattice.mjs +5 -0
- package/package.json +2 -2
- package/sensor/dist/mcp/server-instructions.d.ts +1 -1
- package/sensor/dist/mcp/server-instructions.d.ts.map +1 -1
- package/sensor/dist/mcp/server-instructions.js +20 -0
- package/sensor/dist/mcp/server-instructions.js.map +1 -1
- package/src/cli-help.mjs +13 -0
- package/src/project-cli.mjs +146 -37
- package/src/runtime-front-end.mjs +3 -0
- package/src/todo-cli.mjs +442 -14
- package/src/todo-gantt-html.mjs +92 -6
- package/src/todo-gantt-layout.mjs +69 -1
- package/src/todo-gantt-svg.mjs +23 -4
- package/src/todo-independence-contracts.mjs +380 -0
- package/src/todo-independence-guidance.mjs +116 -0
- package/src/todo-independence.mjs +485 -0
- package/src/todo-status.mjs +47 -12
- package/src/todo-store.mjs +132 -0
package/src/todo-cli.mjs
CHANGED
|
@@ -36,9 +36,14 @@ import {
|
|
|
36
36
|
applyTodoRevisionSet,
|
|
37
37
|
createTodoStoreWriter,
|
|
38
38
|
TodoStoreError,
|
|
39
|
+
readTodoIndependenceArtifact,
|
|
39
40
|
readTodoStore,
|
|
41
|
+
readTodoWitnessSet,
|
|
42
|
+
todoWitnessRef,
|
|
43
|
+
writeTodoWitnessSet,
|
|
40
44
|
readTodoStoreStable,
|
|
41
45
|
rebuildTodoSnapshot,
|
|
46
|
+
writeTodoIndependenceArtifact,
|
|
42
47
|
verifyEffectivePhaseTodoRevisionSources,
|
|
43
48
|
verifyTodoRevisionSources,
|
|
44
49
|
} from './todo-store.mjs';
|
|
@@ -46,7 +51,23 @@ import {
|
|
|
46
51
|
appendTodoExtraction,
|
|
47
52
|
validateTodoExtraction,
|
|
48
53
|
} from './todo-migration.mjs';
|
|
49
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
computeReadyFrontier,
|
|
56
|
+
projectTodoBindings,
|
|
57
|
+
projectTodoStatus,
|
|
58
|
+
} from './todo-status.mjs';
|
|
59
|
+
import {
|
|
60
|
+
TODO_INDEPENDENCE_PROJECTION_SCHEMA,
|
|
61
|
+
explainTodoWitnessSet,
|
|
62
|
+
validateTodoIndependenceProjection,
|
|
63
|
+
} from './todo-independence-contracts.mjs';
|
|
64
|
+
import {
|
|
65
|
+
collectWitnessSensorEvidence,
|
|
66
|
+
compileTodoIndependence,
|
|
67
|
+
migrateWitnessSetTaskIds,
|
|
68
|
+
projectIndependenceFrontier,
|
|
69
|
+
} from './todo-independence.mjs';
|
|
70
|
+
import { selectIndependenceGuidance } from './todo-independence-guidance.mjs';
|
|
50
71
|
import {
|
|
51
72
|
parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
|
|
52
73
|
validateTodoRevision, validateTodoRevisionSet,
|
|
@@ -227,6 +248,12 @@ async function readRevisionInput(repoRoot, inputRef, {
|
|
|
227
248
|
}
|
|
228
249
|
|
|
229
250
|
async function readEvidenceInput(repoRoot, inputRef) {
|
|
251
|
+
return readJsonInput(repoRoot, inputRef, {
|
|
252
|
+
validate: validateEvidenceDescriptor, invalidCode: 'INVALID_EVIDENCE',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readJsonInput(repoRoot, inputRef, { validate, invalidCode }) {
|
|
230
257
|
if (!isTodoRef(inputRef)) {
|
|
231
258
|
throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
|
|
232
259
|
}
|
|
@@ -270,12 +297,32 @@ async function readEvidenceInput(repoRoot, inputRef) {
|
|
|
270
297
|
try { descriptor = JSON.parse(text); } catch {
|
|
271
298
|
throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
|
|
272
299
|
}
|
|
273
|
-
if (!
|
|
274
|
-
throw new TodoStoreError(
|
|
300
|
+
if (!validate(descriptor)) {
|
|
301
|
+
throw new TodoStoreError(invalidCode, 'schema_invalid');
|
|
275
302
|
}
|
|
276
303
|
return descriptor;
|
|
277
304
|
}
|
|
278
305
|
|
|
306
|
+
/** 安全読み取りとcanonical JSON規律を共有したまま、witness set契約で検証する。 */
|
|
307
|
+
async function readWitnessSetInput(repoRoot, inputRef) {
|
|
308
|
+
let explained = null;
|
|
309
|
+
const witnessSet = await readJsonInput(repoRoot, inputRef, {
|
|
310
|
+
validate: (value) => {
|
|
311
|
+
explained = explainTodoWitnessSet(value);
|
|
312
|
+
return explained.valid;
|
|
313
|
+
},
|
|
314
|
+
invalidCode: 'INVALID_TODO_WITNESS_SET',
|
|
315
|
+
}).catch((error) => {
|
|
316
|
+
if (error?.code === 'INVALID_TODO_WITNESS_SET' && explained !== null) {
|
|
317
|
+
throw new TodoStoreError('INVALID_TODO_WITNESS_SET', explained.reason, undefined, {
|
|
318
|
+
input_ref: inputRef, path: explained.path,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
});
|
|
323
|
+
return witnessSet;
|
|
324
|
+
}
|
|
325
|
+
|
|
279
326
|
function mutationActor(env) {
|
|
280
327
|
const entries = ACTOR_ENV_KEYS.map((key) => ({ key, value: env[key] }));
|
|
281
328
|
const missingEnvironment = entries
|
|
@@ -295,7 +342,9 @@ function mutationActor(env) {
|
|
|
295
342
|
return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
|
|
296
343
|
}
|
|
297
344
|
|
|
298
|
-
async function mutate({
|
|
345
|
+
async function mutate({
|
|
346
|
+
repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
|
|
347
|
+
}) {
|
|
299
348
|
const actor = mutationActor(env);
|
|
300
349
|
const evidence = evidenceRef === null ? null : await readEvidenceInput(repoRoot, evidenceRef);
|
|
301
350
|
let eventPayload = payload;
|
|
@@ -311,7 +360,7 @@ async function mutate({ repoRoot, env, planKey, taskId, kind, payload, evidenceR
|
|
|
311
360
|
});
|
|
312
361
|
const task = snapshot.tasks.find(({ task_id: current }) => current === event.task_id);
|
|
313
362
|
const result = {
|
|
314
|
-
schema: 'lattice.todo_mutation_result.
|
|
363
|
+
schema: 'lattice.todo_mutation_result.v2',
|
|
315
364
|
project_id: event.project_id,
|
|
316
365
|
plan_key: event.plan_key,
|
|
317
366
|
plan_version: event.plan_version,
|
|
@@ -322,14 +371,86 @@ async function mutate({ repoRoot, env, planKey, taskId, kind, payload, evidenceR
|
|
|
322
371
|
journal_head_digest: event.event_digest,
|
|
323
372
|
snapshot_digest: snapshot.snapshot_digest,
|
|
324
373
|
status: task.status,
|
|
374
|
+
advisory,
|
|
325
375
|
result_digest: '',
|
|
326
376
|
};
|
|
327
377
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
328
378
|
return result;
|
|
329
379
|
}
|
|
330
380
|
|
|
381
|
+
/**
|
|
382
|
+
* 着手しようとしているtaskについて、記録済みの独立性から助言を組む(ADR 0128 Decision 5)。
|
|
383
|
+
*
|
|
384
|
+
* 助言であって拒否ではない。ADR 0063のdispatch契約は変えない。ただし助言を計算できない
|
|
385
|
+
* 状況——git HEADが読めない等——はsilent degradeせず、呼び出し側でstart自体を止める。
|
|
386
|
+
*/
|
|
387
|
+
async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
|
|
388
|
+
const artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
389
|
+
if (artifact === null) {
|
|
390
|
+
// 記録が無ければ鮮度を語る相手がいない。HEADを要求すると、commitがまだ無いrepoで
|
|
391
|
+
// 「判定できない」でなく「startできない」になってしまう。
|
|
392
|
+
return {
|
|
393
|
+
coverage: 'missing',
|
|
394
|
+
drift_intersecting: null,
|
|
395
|
+
conflicts_with_active: [],
|
|
396
|
+
uncovered_active_task_ids: projection.active_set
|
|
397
|
+
.filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
|
|
398
|
+
self_unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
|
|
399
|
+
guidance: selectIndependenceGuidance({
|
|
400
|
+
coverage: 'missing', taskDeclared: false, taskStale: false,
|
|
401
|
+
}),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
// 記録があるなら鮮度の判定にHEADが要る。ここで読めないのは判定不能であり、
|
|
405
|
+
// 助言なしで通してよい状態ではない。
|
|
406
|
+
const currentBaseSha = currentHeadSha(repoRoot);
|
|
407
|
+
const changedPaths = artifact.base_sha !== currentBaseSha
|
|
408
|
+
? changedPathsSince(repoRoot, artifact.base_sha) : null;
|
|
409
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
410
|
+
const projected = projectIndependenceFrontier({
|
|
411
|
+
artifact,
|
|
412
|
+
readyTaskIds: projection.next_ready
|
|
413
|
+
.filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
|
|
414
|
+
activeTaskIds: projection.active_set
|
|
415
|
+
.filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
|
|
416
|
+
plan: member?.plan ?? { plan_version: null, topology_digest: null },
|
|
417
|
+
currentBaseSha,
|
|
418
|
+
changedPaths,
|
|
419
|
+
});
|
|
420
|
+
const selfUnknowns = projected.frontier.unknown
|
|
421
|
+
.find((entry) => entry.task_id === taskId)?.unknowns ?? [];
|
|
422
|
+
const conflictsWithActive = projected.frontier.conflicts_with_active
|
|
423
|
+
.filter((entry) => entry.ready_task_id === taskId)
|
|
424
|
+
.map((entry) => ({
|
|
425
|
+
active_task_id: entry.active_task_id,
|
|
426
|
+
type: entry.type,
|
|
427
|
+
detail: entry.detail,
|
|
428
|
+
kind: entry.kind,
|
|
429
|
+
severability: entry.severability,
|
|
430
|
+
}));
|
|
431
|
+
const readyConflict = projected.frontier.serialize_pairs
|
|
432
|
+
.find((pair) => pair.task_ids.includes(taskId)) ?? null;
|
|
433
|
+
const declared = !selfUnknowns.some(({ kind }) => kind === 'witness_missing');
|
|
434
|
+
return {
|
|
435
|
+
coverage: projected.coverage,
|
|
436
|
+
drift_intersecting: projected.drift === null
|
|
437
|
+
? null : projected.drift.intersecting_task_ids.includes(taskId),
|
|
438
|
+
conflicts_with_active: conflictsWithActive,
|
|
439
|
+
uncovered_active_task_ids: projected.uncovered_active_task_ids,
|
|
440
|
+
self_unknowns: selfUnknowns,
|
|
441
|
+
guidance: selectIndependenceGuidance({
|
|
442
|
+
coverage: projected.coverage,
|
|
443
|
+
taskDeclared: declared,
|
|
444
|
+
taskStale: selfUnknowns.some(({ kind }) => kind === 'record_stale'),
|
|
445
|
+
conflictWithActive: conflictsWithActive[0]?.severability ?? null,
|
|
446
|
+
conflictBetweenReady: readyConflict?.severability ?? null,
|
|
447
|
+
}),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
331
451
|
async function startTask({ repoRoot, env, planKey, taskId, overrideReason, parallelFrontier }) {
|
|
332
|
-
const
|
|
452
|
+
const store = await readTodoStore({ repoRoot });
|
|
453
|
+
const projection = projectTodoStatus(store);
|
|
333
454
|
const readyTask = projection.next_ready.find((task) => (
|
|
334
455
|
task.plan_key === planKey && task.task_id.toLowerCase() === taskId.toLowerCase()
|
|
335
456
|
));
|
|
@@ -347,8 +468,13 @@ async function startTask({ repoRoot, env, planKey, taskId, overrideReason, paral
|
|
|
347
468
|
serial_reason_flag: '--override-reason',
|
|
348
469
|
});
|
|
349
470
|
}
|
|
350
|
-
|
|
351
|
-
|
|
471
|
+
const resolvedTaskId = readyTask?.task_id ?? taskId;
|
|
472
|
+
// 助言はjournalへ書く前に確定させる。計算できないならstart自体を止める。
|
|
473
|
+
const advisory = await startAdvisory({
|
|
474
|
+
repoRoot, store, projection, planKey, taskId: resolvedTaskId,
|
|
475
|
+
});
|
|
476
|
+
return mutate({ repoRoot, env, planKey, taskId: resolvedTaskId, kind: 'start',
|
|
477
|
+
payload: { override_reason: overrideReason }, evidenceRef: null, advisory });
|
|
352
478
|
}
|
|
353
479
|
|
|
354
480
|
function validatePhaseDecisionInput(value, outcome) {
|
|
@@ -478,6 +604,229 @@ async function bindings({ repoRoot, requestedPlanKey }) {
|
|
|
478
604
|
return projectTodoBindings(await readTodoStore({ repoRoot }), { requestedPlanKey });
|
|
479
605
|
}
|
|
480
606
|
|
|
607
|
+
function currentHeadSha(repoRoot) {
|
|
608
|
+
let head;
|
|
609
|
+
try {
|
|
610
|
+
head = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
611
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
612
|
+
}).trim();
|
|
613
|
+
} catch {
|
|
614
|
+
throw new TodoStoreError('INDEPENDENCE_BASE_UNRESOLVED', 'git_head_unresolved');
|
|
615
|
+
}
|
|
616
|
+
if (!/^[0-9a-f]{40}$/u.test(head)) {
|
|
617
|
+
throw new TodoStoreError('INDEPENDENCE_BASE_UNRESOLVED', 'git_head_invalid');
|
|
618
|
+
}
|
|
619
|
+
return head;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* `base_sha..HEAD`で変わったrepo相対pathを返す。
|
|
624
|
+
*
|
|
625
|
+
* baseがgit historyから到達できない場合(rebase・shallow等)はnullを返す。
|
|
626
|
+
* 「変更なし」の空配列と「差分を確定できない」を同じ顔にしない——前者は記録の有効性を
|
|
627
|
+
* 支える事実だが、後者は支えない(ADR 0128 Decision 4)。
|
|
628
|
+
*/
|
|
629
|
+
function changedPathsSince(repoRoot, baseSha) {
|
|
630
|
+
try {
|
|
631
|
+
execFileSync('git', ['cat-file', '-e', `${baseSha}^{commit}`], {
|
|
632
|
+
cwd: repoRoot, stdio: ['ignore', 'ignore', 'ignore'],
|
|
633
|
+
});
|
|
634
|
+
} catch {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
let output;
|
|
638
|
+
try {
|
|
639
|
+
output = execFileSync('git', ['diff', '--name-only', '--no-renames', `${baseSha}..HEAD`], {
|
|
640
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
641
|
+
});
|
|
642
|
+
} catch {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
return [...new Set(output.split('\n').map((line) => line.trim()).filter((line) => line.length > 0))]
|
|
646
|
+
.sort();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function requireCleanWorktree(repoRoot) {
|
|
650
|
+
let porcelain;
|
|
651
|
+
try {
|
|
652
|
+
porcelain = execFileSync('git', ['status', '--porcelain'], {
|
|
653
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
654
|
+
});
|
|
655
|
+
} catch {
|
|
656
|
+
throw new TodoStoreError('INDEPENDENCE_BASE_UNRESOLVED', 'git_status_unresolved');
|
|
657
|
+
}
|
|
658
|
+
const dirty = porcelain.split('\n').filter((line) => line.trim().length > 0);
|
|
659
|
+
if (dirty.length > 0) {
|
|
660
|
+
// 未commitの観測を検証済み証拠として固定化しない(ADR 0127 Decision 3)。
|
|
661
|
+
throw new TodoStoreError('INDEPENDENCE_WORKTREE_DIRTY', 'worktree_not_clean', undefined, {
|
|
662
|
+
changed_entries: dirty.length,
|
|
663
|
+
next_action: 'commit_or_stash_then_retry',
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function independenceCompile({ repoRoot, planKey, inputRef }) {
|
|
669
|
+
const witnessSet = await readWitnessSetInput(repoRoot, inputRef);
|
|
670
|
+
requireCleanWorktree(repoRoot);
|
|
671
|
+
const baseSha = currentHeadSha(repoRoot);
|
|
672
|
+
const store = await readTodoStore({ repoRoot });
|
|
673
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
674
|
+
if (!member) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, { plan_key: planKey });
|
|
675
|
+
|
|
676
|
+
const artifact = compileTodoIndependence({
|
|
677
|
+
witnessSet,
|
|
678
|
+
plan: member.plan,
|
|
679
|
+
baseSha,
|
|
680
|
+
compiledAt: new Date().toISOString(),
|
|
681
|
+
sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
|
|
682
|
+
});
|
|
683
|
+
const { ref } = await writeTodoIndependenceArtifact({ repoRoot, artifact });
|
|
684
|
+
|
|
685
|
+
const result = {
|
|
686
|
+
schema: 'lattice.todo_independence_compile_result.v1',
|
|
687
|
+
project_id: artifact.project_id,
|
|
688
|
+
plan_key: artifact.plan_key,
|
|
689
|
+
plan_version: artifact.plan_version,
|
|
690
|
+
base_sha: artifact.base_sha,
|
|
691
|
+
artifact_ref: ref,
|
|
692
|
+
outcome: artifact.outcome,
|
|
693
|
+
task_count: artifact.task_ids.length,
|
|
694
|
+
conflict_count: artifact.conflicts.length,
|
|
695
|
+
unknown_count: artifact.unknowns.length,
|
|
696
|
+
result_digest: '',
|
|
697
|
+
};
|
|
698
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* revision後のwitness宣言をtask migrationで写す(ADR 0128 Decision 6)。
|
|
704
|
+
*
|
|
705
|
+
* compileしないので証拠を固定化せず、dirty worktreeを拒否しない。
|
|
706
|
+
* 想定運用は「移行 → commit → cleanな状態でcompile」である。
|
|
707
|
+
*/
|
|
708
|
+
async function independenceWitnessMigrate({ repoRoot, planKey }) {
|
|
709
|
+
const store = await readTodoStore({ repoRoot });
|
|
710
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
711
|
+
if (!member) {
|
|
712
|
+
throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, { plan_key: planKey });
|
|
713
|
+
}
|
|
714
|
+
const witnessSet = await readTodoWitnessSet({ repoRoot, planKey });
|
|
715
|
+
if (witnessSet === null) {
|
|
716
|
+
throw new TodoStoreError('WITNESS_MIGRATION_UNAVAILABLE', 'witness_set_absent', undefined, {
|
|
717
|
+
witness_ref: todoWitnessRef(planKey),
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
const revision = member.revision;
|
|
721
|
+
if (!revision || !Array.isArray(revision.task_migration)) {
|
|
722
|
+
// revisionを経ていないplanには写す先が無い。「移行済み」と装わない。
|
|
723
|
+
throw new TodoStoreError('WITNESS_MIGRATION_UNAVAILABLE', 'plan_has_no_revision', undefined, {
|
|
724
|
+
plan_key: planKey, plan_version: member.plan.plan_version,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const migration = migrateWitnessSetTaskIds({
|
|
729
|
+
witnessSet,
|
|
730
|
+
taskMigration: revision.task_migration,
|
|
731
|
+
planTaskIds: member.plan.tasks.map(({ task_id: taskId }) => taskId),
|
|
732
|
+
});
|
|
733
|
+
const { ref } = await writeTodoWitnessSet({ repoRoot, witnessSet: migration.witnessSet });
|
|
734
|
+
|
|
735
|
+
const result = {
|
|
736
|
+
schema: 'lattice.todo_witness_migrate_result.v1',
|
|
737
|
+
project_id: store.project_id,
|
|
738
|
+
plan_key: planKey,
|
|
739
|
+
plan_version: member.plan.plan_version,
|
|
740
|
+
witness_ref: ref,
|
|
741
|
+
migrated_count: migration.migrated_count,
|
|
742
|
+
removed_count: migration.removed_count,
|
|
743
|
+
unchanged_count: migration.unchanged_count,
|
|
744
|
+
witness_set_digest: migration.witnessSet.witness_set_digest,
|
|
745
|
+
result_digest: '',
|
|
746
|
+
};
|
|
747
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
748
|
+
return result;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function independence({ repoRoot, requestedPlanKey }) {
|
|
752
|
+
const store = await readTodoStore({ repoRoot });
|
|
753
|
+
if (requestedPlanKey !== null
|
|
754
|
+
&& !store.members.some(({ descriptor }) => descriptor.plan_key === requestedPlanKey)) {
|
|
755
|
+
throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, {
|
|
756
|
+
plan_key: requestedPlanKey,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
const frontier = computeReadyFrontier(store);
|
|
760
|
+
const readyPlanKeys = [...new Set(frontier.map(({ plan_key: key }) => key))].sort();
|
|
761
|
+
// planを絞らない呼び出しではreadyがどのplanを指しているかで決める。readyが無い時は
|
|
762
|
+
// 全planが候補になる。どちらの場合も候補が複数なら、片方だけ見せて答えたことにしない。
|
|
763
|
+
// ここで黙ってnullへ倒すと、記録があるのにcoverage missingと報告してしまう。
|
|
764
|
+
const candidatePlanKeys = readyPlanKeys.length > 0
|
|
765
|
+
? readyPlanKeys
|
|
766
|
+
: store.members.map(({ descriptor }) => descriptor.plan_key).sort();
|
|
767
|
+
if (requestedPlanKey === null && candidatePlanKeys.length > 1) {
|
|
768
|
+
throw new TodoStoreError('INDEPENDENCE_PLAN_AMBIGUOUS', 'plan_selection_ambiguous', undefined, {
|
|
769
|
+
plan_keys: candidatePlanKeys,
|
|
770
|
+
ready_plan_keys: readyPlanKeys,
|
|
771
|
+
next_action: 'rerun_with_plan_flag',
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
const planKey = requestedPlanKey ?? candidatePlanKeys[0] ?? null;
|
|
775
|
+
|
|
776
|
+
const currentBaseSha = currentHeadSha(repoRoot);
|
|
777
|
+
const ready = frontier.filter((task) => task.plan_key === planKey);
|
|
778
|
+
const member = planKey === null
|
|
779
|
+
? undefined : store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
780
|
+
const artifact = member === undefined
|
|
781
|
+
? null : await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
782
|
+
const active = projectTodoStatus(store).active_set
|
|
783
|
+
.filter((task) => task.plan_key === planKey);
|
|
784
|
+
// HEADが進んでいる時だけdiffを取る。一致していれば宣言境界を見るまでもない。
|
|
785
|
+
const changedPaths = artifact !== null && artifact.base_sha !== currentBaseSha
|
|
786
|
+
? changedPathsSince(repoRoot, artifact.base_sha) : null;
|
|
787
|
+
|
|
788
|
+
const projected = projectIndependenceFrontier({
|
|
789
|
+
artifact,
|
|
790
|
+
readyTaskIds: ready.map(({ task_id: taskId }) => taskId),
|
|
791
|
+
activeTaskIds: active.map(({ task_id: taskId }) => taskId),
|
|
792
|
+
plan: member?.plan ?? { plan_version: null, topology_digest: null },
|
|
793
|
+
currentBaseSha,
|
|
794
|
+
changedPaths,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const result = {
|
|
798
|
+
schema: TODO_INDEPENDENCE_PROJECTION_SCHEMA,
|
|
799
|
+
project_id: store.project_id,
|
|
800
|
+
plan_key: planKey,
|
|
801
|
+
coverage: projected.coverage,
|
|
802
|
+
compiled_base_sha: artifact?.base_sha ?? null,
|
|
803
|
+
current_base_sha: currentBaseSha,
|
|
804
|
+
plan_version: artifact?.plan_version ?? null,
|
|
805
|
+
topology_digest: artifact?.topology_digest ?? null,
|
|
806
|
+
active_task_ids: projected.active_task_ids,
|
|
807
|
+
uncovered_active_task_ids: projected.uncovered_active_task_ids,
|
|
808
|
+
drift: projected.drift,
|
|
809
|
+
// planを読みに来た人にも、着手する人と同じ文言を返す(ADR 0130 Decision 1)。
|
|
810
|
+
guidance: selectIndependenceGuidance({
|
|
811
|
+
coverage: projected.coverage,
|
|
812
|
+
readyCount: ready.length,
|
|
813
|
+
taskDeclared: projected.frontier.unknown
|
|
814
|
+
.every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
|
|
815
|
+
taskStale: projected.frontier.unknown
|
|
816
|
+
.some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
|
|
817
|
+
conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
|
|
818
|
+
conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
|
|
819
|
+
}),
|
|
820
|
+
frontier: projected.frontier,
|
|
821
|
+
result_digest: '',
|
|
822
|
+
};
|
|
823
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
824
|
+
if (!validateTodoIndependenceProjection(result)) {
|
|
825
|
+
throw new TodoStoreError('INDEPENDENCE_PROJECTION_INVALID', 'independence_projection_invalid');
|
|
826
|
+
}
|
|
827
|
+
return result;
|
|
828
|
+
}
|
|
829
|
+
|
|
481
830
|
async function readNarrative(repoRoot, ref) {
|
|
482
831
|
const canonicalRoot = await realpath(repoRoot);
|
|
483
832
|
const source = parseTodoSourceRef(ref);
|
|
@@ -644,6 +993,66 @@ function parseGanttDescriptor(bytes, descriptorRef) {
|
|
|
644
993
|
return value;
|
|
645
994
|
}
|
|
646
995
|
|
|
996
|
+
/**
|
|
997
|
+
* 図が描く全planについて独立性を投影する(ADR 0129 Decision 4)。
|
|
998
|
+
*
|
|
999
|
+
* Ganttは複数planを同時に描くので、単一plan前提の`todo independence`の曖昧判定は持ち込まない。
|
|
1000
|
+
* plan単位で記録を引き、記録が無いplanは投影を持たないまま通す——描けない事実を
|
|
1001
|
+
* 図の側で作り出さない。
|
|
1002
|
+
*/
|
|
1003
|
+
/**
|
|
1004
|
+
* live配信の更新検知に使うhead digest(ADR 0129 Decision 6)。
|
|
1005
|
+
*
|
|
1006
|
+
* manifest_digestだけでは、独立性の再compileもHEAD前進も検知できず画面が古いまま残る。
|
|
1007
|
+
* 同じ値を描画側と検知側で別々に組み立てると、更新されない状態が静かに再発するため、
|
|
1008
|
+
* 一つの関数だけが組む。
|
|
1009
|
+
*/
|
|
1010
|
+
export async function ganttLiveHeadDigest({ repoRoot, store }) {
|
|
1011
|
+
const independence = await independenceForGantt({ repoRoot, store });
|
|
1012
|
+
return digestTodoArtifact({
|
|
1013
|
+
schema: 'lattice.todo_gantt_live_head.v1',
|
|
1014
|
+
manifest_digest: store.manifest.manifest_digest,
|
|
1015
|
+
independence: independence === null ? null : independence.map((entry) => ({
|
|
1016
|
+
plan_key: entry.plan_key,
|
|
1017
|
+
coverage: entry.coverage,
|
|
1018
|
+
frontier_digest: digestTodoArtifact(entry.frontier),
|
|
1019
|
+
})),
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function independenceForGantt({ repoRoot, store }) {
|
|
1024
|
+
const frontier = computeReadyFrontier(store);
|
|
1025
|
+
const status = projectTodoStatus(store);
|
|
1026
|
+
let currentBaseSha = null;
|
|
1027
|
+
const projections = [];
|
|
1028
|
+
for (const member of store.members) {
|
|
1029
|
+
const planKey = member.plan.plan_key;
|
|
1030
|
+
const artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
1031
|
+
if (artifact === null) continue;
|
|
1032
|
+
// 記録があるplanが1つでもあれば鮮度の判定にHEADが要る。
|
|
1033
|
+
if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
|
|
1034
|
+
const changedPaths = artifact.base_sha !== currentBaseSha
|
|
1035
|
+
? changedPathsSince(repoRoot, artifact.base_sha) : null;
|
|
1036
|
+
const projected = projectIndependenceFrontier({
|
|
1037
|
+
artifact,
|
|
1038
|
+
readyTaskIds: frontier.filter((task) => task.plan_key === planKey)
|
|
1039
|
+
.map(({ task_id: taskId }) => taskId),
|
|
1040
|
+
activeTaskIds: status.active_set.filter((task) => task.plan_key === planKey)
|
|
1041
|
+
.map(({ task_id: taskId }) => taskId),
|
|
1042
|
+
plan: member.plan,
|
|
1043
|
+
currentBaseSha,
|
|
1044
|
+
changedPaths,
|
|
1045
|
+
});
|
|
1046
|
+
projections.push({
|
|
1047
|
+
project_id: member.plan.project_id,
|
|
1048
|
+
plan_key: planKey,
|
|
1049
|
+
coverage: projected.coverage,
|
|
1050
|
+
frontier: projected.frontier,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
return projections.length === 0 ? null : projections;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
647
1056
|
export async function renderTodoGanttForProject({
|
|
648
1057
|
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
649
1058
|
scope = DEFAULT_GANTT_SCOPE,
|
|
@@ -656,11 +1065,12 @@ export async function renderTodoGanttForProject({
|
|
|
656
1065
|
const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
|
|
657
1066
|
const topology = mergedTopology(store);
|
|
658
1067
|
const chain = projectTodoChainV1(topology);
|
|
659
|
-
const
|
|
1068
|
+
const independence = await independenceForGantt({ repoRoot, store });
|
|
1069
|
+
const layout = layoutTodoGantt(store, chain, { scope, independence });
|
|
660
1070
|
// When the diagram hides history, the page also carries the full diagram so
|
|
661
1071
|
// the reader can bring it back in place. Nothing is hidden under `all`.
|
|
662
1072
|
const expandedLayout = layout.scope.folded_task_count === 0
|
|
663
|
-
? null : layoutTodoGantt(store, chain, { scope: 'all' });
|
|
1073
|
+
? null : layoutTodoGantt(store, chain, { scope: 'all', independence });
|
|
664
1074
|
const narrative = await loadNarratives(store, repoRoot);
|
|
665
1075
|
const anchorOutcomes = verifyNarrativeAnchors({
|
|
666
1076
|
readModel: store,
|
|
@@ -785,12 +1195,15 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
|
|
|
785
1195
|
displayName: identity.displayName,
|
|
786
1196
|
port,
|
|
787
1197
|
render: async () => {
|
|
788
|
-
const
|
|
789
|
-
|
|
1198
|
+
const store = await readTodoStoreStable({ repoRoot });
|
|
1199
|
+
const { rendered } = await renderTodoGanttForProject({
|
|
1200
|
+
repoRoot, stable: true, displayName: identity.displayName, scope, readModel: store,
|
|
790
1201
|
});
|
|
791
|
-
return { html: rendered.html, head_digest:
|
|
1202
|
+
return { html: rendered.html, head_digest: await ganttLiveHeadDigest({ repoRoot, store }) };
|
|
792
1203
|
},
|
|
793
|
-
readHead: async () => (
|
|
1204
|
+
readHead: async () => ganttLiveHeadDigest({
|
|
1205
|
+
repoRoot, store: await readTodoStoreStable({ repoRoot }),
|
|
1206
|
+
}),
|
|
794
1207
|
});
|
|
795
1208
|
const result = { schema: 'lattice.todo_gantt_live_result.v2', project_id: live.projectId,
|
|
796
1209
|
host: live.host, port: live.port, project_path: live.projectPath,
|
|
@@ -937,6 +1350,21 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
937
1350
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
938
1351
|
&& (argv.length === 3 || argv[3] === '--json')) {
|
|
939
1352
|
action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: argv[2] });
|
|
1353
|
+
} else if (argv.length === 5 && argv[0] === 'independence' && argv[1] === 'witness'
|
|
1354
|
+
&& argv[2] === 'migrate' && argv[3] === '--plan' && isTodoIdentifier(argv[4])) {
|
|
1355
|
+
action = (repoRoot) => independenceWitnessMigrate({ repoRoot, planKey: argv[4] });
|
|
1356
|
+
} else if (argv.length === 6 && argv[0] === 'independence' && argv[1] === 'compile'
|
|
1357
|
+
&& argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--input') {
|
|
1358
|
+
action = (repoRoot) => independenceCompile({
|
|
1359
|
+
repoRoot, planKey: argv[3], inputRef: argv[5],
|
|
1360
|
+
});
|
|
1361
|
+
} else if ((argv.length === 1 && argv[0] === 'independence')
|
|
1362
|
+
|| (argv.length === 2 && argv[0] === 'independence' && argv[1] === '--json')) {
|
|
1363
|
+
action = (repoRoot) => independence({ repoRoot, requestedPlanKey: null });
|
|
1364
|
+
} else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'independence'
|
|
1365
|
+
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1366
|
+
&& (argv.length === 3 || argv[3] === '--json')) {
|
|
1367
|
+
action = (repoRoot) => independence({ repoRoot, requestedPlanKey: argv[2] });
|
|
940
1368
|
} else if ((argv.length === 1 && argv[0] === 'verify')
|
|
941
1369
|
|| (argv.length === 2 && argv[0] === 'verify' && argv[1] === '--json')) {
|
|
942
1370
|
action = (repoRoot) => verify({ repoRoot, requestedPlanKey: null });
|