@quolu/lattice 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lattice.mjs CHANGED
@@ -14,6 +14,11 @@ if (help !== null) {
14
14
  process.stdout.write(help);
15
15
  } else if (args.length === 1 && args[0] === '--version') {
16
16
  process.stdout.write(`${packageJson.version}\n`);
17
+ } else if (args.length === 2 && args[0] === 'session-context' && args[1] === '--json') {
18
+ const { runSessionContext } = await import('../src/project-cli.mjs');
19
+ process.exitCode = await runSessionContext({
20
+ cwd: process.cwd(), stdout: process.stdout, cliVersion: packageJson.version,
21
+ });
17
22
  } else if (args.length === 2 && args[0] === 'status' && args[1] === '--json') {
18
23
  const { projectStatusFailure, runProjectStatus } = await import('../src/project-cli.mjs');
19
24
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli-help.mjs CHANGED
@@ -4,6 +4,7 @@ const ROOT_HELP = `Usage: lattice <command> [options]
4
4
 
5
5
  Commands:
6
6
  status --json Discover the current project and next action
7
+ session-context --json Session開始時の現在地を1プロセスで返す(工程状態+並列可否)
7
8
  plan <command> Create, compile, or verify plans
8
9
  run <command> Start or observe compiled runs
9
10
  event verify Verify a runtime event log
@@ -13,12 +13,15 @@ import {
13
13
  todoSelfDigest,
14
14
  } from './todo-contracts.mjs';
15
15
  import { projectTodoStatus } from './todo-status.mjs';
16
+ import { projectIndependenceFrontier } from './todo-independence.mjs';
17
+ import { selectIndependenceGuidance } from './todo-independence-guidance.mjs';
16
18
  import { ensureTodoDashboardActivity } from './todo-dashboard-registry.mjs';
17
19
  import { resolveProjectIdentity } from './project-identity.mjs';
18
20
  import {
19
21
  buildTodoPlan,
20
22
  createTodoStoreWriter,
21
23
  initializeAuthoredTodoStore,
24
+ readTodoIndependenceArtifact,
22
25
  readTodoStore,
23
26
  TodoStoreError,
24
27
  } from './todo-store.mjs';
@@ -27,6 +30,9 @@ const STORE_REF = '.lattice/todo';
27
30
  const MANIFEST_REF = `${STORE_REF}/manifest.json`;
28
31
  const MAX_INPUT_BYTES = 8_388_608;
29
32
  const STATUS_SCHEMA = 'lattice.project_status.v1';
33
+ const SESSION_CONTEXT_SCHEMA = 'lattice.session_context.v1';
34
+ /** HEADが読めない環境でも投影を組めるようにする。記録があるときは実HEADで置き換わる。 */
35
+ const PLACEHOLDER_SHA = '0'.repeat(40);
30
36
  const CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v1';
31
37
  const PHASE_CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v2';
32
38
  const DECOUPLED_PHASE_CREATE_INPUT_SCHEMA = 'lattice.plan_create_input.v3';
@@ -106,6 +112,73 @@ export function validateProjectStatus(value) {
106
112
  } catch { return false; }
107
113
  }
108
114
 
115
+ /**
116
+ * readyのあるplanについてだけ並列可否を要約する(ADR 0131 Decision 5)。
117
+ *
118
+ * store読みは呼び出し側が済ませている。ここが払うのはplanごとの小さな記録ファイルと、
119
+ * 記録があるときだけのHEAD照合である。readyが無いplanは述べる対象が無いので載せない。
120
+ */
121
+ async function summarizeIndependence({ repoRoot, store, todo }) {
122
+ const readyPlanKeys = [...new Set(todo.next_ready.map(({ plan_key: key }) => key))].sort();
123
+ if (readyPlanKeys.length === 0) return [];
124
+ const activeByPlan = new Map();
125
+ for (const task of todo.active_set) {
126
+ if (!activeByPlan.has(task.plan_key)) activeByPlan.set(task.plan_key, []);
127
+ activeByPlan.get(task.plan_key).push(task.task_id);
128
+ }
129
+ let currentBaseSha = null;
130
+ const summaries = [];
131
+ for (const planKey of readyPlanKeys) {
132
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
133
+ if (member === undefined) continue;
134
+ let artifact = null;
135
+ try {
136
+ artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
137
+ } catch (error) {
138
+ // 読めない記録を「記録なし」へ丸めない。理由を載せて先へ進む。
139
+ summaries.push({
140
+ plan_key: planKey, coverage: null,
141
+ guidance: { code: 'independence_unrecorded', message: null, next_action: 'none' },
142
+ unreadable_reason: error instanceof TodoStoreError
143
+ ? `${error.code}:${error.detail?.reason ?? error.message}` : 'independence_unreadable',
144
+ parallel_groups: [], serialize_pair_count: 0,
145
+ conflict_with_active_count: 0, unknown_task_ids: [],
146
+ });
147
+ continue;
148
+ }
149
+ if (currentBaseSha === null && artifact !== null) currentBaseSha = gitHead(repoRoot);
150
+ const projected = projectIndependenceFrontier({
151
+ artifact,
152
+ readyTaskIds: todo.next_ready.filter((task) => task.plan_key === planKey)
153
+ .map(({ task_id: taskId }) => taskId),
154
+ activeTaskIds: activeByPlan.get(planKey) ?? [],
155
+ plan: member.plan,
156
+ currentBaseSha: currentBaseSha ?? PLACEHOLDER_SHA,
157
+ changedPaths: null,
158
+ });
159
+ summaries.push({
160
+ plan_key: planKey,
161
+ coverage: projected.coverage,
162
+ guidance: selectIndependenceGuidance({
163
+ coverage: projected.coverage,
164
+ readyCount: todo.next_ready.filter((task) => task.plan_key === planKey).length,
165
+ taskDeclared: projected.frontier.unknown
166
+ .every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
167
+ taskStale: projected.frontier.unknown
168
+ .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
169
+ conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
170
+ conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
171
+ }),
172
+ unreadable_reason: null,
173
+ parallel_groups: projected.frontier.parallel_groups.map(({ task_ids: ids }) => [...ids]),
174
+ serialize_pair_count: projected.frontier.serialize_pairs.length,
175
+ conflict_with_active_count: projected.frontier.conflicts_with_active.length,
176
+ unknown_task_ids: projected.frontier.unknown.map(({ task_id: taskId }) => taskId),
177
+ });
178
+ }
179
+ return summaries;
180
+ }
181
+
109
182
  function statusResult(fields) {
110
183
  const result = { schema: STATUS_SCHEMA, ...fields, result_digest: '' };
111
184
  result.result_digest = resultDigest(result);
@@ -124,12 +197,18 @@ function invalidStatus({ cliVersion, repoRoot, reason }) {
124
197
  });
125
198
  }
126
199
 
127
- export async function runProjectStatus({ cwd, stdout, cliVersion, env = process.env,
128
- ensureDashboardActivity = ensureTodoDashboardActivity }) {
200
+ /**
201
+ * discoveryとstore読みを1回で済ませ、status結果と(読めたなら)storeを返す。
202
+ *
203
+ * `runProjectStatus`と`runSessionContext`が同じ判定を二度書かないための共有点。
204
+ * store読みはここでしか行わない——session開始経路が同じstoreを二度払っていたのが
205
+ * ADR 0131で直した欠陥である。
206
+ */
207
+ async function resolveProjectState({ cwd, cliVersion }) {
129
208
  const repoRoot = resolveRepoRoot(cwd);
130
209
  if (repoRoot === null) {
131
- stdout.write(`${JSON.stringify(invalidStatus({ cliVersion, repoRoot, reason: 'git_repository_unresolved' }))}\n`);
132
- return 1;
210
+ return { exitCode: 1, repoRoot: null, store: null, todo: null,
211
+ result: invalidStatus({ cliVersion, repoRoot: null, reason: 'git_repository_unresolved' }) };
133
212
  }
134
213
  const storeAbsolute = path.join(repoRoot, STORE_REF);
135
214
  const manifestAbsolute = path.join(repoRoot, MANIFEST_REF);
@@ -137,30 +216,24 @@ export async function runProjectStatus({ cwd, stdout, cliVersion, env = process.
137
216
  let latticeState;
138
217
  let storeState;
139
218
  let manifestState;
219
+ const invalid = (reason) => ({
220
+ exitCode: 1, repoRoot, store: null, todo: null,
221
+ result: invalidStatus({ cliVersion, repoRoot, reason }),
222
+ });
140
223
  try { latticeState = await lstat(latticeAbsolute); } catch (error) {
141
- if (error?.code !== 'ENOENT') {
142
- stdout.write(`${JSON.stringify(invalidStatus({ cliVersion, repoRoot, reason: 'lattice_root_unreadable' }))}\n`);
143
- return 1;
144
- }
224
+ if (error?.code !== 'ENOENT') return invalid('lattice_root_unreadable');
145
225
  }
146
226
  if (latticeState !== undefined && (latticeState.isSymbolicLink() || !latticeState.isDirectory())) {
147
- stdout.write(`${JSON.stringify(invalidStatus({ cliVersion, repoRoot, reason: 'lattice_root_invalid' }))}\n`);
148
- return 1;
227
+ return invalid('lattice_root_invalid');
149
228
  }
150
229
  try { storeState = await lstat(storeAbsolute); } catch (error) {
151
- if (error?.code !== 'ENOENT') {
152
- stdout.write(`${JSON.stringify(invalidStatus({ cliVersion, repoRoot, reason: 'store_unreadable' }))}\n`);
153
- return 1;
154
- }
230
+ if (error?.code !== 'ENOENT') return invalid('store_unreadable');
155
231
  }
156
232
  try { manifestState = await lstat(manifestAbsolute); } catch (error) {
157
- if (error?.code !== 'ENOENT') {
158
- stdout.write(`${JSON.stringify(invalidStatus({ cliVersion, repoRoot, reason: 'manifest_unreadable' }))}\n`);
159
- return 1;
160
- }
233
+ if (error?.code !== 'ENOENT') return invalid('manifest_unreadable');
161
234
  }
162
235
  if (storeState === undefined && manifestState === undefined) {
163
- const result = statusResult({
236
+ return { exitCode: 0, repoRoot, store: null, todo: null, result: statusResult({
164
237
  cli: { available: true, version: cliVersion },
165
238
  project: { root: repoRoot, git_head: gitHead(repoRoot), project_id: null },
166
239
  state: 'uninitialized',
@@ -171,14 +244,11 @@ export async function runProjectStatus({ cwd, stdout, cliVersion, env = process.
171
244
  input_schema: CURRENT_CREATE_INPUT_SCHEMA,
172
245
  schema_command: CURRENT_CREATE_SCHEMA_COMMAND,
173
246
  },
174
- });
175
- stdout.write(`${JSON.stringify(result)}\n`);
176
- return 0;
247
+ }) };
177
248
  }
178
249
  if (storeState?.isSymbolicLink() || !storeState?.isDirectory()
179
250
  || manifestState?.isSymbolicLink() || !manifestState?.isFile()) {
180
- stdout.write(`${JSON.stringify(invalidStatus({ cliVersion, repoRoot, reason: 'store_layout_invalid' }))}\n`);
181
- return 1;
251
+ return invalid('store_layout_invalid');
182
252
  }
183
253
  try {
184
254
  const store = await readTodoStore({ repoRoot });
@@ -205,21 +275,60 @@ export async function runProjectStatus({ cwd, stdout, cliVersion, env = process.
205
275
  can_create_plan: false,
206
276
  next_action: next,
207
277
  });
208
- if (env.LATTICE_DASHBOARD_AUTOSTART !== '0') {
209
- const identity = await resolveProjectIdentity({ repoRoot, projectId: store.project_id, env });
210
- const actorSession = env.LATTICE_TODO_ACTOR_SESSION;
211
- await ensureDashboardActivity({
212
- repoRoot, projectId: store.project_id, displayName: identity.displayName,
213
- sessionId: isTodoIdentifier(actorSession) ? actorSession : `status-${process.pid}`, env,
214
- });
215
- }
216
- stdout.write(`${JSON.stringify(result)}\n`);
217
- return 0;
278
+ return { exitCode: 0, repoRoot, store, todo, result };
218
279
  } catch (error) {
219
- const reason = error instanceof TodoStoreError ? `${error.code}:${error.detail?.reason ?? error.message}` : 'store_validation_failed';
220
- stdout.write(`${JSON.stringify(invalidStatus({ cliVersion, repoRoot, reason }))}\n`);
221
- return 1;
280
+ const reason = error instanceof TodoStoreError
281
+ ? `${error.code}:${error.detail?.reason ?? error.message}` : 'store_validation_failed';
282
+ return invalid(reason);
283
+ }
284
+ }
285
+
286
+ export async function runProjectStatus({ cwd, stdout, cliVersion, env = process.env,
287
+ ensureDashboardActivity = ensureTodoDashboardActivity }) {
288
+ const state = await resolveProjectState({ cwd, cliVersion });
289
+ // dashboard活動の登録はdiscovery面の副作用として維持する(ADR 0131 Decision 4で
290
+ // session-context側だけが持たない、と決めた面である)。
291
+ if (state.store !== null && env.LATTICE_DASHBOARD_AUTOSTART !== '0') {
292
+ const identity = await resolveProjectIdentity({
293
+ repoRoot: state.repoRoot, projectId: state.store.project_id, env,
294
+ });
295
+ const actorSession = env.LATTICE_TODO_ACTOR_SESSION;
296
+ await ensureDashboardActivity({
297
+ repoRoot: state.repoRoot, projectId: state.store.project_id,
298
+ displayName: identity.displayName,
299
+ sessionId: isTodoIdentifier(actorSession) ? actorSession : `status-${process.pid}`, env,
300
+ });
222
301
  }
302
+ stdout.write(`${JSON.stringify(state.result)}\n`);
303
+ return state.exitCode;
304
+ }
305
+
306
+ /**
307
+ * session開始時の現在地を1プロセス・1 store読みで返す(ADR 0131)。
308
+ *
309
+ * `lattice status`と`lattice todo status`は同じ`readTodoStore`を別プロセスで二重に払う。
310
+ * hostのSessionStartはその両方を呼ぶため、storeが育ったprojectでは実行枠を超えて
311
+ * 案内ごと捨てられていた。ここは合成であって置き換えではない——既存2面は不変で、
312
+ * それぞれの消費者を持ち続ける。
313
+ *
314
+ * dashboard活動の登録は行わない。現在地を知るために呼ぶ面であり、常駐面を起こす面ではない。
315
+ */
316
+ export async function runSessionContext({ cwd, stdout, cliVersion }) {
317
+ const state = await resolveProjectState({ cwd, cliVersion });
318
+ const independence = state.store === null || state.todo === null
319
+ ? [] : await summarizeIndependence({ repoRoot: state.repoRoot, store: state.store, todo: state.todo });
320
+ const result = {
321
+ schema: SESSION_CONTEXT_SCHEMA,
322
+ // project discoveryの答えをそのまま埋める。hostは既存の検証器を再利用できる。
323
+ status: state.result,
324
+ // todoは`todo_status_result.v4`そのもの。新しい意味論を発明しない。
325
+ todo: state.todo,
326
+ independence,
327
+ result_digest: '',
328
+ };
329
+ result.result_digest = todoSelfDigest(result, 'result_digest');
330
+ stdout.write(`${JSON.stringify(result)}\n`);
331
+ return state.exitCode;
223
332
  }
224
333
 
225
334
  async function readCanonicalInput(repoRoot, inputRef) {
package/src/todo-cli.mjs CHANGED
@@ -809,6 +809,7 @@ async function independence({ repoRoot, requestedPlanKey }) {
809
809
  // planを読みに来た人にも、着手する人と同じ文言を返す(ADR 0130 Decision 1)。
810
810
  guidance: selectIndependenceGuidance({
811
811
  coverage: projected.coverage,
812
+ readyCount: ready.length,
812
813
  taskDeclared: projected.frontier.unknown
813
814
  .every(({ unknowns }) => !unknowns.some(({ kind }) => kind === 'witness_missing')),
814
815
  taskStale: projected.frontier.unknown
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
12
+ 'independence_no_ready_frontier',
12
13
  'independence_unrecorded',
13
14
  'independence_task_undeclared',
14
15
  'independence_superseded',
@@ -19,6 +20,10 @@ export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
19
20
  ]);
20
21
 
21
22
  const CATALOG = Object.freeze({
23
+ independence_no_ready_frontier: Object.freeze({
24
+ message: '着手候補が無いため、並列可否を述べる対象が無い。',
25
+ next_action: 'none',
26
+ }),
22
27
  independence_unrecorded: Object.freeze({
23
28
  message: 'このplanの並列可否はまだ判定していない。競合が無いのではなく、記録が存在しない。',
24
29
  next_action: 'declare_witness_set_then_compile',
@@ -77,7 +82,11 @@ export function todoIndependenceGuidance(code, { severability = null } = {}) {
77
82
  */
78
83
  export function selectIndependenceGuidance({
79
84
  coverage, taskDeclared, taskStale, conflictWithActive = null, conflictBetweenReady = null,
85
+ readyCount = null,
80
86
  }) {
87
+ // 着手候補が無いなら述べる対象が無い。ここを通さないと、readyが空のとき
88
+ // 「未検査taskが1件も無い」が空虚に真になり、記録が古くても検証済みへ倒れる。
89
+ if (readyCount === 0) return todoIndependenceGuidance('independence_no_ready_frontier');
81
90
  if (conflictWithActive !== null) {
82
91
  return todoIndependenceGuidance('independence_conflict_with_active', {
83
92
  severability: conflictWithActive,