@quolu/lattice 0.46.2 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lattice-dashboard.mjs +2 -2
- package/package.json +1 -1
- package/src/artifact-contracts.mjs +0 -3
- package/src/cli-help.mjs +2 -2
- package/src/project-cli.mjs +19 -3
- package/src/rc3-scripted-campaign.mjs +15 -16
- package/src/runtime-cli.mjs +230 -146
- package/src/runtime-contracts.mjs +9 -5
- package/src/runtime-decision-verifier.mjs +26 -5
- package/src/runtime-engine.mjs +48 -5
- package/src/runtime-front-end.mjs +34 -15
- package/src/runtime-hold-recompile.mjs +11 -5
- package/src/runtime-managed-supervisor.mjs +54 -15
- package/src/runtime-scripted-adapter-controller.mjs +43 -19
- package/src/runtime-seam-resolve.mjs +4 -2
- package/src/seam-apply.mjs +3 -3
- package/src/seam-verification.mjs +38 -2
- package/src/todo-audit-pending.mjs +91 -0
- package/src/todo-cli.mjs +111 -18
- package/src/todo-contracts.mjs +84 -14
- package/src/todo-dashboard-registry.mjs +2 -2
- package/src/todo-gantt-html-style.mjs +6 -2
- package/src/todo-gantt-html.mjs +30 -2
- package/src/todo-gantt-scope.mjs +9 -5
- package/src/todo-independence-contracts.mjs +10 -6
- package/src/todo-independence-guidance.mjs +26 -29
- package/src/todo-note-store.mjs +148 -21
- package/src/todo-parallel-candidates.mjs +114 -0
- package/src/todo-status.mjs +254 -11
- package/src/todo-store.mjs +148 -2
- package/src/witness-scaffold.mjs +13 -32
|
@@ -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
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { parseTree } from 'jsonc-parser';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
TODO_COORDINATION_MODES,
|
|
11
12
|
TODO_DESIGN_MEMO_PROMPT,
|
|
12
13
|
canonicalizeTodoArtifact,
|
|
13
14
|
digestTodoArtifact,
|
|
@@ -72,6 +73,7 @@ import {
|
|
|
72
73
|
import {
|
|
73
74
|
computeReadyFrontier,
|
|
74
75
|
projectTodoBindings,
|
|
76
|
+
TODO_STATUS_DISPATCH_ONLY,
|
|
75
77
|
projectTodoStatus,
|
|
76
78
|
} from './todo-status.mjs';
|
|
77
79
|
import {
|
|
@@ -103,6 +105,7 @@ import {
|
|
|
103
105
|
import { compileSeamProposalArtifact, declaredConcernSymbols } from './seam-proposal.mjs';
|
|
104
106
|
import { collectSensorEvidence } from './sensor-adapter.mjs';
|
|
105
107
|
import { applySeamProposal } from './seam-apply.mjs';
|
|
108
|
+
import { todoPlanPrecedences } from './seam-verification.mjs';
|
|
106
109
|
import {
|
|
107
110
|
WITNESS_DRAFT_SCHEMA, buildWitnessObservationQuerySet, buildWitnessSet, serializeWitnessSet,
|
|
108
111
|
validateWitnessDraft,
|
|
@@ -117,7 +120,9 @@ import {
|
|
|
117
120
|
readTodoNoteContext,
|
|
118
121
|
readTodoNoteContextsForPlan,
|
|
119
122
|
readTodoNoteEvents,
|
|
123
|
+
readTodoPlanNotesForStatus,
|
|
120
124
|
} from './todo-note-store.mjs';
|
|
125
|
+
import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
|
|
121
126
|
|
|
122
127
|
const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
|
|
123
128
|
const DEFAULT_GANTT_SCOPE = 'live';
|
|
@@ -593,6 +598,9 @@ function designMemoProjection(task) {
|
|
|
593
598
|
*/
|
|
594
599
|
async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
|
|
595
600
|
const artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
601
|
+
// 調整方式の宣言(ob03)。未宣言はnullで、「まだ選んでいない」を意味する。
|
|
602
|
+
const coordinationMode = store.members
|
|
603
|
+
.find(({ descriptor }) => descriptor.plan_key === planKey)?.coordination?.mode ?? null;
|
|
596
604
|
if (artifact === null) {
|
|
597
605
|
// 記録が無ければ鮮度を語る相手がいない。HEADを要求すると、commitがまだ無いrepoで
|
|
598
606
|
// 「判定できない」でなく「startできない」になってしまう。
|
|
@@ -604,7 +612,7 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
|
|
|
604
612
|
.filter((task) => task.plan_key === planKey).map(({ task_id: id }) => id),
|
|
605
613
|
self_unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
|
|
606
614
|
guidance: selectIndependenceGuidance({
|
|
607
|
-
coverage: 'missing', taskDeclared: false, taskStale: false,
|
|
615
|
+
coverage: 'missing', taskDeclared: false, taskStale: false, coordinationMode,
|
|
608
616
|
}),
|
|
609
617
|
};
|
|
610
618
|
}
|
|
@@ -653,6 +661,7 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
|
|
|
653
661
|
conflictWithActive: conflictsWithActive[0]?.severability ?? null,
|
|
654
662
|
conflictBetweenReady: readyConflict?.severability ?? null,
|
|
655
663
|
verdictsAbsent: selfUnknowns.some(({ kind }) => kind === 'plan_verdicts_absent'),
|
|
664
|
+
coordinationMode,
|
|
656
665
|
}),
|
|
657
666
|
};
|
|
658
667
|
}
|
|
@@ -684,7 +693,8 @@ async function startTask({
|
|
|
684
693
|
repoRoot, env, planKey, taskId, overrideReason, parallelFrontier, serialConfirmed = false,
|
|
685
694
|
}) {
|
|
686
695
|
const store = await readTodoStore({ repoRoot });
|
|
687
|
-
|
|
696
|
+
// startはready判定にしかprojectionを使わず、resultを出力しない。
|
|
697
|
+
const projection = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY);
|
|
688
698
|
const readyTask = projection.next_ready.find((task) => (
|
|
689
699
|
task.plan_key === planKey && task.task_id.toLowerCase() === taskId.toLowerCase()
|
|
690
700
|
));
|
|
@@ -826,6 +836,33 @@ async function phaseMutation({ repoRoot, env, planKey, phaseId, kind, payload })
|
|
|
826
836
|
return result;
|
|
827
837
|
}
|
|
828
838
|
|
|
839
|
+
/**
|
|
840
|
+
* 調整方式を宣言する(ob03・オーナー裁定C①)。
|
|
841
|
+
*
|
|
842
|
+
* witnessが全planの暗黙義務だった時、「誰がやるか」が誰にも属さず、正確な案内が素通りされた。
|
|
843
|
+
* 起票後にこのコマンドで明示選択させ、eventのactorへ帰属を残す。宣言はdispatchを変えない
|
|
844
|
+
* ——未宣言でもready frontierは通常どおり出る(ADR 0160・ob04のProtected behavior)。
|
|
845
|
+
*/
|
|
846
|
+
async function independenceMode({ repoRoot, env, planKey, mode, reason }) {
|
|
847
|
+
const { event } = await appendTodoEvent({
|
|
848
|
+
repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }), planKey,
|
|
849
|
+
event: { kind: 'coordination_mode', actor: mutationActor(env), payload: { mode, reason } },
|
|
850
|
+
});
|
|
851
|
+
const result = {
|
|
852
|
+
schema: 'lattice.todo_coordination_mode_result.v1', project_id: event.project_id,
|
|
853
|
+
plan_key: event.plan_key, plan_version: event.plan_version,
|
|
854
|
+
mode: event.payload.mode, reason: event.payload.reason,
|
|
855
|
+
declared_by: event.actor, declared_at: event.recorded_at,
|
|
856
|
+
// 宣言はplan-scoped chainのheadを進める。lifecycle journalのheadは動かない——
|
|
857
|
+
// 「作業が進んだ」の意味をここへ混ぜないため、journal_head_digestは返さない。
|
|
858
|
+
sequence: event.sequence, event_digest: event.event_digest,
|
|
859
|
+
plan_scoped_head_digest: event.event_digest,
|
|
860
|
+
result_digest: '',
|
|
861
|
+
};
|
|
862
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
863
|
+
return result;
|
|
864
|
+
}
|
|
865
|
+
|
|
829
866
|
async function phaseStatus({ repoRoot, planKey }) {
|
|
830
867
|
const store = await readTodoStore({ repoRoot });
|
|
831
868
|
const [member] = selectMembers(store, planKey);
|
|
@@ -972,7 +1009,8 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
|
|
|
972
1009
|
|
|
973
1010
|
const imported = await appendTodoExtraction({ repoRoot, extraction });
|
|
974
1011
|
const result = {
|
|
975
|
-
|
|
1012
|
+
// ob03: 調整方式の案内をv3で足す。ADR 0054のとおり既存versionへのin-place追加はしない。
|
|
1013
|
+
schema: 'lattice.todo_migrate_result.v3',
|
|
976
1014
|
project_id: imported.plan.project_id,
|
|
977
1015
|
plan_key: imported.plan.plan_key,
|
|
978
1016
|
plan_version: imported.plan.plan_version,
|
|
@@ -1001,6 +1039,13 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
|
|
|
1001
1039
|
required_state_policy: 'acquire_phase',
|
|
1002
1040
|
next_action: `lattice todo revise-phase --plan ${imported.plan.plan_key} --input <phase-revision.json>`,
|
|
1003
1041
|
} : null,
|
|
1042
|
+
// ob03: 起票直後のplanは必ず調整方式が未宣言である。ここで案内しないと、選ぶ機会が
|
|
1043
|
+
// 「誰も呼ぶ動機の無いdrilldown」にしか無くなる——前campaignの監査待ちと同じ形になる。
|
|
1044
|
+
coordination_guidance: {
|
|
1045
|
+
mode: null,
|
|
1046
|
+
modes: [...TODO_COORDINATION_MODES],
|
|
1047
|
+
next_action: `lattice todo independence mode --plan ${imported.plan.plan_key} --set <witness|conversation> --reason <text>`,
|
|
1048
|
+
},
|
|
1004
1049
|
result_digest: '',
|
|
1005
1050
|
};
|
|
1006
1051
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
@@ -1277,7 +1322,13 @@ async function revisePhase({ repoRoot, env, planKey, inputRef }) {
|
|
|
1277
1322
|
}
|
|
1278
1323
|
|
|
1279
1324
|
async function status({ repoRoot }) {
|
|
1280
|
-
|
|
1325
|
+
const store = await readTodoStore({ repoRoot });
|
|
1326
|
+
return projectTodoStatus(store, {
|
|
1327
|
+
planNotes: await readTodoPlanNotesForStatus({ repoRoot, store }),
|
|
1328
|
+
parallelCandidates: await readTodoParallelCandidatesForStatus({
|
|
1329
|
+
repoRoot, store, gitHead: currentHeadSha, changedPathsSince,
|
|
1330
|
+
}),
|
|
1331
|
+
});
|
|
1281
1332
|
}
|
|
1282
1333
|
|
|
1283
1334
|
async function adoptDashboardRoot({ repoRoot, env }) {
|
|
@@ -1323,32 +1374,44 @@ async function todoDetail({ repoRoot, planKey, taskId }) {
|
|
|
1323
1374
|
return result;
|
|
1324
1375
|
}
|
|
1325
1376
|
|
|
1377
|
+
/**
|
|
1378
|
+
* `taskId === null`はplan単位note。task noteと違い宛先taskが無いので、
|
|
1379
|
+
* 訂正できる相手はplan noteだけ、返すcontextも特定taskのものにできない。
|
|
1380
|
+
*/
|
|
1326
1381
|
async function appendNote({ repoRoot, env, planKey, taskId, message, inputRef, supersedes }) {
|
|
1327
1382
|
const store = await readTodoStore({ repoRoot });
|
|
1328
1383
|
const [member] = selectMembers(store, planKey);
|
|
1329
|
-
const task = selectNoteTask(member, taskId);
|
|
1384
|
+
const task = taskId === null ? null : selectNoteTask(member, taskId);
|
|
1330
1385
|
const body = inputRef === null ? message : await readNoteTextInput(repoRoot, inputRef);
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1386
|
+
// 訂正はscopeを跨げない。plan noteはplan chainの中だけ、task noteは自分のtaskの中だけを
|
|
1387
|
+
// 訂正できる。contextはplan noteも載せるので、task側はscopeで絞らないと跨げてしまう。
|
|
1388
|
+
const eligibleSupersedes = task === null
|
|
1389
|
+
? (await readTodoNoteEvents({ repoRoot, planKey, scope: 'plan' })).events
|
|
1390
|
+
.map(({ event_digest: digest }) => digest)
|
|
1391
|
+
: (await readTodoNoteContext({ repoRoot, store, planKey, taskId: task.task_id }))
|
|
1392
|
+
.history.filter(({ scope }) => scope === 'task').map(({ event_digest: digest }) => digest);
|
|
1334
1393
|
const event = await appendTodoNote({
|
|
1335
1394
|
repoRoot,
|
|
1336
1395
|
projectId: store.project_id,
|
|
1337
1396
|
planKey,
|
|
1338
1397
|
planVersion: member.plan.plan_version,
|
|
1339
|
-
taskId: task
|
|
1398
|
+
taskId: task?.task_id ?? null,
|
|
1340
1399
|
actor: mutationActor(env),
|
|
1341
1400
|
recordedAt: new Date().toISOString(),
|
|
1342
1401
|
body,
|
|
1343
1402
|
supersedes,
|
|
1344
|
-
eligibleSupersedes
|
|
1403
|
+
eligibleSupersedes,
|
|
1345
1404
|
});
|
|
1346
|
-
|
|
1405
|
+
// plan noteはどのtaskのcontextにも載るので、1つを選んで返すと嘘になる。全部読むなら
|
|
1406
|
+
// `note list --plan <k>`。書けたことの証拠はeventそのものが持つ。
|
|
1407
|
+
const context = task === null
|
|
1408
|
+
? null : (await readTodoNoteContext({ repoRoot, store, planKey, taskId: task.task_id })).context;
|
|
1347
1409
|
const result = {
|
|
1348
|
-
schema: 'lattice.todo_note_append_result.
|
|
1410
|
+
schema: 'lattice.todo_note_append_result.v2',
|
|
1349
1411
|
project_id: store.project_id,
|
|
1350
1412
|
plan_key: planKey,
|
|
1351
|
-
|
|
1413
|
+
scope: task === null ? 'plan' : 'task',
|
|
1414
|
+
task_id: task?.task_id ?? null,
|
|
1352
1415
|
event,
|
|
1353
1416
|
note_context: context,
|
|
1354
1417
|
result_digest: '',
|
|
@@ -1361,7 +1424,10 @@ async function listNotes({ repoRoot, planKey, taskId }) {
|
|
|
1361
1424
|
const store = await readTodoStore({ repoRoot });
|
|
1362
1425
|
const [member] = selectMembers(store, planKey);
|
|
1363
1426
|
const chain = await readTodoNoteEvents({ repoRoot, planKey });
|
|
1364
|
-
|
|
1427
|
+
const planChain = await readTodoNoteEvents({ repoRoot, planKey, scope: 'plan' });
|
|
1428
|
+
// plan全体の一覧は両chainを返す。`full_history_command`がこの形を指す以上、片方でも
|
|
1429
|
+
// 落とせば「full」と名乗りながら全部を取りに行けない。並びはcontextと同じくplanが先。
|
|
1430
|
+
let notes = [...planChain.events, ...chain.events];
|
|
1365
1431
|
let archived = [];
|
|
1366
1432
|
let resolvedTaskId = null;
|
|
1367
1433
|
if (taskId !== null) {
|
|
@@ -1370,17 +1436,20 @@ async function listNotes({ repoRoot, planKey, taskId }) {
|
|
|
1370
1436
|
const projected = await readTodoNoteContext({
|
|
1371
1437
|
repoRoot, store, planKey, taskId: task.task_id,
|
|
1372
1438
|
});
|
|
1373
|
-
|
|
1439
|
+
// `--task`は「そのtaskのnote」を問う形。plan noteはtaskのものではないので混ぜない
|
|
1440
|
+
// ——欲しければ`--task`を外す。
|
|
1441
|
+
notes = projected.history.filter(({ scope }) => scope === 'task');
|
|
1374
1442
|
archived = projected.archived;
|
|
1375
1443
|
}
|
|
1376
1444
|
const result = {
|
|
1377
|
-
schema: 'lattice.todo_note_list_result.
|
|
1445
|
+
schema: 'lattice.todo_note_list_result.v2',
|
|
1378
1446
|
project_id: store.project_id,
|
|
1379
1447
|
plan_key: planKey,
|
|
1380
1448
|
requested_task_id: resolvedTaskId,
|
|
1381
1449
|
notes,
|
|
1382
1450
|
archived,
|
|
1383
1451
|
note_head_digest: chain.head_digest,
|
|
1452
|
+
plan_note_head_digest: planChain.head_digest,
|
|
1384
1453
|
result_digest: '',
|
|
1385
1454
|
};
|
|
1386
1455
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
@@ -1566,7 +1635,7 @@ async function independence({ repoRoot, requestedPlanKey }) {
|
|
|
1566
1635
|
? undefined : store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
1567
1636
|
const artifact = member === undefined
|
|
1568
1637
|
? null : await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
1569
|
-
const active = projectTodoStatus(store).active_set
|
|
1638
|
+
const active = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY).active_set
|
|
1570
1639
|
.filter((task) => task.plan_key === planKey);
|
|
1571
1640
|
// HEADが進んでいる時だけdiffを取る。一致していれば宣言境界を見るまでもない。
|
|
1572
1641
|
const changedPaths = artifact !== null && artifact.base_sha !== null
|
|
@@ -1606,6 +1675,9 @@ async function independence({ repoRoot, requestedPlanKey }) {
|
|
|
1606
1675
|
.some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
|
|
1607
1676
|
conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
|
|
1608
1677
|
conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
|
|
1678
|
+
// 案内の正本は1つ(ADR 0130 Decision 1)。着手する人と読みに来た人が同じ状況について
|
|
1679
|
+
// 違う文言を受け取らないよう、調整方式もここへ渡す。
|
|
1680
|
+
coordinationMode: member?.coordination?.mode ?? null,
|
|
1609
1681
|
verdictsAbsent: projected.frontier.unknown
|
|
1610
1682
|
.some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'plan_verdicts_absent')),
|
|
1611
1683
|
}),
|
|
@@ -1871,6 +1943,7 @@ async function seamProposalApply({ repoRoot, planKey, pathNames = {}, land = fal
|
|
|
1871
1943
|
latticeBin: fileURLToPath(new URL('../bin/lattice.mjs', import.meta.url)),
|
|
1872
1944
|
sharedPathFor: (sourcePath) => sourcePath.replace(/(\.[^./]+)$/u, '.seam-shared$1'),
|
|
1873
1945
|
executors: witnessSet.capacity.executors,
|
|
1946
|
+
precedences: todoPlanPrecedences(member.plan),
|
|
1874
1947
|
compileIndependence: {
|
|
1875
1948
|
baseArtifact,
|
|
1876
1949
|
// 変換後のworktreeで、写した宣言と再indexした索引から実compileする。
|
|
@@ -2139,7 +2212,7 @@ async function notesForGantt({ repoRoot, store }) {
|
|
|
2139
2212
|
|
|
2140
2213
|
async function independenceForGantt({ repoRoot, store }) {
|
|
2141
2214
|
const frontier = computeReadyFrontier(store);
|
|
2142
|
-
const status = projectTodoStatus(store);
|
|
2215
|
+
const status = projectTodoStatus(store, TODO_STATUS_DISPATCH_ONLY);
|
|
2143
2216
|
let currentBaseSha = null;
|
|
2144
2217
|
const projections = [];
|
|
2145
2218
|
for (const member of store.members) {
|
|
@@ -2526,6 +2599,20 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2526
2599
|
inputRef: argv[5] === '--input' ? argv[6] : null,
|
|
2527
2600
|
supersedes: argv[8] ?? null,
|
|
2528
2601
|
});
|
|
2602
|
+
} else if ((argv.length === 5 || argv.length === 7) && argv[0] === 'note'
|
|
2603
|
+
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
2604
|
+
&& ['--message', '--input'].includes(argv[3])
|
|
2605
|
+
&& ((argv[3] === '--message' && argv[4].length > 0)
|
|
2606
|
+
|| (argv[3] === '--input' && isTodoRef(argv[4])))
|
|
2607
|
+
&& (argv.length === 5 || (argv[5] === '--supersedes' && isTodoDigest(argv[6])))) {
|
|
2608
|
+
// `--task`省略でplan単位note。工程レベルの義務(順序制約・一度きりの観測が在ること)は
|
|
2609
|
+
// 特定のtaskに属さない。
|
|
2610
|
+
action = (repoRoot) => appendNote({
|
|
2611
|
+
repoRoot, env, planKey: argv[2], taskId: null,
|
|
2612
|
+
message: argv[3] === '--message' ? argv[4] : null,
|
|
2613
|
+
inputRef: argv[3] === '--input' ? argv[4] : null,
|
|
2614
|
+
supersedes: argv[6] ?? null,
|
|
2615
|
+
});
|
|
2529
2616
|
} else if (argv.length === 5 && argv[0] === 'note' && argv[1] === 'list'
|
|
2530
2617
|
&& argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--json') {
|
|
2531
2618
|
action = (repoRoot) => listNotes({ repoRoot, planKey: argv[3], taskId: null });
|
|
@@ -2536,6 +2623,12 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2536
2623
|
} else if (argv.length === 5 && argv[0] === 'independence' && argv[1] === 'witness'
|
|
2537
2624
|
&& argv[2] === 'migrate' && argv[3] === '--plan' && isTodoIdentifier(argv[4])) {
|
|
2538
2625
|
action = (repoRoot) => independenceWitnessMigrate({ repoRoot, planKey: argv[4] });
|
|
2626
|
+
} else if (argv.length === 8 && argv[0] === 'independence' && argv[1] === 'mode'
|
|
2627
|
+
&& argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--set'
|
|
2628
|
+
&& TODO_COORDINATION_MODES.includes(argv[5]) && argv[6] === '--reason' && argv[7].length > 0) {
|
|
2629
|
+
action = (repoRoot) => independenceMode({
|
|
2630
|
+
repoRoot, env, planKey: argv[3], mode: argv[5], reason: argv[7],
|
|
2631
|
+
});
|
|
2539
2632
|
} else if (argv.length === 6 && argv[0] === 'independence' && argv[1] === 'compile'
|
|
2540
2633
|
&& argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--input') {
|
|
2541
2634
|
action = (repoRoot) => independenceCompile({
|
package/src/todo-contracts.mjs
CHANGED
|
@@ -8,9 +8,35 @@ export const TODO_EVENT_KINDS = Object.freeze([
|
|
|
8
8
|
// phase_review/accept/reject/reopenと同じv3 tail event shape(phase_id持ち)に収め、
|
|
9
9
|
// 新しいevent schema版は作らない。
|
|
10
10
|
'phase_close_unaudited',
|
|
11
|
+
// ob03: 調整方式(witness検証で並列するか、会話調整で行くか)の宣言。planに帰属する事実で
|
|
12
|
+
// taskにもPhaseにも属さないため、task_idもphase_idも持たない最初のkindになる。actorが
|
|
13
|
+
// 「誰が選んだか」の帰属を持つ——witnessが全planの暗黙義務だった時に帰属が無く、正確な
|
|
14
|
+
// 案内が素通りされたことへの是正である(オーナー裁定C①)。
|
|
15
|
+
'coordination_mode',
|
|
11
16
|
]);
|
|
17
|
+
|
|
18
|
+
/** planへ帰属し、taskにもPhaseにも属さないevent kind。 */
|
|
19
|
+
export const TODO_PLAN_SCOPED_EVENT_KINDS = Object.freeze(['coordination_mode']);
|
|
20
|
+
|
|
21
|
+
/** 調整方式。witness=独立性を宣言し検証して並列する/conversation=会話で調整する。 */
|
|
22
|
+
export const TODO_COORDINATION_MODES = Object.freeze(['witness', 'conversation']);
|
|
12
23
|
export const TODO_NOTE_EVENT_SCHEMA = 'lattice.todo_note_event.v1';
|
|
13
|
-
|
|
24
|
+
/**
|
|
25
|
+
* plan単位のnote event。工程レベルの義務(順序制約・一度きりの観測が在ること)は特定のtaskに
|
|
26
|
+
* 属さないので、v1の`task_id`必須では書けない。v1は書き換えない——既存chainはhash連鎖で
|
|
27
|
+
* 固定済みであり、taskノートの挙動も digest も動かさない。
|
|
28
|
+
*
|
|
29
|
+
* v2は**別のchain file**(`plan-active.jsonl`)へ積む。同じchainへ混ぜると、旧CLIは
|
|
30
|
+
* 1 eventずつのbyte検証で chain全体を壊れと読み、noteの読みを前提条件とする`todo start`が
|
|
31
|
+
* 落ちる。しかもstoreへ書いたものは戻せない。分離すれば旧CLIはfileの存在に気づかず、
|
|
32
|
+
* task noteの読み書きは1バイトも変わらない。
|
|
33
|
+
*/
|
|
34
|
+
export const TODO_NOTE_EVENT_V2_SCHEMA = 'lattice.todo_note_event.v2';
|
|
35
|
+
/**
|
|
36
|
+
* v2でnoteの`scope`とplan noteを載せる。v1のままでは`task_id`が identifier 必須・
|
|
37
|
+
* `full_history_command`が`--task <id>`込みの文字列と完全一致で、どちらもplan noteを表せない。
|
|
38
|
+
*/
|
|
39
|
+
export const TODO_NOTE_CONTEXT_SCHEMA = 'lattice.todo_note_context.v2';
|
|
14
40
|
export const TODO_LIMITS = Object.freeze({
|
|
15
41
|
tasksPerPlan: 512,
|
|
16
42
|
edgesPerPlan: 2_048,
|
|
@@ -20,6 +46,9 @@ export const TODO_LIMITS = Object.freeze({
|
|
|
20
46
|
narrativeSectionBytes: 262_144,
|
|
21
47
|
noteBodyBytes: 16_384,
|
|
22
48
|
noteContextBytes: 65_536,
|
|
49
|
+
// statusのplan_notes entryが載せる最新noteの件数。本文を載せない代わりに
|
|
50
|
+
// 「誰がいつ置いたか」だけを新しい順で数件出す(中身はnote listが持つ)。
|
|
51
|
+
statusPlanNoteLatest: 3,
|
|
23
52
|
});
|
|
24
53
|
|
|
25
54
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
@@ -100,15 +129,27 @@ const noteBody = (value) => typeof value === 'string' && value.length > 0
|
|
|
100
129
|
&& Buffer.byteLength(value, 'utf8') <= TODO_LIMITS.noteBodyBytes
|
|
101
130
|
&& !NOTE_FORBIDDEN_CONTROL.test(value);
|
|
102
131
|
|
|
103
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* lifecycle journalとは独立したnote eventを検証する。v1(task note)とv2(plan note)は
|
|
134
|
+
* 別chainへ積まれるが、検証器は両方を受ける——どちらのchainを読んでいるかはpathが決め、
|
|
135
|
+
* chainへ他scopeが混ざっていないことは`readTodoNoteEvents`が読み出し時に落とす。
|
|
136
|
+
* v1は`scope`を持たない——定義上taskであり、投影の側で明示`scope`へ正規化する。
|
|
137
|
+
*/
|
|
104
138
|
export function validateTodoNoteEvent(value) {
|
|
105
139
|
try {
|
|
106
|
-
|
|
140
|
+
const keys = [
|
|
107
141
|
'schema', 'project_id', 'plan_key', 'task_id', 'plan_version', 'sequence',
|
|
108
142
|
'previous_digest', 'actor', 'recorded_at', 'body', 'supersedes', 'event_digest',
|
|
109
|
-
]
|
|
143
|
+
];
|
|
144
|
+
const planScoped = value?.schema === TODO_NOTE_EVENT_V2_SCHEMA;
|
|
145
|
+
return exactRecord(value, planScoped ? [...keys, 'scope'] : keys)
|
|
146
|
+
&& (planScoped
|
|
147
|
+
// v2は今のところplan scopeだけを表す。phase scopeは配達面(audit_pending entry)を
|
|
148
|
+
// 持つtaskと同じ波で足す——書けるが届かない面を作らないため。
|
|
149
|
+
? value.scope === 'plan' && value.task_id === null
|
|
150
|
+
: value.schema === TODO_NOTE_EVENT_SCHEMA && isTodoIdentifier(value.task_id))
|
|
110
151
|
&& isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
|
|
111
|
-
&& isTodoIdentifier(value.
|
|
152
|
+
&& isTodoIdentifier(value.plan_version)
|
|
112
153
|
&& Number.isSafeInteger(value.sequence) && value.sequence >= 1
|
|
113
154
|
&& (value.sequence === 1 ? value.previous_digest === null : isTodoDigest(value.previous_digest))
|
|
114
155
|
&& actor(value.actor) && isStrictTodoTimestamp(value.recorded_at) && noteBody(value.body)
|
|
@@ -120,12 +161,20 @@ export function validateTodoNoteEvent(value) {
|
|
|
120
161
|
}
|
|
121
162
|
}
|
|
122
163
|
|
|
164
|
+
/**
|
|
165
|
+
* `scope`は投影が必ず埋める。読み手はここでv1/v2の区別を持たないので、`origin_task_id`が
|
|
166
|
+
* nullであることから「plan単位だ」を推論させない——型で表現していない区別は、exact検証を
|
|
167
|
+
* 通り抜けて受け手の解釈に落ちる。
|
|
168
|
+
*/
|
|
123
169
|
function noteContextEntry(value) {
|
|
124
170
|
return exactRecord(value, [
|
|
125
|
-
'event_digest', 'origin_plan_version', 'origin_task_id', 'actor', 'recorded_at',
|
|
171
|
+
'event_digest', 'origin_plan_version', 'scope', 'origin_task_id', 'actor', 'recorded_at',
|
|
126
172
|
'body', 'supersedes', 'superseded_by', 'correction_state',
|
|
127
173
|
]) && isTodoDigest(value.event_digest) && isTodoIdentifier(value.origin_plan_version)
|
|
128
|
-
&&
|
|
174
|
+
&& ['plan', 'task'].includes(value.scope)
|
|
175
|
+
&& (value.scope === 'plan'
|
|
176
|
+
? value.origin_task_id === null : isTodoIdentifier(value.origin_task_id))
|
|
177
|
+
&& actor(value.actor)
|
|
129
178
|
&& isStrictTodoTimestamp(value.recorded_at) && noteBody(value.body)
|
|
130
179
|
&& (value.supersedes === null || isTodoDigest(value.supersedes))
|
|
131
180
|
&& (value.superseded_by === null || isTodoDigest(value.superseded_by))
|
|
@@ -139,17 +188,29 @@ export function validateTodoNoteContext(value) {
|
|
|
139
188
|
try {
|
|
140
189
|
if (!exactRecord(value, [
|
|
141
190
|
'schema', 'project_id', 'plan_key', 'task_id', 'notes', 'note_head_digest',
|
|
142
|
-
'overflow_count', 'full_history_command', 'context_digest',
|
|
191
|
+
'plan_note_head_digest', 'overflow_count', 'full_history_command', 'context_digest',
|
|
143
192
|
]) || value.schema !== TODO_NOTE_CONTEXT_SCHEMA
|
|
144
193
|
|| !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
|
|
145
194
|
|| !isTodoIdentifier(value.task_id) || !Array.isArray(value.notes)
|
|
146
195
|
|| value.notes.length > TODO_LIMITS.tasksPerPlan || !value.notes.every(noteContextEntry)
|
|
147
196
|
|| !(value.note_head_digest === null || isTodoDigest(value.note_head_digest))
|
|
197
|
+
|| !(value.plan_note_head_digest === null || isTodoDigest(value.plan_note_head_digest))
|
|
148
198
|
|| !isNonNegativeSafeInteger(value.overflow_count)
|
|
149
|
-
|
|
199
|
+
// contextはplan noteも載せるので、案内するのはplan全体を返す形でなければならない。
|
|
200
|
+
// `--task <id>`形はplan noteを落とすため、fullと名乗りながら全部を取りに行けなくなる。
|
|
201
|
+
|| value.full_history_command !== `lattice todo note list --plan ${value.plan_key} --json`
|
|
150
202
|
|| !isTodoDigest(value.context_digest)
|
|
151
203
|
|| value.context_digest !== todoSelfDigest(value, 'context_digest')) return false;
|
|
152
|
-
|
|
204
|
+
// headはchainごとなので、同値もscopeで切る。task noteが空でplan noteが在る時に
|
|
205
|
+
// 「notesが非空なのにheadがnull」を壊れと読まないため。overflowで本文が落ちても
|
|
206
|
+
// headはchainの実在を述べ続ける——同値はnotesではなくoverflow込みの母集合で見る。
|
|
207
|
+
const scoped = (scope) => value.notes.some((note) => note.scope === scope);
|
|
208
|
+
if (value.overflow_count === 0) {
|
|
209
|
+
if (scoped('task') !== (value.note_head_digest !== null)) return false;
|
|
210
|
+
if (scoped('plan') !== (value.plan_note_head_digest !== null)) return false;
|
|
211
|
+
} else if (value.note_head_digest === null && value.plan_note_head_digest === null) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
153
214
|
return value.notes.reduce((bytes, note) => bytes + Buffer.byteLength(note.body, 'utf8'), 0)
|
|
154
215
|
<= TODO_LIMITS.noteContextBytes;
|
|
155
216
|
} catch {
|
|
@@ -440,6 +501,11 @@ function validPayload(event) {
|
|
|
440
501
|
&& (payload.started_at === 'unknown_requires_evidence' || isStrictTodoTimestamp(payload.started_at))
|
|
441
502
|
&& validateTodoImportSource(payload.evidence);
|
|
442
503
|
}
|
|
504
|
+
if (event.kind === 'coordination_mode') {
|
|
505
|
+
return exactRecord(payload, ['mode', 'reason'])
|
|
506
|
+
&& TODO_COORDINATION_MODES.includes(payload.mode)
|
|
507
|
+
&& nullableText(payload.reason) && payload.reason !== null;
|
|
508
|
+
}
|
|
443
509
|
if (event.kind === 'start') return exactRecord(payload, ['override_reason']) && nullableText(payload.override_reason);
|
|
444
510
|
if (event.kind === 'block') return exactRecord(payload, ['reason']) && nullableText(payload.reason) && payload.reason !== null;
|
|
445
511
|
if (event.kind === 'unblock') return exactRecord(payload, []);
|
|
@@ -561,17 +627,21 @@ export function validateTodoEvent(value) {
|
|
|
561
627
|
&& validPhaseStateMigration(value.phase_state_migration);
|
|
562
628
|
const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
|
|
563
629
|
.includes(value?.kind);
|
|
630
|
+
// planへ帰属するkindは、plan_genesisと同じくtask_idを持たない(v3/v4ではphase_idも持たない)。
|
|
631
|
+
// plan_genesisと違うのはjournalの途中に何度でも積めることで、最後の1件が現在の宣言になる。
|
|
632
|
+
const planScopedKind = TODO_PLAN_SCOPED_EVENT_KINDS.includes(value?.kind);
|
|
633
|
+
const planLevel = value?.kind === 'plan_genesis' || planScopedKind;
|
|
564
634
|
return (v1 || v2 || v3 || v4) && isTodoIdentifier(value.project_id)
|
|
565
635
|
&& isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.plan_version)
|
|
566
636
|
&& isNonNegativeSafeInteger(value.sequence) && nullableDigest(value.previous_digest)
|
|
567
637
|
&& TODO_EVENT_KINDS.includes(value.kind)
|
|
568
638
|
&& (v3 || v4
|
|
569
|
-
? ((
|
|
639
|
+
? ((planLevel && value.task_id === null && value.phase_id === null)
|
|
570
640
|
|| (phaseKind && value.task_id === null && isTodoIdentifier(value.phase_id))
|
|
571
|
-
|| (!phaseKind &&
|
|
641
|
+
|| (!phaseKind && !planLevel && isTodoIdentifier(value.task_id)
|
|
572
642
|
&& value.phase_id === null))
|
|
573
|
-
: !phaseKind && ((
|
|
574
|
-
|| (
|
|
643
|
+
: !phaseKind && ((planLevel && value.task_id === null)
|
|
644
|
+
|| (!planLevel && isTodoIdentifier(value.task_id))))
|
|
575
645
|
&& actor(value.actor) && isStrictTodoTimestamp(value.recorded_at) && provenance(value.provenance)
|
|
576
646
|
&& validPayload(value) && isTodoDigest(value.event_digest)
|
|
577
647
|
&& value.event_digest === todoSelfDigest(value, 'event_digest');
|
|
@@ -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 }) =>
|
|
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
|
-
|
|
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}
|
|
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)}
|