@quolu/lattice 0.58.4 → 0.59.1

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.mjs CHANGED
@@ -73,6 +73,26 @@ if (sensorRelaunchStatus !== null) {
73
73
  } catch (error) {
74
74
  process.exitCode = projectCliFailure(process.stderr, error);
75
75
  }
76
+ } else if (args.length === 7 && args[0] === 'plan' && args[1] === 'scope-review'
77
+ && args[2] === '--plan-input' && args[4] === '--review' && args[6] === '--json') {
78
+ const { runPlanScopeReview } = await import('../src/plan-scope-review.mjs');
79
+ const { projectCliFailure } = await import('../src/project-cli.mjs');
80
+ try {
81
+ process.exitCode = await runPlanScopeReview({
82
+ cwd: process.cwd(), planInputRef: args[3], reviewRef: args[5], stdout: process.stdout,
83
+ });
84
+ } catch (error) {
85
+ process.exitCode = projectCliFailure(process.stderr, error);
86
+ }
87
+ } else if (args.length === 4 && args[0] === 'plan' && args[1] === 'scope-review'
88
+ && args[2] === '--schema' && args[3] === '--json') {
89
+ const { runPlanScopeReviewSchema } = await import('../src/plan-scope-review.mjs');
90
+ const { projectCliFailure } = await import('../src/project-cli.mjs');
91
+ try {
92
+ process.exitCode = await runPlanScopeReviewSchema({ stdout: process.stdout });
93
+ } catch (error) {
94
+ process.exitCode = projectCliFailure(process.stderr, error);
95
+ }
76
96
  } else if (args.length === 2 && args[0] === 'factory-diagnostics' && args[1] === '--json') {
77
97
  const { buildFactoryDiagnostics } = await import('../src/factory-diagnostics.mjs');
78
98
  const diagnostics = await buildFactoryDiagnostics();
@@ -39,9 +39,9 @@
39
39
  },
40
40
  "forbidden_operations": {
41
41
  "type": "array",
42
- "minItems": 1,
43
42
  "maxItems": 256,
44
- "items": { "type": "string" }
43
+ "items": { "type": "string" },
44
+ "description": "Compatibility field. Lattice-produced packets use an empty array; operation authority belongs to the host."
45
45
  },
46
46
  "context_content_digest": { "$ref": "#/$defs/digest" },
47
47
  "packet_digest": { "$ref": "#/$defs/digest" }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/kitepon-rgb/Lattice/blob/main/docs/schemas/lattice.plan_create_input.v4.schema.json",
4
4
  "title": "lattice.plan_create_input.v4",
5
- "description": "Canonical initial authoring input for a phase-audited plan. Every task requires a design memo. あなたがこのToDoに対して、何も考えていないならば、設計メモに `NO_PLAN` と書いてください",
5
+ "description": "Canonical initial authoring input for a phase-audited plan. Every task requires a design memo. After the complete plan is written, run `lattice plan scope-review` and confirm that every task preserves an owner-supplied work specification without adding a product purpose. あなたがこのToDoに対して、何も考えていないならば、設計メモに `NO_PLAN` と書いてください",
6
6
  "$comment": "Runtime validation additionally enforces canonical JSON+LF bytes, sorted identifiers and edges, unique ids, combined task/phase acyclicity, byte bounds, non-empty design_memo, and input_digest.",
7
7
  "type": "object",
8
8
  "additionalProperties": false,
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/kitepon-rgb/Lattice/blob/main/docs/schemas/lattice.plan_scope_review.v1.schema.json",
4
+ "title": "lattice.plan_scope_review.v1",
5
+ "description": "AI-authored review performed after the complete plan is written and before it is registered. Every planned task is judged against the owner-supplied work specifications; task splitting and parallelization are allowed, but a new product purpose is not.",
6
+ "$comment": "Lattice validates complete task coverage, known work-spec references, verdict consistency, and digest binding. The operating AI owns the semantic judgment.",
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": [
10
+ "schema", "authoring_digest", "work_specs", "task_assessments", "verdict", "review_digest"
11
+ ],
12
+ "properties": {
13
+ "schema": { "const": "lattice.plan_scope_review.v1" },
14
+ "authoring_digest": { "$ref": "#/$defs/digest" },
15
+ "work_specs": {
16
+ "type": "array", "minItems": 1, "maxItems": 512,
17
+ "description": "The owner-supplied work specifications. Do not add inferred improvements as work specifications.",
18
+ "items": { "$ref": "#/$defs/workSpec" }
19
+ },
20
+ "task_assessments": {
21
+ "type": "array", "minItems": 1, "maxItems": 512,
22
+ "description": "Exactly one assessment for every task that will be registered. Use out_of_scope when the task creates a purpose not required by any work specification.",
23
+ "items": { "$ref": "#/$defs/taskAssessment" }
24
+ },
25
+ "verdict": { "enum": ["scope_preserved", "scope_mismatch"] },
26
+ "review_digest": { "$ref": "#/$defs/digest" }
27
+ },
28
+ "$defs": {
29
+ "identifier": { "type": "string", "pattern": "^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$" },
30
+ "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
31
+ "text": { "type": "string", "minLength": 1, "maxLength": 16384, "pattern": "\\S" },
32
+ "workSpec": {
33
+ "type": "object", "additionalProperties": false,
34
+ "required": ["work_spec_id", "requirement", "acceptance"],
35
+ "properties": {
36
+ "work_spec_id": { "$ref": "#/$defs/identifier" },
37
+ "requirement": { "$ref": "#/$defs/text" },
38
+ "acceptance": { "$ref": "#/$defs/text" }
39
+ }
40
+ },
41
+ "taskAssessment": {
42
+ "type": "object", "additionalProperties": false,
43
+ "required": ["task_id", "work_spec_ids", "judgment", "reason"],
44
+ "properties": {
45
+ "task_id": { "$ref": "#/$defs/identifier" },
46
+ "work_spec_ids": {
47
+ "type": "array", "maxItems": 512, "uniqueItems": true,
48
+ "items": { "$ref": "#/$defs/identifier" }
49
+ },
50
+ "judgment": { "enum": ["required", "out_of_scope"] },
51
+ "reason": { "$ref": "#/$defs/text" }
52
+ }
53
+ }
54
+ }
55
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/kitepon-rgb/Lattice/blob/main/docs/schemas/lattice.todo_extraction.v3.schema.json",
4
4
  "title": "lattice.todo_extraction.v3",
5
- "description": "AI-authored ToDo migration artifact. Every task carries a non-blank Markdown design_memo. If no plan exists, write NO_PLAN explicitly.",
5
+ "description": "AI-authored ToDo migration artifact. Every task carries a non-blank Markdown design_memo. After the complete plan is written, run `lattice plan scope-review` and confirm that every registered task preserves an owner-supplied work specification without adding a product purpose. If no plan exists, write NO_PLAN explicitly.",
6
6
  "$comment": "あなたがこのToDoに対して、何も考えていないならば、設計メモに `NO_PLAN` と書いてください。Runtime validation also enforces UTF-8 byte bounds, exact keys, strict sort order, referential integrity, disposition consistency, and the canonical self-digest.",
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.58.4",
3
+ "version": "0.59.1",
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",
@@ -29,6 +29,7 @@
29
29
  "docs/schemas/lattice.plan_create_input.v2.schema.json",
30
30
  "docs/schemas/lattice.plan_create_input.v3.schema.json",
31
31
  "docs/schemas/lattice.plan_create_input.v4.schema.json",
32
+ "docs/schemas/lattice.plan_scope_review.v1.schema.json",
32
33
  "docs/schemas/lattice.todo_revision.v2.schema.json",
33
34
  "docs/schemas/lattice.todo_revision_set.v3.schema.json",
34
35
  "docs/schemas/lattice.phase_todo_revision.v3.schema.json",
package/src/cli-help.mjs CHANGED
@@ -202,6 +202,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
202
202
  'session-context': 'session-context --json',
203
203
  'plan create': 'plan create --input <file> [--serialization-reviewed] | --schema --json | --schema-version <1|2|3|4> --json',
204
204
  'plan show': 'plan show <plan_key> --json',
205
+ 'plan scope-review': 'plan scope-review --plan-input <plan-create-or-extraction.json> --review <scope-review.json> --json | --schema --json',
205
206
  'plan compile': 'plan compile --request <request.json> | --schema --json',
206
207
  'plan verify': 'plan verify --request <request.json> --plan <plan.json>',
207
208
  'run start': 'run start --request <request.json> --executor <adapter>'
@@ -262,7 +263,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
262
263
  'todo retract': 'todo retract --plan <key> --task <id> --reason <text>',
263
264
  'todo block': 'todo block --plan <key> --task <id> --reason <text>',
264
265
  'todo unblock': 'todo unblock --plan <key> --task <id>',
265
- 'todo done': 'todo done --plan <key> --task <id> --evidence <file>',
266
+ 'todo done': 'todo done --plan <key> --task <id> --evidence <file> [--test-result <markdown-file>]',
266
267
  'todo reopen': 'todo reopen --plan <key> --task <id> --reason <text> [--override-reason <text>]',
267
268
  'todo evidence': 'todo evidence promote --plan <key> --task <id> --evidence <file>',
268
269
  'todo evidence promote': 'todo evidence promote --plan <key> --task <id> --evidence <file>',
@@ -0,0 +1,211 @@
1
+ import { constants as fsConstants } from 'node:fs';
2
+ import { lstat, open, realpath, readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { gitSync } from './git-process.mjs';
6
+ import {
7
+ exactRecord,
8
+ isTodoDigest,
9
+ isTodoIdentifier,
10
+ isTodoRef,
11
+ todoSelfDigest,
12
+ } from './todo-contracts.mjs';
13
+ import { TodoStoreError } from './todo-store.mjs';
14
+
15
+ const REVIEW_SCHEMA = 'lattice.plan_scope_review.v1';
16
+ const RESULT_SCHEMA = 'lattice.plan_scope_review_result.v1';
17
+ const PLAN_CREATE_SCHEMA = 'lattice.plan_create_input.v4';
18
+ const TODO_EXTRACTION_SCHEMA = 'lattice.todo_extraction.v3';
19
+ const MAX_INPUT_BYTES = 8_388_608;
20
+ const JUDGMENTS = new Set(['required', 'out_of_scope']);
21
+
22
+ function boundedText(value) {
23
+ return typeof value === 'string' && value.trim().length > 0
24
+ && Buffer.byteLength(value) <= 16_384;
25
+ }
26
+
27
+ function sortedStrictly(values, key = (value) => value) {
28
+ return values.every((value, index) => index === 0 || key(values[index - 1]) < key(value));
29
+ }
30
+
31
+ function within(root, candidate) {
32
+ return candidate.startsWith(`${root}${path.sep}`);
33
+ }
34
+
35
+ function resolveRepoRoot(cwd) {
36
+ try {
37
+ return path.resolve(gitSync(['rev-parse', '--show-toplevel'], {
38
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
39
+ }).replace(/\r?\n$/u, ''));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ async function readJsonWithinRepo(repoRoot, inputRef) {
46
+ if (!isTodoRef(inputRef)) {
47
+ throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined,
48
+ { input_ref: inputRef });
49
+ }
50
+ const canonicalRoot = await realpath(repoRoot);
51
+ const absolute = path.resolve(canonicalRoot, inputRef);
52
+ if (!within(canonicalRoot, absolute)) {
53
+ throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined,
54
+ { input_ref: inputRef });
55
+ }
56
+ let stats;
57
+ try { stats = await lstat(absolute); } catch {
58
+ throw new TodoStoreError('INPUT_UNREADABLE', 'input_unreadable', undefined,
59
+ { input_ref: inputRef });
60
+ }
61
+ if (!stats.isFile() || stats.isSymbolicLink() || stats.size > MAX_INPUT_BYTES
62
+ || await realpath(absolute) !== absolute) {
63
+ throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_unsafe', undefined,
64
+ { input_ref: inputRef });
65
+ }
66
+ let value;
67
+ try { value = JSON.parse(await readFile(absolute, 'utf8')); } catch {
68
+ throw new TodoStoreError('INPUT_INVALID', 'input_json_invalid', undefined,
69
+ { input_ref: inputRef });
70
+ }
71
+ return value;
72
+ }
73
+
74
+ function authoredPlan(value) {
75
+ const digestField = value?.schema === PLAN_CREATE_SCHEMA ? 'input_digest'
76
+ : value?.schema === TODO_EXTRACTION_SCHEMA ? 'extraction_digest' : null;
77
+ if (digestField === null) {
78
+ throw new TodoStoreError('PLAN_SCOPE_REVIEW_INVALID', 'authoring_schema_unsupported', undefined, {
79
+ pointer: '/schema', expected: [PLAN_CREATE_SCHEMA, TODO_EXTRACTION_SCHEMA],
80
+ actual: typeof value?.schema === 'string' ? value.schema : null,
81
+ });
82
+ }
83
+ if (!isTodoDigest(value[digestField])
84
+ || value[digestField] !== todoSelfDigest(value, digestField)) {
85
+ throw new TodoStoreError('PLAN_SCOPE_REVIEW_INVALID', 'authoring_digest_mismatch', undefined, {
86
+ pointer: `/${digestField}`,
87
+ });
88
+ }
89
+ const selected = value.schema === TODO_EXTRACTION_SCHEMA
90
+ ? value.tasks?.filter((task) => typeof task?.disposition === 'string'
91
+ && task.disposition.startsWith('register_'))
92
+ : value.tasks;
93
+ if (!Array.isArray(selected) || selected.length === 0
94
+ || selected.some((task) => !isTodoIdentifier(task?.task_id))) {
95
+ throw new TodoStoreError('PLAN_SCOPE_REVIEW_INVALID', 'authoring_tasks_invalid', undefined, {
96
+ pointer: '/tasks',
97
+ });
98
+ }
99
+ const taskIds = selected.map(({ task_id: taskId }) => taskId).sort();
100
+ if (new Set(taskIds).size !== taskIds.length) {
101
+ throw new TodoStoreError('PLAN_SCOPE_REVIEW_INVALID', 'authoring_task_ids_duplicate', undefined, {
102
+ pointer: '/tasks',
103
+ });
104
+ }
105
+ return { schema: value.schema, digest: value[digestField], taskIds };
106
+ }
107
+
108
+ function invalid(reason, pointer, detail = {}) {
109
+ throw new TodoStoreError('PLAN_SCOPE_REVIEW_INVALID', reason, undefined, { pointer, ...detail });
110
+ }
111
+
112
+ function validateReview(review, authored) {
113
+ if (!exactRecord(review, [
114
+ 'schema', 'authoring_digest', 'work_specs', 'task_assessments', 'verdict', 'review_digest',
115
+ ]) || review.schema !== REVIEW_SCHEMA) invalid('review_schema_invalid', '/schema');
116
+ if (!isTodoDigest(review.authoring_digest) || review.authoring_digest !== authored.digest) {
117
+ invalid('review_authoring_digest_mismatch', '/authoring_digest', {
118
+ expected: authored.digest, actual: review.authoring_digest ?? null,
119
+ });
120
+ }
121
+ if (!isTodoDigest(review.review_digest)
122
+ || review.review_digest !== todoSelfDigest(review, 'review_digest')) {
123
+ invalid('review_digest_mismatch', '/review_digest');
124
+ }
125
+ if (!Array.isArray(review.work_specs) || review.work_specs.length === 0
126
+ || !review.work_specs.every((spec) => exactRecord(spec, [
127
+ 'work_spec_id', 'requirement', 'acceptance',
128
+ ]) && isTodoIdentifier(spec.work_spec_id) && boundedText(spec.requirement)
129
+ && boundedText(spec.acceptance))
130
+ || !sortedStrictly(review.work_specs, ({ work_spec_id: id }) => id)) {
131
+ invalid('work_specs_invalid', '/work_specs');
132
+ }
133
+ const workSpecIds = new Set(review.work_specs.map(({ work_spec_id: id }) => id));
134
+ if (!Array.isArray(review.task_assessments)
135
+ || !review.task_assessments.every((assessment) => exactRecord(assessment, [
136
+ 'task_id', 'work_spec_ids', 'judgment', 'reason',
137
+ ]) && isTodoIdentifier(assessment.task_id) && Array.isArray(assessment.work_spec_ids)
138
+ && assessment.work_spec_ids.every(isTodoIdentifier)
139
+ && sortedStrictly(assessment.work_spec_ids) && JUDGMENTS.has(assessment.judgment)
140
+ && boundedText(assessment.reason)
141
+ && (assessment.judgment === 'required'
142
+ ? assessment.work_spec_ids.length > 0 : assessment.work_spec_ids.length === 0)
143
+ && assessment.work_spec_ids.every((id) => workSpecIds.has(id)))
144
+ || !sortedStrictly(review.task_assessments, ({ task_id: id }) => id)) {
145
+ invalid('task_assessments_invalid', '/task_assessments');
146
+ }
147
+ const assessedTaskIds = review.task_assessments.map(({ task_id: id }) => id);
148
+ if (assessedTaskIds.length !== authored.taskIds.length
149
+ || assessedTaskIds.some((id, index) => id !== authored.taskIds[index])) {
150
+ invalid('task_assessments_incomplete', '/task_assessments', {
151
+ expected_task_ids: authored.taskIds, actual_task_ids: assessedTaskIds,
152
+ });
153
+ }
154
+ const outOfScopeTaskIds = review.task_assessments
155
+ .filter(({ judgment }) => judgment === 'out_of_scope')
156
+ .map(({ task_id: id }) => id);
157
+ const covered = new Set(review.task_assessments.flatMap(({ work_spec_ids: ids }) => ids));
158
+ const uncoveredWorkSpecIds = [...workSpecIds].filter((id) => !covered.has(id));
159
+ const expectedVerdict = outOfScopeTaskIds.length === 0 && uncoveredWorkSpecIds.length === 0
160
+ ? 'scope_preserved' : 'scope_mismatch';
161
+ if (review.verdict !== expectedVerdict) {
162
+ invalid('review_verdict_inconsistent', '/verdict', {
163
+ expected: expectedVerdict, actual: review.verdict ?? null,
164
+ });
165
+ }
166
+ return { outOfScopeTaskIds, uncoveredWorkSpecIds };
167
+ }
168
+
169
+ export function evaluatePlanScopeReview(planInput, review) {
170
+ const authored = authoredPlan(planInput);
171
+ const findings = validateReview(review, authored);
172
+ const result = {
173
+ schema: RESULT_SCHEMA,
174
+ authoring_schema: authored.schema,
175
+ authoring_digest: authored.digest,
176
+ work_spec_count: review.work_specs.length,
177
+ reviewed_task_count: review.task_assessments.length,
178
+ verdict: review.verdict,
179
+ accepted: review.verdict === 'scope_preserved',
180
+ out_of_scope_task_ids: findings.outOfScopeTaskIds,
181
+ uncovered_work_spec_ids: findings.uncoveredWorkSpecIds,
182
+ review_digest: review.review_digest,
183
+ result_digest: '',
184
+ };
185
+ result.result_digest = todoSelfDigest(result, 'result_digest');
186
+ return result;
187
+ }
188
+
189
+ export async function runPlanScopeReview({ cwd, planInputRef, reviewRef, stdout }) {
190
+ const repoRoot = resolveRepoRoot(cwd);
191
+ if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
192
+ const [planInput, review] = await Promise.all([
193
+ readJsonWithinRepo(repoRoot, planInputRef), readJsonWithinRepo(repoRoot, reviewRef),
194
+ ]);
195
+ const result = evaluatePlanScopeReview(planInput, review);
196
+ stdout.write(`${JSON.stringify(result)}\n`);
197
+ return result.accepted ? 0 : 1;
198
+ }
199
+
200
+ export async function runPlanScopeReviewSchema({ stdout }) {
201
+ const schemaUrl = new URL('../docs/schemas/lattice.plan_scope_review.v1.schema.json', import.meta.url);
202
+ const handle = await open(schemaUrl, fsConstants.O_RDONLY);
203
+ try {
204
+ const schema = JSON.parse(await handle.readFile('utf8'));
205
+ if (schema?.title !== REVIEW_SCHEMA) throw new TypeError('bundled plan scope review schema invalid');
206
+ stdout.write(`${JSON.stringify(schema)}\n`);
207
+ return 0;
208
+ } finally {
209
+ await handle.close();
210
+ }
211
+ }
@@ -642,7 +642,7 @@ export function validateExecutorPacket(value) {
642
642
  && identifier(packet.plan_ref)
643
643
  && nonNegativeInteger(packet.plan_epoch)
644
644
  && boundedArray(packet.verifier_refs, (entry) => typeof entry === 'string')
645
- && boundedArray(packet.forbidden_operations, (entry) => typeof entry === 'string', { min: 1 })
645
+ && boundedArray(packet.forbidden_operations, (entry) => typeof entry === 'string')
646
646
  && packet.context_content_digest === computeContextContentDigest(packet)
647
647
  && selfDigestValid(packet, 'packet_digest')
648
648
  ));
@@ -32,18 +32,10 @@ import {
32
32
 
33
33
  const ENGINE_ACTOR = 'lattice-runtime';
34
34
  /**
35
- * workerへ配る禁止操作。
36
- *
37
- * `commit`は含めない。自分の隔離worktreeでdetached HEADへ進めるcommitは、canonical branchを
38
- * 動かさず外部へ効果を出さない一方で、進行中の成果を耐久化し、diff観測が生み出した木そのものを
39
- * 縛れるようにする(ADR 0139)。
40
- *
41
- * 禁止のままにするのは、HEADをbaseの子孫から外す操作と、外部へ効果を出す操作である。
42
- * 前者は観測の前提を壊し、後者は承認なしに行わないという公開契約に当たる。
35
+ * v1 packetの互換field。操作権限はhostへ委ね、Latticeからは禁止操作を配らない。
36
+ * worktree観測に必要な前提は、実際のHEADとdiffを観測して判定する(ADR 0178)。
43
37
  */
44
- const FORBIDDEN_OPERATIONS = Object.freeze([
45
- 'push', 'branch', 'merge', 'rebase', 'reset', 'stash',
46
- ]);
38
+ const FORBIDDEN_OPERATIONS = Object.freeze([]);
47
39
 
48
40
  function fail(reason) {
49
41
  throw new TypeError(`runtime engine契約違反: ${reason}`);
@@ -57,7 +57,7 @@ export function validateRunWorkOrder(value) {
57
57
  && GIT_SHA1.test(value.base_sha ?? '')
58
58
  && stringArray(value.scope_writes, { paths: true })
59
59
  && stringArray(value.verifier_refs)
60
- && stringArray(value.forbidden_operations, { min: 1 })
60
+ && stringArray(value.forbidden_operations)
61
61
  && SHA256.test(value.packet_digest ?? '')
62
62
  && SHA256.test(value.order_digest ?? '')
63
63
  && selfDigest(value, 'order_digest') === value.order_digest;
package/src/todo-cli.mjs CHANGED
@@ -10,6 +10,7 @@ import { parseTree } from 'jsonc-parser';
10
10
  import {
11
11
  TODO_COORDINATION_MODES,
12
12
  TODO_DESIGN_MEMO_PROMPT,
13
+ TODO_TEST_RESULT_CONTRACT_ID,
13
14
  canonicalizeTodoArtifact,
14
15
  digestTodoArtifact,
15
16
  exactRecord,
@@ -18,6 +19,7 @@ import {
18
19
  isTodoDesignMemo,
19
20
  isTodoIdentifier,
20
21
  isTodoRef,
22
+ isTodoTestResult,
21
23
  todoSelfDigest,
22
24
  validateEvidenceDescriptor,
23
25
  } from './todo-contracts.mjs';
@@ -333,6 +335,16 @@ async function readNoteTextInput(repoRoot, inputRef) {
333
335
  catch { throw new TodoStoreError('INPUT_UNREADABLE', 'note_input_invalid_utf8'); }
334
336
  }
335
337
 
338
+ async function readTestResultInput(repoRoot, inputRef) {
339
+ const result = await readNoteTextInput(repoRoot, inputRef);
340
+ if (!isTodoTestResult(result)) {
341
+ throw new TodoStoreError('INVALID_TEST_RESULT', 'test_result_must_be_non_empty_markdown', undefined, {
342
+ contract_id: TODO_TEST_RESULT_CONTRACT_ID,
343
+ });
344
+ }
345
+ return result;
346
+ }
347
+
336
348
  function taskRef(plan, taskId) {
337
349
  return { project_id: plan.project_id, plan_key: plan.plan_key, task_id: taskId };
338
350
  }
@@ -650,12 +662,15 @@ function terminalAuditDoneAdvisory(plan, phases) {
650
662
 
651
663
  async function mutate({
652
664
  repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
653
- noteContext = null, structureContext = null,
665
+ noteContext = null, structureContext = null, testResultRef = null,
654
666
  }) {
655
667
  const actor = mutationActor(env);
656
668
  const evidence = evidenceRef === null ? null : await readEvidenceInput(repoRoot, evidenceRef);
669
+ const testResult = testResultRef === null ? null : await readTestResultInput(repoRoot, testResultRef);
657
670
  let eventPayload = payload;
658
- if (kind === 'done' && payload === 'authored') eventPayload = { evidence };
671
+ if (kind === 'done' && payload === 'authored') {
672
+ eventPayload = { evidence, ...(testResult === null ? {} : { test_result: testResult }) };
673
+ }
659
674
  if (kind === 'done' && payload === 'evidence_promotion') {
660
675
  eventPayload = { done_mode: 'evidence_promotion', imported: true, evidence };
661
676
  }
@@ -1672,7 +1687,7 @@ async function todoDetail({ repoRoot, planKey, taskId }) {
1672
1687
  repoRoot, store, planKey, taskId: task.task_id,
1673
1688
  });
1674
1689
  const result = {
1675
- schema: 'lattice.todo_detail_result.v2',
1690
+ schema: 'lattice.todo_detail_result.v3',
1676
1691
  project_id: store.project_id,
1677
1692
  plan_key: planKey,
1678
1693
  plan_version: member.plan.plan_version,
@@ -3926,12 +3941,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3926
3941
  && argv[3] === '--task' && isTodoIdentifier(argv[4])) {
3927
3942
  action = (repoRoot) => mutate({ repoRoot, env, planKey: argv[2], taskId: argv[4],
3928
3943
  kind: 'unblock', payload: {}, evidenceRef: null });
3929
- } else if (argv.length === 7 && argv[0] === 'done'
3944
+ } else if ((argv.length === 7 || argv.length === 9) && argv[0] === 'done'
3930
3945
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3931
3946
  && argv[3] === '--task' && isTodoIdentifier(argv[4])
3932
- && argv[5] === '--evidence' && isTodoRef(argv[6])) {
3947
+ && argv[5] === '--evidence' && isTodoRef(argv[6])
3948
+ && (argv.length === 7 || (argv[7] === '--test-result' && isTodoRef(argv[8])))) {
3933
3949
  action = (repoRoot) => mutate({ repoRoot, env, planKey: argv[2], taskId: argv[4],
3934
- kind: 'done', payload: 'authored', evidenceRef: argv[6] });
3950
+ kind: 'done', payload: 'authored', evidenceRef: argv[6], testResultRef: argv[8] ?? null });
3935
3951
  } else if (argv.length === 8 && argv[0] === 'evidence' && argv[1] === 'promote'
3936
3952
  && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3937
3953
  && argv[4] === '--task' && isTodoIdentifier(argv[5])
@@ -62,10 +62,14 @@ const CONTROL = /[\u0000-\u001f\u007f]/u;
62
62
  const NOTE_FORBIDDEN_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
63
63
 
64
64
  export const TODO_DESIGN_MEMO_PROMPT = 'あなたがこのToDoに対して、何も考えていないならば、設計メモに `NO_PLAN` と書いてください';
65
+ export const TODO_TEST_RESULT_CONTRACT_ID = 'lattice.todo_test_result.v1';
65
66
 
66
67
  export const isTodoDigest = (value) => typeof value === 'string' && DIGEST.test(value);
67
68
  export const isTodoIdentifier = (value) => typeof value === 'string' && IDENTIFIER.test(value);
68
69
  export const isNonNegativeSafeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
70
+ export const isTodoTestResult = (value) => typeof value === 'string' && value.trim().length > 0
71
+ && Buffer.byteLength(value, 'utf8') <= TODO_LIMITS.noteBodyBytes
72
+ && !NOTE_FORBIDDEN_CONTROL.test(value);
69
73
 
70
74
  export function isStrictTodoTimestamp(value) {
71
75
  return isCanonicalUtcTimestamp(value);
@@ -527,8 +531,10 @@ function validPayload(event) {
527
531
  if (event.kind === 'block') return exactRecord(payload, ['reason']) && nullableText(payload.reason) && payload.reason !== null;
528
532
  if (event.kind === 'unblock') return exactRecord(payload, []);
529
533
  if (event.kind === 'done' && payload?.done_mode === 'authored') {
530
- return exactRecord(payload, ['done_mode', 'imported', 'evidence'])
531
- && payload.imported === false && evidence(payload.evidence);
534
+ return (exactRecord(payload, ['done_mode', 'imported', 'evidence'])
535
+ || exactRecord(payload, ['done_mode', 'imported', 'evidence', 'test_result']))
536
+ && payload.imported === false && evidence(payload.evidence)
537
+ && (payload.test_result === undefined || isTodoTestResult(payload.test_result));
532
538
  }
533
539
  if (event.kind === 'done' && payload?.done_mode === 'historical_import') {
534
540
  return exactRecord(payload, ['done_mode', 'imported', 'status', 'completed_at', 'evidence'])
@@ -569,22 +575,29 @@ function validPayload(event) {
569
575
  }
570
576
 
571
577
  function validCarriedState(value) {
572
- if (!exactRecord(value, [
578
+ const legacy = exactRecord(value, [
573
579
  'status', 'started_at', 'done_at', 'blocked_reason', 'evidence', 'imported',
574
- ]) || !['pending', 'in-progress', 'blocked', 'done'].includes(value.status)
580
+ ]);
581
+ const resultAware = exactRecord(value, [
582
+ 'status', 'started_at', 'done_at', 'blocked_reason', 'evidence', 'imported', 'test_result',
583
+ ]);
584
+ if ((!legacy && !resultAware) || !['pending', 'in-progress', 'blocked', 'done'].includes(value.status)
575
585
  || (value.started_at !== null && !isStrictTodoTimestamp(value.started_at))
576
586
  || (value.done_at !== null && !isStrictTodoTimestamp(value.done_at))
577
587
  || (value.blocked_reason !== null && !nullableText(value.blocked_reason))
578
588
  || typeof value.imported !== 'boolean') return false;
589
+ const testResult = resultAware ? value.test_result : null;
590
+ if (testResult !== null && !isTodoTestResult(testResult)) return false;
579
591
  if (value.status === 'pending') return value.started_at === null && value.done_at === null
580
- && value.blocked_reason === null && value.evidence === null && value.imported === false;
592
+ && value.blocked_reason === null && value.evidence === null && value.imported === false
593
+ && testResult === null;
581
594
  const activeEvidenceValid = value.imported
582
595
  ? value.evidence === null || validateTodoImportSource(value.evidence)
583
596
  : value.evidence === null;
584
597
  if (value.status === 'in-progress') return value.done_at === null && value.blocked_reason === null
585
- && activeEvidenceValid;
598
+ && activeEvidenceValid && testResult === null;
586
599
  if (value.status === 'blocked') return value.done_at === null && value.blocked_reason !== null
587
- && activeEvidenceValid;
600
+ && activeEvidenceValid && testResult === null;
588
601
  return value.blocked_reason === null && value.evidence !== null
589
602
  && (value.imported ? validateTodoImportSource(value.evidence) : evidence(value.evidence));
590
603
  }
@@ -700,20 +713,35 @@ export function validateTodoSnapshot(value) {
700
713
  'schema', 'project_id', 'plan_key', 'plan_version', 'projection_version', 'through_sequence',
701
714
  'journal_head_digest', 'tasks', 'phases', 'snapshot_digest',
702
715
  ]);
703
- return (v1 || v2) && isTodoIdentifier(value.project_id)
716
+ const v3 = value?.schema === 'lattice.todo_snapshot.v3' && exactRecord(value, [
717
+ 'schema', 'project_id', 'plan_key', 'plan_version', 'projection_version', 'through_sequence',
718
+ 'journal_head_digest', 'tasks', 'snapshot_digest',
719
+ ]);
720
+ const v4 = value?.schema === 'lattice.todo_snapshot.v4' && exactRecord(value, [
721
+ 'schema', 'project_id', 'plan_key', 'plan_version', 'projection_version', 'through_sequence',
722
+ 'journal_head_digest', 'tasks', 'phases', 'snapshot_digest',
723
+ ]);
724
+ const resultAware = v3 || v4;
725
+ const phaseAware = v2 || v4;
726
+ return (v1 || v2 || v3 || v4) && isTodoIdentifier(value.project_id)
704
727
  && isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.plan_version)
705
- && value.projection_version === (v1 ? 1 : 2) && isNonNegativeSafeInteger(value.through_sequence)
728
+ && value.projection_version === (v1 ? 1 : v2 ? 2 : v3 ? 3 : 4)
729
+ && isNonNegativeSafeInteger(value.through_sequence)
706
730
  && isTodoDigest(value.journal_head_digest) && Array.isArray(value.tasks)
707
731
  && value.tasks.length > 0 && value.tasks.length <= TODO_LIMITS.tasksPerPlan
708
732
  && value.tasks.every((entry) => exactRecord(entry, [
709
733
  'task_id', 'status', 'started_at', 'done_at', 'blocked_reason', 'evidence', 'evidence_unverified', 'imported',
734
+ ...(resultAware ? ['test_result'] : []),
710
735
  ]) && isTodoIdentifier(entry.task_id) && ['pending', 'in-progress', 'blocked', 'done'].includes(entry.status)
711
736
  && (entry.started_at === null || isStrictTodoTimestamp(entry.started_at))
712
737
  && (entry.done_at === null || isStrictTodoTimestamp(entry.done_at)) && nullableText(entry.blocked_reason)
713
738
  && (entry.evidence === null || evidence(entry.evidence) || validateTodoImportSource(entry.evidence))
714
- && typeof entry.evidence_unverified === 'boolean' && typeof entry.imported === 'boolean')
739
+ && typeof entry.evidence_unverified === 'boolean' && typeof entry.imported === 'boolean'
740
+ && (!resultAware || (entry.status === 'done'
741
+ ? entry.test_result === null || isTodoTestResult(entry.test_result)
742
+ : entry.test_result === null)))
715
743
  && value.tasks.every((entry, index) => index === 0 || value.tasks[index - 1].task_id < entry.task_id)
716
- && (!v2 || (Array.isArray(value.phases) && value.phases.length > 0
744
+ && (!phaseAware || (Array.isArray(value.phases) && value.phases.length > 0
717
745
  && value.phases.every((entry) => exactRecord(entry, [
718
746
  'phase_id', 'status', 'review_event_digest', 'decision_event_digest', 'decision_evidence',
719
747
  ]) && isTodoIdentifier(entry.phase_id)
@@ -302,7 +302,7 @@ async function readPlanScopedJournal(repoRoot, journalRef) {
302
302
 
303
303
  function taskState(taskId) {
304
304
  return { task_id: taskId, status: 'pending', started_at: null, done_at: null, blocked_reason: null,
305
- evidence: null, evidence_unverified: false, imported: false };
305
+ evidence: null, evidence_unverified: false, imported: false, test_result: null };
306
306
  }
307
307
 
308
308
  function emptyPhaseState(phaseId) {
@@ -685,6 +685,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
685
685
  plan_key: plan.plan_key, task_id: event.task_id,
686
686
  });
687
687
  state.status = 'done'; state.done_at = event.recorded_at; state.evidence = event.payload.evidence;
688
+ state.test_result = event.payload.test_result ?? null;
688
689
  state.imported = false;
689
690
  completion.set(event.task_id, { mode: 'authored', completed_at: event.recorded_at });
690
691
  } else if (event.payload.done_mode === 'historical_import') {
@@ -731,7 +732,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
731
732
  }
732
733
  const startedSuccessor = localSuccessors(plan, event.task_id).some((id) => states.get(id).status !== 'pending');
733
734
  if (startedSuccessor && event.payload.override_reason === null) fail('STORE_INCONSISTENT', 'reopen_has_started_successor');
734
- state.status = 'in-progress'; state.done_at = null; state.evidence = null;
735
+ state.status = 'in-progress'; state.done_at = null; state.evidence = null; state.test_result = null;
735
736
  completion.delete(event.task_id);
736
737
  }
737
738
  }
@@ -774,11 +775,21 @@ function snapshotFor(plan, events, tasks) {
774
775
  // ——ADR 0147以降の暗黙terminal-audit Phaseの状態は、この関数の外(readTodoStore/
775
776
  // appendTodoEventが返す`phases`という導出ビュー)で供給する。
776
777
  const phasePlan = isPhaseTodoPlanSchema(plan.schema);
778
+ const resultAware = events.some((event) => event.kind === 'done'
779
+ && typeof event.payload?.test_result === 'string')
780
+ || events.some((event) => event.kind === 'plan_genesis'
781
+ && event.state_migration?.some(({ state }) => typeof state?.test_result === 'string'));
782
+ const snapshotTasks = resultAware ? tasks.map((task) => ({ ...task, test_result: task.test_result ?? null }))
783
+ : tasks.map(({ test_result: _testResult, ...task }) => task);
777
784
  const snapshot = {
778
- schema: phasePlan ? 'lattice.todo_snapshot.v2' : 'lattice.todo_snapshot.v1',
785
+ schema: resultAware
786
+ ? (phasePlan ? 'lattice.todo_snapshot.v4' : 'lattice.todo_snapshot.v3')
787
+ : (phasePlan ? 'lattice.todo_snapshot.v2' : 'lattice.todo_snapshot.v1'),
779
788
  project_id: plan.project_id, plan_key: plan.plan_key,
780
- plan_version: plan.plan_version, projection_version: phasePlan ? 2 : 1, through_sequence: head.sequence,
781
- journal_head_digest: head.event_digest, tasks, snapshot_digest: '',
789
+ plan_version: plan.plan_version,
790
+ projection_version: resultAware ? (phasePlan ? 4 : 3) : (phasePlan ? 2 : 1),
791
+ through_sequence: head.sequence,
792
+ journal_head_digest: head.event_digest, tasks: snapshotTasks, snapshot_digest: '',
782
793
  ...(phasePlan ? { phases: projectPhaseStates(plan, events,
783
794
  new Map(tasks.map((task) => [task.task_id, task]))) } : {}),
784
795
  };
@@ -1515,9 +1526,14 @@ export async function rebuildTodoSnapshot(options = {}) {
1515
1526
 
1516
1527
  function nextEvent(input, storeMember) {
1517
1528
  const previous = storeMember.journal.events.at(-1);
1518
- const payload = input.kind === 'done' && exactRecord(input.payload, ['evidence'])
1519
- ? { done_mode: 'authored', imported: false, evidence: input.payload.evidence }
1520
- : input.payload;
1529
+ let payload = input.payload;
1530
+ if (input.kind === 'done' && (exactRecord(input.payload, ['evidence'])
1531
+ || exactRecord(input.payload, ['evidence', 'test_result']))) {
1532
+ payload = {
1533
+ done_mode: 'authored', imported: false, evidence: input.payload.evidence,
1534
+ ...(input.payload.test_result === undefined ? {} : { test_result: input.payload.test_result }),
1535
+ };
1536
+ }
1521
1537
  const phaseCapablePlan = isPhaseTodoPlanSchema(storeMember.plan.schema);
1522
1538
  const phaseKind = ['phase_review', 'phase_accept', 'phase_reject', 'phase_reopen', 'phase_close_unaudited']
1523
1539
  .includes(input.kind);
@@ -2613,6 +2629,7 @@ function stateMigrationFor(previous, revision) {
2613
2629
  return { ...migration, state: {
2614
2630
  status: state.status, started_at: state.started_at, done_at: state.done_at,
2615
2631
  blocked_reason: state.blocked_reason, evidence: state.evidence, imported: state.imported,
2632
+ ...(typeof state.test_result === 'string' ? { test_result: state.test_result } : {}),
2616
2633
  } };
2617
2634
  });
2618
2635
  }