@quolu/lattice 0.46.2 → 0.47.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.
@@ -356,7 +356,7 @@ export function seamConflictFromFinding({
356
356
  */
357
357
  export async function applySeamConflict({
358
358
  repoRoot, planKey, conflict, witnessSet, latticeBin, sharedPathFor,
359
- executors, compileIndependence, pathNames = {},
359
+ executors, compileIndependence, pathNames = {}, precedences = [],
360
360
  } = {}) {
361
361
  const {
362
362
  sourcePath, taskIds, ownedSymbolsByTask, proposedPathByTask, affectedTests,
@@ -492,10 +492,10 @@ export async function applySeamConflict({
492
492
  : { targetResolved: !afterPairs.has(targetPair), before: beforePairs.size, after: afterPairs.size },
493
493
  waves: {
494
494
  before: measureWaveCount({
495
- taskIds, conflictPairs: [...beforePairs].map((key) => key.split('\0')), executors,
495
+ taskIds, conflictPairs: [...beforePairs].map((key) => key.split('\0')), precedences, executors,
496
496
  }).waves,
497
497
  after: afterPairs === null ? null : measureWaveCount({
498
- taskIds, conflictPairs: [...afterPairs].map((key) => key.split('\0')), executors,
498
+ taskIds, conflictPairs: [...afterPairs].map((key) => key.split('\0')), precedences, executors,
499
499
  }).waves,
500
500
  },
501
501
  });
@@ -111,9 +111,10 @@ export function buildPostTransformWitnessSet({ witnessSet, candidate, affectedTe
111
111
  * 独自の近似を持たない。変換前後を同じ規則で測らないと、改善したという主張が
112
112
  * 測り方の差で出てしまう。
113
113
  */
114
- export function measureWaveCount({ taskIds, conflictPairs, executors } = {}) {
114
+ export function measureWaveCount({ taskIds, conflictPairs, precedences = [], executors } = {}) {
115
115
  const todos = sortedUnique(taskIds ?? []);
116
116
  if (todos.length === 0) return { waves: null, reason: 'no_todos' };
117
+ const todoSet = new Set(todos);
117
118
  const seen = new Set();
118
119
  const conflicts = [];
119
120
  for (const pair of conflictPairs ?? []) {
@@ -124,11 +125,24 @@ export function measureWaveCount({ taskIds, conflictPairs, executors } = {}) {
124
125
  seen.add(key);
125
126
  conflicts.push({ todo_ids: [left, right], resource_id: `pair-${conflicts.length}` });
126
127
  }
128
+ const precedenceSeen = new Set();
129
+ const scopedPrecedences = [];
130
+ for (const edge of precedences) {
131
+ const from = edge?.from_todo_id;
132
+ const to = edge?.to_todo_id;
133
+ if (!todoSet.has(from) || !todoSet.has(to) || from === to) continue;
134
+ const reason = typeof edge.reason === 'string' && edge.reason.trim() !== ''
135
+ ? edge.reason : 'plan_precedence';
136
+ const key = `${from}\0${to}\0${reason}`;
137
+ if (precedenceSeen.has(key)) continue;
138
+ precedenceSeen.add(key);
139
+ scopedPrecedences.push({ from_todo_id: from, to_todo_id: to, reason });
140
+ }
127
141
  const compiled = compileSchedulabilityGraphV2({
128
142
  schema_version: GRAPH_SCHEMA,
129
143
  todos,
130
144
  conflicts,
131
- precedences: [],
145
+ precedences: scopedPrecedences,
132
146
  unknowns: [],
133
147
  capacity: Number.isSafeInteger(executors) && executors >= 1 ? executors : 1,
134
148
  });
@@ -138,6 +152,28 @@ export function measureWaveCount({ taskIds, conflictPairs, executors } = {}) {
138
152
  return { waves: compiled.plan.minimum_feasible_waves, reason: null };
139
153
  }
140
154
 
155
+ /** todo planの順序制約をschedulability compilerのcanonical edgeへ写す。 */
156
+ export function todoPlanPrecedences(plan) {
157
+ const edges = [];
158
+ for (const edge of plan?.hard_dependencies ?? []) {
159
+ edges.push({
160
+ from_todo_id: edge.from.task_id,
161
+ to_todo_id: edge.to.task_id,
162
+ reason: 'hard_dependency',
163
+ });
164
+ }
165
+ for (const join of plan?.joins ?? []) {
166
+ for (const after of join.after) {
167
+ edges.push({
168
+ from_todo_id: after.task_id,
169
+ to_todo_id: join.before.task_id,
170
+ reason: `join:${join.id}`,
171
+ });
172
+ }
173
+ }
174
+ return edges;
175
+ }
176
+
141
177
  const CONDITIONS = Object.freeze([
142
178
  'behavior_equivalent',
143
179
  'focused_tests_passed',
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 監査待ちPhaseの定義(ADR 0147/0148)。
3
+ *
4
+ * 「監査待ち」は同じ3状態の集合として、gantt scopeのfold判定・dashboardの表示判定へ
5
+ * 別々に書かれていた。status面にも同じ判定が要るので、定義をここへ一本化する——
6
+ * 集合が三重になれば、片方だけ更新された時に「図には出るがstatusには出ない」形の
7
+ * ずれが生まれる。ADR 0147が塞ごうとした事故と外形が同じになる。
8
+ *
9
+ * このmoduleが持つのは監査待ちの**定義**だけである。各消費者の方針
10
+ * (ganttのfold対象をどのplan世代へ限るか等)はここへ持ち込まない。
11
+ */
12
+
13
+ /**
14
+ * 監査の判断がまだ着いていないPhase状態。
15
+ *
16
+ * - `gate_ready`: 所属ToDoが全てdoneで、監査待ち。
17
+ * - `reviewing`: 監査中(reviewは出たが、acceptもrejectも出ていない)。
18
+ * - `rejected`: 監査が通らず、要フォロー。
19
+ *
20
+ * `accepted`と`closed_unaudited`は含めない。どちらも判断が着いた終端状態で、
21
+ * 待っているものが無い(ADR 0148裁定4)。`active`も含めない——まだpending taskが
22
+ * 残っており、監査の地点へ到達していない。
23
+ */
24
+ export const AUDIT_PENDING_PHASE_STATUSES = new Set(['gate_ready', 'reviewing', 'rejected']);
25
+
26
+ /** そのPhase状態が監査待ちか。null/undefined/未知の値はfalse。 */
27
+ export function isAuditPendingPhaseStatus(status) {
28
+ return AUDIT_PENDING_PHASE_STATUSES.has(status ?? null);
29
+ }
30
+
31
+ /**
32
+ * その監査待ち状態から実際に打てるコマンド。
33
+ *
34
+ * `src/todo-store.mjs`の遷移guardと一致させる——gate_readyからいきなりacceptはできず
35
+ * (`phase_gate_not_ready`)、reviewing以外からのrejectもできない(`phase_reject_binding_invalid`)。
36
+ * 実行すれば必ず弾かれる遷移を「次の一歩」として案内しない。
37
+ *
38
+ * 監査待ちでない状態を渡すのは呼び出し側の誤りなので、空配列へ丸めずに投げる。
39
+ * 呼ぶ前に`isAuditPendingPhaseStatus`で絞ること。
40
+ *
41
+ * @returns {string[]} 実行可能なコマンド行(1つ以上)
42
+ */
43
+ export function auditPendingNextCommands(planKey, phaseId, status) {
44
+ const target = `--plan ${planKey} --phase ${phaseId}`;
45
+ if (status === 'gate_ready') {
46
+ return [
47
+ `lattice todo phase review ${target} --reason <text>`,
48
+ `lattice todo phase close-unaudited ${target} --reason <text>`,
49
+ ];
50
+ }
51
+ if (status === 'reviewing') {
52
+ return [
53
+ `lattice todo phase accept ${target} --input <file>`,
54
+ `lattice todo phase reject ${target} --input <file>`,
55
+ ];
56
+ }
57
+ if (status === 'rejected') {
58
+ return [`lattice todo phase reopen ${target} --reason <text>`];
59
+ }
60
+ const error = new Error(`phase status is not audit pending: ${status}`);
61
+ error.code = 'AUDIT_PENDING_STATUS_INVALID';
62
+ throw error;
63
+ }
64
+
65
+ /**
66
+ * store read model(`lattice.todo_store_read.v1`)の中の監査待ちPhaseを列挙する。
67
+ *
68
+ * `member.phases`はstoreが常に埋める導出ビューで、phase無しplanの暗黙terminal-audit Phaseも
69
+ * 同じ形で入っている(ADR 0147)。planの世代で分岐しないのはそのためである。
70
+ *
71
+ * 返すのは`plan_key`・`phase_id`・`status`だけにする。消費者ごとに要る付随情報
72
+ * (evidence slot、次コマンド、implicitかどうか)は形が違うので、ここで先回りして
73
+ * 詰め込まない。並び順はplan_key→phase_idで固定する——同じstoreからは同じ列が出る。
74
+ *
75
+ * @returns {Array<{plan_key: string, phase_id: string, status: string}>}
76
+ */
77
+ export function auditPendingPhasesOf(readModel) {
78
+ const entries = [];
79
+ for (const member of readModel?.members ?? []) {
80
+ if (!Array.isArray(member?.phases)) continue;
81
+ for (const phase of member.phases) {
82
+ if (!isAuditPendingPhaseStatus(phase?.status)) continue;
83
+ entries.push({
84
+ plan_key: member.plan.plan_key, phase_id: phase.phase_id, status: phase.status,
85
+ });
86
+ }
87
+ }
88
+ return entries.sort((left, right) => (
89
+ left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
90
+ : left.phase_id < right.phase_id ? -1 : left.phase_id > right.phase_id ? 1 : 0));
91
+ }
package/src/todo-cli.mjs CHANGED
@@ -103,6 +103,7 @@ import {
103
103
  import { compileSeamProposalArtifact, declaredConcernSymbols } from './seam-proposal.mjs';
104
104
  import { collectSensorEvidence } from './sensor-adapter.mjs';
105
105
  import { applySeamProposal } from './seam-apply.mjs';
106
+ import { todoPlanPrecedences } from './seam-verification.mjs';
106
107
  import {
107
108
  WITNESS_DRAFT_SCHEMA, buildWitnessObservationQuerySet, buildWitnessSet, serializeWitnessSet,
108
109
  validateWitnessDraft,
@@ -1871,6 +1872,7 @@ async function seamProposalApply({ repoRoot, planKey, pathNames = {}, land = fal
1871
1872
  latticeBin: fileURLToPath(new URL('../bin/lattice.mjs', import.meta.url)),
1872
1873
  sharedPathFor: (sourcePath) => sourcePath.replace(/(\.[^./]+)$/u, '.seam-shared$1'),
1873
1874
  executors: witnessSet.capacity.executors,
1875
+ precedences: todoPlanPrecedences(member.plan),
1874
1876
  compileIndependence: {
1875
1877
  baseArtifact,
1876
1878
  // 変換後のworktreeで、写した宣言と再indexした索引から実compileする。
@@ -7,6 +7,7 @@ import {
7
7
  import path from 'node:path';
8
8
 
9
9
  import packageJson from '../package.json' with { type: 'json' };
10
+ import { isAuditPendingPhaseStatus } from './todo-audit-pending.mjs';
10
11
 
11
12
  /**
12
13
  * The version of the code in THIS process. A daemon loads its modules once at
@@ -20,7 +21,6 @@ const REGISTRY_SCHEMA = 'lattice.todo_dashboard_registry.v1';
20
21
  const DAEMON_SCHEMA = 'lattice.todo_dashboard_daemon.v1';
21
22
  const DEFAULT_PORT = 0;
22
23
  export const TODO_DASHBOARD_STALE_MS = 2 * 60 * 60 * 1_000;
23
- const TODO_DASHBOARD_ATTENTION_PHASE_STATUS = new Set(['gate_ready', 'reviewing', 'rejected']);
24
24
  const LOCK_ATTEMPTS = 240;
25
25
  const LOCK_WAIT_MS = 25;
26
26
  const LOCK_STALE_MS = 30_000;
@@ -59,7 +59,7 @@ function validEntry(entry) {
59
59
  /** active taskが無くても、監査の判断待ち・棄却後なら公開工程から消してはいけない。 */
60
60
  export function todoDashboardMemberNeedsVisibility(member) {
61
61
  return Array.isArray(member?.phases)
62
- && member.phases.some(({ status }) => TODO_DASHBOARD_ATTENTION_PHASE_STATUS.has(status));
62
+ && member.phases.some(({ status }) => isAuditPendingPhaseStatus(status));
63
63
  }
64
64
 
65
65
  function validateRegistry(value) {
@@ -20,11 +20,15 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
20
20
  .gantt-pane{display:grid;grid-template-rows:auto auto minmax(0,1fr);min-width:0;min-height:0;overflow:hidden;background:var(--surface-1)}
21
21
  .pane-divider{width:8px;cursor:col-resize;background:rgba(217,216,212,.5);touch-action:none}
22
22
  .diagram-toolbar{z-index:3;display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--surface-2);color:var(--text-secondary)}
23
- .diagram-toolbar button{min-height:32px;padding:0 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface-2);color:var(--text-primary);font:500 12px/1.6 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}
23
+ /* 監査待ちの札が入って以降、ツールバーは幅の奪い合いになる。操作系は縮ませない——
24
+ 縮むと「等倍」「全体表示」が2行に折れて、押せるが読みにくい形になる。削るのは札の側で、
25
+ そちらはellipsisと件数の下限を持っている。 */
26
+ .diagram-toolbar button{flex:0 0 auto;white-space:nowrap;min-height:32px;padding:0 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface-2);color:var(--text-primary);font:500 12px/1.6 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}
24
27
  .diagram-toolbar button:focus-visible{outline:2px solid var(--text-primary);outline-offset:2px}
25
28
  .zoom-readout{min-width:48px;text-align:center;font-size:12px;font-weight:500;font-variant-numeric:tabular-nums}
26
29
  .diagram-note{margin-left:auto;color:var(--text-secondary);font-size:12px;font-weight:500}
27
- .project-heading{margin-right:8px;color:var(--text-primary);font-size:13px;font-weight:650;white-space:nowrap}.status-symbol.status-in-progress{color:var(--accent)}.status-symbol.status-done{color:var(--good)}.status-symbol.status-blocked{color:var(--critical)}
30
+ .project-heading{margin-right:8px;color:var(--text-primary);font-size:13px;font-weight:650;white-space:nowrap}
31
+ .audit-pending-chip{flex:0 1 auto;min-width:9em;max-width:30em;overflow:hidden;padding:2px 8px;border:1px solid var(--critical);border-radius:9999px;background:var(--surface-1);color:var(--critical);font-size:12px;font-weight:650;white-space:nowrap;text-overflow:ellipsis}.status-symbol.status-in-progress{color:var(--accent)}.status-symbol.status-done{color:var(--good)}.status-symbol.status-blocked{color:var(--critical)}
28
32
  .diagram-legend{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--surface-1);color:var(--text-secondary);font-size:12px;font-weight:500}
29
33
  .diagram-legend>span{white-space:nowrap}.diagram-legend>p{flex:1 0 100%;margin:0;font-weight:400}
30
34
  .category-legend{margin-left:auto}.category-legend summary{cursor:pointer;color:var(--text-primary)}
@@ -1,12 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
+ import { auditPendingPhasesOf } from './todo-audit-pending.mjs';
3
4
  import { serializeJsonForScript } from './todo-markdown-renderer.mjs';
4
5
  import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
5
6
  import { renderDiagramLegend, renderRightPane } from './todo-gantt-html-independence.mjs';
6
7
  import { escapeHtmlAttribute, escapeHtmlText, refKey } from './todo-gantt-html-shared.mjs';
7
8
  import { CSS } from './todo-gantt-html-style.mjs';
8
9
 
9
- export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v18';
10
+ export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v19';
10
11
  export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
11
12
  export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
12
13
 
@@ -17,6 +18,33 @@ function projectDisplayName(readModel, metadata) {
17
18
  return readModel.project_id;
18
19
  }
19
20
 
21
+ /**
22
+ * 監査待ちPhaseをヘッダへ出す(ADR 0147/0148)。
23
+ *
24
+ * CLIのstdoutは1行のversioned JSONというADR 0049の公理があり、人が読むテキスト面が無い。
25
+ * 人が実際に見るのはこの図とdashboard(同じHTMLを配信する)なので、監査待ちはここへ出す。
26
+ * 全taskがdoneでも監査が済んでいなければ工程は閉じていない——図が「全部緑」に見えるのに
27
+ * ヘッダが無言なら、読み手は完了したと読む。
28
+ *
29
+ * 判断の着いたPhase(accepted/closed_unaudited)しか無ければ、この札は出ない。
30
+ *
31
+ * **出す文字列を有界にする。** ツールバーはpane幅で制約されていないので、長い札は縮まずに
32
+ * 行を押し広げ、ズーム操作を画面外へ追い出す(2026-08-08に実測)。CSSの`text-overflow`は
33
+ * ここでは発動しないため、頼れない。全plan名を並べる代わりに先頭1件+残件数だけを出し、
34
+ * 全文は`title`に残す。件数(`監査待ち N件`)は常に見える——省略するのは内訳であって、
35
+ * 監査待ちの存在そのものではない。
36
+ */
37
+ function renderAuditPendingChip(readModel) {
38
+ const pending = auditPendingPhasesOf(readModel);
39
+ if (pending.length === 0) return '';
40
+ const label = ({ plan_key: planKey, phase_id: phaseId, status }) => `${planKey}/${phaseId} (${status})`;
41
+ // 残件数は書かない——先頭の`N件`が既に全体を数えており、「ほかM件」はその引き算でしかない。
42
+ // 幅は有限なので、同じことを二度言う分だけplan名が削れる。
43
+ const text = `監査待ち ${pending.length}件: ${label(pending[0])}`;
44
+ const full = `監査待ち ${pending.length}件: ${pending.map(label).join(' · ')}`;
45
+ return `<span class="audit-pending-chip" title="${escapeHtmlAttribute(full)}">${escapeHtmlText(text)}</span>`;
46
+ }
47
+
20
48
  export class TodoGanttRenderError extends Error {
21
49
  constructor(code, message, detail = {}) {
22
50
  super(message);
@@ -196,7 +224,7 @@ export function renderTodoGanttHtml({
196
224
  metadata,
197
225
  presentation,
198
226
  });
199
- const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong><button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
227
+ const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong>${renderAuditPendingChip(readModel)}<button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
200
228
  const htmlBytes = Buffer.byteLength(html, 'utf8');
201
229
  if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
202
230
  throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
@@ -26,6 +26,8 @@
26
26
  * the real plan, and measuring them on a narrowed graph would make them lie.
27
27
  */
28
28
 
29
+ import { isAuditPendingPhaseStatus } from './todo-audit-pending.mjs';
30
+
29
31
  export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
30
32
 
31
33
  /**
@@ -50,14 +52,16 @@ function compareText(left, right) {
50
52
  * phase無しplanのtaskにはphase_idフィールド自体が無いため、そこでは暗黙Phaseの状態を
51
53
  * 埋める。フィールドが無い/nullの入力(既存test・素のnode)は従来どおり対象外(false)になる。
52
54
  *
53
- * 対象は`gate_ready`(全task doneで監査待ち)・`reviewing`(監査中)・`rejected`
54
- * (監査が通らず要フォロー)の3状態だけに絞る。`active`(一部taskがまだpending)は、
55
- * 他のtaskが図に残っている限りplanが未完了だと分かるので対象にしない——ここまで
55
+ * 監査待ちの状態集合そのものは`todo-audit-pending.mjs`が持つ(gate_readyreviewing・rejected)
56
+ * `active`(一部taskがまだpending)が入っていないことにここも依存している——ここまで
56
57
  * 広げると、完走していない枝の通常foldまで止めてしまい既存挙動を変える。
58
+ *
59
+ * 下のv4/v5除外は共有moduleへ移さない。あれは「監査待ちとは何か」の定義ではなく、
60
+ * ganttがどこまでfoldを止めるかという本moduleの方針である。移すと、宣言Phase planの
61
+ * fold挙動を他の消費者の都合で変えてしまう。
57
62
  */
58
- const AUDIT_PENDING_PHASE_STATUSES = new Set(['gate_ready', 'reviewing', 'rejected']);
59
63
  function auditPending(node) {
60
- if (!AUDIT_PENDING_PHASE_STATUSES.has(node.phase_status ?? null)) return false;
64
+ if (!isAuditPendingPhaseStatus(node.phase_status)) return false;
61
65
  const schema = node.plan_schema ?? null;
62
66
  return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(schema);
63
67
  }
@@ -8,18 +8,20 @@ import {
8
8
  } from './todo-contracts.mjs';
9
9
  import {
10
10
  RUN_REQUEST_CLAIM_MODE,
11
+ RUN_REQUEST_DECLARATIVE_SCHEMA,
11
12
  RUN_REQUEST_SCHEMA,
12
13
  explainRunRequest,
13
14
  selfDigest as runtimeSelfDigest,
14
15
  } from './runtime-contracts.mjs';
15
16
  import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
16
17
 
17
- export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v3';
18
+ export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v4';
18
19
  /**
19
- * まだ受理する旧witness set契約。v1はconcern anchorを、v2は創作宣言を持てないだけで、
20
- * 境界宣言としてはv3と同値である。既存宣言を書き換えさせないために読み口を残す。
20
+ * まだ受理する旧witness set契約。v3以前の厳密なcompile意味を変えず、
21
+ * 既存宣言を書き換えさせないために読み口を残す。
21
22
  */
22
23
  export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze([
24
+ 'lattice.todo_witness_set.v3',
23
25
  'lattice.todo_witness_set.v2',
24
26
  'lattice.todo_witness_set.v1',
25
27
  ]);
@@ -30,9 +32,10 @@ export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
30
32
  /** 宣言できる欄はversionごとに違う。どの版から使えるかを1箇所で持つ。 */
31
33
  const CONCERN_ANCHOR_SCHEMAS = Object.freeze([
32
34
  TODO_WITNESS_SET_SCHEMA,
35
+ 'lattice.todo_witness_set.v3',
33
36
  'lattice.todo_witness_set.v2',
34
37
  ]);
35
- const CREATES_SCHEMAS = Object.freeze([TODO_WITNESS_SET_SCHEMA]);
38
+ const CREATES_SCHEMAS = Object.freeze([TODO_WITNESS_SET_SCHEMA, 'lattice.todo_witness_set.v3']);
36
39
 
37
40
  /** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
38
41
  export const TODO_CONCERN_ANCHOR_LIMIT = 256;
@@ -127,7 +130,8 @@ function boundedText(value, maximumBytes = 4_096) {
127
130
  export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
128
131
  const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
129
132
  const request = {
130
- schema: RUN_REQUEST_SCHEMA,
133
+ schema: witnessSet.schema === TODO_WITNESS_SET_SCHEMA
134
+ ? RUN_REQUEST_SCHEMA : RUN_REQUEST_DECLARATIVE_SCHEMA,
131
135
  request_id: requestId,
132
136
  repo: { base_sha: baseSha, root_kind: 'git' },
133
137
  capacity: witnessSet.capacity,
@@ -468,7 +472,7 @@ function unknownTaskEntry(value) {
468
472
  *
469
473
  * ready frontierを「検証済み並列グループ」「直列化すべき組」「未検査」へ分けた読み出し面。
470
474
  * v2は進行中ToDoとの競合(`conflicts_with_active`)と鮮度の内訳(`drift`)を加える。
471
- * `todo_status_result.v4`と`dispatch_frontier`は変更せず、加算の別面として持つ(ADR 0124の規律)。
475
+ * `todo_status_result`と`dispatch_frontier`は変更せず、加算の別面として持つ(ADR 0124の規律)。
472
476
  */
473
477
  export function validateTodoIndependenceProjection(value) {
474
478
  try {
@@ -241,36 +241,12 @@ export const TODO_INDEPENDENCE_WORKFLOW = Object.freeze([
241
241
  * のは、ADR 0130が禁じたものそのものである。
242
242
  */
243
243
  const SCAFFOLD_CATALOG = Object.freeze({
244
- path_absent_declare_creates: {
245
- message: '宣言したpathは不在と観測できている。そのToDoが作るなら、owns entryを{ path, creates: true }(下書きv2)にして再実行する。作らないなら、宣言しているpathが正しいか確かめる。',
246
- next_action: 'declare_creates_then_retry',
247
- },
248
- creates_path_present: {
249
- message: '創作を宣言したpathは既に存在する。作るのでなく変更するなら、creates宣言を外してpath名だけにする。',
250
- next_action: 'drop_creates_then_retry',
251
- },
252
- creates_unverified: {
253
- message: '創作宣言の裏付けが取れない。sensorの観測が「対象1件・依存test無し」の形になっていない。sensor syncで索引を取り直してから再実行する。',
254
- next_action: 'sync_sensor_then_retry',
255
- },
256
- affected_tests_unobserved: {
257
- message: '所有pathのaffected観測が取れていない。sensor syncで索引へ収載してから再実行する。',
258
- next_action: 'sync_sensor_then_retry',
259
- },
260
- multiple_owned_paths_unsupported: {
261
- message: 'affected_testsは宣言とfresh観測をbinding単位でexact比較するため、1 ToDoが複数pathを所有する宣言は今の契約で表現できない。ToDoを分けるか、所有を1 pathに絞る。',
262
- next_action: 'split_todo_or_narrow_owns',
263
- },
264
244
  anchor_outside_owned: {
265
245
  message: 'concern_anchorのwithinが、自分の所有pathを指していない。所有していない資源の内側に担当を主張できない。',
266
246
  next_action: 'align_anchor_with_owns',
267
247
  },
268
- owns_empty: {
269
- message: 'ownsが空のToDoがある。何を所有するかは判定の起点なので、道具が補わない。',
270
- next_action: 'declare_owns_then_retry',
271
- },
272
248
  draft_invalid: {
273
- message: '下書きがlattice.todo_witness_draft契約を満たしていない。creates宣言を使うならschemaをv2にする。',
249
+ message: '下書きがlattice.todo_witness_draft契約を満たしていない。',
274
250
  next_action: 'fix_draft_schema_then_retry',
275
251
  },
276
252
  });
@@ -284,9 +260,7 @@ const SCAFFOLD_CATALOG = Object.freeze({
284
260
  export function selectWitnessScaffoldGuidance(reasons = []) {
285
261
  const codes = reasons.map((reason) => String(reason).split(':')[0]);
286
262
  const order = [
287
- 'draft_invalid', 'owns_empty', 'multiple_owned_paths_unsupported', 'anchor_outside_owned',
288
- 'path_absent_declare_creates', 'creates_path_present', 'creates_unverified',
289
- 'affected_tests_unobserved',
263
+ 'draft_invalid', 'anchor_outside_owned',
290
264
  ];
291
265
  const code = order.find((candidate) => codes.includes(candidate));
292
266
  if (code === undefined) {
@@ -1,3 +1,8 @@
1
+ import {
2
+ AUDIT_PENDING_PHASE_STATUSES,
3
+ auditPendingNextCommands,
4
+ isAuditPendingPhaseStatus,
5
+ } from './todo-audit-pending.mjs';
1
6
  import {
2
7
  exactRecord,
3
8
  isNonNegativeSafeInteger,
@@ -6,8 +11,9 @@ import {
6
11
  todoSelfDigest,
7
12
  } from './todo-contracts.mjs';
8
13
  import { todoLegacyReconciliationDigest } from './todo-revision.mjs';
14
+ import { isPhaselessTodoPlanSchema, todoPhaseDefinitions } from './todo-store.mjs';
9
15
 
10
- export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v4';
16
+ export const TODO_STATUS_SCHEMA = 'lattice.todo_status_result.v5';
11
17
  export const TODO_DISPATCH_FRONTIER_SCHEMA = 'lattice.todo_dispatch_frontier.v1';
12
18
  export const TODO_STATUS_LIST_LIMIT = 2_000;
13
19
  export const TODO_STATUS_LABEL_LIMIT = 160;
@@ -97,6 +103,25 @@ function blockedEntry(value) {
97
103
  && isTodoStatusBoundedText(value.reason, TODO_STATUS_REASON_LIMIT);
98
104
  }
99
105
 
106
+ /**
107
+ * 監査待ちPhase 1件。
108
+ *
109
+ * task entryと紛れないよう`status`ではなく`phase_status`にする。状態集合は
110
+ * `todo-audit-pending.mjs`の定義をそのまま使う(ここで書き直さない)。
111
+ * `next_commands`を非空必須にしたのは、監査待ちなのに次の一手が空なら次アクション面として
112
+ * 無意味だからである(状態集合が閉じている以上、空になる枝は無い)。
113
+ */
114
+ function auditPendingEntry(value) {
115
+ return exactRecord(value, [
116
+ 'plan_key', 'phase_id', 'phase_status', 'implicit', 'required_evidence_slots', 'next_commands',
117
+ ]) && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.phase_id)
118
+ && AUDIT_PENDING_PHASE_STATUSES.has(value.phase_status)
119
+ && typeof value.implicit === 'boolean'
120
+ && boundedList(value.required_evidence_slots, isTodoIdentifier)
121
+ && Array.isArray(value.next_commands) && value.next_commands.length > 0
122
+ && boundedList(value.next_commands, (command) => isTodoStatusBoundedText(command, TODO_STATUS_REASON_LIMIT));
123
+ }
124
+
100
125
  function memberHead(value) {
101
126
  return exactRecord(value, [
102
127
  'plan_key', 'plan_version', 'through_sequence', 'journal_head_digest',
@@ -150,16 +175,17 @@ function dispatchFrontierEntry(value, projectId, nextReady) {
150
175
  && value.frontier_digest === expected.frontier_digest;
151
176
  }
152
177
 
153
- /** todo status v4 wire shapeを検証し、digestも再計算する。 */
178
+ /** todo status v5 wire shapeを検証し、digestも再計算する。 */
154
179
  export function validateTodoStatusResult(value) {
155
180
  try {
156
181
  return exactRecord(value, [
157
182
  'schema', 'project_id', 'active_set', 'next_ready', 'dispatch_frontier',
158
- 'blocked', 'member_heads', 'result_digest',
183
+ 'blocked', 'audit_pending', 'member_heads', 'result_digest',
159
184
  ]) && value.schema === TODO_STATUS_SCHEMA && isTodoIdentifier(value.project_id)
160
185
  && boundedList(value.active_set, activeTaskEntry) && boundedList(value.next_ready, taskEntry)
161
186
  && dispatchFrontierEntry(value.dispatch_frontier, value.project_id, value.next_ready)
162
- && boundedList(value.blocked, blockedEntry) && boundedList(value.member_heads, memberHead)
187
+ && boundedList(value.blocked, blockedEntry) && boundedList(value.audit_pending, auditPendingEntry)
188
+ && boundedList(value.member_heads, memberHead)
163
189
  && isTodoDigest(value.result_digest)
164
190
  && value.result_digest === todoSelfDigest(value, 'result_digest');
165
191
  } catch {
@@ -167,6 +193,46 @@ export function validateTodoStatusResult(value) {
167
193
  }
168
194
  }
169
195
 
196
+ /**
197
+ * memberの監査待ちPhaseを`audit_pending` entryへ起こす。
198
+ *
199
+ * 「監査待ちか」の判定は`todo-audit-pending.mjs`、Phase定義(slots)は`todo-store.mjs`の
200
+ * `todoPhaseDefinitions`が正本である。`plan.phases`を直接読むとphase無しplan(v1/v2/v3)と
201
+ * synthetic read modelで落ちるので読まない。
202
+ *
203
+ * dispatchへは一切流さない——ここで作るのは`member.phases`だけを見る別の列であり、
204
+ * nodes/incomingへは入れない(ADR 0062・ADR 0147裁定5)。
205
+ */
206
+ function collectAuditPending(member, sink) {
207
+ const declared = todoPhaseDefinitions(member.plan);
208
+ // Phase plan(v4/v5/v7)なのに`plan.phases`が配列でない場合はここでtypedに落とす
209
+ // (素のTypeErrorで抜けさせない)。phase無しplanは常に暗黙Phase 1件が返るので該当しない。
210
+ if (!Array.isArray(declared)) {
211
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_plan_phases_invalid', { plan_key: member.plan.plan_key });
212
+ }
213
+ const definitions = new Map(declared.map((entry) => [entry.phase_id, entry]));
214
+ const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
215
+ for (const phase of member.phases ?? []) {
216
+ if (!isAuditPendingPhaseStatus(phase?.status)) continue;
217
+ const definition = definitions.get(phase.phase_id);
218
+ // 導出ビューに在ってplanに定義が無いPhaseは、状態とplanがずれている証拠である。
219
+ // slotsを空へ丸めるとgateが何を要求するか嘘を吐くので、fail closedにする。
220
+ if (definition === undefined) {
221
+ fail('TODO_STATUS_INVALID_INPUT', 'todo_status_phase_definition_missing', {
222
+ plan_key: member.plan.plan_key, phase_id: phase.phase_id,
223
+ });
224
+ }
225
+ sink.push({
226
+ plan_key: member.plan.plan_key,
227
+ phase_id: phase.phase_id,
228
+ phase_status: phase.status,
229
+ implicit,
230
+ required_evidence_slots: [...definition.required_evidence_slots],
231
+ next_commands: auditPendingNextCommands(member.plan.plan_key, phase.phase_id, phase.status),
232
+ });
233
+ }
234
+ }
235
+
170
236
  /**
171
237
  * read modelからtask node・依存辺・phase gateを組み立てる。
172
238
  *
@@ -182,6 +248,7 @@ function buildTodoGraph(readModel) {
182
248
  const nodes = new Map();
183
249
  const incoming = new Map();
184
250
  const memberHeads = [];
251
+ const auditPending = [];
185
252
  // snapshot artifactの形式(v1にはphasesキーが無い)には縛られない導出ビューを読む
186
253
  // (readTodoStoreが常にmember.phasesとして埋める。ADR 0147)。
187
254
  const phaseStatuses = new Map(readModel.members.flatMap((member) => (
@@ -228,6 +295,7 @@ function buildTodoGraph(readModel) {
228
295
  const states = new Map(member.tasks.map((state) => [state.task_id, state]));
229
296
  // snapshot artifactの形式には縛られない導出ビュー(member.phases)を読む(ADR 0147)。
230
297
  const phases = new Map((member.phases ?? []).map((state) => [state.phase_id, state]));
298
+ collectAuditPending(member, auditPending);
231
299
  for (const task of member.plan.tasks) {
232
300
  const state = states.get(task.task_id);
233
301
  if (!plain(state) || !['pending', 'in-progress', 'blocked', 'done'].includes(state.status)) {
@@ -283,7 +351,11 @@ function buildTodoGraph(readModel) {
283
351
  }
284
352
  }
285
353
 
286
- return { nodes, incoming, phaseAcceptIncoming, phaseStatuses, memberHeads };
354
+ auditPending.sort((left, right) => (
355
+ left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1
356
+ : left.phase_id < right.phase_id ? -1 : left.phase_id > right.phase_id ? 1 : 0));
357
+
358
+ return { nodes, incoming, phaseAcceptIncoming, phaseStatuses, memberHeads, auditPending };
287
359
  }
288
360
 
289
361
  /** 先行完了とphase gateを満たしたpending taskだけがreadyになる。 */
@@ -320,7 +392,7 @@ export function computeReadyFrontier(readModel) {
320
392
  /** Canonical todo read modelからSessionStart向け現在地をread-only投影する。 */
321
393
  export function projectTodoStatus(readModel) {
322
394
  const graph = buildTodoGraph(readModel);
323
- const { nodes, incoming, memberHeads } = graph;
395
+ const { nodes, incoming, memberHeads, auditPending } = graph;
324
396
 
325
397
  const activeSet = [];
326
398
  const nextReady = [];
@@ -352,7 +424,8 @@ export function projectTodoStatus(readModel) {
352
424
  blocked.sort(compareTaskEntries);
353
425
  memberHeads.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
354
426
  for (const [name, value] of [
355
- ['active_set', activeSet], ['next_ready', nextReady], ['blocked', blocked], ['member_heads', memberHeads],
427
+ ['active_set', activeSet], ['next_ready', nextReady], ['blocked', blocked],
428
+ ['audit_pending', auditPending], ['member_heads', memberHeads],
356
429
  ]) enforceListLimit(name, value);
357
430
 
358
431
  const result = {
@@ -362,6 +435,9 @@ export function projectTodoStatus(readModel) {
362
435
  next_ready: nextReady,
363
436
  dispatch_frontier: dispatchFrontier(readModel.project_id, nextReady),
364
437
  blocked,
438
+ // 監査待ちは`member.phases`だけから作った別の列で、dispatch(next_ready/dispatch_frontier)へは
439
+ // 影響しない。監査が進んでもfrontier_digestは動かない。
440
+ audit_pending: auditPending,
365
441
  member_heads: memberHeads,
366
442
  result_digest: '',
367
443
  };
@@ -386,8 +462,8 @@ export const TODO_BINDING_PROJECTION_SCHEMA = 'lattice.todo_binding_projection.v
386
462
  * `compiled_plan_digest`で`runtime_plan.v1`を、`base_sha`でrun requestのbaseを照合し、
387
463
  * plan→`executor_packet.v1`→`executor_receipt.v1`(`packet_digest`帰属)まで辿れる。
388
464
  *
389
- * `todo_status_result.v4`は変更しない。binding投影は加算の別面とし、v4を受理する
390
- * 既存hostを壊さない。
465
+ * binding投影は`todo_status_result`とは独立した加算の別面である(ADR 0124)。status側の
466
+ * version bump(v4→v5)はこの面の形を動かさない。
391
467
  */
392
468
  export function projectTodoBindings(readModel, { requestedPlanKey = null } = {}) {
393
469
  if (!plain(readModel) || readModel.schema !== 'lattice.todo_store_read.v1'
@@ -278,6 +278,15 @@ function phasesOf(plan) {
278
278
  return isPhaselessTodoPlanSchema(plan.schema) ? [terminalAuditPhase()] : plan.phases;
279
279
  }
280
280
 
281
+ // 終端監査Phaseの定義(required_evidence_slotsを含む)はterminalAuditPhase()の中だけに在り、
282
+ // store外からは読めなかった。todo-status側で再導出すると「終端監査が何を要求するか」の定義が
283
+ // 二重化するので、phasesOfをそのまま公開して定義の正本を1つに保つ。
284
+ // 返るのは呼び手が渡したplan自身のphases(またはその場で作る暗黙Phase)であり、新たな内部状態は
285
+ // 露出しない。
286
+ export function todoPhaseDefinitions(plan) {
287
+ return phasesOf(plan);
288
+ }
289
+
281
290
  function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
282
291
  const state = phaseStates.get(phaseId);
283
292
  // ADR 0148: closed_unauditedも他の終端状態と同じく確定済みとして扱う。ここへ足さないと、