@quolu/lattice 0.12.9 → 0.12.11
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.mjs +3 -0
- package/package.json +2 -2
- package/src/cli-help.mjs +3 -3
- package/src/cli-stdio.mjs +40 -0
- package/src/todo-cli.mjs +39 -12
- package/src/todo-gantt-html.mjs +42 -13
- package/src/todo-gantt-layout.mjs +68 -14
- package/src/todo-gantt-scope.mjs +296 -0
- package/src/todo-gantt-svg.mjs +17 -6
- package/src/todo-store.mjs +35 -10
package/bin/lattice.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { installPipeCloseGuard } from '../src/cli-stdio.mjs';
|
|
3
4
|
import { runRuntimeCli } from '../src/runtime-cli.mjs';
|
|
4
5
|
import { renderCliHelp } from '../src/cli-help.mjs';
|
|
5
6
|
import packageJson from '../package.json' with { type: 'json' };
|
|
6
7
|
|
|
8
|
+
installPipeCloseGuard();
|
|
9
|
+
|
|
7
10
|
const args = process.argv.slice(2);
|
|
8
11
|
const help = renderCliHelp(args);
|
|
9
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.11",
|
|
4
4
|
"description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"test": "node scripts/run-product-tests.mjs",
|
|
55
55
|
"test:sensor": "npm --prefix sensor test",
|
|
56
56
|
"precheck": "node --check src/bridge-launch-agent.mjs",
|
|
57
|
-
"check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
|
|
57
|
+
"check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check src/cli-stdio.mjs && node --check src/todo-gantt-layout.mjs && node --check src/todo-gantt-scope.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
|
|
58
58
|
"check:project-identity": "node --check src/project-identity.mjs",
|
|
59
59
|
"ci": "npm run test && npm run test:sensor && npm run check && npm run check:project-identity"
|
|
60
60
|
}
|
package/src/cli-help.mjs
CHANGED
|
@@ -45,9 +45,9 @@ Read commands:
|
|
|
45
45
|
status [--json]
|
|
46
46
|
verify [--plan <key>] [--json]
|
|
47
47
|
snapshot --rebuild --plan <key>
|
|
48
|
-
gantt [--out <file>]
|
|
48
|
+
gantt [--out <file>] [--scope live|all] # 既定live: 完走した枝を畳む
|
|
49
49
|
gantt status [--out <file>]
|
|
50
|
-
gantt serve --port <port> # /projects/<project_id>/ をforeground配信
|
|
50
|
+
gantt serve --port <port> [--scope live|all] # /projects/<project_id>/ をforeground配信
|
|
51
51
|
phase status --plan <key>
|
|
52
52
|
|
|
53
53
|
Write commands:
|
|
@@ -106,7 +106,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
106
106
|
'todo status': 'todo status [--json]',
|
|
107
107
|
'todo verify': 'todo verify [--plan <key>] [--json]',
|
|
108
108
|
'todo snapshot': 'todo snapshot --rebuild --plan <key>',
|
|
109
|
-
'todo gantt': 'todo gantt [--out <file>] | status [--out <file>] | serve --port <port>',
|
|
109
|
+
'todo gantt': 'todo gantt [--out <file>] [--scope live|all] | status [--out <file>] | serve --port <port> [--scope live|all]',
|
|
110
110
|
'todo phase': 'todo phase <status|review|accept|reject|reopen> --plan <key> [options]',
|
|
111
111
|
'todo phase status': 'todo phase status --plan <key>',
|
|
112
112
|
'todo phase review': 'todo phase review --plan <key> --phase <id> --reason <text>',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI stdio guards.
|
|
3
|
+
*
|
|
4
|
+
* Reading part of a result is a legitimate use: `lattice todo status --json |
|
|
5
|
+
* head` stops the consumer as soon as it has what it wants. When that consumer
|
|
6
|
+
* exits, the next write to stdout raises EPIPE, and Node's default handling
|
|
7
|
+
* turns it into an unhandled 'error' event — the CLI dies with a stack trace
|
|
8
|
+
* and a non-zero exit, so a working pipeline reports a failure that never
|
|
9
|
+
* happened.
|
|
10
|
+
*
|
|
11
|
+
* EPIPE from a closed consumer is the POSIX contract, not a fault, so the
|
|
12
|
+
* process stops quietly. This is deliberately narrow: only EPIPE, only on the
|
|
13
|
+
* output streams. Every other stream error keeps its previous behaviour and
|
|
14
|
+
* still crashes the CLI, because those are real faults.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} [options]
|
|
19
|
+
* @param {Array<NodeJS.WriteStream>} [options.streams] streams to guard.
|
|
20
|
+
* @param {(code: number) => void} [options.exit] process exit, injectable for tests.
|
|
21
|
+
* @returns {() => void} removes the guards again (used by tests).
|
|
22
|
+
*/
|
|
23
|
+
export function installPipeCloseGuard({
|
|
24
|
+
streams = [process.stdout, process.stderr],
|
|
25
|
+
exit = (code) => process.exit(code),
|
|
26
|
+
} = {}) {
|
|
27
|
+
const installed = [];
|
|
28
|
+
for (const stream of streams) {
|
|
29
|
+
if (stream === undefined || stream === null || typeof stream.on !== 'function') continue;
|
|
30
|
+
const onError = (error) => {
|
|
31
|
+
if (error?.code !== 'EPIPE') throw error;
|
|
32
|
+
exit(0);
|
|
33
|
+
};
|
|
34
|
+
stream.on('error', onError);
|
|
35
|
+
installed.push(() => stream.off('error', onError));
|
|
36
|
+
}
|
|
37
|
+
return () => {
|
|
38
|
+
for (const remove of installed) remove();
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/todo-cli.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { projectTodoChainV1 } from './todo-chain.mjs';
|
|
|
20
20
|
import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
|
|
21
21
|
import { resolveProjectIdentity } from './project-identity.mjs';
|
|
22
22
|
import { layoutTodoGantt } from './todo-gantt-layout.mjs';
|
|
23
|
+
import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
|
|
23
24
|
import { loadTodoGanttPresentation } from './todo-gantt-presentation.mjs';
|
|
24
25
|
import { startTodoGanttLiveServer } from './todo-gantt-live.mjs';
|
|
25
26
|
import {
|
|
@@ -55,6 +56,7 @@ const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
|
|
|
55
56
|
const DEFAULT_GANTT_REF = '.lattice/generated/gantt.html';
|
|
56
57
|
const GANTT_DESCRIPTOR_SUFFIX = '.status.json';
|
|
57
58
|
const MAX_GANTT_DESCRIPTOR_BYTES = 65_536;
|
|
59
|
+
const DEFAULT_GANTT_SCOPE = 'live';
|
|
58
60
|
const MAX_MIGRATION_INPUT_BYTES = 8_388_608;
|
|
59
61
|
const ACTOR_ENV_KEYS = Object.freeze([
|
|
60
62
|
'LATTICE_TODO_ACTOR_HOST',
|
|
@@ -596,15 +598,19 @@ function ganttDescriptorRef(outputRef) {
|
|
|
596
598
|
return `${outputRef}${GANTT_DESCRIPTOR_SUFFIX}`;
|
|
597
599
|
}
|
|
598
600
|
|
|
601
|
+
// v2 records the scope the artifact was drawn at. Without it `gantt status`
|
|
602
|
+
// would re-render at the default scope and report a `--scope all` artifact as
|
|
603
|
+
// stale even though nothing in the store had moved.
|
|
599
604
|
function validateGanttArtifactDescriptor(value) {
|
|
600
605
|
return exactRecord(value, [
|
|
601
606
|
'schema', 'project_id', 'output_ref', 'manifest_digest', 'renderer_version',
|
|
602
|
-
'html_digest', 'artifact_digest',
|
|
603
|
-
]) && value.schema === 'lattice.todo_gantt_artifact.
|
|
607
|
+
'scope', 'html_digest', 'artifact_digest',
|
|
608
|
+
]) && value.schema === 'lattice.todo_gantt_artifact.v2'
|
|
604
609
|
&& isTodoIdentifier(value.project_id) && isTodoRef(value.output_ref)
|
|
605
610
|
&& isTodoDigest(value.manifest_digest)
|
|
606
611
|
&& typeof value.renderer_version === 'string'
|
|
607
612
|
&& /^lattice\.todo_gantt_renderer\.v[1-9][0-9]*$/u.test(value.renderer_version)
|
|
613
|
+
&& TODO_GANTT_SCOPES.includes(value.scope)
|
|
608
614
|
&& isTodoDigest(value.html_digest) && isTodoDigest(value.artifact_digest)
|
|
609
615
|
&& value.artifact_digest === todoSelfDigest(value, 'artifact_digest');
|
|
610
616
|
}
|
|
@@ -636,6 +642,7 @@ function parseGanttDescriptor(bytes, descriptorRef) {
|
|
|
636
642
|
|
|
637
643
|
export async function renderTodoGanttForProject({
|
|
638
644
|
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
645
|
+
scope = DEFAULT_GANTT_SCOPE,
|
|
639
646
|
}) {
|
|
640
647
|
const store = readModel
|
|
641
648
|
?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
|
|
@@ -645,7 +652,7 @@ export async function renderTodoGanttForProject({
|
|
|
645
652
|
const presentation = await loadTodoGanttPresentation({ repoRoot, readModel: store });
|
|
646
653
|
const topology = mergedTopology(store);
|
|
647
654
|
const chain = projectTodoChainV1(topology);
|
|
648
|
-
const layout = layoutTodoGantt(store, chain);
|
|
655
|
+
const layout = layoutTodoGantt(store, chain, { scope });
|
|
649
656
|
const narrative = await loadNarratives(store, repoRoot);
|
|
650
657
|
const anchorOutcomes = verifyNarrativeAnchors({
|
|
651
658
|
readModel: store,
|
|
@@ -673,6 +680,7 @@ export async function renderTodoGanttForProject({
|
|
|
673
680
|
layout_digest: digestTodoArtifact(layout),
|
|
674
681
|
renderer_version: TODO_GANTT_RENDERER_VERSION,
|
|
675
682
|
project_display_name: identity.displayName,
|
|
683
|
+
folded_task_count: layout.scope.folded_task_count,
|
|
676
684
|
};
|
|
677
685
|
const rendered = renderTodoGanttHtml({
|
|
678
686
|
readModel: store,
|
|
@@ -685,12 +693,14 @@ export async function renderTodoGanttForProject({
|
|
|
685
693
|
return { store, metadata, memberBindings, rendered };
|
|
686
694
|
}
|
|
687
695
|
|
|
688
|
-
async function gantt({ repoRoot, outputRef, env }) {
|
|
689
|
-
const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({
|
|
696
|
+
async function gantt({ repoRoot, outputRef, env, scope = DEFAULT_GANTT_SCOPE }) {
|
|
697
|
+
const { store, metadata, memberBindings, rendered } = await renderTodoGanttForProject({
|
|
698
|
+
repoRoot, env, scope,
|
|
699
|
+
});
|
|
690
700
|
await atomicWriteOutput(repoRoot, outputRef, rendered.html);
|
|
691
|
-
const descriptor = { schema: 'lattice.todo_gantt_artifact.
|
|
701
|
+
const descriptor = { schema: 'lattice.todo_gantt_artifact.v2', project_id: store.project_id,
|
|
692
702
|
output_ref: outputRef, manifest_digest: metadata.manifest_digest,
|
|
693
|
-
renderer_version: TODO_GANTT_RENDERER_VERSION, html_digest: rendered.html_digest,
|
|
703
|
+
renderer_version: TODO_GANTT_RENDERER_VERSION, scope, html_digest: rendered.html_digest,
|
|
694
704
|
artifact_digest: '' };
|
|
695
705
|
descriptor.artifact_digest = todoSelfDigest(descriptor, 'artifact_digest');
|
|
696
706
|
await atomicWriteOutput(repoRoot, ganttDescriptorRef(outputRef),
|
|
@@ -699,6 +709,8 @@ async function gantt({ repoRoot, outputRef, env }) {
|
|
|
699
709
|
schema: 'lattice.todo_gantt_result.v1',
|
|
700
710
|
project_id: store.project_id,
|
|
701
711
|
output_ref: outputRef,
|
|
712
|
+
scope,
|
|
713
|
+
folded_task_count: metadata.folded_task_count,
|
|
702
714
|
manifest_digest: metadata.manifest_digest,
|
|
703
715
|
member_bindings: memberBindings,
|
|
704
716
|
narrative_bindings_digest: metadata.narrative_bindings_digest,
|
|
@@ -727,7 +739,7 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
|
|
|
727
739
|
const result = { schema: 'lattice.todo_gantt_status_result.v1', project_id: store.project_id,
|
|
728
740
|
output_ref: outputRef, descriptor_ref: descriptorRef, artifact_status: 'missing',
|
|
729
741
|
current_manifest_digest: store.manifest.manifest_digest, artifact_manifest_digest: null,
|
|
730
|
-
html_digest: null, renderer_version: null, result_digest: '' };
|
|
742
|
+
html_digest: null, renderer_version: null, scope: null, result_digest: '' };
|
|
731
743
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
732
744
|
return result;
|
|
733
745
|
}
|
|
@@ -737,7 +749,9 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
|
|
|
737
749
|
throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_digest_mismatch', undefined,
|
|
738
750
|
{ output_ref: outputRef, descriptor_ref: descriptorRef });
|
|
739
751
|
}
|
|
740
|
-
|
|
752
|
+
// Re-render at the artifact's own scope: comparing a `--scope all` artifact
|
|
753
|
+
// against a default-scope render would report a false `stale`.
|
|
754
|
+
const current = await renderTodoGanttForProject({ repoRoot, env, scope: descriptor.scope });
|
|
741
755
|
if (descriptor.project_id !== current.store.project_id) {
|
|
742
756
|
throw new TodoStoreError('GANTT_ARTIFACT_INVALID', 'artifact_project_mismatch', undefined,
|
|
743
757
|
{ output_ref: outputRef });
|
|
@@ -749,12 +763,12 @@ async function ganttStatus({ repoRoot, outputRef, env }) {
|
|
|
749
763
|
project_id: current.store.project_id, output_ref: outputRef, descriptor_ref: descriptorRef,
|
|
750
764
|
artifact_status: artifactStatus, current_manifest_digest: current.metadata.manifest_digest,
|
|
751
765
|
artifact_manifest_digest: descriptor.manifest_digest, html_digest: descriptor.html_digest,
|
|
752
|
-
renderer_version: descriptor.renderer_version, result_digest: '' };
|
|
766
|
+
renderer_version: descriptor.renderer_version, scope: descriptor.scope, result_digest: '' };
|
|
753
767
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
754
768
|
return result;
|
|
755
769
|
}
|
|
756
770
|
|
|
757
|
-
async function serveGantt({ repoRoot, port, stdout, env }) {
|
|
771
|
+
async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_SCOPE }) {
|
|
758
772
|
const initialStore = await readTodoStoreStable({ repoRoot });
|
|
759
773
|
const identity = await resolveProjectIdentity({ repoRoot, projectId: initialStore.project_id, env });
|
|
760
774
|
const live = await startTodoGanttLiveServer({
|
|
@@ -763,7 +777,7 @@ async function serveGantt({ repoRoot, port, stdout, env }) {
|
|
|
763
777
|
port,
|
|
764
778
|
render: async () => {
|
|
765
779
|
const { rendered, metadata } = await renderTodoGanttForProject({
|
|
766
|
-
repoRoot, stable: true, displayName: identity.displayName,
|
|
780
|
+
repoRoot, stable: true, displayName: identity.displayName, scope,
|
|
767
781
|
});
|
|
768
782
|
return { html: rendered.html, head_digest: metadata.manifest_digest };
|
|
769
783
|
},
|
|
@@ -919,9 +933,15 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
919
933
|
action = (repoRoot) => rebuildSnapshot({ repoRoot, planKey: argv[3] });
|
|
920
934
|
} else if (argv.length === 1 && argv[0] === 'gantt') {
|
|
921
935
|
action = (repoRoot) => gantt({ repoRoot, outputRef: DEFAULT_GANTT_REF, env });
|
|
936
|
+
} else if (argv.length === 3 && argv[0] === 'gantt' && argv[1] === '--scope'
|
|
937
|
+
&& TODO_GANTT_SCOPES.includes(argv[2])) {
|
|
938
|
+
action = (repoRoot) => gantt({ repoRoot, outputRef: DEFAULT_GANTT_REF, env, scope: argv[2] });
|
|
922
939
|
} else if (argv.length === 3 && argv[0] === 'gantt' && argv[1] === '--out'
|
|
923
940
|
&& isTodoRef(argv[2])) {
|
|
924
941
|
action = (repoRoot) => gantt({ repoRoot, outputRef: argv[2], env });
|
|
942
|
+
} else if (argv.length === 5 && argv[0] === 'gantt' && argv[1] === '--out'
|
|
943
|
+
&& isTodoRef(argv[2]) && argv[3] === '--scope' && TODO_GANTT_SCOPES.includes(argv[4])) {
|
|
944
|
+
action = (repoRoot) => gantt({ repoRoot, outputRef: argv[2], env, scope: argv[4] });
|
|
925
945
|
} else if (argv.length === 2 && argv[0] === 'gantt' && argv[1] === 'status') {
|
|
926
946
|
action = (repoRoot) => ganttStatus({ repoRoot, outputRef: DEFAULT_GANTT_REF, env });
|
|
927
947
|
} else if (argv.length === 4 && argv[0] === 'gantt' && argv[1] === 'status'
|
|
@@ -931,6 +951,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
931
951
|
&& argv[2] === '--port' && /^(?:0|[1-9][0-9]{0,4})$/u.test(argv[3])
|
|
932
952
|
&& Number(argv[3]) <= 65_535) {
|
|
933
953
|
action = (repoRoot) => serveGantt({ repoRoot, port: Number(argv[3]), stdout, env });
|
|
954
|
+
} else if (argv.length === 6 && argv[0] === 'gantt' && argv[1] === 'serve'
|
|
955
|
+
&& argv[2] === '--port' && /^(?:0|[1-9][0-9]{0,4})$/u.test(argv[3])
|
|
956
|
+
&& Number(argv[3]) <= 65_535 && argv[4] === '--scope'
|
|
957
|
+
&& TODO_GANTT_SCOPES.includes(argv[5])) {
|
|
958
|
+
action = (repoRoot) => serveGantt({
|
|
959
|
+
repoRoot, port: Number(argv[3]), stdout, env, scope: argv[5],
|
|
960
|
+
});
|
|
934
961
|
} else if (argv.length === 3 && argv[0] === 'migrate' && argv[1] === '--input'
|
|
935
962
|
&& isTodoRef(argv[2])) {
|
|
936
963
|
action = (repoRoot) => migrate({ repoRoot, inputRef: argv[2] });
|
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from './todo-markdown-renderer.mjs';
|
|
7
7
|
import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
|
|
8
8
|
|
|
9
|
-
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.
|
|
9
|
+
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v9';
|
|
10
10
|
export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
11
|
export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
|
|
12
12
|
|
|
@@ -126,7 +126,23 @@ function normalizeSections(readModel, narratives, anchorOutcomes) {
|
|
|
126
126
|
return { sections: result, proseBytes };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
/** task node key -> the fold node standing in for it, empty when nothing folded. */
|
|
130
|
+
function foldIndex(layout) {
|
|
131
|
+
return new Map((layout?.folded ?? []).map((entry) => [refKey(entry.task), refKey(entry.fold)]));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderTaskIndexEntry(section, lookup, folds) {
|
|
135
|
+
const key = refKey(section.ref);
|
|
136
|
+
const status = DOCUMENT_STATUS[section.state.status] ?? { mark: '?', label: '状態不明' };
|
|
137
|
+
const blockedReason = section.state.status === 'blocked'
|
|
138
|
+
? `<span class="task-index-blocked-reason">— ${escapeHtmlText(section.state.blocked_reason ?? '理由未記録')}</span>` : '';
|
|
139
|
+
// A folded ToDo keeps its row here — the index is the complete list — but
|
|
140
|
+
// selecting it points at the fold node that actually stands on the diagram.
|
|
141
|
+
const selectKey = folds.get(key) ?? key;
|
|
142
|
+
return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(selectKey)}"><span class="task-index-status status-${escapeHtmlAttribute(section.state.status)}" role="img" aria-label="${escapeHtmlAttribute(status.label)}">${escapeHtmlText(status.mark)}</span><span class="task-index-reference">${escapeHtmlText(taskReference(section, lookup))}</span><strong>${escapeHtmlText(section.task.title)}</strong>${blockedReason}</button></li>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderTaskIndex(sections, lookup, folds = new Map()) {
|
|
130
146
|
const plans = [];
|
|
131
147
|
for (const section of sections) {
|
|
132
148
|
let plan = plans.at(-1);
|
|
@@ -136,13 +152,15 @@ function renderTaskIndex(sections, lookup) {
|
|
|
136
152
|
}
|
|
137
153
|
plan.tasks.push(section);
|
|
138
154
|
}
|
|
139
|
-
return plans.map((plan) =>
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
155
|
+
return plans.map((plan) => {
|
|
156
|
+
const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
|
|
157
|
+
const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
|
|
158
|
+
const drawnList = drawn.length === 0 ? ''
|
|
159
|
+
: `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol>`;
|
|
160
|
+
const foldedList = folded.length === 0 ? ''
|
|
161
|
+
: `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol></details>`;
|
|
162
|
+
return `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2>${drawnList}${foldedList}</section>`;
|
|
163
|
+
}).join('');
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
function presentationLookup(presentation) {
|
|
@@ -240,14 +258,19 @@ function renderRightPane(sections, layout, presentation, readModel) {
|
|
|
240
258
|
: incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
|
|
241
259
|
return `<article class="task-detail" data-detail-key="${escapeHtmlAttribute(key)}" hidden><header><span class="detail-status status-${escapeHtmlAttribute(section.state.status)}">${escapeHtmlText(status.mark)} ${escapeHtmlText(status.label)}</span><span class="detail-reference">${escapeHtmlText(taskReference(section, lookup))}</span></header><h1>${escapeHtmlText(section.task.title)}</h1><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(category)}</p>${categoryDescription}<p><strong>正規ID:</strong> <code>${escapeHtmlText(`${section.ref.plan_key}/${section.task.task_id}`)}</code></p>${blockedReason}${readiness}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。')}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。')}</section><p class="anchor-status">${escapeHtmlText(anchorText)}</p><details class="task-diagnostics"><summary>開発者向け診断</summary><dl><dt>canonical ref</dt><dd><code>${escapeHtmlText(`${section.ref.project_id}/${section.ref.plan_key}/${section.task.task_id}`)}</code></dd><dt>anchor</dt><dd>${escapeHtmlText(section.anchorOutcome.anchored ? 'verified' : section.anchorOutcome.reason)}</dd></dl></details></article>`;
|
|
242
260
|
}).join('');
|
|
243
|
-
const taskIndex = renderTaskIndex(sections, lookup);
|
|
261
|
+
const taskIndex = renderTaskIndex(sections, lookup, foldIndex(layout));
|
|
244
262
|
return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>元Markdown全文</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
|
|
245
263
|
}
|
|
246
264
|
|
|
247
|
-
function renderDiagramLegend(presentation) {
|
|
265
|
+
function renderDiagramLegend(presentation, layout = null) {
|
|
248
266
|
const categories = (presentation?.lanes ?? []).map((lane) => `<div class="category-entry"><dt><code>${escapeHtmlText(lane.lane)}</code> — ${escapeHtmlText(lane.name)}</dt><dd>${escapeHtmlText(lane.description)}</dd></div>`).join('');
|
|
249
267
|
const categoryDetails = categories === '' ? '' : `<details class="category-legend"><summary>カテゴリ説明</summary><dl>${categories}</dl></details>`;
|
|
250
|
-
|
|
268
|
+
const foldedCount = layout?.scope?.folded_task_count ?? 0;
|
|
269
|
+
const foldChip = foldedCount === 0 ? ''
|
|
270
|
+
: `<span class="fold-chip">▣ 完走済み ${foldedCount}件を畳んで表示中</span>`;
|
|
271
|
+
const foldNote = foldedCount === 0 ? ''
|
|
272
|
+
: `<p class="fold-note">後続に作業中・未着手が残っていない完了工程を、${layout.scope.fold_node_count}個の畳み込みノードへまとめています。生きた工程とその直接の前提工程は必ず展開したままです。全件を描くには <code>lattice todo gantt --scope all</code> を実行してください。総数・進捗・最長依存鎖は畳み込み前の全工程で数えています。</p>`;
|
|
273
|
+
return `<div class="diagram-legend" aria-label="工程図の凡例"><span>${statusMarkup('pending', ' 未着手')}</span><span>${statusMarkup('in-progress', ' 作業中')}</span><span>${statusMarkup('done', ' 完了')}</span><span>${statusMarkup('blocked', ' ブロック中')}</span><span>破線枠: ready frontier(同時dispatch推奨)</span><span>太線: 構造上の最長依存鎖</span><span>半円: 非接触の線交差</span><span>黒丸: 論理上の合流</span>${foldChip}${categoryDetails}${foldNote}<p>縦方向は時間ではなく、登録済み依存関係による工程段階です。ready frontierは全件同時dispatchが既定です。未登録の資源・host制約によりsubsetだけを選ぶ場合は理由を記録します。構造上の最長依存鎖は各工程を同じ重みとして数え、実時間・工数・納期を表しません。</p></div>`;
|
|
251
274
|
}
|
|
252
275
|
|
|
253
276
|
const CSS = `
|
|
@@ -310,6 +333,12 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
|
|
|
310
333
|
.todo-node .node-title{fill:var(--text-primary);font-size:13.5px;font-weight:400}
|
|
311
334
|
.todo-node .node-title-line{font-size:13.5px;font-weight:400}
|
|
312
335
|
.todo-node .status-mark{fill:var(--text-secondary);font-size:13.5px;font-weight:400}
|
|
336
|
+
.folded-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:2;stroke-dasharray:none}
|
|
337
|
+
.folded-node .node-title,.folded-node .node-meta{fill:var(--text-secondary)}
|
|
338
|
+
.fold-chip{padding:2px 8px;border:1px solid var(--border);border-radius:9999px;background:var(--surface-2);color:var(--text-primary);font-weight:650}
|
|
339
|
+
.fold-note{flex:1 0 100%;margin:4px 0 0;color:var(--text-secondary);font-weight:400}
|
|
340
|
+
.task-index-folded{margin-top:8px}
|
|
341
|
+
.task-index-folded>summary{cursor:pointer;padding:6px 0;color:var(--text-secondary);font-weight:600}
|
|
313
342
|
.status-in-progress .node-surface{fill:var(--surface-1);stroke:var(--accent);stroke-width:2}
|
|
314
343
|
.status-in-progress .status-mark{fill:var(--accent)}
|
|
315
344
|
.status-in-progress .status-bar{stroke:var(--accent);stroke-width:2;stroke-linecap:round}
|
|
@@ -405,7 +434,7 @@ export function renderTodoGanttHtml({
|
|
|
405
434
|
metadata,
|
|
406
435
|
presentation,
|
|
407
436
|
});
|
|
408
|
-
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><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)}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${svg}</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>`;
|
|
437
|
+
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><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)}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${svg}</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>`;
|
|
409
438
|
const htmlBytes = Buffer.byteLength(html, 'utf8');
|
|
410
439
|
if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
|
|
411
440
|
throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TODO_GANTT_SCOPES, projectTodoGanttScope } from './todo-gantt-scope.mjs';
|
|
2
|
+
|
|
1
3
|
const TASK_LIMIT = 2_000;
|
|
2
4
|
const EDGE_LIMIT = 8_000;
|
|
3
5
|
const SWEEP_ROUNDS = 4;
|
|
@@ -370,18 +372,21 @@ function crossingCount(edges, wave, transversePosition) {
|
|
|
370
372
|
return total;
|
|
371
373
|
}
|
|
372
374
|
|
|
373
|
-
export function layoutTodoGantt(readModel, chainProjection) {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const transversePosition = new Map();
|
|
378
|
-
for (const layer of layers) {
|
|
379
|
-
for (let index = 0; index < layer.length; index += 1) transversePosition.set(layer[index], index);
|
|
375
|
+
export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
376
|
+
const scope = options.scope ?? 'live';
|
|
377
|
+
if (!TODO_GANTT_SCOPES.includes(scope)) {
|
|
378
|
+
fail('TODO_LAYOUT_INVALID_INPUT', `scope must be one of ${TODO_GANTT_SCOPES.join(', ')}`);
|
|
380
379
|
}
|
|
381
380
|
|
|
381
|
+
// Every structural number below is measured on the FULL graph: the dependency
|
|
382
|
+
// waves, the longest dependency chain and the ready frontier describe the real
|
|
383
|
+
// plan. Folding first and measuring second would make all three lie.
|
|
384
|
+
const full = normalizeInput(readModel, chainProjection);
|
|
385
|
+
const fullWaves = assignWaves(full.nodes, full.nodesByKey, full.edges);
|
|
386
|
+
|
|
382
387
|
const longestNodeKeys = new Set(chainProjection.longest_chain_node_refs.map((ref, index) => {
|
|
383
388
|
const key = refKey(refOf(ref, `longest_chain_node_refs[${index}]`));
|
|
384
|
-
if (!nodesByKey.has(key)) fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection references an absent task');
|
|
389
|
+
if (!full.nodesByKey.has(key)) fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection references an absent task');
|
|
385
390
|
return key;
|
|
386
391
|
}));
|
|
387
392
|
const longestEdgeKeys = new Set(chainProjection.longest_chain_edges.map((edge, index) => {
|
|
@@ -389,12 +394,28 @@ export function layoutTodoGantt(readModel, chainProjection) {
|
|
|
389
394
|
const from = refKey(refOf(edge.from, `longest_chain_edges[${index}].from`));
|
|
390
395
|
const to = refKey(refOf(edge.to, `longest_chain_edges[${index}].to`));
|
|
391
396
|
const key = JSON.stringify([from, to]);
|
|
392
|
-
if (!edges.some((candidate) => candidate.key === key)) {
|
|
397
|
+
if (!full.edges.some((candidate) => candidate.key === key)) {
|
|
393
398
|
fail('TODO_LAYOUT_INVALID_INPUT', 'chain projection edge is absent from the read model');
|
|
394
399
|
}
|
|
395
400
|
return key;
|
|
396
401
|
}));
|
|
397
|
-
const readyKeys = readyTaskKeys(readModel, nodes, nodesByKey, incoming);
|
|
402
|
+
const readyKeys = readyTaskKeys(readModel, full.nodes, full.nodesByKey, fullWaves.incoming);
|
|
403
|
+
|
|
404
|
+
// Only the geometry stage below sees the narrowed graph.
|
|
405
|
+
const projected = scope === 'all'
|
|
406
|
+
? { nodes: full.nodes, edges: full.edges, foldedByKey: new Map(), folds: [], refined: false }
|
|
407
|
+
: projectTodoGanttScope({
|
|
408
|
+
nodes: full.nodes, edges: full.edges, wave: fullWaves.wave, longestChainKeys: longestNodeKeys,
|
|
409
|
+
});
|
|
410
|
+
const nodes = projected.nodes;
|
|
411
|
+
const edges = projected.edges;
|
|
412
|
+
const nodesByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
413
|
+
const { incoming, outgoing, wave } = assignWaves(nodes, nodesByKey, edges);
|
|
414
|
+
const layers = orderLayers(nodes, wave, incoming, outgoing);
|
|
415
|
+
const transversePosition = new Map();
|
|
416
|
+
for (const layer of layers) {
|
|
417
|
+
for (let index = 0; index < layer.length; index += 1) transversePosition.set(layer[index], index);
|
|
418
|
+
}
|
|
398
419
|
const visibleKeys = new Set(nodes.map(({ key }) => key));
|
|
399
420
|
const displayBranches = edges.flatMap((edge, semanticIndex) => {
|
|
400
421
|
const identities = [...edge.joinIdentities.entries()]
|
|
@@ -456,10 +477,19 @@ export function layoutTodoGantt(readModel, chainProjection) {
|
|
|
456
477
|
ref: { ...node.ref }, title: node.title, lane: node.lane, status: node.status,
|
|
457
478
|
wave: wave.get(node.key), row: transversePosition.get(node.key), visible,
|
|
458
479
|
visibility: {
|
|
459
|
-
|
|
480
|
+
// A fold node stands in for a finished branch, so it inherits the chain
|
|
481
|
+
// marking of the ToDos it replaced rather than carrying one of its own.
|
|
482
|
+
longest_dependency_chain: node.fold === undefined
|
|
483
|
+
? longestNodeKeys.has(node.key) : node.fold.longest_chain_task_count > 0,
|
|
460
484
|
active: node.status === 'in-progress', next_ready: readyKeys.has(node.key),
|
|
461
485
|
selected: false,
|
|
462
486
|
},
|
|
487
|
+
fold: node.fold === undefined ? null : {
|
|
488
|
+
task_count: node.fold.task_count,
|
|
489
|
+
lanes: [...node.fold.lanes],
|
|
490
|
+
longest_chain_task_count: node.fold.longest_chain_task_count,
|
|
491
|
+
task_refs: node.fold.task_refs.map((entry) => ({ ...entry })),
|
|
492
|
+
},
|
|
463
493
|
geometry,
|
|
464
494
|
};
|
|
465
495
|
});
|
|
@@ -566,9 +596,11 @@ export function layoutTodoGantt(readModel, chainProjection) {
|
|
|
566
596
|
for (const [x] of connector.route) routeMaximumX = Math.max(routeMaximumX, x);
|
|
567
597
|
}
|
|
568
598
|
|
|
599
|
+
// Counts stay honest: the summary chips report every ToDo in the plan, not
|
|
600
|
+
// only the ones the narrowed diagram happens to draw.
|
|
569
601
|
const planMap = new Map();
|
|
570
602
|
const laneMap = new Map();
|
|
571
|
-
for (const node of nodes) {
|
|
603
|
+
for (const node of full.nodes) {
|
|
572
604
|
if (!planMap.has(node.ref.plan_key)) planMap.set(node.ref.plan_key, 0);
|
|
573
605
|
planMap.set(node.ref.plan_key, planMap.get(node.ref.plan_key) + 1);
|
|
574
606
|
const key = groupKey(node.ref.plan_key, node.lane);
|
|
@@ -592,12 +624,34 @@ export function layoutTodoGantt(readModel, chainProjection) {
|
|
|
592
624
|
plans: [...planMap.entries()].map(([plan_key, task_count]) => ({ plan_key, task_count })),
|
|
593
625
|
lanes: [...laneMap.values()],
|
|
594
626
|
},
|
|
627
|
+
scope: {
|
|
628
|
+
requested: scope,
|
|
629
|
+
folded_task_count: projected.foldedByKey.size,
|
|
630
|
+
fold_node_count: projected.folds.length,
|
|
631
|
+
per_wave_refinement: projected.refined,
|
|
632
|
+
folds: projected.folds.map((entry) => ({
|
|
633
|
+
ref: { ...entry.ref },
|
|
634
|
+
task_count: entry.task_count,
|
|
635
|
+
lanes: [...entry.lanes],
|
|
636
|
+
longest_chain_task_count: entry.longest_chain_task_count,
|
|
637
|
+
})),
|
|
638
|
+
},
|
|
639
|
+
folded: [...projected.foldedByKey.entries()]
|
|
640
|
+
.map(([taskKey, foldKey]) => ({ task: JSON.parse(taskKey), fold: JSON.parse(foldKey) }))
|
|
641
|
+
.sort((left, right) => compareRefs(
|
|
642
|
+
{ project_id: left.task[0], plan_key: left.task[1], task_id: left.task[2] },
|
|
643
|
+
{ project_id: right.task[0], plan_key: right.task[1], task_id: right.task[2] },
|
|
644
|
+
))
|
|
645
|
+
.map(({ task, fold }) => ({
|
|
646
|
+
task: { project_id: task[0], plan_key: task[1], task_id: task[2] },
|
|
647
|
+
fold: { project_id: fold[0], plan_key: fold[1], task_id: fold[2] },
|
|
648
|
+
})),
|
|
595
649
|
metrics: {
|
|
596
650
|
crossing_count: crossingCount(edges, wave, transversePosition),
|
|
597
651
|
visible_node_count: visibleKeys.size,
|
|
598
652
|
visible_edge_count: projectedEdges.filter(({ visible }) => visible).length,
|
|
599
|
-
task_count: nodes.length,
|
|
600
|
-
edge_count: edges.length,
|
|
653
|
+
task_count: full.nodes.length,
|
|
654
|
+
edge_count: full.edges.length,
|
|
601
655
|
},
|
|
602
656
|
};
|
|
603
657
|
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Gantt scope projection.
|
|
3
|
+
*
|
|
4
|
+
* The store carries every completed ToDo forward across plan revisions on
|
|
5
|
+
* purpose — an accepted artifact is the predecessor of the work that follows
|
|
6
|
+
* it. That makes the dependency diagram grow monotonically: once a campaign
|
|
7
|
+
* finishes, its whole tree stays on screen forever and every later revision
|
|
8
|
+
* stacks on top of it. The journal is the source of truth, so nothing is
|
|
9
|
+
* removed from the store; the diagram is a projection, and this module is
|
|
10
|
+
* where that projection narrows.
|
|
11
|
+
*
|
|
12
|
+
* The rule is "fold the dead branches": a completed ToDo that no longer leads
|
|
13
|
+
* to any live work is history, and history collapses into one labelled node
|
|
14
|
+
* per finished branch. Completed ToDos that are still the direct premise of
|
|
15
|
+
* live work stay visible, because they are the context for what is dispatchable
|
|
16
|
+
* right now.
|
|
17
|
+
*
|
|
18
|
+
* This module is pure graph math over the layout's internal node/edge shape.
|
|
19
|
+
* It must run AFTER dependency waves, the longest dependency chain and the
|
|
20
|
+
* ready frontier have been computed on the FULL graph — those numbers describe
|
|
21
|
+
* the real plan, and measuring them on a folded graph would make them lie.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hops of completed predecessors kept in front of live work. 1 = keep the
|
|
28
|
+
* direct premises of live ToDos, fold everything upstream of them.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_FOLD_DISTANCE = 1;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `task_id` values in the store match /^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/, so
|
|
34
|
+
* a leading '~' cannot collide with a real ToDo.
|
|
35
|
+
*/
|
|
36
|
+
const FOLD_TASK_ID_PREFIX = '~folded';
|
|
37
|
+
|
|
38
|
+
export class TodoGanttScopeError extends Error {
|
|
39
|
+
constructor(code, message, detail = null) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'TodoGanttScopeError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.detail = detail;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compareText(left, right) {
|
|
48
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isFoldNodeRef(ref) {
|
|
52
|
+
return typeof ref?.task_id === 'string' && ref.task_id.startsWith(`${FOLD_TASK_ID_PREFIX}:`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Forward distance from each node to the nearest live (non-done) node, over the
|
|
57
|
+
* dependency DAG. A live node is at distance 0; a node with no live descendant
|
|
58
|
+
* is at Infinity.
|
|
59
|
+
*
|
|
60
|
+
* Edges always increase the wave (`assignWaves` is a longest-path layering), so
|
|
61
|
+
* visiting nodes in descending wave order guarantees every successor is settled
|
|
62
|
+
* before the node that depends on it.
|
|
63
|
+
*/
|
|
64
|
+
function distanceToLive(nodes, edges, wave) {
|
|
65
|
+
const outgoing = new Map(nodes.map(({ key }) => [key, []]));
|
|
66
|
+
for (const edge of edges) outgoing.get(edge.from).push(edge.to);
|
|
67
|
+
const distance = new Map();
|
|
68
|
+
const ordered = [...nodes].sort((left, right) => wave.get(right.key) - wave.get(left.key)
|
|
69
|
+
|| compareText(right.key, left.key));
|
|
70
|
+
for (const node of ordered) {
|
|
71
|
+
if (node.status !== 'done') {
|
|
72
|
+
distance.set(node.key, 0);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let best = Infinity;
|
|
76
|
+
for (const successor of outgoing.get(node.key)) {
|
|
77
|
+
const settled = distance.get(successor);
|
|
78
|
+
if (settled !== undefined && settled + 1 < best) best = settled + 1;
|
|
79
|
+
}
|
|
80
|
+
distance.set(node.key, best);
|
|
81
|
+
}
|
|
82
|
+
return distance;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function foldableKeys(nodes, distance, foldDistance) {
|
|
86
|
+
return new Set(nodes
|
|
87
|
+
.filter((node) => node.status === 'done' && distance.get(node.key) > foldDistance)
|
|
88
|
+
.map(({ key }) => key));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Group foldable nodes into fold units.
|
|
93
|
+
*
|
|
94
|
+
* `byComponent` groups each weakly connected component of the foldable subgraph
|
|
95
|
+
* into one unit — the compact projection, one node per finished branch.
|
|
96
|
+
* `byComponentAndWave` additionally splits each component per dependency wave.
|
|
97
|
+
* The latter is always acyclic once contracted (every dependency edge strictly
|
|
98
|
+
* increases the wave, so contracted edges do too), which makes it the
|
|
99
|
+
* guaranteed-safe refinement when contraction would otherwise close a cycle.
|
|
100
|
+
*/
|
|
101
|
+
function groupFoldable(nodes, edges, foldable, wave, splitByWave) {
|
|
102
|
+
const parent = new Map([...foldable].map((key) => [key, key]));
|
|
103
|
+
const find = (key) => {
|
|
104
|
+
let root = key;
|
|
105
|
+
while (parent.get(root) !== root) root = parent.get(root);
|
|
106
|
+
let cursor = key;
|
|
107
|
+
while (parent.get(cursor) !== root) {
|
|
108
|
+
const next = parent.get(cursor);
|
|
109
|
+
parent.set(cursor, root);
|
|
110
|
+
cursor = next;
|
|
111
|
+
}
|
|
112
|
+
return root;
|
|
113
|
+
};
|
|
114
|
+
const union = (left, right) => {
|
|
115
|
+
const leftRoot = find(left);
|
|
116
|
+
const rightRoot = find(right);
|
|
117
|
+
if (leftRoot === rightRoot) return;
|
|
118
|
+
if (compareText(leftRoot, rightRoot) <= 0) parent.set(rightRoot, leftRoot);
|
|
119
|
+
else parent.set(leftRoot, rightRoot);
|
|
120
|
+
};
|
|
121
|
+
const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
122
|
+
for (const edge of edges) {
|
|
123
|
+
if (!foldable.has(edge.from) || !foldable.has(edge.to)) continue;
|
|
124
|
+
// A fold unit never spans plans: the diagram groups by plan and a unit that
|
|
125
|
+
// straddled two plans would have no honest lane to sit in.
|
|
126
|
+
if (nodeByKey.get(edge.from).ref.plan_key !== nodeByKey.get(edge.to).ref.plan_key) continue;
|
|
127
|
+
union(edge.from, edge.to);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const unitByNode = new Map();
|
|
131
|
+
const members = new Map();
|
|
132
|
+
for (const key of [...foldable].sort(compareText)) {
|
|
133
|
+
const unitKey = splitByWave
|
|
134
|
+
? JSON.stringify([find(key), wave.get(key)])
|
|
135
|
+
: JSON.stringify([find(key), null]);
|
|
136
|
+
unitByNode.set(key, unitKey);
|
|
137
|
+
if (!members.has(unitKey)) members.set(unitKey, []);
|
|
138
|
+
members.get(unitKey).push(key);
|
|
139
|
+
}
|
|
140
|
+
return { unitByNode, members };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function dominantLane(memberNodes) {
|
|
144
|
+
const counts = new Map();
|
|
145
|
+
for (const node of memberNodes) counts.set(node.lane, (counts.get(node.lane) ?? 0) + 1);
|
|
146
|
+
return [...counts.entries()]
|
|
147
|
+
.sort(([leftLane, leftCount], [rightLane, rightCount]) => rightCount - leftCount
|
|
148
|
+
|| compareText(leftLane, rightLane))[0][0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildSummaryNodes(nodes, members, longestChainKeys) {
|
|
152
|
+
const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
153
|
+
// Order fold units by their first member so synthetic ids are stable across runs.
|
|
154
|
+
const ordered = [...members.entries()]
|
|
155
|
+
.map(([unitKey, memberKeys]) => ({ unitKey, memberKeys: [...memberKeys].sort(compareText) }))
|
|
156
|
+
.sort((left, right) => compareText(left.memberKeys[0], right.memberKeys[0]));
|
|
157
|
+
|
|
158
|
+
const summaryByUnit = new Map();
|
|
159
|
+
ordered.forEach((unit, index) => {
|
|
160
|
+
const memberNodes = unit.memberKeys.map((key) => nodeByKey.get(key));
|
|
161
|
+
const { plan_key: planKey, project_id: projectId } = memberNodes[0].ref;
|
|
162
|
+
const ref = { project_id: projectId, plan_key: planKey, task_id: `${FOLD_TASK_ID_PREFIX}:${index}` };
|
|
163
|
+
const key = JSON.stringify([ref.project_id, ref.plan_key, ref.task_id]);
|
|
164
|
+
const lanes = [...new Set(memberNodes.map((node) => node.lane))].sort(compareText);
|
|
165
|
+
summaryByUnit.set(unit.unitKey, {
|
|
166
|
+
key,
|
|
167
|
+
ref,
|
|
168
|
+
title: `完了済み ${memberNodes.length}件`,
|
|
169
|
+
lane: dominantLane(memberNodes),
|
|
170
|
+
status: 'done',
|
|
171
|
+
plan_schema: memberNodes[0].plan_schema,
|
|
172
|
+
phase_id: null,
|
|
173
|
+
phase_status: null,
|
|
174
|
+
phase_ready: false,
|
|
175
|
+
fold: {
|
|
176
|
+
task_count: memberNodes.length,
|
|
177
|
+
lanes,
|
|
178
|
+
longest_chain_task_count: memberNodes
|
|
179
|
+
.filter((node) => longestChainKeys.has(node.key)).length,
|
|
180
|
+
task_refs: memberNodes.map((node) => ({ ...node.ref })),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
return summaryByUnit;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function contract(nodes, edges, unitByNode, summaryByUnit) {
|
|
188
|
+
const mapKey = (key) => {
|
|
189
|
+
const unitKey = unitByNode.get(key);
|
|
190
|
+
return unitKey === undefined ? key : summaryByUnit.get(unitKey).key;
|
|
191
|
+
};
|
|
192
|
+
const keptNodes = nodes.filter((node) => !unitByNode.has(node.key));
|
|
193
|
+
const summaries = [...summaryByUnit.values()];
|
|
194
|
+
const resultNodes = [...keptNodes, ...summaries]
|
|
195
|
+
.sort((left, right) => compareText(left.key, right.key));
|
|
196
|
+
|
|
197
|
+
const contracted = new Map();
|
|
198
|
+
for (const edge of edges) {
|
|
199
|
+
const from = mapKey(edge.from);
|
|
200
|
+
const to = mapKey(edge.to);
|
|
201
|
+
if (from === to) continue; // interior of a fold unit
|
|
202
|
+
const key = JSON.stringify([from, to]);
|
|
203
|
+
let merged = contracted.get(key);
|
|
204
|
+
if (merged === undefined) {
|
|
205
|
+
// A rewired edge is an aggregate. Join identity describes how several
|
|
206
|
+
// premises meet at one ToDo; carrying it onto an aggregate edge would
|
|
207
|
+
// draw a junction marker for a join whose members are no longer on
|
|
208
|
+
// screen, so aggregates keep only the dependency kinds.
|
|
209
|
+
merged = { key, from, to, kinds: new Set(), joinIdentities: new Map(), aggregated: false };
|
|
210
|
+
contracted.set(key, merged);
|
|
211
|
+
}
|
|
212
|
+
for (const kind of edge.kinds) merged.kinds.add(kind);
|
|
213
|
+
if (from === edge.from && to === edge.to) {
|
|
214
|
+
for (const [identityKey, identity] of edge.joinIdentities) {
|
|
215
|
+
merged.joinIdentities.set(identityKey, identity);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
merged.aggregated = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const edge of contracted.values()) {
|
|
222
|
+
if (edge.aggregated) edge.joinIdentities = new Map();
|
|
223
|
+
}
|
|
224
|
+
const resultEdges = [...contracted.values()].sort((left, right) => compareText(left.key, right.key));
|
|
225
|
+
return { nodes: resultNodes, edges: resultEdges };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Kahn peel; returns true when the graph is acyclic. */
|
|
229
|
+
function isAcyclic(nodes, edges) {
|
|
230
|
+
const indegree = new Map(nodes.map(({ key }) => [key, 0]));
|
|
231
|
+
const outgoing = new Map(nodes.map(({ key }) => [key, []]));
|
|
232
|
+
for (const edge of edges) {
|
|
233
|
+
outgoing.get(edge.from).push(edge.to);
|
|
234
|
+
indegree.set(edge.to, indegree.get(edge.to) + 1);
|
|
235
|
+
}
|
|
236
|
+
const ready = nodes.filter(({ key }) => indegree.get(key) === 0).map(({ key }) => key);
|
|
237
|
+
let visited = 0;
|
|
238
|
+
while (ready.length > 0) {
|
|
239
|
+
const current = ready.pop();
|
|
240
|
+
visited += 1;
|
|
241
|
+
for (const next of outgoing.get(current)) {
|
|
242
|
+
indegree.set(next, indegree.get(next) - 1);
|
|
243
|
+
if (indegree.get(next) === 0) ready.push(next);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return visited === nodes.length;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Project the full dependency graph onto the `live` scope.
|
|
251
|
+
*
|
|
252
|
+
* Contracting a weakly connected component can close a cycle: with a fold
|
|
253
|
+
* distance of 1 the shape `f1 -> s -> f2` is reachable, where `s` is a
|
|
254
|
+
* completed ToDo kept as the direct premise of live work and `f1`/`f2` belong
|
|
255
|
+
* to the same fold unit. Contracting that unit would produce
|
|
256
|
+
* `summary -> s -> summary`. So the contraction is verified, and on a cycle the
|
|
257
|
+
* grouping is refined to (component, wave) — provably acyclic, because every
|
|
258
|
+
* dependency edge strictly increases the wave.
|
|
259
|
+
*
|
|
260
|
+
* @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, refined: boolean}}
|
|
261
|
+
*/
|
|
262
|
+
export function projectTodoGanttScope({
|
|
263
|
+
nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
|
|
264
|
+
}) {
|
|
265
|
+
const distance = distanceToLive(nodes, edges, wave);
|
|
266
|
+
const foldable = foldableKeys(nodes, distance, foldDistance);
|
|
267
|
+
if (foldable.size === 0) {
|
|
268
|
+
return { nodes, edges, foldedByKey: new Map(), folds: [], refined: false };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let refined = false;
|
|
272
|
+
let grouping = groupFoldable(nodes, edges, foldable, wave, false);
|
|
273
|
+
let summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
|
|
274
|
+
let contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
|
|
275
|
+
if (!isAcyclic(contracted.nodes, contracted.edges)) {
|
|
276
|
+
refined = true;
|
|
277
|
+
grouping = groupFoldable(nodes, edges, foldable, wave, true);
|
|
278
|
+
summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
|
|
279
|
+
contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
|
|
280
|
+
if (!isAcyclic(contracted.nodes, contracted.edges)) {
|
|
281
|
+
throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
|
|
282
|
+
'todo gantt scope contraction produced a cycle after per-wave refinement',
|
|
283
|
+
{ fold_unit_count: grouping.members.size });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const foldedByKey = new Map();
|
|
288
|
+
for (const [nodeKey, unitKey] of grouping.unitByNode) {
|
|
289
|
+
foldedByKey.set(nodeKey, summaryByUnit.get(unitKey).key);
|
|
290
|
+
}
|
|
291
|
+
const folds = [...summaryByUnit.values()]
|
|
292
|
+
.sort((left, right) => compareText(left.key, right.key))
|
|
293
|
+
.map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
|
|
294
|
+
|
|
295
|
+
return { nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, refined };
|
|
296
|
+
}
|
package/src/todo-gantt-svg.mjs
CHANGED
|
@@ -140,28 +140,39 @@ function presentationMaps(presentation) {
|
|
|
140
140
|
function renderNode(node, maps) {
|
|
141
141
|
if (!node.visible || node.geometry === null) return '';
|
|
142
142
|
const { x, y, width, height } = node.geometry;
|
|
143
|
+
const fold = node.fold ?? null;
|
|
143
144
|
const classes = ['todo-node', STATUS_CLASSES[node.status] ?? 'status-unknown'];
|
|
145
|
+
if (fold !== null) classes.push('folded-node');
|
|
144
146
|
if (node.visibility.longest_dependency_chain) classes.push('longest-chain-node');
|
|
145
147
|
if (node.visibility.active) classes.push('active-node');
|
|
146
148
|
if (node.visibility.next_ready) classes.push('next-ready-node');
|
|
147
149
|
if (node.visibility.selected) classes.push('selected-node');
|
|
148
150
|
const key = nodeKey(node.ref);
|
|
149
151
|
const nodeLaneKey = laneKey(node.ref.plan_key, node.lane);
|
|
150
|
-
const status =
|
|
152
|
+
const status = fold === null
|
|
153
|
+
? TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' }
|
|
154
|
+
: { mark: '▣', label: '完走済み(畳み込み)' };
|
|
151
155
|
const lane = maps.lanes.get(nodeLaneKey);
|
|
152
|
-
const laneLabel =
|
|
156
|
+
const laneLabel = fold === null
|
|
157
|
+
? (lane === undefined ? node.lane : `${node.lane}、${lane.name}`)
|
|
158
|
+
: fold.lanes.join('、');
|
|
153
159
|
const taskNumber = maps.taskNumbers.get(key);
|
|
154
|
-
const visibleReference =
|
|
155
|
-
|
|
160
|
+
const visibleReference = fold !== null ? `${fold.task_count}工程`
|
|
161
|
+
: taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
|
|
162
|
+
const spokenReference = fold !== null ? `${fold.task_count}工程の畳み込み`
|
|
163
|
+
: taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
|
|
156
164
|
const readyLabel = node.visibility.next_ready ? '。ready frontierの同時dispatch候補' : '';
|
|
157
|
-
const
|
|
165
|
+
const identity = fold === null
|
|
166
|
+
? `正規ID ${node.ref.plan_key}/${node.ref.task_id}`
|
|
167
|
+
: `${node.ref.plan_key}の完走済み${fold.task_count}工程をまとめたノード。全件を描くには --scope all`;
|
|
168
|
+
const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。${identity}${readyLabel}`;
|
|
158
169
|
const statusBar = node.status === 'in-progress'
|
|
159
170
|
? `<line class="status-bar" x1="${x + 5}" y1="${y + 6}" x2="${x + 5}" y2="${y + height - 6}"></line>` : '';
|
|
160
171
|
const titleLines = wrapLabel(node.title);
|
|
161
172
|
const titleMarkup = titleLines.map((line, index) => `<tspan x="${x + 10}" dy="${index === 0 ? 0 : 17}" class="node-title-line">${escapeSvgText(line)}</tspan>`).join('');
|
|
162
173
|
const taskNumberAttributes = taskNumber === undefined ? ''
|
|
163
174
|
: ` data-task-number="${escapeSvgAttribute(taskNumber.display_number)}" data-task-number-normalized="${escapeSvgAttribute(taskNumber.normalized_number)}" data-task-number-globally-unique="${taskNumber.globally_unique ? 'true' : 'false'}"`;
|
|
164
|
-
return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} —
|
|
175
|
+
return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} — ${identity}`)}</title></g>`;
|
|
165
176
|
}
|
|
166
177
|
|
|
167
178
|
function summaryLabel(value, maximum = 34) {
|
package/src/todo-store.mjs
CHANGED
|
@@ -1082,29 +1082,54 @@ function nextEvent(input, storeMember) {
|
|
|
1082
1082
|
return event;
|
|
1083
1083
|
}
|
|
1084
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* The digest replay binds a task's completion to.
|
|
1087
|
+
*
|
|
1088
|
+
* A ToDo completed inside this plan version binds to its own `done` event. A
|
|
1089
|
+
* ToDo carried across a revision has no `done` event in the successor journal —
|
|
1090
|
+
* its completion arrives with the `plan_genesis` state migration, and replay
|
|
1091
|
+
* binds it to the genesis digest (see the carry branch in `replay`). Resolving
|
|
1092
|
+
* only the first case is what made `reopen` unusable on carried work.
|
|
1093
|
+
*
|
|
1094
|
+
* Returns null when the task has no completion to bind to at all.
|
|
1095
|
+
*/
|
|
1096
|
+
function resolveDoneBindingDigest(storeMember, taskId) {
|
|
1097
|
+
const events = storeMember.journal.events;
|
|
1098
|
+
const authored = [...events].reverse().find((event) => (
|
|
1099
|
+
event.kind === 'done' && event.task_id === taskId
|
|
1100
|
+
));
|
|
1101
|
+
if (authored !== undefined) return authored.event_digest;
|
|
1102
|
+
const genesis = events[0];
|
|
1103
|
+
if (genesis?.kind !== 'plan_genesis' || !Array.isArray(genesis.state_migration)) return null;
|
|
1104
|
+
const carried = genesis.state_migration.some((migration) => (
|
|
1105
|
+
['carry', 'carry_reconciled_metadata'].includes(migration.state_policy)
|
|
1106
|
+
&& migration.to_task_id === taskId && migration.state?.status === 'done'
|
|
1107
|
+
));
|
|
1108
|
+
return carried ? genesis.event_digest : null;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1085
1111
|
function resolveTargetedEvent(input, storeMember) {
|
|
1086
1112
|
if (input.kind === 'reopen' && exactRecord(input.payload, [
|
|
1087
1113
|
'reason', 'override_reason',
|
|
1088
1114
|
])) {
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
));
|
|
1092
|
-
if (target === undefined) fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
|
|
1115
|
+
const targetDigest = resolveDoneBindingDigest(storeMember, input.task_id);
|
|
1116
|
+
if (targetDigest === null) fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
|
|
1093
1117
|
return {
|
|
1094
1118
|
...input,
|
|
1095
|
-
payload: { ...input.payload, target_done_digest:
|
|
1119
|
+
payload: { ...input.payload, target_done_digest: targetDigest },
|
|
1096
1120
|
};
|
|
1097
1121
|
}
|
|
1098
1122
|
if (input.kind === 'done' && exactRecord(input.payload, [
|
|
1099
1123
|
'done_mode', 'imported', 'evidence',
|
|
1100
1124
|
]) && input.payload.done_mode === 'evidence_promotion' && input.payload.imported === true) {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1125
|
+
// Same binding as `reopen`: an imported completion carried across a revision
|
|
1126
|
+
// is bound to the carrying `plan_genesis`, not to a `done` event. Whether the
|
|
1127
|
+
// completion is actually eligible for promotion stays replay's decision.
|
|
1128
|
+
const targetDigest = resolveDoneBindingDigest(storeMember, input.task_id);
|
|
1129
|
+
if (targetDigest === null) fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
|
|
1105
1130
|
return {
|
|
1106
1131
|
...input,
|
|
1107
|
-
payload: { ...input.payload, target_done_digest:
|
|
1132
|
+
payload: { ...input.payload, target_done_digest: targetDigest },
|
|
1108
1133
|
};
|
|
1109
1134
|
}
|
|
1110
1135
|
if (input.kind === 'phase_reopen' && exactRecord(input.payload, ['reason', 'override_reason'])) {
|