@quolu/lattice 0.26.0 → 0.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -60,7 +60,10 @@
60
60
  "test": "node scripts/run-product-tests.mjs",
61
61
  "test:sensor": "npm --prefix sensor test",
62
62
  "check": "node scripts/check-syntax.mjs",
63
- "ci": "npm run test && npm run test:sensor && npm run check && npm run verify:todo-store",
64
- "verify:todo-store": "node bin/lattice.mjs todo verify --json"
63
+ "ci": "npm run test && npm run test:sensor && npm run check && npm run check:cli-surface && npm run check:open-questions && npm run check:reachability && npm run verify:todo-store",
64
+ "verify:todo-store": "node bin/lattice.mjs todo verify --json",
65
+ "check:cli-surface": "node scripts/verify-cli-surface.mjs",
66
+ "check:open-questions": "node scripts/verify-open-questions.mjs",
67
+ "check:reachability": "node scripts/verify-product-reachability.mjs"
65
68
  }
66
69
  }
package/src/cli-help.mjs CHANGED
@@ -44,6 +44,10 @@ Commands:
44
44
  close --run .lattice/runs/<id>
45
45
  abandon --run .lattice/runs/<id> --reason <reason>
46
46
  list --json
47
+ seam resolve --run .lattice/runs/<id> --finding <digest> --input <request.json>
48
+ # 記録済み競合を隔離worktreeで実際に変換し、五条件を通ればseam splitと後継baseを返す。
49
+ # 入力(lattice.runtime_seam_request.v1)へ書くのは、係争fileの中で各TODOが触るsymbolと
50
+ # 新しい面の名前だけ。branchは動かさないので、後継baseへ進めるかは呼び出し側が決める。
47
51
  `,
48
52
  event: `Usage: lattice event verify --run .lattice/runs/<id>
49
53
  `,
@@ -119,6 +123,8 @@ registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPT
119
123
  });
120
124
 
121
125
  const SUBCOMMAND_USAGE = Object.freeze({
126
+ status: 'status --json',
127
+ 'session-context': 'session-context --json',
122
128
  'plan create': 'plan create --input <file> | --schema --json | --schema-version <2|3> --json',
123
129
  'plan compile': 'plan compile --request <request.json> | --schema --json',
124
130
  'plan verify': 'plan verify --request <request.json> --plan <plan.json>',
@@ -131,6 +137,13 @@ const SUBCOMMAND_USAGE = Object.freeze({
131
137
  'run close': 'run close --run .lattice/runs/<id>',
132
138
  'run abandon': 'run abandon --run .lattice/runs/<id> --reason <reason>',
133
139
  'run list': 'run list --json',
140
+ 'run activate': 'run activate --run .lattice/runs/<id>',
141
+ 'run conflict': 'run conflict --run .lattice/runs/<id> --finding <digest>',
142
+ 'run hold': 'run hold --run .lattice/runs/<id> --finding <digest>',
143
+ 'run recompile': 'run recompile --run .lattice/runs/<id> --input <recompile-request.json>',
144
+ 'run reprocess': 'run reprocess --run .lattice/runs/<id>',
145
+ 'run finding record': 'run finding record --run .lattice/runs/<id> --checkpoint <digest> --input <candidate.json>',
146
+ 'run seam resolve': 'run seam resolve --run .lattice/runs/<id> --finding <digest> --input <seam-request.json>',
134
147
  'event verify': 'event verify --run .lattice/runs/<id>',
135
148
  'todo status': 'todo status [--json]',
136
149
  'todo bindings': 'todo bindings [--plan <key>] [--json]',
@@ -155,6 +168,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
155
168
  'todo revise': 'todo revise --plan <key> --input <file>',
156
169
  'todo revise-phase': 'todo revise-phase --plan <key> --input <file>',
157
170
  'todo revise-set': 'todo revise-set --input <file>',
171
+ 'todo migrate': 'todo migrate --input <extraction.json>',
158
172
  'sensor init': 'sensor init [path] --json',
159
173
  'sensor sync': 'sensor sync [path] --json',
160
174
  'runtime-errors snapshot': 'runtime-errors snapshot [--after-cursor <n>] [--limit <n>] --json',
@@ -192,5 +206,10 @@ export function renderCliHelp(argv) {
192
206
  return usage === undefined ? null : `Usage: lattice ${usage}\n`;
193
207
  }
194
208
  const namespace = requestedNamespace(argv);
195
- return namespace === null ? null : NAMESPACE_HELP[namespace] ?? null;
209
+ if (namespace === null) return null;
210
+ // namespaceを持たない1語コマンド(status等)は、SUBCOMMAND_USAGEへ落ちる。
211
+ // 落とさないと「存在するのに使い方を知る手段が無い」コマンドになる。
212
+ if (NAMESPACE_HELP[namespace] !== undefined) return NAMESPACE_HELP[namespace];
213
+ const usage = SUBCOMMAND_USAGE[namespace];
214
+ return usage === undefined ? null : `Usage: lattice ${usage}\n`;
196
215
  }
@@ -311,7 +311,15 @@ export async function runIsolatedTransform({ repoRoot, baseRef, allowedPaths, tr
311
311
  const mountedEntries = mounts.map(({ entry }) => entry);
312
312
 
313
313
  const sourceState = await captureSourceState(repoRoot);
314
- if (sourceState.visibleStatus.length > 0) throw new Error('source repository must be clean');
314
+ if (sourceState.visibleStatus.length > 0) {
315
+ // どのpathが汚しているかを言わないと、呼び出したAIは何を片付ければよいか分からない。
316
+ // 実運用で、このコマンドへ渡す入力fileそのものが木を汚して詰まった。
317
+ // visibleStatusはNUL区切りのBufferである。行として扱うと空文字が並ぶ。
318
+ const entries = statusPaths(sourceState.visibleStatus).slice(0, 20);
319
+ throw new Error('source repository must be clean :: '
320
+ + `${entries.join(' | ')} :: 変換は既知のbaseに対して測る。commitするか、`
321
+ + 'gitが無視するpathへ退避してから再実行する');
322
+ }
315
323
  const baseSha = (await run('git', ['rev-parse', '--verify', `${baseRef}^{commit}`], { cwd: repoRoot })).stdout.toString('utf8').trim();
316
324
  const worktreePath = await mkdtemp(path.join(os.tmpdir(), 'lattice-isolated-transform-'));
317
325
  let added = false;
@@ -349,7 +357,18 @@ export async function runIsolatedTransform({ repoRoot, baseRef, allowedPaths, tr
349
357
  verifications.push(verificationReceipt(verifier, verification, 'passed'));
350
358
  } catch (error) {
351
359
  verifications.push(verificationReceipt(verifier, error, 'failed'));
352
- throw new Error(`verifier failed (${error.signal ?? error.code}): ${verifier.command}`);
360
+ // どのverifierがなぜ落ちたかを載せる。commandだけ返すと、五条件の棄却理由が
361
+ // 「focused testが落ちた」で止まり、原因を追う手段が無くなる。
362
+ // stdout/stderrはBufferで、空でもtruthyになる。`||`で繋ぐとstdoutへ落ちない。
363
+ // node --testは失敗をstdoutへ書くので、それを取り落とすと理由が消える。
364
+ const streams = [error?.stderr, error?.stdout]
365
+ .map((value) => (value === undefined ? '' : String(value)))
366
+ .filter((value) => value.trim().length > 0);
367
+ const detail = (streams[0] ?? '')
368
+ .split('\n').filter((line) => line.trim().length > 0).slice(-6).join(' | ').slice(0, 600);
369
+ throw new Error(`verifier failed (${error.signal ?? error.code}):`
370
+ + ` ${[verifier.command, ...verifier.args].join(' ')}`
371
+ + (detail.length > 0 ? ` :: ${detail}` : ''));
353
372
  }
354
373
  await assertSnapshotUnchanged(worktreePath, baseSha, allowedPaths, snapshot, 'verifier', mountedEntries);
355
374
  }
@@ -15,10 +15,15 @@ import {
15
15
  writeFile,
16
16
  } from 'node:fs/promises';
17
17
  import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
18
19
 
19
20
  import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
20
21
  import { collectSensorEvidence } from './sensor-adapter.mjs';
21
22
  import { detectCheckpointFindings } from './runtime-diff-observer.mjs';
23
+ import {
24
+ buildRuntimeSeamResolution, readRuntimeFindingRecord, resolveRuntimeSeam,
25
+ validateRuntimeSeamRequest, verifySeamSplitSuccessor,
26
+ } from './runtime-seam-resolve.mjs';
22
27
  import {
23
28
  compileRuntimePlanV1,
24
29
  evidenceFromCollectedOutcomes,
@@ -995,6 +1000,51 @@ async function runStart({ requestPath, executorAdapter, cwd, stdout }) {
995
1000
  return 0;
996
1001
  }
997
1002
 
1003
+ /**
1004
+ * 記録済み競合findingを、実際の変換で解消する(請求項8・ADR 0137〜0141)。
1005
+ *
1006
+ * 事前宣言されたtreatmentが無い競合は、これが無い間ずっと意図的直列へ退化していた。
1007
+ * 隔離worktreeで変換し、五条件(ADR 0138)を通り、確定できた時だけseam splitを返す。
1008
+ *
1009
+ * branchは動かさない。返す`successor_base_sha`へ進めるかどうかは操作するAIが決める——
1010
+ * 静的側の`land`と同じ責務分担であり、Latticeは変換・検証・記録と、後継baseの検査を持つ。
1011
+ */
1012
+ async function runSeamResolve({ runDir, repoRoot, findingDigest, requestPath, stdout }) {
1013
+ const committed = await readCommittedEpochStore(runDir);
1014
+ if (committed === null) {
1015
+ throw new CliContractError('RUN_NOT_MANAGED', 'runがmanaged storeへactivateされていない');
1016
+ }
1017
+ const declaration = await readBoundedJson(requestPath, 'runtime seam request');
1018
+ if (!validateRuntimeSeamRequest(declaration)) {
1019
+ throw new CliContractError('INVALID_SEAM_REQUEST', 'lattice.runtime_seam_request.v1として不正');
1020
+ }
1021
+ if (declaration.run_id !== committed.meta.run_id) {
1022
+ throw new CliContractError('INVALID_SEAM_REQUEST', '宣言のrun_idがrun storeと一致しない');
1023
+ }
1024
+ if (declaration.finding_digest !== findingDigest) {
1025
+ throw new CliContractError('INVALID_SEAM_REQUEST', '宣言のfinding_digestが--findingと一致しない');
1026
+ }
1027
+ const found = await readRuntimeFindingRecord({
1028
+ runDir, findingDigest, planEpoch: committed.pointer.plan_epoch,
1029
+ });
1030
+ if (found.record === null) throw new CliContractError('STALE_FINDING', found.reason);
1031
+
1032
+ const resolved = await resolveRuntimeSeam({
1033
+ repoRoot,
1034
+ runDir,
1035
+ findingRecord: found.record,
1036
+ bundle: committed.bundle,
1037
+ declaration,
1038
+ latticeBin: fileURLToPath(new URL('../bin/lattice.mjs', import.meta.url)),
1039
+ compiledAt: canonicalNow(),
1040
+ });
1041
+ const resolution = buildRuntimeSeamResolution({
1042
+ runId: committed.meta.run_id, findingDigest, resolved,
1043
+ });
1044
+ stdout.write(`${JSON.stringify(resolution)}\n`);
1045
+ return resolution.lane === 'seam_transform' ? 0 : 1;
1046
+ }
1047
+
998
1048
  async function runObserve({ runDir, stdout }) {
999
1049
  const { events } = await readRunStore(runDir);
1000
1050
  const chain = verifyRunEventChain({ events });
@@ -2068,6 +2118,27 @@ export async function runManagedSupervisorDaemon({
2068
2118
  newPlan.conflicts.sort((left, right) => `${left.todo_ids.join('\0')}\0${left.resource_id}`
2069
2119
  .localeCompare(`${right.todo_ids.join('\0')}\0${right.resource_id}`));
2070
2120
  }
2121
+ if (recompileRequest.mode === 'seam_split') {
2122
+ // 変換を含まないbaseを指したseam_splitを通さない(ADR 0141)。splitが新しい面の
2123
+ // 所有を宣言するのに後継treeにそのfileが無い、という状態を作らせない。
2124
+ const predecessorBaseSha = active.bundle.request.repo.base_sha;
2125
+ const successorBaseSha = recompileRequest.successor_request.repo.base_sha;
2126
+ const ancestry = await runGit(
2127
+ ['merge-base', '--is-ancestor', predecessorBaseSha, successorBaseSha], repoRoot,
2128
+ );
2129
+ const verdict = verifySeamSplitSuccessor({
2130
+ split: recompileRequest.seam_split,
2131
+ predecessorBaseSha,
2132
+ successorBaseSha,
2133
+ successorIsDescendant: ancestry.code === 0,
2134
+ successorConflicts: newPlan.conflicts,
2135
+ successorWitness: recompileRequest.successor_request.manual_witness,
2136
+ });
2137
+ if (!verdict.ok) {
2138
+ throw new ManagedRuntimeError('SEAM_SPLIT_UNPROVEN',
2139
+ `後継baseがseam splitの主張を満たさない: ${verdict.reasons.join(', ')}`);
2140
+ }
2141
+ }
2071
2142
  newPlan.plan_digest = selfDigest(newPlan, 'plan_digest');
2072
2143
  const executorPackets = buildExecutorPackets({ plan: newPlan, manifests: compiled.manifests });
2073
2144
 
@@ -3189,6 +3260,16 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
3189
3260
  requestId: requestIdOverride,
3190
3261
  });
3191
3262
  };
3263
+ } else if (argv.length === 9
3264
+ && argv[0] === 'run' && argv[1] === 'seam' && argv[2] === 'resolve'
3265
+ && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
3266
+ && argv[5] === '--finding' && /^[0-9a-f]{64}$/u.test(argv[6])
3267
+ && argv[7] === '--input' && typeof argv[8] === 'string' && argv[8].length > 0) {
3268
+ action = async () => {
3269
+ const { repoRoot, runDir } = await resolveRunStore(cwd, argv[4]);
3270
+ return runSeamResolve({ runDir, repoRoot, findingDigest: argv[6],
3271
+ requestPath: path.resolve(cwd, argv[8]), stdout });
3272
+ };
3192
3273
  } else if (argv.length === 8
3193
3274
  && argv[0] === 'run' && argv[1] === 'finding' && argv[2] === 'record'
3194
3275
  && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
@@ -65,7 +65,6 @@ function sha16(value) {
65
65
 
66
66
  const WITNESS_KINDS = Object.freeze(['state', 'schema', 'invariant', 'effect', 'external_effect']);
67
67
  const HEX_DIGEST = /^[0-9a-f]{64}$/u;
68
- const HEX_SHA1 = /^[0-9a-f]{40}$/u;
69
68
  const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
70
69
  const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
71
70
 
@@ -616,24 +615,20 @@ export function routeConflictTreatment(options = {}) {
616
615
  * (content不変・epoch/plan refのみ更新)、hold TODOへは新plan_ref由来の
617
616
  * 新context packetを発行し、旧contextを失効する。rebind/prepare ack、epoch pointer、
618
617
  * controller ready/release ack、中央gate commit前にはepoch_rebound/intake_resumedを発行しない。
618
+ *
619
+ * baseはここでは動かさない。変換で構造が変わった時に後継baseを決めるのは後継run requestで
620
+ * あり、それが本当に変換を含むかは`verifySeamSplitSuccessor`が見る(ADR 0141)。
619
621
  */
620
622
  export function recompileNextEpochPlan(options = {}) {
621
623
  if (!exactRecord(options, [
622
624
  'runId', 'request', 'plan', 'manifests', 'packets', 'events', 'holdDecision',
623
625
  'additionalConflicts', 'recordedAt',
624
- ]) && !exactRecord(options, [
625
- 'runId', 'request', 'plan', 'manifests', 'packets', 'events', 'holdDecision',
626
- 'additionalConflicts', 'recordedAt', 'successorBaseSha',
627
626
  ])) {
628
627
  fail('recompileNextEpochPlan optionsがexact shapeでない');
629
628
  }
630
629
  const {
631
630
  runId, request, plan, manifests, packets, events, holdDecision, additionalConflicts, recordedAt,
632
- successorBaseSha = null,
633
631
  } = options;
634
- if (successorBaseSha !== null && !HEX_SHA1.test(successorBaseSha)) {
635
- fail('successorBaseShaがgit shaでない');
636
- }
637
632
  if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
638
633
  if (!validateHoldDecision(holdDecision)) fail('holdDecisionがcontractを満たさない');
639
634
  if (!Array.isArray(additionalConflicts)) fail('additionalConflictsがarrayではない');
@@ -681,11 +676,7 @@ export function recompileNextEpochPlan(options = {}) {
681
676
  plan_ref: newPlanRef,
682
677
  plan_epoch: newEpoch,
683
678
  request_digest: request.request_digest,
684
- // 変換でsourceの構造が変わったなら、再開先は変換を含むbaseでなければならない。
685
- // 旧baseのまま再開させると、splitが所有を宣言した新pathがworktreeに存在しない(ADR 0141)。
686
- // carry-over側のrebind packetはcontent不変が要件なので、そちらは触らない——
687
- // 継続する作業は自分のworktreeで走り続けており、baseを付け替える対象ではない。
688
- base_sha: successorBaseSha ?? plan.base_sha,
679
+ base_sha: plan.base_sha,
689
680
  nodes: structuredClone(plan.nodes),
690
681
  precedence: structuredClone(plan.precedence),
691
682
  conflicts: mergedConflicts,
@@ -0,0 +1,283 @@
1
+ /**
2
+ * 実行時に観測した競合を、実際の変換で解消するproduct入口(請求項8)。
3
+ *
4
+ * これが無い間、変換の中身は動くのに実運転からそこへ行く道が無かった。実運転側が使う
5
+ * `routeConflictTreatment`は「事前宣言済みtreatmentがpathを覆う時だけseam_transform」なので、
6
+ * **予期しなかった競合は変換にかからず直列へ退化していた**。
7
+ *
8
+ * 装置の境界にAIを含める(AGENTS.md)。したがってここでLatticeが供給するのは、AIが自分では
9
+ * 作れないもの——構造観測、隔離実行、五条件の検証、変換の確定と記録——だけである。
10
+ * 「どのTODOが係争fileの中のどのsymbolを触るか」「新しい面をどう名付けるか」はAIが既に
11
+ * 知っているので、宣言として受け取る。推定しない。
12
+ */
13
+
14
+ import path from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import { readFile } from 'node:fs/promises';
17
+
18
+ import { digestArtifact } from './artifact-contracts.mjs';
19
+ import { commitSeamTransform } from './seam-commit.mjs';
20
+ import { applySeamConflict } from './seam-apply.mjs';
21
+ import { resolveRuntimeSeamTreatment } from './runtime-seam-treatment.mjs';
22
+ import { collectWitnessSensorEvidence, compileTodoIndependence } from './todo-independence.mjs';
23
+ import { todoSelfDigest } from './todo-contracts.mjs';
24
+
25
+ export const RUNTIME_SEAM_REQUEST_SCHEMA = 'lattice.runtime_seam_request.v1';
26
+ export const RUNTIME_SEAM_RESOLUTION_SCHEMA = 'lattice.runtime_seam_resolution.v1';
27
+
28
+ /** 合成するtodo planのkey。実行時planとは別空間なので固定でよい。 */
29
+ const SYNTHETIC_PLAN_KEY = 'runtime';
30
+ const HEX_DIGEST = /^[0-9a-f]{64}$/u;
31
+ const REPO_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[\w./-]+$/u;
32
+ const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
33
+
34
+ const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
35
+
36
+ /** 資源idはruntime front-endと同じ合成形にする(`own-<kind>-<sha16>`)。 */
37
+ const sha16 = (value) => createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16);
38
+
39
+ function plainObject(value) {
40
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ }
42
+
43
+ function exactRecord(value, keys) {
44
+ if (!plainObject(value)) return false;
45
+ const own = Object.keys(value);
46
+ return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
47
+ }
48
+
49
+ /**
50
+ * AIが出す宣言。ここに書くのは「係争fileの中で各TODOが触るsymbol」「新しい面の名前」
51
+ * 「後継planへ渡すtask migrationのdigest」だけで、どれもAIが既に持っている情報である。
52
+ */
53
+ export function validateRuntimeSeamRequest(value) {
54
+ if (!exactRecord(value, [
55
+ 'schema', 'run_id', 'finding_digest', 'concern_symbols', 'path_names',
56
+ 'task_migration_digest', 'request_digest',
57
+ ])) return false;
58
+ if (value.schema !== RUNTIME_SEAM_REQUEST_SCHEMA) return false;
59
+ if (!IDENTIFIER.test(value.run_id ?? '')) return false;
60
+ if (!HEX_DIGEST.test(value.finding_digest ?? '')) return false;
61
+ if (!HEX_DIGEST.test(value.task_migration_digest ?? '')) return false;
62
+ if (!plainObject(value.concern_symbols) || !plainObject(value.path_names)) return false;
63
+
64
+ const todoIds = Object.keys(value.concern_symbols);
65
+ if (todoIds.length < 2 || todoIds.length > 64) return false;
66
+ for (const todoId of todoIds) {
67
+ if (!IDENTIFIER.test(todoId)) return false;
68
+ const symbols = value.concern_symbols[todoId];
69
+ if (!Array.isArray(symbols) || symbols.length === 0 || symbols.length > 256) return false;
70
+ if (!symbols.every((symbol) => IDENTIFIER.test(symbol))) return false;
71
+ if (!REPO_PATH.test(value.path_names[todoId] ?? '')) return false;
72
+ }
73
+ // sharedはresidualから切り出す共有面。TODOに属さないので別枠で必ず要る。
74
+ if (!REPO_PATH.test(value.path_names.shared ?? '')) return false;
75
+ if (!exactRecord(value.path_names, [...todoIds, 'shared'])) return false;
76
+ if (!HEX_DIGEST.test(value.request_digest ?? '')) return false;
77
+ return value.request_digest === todoSelfDigest(value, 'request_digest');
78
+ }
79
+
80
+ /**
81
+ * 実行時witnessへconcern anchorを足してtodo witness setにする。
82
+ *
83
+ * 実行時のmanual_witnessはconcern_anchorsを持たない(`lattice.run_request.v3`)。持たせるのでなく、
84
+ * 宣言から足す——係争資源の中のどのsymbolを触るかは実行時に確定する情報であり、run開始時点の
85
+ * 契約に書けるものではないからである。
86
+ */
87
+ export function buildRuntimeSeamWitnessSet({ request, declaration, contestedPath, executors }) {
88
+ const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
89
+ const manual = {};
90
+ for (const todoId of todoIds) {
91
+ const witness = request.manual_witness?.[todoId];
92
+ if (!plainObject(witness)) return { witnessSet: null, reasons: [`witness_absent:${todoId}`] };
93
+ manual[todoId] = {
94
+ ...structuredClone(witness),
95
+ concern_anchors: [{
96
+ within: { kind: 'path', target: contestedPath },
97
+ symbols: [...declaration.concern_symbols[todoId]].sort(compareText),
98
+ }],
99
+ };
100
+ }
101
+ const witnessSet = {
102
+ schema: 'lattice.todo_witness_set.v3',
103
+ project_id: SYNTHETIC_PLAN_KEY,
104
+ plan_key: SYNTHETIC_PLAN_KEY,
105
+ capacity: { executors },
106
+ sensor_query_set: structuredClone(request.sensor_query_set),
107
+ manual_witness: manual,
108
+ witness_set_digest: '',
109
+ };
110
+ witnessSet.witness_set_digest = todoSelfDigest(witnessSet, 'witness_set_digest');
111
+ return { witnessSet, reasons: [] };
112
+ }
113
+
114
+ function syntheticTodoPlan(todoIds) {
115
+ return {
116
+ schema: 'lattice.todo_plan.v2',
117
+ project_id: SYNTHETIC_PLAN_KEY,
118
+ plan_key: SYNTHETIC_PLAN_KEY,
119
+ plan_version: 'v1',
120
+ topology_digest: digestArtifact({ tasks: [...todoIds].sort(compareText) }),
121
+ tasks: [...todoIds].sort(compareText).map((taskId) => ({ task_id: taskId })),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * 記録済みfindingと宣言から、変換を導出・適用・検証・確定して処置を返す。
127
+ *
128
+ * 五条件(ADR 0138)を1つでも欠いたら意図的直列を返す。確定できなければ採用しない(ADR 0141)。
129
+ * 「変換した」と言いながら再開できない状態を作らない。
130
+ */
131
+ export async function resolveRuntimeSeam({
132
+ repoRoot, runDir, findingRecord, bundle, declaration, latticeBin, compiledAt,
133
+ } = {}) {
134
+ const finding = findingRecord.finding;
135
+ const request = bundle.request;
136
+ const baseSha = request.repo.base_sha;
137
+ const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
138
+
139
+ if (finding.kind !== 'observed_write_conflict' || typeof finding.path !== 'string') {
140
+ return { lane: 'intentional_serial', reasons: ['finding_not_write_conflict'], split: null };
141
+ }
142
+ const findingTodoIds = [...finding.todo_ids].sort(compareText);
143
+ if (findingTodoIds.join('\0') !== todoIds.join('\0')) {
144
+ return {
145
+ lane: 'intentional_serial',
146
+ reasons: ['declared_todos_differ_from_finding'],
147
+ split: null,
148
+ };
149
+ }
150
+
151
+ const built = buildRuntimeSeamWitnessSet({
152
+ request, declaration, contestedPath: finding.path,
153
+ executors: request.capacity.executors,
154
+ });
155
+ if (built.witnessSet === null) {
156
+ return { lane: 'intentional_serial', reasons: built.reasons, split: null };
157
+ }
158
+ const witnessSet = built.witnessSet;
159
+ const plan = syntheticTodoPlan(todoIds);
160
+
161
+ // 観測したaffected testsだけを検証に使う。宣言から発明しない。
162
+ const affectedTests = [...new Set(todoIds
163
+ .flatMap((todoId) => request.manual_witness[todoId].affected_tests))].sort(compareText);
164
+
165
+ const baseArtifact = compileTodoIndependence({
166
+ witnessSet, plan, baseSha, compiledAt,
167
+ sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
168
+ });
169
+
170
+ const pathNames = { ...declaration.path_names };
171
+ return resolveRuntimeSeamTreatment({
172
+ finding,
173
+ witnessSet,
174
+ pathNames,
175
+ baseSha,
176
+ manifestDigest: baseArtifact.result_digest,
177
+ affectedTests,
178
+ taskMigrationDigest: declaration.task_migration_digest,
179
+ commitTransform: async ({ files, candidateId }) => commitSeamTransform({
180
+ repoRoot, baseSha, files, candidateId,
181
+ }),
182
+ applyConflict: async ({ conflict }) => {
183
+ const applied = await applySeamConflict({
184
+ repoRoot,
185
+ planKey: SYNTHETIC_PLAN_KEY,
186
+ conflict,
187
+ witnessSet,
188
+ latticeBin,
189
+ sharedPathFor: () => declaration.path_names.shared,
190
+ executors: request.capacity.executors,
191
+ pathNames,
192
+ compileIndependence: {
193
+ baseArtifact,
194
+ inWorktree: async ({ worktreePath, witnessSet: postWitness }) => compileTodoIndependence({
195
+ witnessSet: postWitness,
196
+ plan,
197
+ baseSha,
198
+ compiledAt,
199
+ sensorEvidence: await collectWitnessSensorEvidence({
200
+ cwd: worktreePath, witnessSet: postWitness,
201
+ }),
202
+ }),
203
+ },
204
+ });
205
+ return { ...applied, candidate: applied.candidate ?? null };
206
+ },
207
+ });
208
+ }
209
+
210
+ /** 決着をartifactにする。branchを動かすのは操作するAIなので、行き先を明示して返す。 */
211
+ export function buildRuntimeSeamResolution({ runId, findingDigest, resolved }) {
212
+ const resolution = {
213
+ schema: RUNTIME_SEAM_RESOLUTION_SCHEMA,
214
+ run_id: runId,
215
+ finding_digest: findingDigest,
216
+ lane: resolved.lane,
217
+ reasons: [...resolved.reasons].sort(compareText),
218
+ split: resolved.split ?? null,
219
+ successor_base_sha: resolved.successor_base_sha ?? null,
220
+ successor_base_ref: resolved.successor_base_ref ?? null,
221
+ resolution_digest: '',
222
+ };
223
+ resolution.resolution_digest = todoSelfDigest(resolution, 'resolution_digest');
224
+ return resolution;
225
+ }
226
+
227
+ /**
228
+ * seam_split再計画で、後継baseが本当に変換を含むかを検査する(ADR 0141)。
229
+ *
230
+ * これが無い間、`mode: 'seam_split'`の再計画requestは**変換を含まないbaseを指していても通った**。
231
+ * splitは新しい面の所有を宣言するのに、compileされる後継treeにそのfileが無い——rb工程で
232
+ * 直したのと同じ欠陥が、管理runtimeの層に残っていた。
233
+ *
234
+ * 検査するのは3つ。どれもLatticeが既に持っているartifactだけで判定でき、推定を含まない。
235
+ *
236
+ * 1. baseが前進し、かつ旧baseの子孫であること。変換が着地していなければ前進しない。
237
+ * 2. splitが「消える」と述べた競合辺が、後継planに実際に無いこと。後継treeに変換が
238
+ * 載っていなければ両TODOは同じfileを書き続けるので、この辺は消えない。
239
+ * 3. splitが新たに所有すると述べた資源が、後継requestで**creationとして宣言されていない**こと。
240
+ * seam splitは既存codeを新しい面へ移す操作であり、変換が既に作っている。これから作る、
241
+ * と宣言されているなら、指しているbaseは変換前である。
242
+ */
243
+ export function verifySeamSplitSuccessor({
244
+ split, predecessorBaseSha, successorBaseSha, successorIsDescendant,
245
+ successorConflicts = [], successorWitness = {},
246
+ } = {}) {
247
+ const reasons = [];
248
+ if (successorBaseSha === predecessorBaseSha) reasons.push('successor_base_not_advanced');
249
+ else if (successorIsDescendant !== true) reasons.push('successor_base_not_descendant');
250
+
251
+ const removedEdges = split?.edge_diff?.removed ?? [];
252
+ const remaining = new Set(successorConflicts
253
+ .map(({ todo_ids: ids }) => [...ids].sort(compareText).join('\0')));
254
+ for (const edge of removedEdges) {
255
+ const key = [edge.from_todo_id, edge.to_todo_id].sort(compareText).join('\0');
256
+ if (remaining.has(key)) reasons.push(`declared_removed_conflict_persists:${key.replace('\0', ',')}`);
257
+ }
258
+
259
+ for (const added of split?.ownership_diff?.added ?? []) {
260
+ const owns = successorWitness[added.owner_todo_id]?.owns ?? [];
261
+ for (const own of owns) {
262
+ const resourceId = `own-${own.kind}-${sha16(own.target)}`;
263
+ if (resourceId === added.resource_id && own.creates === true) {
264
+ reasons.push(`declared_owned_surface_is_creation:${added.owner_todo_id}`);
265
+ }
266
+ }
267
+ }
268
+ return { ok: reasons.length === 0, reasons: [...new Set(reasons)].sort(compareText) };
269
+ }
270
+
271
+ /** `<runDir>/findings/<digest>.json`を読む。存在しない/別epochのfindingは受けない。 */
272
+ export async function readRuntimeFindingRecord({ runDir, findingDigest, planEpoch }) {
273
+ const filePath = path.join(runDir, 'findings', `${findingDigest}.json`);
274
+ let record;
275
+ try {
276
+ record = JSON.parse(await readFile(filePath, 'utf8'));
277
+ } catch {
278
+ return { record: null, reason: 'finding_not_recorded' };
279
+ }
280
+ if (record?.finding_digest !== findingDigest) return { record: null, reason: 'finding_digest_mismatch' };
281
+ if (record.plan_epoch !== planEpoch) return { record: null, reason: 'finding_from_other_epoch' };
282
+ return { record, reason: null };
283
+ }
Binary file
@@ -0,0 +1,22 @@
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { promisify } from 'node:util';
4
+
5
+ export const execFileAsync = promisify(execFile);
6
+
7
+ export const GIT_SHA1 = /^[0-9a-f]{40}$/u;
8
+
9
+ export const SEAM_REF_PREFIX = 'refs/lattice/seam';
10
+
11
+ export async function git(args, cwd) {
12
+ const { stdout } = await execFileAsync('git', args, {
13
+ cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
14
+ });
15
+ return stdout;
16
+ }
17
+
18
+ export function safeRelative(target) {
19
+ return typeof target === 'string' && target.length > 0 && !target.includes('\0')
20
+ && !path.posix.isAbsolute(target) && target === path.posix.normalize(target)
21
+ && !target.split('/').includes('..');
22
+ }
@@ -0,0 +1,81 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { GIT_SHA1, execFileAsync, git, safeRelative } from './seam-commit-shared.mjs';
5
+ import { seamRefFor } from './seam-ref.mjs';
6
+
7
+ /**
8
+ * 採用された変換をcommitへ確定し、そのshaを返す。
9
+ *
10
+ * 本repositoryの作業ツリーは触らない。使い捨てworktreeをbaseへ張り、変換後のfileを書き、
11
+ * detached HEADでcommitし、refへ繋いでからworktreeを畳む。commit objectはobject DBを共有する
12
+ * ので、worktreeを消してもshaは生き残る——refはそれをGCから守るためである。
13
+ *
14
+ * @param {object} options
15
+ * @param {string} options.repoRoot 本repository
16
+ * @param {string} options.baseSha 変換前のbase
17
+ * @param {object} options.files pathごとの変換後text
18
+ * @param {string} options.candidateId ref名に使う識別子
19
+ * @param {string} options.message commit message
20
+ * @returns {Promise<{commitSha: string, ref: string}>}
21
+ */
22
+ export async function commitSeamTransform({
23
+ repoRoot, baseSha, files, candidateId, message,
24
+ } = {}) {
25
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) throw new TypeError('repoRootが不正');
26
+ if (!GIT_SHA1.test(baseSha ?? '')) throw new TypeError('baseShaが不正');
27
+ if (files === null || typeof files !== 'object' || Array.isArray(files)
28
+ || Object.keys(files).length === 0) throw new TypeError('filesが不正');
29
+ const targets = Object.keys(files);
30
+ if (!targets.every(safeRelative)) throw new TypeError('変換後pathがrepo相対規律を満たさない');
31
+ if (typeof candidateId !== 'string' || !/^[0-9A-Za-z][\w.-]{0,127}$/u.test(candidateId)) {
32
+ throw new TypeError('candidateIdが不正');
33
+ }
34
+
35
+ const worktreeRoot = await mkdtemp(path.join(tmpdir(), 'lattice-seam-commit-'));
36
+ const worktreePath = path.join(worktreeRoot, 'tree');
37
+ let commitSha;
38
+ try {
39
+ await git(['worktree', 'add', '--detach', '--quiet', worktreePath, baseSha], repoRoot);
40
+ for (const [target, text] of Object.entries(files)) {
41
+ const absolute = path.join(worktreePath, target);
42
+ await mkdir(path.dirname(absolute), { recursive: true });
43
+ await writeFile(absolute, text);
44
+ }
45
+ await git(['add', '--', ...targets], worktreePath);
46
+ // 変換で1 byteも変わらなかったなら、確定すべき成果が無い。空commitで
47
+ // 「進んだ」ように見せない。
48
+ const staged = await git(['diff', '--cached', '--name-only'], worktreePath);
49
+ if (staged.trim() === '') throw new TypeError('変換後の差分が無い');
50
+ await git([
51
+ '-c', 'user.email=lattice@localhost', '-c', 'user.name=lattice',
52
+ 'commit', '--quiet', '-m', message ?? `seam transform ${candidateId}`,
53
+ ], worktreePath);
54
+ commitSha = (await git(['rev-parse', 'HEAD'], worktreePath)).trim();
55
+ if (!GIT_SHA1.test(commitSha)) throw new TypeError('commit shaが不正');
56
+ } finally {
57
+ await git(['worktree', 'remove', '--force', worktreePath], repoRoot).catch(() => {});
58
+ await rm(worktreeRoot, { recursive: true, force: true });
59
+ }
60
+
61
+ // worktreeを畳んだ後にrefを張る。object DBは共有なのでshaは生きているが、
62
+ // refが無いとGCの対象になる。
63
+ const ref = seamRefFor(candidateId);
64
+ // 同じcandidateへ2回目の変換が来た時、黙って上書きすると1回目の証跡が消える。
65
+ // 連鎖は「前の変換を含むbaseの上で次を確定する」形でしか正しくならないので、
66
+ // 既存refが今回のbaseの祖先を指していなければ拒む(ADR 0142 / ADR 0141 OQ2)。
67
+ const existing = await git(['for-each-ref', '--format=%(objectname)', ref], repoRoot)
68
+ .then((stdout) => stdout.trim())
69
+ .catch(() => '');
70
+ if (GIT_SHA1.test(existing) && existing !== commitSha) {
71
+ const chained = await execFileAsync('git', ['merge-base', '--is-ancestor', existing, baseSha], {
72
+ cwd: repoRoot, encoding: 'utf8',
73
+ }).then(() => true).catch(() => false);
74
+ if (!chained) {
75
+ throw new Error(`seam ref ${ref} は既に ${existing} を指しており、今回のbaseはその子孫でない`
76
+ + '。前の変換を含むbaseの上で確定するか、別のcandidate idを使う');
77
+ }
78
+ }
79
+ await git(['update-ref', ref, commitSha], repoRoot);
80
+ return { commitSha, ref };
81
+ }
Binary file
@@ -0,0 +1,33 @@
1
+ import { SEAM_REF_PREFIX, git } from './seam-commit-shared.mjs';
2
+
3
+ /** 変換の成果を指すref。branch名前空間へ置かないので、通常のbranch一覧には現れない。 */
4
+ export function seamRefFor(candidateId) {
5
+ return `${SEAM_REF_PREFIX}/${candidateId}`;
6
+ }
7
+
8
+ /**
9
+ * 確定済みseam refを列挙する(ADR 0142 / ADR 0141 OQ1)。
10
+ *
11
+ * **自動では消さない。** このrefが指すのは「五条件を通って受理された変換の実体」であり、
12
+ * どの版がどの競合をどう解いたかを後から辿れる唯一の資源である。runが閉じたら消す設計も
13
+ * 検討したが、それは証跡を寿命付きにするということで、記録を所有するという製品の役目と
14
+ * 衝突する。消すかどうかは所有者の裁定に委ね、道具は「何が在るか」を見せる側だけを持つ。
15
+ *
16
+ * @returns {Promise<Array<{ref: string, candidate_id: string, commit_sha: string}>>}
17
+ */
18
+ export async function listSeamRefs({ repoRoot } = {}) {
19
+ let stdout;
20
+ try {
21
+ stdout = await git(['for-each-ref', '--format=%(refname) %(objectname)', SEAM_REF_PREFIX], repoRoot);
22
+ } catch {
23
+ return [];
24
+ }
25
+ return stdout.split('\n')
26
+ .map((line) => line.trim())
27
+ .filter((line) => line.length > 0)
28
+ .map((line) => {
29
+ const [ref, commitSha] = line.split(' ');
30
+ return { ref, candidate_id: ref.slice(`${SEAM_REF_PREFIX}/`.length), commit_sha: commitSha };
31
+ })
32
+ .sort((left, right) => (left.ref < right.ref ? -1 : left.ref > right.ref ? 1 : 0));
33
+ }
@@ -85,6 +85,13 @@ function exportedBlock(raw) {
85
85
  return parts.join('\n');
86
86
  }
87
87
 
88
+ /** 原pathでexport宣言だったか。移動先でexportを足したかではなく、元の姿を見る。 */
89
+ function wasExported(raw) {
90
+ const declaration = raw.split('\n')
91
+ .find((line) => !COMMENT_LINE.test(line) && line.trim() !== '');
92
+ return declaration !== undefined && /^\s*export\s/u.test(declaration);
93
+ }
94
+
88
95
  function relativeSpecifier(fromPath, toPath) {
89
96
  const fromDir = fromPath.slice(0, fromPath.lastIndexOf('/') + 1);
90
97
  return toPath.startsWith(fromDir) ? `./${toPath.slice(fromDir.length)}` : `./${toPath}`;
@@ -138,10 +145,17 @@ export function planSeamRewrite({ sourceText, candidate, symbolExtents } = {}) {
138
145
 
139
146
  const bodyByPath = new Map();
140
147
  const removal = new Set();
148
+ // 原pathでexportされていたsymbolは、移した先から残余面が再exportする。
149
+ // しないと原pathをimportしている全fileが壊れ、外部挙動同等性が原理的に満たせない。
150
+ const reExportByPath = new Map();
141
151
  for (const block of blocks) {
142
152
  const raw = lines.slice(block.start - 1, block.end).join('\n');
143
153
  if (!bodyByPath.has(block.path)) bodyByPath.set(block.path, []);
144
154
  bodyByPath.get(block.path).push(exportedBlock(raw));
155
+ if (wasExported(raw)) {
156
+ if (!reExportByPath.has(block.path)) reExportByPath.set(block.path, []);
157
+ reExportByPath.get(block.path).push(block.symbol);
158
+ }
145
159
  for (let line = block.start; line <= block.end; line += 1) removal.add(line);
146
160
  }
147
161
 
@@ -175,7 +189,11 @@ export function planSeamRewrite({ sourceText, candidate, symbolExtents } = {}) {
175
189
  const residualBody = keptBody.join('\n').replace(/\n{3,}/gu, '\n\n').replace(/\n+$/u, '');
176
190
  const residualCross = importsFor(residual.path, residualBody)
177
191
  .filter((statement) => !keptHeader.join('\n').includes(statement));
178
- files[residual.path] = `${[...keptHeader, ...residualCross].join('\n')}\n${residualBody}\n`
192
+ const reExports = [...reExportByPath.entries()]
193
+ .sort(([left], [right]) => compareText(left, right))
194
+ .map(([targetPath, names]) => `export { ${[...names].sort(compareText).join(', ')} }`
195
+ + ` from '${relativeSpecifier(residual.path, targetPath)}';`);
196
+ files[residual.path] = `${[...keptHeader, ...residualCross, ...reExports].join('\n')}\n${residualBody}\n`
179
197
  .replace(/^\n+/u, '');
180
198
 
181
199
  return { files, reasons: [] };
package/src/todo-cli.mjs CHANGED
@@ -276,7 +276,7 @@ async function readEvidenceInput(repoRoot, inputRef) {
276
276
  });
277
277
  }
278
278
 
279
- async function readJsonInput(repoRoot, inputRef, { validate, invalidCode }) {
279
+ async function readJsonInput(repoRoot, inputRef, { validate, invalidCode, expected = null }) {
280
280
  if (!isTodoRef(inputRef)) {
281
281
  throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
282
282
  }
@@ -321,7 +321,10 @@ async function readJsonInput(repoRoot, inputRef, { validate, invalidCode }) {
321
321
  throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
322
322
  }
323
323
  if (!validate(descriptor)) {
324
- throw new TodoStoreError(invalidCode, 'schema_invalid');
324
+ // schema_invalid」だけを返すと、呼び出したAIは何をどう直せばよいか分からない。
325
+ // 期待する形を渡されている入口は、それをそのまま返す(ADR 0130の案内規律)。
326
+ throw new TodoStoreError(invalidCode, 'schema_invalid', undefined,
327
+ expected === null ? undefined : { expected });
325
328
  }
326
329
  return descriptor;
327
330
  }
@@ -1006,6 +1009,11 @@ async function readSeamPathNames(repoRoot, inputRef) {
1006
1009
  && Object.entries(candidate.names)
1007
1010
  .every(([key, target]) => isTodoIdentifier(key) && isTodoRef(target)),
1008
1011
  invalidCode: 'SEAM_PATH_NAMES_INVALID',
1012
+ expected: {
1013
+ schema: 'lattice.seam_path_names.v1',
1014
+ shape: '{ "schema": "lattice.seam_path_names.v1", "names": { "<task_id>": "<repo相対path>" } }',
1015
+ note: '所有面はtask_idごとに、共有面は"shared"というkeyで名前を与える',
1016
+ },
1009
1017
  });
1010
1018
  return value.names;
1011
1019
  }