@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 +5 -0
- package/package.json +1 -1
- package/src/cli-help.mjs +1 -0
- package/src/project-cli.mjs +146 -37
- package/src/todo-cli.mjs +1 -0
- package/src/todo-independence-guidance.mjs +9 -0
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
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
|
package/src/project-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
return
|
|
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,
|