@quolu/lattice 0.36.1 → 0.37.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 +14 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/cli-help.mjs +10 -1
- package/src/todo-cli.mjs +132 -4
- package/src/todo-contracts.mjs +14 -3
- package/src/todo-store.mjs +40 -12
package/README.ja.md
CHANGED
|
@@ -213,6 +213,20 @@ lattice todo phase review --plan <key> --phase terminal-audit --reason <text>
|
|
|
213
213
|
lattice todo phase accept --plan <key> --phase terminal-audit --input <file>
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
+
**過去の工程は監査できません。** 監査対象のコードが既に変化しているためです。そこで
|
|
217
|
+
「監査なしで閉じた」という状態を別に持ちます——`accepted`(監査を通った)へは絶対に化けず、
|
|
218
|
+
記録は永久に区別できます。工程図では畳まれるので監査待ちの札は外れます。
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
lattice todo phase close-unaudited --plan <key> --phase terminal-audit --reason <text>
|
|
222
|
+
lattice todo phase baseline --reason <text> --except <監査したいplan_key> # 一括
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
一括の入口は、現在監査待ちで一度も監査に触れていないPhaseだけを対象にします。**自動では
|
|
226
|
+
実行しません**——どれを監査し、どれを歴史として畳むかは人が決め、Latticeは宣言を受け取って
|
|
227
|
+
記録するだけです。`--except`は「最近の作業でコードも生きているので本当に監査したい」planを
|
|
228
|
+
残すための口です。規約は[ADR 0148](docs/adr/0148-history-closes-unaudited-not-audited.md)が正です。
|
|
229
|
+
|
|
216
230
|
誤ってphase無しで作ったplanへ後からPhaseを被せる場合は、`revise-phase`の
|
|
217
231
|
`state_policy: acquire_phase`でdone状態を保ったまま獲得できます(未割当→割当の向きだけを許し、
|
|
218
232
|
既にphaseを持つtaskの付け替えは拒否します)。
|
package/README.md
CHANGED
|
@@ -158,6 +158,15 @@ rejected over it; the requirement is reported instead. And the audit gate never
|
|
|
158
158
|
phases order reviews, the ToDo DAG orders work
|
|
159
159
|
([ADR 0147](docs/adr/0147-audit-is-on-by-default.md)).
|
|
160
160
|
|
|
161
|
+
**History closes unaudited, never audited.** Work that finished long ago cannot be audited — the
|
|
162
|
+
code under review has already moved. Demanding an audit there produces either a false finding
|
|
163
|
+
(pointing at a later, intentional change) or a rubber stamp. So there is a third terminal state,
|
|
164
|
+
`closed_unaudited`: recorded with a reason, folded away like finished work, and **structurally
|
|
165
|
+
incapable of passing as `accepted`** — phase-accept dependencies unlock on `accepted` alone. The
|
|
166
|
+
bulk entry point never runs by itself, and the machine never infers "old enough to skip"; a human
|
|
167
|
+
decides what gets audited and what becomes history
|
|
168
|
+
([ADR 0148](docs/adr/0148-history-closes-unaudited-not-audited.md)).
|
|
169
|
+
|
|
161
170
|
## Patent
|
|
162
171
|
|
|
163
172
|
The design in this repository is the subject of a Japanese patent application:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.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
|
@@ -98,6 +98,12 @@ Write commands:
|
|
|
98
98
|
phase review --plan <key> --phase <id> --reason <text>
|
|
99
99
|
phase <accept|reject> --plan <key> --phase <id> --input <file>
|
|
100
100
|
phase reopen --plan <key> --phase <id> --reason <text> [--override-reason <text>]
|
|
101
|
+
phase close-unaudited --plan <key> --phase <id> --reason <text>
|
|
102
|
+
# 監査せず「監査なしで閉じた」として明示的に閉じる(ADR 0148)。前提はgate_readyで、
|
|
103
|
+
# acceptedへは化けない(phase_accept_dependenciesを解錠しない)
|
|
104
|
+
phase baseline --reason <text> [--except <plan_key>]...
|
|
105
|
+
# 現在gate_readyかつphase eventを1つも持たないPhaseを一括でclosed_unauditedへ宣言する。
|
|
106
|
+
# 自動実行はしない(明示コマンドのみ)。--exceptで指定したplanは対象から除外する
|
|
101
107
|
|
|
102
108
|
Write commands require LATTICE_TODO_ACTOR_HOST, LATTICE_TODO_ACTOR_SESSION,
|
|
103
109
|
and LATTICE_TODO_ACTOR_AGENT.
|
|
@@ -169,12 +175,15 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
169
175
|
'todo verify': 'todo verify [--plan <key>] [--json]',
|
|
170
176
|
'todo snapshot': 'todo snapshot --rebuild --plan <key>',
|
|
171
177
|
'todo gantt': 'todo gantt [--out <file>] [--scope live|all] | status [--out <file>] | serve --port <port> [--scope live|all]',
|
|
172
|
-
'todo phase': 'todo phase <status|review|accept|reject|reopen> --plan <key> [options]'
|
|
178
|
+
'todo phase': 'todo phase <status|review|accept|reject|reopen|close-unaudited> --plan <key> [options]'
|
|
179
|
+
+ ' | baseline --reason <text> [--except <plan_key>]...',
|
|
173
180
|
'todo phase status': 'todo phase status --plan <key>',
|
|
174
181
|
'todo phase review': 'todo phase review --plan <key> --phase <id> --reason <text>',
|
|
175
182
|
'todo phase accept': 'todo phase accept --plan <key> --phase <id> --input <file>',
|
|
176
183
|
'todo phase reject': 'todo phase reject --plan <key> --phase <id> --input <file>',
|
|
177
184
|
'todo phase reopen': 'todo phase reopen --plan <key> --phase <id> --reason <text> [--override-reason <text>]',
|
|
185
|
+
'todo phase close-unaudited': 'todo phase close-unaudited --plan <key> --phase <id> --reason <text>',
|
|
186
|
+
'todo phase baseline': 'todo phase baseline --reason <text> [--except <plan_key>]...',
|
|
178
187
|
'todo start': 'todo start --plan <key> --task <id> [--parallel-frontier|--override-reason <text>]',
|
|
179
188
|
'todo block': 'todo block --plan <key> --task <id> --reason <text>',
|
|
180
189
|
'todo unblock': 'todo unblock --plan <key> --task <id>',
|
package/src/todo-cli.mjs
CHANGED
|
@@ -416,6 +416,26 @@ function mutationActor(env) {
|
|
|
416
416
|
return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/**
|
|
420
|
+
* gate_readyの札が出た理由と次の一歩をtypedに言う(ADR 0148裁定8)。
|
|
421
|
+
*
|
|
422
|
+
* 0.36.0で入れた終端監査gate(ADR 0147)は、これから終わる工程だけでなく過去に完了した
|
|
423
|
+
* 工程まで監査待ちにしてしまっていた——原因を言わないgateは、なぜ止まっているかを
|
|
424
|
+
* 説明しないのと同じなので、ここでその経緯を明示する。次の一歩は2択で書く:
|
|
425
|
+
* 今から監査する(review→accept)か、監査せず歴史として閉じる(close-unaudited、
|
|
426
|
+
* 複数plan一括ならbaseline)か。
|
|
427
|
+
*/
|
|
428
|
+
function auditGateGuidance(planKey, phaseId) {
|
|
429
|
+
return '全ToDoがdoneになり監査待ち(gate_ready)。0.36.0で入れた終端監査gateは、'
|
|
430
|
+
+ 'これから終わる工程だけでなく過去に完了した工程まで監査待ちにしてしまっていた'
|
|
431
|
+
+ '(ADR 0148で修正)。今から監査するなら: '
|
|
432
|
+
+ `todo phase review --plan ${planKey} --phase ${phaseId} --reason <text> → `
|
|
433
|
+
+ `todo phase accept --plan ${planKey} --phase ${phaseId} --input <file>。`
|
|
434
|
+
+ '監査せず歴史として閉じるなら: '
|
|
435
|
+
+ `todo phase close-unaudited --plan ${planKey} --phase ${phaseId} --reason <text>`
|
|
436
|
+
+ '(複数planをまとめて畳むなら todo phase baseline --reason <text> [--except <plan_key>]...)。';
|
|
437
|
+
}
|
|
438
|
+
|
|
419
439
|
/**
|
|
420
440
|
* phase無しplanで、この変異の結果terminal-audit Phaseがgate_ready(全task done・未監査)に
|
|
421
441
|
* なっていれば助言を返す(ADR 0147)。doneの結果だけを見て機械的に判定するので、既にreview
|
|
@@ -429,9 +449,7 @@ function terminalAuditDoneAdvisory(plan, phases) {
|
|
|
429
449
|
if (phase?.status !== 'gate_ready') return null;
|
|
430
450
|
return {
|
|
431
451
|
terminal_audit_required: true, phase_id: TERMINAL_AUDIT_PHASE_ID, status: phase.status,
|
|
432
|
-
guidance:
|
|
433
|
-
+ '(todo phase review --plan <key> --phase terminal-audit → todo phase accept)を'
|
|
434
|
-
+ '経るまで「閉じた」ことにはならない。',
|
|
452
|
+
guidance: auditGateGuidance(plan.plan_key, TERMINAL_AUDIT_PHASE_ID),
|
|
435
453
|
};
|
|
436
454
|
}
|
|
437
455
|
|
|
@@ -705,11 +723,110 @@ async function phaseStatus({ repoRoot, planKey }) {
|
|
|
705
723
|
// 無いのでsnapshot.phasesは直接読まない)。ここでPHASE_UNAVAILABLEへ拒否せず、その暗黙Phase
|
|
706
724
|
// をそのまま返す——`implicit`で機械可読に「宣言されたPhaseではない」ことを示す。
|
|
707
725
|
const implicit = isPhaselessTodoPlanSchema(member.plan.schema);
|
|
726
|
+
// ADR 0148裁定8: gate_readyのPhaseには、なぜ監査待ちになったかと次の一歩をここでも言う
|
|
727
|
+
// (todo doneのadvisoryと同じ文言)。implicit(暗黙のterminal-audit Phase)に限らず、
|
|
728
|
+
// v4/v5の実Phaseがgate_readyになった場合も同じ理由で同じ案内が要る。
|
|
729
|
+
const phases = member.phases.map((phase) => ({
|
|
730
|
+
...phase,
|
|
731
|
+
guidance: phase.status === 'gate_ready'
|
|
732
|
+
? auditGateGuidance(member.plan.plan_key, phase.phase_id) : null,
|
|
733
|
+
}));
|
|
708
734
|
const result = {
|
|
709
735
|
schema: 'lattice.phase_status_result.v1', project_id: store.project_id,
|
|
710
736
|
plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
|
|
711
737
|
journal_head_digest: member.journal.events.at(-1).event_digest,
|
|
712
|
-
implicit, phases
|
|
738
|
+
implicit, phases, result_digest: '',
|
|
739
|
+
};
|
|
740
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
741
|
+
return result;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* `phase baseline --reason <text> [--except <plan_key>]...`の可変長`--except`を解析する。
|
|
746
|
+
* 他のargvはすべて固定位置(argv.length + 位置一致)で判定しているが、0回以上繰り返せる
|
|
747
|
+
* flagだけは固定位置で表現できないため、並び全体を見る専用ヘルパへ切り出す
|
|
748
|
+
* (bridge-cliの`parseOptions`と同じ発想)。不正な並びはnullを返し、呼び出し側の
|
|
749
|
+
* 分岐条件がそのままusageFailureへ落ちる。
|
|
750
|
+
*/
|
|
751
|
+
function parseBaselineExceptFlags(rest) {
|
|
752
|
+
if (rest.length % 2 !== 0) return null;
|
|
753
|
+
const planKeys = [];
|
|
754
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
755
|
+
if (rest[index] !== '--except' || !isTodoIdentifier(rest[index + 1])) return null;
|
|
756
|
+
planKeys.push(rest[index + 1]);
|
|
757
|
+
}
|
|
758
|
+
return planKeys;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* `phase baseline`——現在gate_readyかつphase eventを1つも持たないPhaseを一括で
|
|
763
|
+
* closed_unauditedへ宣言する(ADR 0148裁定6)。「phase eventを1つも持たない」は
|
|
764
|
+
* derivedStatusだけでは判定できない——一度reviewしてからreopenしたPhaseは、構造的には
|
|
765
|
+
* また`gate_ready`に戻り得るが、既に監査に触れている以上は対象外である。journalの実event
|
|
766
|
+
* 履歴(phase_id別に`phase_`で始まるkindがあるか)を直接見て区別する。
|
|
767
|
+
*
|
|
768
|
+
* `--except`はplan単位の除外(ADR 0148裁定7)。ServerManagerの26 ToDoのような
|
|
769
|
+
* 「最近の作業でコードも生きている」campaignを基準線で流さないための口であり、
|
|
770
|
+
* Phase単位では絞れない(そのplanのPhaseは丸ごと対象外になる)。
|
|
771
|
+
*/
|
|
772
|
+
async function phaseBaseline({ repoRoot, env, reason, exceptPlanKeys }) {
|
|
773
|
+
const store = await readTodoStore({ repoRoot });
|
|
774
|
+
const knownPlanKeys = new Set(store.members.map(({ descriptor }) => descriptor.plan_key));
|
|
775
|
+
const unknownExcept = exceptPlanKeys.filter((planKey) => !knownPlanKeys.has(planKey));
|
|
776
|
+
if (unknownExcept.length > 0) {
|
|
777
|
+
throw new TodoStoreError('PHASE_BASELINE_INVALID', 'except_plan_key_unknown', undefined, {
|
|
778
|
+
unknown_plan_keys: unknownExcept,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
const exceptSet = new Set(exceptPlanKeys);
|
|
782
|
+
const applied = [];
|
|
783
|
+
const excluded = [];
|
|
784
|
+
const notApplicable = [];
|
|
785
|
+
const failed = [];
|
|
786
|
+
for (const member of store.members) {
|
|
787
|
+
const planKey = member.descriptor.plan_key;
|
|
788
|
+
const touchedPhaseIds = new Set(member.journal.events
|
|
789
|
+
.filter((event) => event.kind.startsWith('phase_'))
|
|
790
|
+
.map((event) => event.phase_id));
|
|
791
|
+
for (const phase of member.phases) {
|
|
792
|
+
const entry = { plan_key: planKey, phase_id: phase.phase_id, status: phase.status };
|
|
793
|
+
// 「対象外だったもの(既にaccepted等)も区別して返す」——まだgate_readyに到達していない
|
|
794
|
+
// (locked/active)のと、既に監査の決着が付いている(accepted/rejected/closed_unaudited)のは
|
|
795
|
+
// 原因が違うので、causeを分けて機械可読にする。
|
|
796
|
+
if (['accepted', 'rejected', 'closed_unaudited'].includes(phase.status)) {
|
|
797
|
+
notApplicable.push({ ...entry, cause: `already_${phase.status}` });
|
|
798
|
+
} else if (phase.status !== 'gate_ready') {
|
|
799
|
+
notApplicable.push({ ...entry, cause: 'not_gate_ready' });
|
|
800
|
+
} else if (touchedPhaseIds.has(phase.phase_id)) {
|
|
801
|
+
notApplicable.push({ ...entry, cause: 'already_has_phase_event' });
|
|
802
|
+
} else if (exceptSet.has(planKey)) {
|
|
803
|
+
excluded.push({ ...entry, cause: 'excepted' });
|
|
804
|
+
} else {
|
|
805
|
+
// ADR 0148裁定6の非目標: journalはappend-onlyで、一度書いた宣言を後から取り消す
|
|
806
|
+
// 手段が無い。1件失敗しても既に書けた他件を無かったことにはできない以上、
|
|
807
|
+
// 全件一括のtransactionは新設せず、appendTodoEvent単位(1 event = 1排他書込み)の
|
|
808
|
+
// 独立実行を続ける。各書込みは呼ぶたびにstoreを読み直してgate_ready前提を
|
|
809
|
+
// 再検証するため、他項目の成否とは無関係に安全である——1件の失敗で残りを
|
|
810
|
+
// 止めず、続行して全件の結果をtypedで返す。
|
|
811
|
+
try {
|
|
812
|
+
const result = await phaseMutation({
|
|
813
|
+
repoRoot, env, planKey, phaseId: phase.phase_id,
|
|
814
|
+
kind: 'phase_close_unaudited', payload: { reason },
|
|
815
|
+
});
|
|
816
|
+
applied.push({ ...entry, event_digest: result.event_digest,
|
|
817
|
+
journal_head_digest: result.journal_head_digest });
|
|
818
|
+
} catch (error) {
|
|
819
|
+
failed.push({ ...entry, code: error?.code ?? 'INTERNAL_FAILURE',
|
|
820
|
+
message: typeof error?.message === 'string' ? error.message : null });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const result = {
|
|
826
|
+
schema: 'lattice.phase_baseline_result.v1',
|
|
827
|
+
reason, except_plan_keys: [...exceptSet].sort(),
|
|
828
|
+
applied, excluded, not_applicable: notApplicable, failed,
|
|
829
|
+
result_digest: '',
|
|
713
830
|
};
|
|
714
831
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
715
832
|
return result;
|
|
@@ -2148,6 +2265,17 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2148
2265
|
&& (argv.length === 8 || (argv[8] === '--override-reason' && argv[9].length > 0))) {
|
|
2149
2266
|
action = (repoRoot) => phaseMutation({ repoRoot, env, planKey: argv[3], phaseId: argv[5],
|
|
2150
2267
|
kind: 'phase_reopen', payload: { reason: argv[7], override_reason: argv[9] ?? null } });
|
|
2268
|
+
} else if (argv.length === 8 && argv[0] === 'phase' && argv[1] === 'close-unaudited'
|
|
2269
|
+
&& argv[2] === '--plan' && isTodoIdentifier(argv[3])
|
|
2270
|
+
&& argv[4] === '--phase' && isTodoIdentifier(argv[5])
|
|
2271
|
+
&& argv[6] === '--reason' && argv[7].length > 0) {
|
|
2272
|
+
action = (repoRoot) => phaseMutation({ repoRoot, env, planKey: argv[3], phaseId: argv[5],
|
|
2273
|
+
kind: 'phase_close_unaudited', payload: { reason: argv[7] } });
|
|
2274
|
+
} else if (argv.length >= 4 && argv[0] === 'phase' && argv[1] === 'baseline'
|
|
2275
|
+
&& argv[2] === '--reason' && argv[3].length > 0
|
|
2276
|
+
&& parseBaselineExceptFlags(argv.slice(4)) !== null) {
|
|
2277
|
+
const exceptPlanKeys = parseBaselineExceptFlags(argv.slice(4));
|
|
2278
|
+
action = (repoRoot) => phaseBaseline({ repoRoot, env, reason: argv[3], exceptPlanKeys });
|
|
2151
2279
|
} else if ((argv.length === 5 || argv.length === 6 || argv.length === 7 || argv.length === 8)
|
|
2152
2280
|
&& argv[0] === 'start'
|
|
2153
2281
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
package/src/todo-contracts.mjs
CHANGED
|
@@ -4,6 +4,10 @@ import { isCanonicalUtcTimestamp } from './timestamp-contract.mjs';
|
|
|
4
4
|
export const TODO_EVENT_KINDS = Object.freeze([
|
|
5
5
|
'plan_genesis', 'start', 'block', 'unblock', 'done', 'reopen',
|
|
6
6
|
'phase_review', 'phase_accept', 'phase_reject', 'phase_reopen',
|
|
7
|
+
// ADR 0148: 監査していない歴史を「監査なしで閉じた」として明示的に閉じるための専用kind。
|
|
8
|
+
// phase_review/accept/reject/reopenと同じv3 tail event shape(phase_id持ち)に収め、
|
|
9
|
+
// 新しいevent schema版は作らない。
|
|
10
|
+
'phase_close_unaudited',
|
|
7
11
|
]);
|
|
8
12
|
export const TODO_LIMITS = Object.freeze({
|
|
9
13
|
tasksPerPlan: 512,
|
|
@@ -345,6 +349,10 @@ function validPayload(event) {
|
|
|
345
349
|
'reason', 'target_decision_digest', 'override_reason',
|
|
346
350
|
]) && nullableText(payload.reason) && payload.reason !== null
|
|
347
351
|
&& isTodoDigest(payload.target_decision_digest) && nullableText(payload.override_reason);
|
|
352
|
+
// ADR 0148裁定1: 監査なしで閉じたことの理由は必須(payload.reason !== null)。証拠は無い
|
|
353
|
+
// ——監査していないというのが事実であり、evidenceを要求すると「監査した体」を装う経路になる。
|
|
354
|
+
if (event.kind === 'phase_close_unaudited') return exactRecord(payload, ['reason'])
|
|
355
|
+
&& nullableText(payload.reason) && payload.reason !== null;
|
|
348
356
|
return false;
|
|
349
357
|
}
|
|
350
358
|
|
|
@@ -392,7 +400,8 @@ function validPhaseStateMigration(value) {
|
|
|
392
400
|
&& (entry.state_policy === 'reset' ? entry.state === null
|
|
393
401
|
: exactRecord(entry.state, [
|
|
394
402
|
'status', 'review_event_digest', 'decision_event_digest', 'decision_evidence',
|
|
395
|
-
]) && ['locked', 'active', 'gate_ready', 'reviewing', 'accepted', 'rejected']
|
|
403
|
+
]) && ['locked', 'active', 'gate_ready', 'reviewing', 'accepted', 'rejected', 'closed_unaudited']
|
|
404
|
+
.includes(entry.state.status)
|
|
396
405
|
&& nullableDigest(entry.state.review_event_digest)
|
|
397
406
|
&& nullableDigest(entry.state.decision_event_digest)
|
|
398
407
|
&& (entry.state.decision_evidence === null || evidence(entry.state.decision_evidence))))
|
|
@@ -421,7 +430,8 @@ export function validateTodoEvent(value) {
|
|
|
421
430
|
]) && value.kind === 'plan_genesis' && value.task_id === null && value.phase_id === null
|
|
422
431
|
&& isTodoDigest(value.revision_digest) && validStateMigration(value.state_migration)
|
|
423
432
|
&& validPhaseStateMigration(value.phase_state_migration);
|
|
424
|
-
const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen']
|
|
433
|
+
const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
|
|
434
|
+
.includes(value?.kind);
|
|
425
435
|
return (v1 || v2 || v3 || v4) && isTodoIdentifier(value.project_id)
|
|
426
436
|
&& isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.plan_version)
|
|
427
437
|
&& isNonNegativeSafeInteger(value.sequence) && nullableDigest(value.previous_digest)
|
|
@@ -491,7 +501,8 @@ export function validateTodoSnapshot(value) {
|
|
|
491
501
|
&& value.phases.every((entry) => exactRecord(entry, [
|
|
492
502
|
'phase_id', 'status', 'review_event_digest', 'decision_event_digest', 'decision_evidence',
|
|
493
503
|
]) && isTodoIdentifier(entry.phase_id)
|
|
494
|
-
&& ['locked', 'active', 'gate_ready', 'reviewing', 'accepted', 'rejected']
|
|
504
|
+
&& ['locked', 'active', 'gate_ready', 'reviewing', 'accepted', 'rejected', 'closed_unaudited']
|
|
505
|
+
.includes(entry.status)
|
|
495
506
|
&& nullableDigest(entry.review_event_digest) && nullableDigest(entry.decision_event_digest)
|
|
496
507
|
&& (entry.decision_evidence === null || evidence(entry.decision_evidence)))
|
|
497
508
|
&& value.phases.every((entry, index) => index === 0
|
package/src/todo-store.mjs
CHANGED
|
@@ -217,12 +217,13 @@ async function readJournal(repoRoot, journalRef) {
|
|
|
217
217
|
const phaseTail = (event) => event.schema === 'lattice.todo_event.v3';
|
|
218
218
|
const legacyTail = (event) => event.schema === 'lattice.todo_event.v1';
|
|
219
219
|
// ADR 0147: genesisがv1/v2(phase無しplan)でも、暗黙のterminal-audit Phaseへの
|
|
220
|
-
// phase_review/phase_accept/phase_reject/phase_reopenだけは
|
|
221
|
-
//
|
|
220
|
+
// phase_review/phase_accept/phase_reject/phase_reopen/phase_close_unaudited(ADR 0148)だけは
|
|
221
|
+
// v3 tail eventとして混在を許す。task側のevent(start/done/block/unblock/reopen)は従来どおりv1のまま
|
|
222
222
|
// ——既存planの既存event bytesは1つも変わらない。新しく増えるのは、これまで
|
|
223
223
|
// phase無しplanには存在し得なかったphase_*event kindの受け皿だけである。
|
|
224
224
|
const implicitTerminalAuditTail = (event) => phaseTail(event)
|
|
225
|
-
&& ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen']
|
|
225
|
+
&& ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
|
|
226
|
+
.includes(event.kind);
|
|
226
227
|
const legacyOrImplicitPhaseTail = (event) => legacyTail(event) || implicitTerminalAuditTail(event);
|
|
227
228
|
if ((phaseSchema && events.some(({ schema }, index) => index === 0
|
|
228
229
|
? !['lattice.todo_event.v3', 'lattice.todo_event.v4'].includes(schema) : !phaseTail(events[index])))
|
|
@@ -271,7 +272,11 @@ function phasesOf(plan) {
|
|
|
271
272
|
|
|
272
273
|
function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
|
|
273
274
|
const state = phaseStates.get(phaseId);
|
|
274
|
-
|
|
275
|
+
// ADR 0148: closed_unauditedも他の終端状態と同じく確定済みとして扱う。ここへ足さないと、
|
|
276
|
+
// 全taskがdone(=構造的にgate_ready)のままなので、以後の呼び出しで毎回gate_readyへ
|
|
277
|
+
// 再導出されてしまい、記録した「監査なしで閉じた」がgantt折り畳み・phase_accept_dependencies
|
|
278
|
+
// 判定の上で一瞬で消える。
|
|
279
|
+
if (['reviewing', 'accepted', 'rejected', 'closed_unaudited'].includes(state.status)) return state.status;
|
|
275
280
|
const phase = phasesOf(plan).find((entry) => entry.phase_id === phaseId);
|
|
276
281
|
if (!phase.predecessor_phase_ids.every((id) => phaseStates.get(id)?.status === 'accepted')) return 'locked';
|
|
277
282
|
// 暗黙のterminal-audit Phaseはtask側にphase_idフィールドが無い(v1/v2/v3にはそもそも
|
|
@@ -303,6 +308,13 @@ function projectPhaseStates(plan, events, taskStates) {
|
|
|
303
308
|
const state = states.get(event.phase_id);
|
|
304
309
|
state.status = 'rejected'; state.decision_event_digest = event.event_digest;
|
|
305
310
|
state.decision_evidence = event.payload.decision_evidence;
|
|
311
|
+
} else if (event.kind === 'phase_close_unaudited') {
|
|
312
|
+
// ADR 0148: 監査していないので証拠(decision_evidence)は残さない。理由はevent payload
|
|
313
|
+
// 自身(decision_event_digestで指せるこの event)に記録済みで、accept/rejectと違い
|
|
314
|
+
// ここに複製しない。
|
|
315
|
+
const state = states.get(event.phase_id);
|
|
316
|
+
state.status = 'closed_unaudited'; state.decision_event_digest = event.event_digest;
|
|
317
|
+
state.decision_evidence = null;
|
|
306
318
|
} else if (event.kind === 'phase_reopen') {
|
|
307
319
|
Object.assign(states.get(event.phase_id), emptyPhaseState(event.phase_id));
|
|
308
320
|
}
|
|
@@ -428,8 +440,17 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
428
440
|
if (verifyEvidence) verifyEvidence(event.payload.decision_evidence);
|
|
429
441
|
state.status = 'rejected'; state.decision_event_digest = event.event_digest;
|
|
430
442
|
state.decision_evidence = event.payload.decision_evidence;
|
|
443
|
+
} else if (event.kind === 'phase_close_unaudited') {
|
|
444
|
+
// ADR 0148裁定3: reviewと同じ前提(gate_ready)を課す——所属ToDoが全てdoneでない段階では
|
|
445
|
+
// まだ監査の地点に到達していないので、監査なしで閉じることもできない。
|
|
446
|
+
if (currentStatus !== 'gate_ready') fail('STORE_INCONSISTENT', 'phase_gate_not_ready');
|
|
447
|
+
state.status = 'closed_unaudited'; state.decision_event_digest = event.event_digest;
|
|
448
|
+
state.decision_evidence = null;
|
|
431
449
|
} else {
|
|
432
|
-
|
|
450
|
+
// phase_reopen。ADR 0148裁定5: closed_unauditedもaccepted/rejectedと同じく
|
|
451
|
+
// reopenで初期状態へ戻せる——監査せずに閉じた工程を、後から本当に監査したくなった時に
|
|
452
|
+
// 永久に締め出さない。
|
|
453
|
+
if (!['accepted', 'rejected', 'closed_unaudited'].includes(currentStatus)
|
|
433
454
|
|| state.decision_event_digest !== event.payload.target_decision_digest) {
|
|
434
455
|
fail('STORE_INCONSISTENT', 'phase_reopen_binding_invalid');
|
|
435
456
|
}
|
|
@@ -516,14 +537,16 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
516
537
|
fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
|
|
517
538
|
}
|
|
518
539
|
{
|
|
519
|
-
// 暗黙のterminal-audit Phaseがacceptedの後にtaskだけを無警告でreopen
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
540
|
+
// 暗黙のterminal-audit Phaseがaccepted/closed_unauditedの後にtaskだけを無警告でreopen
|
|
541
|
+
// できると、監査済み(または監査なしで閉じた)まま(gantt上も畳まれたまま)裏で作業が
|
|
542
|
+
// 再開する抜け道になり、ADR 0147/0148の「監査の記録なしに閉じたことにさせない」を
|
|
543
|
+
// 潜脱する。v4/v5の既存Phaseと同じ規律を暗黙Phaseにも及ぼし、phase_reopenを先に
|
|
544
|
+
// 通させる。closed_unauditedをここへ足さないと、ADR 0148で新設した状態だけが
|
|
545
|
+
// このgateを素通りしてしまう。
|
|
523
546
|
const phaseId = isPhaselessTodoPlanSchema(plan.schema)
|
|
524
547
|
? TERMINAL_AUDIT_PHASE_ID
|
|
525
548
|
: plan.tasks.find(({ task_id }) => task_id === event.task_id).phase_id;
|
|
526
|
-
if (derivedPhaseStatus(plan, states, phaseStates, phaseId)
|
|
549
|
+
if (['accepted', 'closed_unaudited'].includes(derivedPhaseStatus(plan, states, phaseStates, phaseId))) {
|
|
527
550
|
fail('STORE_INCONSISTENT', 'task_reopen_requires_phase_reopen');
|
|
528
551
|
}
|
|
529
552
|
}
|
|
@@ -1174,7 +1197,8 @@ function nextEvent(input, storeMember) {
|
|
|
1174
1197
|
? { done_mode: 'authored', imported: false, evidence: input.payload.evidence }
|
|
1175
1198
|
: input.payload;
|
|
1176
1199
|
const phaseCapablePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema);
|
|
1177
|
-
const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen']
|
|
1200
|
+
const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
|
|
1201
|
+
.includes(input.kind);
|
|
1178
1202
|
// ADR 0147: phase無しplanでも、暗黙のterminal-audit Phaseへのphase_*eventだけは
|
|
1179
1203
|
// v3 tail eventとして書く(readJournalのlegacyOrImplicitPhaseTailが同じ規則で受理する)。
|
|
1180
1204
|
// task側のevent(start/done等)はphase無しplanのままv1で書き続ける——既存の
|
|
@@ -1246,8 +1270,12 @@ function resolveTargetedEvent(input, storeMember) {
|
|
|
1246
1270
|
};
|
|
1247
1271
|
}
|
|
1248
1272
|
if (input.kind === 'phase_reopen' && exactRecord(input.payload, ['reason', 'override_reason'])) {
|
|
1273
|
+
// ADR 0148裁定5: closed_unauditedもaccept/rejectと同じ「決定event」なので、reopenの
|
|
1274
|
+
// 結びつけ先として同列に探す。ここへ足し忘れると、監査なしで閉じたPhaseをreopenする
|
|
1275
|
+
// 手段が無くなる(target_decision_digestを解決できずphase_reopen_binding_invalidで拒否される)。
|
|
1249
1276
|
const target = [...storeMember.journal.events].reverse().find((event) => (
|
|
1250
|
-
['phase_accept', 'phase_reject'].includes(event.kind)
|
|
1277
|
+
['phase_accept', 'phase_reject', 'phase_close_unaudited'].includes(event.kind)
|
|
1278
|
+
&& event.phase_id === input.phase_id
|
|
1251
1279
|
));
|
|
1252
1280
|
if (target === undefined) fail('STORE_INCONSISTENT', 'phase_reopen_binding_invalid');
|
|
1253
1281
|
return { ...input, payload: { ...input.payload, target_decision_digest: target.event_digest } };
|