@quolu/lattice 0.35.0 → 0.36.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/README.ja.md CHANGED
@@ -197,7 +197,34 @@ lattice todo migrate --input <extraction.json> --serialization-reviewed
197
197
  `active_set`と`next_ready`で観測できます。
198
198
  ToDo完了は軽量確認までで、所属ToDoが全てdoneになったPhaseは`gate_ready`となり、`todo phase review`後に
199
199
  required evidenceを束縛した`todo phase accept`で重監査の判断を記録します。監査回数やPhase数を自動追加する
200
- 機能ではありません。Phase状態は
200
+ 機能ではありません。
201
+
202
+ **監査の既定は「有り」です。** phaseを持たないplanも終端に重監査が要ります(予約Phase
203
+ `terminal-audit`を暗黙に1つ持ちます)。全taskがdoneになった状態は「完走」ではなく`gate_ready`=
204
+ **監査待ち**であり、工程図のlive scopeはそのplanのToDoを畳みません——完走扱いで図から消えることが
205
+ 「閉じた」の可視表現なので、監査の記録なしにそこへ行かせません。作成時に拒否はせず、
206
+ `todo migrate`/`plan create`の結果と最後のdoneのadvisoryで`terminal_audit_required`を通知します。
207
+ **終端監査はToDoのdispatch可否へ影響しません**(Phaseは重監査の順序だけを制御し、開始順はToDo DAGが決めます)。
208
+ 規約は[ADR 0147](docs/adr/0147-audit-is-on-by-default.md)が正です。
209
+
210
+ ```bash
211
+ lattice todo phase status --plan <key> # phase無しplanでも暗黙Phaseを返す(implicit: true)
212
+ lattice todo phase review --plan <key> --phase terminal-audit --reason <text>
213
+ lattice todo phase accept --plan <key> --phase terminal-audit --input <file>
214
+ ```
215
+
216
+ 誤ってphase無しで作ったplanへ後からPhaseを被せる場合は、`revise-phase`の
217
+ `state_policy: acquire_phase`でdone状態を保ったまま獲得できます(未割当→割当の向きだけを許し、
218
+ 既にphaseを持つtaskの付け替えは拒否します)。
219
+
220
+ 契約のJSON Schemaは各入口から取れます。入力が合わないときは、違反フィールドのpathが
221
+ error detailの`violation_path`へ載ります(配列のソート違反は`/tasks/1`のようにindexまで名指しします)。
222
+
223
+ ```bash
224
+ lattice plan create --schema --json # 既定は最新v3
225
+ lattice todo revise-phase --schema --json
226
+ lattice plan show <plan_key> --json # planのtask・依存・phase・状態を1コマンドで読む
227
+ ```Phase状態は
201
228
  `lattice todo phase status --plan <key>`、閲覧中に進捗が更新される工程表は
202
229
  `lattice todo gantt serve --port 0`で確認できます。live viewerはloopback-only、read-onlyで、
203
230
  `/projects/<project_id>/`というproject固有URLを返します。別projectからそれぞれ起動すれば、独立port・独立SSE経路で同時表示できます。
package/README.md CHANGED
@@ -150,6 +150,14 @@ point of use.
150
150
  transform that cannot be verified is not adopted. A finding that cannot be independently
151
151
  re-derived is not recorded.
152
152
 
153
+ **Heavy audit is on by default.** A plan without explicit phases still carries an implicit
154
+ terminal audit: every task being done means `gate_ready` — *awaiting audit* — not finished. The
155
+ live dependency diagram refuses to fold such a plan away, because folding is how the product
156
+ says "closed", and nothing gets there without an evidence-bound `phase accept`. Creation is never
157
+ rejected over it; the requirement is reported instead. And the audit gate never touches dispatch:
158
+ phases order reviews, the ToDo DAG orders work
159
+ ([ADR 0147](docs/adr/0147-audit-is-on-by-default.md)).
160
+
153
161
  ## Patent
154
162
 
155
163
  The design in this repository is the subject of a Japanese patent application:
package/bin/lattice.mjs CHANGED
@@ -50,13 +50,21 @@ if (help !== null) {
50
50
  process.exitCode = projectCliFailure(process.stderr, error);
51
51
  }
52
52
  } else if (args.length === 5 && args[0] === 'plan' && args[1] === 'create'
53
- && args[2] === '--schema-version' && ['2', '3'].includes(args[3]) && args[4] === '--json') {
53
+ && args[2] === '--schema-version' && ['1', '2', '3'].includes(args[3]) && args[4] === '--json') {
54
54
  const { projectCliFailure, runPlanCreateSchema } = await import('../src/project-cli.mjs');
55
55
  try {
56
56
  process.exitCode = await runPlanCreateSchema({ stdout: process.stdout, version: Number(args[3]) });
57
57
  } catch (error) {
58
58
  process.exitCode = projectCliFailure(process.stderr, error);
59
59
  }
60
+ } else if (args.length === 4 && args[0] === 'plan' && args[1] === 'show'
61
+ && typeof args[2] === 'string' && args[2].length > 0 && args[3] === '--json') {
62
+ const { projectCliFailure, runPlanShow } = await import('../src/project-cli.mjs');
63
+ try {
64
+ process.exitCode = await runPlanShow({ cwd: process.cwd(), planKey: args[2], stdout: process.stdout });
65
+ } catch (error) {
66
+ process.exitCode = projectCliFailure(process.stderr, error);
67
+ }
60
68
  } else if (args.length === 2 && args[0] === 'factory-diagnostics' && args[1] === '--json') {
61
69
  const { buildFactoryDiagnostics } = await import('../src/factory-diagnostics.mjs');
62
70
  const diagnostics = await buildFactoryDiagnostics();
@@ -107,6 +107,19 @@ public hostname `lattice.kitepon.dev` を次のoriginへ対応付ける。
107
107
  3. **Cloudflare public HTTPS**: `https://lattice.kitepon.dev/projects/`がredirectなしで200となり、一覧から開いた
108
108
  `/projects/<project_id>/`のHTML titleが`Lattice — <project名> 依存工程図`である。
109
109
 
110
+ 公開viewerの404も、ブラウザとAPIの両契約を別々に確認する。未知URLへ`Accept: text/html`を
111
+ 付けたrequestはHTTP 404かつ`Content-Type: text/html`で、`noindex, nofollow`と
112
+ `/projects/`、`https://kitepon.dev/`への戻り先を持つ。`Accept: application/json`では
113
+ HTTP 404かつ`Content-Type: application/json`で、既存の
114
+ `lattice.todo_gantt_http_error.v1`を返す。
115
+
116
+ ```bash
117
+ curl --silent --show-error --include --header 'Accept: text/html' \
118
+ https://lattice.kitepon.dev/unknown
119
+ curl --silent --show-error --include --header 'Accept: application/json' \
120
+ https://lattice.kitepon.dev/unknown
121
+ ```
122
+
110
123
  外部gateはHTMLだけで閉じず、各projectの
111
124
  `https://lattice.kitepon.dev/projects/<project_id>/events`も確認する。応答は200かつ
112
125
  `Content-Type: text/event-stream`で、接続直後に`event: state`と現在の`head_digest`を返さなければならない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
package/src/cli-help.mjs CHANGED
@@ -26,8 +26,9 @@ Commands:
26
26
  create --input <file> [--serialization-reviewed]
27
27
  # 依存グラフがほぼ一直線(serialization_ratioが閾値超)なら一度突き返す。
28
28
  # 再考した上でなお直列でよいなら --serialization-reviewed を付けて再実行する
29
- create --schema --json
30
- create --schema-version <2|3> --json
29
+ create --schema --json # 既定は最新版(v3)のJSON Schemaを返す
30
+ create --schema-version <1|2|3> --json
31
+ show <plan_key> --json # task・依存・phase・状態をplan本体から1コマンドで投影する
31
32
  compile --request <request.json>
32
33
  compile --schema --json # lattice.run_request.v1 の JSON Schema を出す
33
34
  verify --request <request.json> --plan <plan.json>
@@ -91,6 +92,9 @@ Write commands:
91
92
  revise --plan <key> --input <file>
92
93
  revise-phase --plan <key> --input <file>
93
94
  revise-set --input <file>
95
+ <revise|revise-phase|revise-set|migrate> --schema --json
96
+ # 実際に受理する最新契約のJSON Schemaを返す(storeを読まない)。
97
+ # 入力が契約に合わないときは、違反フィールドのpathがerror detailへ載る
94
98
  phase review --plan <key> --phase <id> --reason <text>
95
99
  phase <accept|reject> --plan <key> --phase <id> --input <file>
96
100
  phase reopen --plan <key> --phase <id> --reason <text> [--override-reason <text>]
@@ -135,7 +139,8 @@ registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPT
135
139
  const SUBCOMMAND_USAGE = Object.freeze({
136
140
  status: 'status --json',
137
141
  'session-context': 'session-context --json',
138
- 'plan create': 'plan create --input <file> [--serialization-reviewed] | --schema --json | --schema-version <2|3> --json',
142
+ 'plan create': 'plan create --input <file> [--serialization-reviewed] | --schema --json | --schema-version <1|2|3> --json',
143
+ 'plan show': 'plan show <plan_key> --json',
139
144
  'plan compile': 'plan compile --request <request.json> | --schema --json',
140
145
  'plan verify': 'plan verify --request <request.json> --plan <plan.json>',
141
146
  'run start': 'run start --request <request.json> --executor <adapter> | --schema --json',
@@ -177,10 +182,10 @@ const SUBCOMMAND_USAGE = Object.freeze({
177
182
  'todo reopen': 'todo reopen --plan <key> --task <id> --reason <text> [--override-reason <text>]',
178
183
  'todo evidence': 'todo evidence promote --plan <key> --task <id> --evidence <file>',
179
184
  'todo evidence promote': 'todo evidence promote --plan <key> --task <id> --evidence <file>',
180
- 'todo revise': 'todo revise --plan <key> --input <file>',
181
- 'todo revise-phase': 'todo revise-phase --plan <key> --input <file>',
182
- 'todo revise-set': 'todo revise-set --input <file>',
183
- 'todo migrate': 'todo migrate --input <extraction.json> [--serialization-reviewed]',
185
+ 'todo revise': 'todo revise --plan <key> --input <file> | --schema --json',
186
+ 'todo revise-phase': 'todo revise-phase --plan <key> --input <file> | --schema --json',
187
+ 'todo revise-set': 'todo revise-set --input <file> | --schema --json',
188
+ 'todo migrate': 'todo migrate --input <extraction.json> [--serialization-reviewed] | --schema --json',
184
189
  'sensor init': 'sensor init [path] --json',
185
190
  'sensor sync': 'sensor sync [path] --json',
186
191
  'runtime-errors snapshot': 'runtime-errors snapshot [--after-cursor <n>] [--limit <n>] --json',
@@ -22,6 +22,7 @@ import {
22
22
  buildTodoPlan,
23
23
  createTodoStoreWriter,
24
24
  initializeAuthoredTodoStore,
25
+ isPhaselessTodoPlanSchema,
25
26
  readTodoIndependenceArtifact,
26
27
  readTodoStore,
27
28
  TodoStoreError,
@@ -481,6 +482,9 @@ export async function runPlanCreate({ cwd, inputRef, stdout, serializationReview
481
482
  max_frontier_width: dispatchShape.max_frontier_width,
482
483
  serialization_ratio: dispatchShape.serialization_ratio,
483
484
  },
485
+ // ADR 0147裁定3: phase無し(v3)のplan createは拒否せず、終端監査が要ることを結果へ
486
+ // 明示するに留める。phase入力(v4/v5)ならfalse——既存のPhase gateがそのまま重監査を担う。
487
+ terminal_audit_required: isPhaselessTodoPlanSchema(member.plan.schema),
484
488
  result_digest: '',
485
489
  };
486
490
  result.result_digest = resultDigest(result);
@@ -488,7 +492,16 @@ export async function runPlanCreate({ cwd, inputRef, stdout, serializationReview
488
492
  return 0;
489
493
  }
490
494
 
491
- export async function runPlanCreateSchema({ stdout, version = 1 }) {
495
+ /**
496
+ * `--schema --json`(版指定なし)の既定はCURRENT_CREATE_INPUT_SCHEMAと同じv3にする。
497
+ * 既定がv1のままだと、素の`--schema`を叩いたAIが古いv1(Phaseを表現できない)を
498
+ * 受け取り、実運用で通らない入力を作ってしまう(実際に踏んだ)。
499
+ * `--schema-version 1`は互換のため引き続き取得できる(bin/lattice.mjs側で許可)。
500
+ *
501
+ * 「どの版を返したか」は返すJSON Schema自身の`title`(例: `lattice.plan_create_input.v3`)が
502
+ * 既に機械可読に持っている。壊さずに追加のkeyを足す理由が無いので足さない。
503
+ */
504
+ export async function runPlanCreateSchema({ stdout, version = 3 }) {
492
505
  if (![1, 2, 3].includes(version)) throw new TypeError('unsupported plan create schema version');
493
506
  const expected = version === 3 ? DECOUPLED_PHASE_CREATE_INPUT_SCHEMA
494
507
  : version === 2 ? PHASE_CREATE_INPUT_SCHEMA : CREATE_INPUT_SCHEMA;
@@ -504,6 +517,95 @@ export async function runPlanCreateSchema({ stdout, version = 1 }) {
504
517
  }
505
518
  }
506
519
 
520
+ const PLAN_SHOW_RESULT_SCHEMA = 'lattice.plan_show_result.v1';
521
+
522
+ /**
523
+ * `todo bindings`はcompile_binding付きtaskだけを投影するので、通常planでは空配列を返す。
524
+ * それを見て「planが空だ」と誤読された実績があるため、plan本体(task・依存・phase・状態)を
525
+ * 1コマンドで読める面を別に持つ。読み出しは既存store readerとtodo status projectionの
526
+ * 再利用に留め、journalを独自に再実装しない。
527
+ */
528
+ export async function runPlanShow({ cwd, planKey, stdout }) {
529
+ const repoRoot = resolveRepoRoot(cwd);
530
+ if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
531
+ const store = await readTodoStore({ repoRoot });
532
+ const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
533
+ if (member === undefined) {
534
+ // 既存read commands(independence compile等)と同じcode/reasonを踏襲する。
535
+ // 未知のplan_keyを「store不整合」と同じ扱いにするのはこのCLI全体の既定であり、
536
+ // ここだけ別codeへ逸れると呼び出し側の分岐が増える。
537
+ throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, {
538
+ plan_key: planKey, next_action: 'check_active_plans_via_status',
539
+ });
540
+ }
541
+ const { plan, tasks, phases } = member;
542
+ const phaseInput = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema);
543
+ const stateByTaskId = new Map(tasks.map((state) => [state.task_id, state]));
544
+ // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
545
+ // (readTodoStoreが常に member.phases として埋める。ADR 0147)。
546
+ const phaseStatusById = new Map((phases ?? []).map((phase) => [phase.phase_id, phase.status]));
547
+
548
+ // 依存の本数: このplanの中でそのtaskへ入ってくるhard_dependencies辺と、joinで
549
+ // 合流するafter辺の合計。cross-plan参照は数えない(plan showは単一planの投影のため)。
550
+ const dependsOnCount = new Map(plan.tasks.map((task) => [task.task_id, 0]));
551
+ for (const edge of plan.hard_dependencies) {
552
+ if (edge.to.plan_key === planKey && dependsOnCount.has(edge.to.task_id)) {
553
+ dependsOnCount.set(edge.to.task_id, dependsOnCount.get(edge.to.task_id) + 1);
554
+ }
555
+ }
556
+ for (const join of plan.joins) {
557
+ if (join.before.plan_key === planKey && dependsOnCount.has(join.before.task_id)) {
558
+ dependsOnCount.set(join.before.task_id, dependsOnCount.get(join.before.task_id) + join.after.length);
559
+ }
560
+ }
561
+
562
+ const taskList = plan.tasks.map((task) => ({
563
+ task_id: task.task_id,
564
+ title: task.title,
565
+ lane: task.lane,
566
+ phase_id: phaseInput ? task.phase_id : null,
567
+ state: stateByTaskId.get(task.task_id).status,
568
+ depends_on_count: dependsOnCount.get(task.task_id) ?? 0,
569
+ }));
570
+
571
+ const phaseList = phaseInput ? plan.phases.map((phase) => ({
572
+ phase_id: phase.phase_id,
573
+ title: phase.title,
574
+ gate_policy: phase.gate_policy,
575
+ predecessor_phase_ids: phase.predecessor_phase_ids,
576
+ status: phaseStatusById.get(phase.phase_id) ?? null,
577
+ })) : [];
578
+
579
+ // dispatch形状はplan create時に既に計算している同じ関数を再利用する。
580
+ // critical path長・frontier幅を独自に計算し直さない。
581
+ const dispatchShape = computeTodoDispatchShapeForPlan({
582
+ projectId: plan.project_id, planKey,
583
+ taskIds: plan.tasks.map(({ task_id: taskId }) => taskId),
584
+ hardDependencies: plan.hard_dependencies, joins: plan.joins,
585
+ });
586
+
587
+ const result = {
588
+ schema: PLAN_SHOW_RESULT_SCHEMA,
589
+ project_id: plan.project_id,
590
+ plan_key: plan.plan_key,
591
+ plan_version: plan.plan_version,
592
+ plan_schema: plan.schema,
593
+ has_phases: phaseInput,
594
+ phases: phaseList,
595
+ tasks: taskList,
596
+ topology: {
597
+ task_count: dispatchShape.task_count,
598
+ critical_path_length: dispatchShape.critical_path_length,
599
+ max_frontier_width: dispatchShape.max_frontier_width,
600
+ serialization_ratio: dispatchShape.serialization_ratio,
601
+ },
602
+ result_digest: '',
603
+ };
604
+ result.result_digest = resultDigest(result);
605
+ stdout.write(`${JSON.stringify(result)}\n`);
606
+ return 0;
607
+ }
608
+
507
609
  export function projectStatusFailure({ cwd, stdout, cliVersion, error }) {
508
610
  const result = invalidStatus({
509
611
  cliVersion, repoRoot: resolveRepoRoot(cwd),
package/src/todo-cli.mjs CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  applyTodoRevisionSet,
38
38
  createTodoStoreWriter,
39
39
  TodoStoreError,
40
+ isPhaselessTodoPlanSchema,
41
+ TERMINAL_AUDIT_PHASE_ID,
40
42
  readTodoIndependenceArtifact,
41
43
  readTodoSeamProposalArtifact,
42
44
  readTodoStore,
@@ -52,6 +54,7 @@ import {
52
54
  } from './todo-store.mjs';
53
55
  import {
54
56
  appendTodoExtraction,
57
+ explainTodoExtraction,
55
58
  validateTodoExtraction,
56
59
  } from './todo-migration.mjs';
57
60
  import {
@@ -97,6 +100,7 @@ import {
97
100
  validateWitnessDraft,
98
101
  } from './witness-scaffold.mjs';
99
102
  import {
103
+ explainPhaseTodoRevision, explainTodoRevision, explainTodoRevisionSet,
100
104
  parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
101
105
  validateTodoRevision, validateTodoRevisionSet,
102
106
  } from './todo-revision.mjs';
@@ -113,6 +117,31 @@ const ACTOR_ENV_KEYS = Object.freeze([
113
117
  'LATTICE_TODO_ACTOR_AGENT',
114
118
  ]);
115
119
 
120
+ /**
121
+ * `revise` / `revise-set` / `revise-phase` / `migrate`が実際に受理する最新契約のJSON
122
+ * Schemaを配布物から読む入口(`project-cli.mjs`の`runPlanCreateSchema`と同じ作法)。
123
+ *
124
+ * schemaを取る手段がCLIに無いと、AIはsrcを読んで必須keyを数えるしかなくなる
125
+ * (実運用で`phase_todo_revision.v3`の必須12 keyを試行錯誤で当てた)。storeは読まない
126
+ * ——`plan create --schema`と同じく決定的な出力・exit 0にする。
127
+ */
128
+ const TODO_SCHEMA_COMMANDS = Object.freeze({
129
+ revise: { title: 'lattice.todo_revision.v2', file: 'lattice.todo_revision.v2.schema.json' },
130
+ 'revise-set': { title: 'lattice.todo_revision_set.v3', file: 'lattice.todo_revision_set.v3.schema.json' },
131
+ 'revise-phase': {
132
+ title: 'lattice.phase_todo_revision.v3', file: 'lattice.phase_todo_revision.v3.schema.json',
133
+ },
134
+ migrate: { title: 'lattice.todo_extraction.v2', file: 'lattice.todo_extraction.v2.schema.json' },
135
+ });
136
+
137
+ async function runTodoSchemaCommand(command, stdout) {
138
+ const spec = TODO_SCHEMA_COMMANDS[command];
139
+ const schemaUrl = new URL(`../docs/schemas/${spec.file}`, import.meta.url);
140
+ const schema = JSON.parse(await readFile(schemaUrl, 'utf8'));
141
+ if (schema?.title !== spec.title) throw new TypeError(`bundled ${command} schema invalid`);
142
+ stdout.write(`${JSON.stringify(schema)}\n`);
143
+ }
144
+
116
145
  function usageFailure(stderr, argv) {
117
146
  const received = argv.length === 0 ? '(none)' : argv.join(' ').replace(/[\r\n]/gu, ' ');
118
147
  stderr.write(`lattice todo: unsupported command or arguments: ${received}\n`);
@@ -223,7 +252,11 @@ async function readMigrationInput(repoRoot, inputRef) {
223
252
  throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
224
253
  }
225
254
  if (!validateTodoExtraction(extraction)) {
226
- throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid');
255
+ // 「schema_invalid」だけでは何のfieldがどう壊れているか分からない(ADR 0130の案内規律)。
256
+ // explainは可否判定を変えず、診断だけを追加する。
257
+ const explained = explainTodoExtraction(extraction);
258
+ throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid', undefined,
259
+ explained.valid ? undefined : { violation_reason: explained.reason, violation_path: explained.path });
227
260
  }
228
261
  return extraction;
229
262
  }
@@ -232,6 +265,7 @@ async function readRevisionInput(repoRoot, inputRef, {
232
265
  validate = validateTodoRevision,
233
266
  invalidCode = 'REVISION_INVALID',
234
267
  invalidReason = 'revision_schema_or_digest_invalid',
268
+ explain = explainTodoRevision,
235
269
  } = {}) {
236
270
  const canonicalRoot = await realpath(repoRoot);
237
271
  const absolute = path.resolve(canonicalRoot, inputRef);
@@ -268,7 +302,16 @@ async function readRevisionInput(repoRoot, inputRef, {
268
302
  try { revision = JSON.parse(text.slice(0, -1)); } catch {
269
303
  throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
270
304
  }
271
- if (!validate(revision)) throw new TodoStoreError(invalidCode, invalidReason);
305
+ if (!validate(revision)) {
306
+ // 「schema_or_digest_invalid」だけでは何のfieldがどう壊れているか分からない
307
+ // (ADR 0130の案内規律)。explainは可否判定を変えず、診断だけを追加する。
308
+ // 呼び出し元がexplainを渡さない(phase decision入力等)場合はdetail無しのまま。
309
+ const explained = explain === null ? null : explain(revision);
310
+ throw new TodoStoreError(invalidCode, invalidReason, undefined,
311
+ explained === null || explained.valid ? undefined : {
312
+ violation_reason: explained.reason, violation_path: explained.path,
313
+ });
314
+ }
272
315
  if (text !== `${canonicalizeTodoArtifact(revision)}\n`) {
273
316
  throw new TodoStoreError(invalidCode, 'non_canonical_revision_bytes');
274
317
  }
@@ -373,6 +416,25 @@ function mutationActor(env) {
373
416
  return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
374
417
  }
375
418
 
419
+ /**
420
+ * phase無しplanで、この変異の結果terminal-audit Phaseがgate_ready(全task done・未監査)に
421
+ * なっていれば助言を返す(ADR 0147)。doneの結果だけを見て機械的に判定するので、既にreview
422
+ * まで進んでいれば`gate_ready`ではなくなり、二重に案内しない。phase付きplanや、まだ
423
+ * pending taskが残っているplanではterminal-audit Phase自体が無い/gate_readyでないので、
424
+ * このヘルパはnullを返し既存の`advisory: null`の挙動を変えない。
425
+ */
426
+ function terminalAuditDoneAdvisory(plan, phases) {
427
+ if (!isPhaselessTodoPlanSchema(plan.schema)) return null;
428
+ const phase = phases.find(({ phase_id }) => phase_id === TERMINAL_AUDIT_PHASE_ID);
429
+ if (phase?.status !== 'gate_ready') return null;
430
+ return {
431
+ terminal_audit_required: true, phase_id: TERMINAL_AUDIT_PHASE_ID, status: phase.status,
432
+ guidance: '全taskがdoneになった。このplanはphaseを持たないため、終端の重監査'
433
+ + '(todo phase review --plan <key> --phase terminal-audit → todo phase accept)を'
434
+ + '経るまで「閉じた」ことにはならない。',
435
+ };
436
+ }
437
+
376
438
  async function mutate({
377
439
  repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
378
440
  }) {
@@ -383,13 +445,18 @@ async function mutate({
383
445
  if (kind === 'done' && payload === 'evidence_promotion') {
384
446
  eventPayload = { done_mode: 'evidence_promotion', imported: true, evidence };
385
447
  }
386
- const { event, snapshot } = await appendTodoEvent({
448
+ const { event, snapshot, plan, phases } = await appendTodoEvent({
387
449
  repoRoot,
388
450
  writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
389
451
  planKey,
390
452
  event: { kind, task_id: taskId, actor, payload: eventPayload },
391
453
  });
392
454
  const task = snapshot.tasks.find(({ task_id: current }) => current === event.task_id);
455
+ // advisoryは呼び出し側(startTask)がstart用に既に組んでいればそれを尊重し、無ければ
456
+ // done時だけ終端監査の要否を調べる。block/unblock/reopenはnullのまま(既存挙動を変えない)。
457
+ // Phase状態はsnapshot(v1にはphasesキーが無い)でなく、appendTodoEventが別途返す
458
+ // 導出ビュー`phases`から読む。
459
+ const resolvedAdvisory = advisory ?? (kind === 'done' ? terminalAuditDoneAdvisory(plan, phases) : null);
393
460
  const result = {
394
461
  schema: 'lattice.todo_mutation_result.v2',
395
462
  project_id: event.project_id,
@@ -402,7 +469,7 @@ async function mutate({
402
469
  journal_head_digest: event.event_digest,
403
470
  snapshot_digest: snapshot.snapshot_digest,
404
471
  status: task.status,
405
- advisory,
472
+ advisory: resolvedAdvisory,
406
473
  result_digest: '',
407
474
  };
408
475
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -599,6 +666,8 @@ async function phaseDecision({ repoRoot, env, planKey, phaseId, outcome, inputRe
599
666
  const input = await readRevisionInput(repoRoot, inputRef, {
600
667
  validate: (value) => validatePhaseDecisionInput(value, outcome),
601
668
  invalidCode: 'PHASE_DECISION_INVALID', invalidReason: 'phase_decision_schema_or_digest_invalid',
669
+ // phase decision入力はrevision契約と別形状。既定のrevision explainを誤って当てない。
670
+ explain: null,
602
671
  });
603
672
  const payload = outcome === 'accept'
604
673
  ? { review_event_digest: input.review_event_digest, decision_evidence: input.decision_evidence,
@@ -609,11 +678,13 @@ async function phaseDecision({ repoRoot, env, planKey, phaseId, outcome, inputRe
609
678
  }
610
679
 
611
680
  async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload }) {
612
- const { event, snapshot } = await appendTodoEvent({
681
+ const { event, snapshot, phases } = await appendTodoEvent({
613
682
  repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), planKey,
614
683
  event: { kind, phase_id: phaseId, actor: mutationActor(env), payload },
615
684
  });
616
- const phase = snapshot.phases?.find(({ phase_id: current }) => current === phaseId);
685
+ // snapshot.phasesはv1(phase無しplan)には存在しない。導出ビュー`phases`を見る
686
+ // (これは暗黙のterminal-audit Phaseにも常に埋まっている)。
687
+ const phase = phases.find(({ phase_id: current }) => current === phaseId);
617
688
  if (phase === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'phase_not_active');
618
689
  const result = {
619
690
  schema: 'lattice.phase_mutation_result.v1', project_id: event.project_id,
@@ -629,14 +700,16 @@ async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload })
629
700
  async function phaseStatus({ repoRoot, planKey }) {
630
701
  const store = await readTodoStore({ repoRoot });
631
702
  const [member] = selectMembers(store, planKey);
632
- if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) {
633
- throw new TodoStoreError('PHASE_UNAVAILABLE', 'plan_has_no_phase_contract');
634
- }
703
+ // ADR 0147以降、phase無しplan(v1/v2/v3)もreadTodoStoreが導出済みの暗黙terminal-audit
704
+ // Phaseをmember.phasesへ積んでいる(snapshot artifactの形式は変えない・v1にはphasesキーが
705
+ // 無いのでsnapshot.phasesは直接読まない)。ここでPHASE_UNAVAILABLEへ拒否せず、その暗黙Phase
706
+ // をそのまま返す——`implicit`で機械可読に「宣言されたPhaseではない」ことを示す。
707
+ const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
635
708
  const result = {
636
709
  schema: 'lattice.phase_status_result.v1', project_id: store.project_id,
637
710
  plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
638
711
  journal_head_digest: member.journal.events.at(-1).event_digest,
639
- phases: member.snapshot.phases, result_digest: '',
712
+ implicit, phases: member.phases, result_digest: '',
640
713
  };
641
714
  result.result_digest = todoSelfDigest(result, 'result_digest');
642
715
  return result;
@@ -689,6 +762,10 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
689
762
  max_frontier_width: dispatchShape.max_frontier_width,
690
763
  serialization_ratio: dispatchShape.serialization_ratio,
691
764
  },
765
+ // ADR 0147裁定3: phase無しplanの作成は拒否せず、終端監査が要ることを結果へ明示するに
766
+ // 留める。extraction経由のmigrateは常にphase無しplan(todo_plan.v2)を作るが、将来の
767
+ // 拡張に備えisPhaselessTodoPlanSchemaで動的に判定する。
768
+ terminal_audit_required: isPhaselessTodoPlanSchema(imported.plan.schema),
692
769
  result_digest: '',
693
770
  };
694
771
  result.result_digest = todoSelfDigest(result, 'result_digest');
@@ -711,6 +788,7 @@ async function reviseSet({ repoRoot, env, inputRef }) {
711
788
  validate: validateTodoRevisionSet,
712
789
  invalidCode: 'REVISION_SET_INVALID',
713
790
  invalidReason: 'revision_set_schema_invalid',
791
+ explain: explainTodoRevisionSet,
714
792
  });
715
793
  return applyTodoRevisionSet({
716
794
  repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), revisionSet,
@@ -722,6 +800,7 @@ async function revisePhase({ repoRoot, env, planKey, inputRef }) {
722
800
  const revision = await readRevisionInput(repoRoot, inputRef, {
723
801
  validate: validatePhaseTodoRevision, invalidCode: 'REVISION_INVALID',
724
802
  invalidReason: 'phase_revision_schema_or_digest_invalid',
803
+ explain: explainPhaseTodoRevision,
725
804
  });
726
805
  if (revision.plan_key !== planKey) throw new TodoStoreError('REVISION_INVALID', 'requested_plan_mismatch');
727
806
  return applyPhaseTodoRevision({ repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
@@ -1922,6 +2001,20 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
1922
2001
  throw new TypeError('runTodoCli optionsが不正');
1923
2002
  }
1924
2003
 
2004
+ // `--schema --json`はstoreを読まない決定的な出力(`plan create --schema`と同じ規律)。
2005
+ // 通常dispatchより前に処理し、repoRoot解決やdashboard daemon起動を経由させない。
2006
+ if (argv.length === 3 && argv[1] === '--schema' && argv[2] === '--json'
2007
+ && Object.hasOwn(TODO_SCHEMA_COMMANDS, argv[0])) {
2008
+ try {
2009
+ await runTodoSchemaCommand(argv[0], stdout);
2010
+ return 0;
2011
+ } catch (error) {
2012
+ return typedFailure(stderr, {
2013
+ code: 'INTERNAL_FAILURE', message: error?.constructor?.name ?? 'Error',
2014
+ });
2015
+ }
2016
+ }
2017
+
1925
2018
  let action = null;
1926
2019
  if ((argv.length === 1 && argv[0] === 'status')
1927
2020
  || (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
@@ -375,8 +375,8 @@ function validStateMigration(value) {
375
375
  'from_task_id', 'to_task_id', 'state_policy', 'state',
376
376
  ]) && isTodoIdentifier(entry.from_task_id)
377
377
  && (entry.to_task_id === 'removed' || isTodoIdentifier(entry.to_task_id))
378
- && ['carry', 'carry_reconciled_metadata', 'reset_pending', 'removed'].includes(entry.state_policy)
379
- && ((['carry', 'carry_reconciled_metadata'].includes(entry.state_policy)
378
+ && ['carry', 'carry_reconciled_metadata', 'reset_pending', 'removed', 'acquire_phase'].includes(entry.state_policy)
379
+ && ((['carry', 'carry_reconciled_metadata', 'acquire_phase'].includes(entry.state_policy)
380
380
  && entry.to_task_id !== 'removed' && validCarriedState(entry.state))
381
381
  || (entry.state_policy === 'reset_pending' && entry.to_task_id !== 'removed' && entry.state === null)
382
382
  || (entry.state_policy === 'removed' && entry.to_task_id === 'removed' && entry.state === null)))
@@ -117,7 +117,8 @@ export function renderPhaseProgress(readModel) {
117
117
  const settledRows = [];
118
118
  for (const member of readModel.members) {
119
119
  if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) continue;
120
- const phases = new Map(member.snapshot.phases.map((phase) => [phase.phase_id, phase]));
120
+ // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)
121
+ const phases = new Map(member.phases.map((phase) => [phase.phase_id, phase]));
121
122
  for (const phase of member.plan.phases) {
122
123
  const tasks = member.plan.tasks.filter((task) => task.phase_id === phase.phase_id);
123
124
  const states = new Map(member.tasks.map((task) => [task.task_id, task.status]));
@@ -153,7 +153,9 @@ function normalizeInput(readModel, chainProjection) {
153
153
  }
154
154
  const { plan } = member;
155
155
  const statusByTask = new Map(member.tasks.map((task) => [task.task_id, task]));
156
- const statusByPhase = new Map((member.snapshot?.phases ?? [])
156
+ // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
157
+ // (readTodoStoreが常にmember.phasesとして埋める。ADR 0147)。
158
+ const statusByPhase = new Map((member.phases ?? [])
157
159
  .map((phase) => [phase.phase_id, phase.status]));
158
160
  for (const task of plan.tasks) {
159
161
  if (!plain(task) || typeof task.task_id !== 'string' || typeof task.lane !== 'string') {
@@ -174,7 +176,14 @@ function normalizeInput(readModel, chainProjection) {
174
176
  status: state.status,
175
177
  plan_schema: plan.schema ?? null,
176
178
  phase_id: task.phase_id ?? null,
177
- phase_status: task.phase_id === undefined ? null : statusByPhase.get(task.phase_id) ?? null,
179
+ // ADR 0147: phase無しplan(task.phase_idが無い世代)も、終端に暗黙のterminal-audit
180
+ // Phaseを1つ持つ(todo-store.mjsのphasesOf/TERMINAL_AUDIT_PHASE_ID)。ここで素通しせず
181
+ // nullのままにすると、gantt scope側(todo-gantt-scope.mjs)が「監査未了のplanを畳まない」
182
+ // 判定に使える材料を一切受け取れない。'terminal-audit'はTERMINAL_AUDIT_PHASE_IDと同じ
183
+ // 予約IDで、phase_readyの判定(v4だけを見る既存分岐)には影響しない。
184
+ phase_status: task.phase_id === undefined
185
+ ? statusByPhase.get('terminal-audit') ?? null
186
+ : statusByPhase.get(task.phase_id) ?? null,
178
187
  phase_ready: plan.schema !== 'lattice.todo_plan.v4'
179
188
  || statusByPhase.get(task.phase_id) === 'active',
180
189
  });
@@ -236,7 +245,8 @@ function readyTaskKeys(readModel, nodes, nodesByKey, incoming) {
236
245
  const phaseStatuses = new Map();
237
246
  const phaseAcceptIncoming = new Map(nodes.map(({ key }) => [key, new Set()]));
238
247
  for (const member of readModel.members) {
239
- for (const phase of member.snapshot?.phases ?? []) {
248
+ // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)
249
+ for (const phase of member.phases ?? []) {
240
250
  phaseStatuses.set(JSON.stringify([
241
251
  member.plan.project_id, member.plan.plan_key, phase.phase_id,
242
252
  ]), phase.status);
@@ -38,10 +38,35 @@ function compareText(left, right) {
38
38
  return left < right ? -1 : left > right ? 1 : 0;
39
39
  }
40
40
 
41
+ /**
42
+ * ADR 0147: phase無しplan(v1/v2/v3)は終端の暗黙Phase(terminal-audit)がacceptedになるまで
43
+ * 「閉じた」ことにならない。全taskがdoneでも監査未了なら、そのplanのToDoは生きた作業と同じ
44
+ * 扱い(distance 0)にして畳ませない——畳んでしまうと監査待ちであることが図から消え、
45
+ * ADR 0147が塞ごうとした「一度も重監査を通らず完走した」事故と外形が同じになる。
46
+ *
47
+ * v4/v5(phaseを宣言したplan)はこの判定の対象外にする——既存のPhase gateが重監査を担って
48
+ * おり、ここで同じ規律を足すとPhase単位の既存fold挙動を変えてしまう(非目標)。
49
+ * `phase_status`はレイアウト層(todo-gantt-layout.mjs)がplanの世代を問わず埋める。
50
+ * phase無しplanのtaskにはphase_idフィールド自体が無いため、そこでは暗黙Phaseの状態を
51
+ * 埋める。フィールドが無い/nullの入力(既存test・素のnode)は従来どおり対象外(false)になる。
52
+ *
53
+ * 対象は`gate_ready`(全task doneで監査待ち)・`reviewing`(監査中)・`rejected`
54
+ * (監査が通らず要フォロー)の3状態だけに絞る。`active`(一部taskがまだpending)は、
55
+ * 他のtaskが図に残っている限りplanが未完了だと分かるので対象にしない——ここまで
56
+ * 広げると、完走していない枝の通常foldまで止めてしまい既存挙動を変える。
57
+ */
58
+ const AUDIT_PENDING_PHASE_STATUSES = new Set(['gate_ready', 'reviewing', 'rejected']);
59
+ function auditPending(node) {
60
+ if (!AUDIT_PENDING_PHASE_STATUSES.has(node.phase_status ?? null)) return false;
61
+ const schema = node.plan_schema ?? null;
62
+ return schema !== 'lattice.todo_plan.v4' && schema !== 'lattice.todo_plan.v5';
63
+ }
64
+
41
65
  /**
42
66
  * Forward distance from each node to the nearest live (non-done) node, over the
43
67
  * dependency DAG. A live node is at distance 0; a node with no live descendant
44
- * is at Infinity.
68
+ * is at Infinity. A done node whose plan's terminal audit (ADR 0147) has not
69
+ * been accepted is also pinned at distance 0 — it must not fold away silently.
45
70
  *
46
71
  * Edges always increase the wave (`assignWaves` is a longest-path layering), so
47
72
  * visiting nodes in descending wave order guarantees every successor is settled
@@ -54,7 +79,7 @@ function distanceToLive(nodes, edges, wave) {
54
79
  const ordered = [...nodes].sort((left, right) => wave.get(right.key) - wave.get(left.key)
55
80
  || compareText(right.key, left.key));
56
81
  for (const node of ordered) {
57
- if (node.status !== 'done') {
82
+ if (node.status !== 'done' || auditPending(node)) {
58
83
  distance.set(node.key, 0);
59
84
  continue;
60
85
  }