@quolu/lattice 0.52.0 → 0.52.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-mcp.mjs +0 -0
  5. package/bin/lattice-scripted-adapter.mjs +0 -0
  6. package/bin/lattice-scripted-worker.mjs +0 -0
  7. package/bin/lattice-work-order-adapter.mjs +0 -0
  8. package/bin/lattice.mjs +0 -0
  9. package/docs/bridge-setup.md +132 -132
  10. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  11. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  12. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  13. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  14. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  15. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  16. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  17. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  18. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  19. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  20. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  21. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  22. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  23. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  24. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  25. package/package.json +103 -103
  26. package/sensor/LICENSE +21 -21
  27. package/sensor/NOTICE +19 -19
  28. package/sensor/dist/bin/lattice-sensor.js +9 -9
  29. package/sensor/dist/db/index.js +24 -24
  30. package/sensor/dist/db/migrations.js +41 -41
  31. package/sensor/dist/db/queries.js +164 -164
  32. package/sensor/dist/db/schema.sql +205 -205
  33. package/sensor/dist/directory.js +5 -5
  34. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  35. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  36. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  49. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  50. package/sensor/dist/mcp/server-instructions.js +95 -95
  51. package/sensor/package.json +56 -56
  52. package/src/artifact-contracts-v2.mjs +325 -325
  53. package/src/artifact-contracts.mjs +895 -895
  54. package/src/boundary-compiler.mjs +712 -712
  55. package/src/boundary-observation-compiler-v2.mjs +344 -344
  56. package/src/bounded-seam.mjs +230 -230
  57. package/src/bridge-address.mjs +107 -107
  58. package/src/bridge-cli.mjs +297 -297
  59. package/src/bridge-config.mjs +346 -346
  60. package/src/bridge-daemon.mjs +378 -378
  61. package/src/bridge-launch-agent.mjs +323 -323
  62. package/src/bridge-registrar.mjs +102 -102
  63. package/src/bridge-server.mjs +376 -376
  64. package/src/cli-help.mjs +308 -308
  65. package/src/cli-stdio.mjs +40 -40
  66. package/src/control-compiler.mjs +532 -532
  67. package/src/dag-chain.mjs +261 -261
  68. package/src/factory-diagnostics.mjs +188 -188
  69. package/src/hash-chain.mjs +76 -76
  70. package/src/hooks-cli.mjs +1057 -1053
  71. package/src/isolation-runner.mjs +409 -409
  72. package/src/node-version-guard.mjs +44 -44
  73. package/src/project-cli.mjs +697 -697
  74. package/src/project-identity.mjs +130 -130
  75. package/src/rc1-black-box-oracle.mjs +906 -906
  76. package/src/rc1-comparison.mjs +154 -154
  77. package/src/rc1-evidence-bundle.mjs +456 -456
  78. package/src/rc1-v4-campaign.mjs +708 -708
  79. package/src/rc1-v4-transform.mjs +467 -467
  80. package/src/rc1-v5-artifact-set.mjs +855 -855
  81. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  82. package/src/rc1-v5-campaign.mjs +797 -797
  83. package/src/rc1-v5-transform.mjs +421 -421
  84. package/src/rc1-v6-artifact-set.mjs +807 -807
  85. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  86. package/src/rc1-v6-campaign.mjs +623 -623
  87. package/src/rc1-v6-causal-binding.mjs +473 -473
  88. package/src/rc1-v6-measurement.mjs +313 -313
  89. package/src/rc2-artifact-set.mjs +1584 -1584
  90. package/src/rc2-campaign.mjs +1506 -1502
  91. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  92. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  93. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  94. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  95. package/src/rc3-actual-dogfood.mjs +652 -652
  96. package/src/rc3-dogfood-scaffold.mjs +315 -315
  97. package/src/rc3-scripted-campaign.mjs +1383 -1383
  98. package/src/rc4-stage1-dogfood.mjs +673 -673
  99. package/src/runtime-adapter-registry.mjs +524 -520
  100. package/src/runtime-cli.mjs +4588 -4584
  101. package/src/runtime-contracts.mjs +824 -824
  102. package/src/runtime-control-store.mjs +604 -600
  103. package/src/runtime-controller-protocol.mjs +587 -587
  104. package/src/runtime-decision-verifier.mjs +701 -701
  105. package/src/runtime-diff-observer.mjs +361 -361
  106. package/src/runtime-direct-os-observer.mjs +301 -301
  107. package/src/runtime-driver-state.mjs +166 -162
  108. package/src/runtime-engine.mjs +779 -779
  109. package/src/runtime-errors.mjs +356 -356
  110. package/src/runtime-event-store.mjs +189 -189
  111. package/src/runtime-front-end.mjs +925 -925
  112. package/src/runtime-gate-store.mjs +481 -477
  113. package/src/runtime-hold-recompile.mjs +916 -916
  114. package/src/runtime-io-sentinel.mjs +391 -391
  115. package/src/runtime-lifecycle-lock.mjs +294 -290
  116. package/src/runtime-managed-supervisor.mjs +1490 -1490
  117. package/src/runtime-multi-epoch-store.mjs +838 -834
  118. package/src/runtime-projection.mjs +269 -269
  119. package/src/runtime-pull-intake.mjs +1192 -1188
  120. package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
  121. package/src/runtime-scripted-executor.mjs +163 -163
  122. package/src/runtime-scripted-worktree.mjs +104 -104
  123. package/src/runtime-seam-resolve.mjs +428 -428
  124. package/src/runtime-seam-treatment.mjs +173 -173
  125. package/src/runtime-socket-owner.mjs +125 -125
  126. package/src/runtime-work-order-contracts.mjs +91 -91
  127. package/src/runtime-work-order-controller.mjs +1171 -1167
  128. package/src/runtime-worktree-executor.mjs +199 -199
  129. package/src/schedulability-compiler-v2.mjs +303 -303
  130. package/src/schedulability-verifier-v2.mjs +317 -317
  131. package/src/seam-apply.mjs +549 -549
  132. package/src/seam-commit-shared.mjs +22 -22
  133. package/src/seam-commit-transform.mjs +81 -81
  134. package/src/seam-commit.mjs +18 -18
  135. package/src/seam-cost.mjs +322 -322
  136. package/src/seam-derivation.mjs +188 -188
  137. package/src/seam-gate.mjs +146 -146
  138. package/src/seam-proposal-contracts.mjs +446 -446
  139. package/src/seam-proposal-queries.mjs +521 -521
  140. package/src/seam-proposal.mjs +2011 -2011
  141. package/src/seam-ref.mjs +33 -33
  142. package/src/seam-rewrite.mjs +286 -286
  143. package/src/seam-transform.mjs +554 -554
  144. package/src/seam-verification.mjs +260 -260
  145. package/src/sensor-adapter.mjs +432 -432
  146. package/src/sensor-cli.mjs +139 -139
  147. package/src/sensor-diff.mjs +661 -661
  148. package/src/sensor-node-runtime.mjs +53 -53
  149. package/src/sensor-runtime.mjs +52 -52
  150. package/src/timestamp-contract.mjs +8 -8
  151. package/src/todo-audit-pending.mjs +91 -91
  152. package/src/todo-chain.mjs +178 -178
  153. package/src/todo-cli.mjs +3141 -3126
  154. package/src/todo-contracts.mjs +728 -728
  155. package/src/todo-dashboard-registry.mjs +573 -573
  156. package/src/todo-dispatch-shape.mjs +190 -190
  157. package/src/todo-gantt-html-independence.mjs +239 -239
  158. package/src/todo-gantt-html-shared.mjs +226 -226
  159. package/src/todo-gantt-html-style.mjs +131 -131
  160. package/src/todo-gantt-html.mjs +248 -248
  161. package/src/todo-gantt-layout.mjs +974 -974
  162. package/src/todo-gantt-live.mjs +361 -361
  163. package/src/todo-gantt-nested.mjs +263 -263
  164. package/src/todo-gantt-presentation.mjs +217 -217
  165. package/src/todo-gantt-scope.mjs +123 -123
  166. package/src/todo-gantt-svg.mjs +353 -353
  167. package/src/todo-independence-contracts.mjs +595 -595
  168. package/src/todo-independence-guidance.mjs +322 -322
  169. package/src/todo-independence.mjs +640 -640
  170. package/src/todo-markdown-renderer.mjs +260 -260
  171. package/src/todo-migration.mjs +448 -448
  172. package/src/todo-narrative-anchor.mjs +130 -130
  173. package/src/todo-note-store.mjs +629 -625
  174. package/src/todo-parallel-candidates.mjs +114 -114
  175. package/src/todo-revision.mjs +995 -995
  176. package/src/todo-split.mjs +472 -472
  177. package/src/todo-status.mjs +690 -690
  178. package/src/todo-store-git-transaction.mjs +418 -418
  179. package/src/todo-store.mjs +4322 -4314
  180. package/src/treatment-compiler.mjs +728 -728
  181. package/src/treatment-runner.mjs +656 -656
  182. package/src/witness-scaffold.mjs +180 -180
@@ -1,697 +1,697 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { constants as fsConstants } from 'node:fs';
3
- import { lstat, open, realpath } from 'node:fs/promises';
4
- import path from 'node:path';
5
-
6
- import {
7
- TODO_DESIGN_MEMO_PROMPT,
8
- canonicalizeTodoArtifact,
9
- exactRecord,
10
- explainTodoDesignMemo,
11
- isTodoDesignMemo,
12
- isStrictTodoTimestamp,
13
- isTodoDigest,
14
- isTodoIdentifier,
15
- isTodoRef,
16
- todoSelfDigest,
17
- } from './todo-contracts.mjs';
18
- import { projectTodoStatus } from './todo-status.mjs';
19
- import { readTodoPlanNotesForStatus } from './todo-note-store.mjs';
20
- import { projectIndependenceFrontier } from './todo-independence.mjs';
21
- import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
22
- import { isTodoIndependenceLegacyMarker } from './todo-independence-contracts.mjs';
23
- import { selectIndependenceGuidance } from './todo-independence-guidance.mjs';
24
- import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
25
- import { resolveProjectIdentity } from './project-identity.mjs';
26
- import {
27
- buildTodoPlan,
28
- createTodoStoreWriter,
29
- initializeAuthoredTodoStore,
30
- isPhaselessTodoPlanSchema,
31
- readTodoIndependenceArtifact,
32
- readTodoStore,
33
- TodoStoreError,
34
- } from './todo-store.mjs';
35
- import {
36
- assertTodoDispatchShapeReviewed,
37
- computeTodoDispatchShapeForPlan,
38
- } from './todo-dispatch-shape.mjs';
39
-
40
- const STORE_REF = '.lattice/todo';
41
- const MANIFEST_REF = `${STORE_REF}/manifest.json`;
42
- const MAX_INPUT_BYTES = 8_388_608;
43
- const STATUS_SCHEMA = 'lattice.project_status.v1';
44
- const SESSION_CONTEXT_SCHEMA = 'lattice.session_context.v1';
45
- /** HEADが読めない環境でも投影を組めるようにする。記録があるときは実HEADで置き換わる。 */
46
- const PLACEHOLDER_SHA = '0'.repeat(40);
47
- const CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v1';
48
- const PHASE_CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v2';
49
- const DECOUPLED_PHASE_CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v3';
50
- const MEMO_PHASE_CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v4';
51
- const CURRENT_CREATE_INPUT_SCHEMA = MEMO_PHASE_CREATE_INPUT_SCHEMA;
52
- const CURRENT_CREATE_SCHEMA_COMMAND = 'lattice plan create --schema-version 4 --json';
53
- const CREATE_RESULT_SCHEMA = 'lattice.plan_create_result.v1';
54
-
55
- function resolveRepoRoot(cwd) {
56
- try {
57
- return execFileSync('git', ['rev-parse', '--show-toplevel'], {
58
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
59
- }).replace(/\r?\n$/u, '');
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
- function gitHead(repoRoot) {
66
- try {
67
- return execFileSync('git', ['rev-parse', 'HEAD'], {
68
- cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
69
- }).replace(/\r?\n$/u, '');
70
- } catch {
71
- return null;
72
- }
73
- }
74
-
75
- function resultDigest(value) {
76
- return todoSelfDigest(value, 'result_digest');
77
- }
78
-
79
- function plain(value) {
80
- return value !== null && typeof value === 'object' && !Array.isArray(value)
81
- && Object.getPrototypeOf(value) === Object.prototype;
82
- }
83
-
84
- export function validateProjectStatus(value) {
85
- try {
86
- if (!exactRecord(value, [
87
- 'schema', 'cli', 'project', 'state', 'store', 'active_plans', 'active_runs',
88
- 'can_create_plan', 'next_action', 'result_digest',
89
- ]) || value.schema !== STATUS_SCHEMA
90
- || !exactRecord(value.cli, ['available', 'version']) || value.cli.available !== true
91
- || typeof value.cli.version !== 'string' || value.cli.version.length > 64
92
- || !['uninitialized', 'ready', 'active_run', 'invalid'].includes(value.state)
93
- || !exactRecord(value.store, ['ref', 'absolute_path']) || value.store.ref !== STORE_REF
94
- || (value.store.absolute_path !== null && typeof value.store.absolute_path !== 'string')
95
- || !Array.isArray(value.active_plans) || value.active_plans.length > 256
96
- || !value.active_plans.every((entry) => exactRecord(entry, ['plan_key', 'plan_version'])
97
- && isTodoIdentifier(entry.plan_key) && isTodoIdentifier(entry.plan_version))
98
- || !Array.isArray(value.active_runs) || value.active_runs.length > 2_000
99
- || !value.active_runs.every((entry) => exactRecord(entry, ['plan_key', 'task_id', 'label'])
100
- && isTodoIdentifier(entry.plan_key) && isTodoIdentifier(entry.task_id)
101
- && typeof entry.label === 'string' && [...entry.label].length > 0 && [...entry.label].length <= 160)
102
- || typeof value.can_create_plan !== 'boolean' || !plain(value.next_action)
103
- || typeof value.next_action.command !== 'string' || value.next_action.command.length === 0
104
- || !isTodoDigest(value.result_digest) || value.result_digest !== resultDigest(value)) return false;
105
- if (value.project !== null && (!exactRecord(value.project, ['root', 'git_head', 'project_id'])
106
- || typeof value.project.root !== 'string' || value.project.root.length === 0
107
- || (value.project.git_head !== null && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(value.project.git_head))
108
- || (value.project.project_id !== null && !isTodoIdentifier(value.project.project_id)))) return false;
109
- if (value.state === 'uninitialized') {
110
- return value.project !== null && value.project.project_id === null && value.can_create_plan === true
111
- && exactRecord(value.next_action, ['command', 'input_schema', 'schema_command'])
112
- && value.next_action.input_schema === CURRENT_CREATE_INPUT_SCHEMA
113
- && value.next_action.schema_command === CURRENT_CREATE_SCHEMA_COMMAND;
114
- }
115
- if (value.state === 'invalid') {
116
- return value.can_create_plan === false && exactRecord(value.next_action, ['command', 'reason'])
117
- && typeof value.next_action.reason === 'string' && value.next_action.reason.length > 0;
118
- }
119
- return value.project !== null && isTodoIdentifier(value.project.project_id)
120
- && value.can_create_plan === false && exactRecord(value.next_action, ['command', 'reason'])
121
- && typeof value.next_action.reason === 'string' && value.next_action.reason.length > 0
122
- && (value.state !== 'active_run' || value.active_runs.length > 0)
123
- && (value.state !== 'ready' || value.active_runs.length === 0);
124
- } catch { return false; }
125
- }
126
-
127
- /**
128
- * readyのあるplanについてだけ並列可否を要約する(ADR 0131 Decision 5)。
129
- *
130
- * store読みは呼び出し側が済ませている。ここが払うのはplanごとの小さな記録ファイルと、
131
- * 記録があるときだけのHEAD照合である。readyが無いplanは述べる対象が無いので載せない。
132
- */
133
- async function summarizeIndependence({ repoRoot, store, todo }) {
134
- const readyPlanKeys = [...new Set(todo.next_ready.map(({ plan_key: key }) => key))].sort();
135
- if (readyPlanKeys.length === 0) return [];
136
- const activeByPlan = new Map();
137
- for (const task of todo.active_set) {
138
- if (!activeByPlan.has(task.plan_key)) activeByPlan.set(task.plan_key, []);
139
- activeByPlan.get(task.plan_key).push(task.task_id);
140
- }
141
- let currentBaseSha = null;
142
- const summaries = [];
143
- for (const planKey of readyPlanKeys) {
144
- const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
145
- if (member === undefined) continue;
146
- let artifact = null;
147
- try {
148
- artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
149
- } catch (error) {
150
- // 読めない記録を「記録なし」へ丸めない。理由を載せて先へ進む。
151
- summaries.push({
152
- plan_key: planKey, coverage: null,
153
- guidance: { code: 'independence_unrecorded', message: null, next_action: 'none' },
154
- unreadable_reason: error instanceof TodoStoreError
155
- ? `${error.code}:${error.detail?.reason ?? error.message}` : 'independence_unreadable',
156
- parallel_groups: [], serialize_pair_count: 0,
157
- conflict_with_active_count: 0, unknown_task_ids: [],
158
- });
159
- continue;
160
- }
161
- if (currentBaseSha === null && artifact !== null) currentBaseSha = gitHead(repoRoot);
162
- const projected = projectIndependenceFrontier({
163
- artifact,
164
- readyTaskIds: todo.next_ready.filter((task) => task.plan_key === planKey)
165
- .map(({ task_id: taskId }) => taskId),
166
- activeTaskIds: activeByPlan.get(planKey) ?? [],
167
- plan: member.plan,
168
- currentBaseSha: currentBaseSha ?? PLACEHOLDER_SHA,
169
- changedPaths: null,
170
- });
171
- summaries.push({
172
- plan_key: planKey,
173
- coverage: projected.coverage,
174
- guidance: selectIndependenceGuidance({
175
- coverage: projected.coverage,
176
- contractSuperseded: isTodoIndependenceLegacyMarker(artifact),
177
- readyCount: todo.next_ready.filter((task) => task.plan_key === planKey).length,
178
- taskDeclared: projected.frontier.unknown
179
- .every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
180
- taskStale: projected.frontier.unknown
181
- .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
182
- conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
183
- conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
184
- coordinationMode: member.coordination?.mode ?? null,
185
- }),
186
- unreadable_reason: null,
187
- parallel_groups: projected.frontier.parallel_groups.map(({ task_ids: ids }) => [...ids]),
188
- serialize_pair_count: projected.frontier.serialize_pairs.length,
189
- conflict_with_active_count: projected.frontier.conflicts_with_active.length,
190
- unknown_task_ids: projected.frontier.unknown.map(({ task_id: taskId }) => taskId),
191
- });
192
- }
193
- return summaries;
194
- }
195
-
196
- function statusResult(fields) {
197
- const result = { schema: STATUS_SCHEMA, ...fields, result_digest: '' };
198
- result.result_digest = resultDigest(result);
199
- if (!validateProjectStatus(result)) throw new TypeError('project status result contract invalid');
200
- return result;
201
- }
202
-
203
- function invalidStatus({ cliVersion, repoRoot, reason, nextAction = null }) {
204
- return statusResult({
205
- cli: { available: true, version: cliVersion },
206
- project: repoRoot === null ? null : { root: repoRoot, git_head: gitHead(repoRoot), project_id: null },
207
- state: 'invalid',
208
- store: { ref: STORE_REF, absolute_path: repoRoot === null ? null : path.join(repoRoot, STORE_REF) },
209
- active_plans: [], active_runs: [], can_create_plan: false,
210
- next_action: nextAction ?? {
211
- command: repoRoot === null ? 'git status' : 'lattice todo verify', reason,
212
- },
213
- });
214
- }
215
-
216
- /**
217
- * discoveryとstore読みを1回で済ませ、status結果と(読めたなら)storeを返す。
218
- *
219
- * `runProjectStatus`と`runSessionContext`が同じ判定を二度書かないための共有点。
220
- * store読みはここでしか行わない——session開始経路が同じstoreを二度払っていたのが
221
- * ADR 0131で直した欠陥である。
222
- */
223
- async function resolveProjectState({ cwd, cliVersion }) {
224
- const repoRoot = resolveRepoRoot(cwd);
225
- if (repoRoot === null) {
226
- return { exitCode: 1, repoRoot: null, store: null, todo: null,
227
- result: invalidStatus({ cliVersion, repoRoot: null, reason: 'git_repository_unresolved' }) };
228
- }
229
- const storeAbsolute = path.join(repoRoot, STORE_REF);
230
- const manifestAbsolute = path.join(repoRoot, MANIFEST_REF);
231
- const latticeAbsolute = path.join(repoRoot, '.lattice');
232
- let latticeState;
233
- let storeState;
234
- let manifestState;
235
- const invalid = (reason) => ({
236
- exitCode: 1, repoRoot, store: null, todo: null,
237
- result: invalidStatus({ cliVersion, repoRoot, reason }),
238
- });
239
- try { latticeState = await lstat(latticeAbsolute); } catch (error) {
240
- if (error?.code !== 'ENOENT') return invalid('lattice_root_unreadable');
241
- }
242
- if (latticeState !== undefined && (latticeState.isSymbolicLink() || !latticeState.isDirectory())) {
243
- return invalid('lattice_root_invalid');
244
- }
245
- try { storeState = await lstat(storeAbsolute); } catch (error) {
246
- if (error?.code !== 'ENOENT') return invalid('store_unreadable');
247
- }
248
- try { manifestState = await lstat(manifestAbsolute); } catch (error) {
249
- if (error?.code !== 'ENOENT') return invalid('manifest_unreadable');
250
- }
251
- if (storeState === undefined && manifestState === undefined) {
252
- return { exitCode: 0, repoRoot, store: null, todo: null, result: statusResult({
253
- cli: { available: true, version: cliVersion },
254
- project: { root: repoRoot, git_head: gitHead(repoRoot), project_id: null },
255
- state: 'uninitialized',
256
- store: { ref: STORE_REF, absolute_path: storeAbsolute },
257
- active_plans: [], active_runs: [], can_create_plan: true,
258
- next_action: {
259
- command: 'lattice plan create --input .lattice/plan-create.json',
260
- input_schema: CURRENT_CREATE_INPUT_SCHEMA,
261
- schema_command: CURRENT_CREATE_SCHEMA_COMMAND,
262
- },
263
- }) };
264
- }
265
- if (storeState?.isSymbolicLink() || !storeState?.isDirectory()
266
- || manifestState?.isSymbolicLink() || !manifestState?.isFile()) {
267
- return invalid('store_layout_invalid');
268
- }
269
- try {
270
- const store = await readTodoStore({ repoRoot });
271
- const todo = projectTodoStatus(store, {
272
- planNotes: await readTodoPlanNotesForStatus({ repoRoot, store }),
273
- parallelCandidates: await readTodoParallelCandidatesForStatus({ repoRoot, store, gitHead }),
274
- });
275
- const activeRuns = todo.active_set.map((entry) => ({
276
- plan_key: entry.plan_key, task_id: entry.task_id, label: entry.label,
277
- }));
278
- const state = activeRuns.length > 0 ? 'active_run' : 'ready';
279
- const next = activeRuns.length > 0
280
- ? { command: 'lattice todo status', reason: 'active_run_present' }
281
- : todo.next_ready.length > 0
282
- ? { command: `lattice todo start --plan ${todo.next_ready[0].plan_key} --task ${todo.next_ready[0].task_id}${todo.next_ready.length > 1 ? ' --parallel-frontier' : ''}`,
283
- reason: todo.next_ready.length > 1 ? 'parallel_frontier_present' : 'next_ready_present' }
284
- // 監査待ちが在る限り「残作業なし」と答えない。優先順位はactive_run > next_ready >
285
- // audit_pending > なしで、ready frontierが在る間はADR 0063の並列開始コマンドが勝つ
286
- // ——ここを監査で上書きするとdispatchが再直列化する。監査待ちはtodo statusの
287
- // audit_pending欄に常在するので、順位を下げても消えない。
288
- // commandはverbatim実行可能で読み取り専用のものにする。`phase review`は
289
- // `--reason <text>`のplaceholderを含みjournalを書き換えるので置かない
290
- // (`todo phase status`の結果には既に両分岐のguidanceが載っている)。
291
- : todo.audit_pending.length > 0
292
- ? { command: `lattice todo phase status --plan ${todo.audit_pending[0].plan_key}`,
293
- reason: 'audit_pending' }
294
- : { command: 'lattice todo status', reason: 'no_ready_task' };
295
- const result = statusResult({
296
- cli: { available: true, version: cliVersion },
297
- project: { root: repoRoot, git_head: gitHead(repoRoot), project_id: store.project_id },
298
- state,
299
- store: { ref: STORE_REF, absolute_path: storeAbsolute },
300
- active_plans: todo.member_heads.map((entry) => ({
301
- plan_key: entry.plan_key, plan_version: entry.plan_version,
302
- })),
303
- active_runs: activeRuns,
304
- can_create_plan: false,
305
- next_action: next,
306
- });
307
- return { exitCode: 0, repoRoot, store, todo, result };
308
- } catch (error) {
309
- const reason = error instanceof TodoStoreError
310
- ? `${error.code}:${error.detail?.reason ?? error.message}` : 'store_validation_failed';
311
- return invalid(reason);
312
- }
313
- }
314
-
315
- export async function runProjectStatus({ cwd, stdout, cliVersion, env = process.env,
316
- ensureDashboardActivity = ensureTodoDashboardActivity }) {
317
- const state = await resolveProjectState({ cwd, cliVersion });
318
- // dashboard活動の登録はdiscovery面の副作用として維持する(ADR 0131 Decision 4で
319
- // session-context側だけが持たない、と決めた面である)。
320
- if (state.store !== null && env.LATTICE_DASHBOARD_AUTOSTART !== '0') {
321
- const identity = await resolveProjectIdentity({
322
- repoRoot: state.repoRoot, projectId: state.store.project_id, env,
323
- });
324
- const actorSession = env.LATTICE_TODO_ACTOR_SESSION;
325
- await ensureDashboardActivity({
326
- repoRoot: state.repoRoot, projectId: state.store.project_id,
327
- displayName: identity.displayName,
328
- sessionId: isTodoIdentifier(actorSession) ? actorSession : `status-${process.pid}`, env,
329
- });
330
- }
331
- stdout.write(`${JSON.stringify(state.result)}\n`);
332
- return state.exitCode;
333
- }
334
-
335
- /**
336
- * session開始時の現在地を1プロセス・1 store読みで返す(ADR 0131)。
337
- *
338
- * `lattice status`と`lattice todo status`は同じ`readTodoStore`を別プロセスで二重に払う。
339
- * hostのSessionStartはその両方を呼ぶため、storeが育ったprojectでは実行枠を超えて
340
- * 案内ごと捨てられていた。ここは合成であって置き換えではない——既存2面は不変で、
341
- * それぞれの消費者を持ち続ける。
342
- *
343
- * dashboard活動の登録は行わない。現在地を知るために呼ぶ面であり、常駐面を起こす面ではない。
344
- */
345
- export async function runSessionContext({ cwd, stdout, cliVersion }) {
346
- const state = await resolveProjectState({ cwd, cliVersion });
347
- const independence = state.store === null || state.todo === null
348
- ? [] : await summarizeIndependence({ repoRoot: state.repoRoot, store: state.store, todo: state.todo });
349
- const result = {
350
- schema: SESSION_CONTEXT_SCHEMA,
351
- // project discoveryの答えをそのまま埋める。hostは既存の検証器を再利用できる。
352
- status: state.result,
353
- // todoは`lattice todo status`のresultそのもの。新しい意味論を発明しない。
354
- todo: state.todo,
355
- independence,
356
- result_digest: '',
357
- };
358
- result.result_digest = todoSelfDigest(result, 'result_digest');
359
- stdout.write(`${JSON.stringify(result)}\n`);
360
- return state.exitCode;
361
- }
362
-
363
- async function readCanonicalInput(repoRoot, inputRef) {
364
- if (!isTodoRef(inputRef)) throw new TodoStoreError('INPUT_INVALID', 'input_ref_invalid');
365
- const root = await realpath(repoRoot);
366
- const absolute = path.resolve(root, inputRef);
367
- if (!absolute.startsWith(`${root}${path.sep}`)) throw new TodoStoreError('INPUT_INVALID', 'input_outside_repo');
368
- let cursor = root;
369
- try {
370
- for (const part of path.relative(root, absolute).split(path.sep)) {
371
- cursor = path.join(cursor, part);
372
- const entry = await lstat(cursor);
373
- if (entry.isSymbolicLink()) throw new TodoStoreError('INPUT_INVALID', 'input_path_symlink');
374
- }
375
- } catch (error) {
376
- if (error instanceof TodoStoreError) throw error;
377
- throw new TodoStoreError('INPUT_INVALID', 'input_unreadable');
378
- }
379
- const state = await lstat(absolute);
380
- if (!state.isFile() || await realpath(absolute) !== absolute) {
381
- throw new TodoStoreError('INPUT_INVALID', 'input_path_invalid');
382
- }
383
- if (state.size > MAX_INPUT_BYTES) throw new TodoStoreError('INPUT_INVALID', 'input_too_large');
384
- let handle;
385
- let bytes;
386
- try {
387
- handle = await open(absolute, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
388
- const opened = await handle.stat();
389
- if (opened.dev !== state.dev || opened.ino !== state.ino || opened.size !== state.size
390
- || opened.mtimeMs !== state.mtimeMs || opened.ctimeMs !== state.ctimeMs || !opened.isFile()) {
391
- throw new TodoStoreError('INPUT_INVALID', 'input_changed_during_validation');
392
- }
393
- if (opened.size > MAX_INPUT_BYTES) throw new TodoStoreError('INPUT_INVALID', 'input_too_large');
394
- const capture = Buffer.allocUnsafe(MAX_INPUT_BYTES + 1);
395
- let captured = 0;
396
- while (captured < capture.length) {
397
- const { bytesRead } = await handle.read(capture, captured, capture.length - captured, null);
398
- if (bytesRead === 0) break;
399
- captured += bytesRead;
400
- }
401
- if (captured > MAX_INPUT_BYTES) throw new TodoStoreError('INPUT_INVALID', 'input_too_large');
402
- bytes = capture.subarray(0, captured);
403
- const after = await lstat(absolute);
404
- if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
405
- || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs
406
- || await realpath(absolute) !== absolute) {
407
- throw new TodoStoreError('INPUT_INVALID', 'input_changed_during_validation');
408
- }
409
- } catch (error) {
410
- if (error instanceof TodoStoreError) throw error;
411
- throw new TodoStoreError('INPUT_INVALID', 'input_unreadable');
412
- } finally {
413
- await handle?.close();
414
- }
415
- if (bytes.length > MAX_INPUT_BYTES) throw new TodoStoreError('INPUT_INVALID', 'input_too_large');
416
- let text;
417
- try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
418
- catch { throw new TodoStoreError('INPUT_INVALID', 'input_utf8_invalid'); }
419
- if (!text.endsWith('\n') || text.includes('\r') || text.slice(0, -1).includes('\n')) {
420
- throw new TodoStoreError('INPUT_INVALID', 'input_bytes_noncanonical');
421
- }
422
- let value;
423
- try { value = JSON.parse(text.slice(0, -1)); } catch { throw new TodoStoreError('INPUT_INVALID', 'input_json_invalid'); }
424
- if (`${canonicalizeTodoArtifact(value)}\n` !== text) throw new TodoStoreError('INPUT_INVALID', 'input_bytes_noncanonical');
425
- return value;
426
- }
427
-
428
- function validateCreateInput(value) {
429
- const phaseInput = [PHASE_CREATE_INPUT_SCHEMA, DECOUPLED_PHASE_CREATE_INPUT_SCHEMA,
430
- MEMO_PHASE_CREATE_INPUT_SCHEMA].includes(value?.schema);
431
- const decoupledPhaseInput = [DECOUPLED_PHASE_CREATE_INPUT_SCHEMA,
432
- MEMO_PHASE_CREATE_INPUT_SCHEMA].includes(value?.schema);
433
- const keys = [
434
- 'schema', 'project_id', 'plan_key', 'plan_version', 'actor', 'recorded_at',
435
- 'tasks', 'hard_dependencies', 'joins', 'input_digest',
436
- ];
437
- if (phaseInput) keys.push('phases');
438
- if (decoupledPhaseInput) keys.push('phase_accept_dependencies');
439
- if (!exactRecord(value, keys) || ![
440
- CREATE_INPUT_SCHEMA, PHASE_CREATE_INPUT_SCHEMA, DECOUPLED_PHASE_CREATE_INPUT_SCHEMA,
441
- MEMO_PHASE_CREATE_INPUT_SCHEMA,
442
- ].includes(value.schema)
443
- || !isTodoIdentifier(value.project_id)
444
- || !isTodoIdentifier(value.plan_key) || !isTodoIdentifier(value.plan_version)
445
- || !exactRecord(value.actor, ['host', 'session', 'agent'])
446
- || ![value.actor.host, value.actor.session, value.actor.agent].every(isTodoIdentifier)
447
- || !isStrictTodoTimestamp(value.recorded_at) || !isTodoDigest(value.input_digest)
448
- || value.input_digest !== todoSelfDigest(value, 'input_digest')
449
- || !Array.isArray(value.tasks)
450
- || value.tasks.some((task) => task?.narrative_anchor !== null || task?.compile_binding !== null)) return false;
451
- try {
452
- buildTodoPlan({
453
- schema: value.schema === MEMO_PHASE_CREATE_INPUT_SCHEMA ? 'lattice.todo_plan.v7'
454
- : decoupledPhaseInput ? 'lattice.todo_plan.v5'
455
- : phaseInput ? 'lattice.todo_plan.v4' : 'lattice.todo_plan.v3', project_id: value.project_id,
456
- plan_key: value.plan_key, plan_version: value.plan_version,
457
- predecessor_plan_digest: null, tasks: value.tasks,
458
- hard_dependencies: value.hard_dependencies, joins: value.joins,
459
- ...(phaseInput ? { phases: value.phases } : {}),
460
- ...(decoupledPhaseInput ? { phase_accept_dependencies: value.phase_accept_dependencies } : {}),
461
- });
462
- return true;
463
- } catch { return false; }
464
- }
465
-
466
- export async function runPlanCreate({ cwd, inputRef, stdout, serializationReviewed = false }) {
467
- const repoRoot = resolveRepoRoot(cwd);
468
- if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
469
- const input = await readCanonicalInput(repoRoot, inputRef);
470
- if (input?.schema !== MEMO_PHASE_CREATE_INPUT_SCHEMA) {
471
- throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_retired', undefined, {
472
- violation_kind: 'const_mismatch', pointer: '/schema',
473
- expected: MEMO_PHASE_CREATE_INPUT_SCHEMA,
474
- actual: typeof input?.schema === 'string' ? input.schema : { type: typeof input?.schema },
475
- next_action: CURRENT_CREATE_SCHEMA_COMMAND,
476
- });
477
- }
478
- if (!Array.isArray(input.tasks)) {
479
- throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid', undefined, {
480
- violation_kind: 'type', pointer: '/tasks', expected: { type: 'array', min_items: 1 },
481
- actual: { type: input.tasks === null ? 'null' : typeof input.tasks },
482
- next_action: CURRENT_CREATE_SCHEMA_COMMAND,
483
- });
484
- }
485
- const invalidMemoIndex = input.tasks.findIndex((task) => !isTodoDesignMemo(task?.design_memo));
486
- if (invalidMemoIndex >= 0) {
487
- const explained = explainTodoDesignMemo(input.tasks[invalidMemoIndex]?.design_memo);
488
- throw new TodoStoreError('DESIGN_MEMO_REQUIRED', 'plan_create_design_memo_required', undefined, {
489
- violation_kind: explained.reason, pointer: `/tasks/${invalidMemoIndex}/design_memo`,
490
- expected: explained.expected, actual: explained.actual,
491
- prompt: TODO_DESIGN_MEMO_PROMPT, next_action: CURRENT_CREATE_SCHEMA_COMMAND,
492
- });
493
- }
494
- if (!validateCreateInput(input)) {
495
- const digestValid = isTodoDigest(input.input_digest)
496
- && input.input_digest === todoSelfDigest(input, 'input_digest');
497
- throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid', undefined, {
498
- violation_kind: digestValid ? 'schema_or_topology_invalid' : 'input_digest_mismatch',
499
- pointer: digestValid ? '/' : '/input_digest',
500
- expected: digestValid ? { schema: MEMO_PHASE_CREATE_INPUT_SCHEMA }
501
- : todoSelfDigest(input, 'input_digest'),
502
- actual: digestValid ? { validation: 'failed' }
503
- : { type: typeof input.input_digest, matches_canonical_input: false },
504
- next_action: 'correct_the_reported_pointer_then_rerun_plan_create',
505
- });
506
- }
507
- // dispatch_shapeのgateはstore初期化より前に判定する(拒否時にstoreへ何も書かないため、
508
- // 再考後の再実行がplan_key_already_existsで詰まらない)。
509
- const dispatchShape = computeTodoDispatchShapeForPlan({
510
- projectId: input.project_id,
511
- planKey: input.plan_key,
512
- taskIds: input.tasks.map(({ task_id: taskId }) => taskId),
513
- hardDependencies: input.hard_dependencies,
514
- joins: input.joins,
515
- });
516
- assertTodoDispatchShapeReviewed({ shape: dispatchShape, reviewed: serializationReviewed });
517
- const store = await initializeAuthoredTodoStore({
518
- repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
519
- projectId: input.project_id, repositories: [{ repo_id: 'self', path: '.' }],
520
- plan: {
521
- schema: input.schema === MEMO_PHASE_CREATE_INPUT_SCHEMA ? 'lattice.todo_plan.v7'
522
- : input.schema === DECOUPLED_PHASE_CREATE_INPUT_SCHEMA ? 'lattice.todo_plan.v5'
523
- : input.schema === PHASE_CREATE_INPUT_SCHEMA
524
- ? 'lattice.todo_plan.v4' : 'lattice.todo_plan.v3', project_id: input.project_id,
525
- plan_key: input.plan_key, plan_version: input.plan_version,
526
- predecessor_plan_digest: null, tasks: input.tasks,
527
- hard_dependencies: input.hard_dependencies, joins: input.joins,
528
- ...([PHASE_CREATE_INPUT_SCHEMA, DECOUPLED_PHASE_CREATE_INPUT_SCHEMA,
529
- MEMO_PHASE_CREATE_INPUT_SCHEMA].includes(input.schema) ? { phases: input.phases } : {}),
530
- ...([DECOUPLED_PHASE_CREATE_INPUT_SCHEMA, MEMO_PHASE_CREATE_INPUT_SCHEMA].includes(input.schema)
531
- ? { phase_accept_dependencies: input.phase_accept_dependencies } : {}),
532
- },
533
- genesis: { actor: input.actor, recorded_at: input.recorded_at, provenance: null },
534
- });
535
- const member = store.members[0];
536
- const result = {
537
- schema: CREATE_RESULT_SCHEMA, project_id: store.project_id,
538
- plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
539
- store_ref: STORE_REF, plan_ref: member.descriptor.plan_ref,
540
- journal_ref: member.descriptor.journal_ref, snapshot_ref: member.descriptor.snapshot_ref,
541
- plan_digest: member.plan.plan_digest,
542
- dispatch_shape: {
543
- task_count: dispatchShape.task_count,
544
- critical_path_length: dispatchShape.critical_path_length,
545
- max_frontier_width: dispatchShape.max_frontier_width,
546
- serialization_ratio: dispatchShape.serialization_ratio,
547
- },
548
- // ADR 0147裁定3: phase無し(v3)のplan createは拒否せず、終端監査が要ることを結果へ
549
- // 明示するに留める。phase入力(v4/v5)ならfalse——既存のPhase gateがそのまま重監査を担う。
550
- terminal_audit_required: isPhaselessTodoPlanSchema(member.plan.schema),
551
- result_digest: '',
552
- };
553
- result.result_digest = resultDigest(result);
554
- stdout.write(`${JSON.stringify(result)}\n`);
555
- return 0;
556
- }
557
-
558
- /**
559
- * `--schema --json`(版指定なし)の既定はCURRENT_CREATE_INPUT_SCHEMAと同じv4にする。
560
- * 既定がv1のままだと、素の`--schema`を叩いたAIが古いv1(Phaseを表現できない)を
561
- * 受け取り、実運用で通らない入力を作ってしまう(実際に踏んだ)。
562
- * `--schema-version 1`は互換のため引き続き取得できる(bin/lattice.mjs側で許可)。
563
- *
564
- * 「どの版を返したか」は返すJSON Schema自身の`title`(例: `lattice.plan_create_input.v4`)が
565
- * 既に機械可読に持っている。壊さずに追加のkeyを足す理由が無いので足さない。
566
- */
567
- export async function runPlanCreateSchema({ stdout, version = 4 }) {
568
- if (![1, 2, 3, 4].includes(version)) throw new TypeError('unsupported plan create schema version');
569
- const expected = version === 4 ? MEMO_PHASE_CREATE_INPUT_SCHEMA
570
- : version === 3 ? DECOUPLED_PHASE_CREATE_INPUT_SCHEMA
571
- : version === 2 ? PHASE_CREATE_INPUT_SCHEMA : CREATE_INPUT_SCHEMA;
572
- const schemaUrl = new URL(`../docs/schemas/lattice.plan_create_input.v${version}.schema.json`, import.meta.url);
573
- const handle = await open(schemaUrl, fsConstants.O_RDONLY);
574
- try {
575
- const schema = JSON.parse(await handle.readFile('utf8'));
576
- if (schema?.title !== expected) throw new TypeError('bundled plan create schema invalid');
577
- stdout.write(`${JSON.stringify(schema)}\n`);
578
- return 0;
579
- } finally {
580
- await handle.close();
581
- }
582
- }
583
-
584
- const PLAN_SHOW_RESULT_SCHEMA = 'lattice.plan_show_result.v1';
585
-
586
- /**
587
- * `todo bindings`はcompile_binding付きtaskだけを投影するので、通常planでは空配列を返す。
588
- * それを見て「planが空だ」と誤読された実績があるため、plan本体(task・依存・phase・状態)を
589
- * 1コマンドで読める面を別に持つ。読み出しは既存store readerとtodo status projectionの
590
- * 再利用に留め、journalを独自に再実装しない。
591
- */
592
- export async function runPlanShow({ cwd, planKey, stdout }) {
593
- const repoRoot = resolveRepoRoot(cwd);
594
- if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
595
- const store = await readTodoStore({ repoRoot });
596
- const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
597
- if (member === undefined) {
598
- // 既存read commands(independence compile等)と同じcode/reasonを踏襲する。
599
- // 未知のplan_keyを「store不整合」と同じ扱いにするのはこのCLI全体の既定であり、
600
- // ここだけ別codeへ逸れると呼び出し側の分岐が増える。
601
- throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, {
602
- plan_key: planKey, next_action: 'check_active_plans_via_status',
603
- });
604
- }
605
- const { plan, tasks, phases } = member;
606
- const phaseInput = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7']
607
- .includes(plan.schema);
608
- const stateByTaskId = new Map(tasks.map((state) => [state.task_id, state]));
609
- // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
610
- // (readTodoStoreが常に member.phases として埋める。ADR 0147)。
611
- const phaseStatusById = new Map((phases ?? []).map((phase) => [phase.phase_id, phase.status]));
612
-
613
- // 依存の本数: このplanの中でそのtaskへ入ってくるhard_dependencies辺と、joinで
614
- // 合流するafter辺の合計。cross-plan参照は数えない(plan showは単一planの投影のため)。
615
- const dependsOnCount = new Map(plan.tasks.map((task) => [task.task_id, 0]));
616
- for (const edge of plan.hard_dependencies) {
617
- if (edge.to.plan_key === planKey && dependsOnCount.has(edge.to.task_id)) {
618
- dependsOnCount.set(edge.to.task_id, dependsOnCount.get(edge.to.task_id) + 1);
619
- }
620
- }
621
- for (const join of plan.joins) {
622
- if (join.before.plan_key === planKey && dependsOnCount.has(join.before.task_id)) {
623
- dependsOnCount.set(join.before.task_id, dependsOnCount.get(join.before.task_id) + join.after.length);
624
- }
625
- }
626
-
627
- const taskList = plan.tasks.map((task) => ({
628
- task_id: task.task_id,
629
- title: task.title,
630
- lane: task.lane,
631
- phase_id: phaseInput ? task.phase_id : null,
632
- state: stateByTaskId.get(task.task_id).status,
633
- depends_on_count: dependsOnCount.get(task.task_id) ?? 0,
634
- }));
635
-
636
- const phaseList = phaseInput ? plan.phases.map((phase) => ({
637
- phase_id: phase.phase_id,
638
- title: phase.title,
639
- gate_policy: phase.gate_policy,
640
- predecessor_phase_ids: phase.predecessor_phase_ids,
641
- status: phaseStatusById.get(phase.phase_id) ?? null,
642
- })) : [];
643
-
644
- // dispatch形状はplan create時に既に計算している同じ関数を再利用する。
645
- // critical path長・frontier幅を独自に計算し直さない。
646
- const dispatchShape = computeTodoDispatchShapeForPlan({
647
- projectId: plan.project_id, planKey,
648
- taskIds: plan.tasks.map(({ task_id: taskId }) => taskId),
649
- hardDependencies: plan.hard_dependencies, joins: plan.joins,
650
- });
651
-
652
- const result = {
653
- schema: PLAN_SHOW_RESULT_SCHEMA,
654
- project_id: plan.project_id,
655
- plan_key: plan.plan_key,
656
- plan_version: plan.plan_version,
657
- plan_schema: plan.schema,
658
- has_phases: phaseInput,
659
- phases: phaseList,
660
- tasks: taskList,
661
- topology: {
662
- task_count: dispatchShape.task_count,
663
- critical_path_length: dispatchShape.critical_path_length,
664
- max_frontier_width: dispatchShape.max_frontier_width,
665
- serialization_ratio: dispatchShape.serialization_ratio,
666
- },
667
- result_digest: '',
668
- };
669
- result.result_digest = resultDigest(result);
670
- stdout.write(`${JSON.stringify(result)}\n`);
671
- return 0;
672
- }
673
-
674
- export function projectStatusFailure({ cwd, stdout, cliVersion, error }) {
675
- const projectRootConflict = error?.code === 'PROJECT_ROOT_CONFLICT';
676
- const result = invalidStatus({
677
- cliVersion, repoRoot: resolveRepoRoot(cwd),
678
- reason: projectRootConflict
679
- ? 'project_root_conflict'
680
- : `status_internal_failure:${error?.constructor?.name ?? 'Error'}`,
681
- ...(projectRootConflict ? { nextAction: {
682
- command: 'lattice todo dashboard adopt --json', reason: 'project_root_conflict',
683
- } } : {}),
684
- });
685
- stdout.write(`${JSON.stringify(result)}\n`);
686
- return 1;
687
- }
688
-
689
- export function projectCliFailure(stderr, error) {
690
- const payload = {
691
- schema: 'lattice.cli_error.v2', code: error?.code ?? 'INTERNAL_FAILURE',
692
- message: error?.message ?? 'project command failed',
693
- };
694
- if (error?.detail && Object.keys(error.detail).length > 0) payload.detail = error.detail;
695
- stderr.write(`${JSON.stringify(payload)}\n`);
696
- return 1;
697
- }
1
+ import { execFileSync } from 'node:child_process';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { lstat, open, realpath } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ TODO_DESIGN_MEMO_PROMPT,
8
+ canonicalizeTodoArtifact,
9
+ exactRecord,
10
+ explainTodoDesignMemo,
11
+ isTodoDesignMemo,
12
+ isStrictTodoTimestamp,
13
+ isTodoDigest,
14
+ isTodoIdentifier,
15
+ isTodoRef,
16
+ todoSelfDigest,
17
+ } from './todo-contracts.mjs';
18
+ import { projectTodoStatus } from './todo-status.mjs';
19
+ import { readTodoPlanNotesForStatus } from './todo-note-store.mjs';
20
+ import { projectIndependenceFrontier } from './todo-independence.mjs';
21
+ import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
22
+ import { isTodoIndependenceLegacyMarker } from './todo-independence-contracts.mjs';
23
+ import { selectIndependenceGuidance } from './todo-independence-guidance.mjs';
24
+ import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
25
+ import { resolveProjectIdentity } from './project-identity.mjs';
26
+ import {
27
+ buildTodoPlan,
28
+ createTodoStoreWriter,
29
+ initializeAuthoredTodoStore,
30
+ isPhaselessTodoPlanSchema,
31
+ readTodoIndependenceArtifact,
32
+ readTodoStore,
33
+ TodoStoreError,
34
+ } from './todo-store.mjs';
35
+ import {
36
+ assertTodoDispatchShapeReviewed,
37
+ computeTodoDispatchShapeForPlan,
38
+ } from './todo-dispatch-shape.mjs';
39
+
40
+ const STORE_REF = '.lattice/todo';
41
+ const MANIFEST_REF = `${STORE_REF}/manifest.json`;
42
+ const MAX_INPUT_BYTES = 8_388_608;
43
+ const STATUS_SCHEMA = 'lattice.project_status.v1';
44
+ const SESSION_CONTEXT_SCHEMA = 'lattice.session_context.v1';
45
+ /** HEADが読めない環境でも投影を組めるようにする。記録があるときは実HEADで置き換わる。 */
46
+ const PLACEHOLDER_SHA = '0'.repeat(40);
47
+ const CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v1';
48
+ const PHASE_CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v2';
49
+ const DECOUPLED_PHASE_CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v3';
50
+ const MEMO_PHASE_CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v4';
51
+ const CURRENT_CREATE_INPUT_SCHEMA = MEMO_PHASE_CREATE_INPUT_SCHEMA;
52
+ const CURRENT_CREATE_SCHEMA_COMMAND = 'lattice plan create --schema-version 4 --json';
53
+ const CREATE_RESULT_SCHEMA = 'lattice.plan_create_result.v1';
54
+
55
+ function resolveRepoRoot(cwd) {
56
+ try {
57
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
58
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
59
+ }).replace(/\r?\n$/u, '');
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function gitHead(repoRoot) {
66
+ try {
67
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
68
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
69
+ }).replace(/\r?\n$/u, '');
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function resultDigest(value) {
76
+ return todoSelfDigest(value, 'result_digest');
77
+ }
78
+
79
+ function plain(value) {
80
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
81
+ && Object.getPrototypeOf(value) === Object.prototype;
82
+ }
83
+
84
+ export function validateProjectStatus(value) {
85
+ try {
86
+ if (!exactRecord(value, [
87
+ 'schema', 'cli', 'project', 'state', 'store', 'active_plans', 'active_runs',
88
+ 'can_create_plan', 'next_action', 'result_digest',
89
+ ]) || value.schema !== STATUS_SCHEMA
90
+ || !exactRecord(value.cli, ['available', 'version']) || value.cli.available !== true
91
+ || typeof value.cli.version !== 'string' || value.cli.version.length > 64
92
+ || !['uninitialized', 'ready', 'active_run', 'invalid'].includes(value.state)
93
+ || !exactRecord(value.store, ['ref', 'absolute_path']) || value.store.ref !== STORE_REF
94
+ || (value.store.absolute_path !== null && typeof value.store.absolute_path !== 'string')
95
+ || !Array.isArray(value.active_plans) || value.active_plans.length > 256
96
+ || !value.active_plans.every((entry) => exactRecord(entry, ['plan_key', 'plan_version'])
97
+ && isTodoIdentifier(entry.plan_key) && isTodoIdentifier(entry.plan_version))
98
+ || !Array.isArray(value.active_runs) || value.active_runs.length > 2_000
99
+ || !value.active_runs.every((entry) => exactRecord(entry, ['plan_key', 'task_id', 'label'])
100
+ && isTodoIdentifier(entry.plan_key) && isTodoIdentifier(entry.task_id)
101
+ && typeof entry.label === 'string' && [...entry.label].length > 0 && [...entry.label].length <= 160)
102
+ || typeof value.can_create_plan !== 'boolean' || !plain(value.next_action)
103
+ || typeof value.next_action.command !== 'string' || value.next_action.command.length === 0
104
+ || !isTodoDigest(value.result_digest) || value.result_digest !== resultDigest(value)) return false;
105
+ if (value.project !== null && (!exactRecord(value.project, ['root', 'git_head', 'project_id'])
106
+ || typeof value.project.root !== 'string' || value.project.root.length === 0
107
+ || (value.project.git_head !== null && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(value.project.git_head))
108
+ || (value.project.project_id !== null && !isTodoIdentifier(value.project.project_id)))) return false;
109
+ if (value.state === 'uninitialized') {
110
+ return value.project !== null && value.project.project_id === null && value.can_create_plan === true
111
+ && exactRecord(value.next_action, ['command', 'input_schema', 'schema_command'])
112
+ && value.next_action.input_schema === CURRENT_CREATE_INPUT_SCHEMA
113
+ && value.next_action.schema_command === CURRENT_CREATE_SCHEMA_COMMAND;
114
+ }
115
+ if (value.state === 'invalid') {
116
+ return value.can_create_plan === false && exactRecord(value.next_action, ['command', 'reason'])
117
+ && typeof value.next_action.reason === 'string' && value.next_action.reason.length > 0;
118
+ }
119
+ return value.project !== null && isTodoIdentifier(value.project.project_id)
120
+ && value.can_create_plan === false && exactRecord(value.next_action, ['command', 'reason'])
121
+ && typeof value.next_action.reason === 'string' && value.next_action.reason.length > 0
122
+ && (value.state !== 'active_run' || value.active_runs.length > 0)
123
+ && (value.state !== 'ready' || value.active_runs.length === 0);
124
+ } catch { return false; }
125
+ }
126
+
127
+ /**
128
+ * readyのあるplanについてだけ並列可否を要約する(ADR 0131 Decision 5)。
129
+ *
130
+ * store読みは呼び出し側が済ませている。ここが払うのはplanごとの小さな記録ファイルと、
131
+ * 記録があるときだけのHEAD照合である。readyが無いplanは述べる対象が無いので載せない。
132
+ */
133
+ async function summarizeIndependence({ repoRoot, store, todo }) {
134
+ const readyPlanKeys = [...new Set(todo.next_ready.map(({ plan_key: key }) => key))].sort();
135
+ if (readyPlanKeys.length === 0) return [];
136
+ const activeByPlan = new Map();
137
+ for (const task of todo.active_set) {
138
+ if (!activeByPlan.has(task.plan_key)) activeByPlan.set(task.plan_key, []);
139
+ activeByPlan.get(task.plan_key).push(task.task_id);
140
+ }
141
+ let currentBaseSha = null;
142
+ const summaries = [];
143
+ for (const planKey of readyPlanKeys) {
144
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
145
+ if (member === undefined) continue;
146
+ let artifact = null;
147
+ try {
148
+ artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
149
+ } catch (error) {
150
+ // 読めない記録を「記録なし」へ丸めない。理由を載せて先へ進む。
151
+ summaries.push({
152
+ plan_key: planKey, coverage: null,
153
+ guidance: { code: 'independence_unrecorded', message: null, next_action: 'none' },
154
+ unreadable_reason: error instanceof TodoStoreError
155
+ ? `${error.code}:${error.detail?.reason ?? error.message}` : 'independence_unreadable',
156
+ parallel_groups: [], serialize_pair_count: 0,
157
+ conflict_with_active_count: 0, unknown_task_ids: [],
158
+ });
159
+ continue;
160
+ }
161
+ if (currentBaseSha === null && artifact !== null) currentBaseSha = gitHead(repoRoot);
162
+ const projected = projectIndependenceFrontier({
163
+ artifact,
164
+ readyTaskIds: todo.next_ready.filter((task) => task.plan_key === planKey)
165
+ .map(({ task_id: taskId }) => taskId),
166
+ activeTaskIds: activeByPlan.get(planKey) ?? [],
167
+ plan: member.plan,
168
+ currentBaseSha: currentBaseSha ?? PLACEHOLDER_SHA,
169
+ changedPaths: null,
170
+ });
171
+ summaries.push({
172
+ plan_key: planKey,
173
+ coverage: projected.coverage,
174
+ guidance: selectIndependenceGuidance({
175
+ coverage: projected.coverage,
176
+ contractSuperseded: isTodoIndependenceLegacyMarker(artifact),
177
+ readyCount: todo.next_ready.filter((task) => task.plan_key === planKey).length,
178
+ taskDeclared: projected.frontier.unknown
179
+ .every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
180
+ taskStale: projected.frontier.unknown
181
+ .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
182
+ conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
183
+ conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
184
+ coordinationMode: member.coordination?.mode ?? null,
185
+ }),
186
+ unreadable_reason: null,
187
+ parallel_groups: projected.frontier.parallel_groups.map(({ task_ids: ids }) => [...ids]),
188
+ serialize_pair_count: projected.frontier.serialize_pairs.length,
189
+ conflict_with_active_count: projected.frontier.conflicts_with_active.length,
190
+ unknown_task_ids: projected.frontier.unknown.map(({ task_id: taskId }) => taskId),
191
+ });
192
+ }
193
+ return summaries;
194
+ }
195
+
196
+ function statusResult(fields) {
197
+ const result = { schema: STATUS_SCHEMA, ...fields, result_digest: '' };
198
+ result.result_digest = resultDigest(result);
199
+ if (!validateProjectStatus(result)) throw new TypeError('project status result contract invalid');
200
+ return result;
201
+ }
202
+
203
+ function invalidStatus({ cliVersion, repoRoot, reason, nextAction = null }) {
204
+ return statusResult({
205
+ cli: { available: true, version: cliVersion },
206
+ project: repoRoot === null ? null : { root: repoRoot, git_head: gitHead(repoRoot), project_id: null },
207
+ state: 'invalid',
208
+ store: { ref: STORE_REF, absolute_path: repoRoot === null ? null : path.join(repoRoot, STORE_REF) },
209
+ active_plans: [], active_runs: [], can_create_plan: false,
210
+ next_action: nextAction ?? {
211
+ command: repoRoot === null ? 'git status' : 'lattice todo verify', reason,
212
+ },
213
+ });
214
+ }
215
+
216
+ /**
217
+ * discoveryとstore読みを1回で済ませ、status結果と(読めたなら)storeを返す。
218
+ *
219
+ * `runProjectStatus`と`runSessionContext`が同じ判定を二度書かないための共有点。
220
+ * store読みはここでしか行わない——session開始経路が同じstoreを二度払っていたのが
221
+ * ADR 0131で直した欠陥である。
222
+ */
223
+ async function resolveProjectState({ cwd, cliVersion }) {
224
+ const repoRoot = resolveRepoRoot(cwd);
225
+ if (repoRoot === null) {
226
+ return { exitCode: 1, repoRoot: null, store: null, todo: null,
227
+ result: invalidStatus({ cliVersion, repoRoot: null, reason: 'git_repository_unresolved' }) };
228
+ }
229
+ const storeAbsolute = path.join(repoRoot, STORE_REF);
230
+ const manifestAbsolute = path.join(repoRoot, MANIFEST_REF);
231
+ const latticeAbsolute = path.join(repoRoot, '.lattice');
232
+ let latticeState;
233
+ let storeState;
234
+ let manifestState;
235
+ const invalid = (reason) => ({
236
+ exitCode: 1, repoRoot, store: null, todo: null,
237
+ result: invalidStatus({ cliVersion, repoRoot, reason }),
238
+ });
239
+ try { latticeState = await lstat(latticeAbsolute); } catch (error) {
240
+ if (error?.code !== 'ENOENT') return invalid('lattice_root_unreadable');
241
+ }
242
+ if (latticeState !== undefined && (latticeState.isSymbolicLink() || !latticeState.isDirectory())) {
243
+ return invalid('lattice_root_invalid');
244
+ }
245
+ try { storeState = await lstat(storeAbsolute); } catch (error) {
246
+ if (error?.code !== 'ENOENT') return invalid('store_unreadable');
247
+ }
248
+ try { manifestState = await lstat(manifestAbsolute); } catch (error) {
249
+ if (error?.code !== 'ENOENT') return invalid('manifest_unreadable');
250
+ }
251
+ if (storeState === undefined && manifestState === undefined) {
252
+ return { exitCode: 0, repoRoot, store: null, todo: null, result: statusResult({
253
+ cli: { available: true, version: cliVersion },
254
+ project: { root: repoRoot, git_head: gitHead(repoRoot), project_id: null },
255
+ state: 'uninitialized',
256
+ store: { ref: STORE_REF, absolute_path: storeAbsolute },
257
+ active_plans: [], active_runs: [], can_create_plan: true,
258
+ next_action: {
259
+ command: 'lattice plan create --input .lattice/plan-create.json',
260
+ input_schema: CURRENT_CREATE_INPUT_SCHEMA,
261
+ schema_command: CURRENT_CREATE_SCHEMA_COMMAND,
262
+ },
263
+ }) };
264
+ }
265
+ if (storeState?.isSymbolicLink() || !storeState?.isDirectory()
266
+ || manifestState?.isSymbolicLink() || !manifestState?.isFile()) {
267
+ return invalid('store_layout_invalid');
268
+ }
269
+ try {
270
+ const store = await readTodoStore({ repoRoot });
271
+ const todo = projectTodoStatus(store, {
272
+ planNotes: await readTodoPlanNotesForStatus({ repoRoot, store }),
273
+ parallelCandidates: await readTodoParallelCandidatesForStatus({ repoRoot, store, gitHead }),
274
+ });
275
+ const activeRuns = todo.active_set.map((entry) => ({
276
+ plan_key: entry.plan_key, task_id: entry.task_id, label: entry.label,
277
+ }));
278
+ const state = activeRuns.length > 0 ? 'active_run' : 'ready';
279
+ const next = activeRuns.length > 0
280
+ ? { command: 'lattice todo status', reason: 'active_run_present' }
281
+ : todo.next_ready.length > 0
282
+ ? { command: `lattice todo start --plan ${todo.next_ready[0].plan_key} --task ${todo.next_ready[0].task_id}${todo.next_ready.length > 1 ? ' --parallel-frontier' : ''}`,
283
+ reason: todo.next_ready.length > 1 ? 'parallel_frontier_present' : 'next_ready_present' }
284
+ // 監査待ちが在る限り「残作業なし」と答えない。優先順位はactive_run > next_ready >
285
+ // audit_pending > なしで、ready frontierが在る間はADR 0063の並列開始コマンドが勝つ
286
+ // ——ここを監査で上書きするとdispatchが再直列化する。監査待ちはtodo statusの
287
+ // audit_pending欄に常在するので、順位を下げても消えない。
288
+ // commandはverbatim実行可能で読み取り専用のものにする。`phase review`は
289
+ // `--reason <text>`のplaceholderを含みjournalを書き換えるので置かない
290
+ // (`todo phase status`の結果には既に両分岐のguidanceが載っている)。
291
+ : todo.audit_pending.length > 0
292
+ ? { command: `lattice todo phase status --plan ${todo.audit_pending[0].plan_key}`,
293
+ reason: 'audit_pending' }
294
+ : { command: 'lattice todo status', reason: 'no_ready_task' };
295
+ const result = statusResult({
296
+ cli: { available: true, version: cliVersion },
297
+ project: { root: repoRoot, git_head: gitHead(repoRoot), project_id: store.project_id },
298
+ state,
299
+ store: { ref: STORE_REF, absolute_path: storeAbsolute },
300
+ active_plans: todo.member_heads.map((entry) => ({
301
+ plan_key: entry.plan_key, plan_version: entry.plan_version,
302
+ })),
303
+ active_runs: activeRuns,
304
+ can_create_plan: false,
305
+ next_action: next,
306
+ });
307
+ return { exitCode: 0, repoRoot, store, todo, result };
308
+ } catch (error) {
309
+ const reason = error instanceof TodoStoreError
310
+ ? `${error.code}:${error.detail?.reason ?? error.message}` : 'store_validation_failed';
311
+ return invalid(reason);
312
+ }
313
+ }
314
+
315
+ export async function runProjectStatus({ cwd, stdout, cliVersion, env = process.env,
316
+ ensureDashboardActivity = ensureTodoDashboardActivity }) {
317
+ const state = await resolveProjectState({ cwd, cliVersion });
318
+ // dashboard活動の登録はdiscovery面の副作用として維持する(ADR 0131 Decision 4で
319
+ // session-context側だけが持たない、と決めた面である)。
320
+ if (state.store !== null && env.LATTICE_DASHBOARD_AUTOSTART !== '0') {
321
+ const identity = await resolveProjectIdentity({
322
+ repoRoot: state.repoRoot, projectId: state.store.project_id, env,
323
+ });
324
+ const actorSession = env.LATTICE_TODO_ACTOR_SESSION;
325
+ await ensureDashboardActivity({
326
+ repoRoot: state.repoRoot, projectId: state.store.project_id,
327
+ displayName: identity.displayName,
328
+ sessionId: isTodoIdentifier(actorSession) ? actorSession : `status-${process.pid}`, env,
329
+ });
330
+ }
331
+ stdout.write(`${JSON.stringify(state.result)}\n`);
332
+ return state.exitCode;
333
+ }
334
+
335
+ /**
336
+ * session開始時の現在地を1プロセス・1 store読みで返す(ADR 0131)。
337
+ *
338
+ * `lattice status`と`lattice todo status`は同じ`readTodoStore`を別プロセスで二重に払う。
339
+ * hostのSessionStartはその両方を呼ぶため、storeが育ったprojectでは実行枠を超えて
340
+ * 案内ごと捨てられていた。ここは合成であって置き換えではない——既存2面は不変で、
341
+ * それぞれの消費者を持ち続ける。
342
+ *
343
+ * dashboard活動の登録は行わない。現在地を知るために呼ぶ面であり、常駐面を起こす面ではない。
344
+ */
345
+ export async function runSessionContext({ cwd, stdout, cliVersion }) {
346
+ const state = await resolveProjectState({ cwd, cliVersion });
347
+ const independence = state.store === null || state.todo === null
348
+ ? [] : await summarizeIndependence({ repoRoot: state.repoRoot, store: state.store, todo: state.todo });
349
+ const result = {
350
+ schema: SESSION_CONTEXT_SCHEMA,
351
+ // project discoveryの答えをそのまま埋める。hostは既存の検証器を再利用できる。
352
+ status: state.result,
353
+ // todoは`lattice todo status`のresultそのもの。新しい意味論を発明しない。
354
+ todo: state.todo,
355
+ independence,
356
+ result_digest: '',
357
+ };
358
+ result.result_digest = todoSelfDigest(result, 'result_digest');
359
+ stdout.write(`${JSON.stringify(result)}\n`);
360
+ return state.exitCode;
361
+ }
362
+
363
+ async function readCanonicalInput(repoRoot, inputRef) {
364
+ if (!isTodoRef(inputRef)) throw new TodoStoreError('INPUT_INVALID', 'input_ref_invalid');
365
+ const root = await realpath(repoRoot);
366
+ const absolute = path.resolve(root, inputRef);
367
+ if (!absolute.startsWith(`${root}${path.sep}`)) throw new TodoStoreError('INPUT_INVALID', 'input_outside_repo');
368
+ let cursor = root;
369
+ try {
370
+ for (const part of path.relative(root, absolute).split(path.sep)) {
371
+ cursor = path.join(cursor, part);
372
+ const entry = await lstat(cursor);
373
+ if (entry.isSymbolicLink()) throw new TodoStoreError('INPUT_INVALID', 'input_path_symlink');
374
+ }
375
+ } catch (error) {
376
+ if (error instanceof TodoStoreError) throw error;
377
+ throw new TodoStoreError('INPUT_INVALID', 'input_unreadable');
378
+ }
379
+ const state = await lstat(absolute);
380
+ if (!state.isFile() || await realpath(absolute) !== absolute) {
381
+ throw new TodoStoreError('INPUT_INVALID', 'input_path_invalid');
382
+ }
383
+ if (state.size > MAX_INPUT_BYTES) throw new TodoStoreError('INPUT_INVALID', 'input_too_large');
384
+ let handle;
385
+ let bytes;
386
+ try {
387
+ handle = await open(absolute, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
388
+ const opened = await handle.stat();
389
+ if (opened.dev !== state.dev || opened.ino !== state.ino || opened.size !== state.size
390
+ || opened.mtimeMs !== state.mtimeMs || opened.ctimeMs !== state.ctimeMs || !opened.isFile()) {
391
+ throw new TodoStoreError('INPUT_INVALID', 'input_changed_during_validation');
392
+ }
393
+ if (opened.size > MAX_INPUT_BYTES) throw new TodoStoreError('INPUT_INVALID', 'input_too_large');
394
+ const capture = Buffer.allocUnsafe(MAX_INPUT_BYTES + 1);
395
+ let captured = 0;
396
+ while (captured < capture.length) {
397
+ const { bytesRead } = await handle.read(capture, captured, capture.length - captured, null);
398
+ if (bytesRead === 0) break;
399
+ captured += bytesRead;
400
+ }
401
+ if (captured > MAX_INPUT_BYTES) throw new TodoStoreError('INPUT_INVALID', 'input_too_large');
402
+ bytes = capture.subarray(0, captured);
403
+ const after = await lstat(absolute);
404
+ if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
405
+ || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs
406
+ || await realpath(absolute) !== absolute) {
407
+ throw new TodoStoreError('INPUT_INVALID', 'input_changed_during_validation');
408
+ }
409
+ } catch (error) {
410
+ if (error instanceof TodoStoreError) throw error;
411
+ throw new TodoStoreError('INPUT_INVALID', 'input_unreadable');
412
+ } finally {
413
+ await handle?.close();
414
+ }
415
+ if (bytes.length > MAX_INPUT_BYTES) throw new TodoStoreError('INPUT_INVALID', 'input_too_large');
416
+ let text;
417
+ try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
418
+ catch { throw new TodoStoreError('INPUT_INVALID', 'input_utf8_invalid'); }
419
+ if (!text.endsWith('\n') || text.includes('\r') || text.slice(0, -1).includes('\n')) {
420
+ throw new TodoStoreError('INPUT_INVALID', 'input_bytes_noncanonical');
421
+ }
422
+ let value;
423
+ try { value = JSON.parse(text.slice(0, -1)); } catch { throw new TodoStoreError('INPUT_INVALID', 'input_json_invalid'); }
424
+ if (`${canonicalizeTodoArtifact(value)}\n` !== text) throw new TodoStoreError('INPUT_INVALID', 'input_bytes_noncanonical');
425
+ return value;
426
+ }
427
+
428
+ function validateCreateInput(value) {
429
+ const phaseInput = [PHASE_CREATE_INPUT_SCHEMA, DECOUPLED_PHASE_CREATE_INPUT_SCHEMA,
430
+ MEMO_PHASE_CREATE_INPUT_SCHEMA].includes(value?.schema);
431
+ const decoupledPhaseInput = [DECOUPLED_PHASE_CREATE_INPUT_SCHEMA,
432
+ MEMO_PHASE_CREATE_INPUT_SCHEMA].includes(value?.schema);
433
+ const keys = [
434
+ 'schema', 'project_id', 'plan_key', 'plan_version', 'actor', 'recorded_at',
435
+ 'tasks', 'hard_dependencies', 'joins', 'input_digest',
436
+ ];
437
+ if (phaseInput) keys.push('phases');
438
+ if (decoupledPhaseInput) keys.push('phase_accept_dependencies');
439
+ if (!exactRecord(value, keys) || ![
440
+ CREATE_INPUT_SCHEMA, PHASE_CREATE_INPUT_SCHEMA, DECOUPLED_PHASE_CREATE_INPUT_SCHEMA,
441
+ MEMO_PHASE_CREATE_INPUT_SCHEMA,
442
+ ].includes(value.schema)
443
+ || !isTodoIdentifier(value.project_id)
444
+ || !isTodoIdentifier(value.plan_key) || !isTodoIdentifier(value.plan_version)
445
+ || !exactRecord(value.actor, ['host', 'session', 'agent'])
446
+ || ![value.actor.host, value.actor.session, value.actor.agent].every(isTodoIdentifier)
447
+ || !isStrictTodoTimestamp(value.recorded_at) || !isTodoDigest(value.input_digest)
448
+ || value.input_digest !== todoSelfDigest(value, 'input_digest')
449
+ || !Array.isArray(value.tasks)
450
+ || value.tasks.some((task) => task?.narrative_anchor !== null || task?.compile_binding !== null)) return false;
451
+ try {
452
+ buildTodoPlan({
453
+ schema: value.schema === MEMO_PHASE_CREATE_INPUT_SCHEMA ? 'lattice.todo_plan.v7'
454
+ : decoupledPhaseInput ? 'lattice.todo_plan.v5'
455
+ : phaseInput ? 'lattice.todo_plan.v4' : 'lattice.todo_plan.v3', project_id: value.project_id,
456
+ plan_key: value.plan_key, plan_version: value.plan_version,
457
+ predecessor_plan_digest: null, tasks: value.tasks,
458
+ hard_dependencies: value.hard_dependencies, joins: value.joins,
459
+ ...(phaseInput ? { phases: value.phases } : {}),
460
+ ...(decoupledPhaseInput ? { phase_accept_dependencies: value.phase_accept_dependencies } : {}),
461
+ });
462
+ return true;
463
+ } catch { return false; }
464
+ }
465
+
466
+ export async function runPlanCreate({ cwd, inputRef, stdout, serializationReviewed = false }) {
467
+ const repoRoot = resolveRepoRoot(cwd);
468
+ if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
469
+ const input = await readCanonicalInput(repoRoot, inputRef);
470
+ if (input?.schema !== MEMO_PHASE_CREATE_INPUT_SCHEMA) {
471
+ throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_retired', undefined, {
472
+ violation_kind: 'const_mismatch', pointer: '/schema',
473
+ expected: MEMO_PHASE_CREATE_INPUT_SCHEMA,
474
+ actual: typeof input?.schema === 'string' ? input.schema : { type: typeof input?.schema },
475
+ next_action: CURRENT_CREATE_SCHEMA_COMMAND,
476
+ });
477
+ }
478
+ if (!Array.isArray(input.tasks)) {
479
+ throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid', undefined, {
480
+ violation_kind: 'type', pointer: '/tasks', expected: { type: 'array', min_items: 1 },
481
+ actual: { type: input.tasks === null ? 'null' : typeof input.tasks },
482
+ next_action: CURRENT_CREATE_SCHEMA_COMMAND,
483
+ });
484
+ }
485
+ const invalidMemoIndex = input.tasks.findIndex((task) => !isTodoDesignMemo(task?.design_memo));
486
+ if (invalidMemoIndex >= 0) {
487
+ const explained = explainTodoDesignMemo(input.tasks[invalidMemoIndex]?.design_memo);
488
+ throw new TodoStoreError('DESIGN_MEMO_REQUIRED', 'plan_create_design_memo_required', undefined, {
489
+ violation_kind: explained.reason, pointer: `/tasks/${invalidMemoIndex}/design_memo`,
490
+ expected: explained.expected, actual: explained.actual,
491
+ prompt: TODO_DESIGN_MEMO_PROMPT, next_action: CURRENT_CREATE_SCHEMA_COMMAND,
492
+ });
493
+ }
494
+ if (!validateCreateInput(input)) {
495
+ const digestValid = isTodoDigest(input.input_digest)
496
+ && input.input_digest === todoSelfDigest(input, 'input_digest');
497
+ throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid', undefined, {
498
+ violation_kind: digestValid ? 'schema_or_topology_invalid' : 'input_digest_mismatch',
499
+ pointer: digestValid ? '/' : '/input_digest',
500
+ expected: digestValid ? { schema: MEMO_PHASE_CREATE_INPUT_SCHEMA }
501
+ : todoSelfDigest(input, 'input_digest'),
502
+ actual: digestValid ? { validation: 'failed' }
503
+ : { type: typeof input.input_digest, matches_canonical_input: false },
504
+ next_action: 'correct_the_reported_pointer_then_rerun_plan_create',
505
+ });
506
+ }
507
+ // dispatch_shapeのgateはstore初期化より前に判定する(拒否時にstoreへ何も書かないため、
508
+ // 再考後の再実行がplan_key_already_existsで詰まらない)。
509
+ const dispatchShape = computeTodoDispatchShapeForPlan({
510
+ projectId: input.project_id,
511
+ planKey: input.plan_key,
512
+ taskIds: input.tasks.map(({ task_id: taskId }) => taskId),
513
+ hardDependencies: input.hard_dependencies,
514
+ joins: input.joins,
515
+ });
516
+ assertTodoDispatchShapeReviewed({ shape: dispatchShape, reviewed: serializationReviewed });
517
+ const store = await initializeAuthoredTodoStore({
518
+ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
519
+ projectId: input.project_id, repositories: [{ repo_id: 'self', path: '.' }],
520
+ plan: {
521
+ schema: input.schema === MEMO_PHASE_CREATE_INPUT_SCHEMA ? 'lattice.todo_plan.v7'
522
+ : input.schema === DECOUPLED_PHASE_CREATE_INPUT_SCHEMA ? 'lattice.todo_plan.v5'
523
+ : input.schema === PHASE_CREATE_INPUT_SCHEMA
524
+ ? 'lattice.todo_plan.v4' : 'lattice.todo_plan.v3', project_id: input.project_id,
525
+ plan_key: input.plan_key, plan_version: input.plan_version,
526
+ predecessor_plan_digest: null, tasks: input.tasks,
527
+ hard_dependencies: input.hard_dependencies, joins: input.joins,
528
+ ...([PHASE_CREATE_INPUT_SCHEMA, DECOUPLED_PHASE_CREATE_INPUT_SCHEMA,
529
+ MEMO_PHASE_CREATE_INPUT_SCHEMA].includes(input.schema) ? { phases: input.phases } : {}),
530
+ ...([DECOUPLED_PHASE_CREATE_INPUT_SCHEMA, MEMO_PHASE_CREATE_INPUT_SCHEMA].includes(input.schema)
531
+ ? { phase_accept_dependencies: input.phase_accept_dependencies } : {}),
532
+ },
533
+ genesis: { actor: input.actor, recorded_at: input.recorded_at, provenance: null },
534
+ });
535
+ const member = store.members[0];
536
+ const result = {
537
+ schema: CREATE_RESULT_SCHEMA, project_id: store.project_id,
538
+ plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
539
+ store_ref: STORE_REF, plan_ref: member.descriptor.plan_ref,
540
+ journal_ref: member.descriptor.journal_ref, snapshot_ref: member.descriptor.snapshot_ref,
541
+ plan_digest: member.plan.plan_digest,
542
+ dispatch_shape: {
543
+ task_count: dispatchShape.task_count,
544
+ critical_path_length: dispatchShape.critical_path_length,
545
+ max_frontier_width: dispatchShape.max_frontier_width,
546
+ serialization_ratio: dispatchShape.serialization_ratio,
547
+ },
548
+ // ADR 0147裁定3: phase無し(v3)のplan createは拒否せず、終端監査が要ることを結果へ
549
+ // 明示するに留める。phase入力(v4/v5)ならfalse——既存のPhase gateがそのまま重監査を担う。
550
+ terminal_audit_required: isPhaselessTodoPlanSchema(member.plan.schema),
551
+ result_digest: '',
552
+ };
553
+ result.result_digest = resultDigest(result);
554
+ stdout.write(`${JSON.stringify(result)}\n`);
555
+ return 0;
556
+ }
557
+
558
+ /**
559
+ * `--schema --json`(版指定なし)の既定はCURRENT_CREATE_INPUT_SCHEMAと同じv4にする。
560
+ * 既定がv1のままだと、素の`--schema`を叩いたAIが古いv1(Phaseを表現できない)を
561
+ * 受け取り、実運用で通らない入力を作ってしまう(実際に踏んだ)。
562
+ * `--schema-version 1`は互換のため引き続き取得できる(bin/lattice.mjs側で許可)。
563
+ *
564
+ * 「どの版を返したか」は返すJSON Schema自身の`title`(例: `lattice.plan_create_input.v4`)が
565
+ * 既に機械可読に持っている。壊さずに追加のkeyを足す理由が無いので足さない。
566
+ */
567
+ export async function runPlanCreateSchema({ stdout, version = 4 }) {
568
+ if (![1, 2, 3, 4].includes(version)) throw new TypeError('unsupported plan create schema version');
569
+ const expected = version === 4 ? MEMO_PHASE_CREATE_INPUT_SCHEMA
570
+ : version === 3 ? DECOUPLED_PHASE_CREATE_INPUT_SCHEMA
571
+ : version === 2 ? PHASE_CREATE_INPUT_SCHEMA : CREATE_INPUT_SCHEMA;
572
+ const schemaUrl = new URL(`../docs/schemas/lattice.plan_create_input.v${version}.schema.json`, import.meta.url);
573
+ const handle = await open(schemaUrl, fsConstants.O_RDONLY);
574
+ try {
575
+ const schema = JSON.parse(await handle.readFile('utf8'));
576
+ if (schema?.title !== expected) throw new TypeError('bundled plan create schema invalid');
577
+ stdout.write(`${JSON.stringify(schema)}\n`);
578
+ return 0;
579
+ } finally {
580
+ await handle.close();
581
+ }
582
+ }
583
+
584
+ const PLAN_SHOW_RESULT_SCHEMA = 'lattice.plan_show_result.v1';
585
+
586
+ /**
587
+ * `todo bindings`はcompile_binding付きtaskだけを投影するので、通常planでは空配列を返す。
588
+ * それを見て「planが空だ」と誤読された実績があるため、plan本体(task・依存・phase・状態)を
589
+ * 1コマンドで読める面を別に持つ。読み出しは既存store readerとtodo status projectionの
590
+ * 再利用に留め、journalを独自に再実装しない。
591
+ */
592
+ export async function runPlanShow({ cwd, planKey, stdout }) {
593
+ const repoRoot = resolveRepoRoot(cwd);
594
+ if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
595
+ const store = await readTodoStore({ repoRoot });
596
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
597
+ if (member === undefined) {
598
+ // 既存read commands(independence compile等)と同じcode/reasonを踏襲する。
599
+ // 未知のplan_keyを「store不整合」と同じ扱いにするのはこのCLI全体の既定であり、
600
+ // ここだけ別codeへ逸れると呼び出し側の分岐が増える。
601
+ throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, {
602
+ plan_key: planKey, next_action: 'check_active_plans_via_status',
603
+ });
604
+ }
605
+ const { plan, tasks, phases } = member;
606
+ const phaseInput = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5', 'lattice.todo_plan.v7']
607
+ .includes(plan.schema);
608
+ const stateByTaskId = new Map(tasks.map((state) => [state.task_id, state]));
609
+ // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
610
+ // (readTodoStoreが常に member.phases として埋める。ADR 0147)。
611
+ const phaseStatusById = new Map((phases ?? []).map((phase) => [phase.phase_id, phase.status]));
612
+
613
+ // 依存の本数: このplanの中でそのtaskへ入ってくるhard_dependencies辺と、joinで
614
+ // 合流するafter辺の合計。cross-plan参照は数えない(plan showは単一planの投影のため)。
615
+ const dependsOnCount = new Map(plan.tasks.map((task) => [task.task_id, 0]));
616
+ for (const edge of plan.hard_dependencies) {
617
+ if (edge.to.plan_key === planKey && dependsOnCount.has(edge.to.task_id)) {
618
+ dependsOnCount.set(edge.to.task_id, dependsOnCount.get(edge.to.task_id) + 1);
619
+ }
620
+ }
621
+ for (const join of plan.joins) {
622
+ if (join.before.plan_key === planKey && dependsOnCount.has(join.before.task_id)) {
623
+ dependsOnCount.set(join.before.task_id, dependsOnCount.get(join.before.task_id) + join.after.length);
624
+ }
625
+ }
626
+
627
+ const taskList = plan.tasks.map((task) => ({
628
+ task_id: task.task_id,
629
+ title: task.title,
630
+ lane: task.lane,
631
+ phase_id: phaseInput ? task.phase_id : null,
632
+ state: stateByTaskId.get(task.task_id).status,
633
+ depends_on_count: dependsOnCount.get(task.task_id) ?? 0,
634
+ }));
635
+
636
+ const phaseList = phaseInput ? plan.phases.map((phase) => ({
637
+ phase_id: phase.phase_id,
638
+ title: phase.title,
639
+ gate_policy: phase.gate_policy,
640
+ predecessor_phase_ids: phase.predecessor_phase_ids,
641
+ status: phaseStatusById.get(phase.phase_id) ?? null,
642
+ })) : [];
643
+
644
+ // dispatch形状はplan create時に既に計算している同じ関数を再利用する。
645
+ // critical path長・frontier幅を独自に計算し直さない。
646
+ const dispatchShape = computeTodoDispatchShapeForPlan({
647
+ projectId: plan.project_id, planKey,
648
+ taskIds: plan.tasks.map(({ task_id: taskId }) => taskId),
649
+ hardDependencies: plan.hard_dependencies, joins: plan.joins,
650
+ });
651
+
652
+ const result = {
653
+ schema: PLAN_SHOW_RESULT_SCHEMA,
654
+ project_id: plan.project_id,
655
+ plan_key: plan.plan_key,
656
+ plan_version: plan.plan_version,
657
+ plan_schema: plan.schema,
658
+ has_phases: phaseInput,
659
+ phases: phaseList,
660
+ tasks: taskList,
661
+ topology: {
662
+ task_count: dispatchShape.task_count,
663
+ critical_path_length: dispatchShape.critical_path_length,
664
+ max_frontier_width: dispatchShape.max_frontier_width,
665
+ serialization_ratio: dispatchShape.serialization_ratio,
666
+ },
667
+ result_digest: '',
668
+ };
669
+ result.result_digest = resultDigest(result);
670
+ stdout.write(`${JSON.stringify(result)}\n`);
671
+ return 0;
672
+ }
673
+
674
+ export function projectStatusFailure({ cwd, stdout, cliVersion, error }) {
675
+ const projectRootConflict = error?.code === 'PROJECT_ROOT_CONFLICT';
676
+ const result = invalidStatus({
677
+ cliVersion, repoRoot: resolveRepoRoot(cwd),
678
+ reason: projectRootConflict
679
+ ? 'project_root_conflict'
680
+ : `status_internal_failure:${error?.constructor?.name ?? 'Error'}`,
681
+ ...(projectRootConflict ? { nextAction: {
682
+ command: 'lattice todo dashboard adopt --json', reason: 'project_root_conflict',
683
+ } } : {}),
684
+ });
685
+ stdout.write(`${JSON.stringify(result)}\n`);
686
+ return 1;
687
+ }
688
+
689
+ export function projectCliFailure(stderr, error) {
690
+ const payload = {
691
+ schema: 'lattice.cli_error.v2', code: error?.code ?? 'INTERNAL_FAILURE',
692
+ message: error?.message ?? 'project command failed',
693
+ };
694
+ if (error?.detail && Object.keys(error.detail).length > 0) payload.detail = error.detail;
695
+ stderr.write(`${JSON.stringify(payload)}\n`);
696
+ return 1;
697
+ }