@quolu/lattice 0.39.0 → 0.39.2
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 +4 -1
- package/docs/schemas/lattice.todo_extraction.v3.schema.json +1 -1
- package/docs/schemas/lattice.todo_revision.v2.schema.json +1 -1
- package/package.json +1 -1
- package/src/cli-help.mjs +4 -1
- package/src/project-cli.mjs +46 -8
- package/src/runtime-cli.mjs +20 -0
- package/src/todo-cli.mjs +39 -14
- package/src/todo-contracts.mjs +28 -3
- package/src/todo-dashboard-registry.mjs +11 -1
- package/src/todo-gantt-html-independence.mjs +4 -2
- package/src/todo-gantt-html-shared.mjs +33 -9
- package/src/todo-gantt-html.mjs +9 -0
- package/src/todo-gantt-scope.mjs +1 -1
- package/src/todo-migration.mjs +49 -6
- package/src/todo-store.mjs +39 -11
|
@@ -8,6 +8,7 @@ import { projectTodoStatus } from '../src/todo-status.mjs';
|
|
|
8
8
|
import { ganttLiveHeadDigest, renderPublicTodoGanttForProject } from '../src/todo-cli.mjs';
|
|
9
9
|
import {
|
|
10
10
|
readVisibleTodoDashboardProjects,
|
|
11
|
+
todoDashboardMemberNeedsVisibility,
|
|
11
12
|
writeTodoDashboardDaemonDescriptor,
|
|
12
13
|
} from '../src/todo-dashboard-registry.mjs';
|
|
13
14
|
import {
|
|
@@ -58,7 +59,9 @@ async function synchronize() {
|
|
|
58
59
|
const active = await readVisibleTodoDashboardProjects({ env,
|
|
59
60
|
projectHasActiveRun: async (entry) => {
|
|
60
61
|
try {
|
|
61
|
-
const
|
|
62
|
+
const store = await readCachedStore(entry.repo_root);
|
|
63
|
+
const active = projectTodoStatus(store).active_set.length > 0
|
|
64
|
+
|| store.members.some(todoDashboardMemberNeedsVisibility);
|
|
62
65
|
reportedStoreReadFailures.delete(entry.project_id);
|
|
63
66
|
return active;
|
|
64
67
|
} catch (error) {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"text": { "type": "string", "minLength": 1, "maxLength": 16384 },
|
|
49
49
|
"designMemo": {
|
|
50
|
-
"type": "string", "minLength": 1, "maxLength":
|
|
50
|
+
"type": "string", "minLength": 1, "maxLength": 16384, "pattern": "\\S",
|
|
51
51
|
"description": "Markdown design memo. Use the literal NO_PLAN only when no plan exists."
|
|
52
52
|
},
|
|
53
53
|
"nullableText": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/text" }] },
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"task_id": { "$ref": "#/$defs/identifier" },
|
|
159
159
|
"title": { "$ref": "#/$defs/nullableText" },
|
|
160
160
|
"lane": { "$ref": "#/$defs/identifier" },
|
|
161
|
-
"design_memo": { "type": "string", "minLength": 1, "maxLength":
|
|
161
|
+
"design_memo": { "type": "string", "minLength": 1, "maxLength": 16384, "pattern": "\\S" },
|
|
162
162
|
"narrative_ref": { "$ref": "#/$defs/nullableRepoRef" },
|
|
163
163
|
"narrative_anchor": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/narrativeAnchor" }] },
|
|
164
164
|
"compile_binding": { "$ref": "#/$defs/compileBinding" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.2",
|
|
4
4
|
"description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Quo / クオ at kitepon.dev",
|
package/src/cli-help.mjs
CHANGED
|
@@ -26,7 +26,7 @@ Commands:
|
|
|
26
26
|
create --input <file> [--serialization-reviewed]
|
|
27
27
|
# 依存グラフがほぼ一直線(serialization_ratioが閾値超)なら一度突き返す。
|
|
28
28
|
# 再考した上でなお直列でよいなら --serialization-reviewed を付けて再実行する
|
|
29
|
-
create --schema --json # 既定は最新版(
|
|
29
|
+
create --schema --json # 既定は最新版(v4)のJSON Schemaを返す
|
|
30
30
|
create --schema-version <1|2|3|4> --json
|
|
31
31
|
show <plan_key> --json # task・依存・phase・状態をplan本体から1コマンドで投影する
|
|
32
32
|
compile --request <request.json>
|
|
@@ -182,6 +182,9 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
182
182
|
'todo verify': 'todo verify [--plan <key>] [--json]',
|
|
183
183
|
'todo snapshot': 'todo snapshot --rebuild --plan <key>',
|
|
184
184
|
'todo gantt': 'todo gantt serve --port <port> [--scope live|all] # 動的表示のみ。静的HTML生成は廃止',
|
|
185
|
+
'todo gantt serve': 'todo gantt serve --port <0..65535> [--scope live|all] # loopback動的viewer',
|
|
186
|
+
'todo dashboard': 'todo dashboard adopt --json # 配信元rootの衝突を明示的に解消',
|
|
187
|
+
'todo dashboard adopt': 'todo dashboard adopt --json # 現在repoをproject_idの配信元として明示採用',
|
|
185
188
|
'todo phase': 'todo phase <status|review|accept|reject|reopen|close-unaudited> --plan <key> [options]'
|
|
186
189
|
+ ' | baseline --reason <text> [--except <plan_key>]...',
|
|
187
190
|
'todo phase status': 'todo phase status --plan <key>',
|
package/src/project-cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
TODO_DESIGN_MEMO_PROMPT,
|
|
8
8
|
canonicalizeTodoArtifact,
|
|
9
9
|
exactRecord,
|
|
10
|
+
explainTodoDesignMemo,
|
|
10
11
|
isTodoDesignMemo,
|
|
11
12
|
isStrictTodoTimestamp,
|
|
12
13
|
isTodoDigest,
|
|
@@ -196,14 +197,16 @@ function statusResult(fields) {
|
|
|
196
197
|
return result;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
function invalidStatus({ cliVersion, repoRoot, reason }) {
|
|
200
|
+
function invalidStatus({ cliVersion, repoRoot, reason, nextAction = null }) {
|
|
200
201
|
return statusResult({
|
|
201
202
|
cli: { available: true, version: cliVersion },
|
|
202
203
|
project: repoRoot === null ? null : { root: repoRoot, git_head: gitHead(repoRoot), project_id: null },
|
|
203
204
|
state: 'invalid',
|
|
204
205
|
store: { ref: STORE_REF, absolute_path: repoRoot === null ? null : path.join(repoRoot, STORE_REF) },
|
|
205
206
|
active_plans: [], active_runs: [], can_create_plan: false,
|
|
206
|
-
next_action:
|
|
207
|
+
next_action: nextAction ?? {
|
|
208
|
+
command: repoRoot === null ? 'git status' : 'lattice todo verify', reason,
|
|
209
|
+
},
|
|
207
210
|
});
|
|
208
211
|
}
|
|
209
212
|
|
|
@@ -448,14 +451,43 @@ export async function runPlanCreate({ cwd, inputRef, stdout, serializationReview
|
|
|
448
451
|
const repoRoot = resolveRepoRoot(cwd);
|
|
449
452
|
if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
|
|
450
453
|
const input = await readCanonicalInput(repoRoot, inputRef);
|
|
451
|
-
if (input?.schema !== MEMO_PHASE_CREATE_INPUT_SCHEMA
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
454
|
+
if (input?.schema !== MEMO_PHASE_CREATE_INPUT_SCHEMA) {
|
|
455
|
+
throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_retired', undefined, {
|
|
456
|
+
violation_kind: 'const_mismatch', pointer: '/schema',
|
|
457
|
+
expected: MEMO_PHASE_CREATE_INPUT_SCHEMA,
|
|
458
|
+
actual: typeof input?.schema === 'string' ? input.schema : { type: typeof input?.schema },
|
|
459
|
+
next_action: CURRENT_CREATE_SCHEMA_COMMAND,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (!Array.isArray(input.tasks)) {
|
|
463
|
+
throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid', undefined, {
|
|
464
|
+
violation_kind: 'type', pointer: '/tasks', expected: { type: 'array', min_items: 1 },
|
|
465
|
+
actual: { type: input.tasks === null ? 'null' : typeof input.tasks },
|
|
455
466
|
next_action: CURRENT_CREATE_SCHEMA_COMMAND,
|
|
456
467
|
});
|
|
457
468
|
}
|
|
458
|
-
|
|
469
|
+
const invalidMemoIndex = input.tasks.findIndex((task) => !isTodoDesignMemo(task?.design_memo));
|
|
470
|
+
if (invalidMemoIndex >= 0) {
|
|
471
|
+
const explained = explainTodoDesignMemo(input.tasks[invalidMemoIndex]?.design_memo);
|
|
472
|
+
throw new TodoStoreError('DESIGN_MEMO_REQUIRED', 'plan_create_design_memo_required', undefined, {
|
|
473
|
+
violation_kind: explained.reason, pointer: `/tasks/${invalidMemoIndex}/design_memo`,
|
|
474
|
+
expected: explained.expected, actual: explained.actual,
|
|
475
|
+
prompt: TODO_DESIGN_MEMO_PROMPT, next_action: CURRENT_CREATE_SCHEMA_COMMAND,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (!validateCreateInput(input)) {
|
|
479
|
+
const digestValid = isTodoDigest(input.input_digest)
|
|
480
|
+
&& input.input_digest === todoSelfDigest(input, 'input_digest');
|
|
481
|
+
throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid', undefined, {
|
|
482
|
+
violation_kind: digestValid ? 'schema_or_topology_invalid' : 'input_digest_mismatch',
|
|
483
|
+
pointer: digestValid ? '/' : '/input_digest',
|
|
484
|
+
expected: digestValid ? { schema: MEMO_PHASE_CREATE_INPUT_SCHEMA }
|
|
485
|
+
: todoSelfDigest(input, 'input_digest'),
|
|
486
|
+
actual: digestValid ? { validation: 'failed' }
|
|
487
|
+
: { type: typeof input.input_digest, matches_canonical_input: false },
|
|
488
|
+
next_action: 'correct_the_reported_pointer_then_rerun_plan_create',
|
|
489
|
+
});
|
|
490
|
+
}
|
|
459
491
|
// dispatch_shapeのgateはstore初期化より前に判定する(拒否時にstoreへ何も書かないため、
|
|
460
492
|
// 再考後の再実行がplan_key_already_existsで詰まらない)。
|
|
461
493
|
const dispatchShape = computeTodoDispatchShapeForPlan({
|
|
@@ -624,9 +656,15 @@ export async function runPlanShow({ cwd, planKey, stdout }) {
|
|
|
624
656
|
}
|
|
625
657
|
|
|
626
658
|
export function projectStatusFailure({ cwd, stdout, cliVersion, error }) {
|
|
659
|
+
const projectRootConflict = error?.code === 'PROJECT_ROOT_CONFLICT';
|
|
627
660
|
const result = invalidStatus({
|
|
628
661
|
cliVersion, repoRoot: resolveRepoRoot(cwd),
|
|
629
|
-
reason:
|
|
662
|
+
reason: projectRootConflict
|
|
663
|
+
? 'project_root_conflict'
|
|
664
|
+
: `status_internal_failure:${error?.constructor?.name ?? 'Error'}`,
|
|
665
|
+
...(projectRootConflict ? { nextAction: {
|
|
666
|
+
command: 'lattice todo dashboard adopt --json', reason: 'project_root_conflict',
|
|
667
|
+
} } : {}),
|
|
630
668
|
});
|
|
631
669
|
stdout.write(`${JSON.stringify(result)}\n`);
|
|
632
670
|
return 1;
|
package/src/runtime-cli.mjs
CHANGED
|
@@ -2287,6 +2287,11 @@ export async function runManagedSupervisorDaemon({
|
|
|
2287
2287
|
}
|
|
2288
2288
|
activation = await activateController({ repoRoot, runId: request.request_id,
|
|
2289
2289
|
adapterKind: legacyMeta.executor_adapter });
|
|
2290
|
+
const activationDelayMs = process.env.NODE_ENV === 'test'
|
|
2291
|
+
? Number(process.env.LATTICE_INTERNAL_TEST_ACTIVATION_DELAY_MS ?? 0) : 0;
|
|
2292
|
+
if (Number.isSafeInteger(activationDelayMs) && activationDelayMs > 0) {
|
|
2293
|
+
await new Promise((resolve) => setTimeout(resolve, activationDelayMs));
|
|
2294
|
+
}
|
|
2290
2295
|
if (process.env.NODE_ENV === 'test'
|
|
2291
2296
|
&& process.env.LATTICE_INTERNAL_TEST_CONTROLLER_COUNT === '2') {
|
|
2292
2297
|
additionalActivations = [await activateController({ repoRoot, runId: request.request_id,
|
|
@@ -3293,6 +3298,21 @@ export async function runManagedSupervisorDaemon({
|
|
|
3293
3298
|
known = { ...known, state: 'in_progress', response: null };
|
|
3294
3299
|
}
|
|
3295
3300
|
}
|
|
3301
|
+
// 初回activateがsocket timeoutを超えても、同一request_idの再照会を二重activateとして
|
|
3302
|
+
// 実行しない。初回daemon(run_meta.v1)に残るin_progressは、activation代入前後のどちらでも
|
|
3303
|
+
// 同じ処理がまだ走っている状態なのでunknownを返す。daemon再起動後(run_meta.v2)の
|
|
3304
|
+
// in_progressだけは、直後のrecovery分岐がdurable ledgerから処理を再開する。
|
|
3305
|
+
if (known?.state === 'in_progress' && controlRequest.operation === 'activate'
|
|
3306
|
+
&& !restarting) {
|
|
3307
|
+
const events = await readBoundedJson(path.join(runDir, 'events.json'), 'run events').catch(() => []);
|
|
3308
|
+
const journal = await eventStore.readEvents();
|
|
3309
|
+
const result = buildControlResult({ operation: controlRequest.operation, outcome: 'unknown',
|
|
3310
|
+
eventHeadDigest: events.at(-1)?.event_digest ?? null,
|
|
3311
|
+
controlHeadDigest: journal.at(-1)?.event_digest ?? null, activeEpoch: 1,
|
|
3312
|
+
unmet: ['RUN_OUTCOME_UNKNOWN', '同一request_idのactivateはまだ処理中'] });
|
|
3313
|
+
return buildControlResponse(controlRequest, 'unknown', result,
|
|
3314
|
+
journal.at(-1)?.event_digest ?? null);
|
|
3315
|
+
}
|
|
3296
3316
|
if (known !== null && controlRequest.operation === 'activate') {
|
|
3297
3317
|
const active = await resolveActiveRuntimePaths({ runDir }).catch(() => null);
|
|
3298
3318
|
if (active?.pointer?.activation_request_id === controlRequest.request_id
|
package/src/todo-cli.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
canonicalizeTodoArtifact,
|
|
13
13
|
digestTodoArtifact,
|
|
14
14
|
exactRecord,
|
|
15
|
+
explainTodoDesignMemo,
|
|
15
16
|
isTodoDigest,
|
|
16
17
|
isTodoDesignMemo,
|
|
17
18
|
isTodoIdentifier,
|
|
@@ -1116,10 +1117,20 @@ async function migrateDryRun({ repoRoot, inputRef, serializationReviewed = false
|
|
|
1116
1117
|
const extraction = await readMigrationInput(repoRoot, inputRef, { requireValid: false });
|
|
1117
1118
|
const violations = [];
|
|
1118
1119
|
const tasks = Array.isArray(extraction?.tasks) ? extraction.tasks : [];
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1120
|
+
const invalidMemos = tasks.map((task, index) => ({
|
|
1121
|
+
task, index, explained: explainTodoDesignMemo(task?.design_memo),
|
|
1122
|
+
})).filter(({ explained }) => !explained.valid).slice(0, 64);
|
|
1123
|
+
if (extraction?.schema !== TODO_EXTRACTION_SCHEMA_V3) {
|
|
1124
|
+
violations.push({ code: 'schema_retired', path: '/schema', task_ids: [],
|
|
1125
|
+
expected: TODO_EXTRACTION_SCHEMA_V3,
|
|
1126
|
+
actual: typeof extraction?.schema === 'string' ? extraction.schema
|
|
1127
|
+
: { type: typeof extraction?.schema },
|
|
1128
|
+
next_action: 'lattice todo migrate --schema --json' });
|
|
1129
|
+
}
|
|
1130
|
+
for (const { task, index, explained } of invalidMemos) {
|
|
1131
|
+
violations.push({ code: `design_memo_${explained.reason}`, path: `/tasks/${index}/design_memo`,
|
|
1132
|
+
task_ids: isTodoIdentifier(task?.task_id) ? [task.task_id] : [],
|
|
1133
|
+
expected: explained.expected, actual: explained.actual,
|
|
1123
1134
|
prompt: TODO_DESIGN_MEMO_PROMPT, next_action: 'lattice todo migrate --schema --json' });
|
|
1124
1135
|
}
|
|
1125
1136
|
const schemaValid = extraction?.schema === TODO_EXTRACTION_SCHEMA_V3
|
|
@@ -1171,13 +1182,23 @@ async function migrateDryRun({ repoRoot, inputRef, serializationReviewed = false
|
|
|
1171
1182
|
task_ids: error?.detail?.critical_path_task_ids ?? [],
|
|
1172
1183
|
next_action: 'reconsider_parallel_seams_or_pass_serialization_reviewed' });
|
|
1173
1184
|
}
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
plannedPlan = null;
|
|
1187
|
+
dispatchShape = null;
|
|
1188
|
+
violations.push({ code: error?.detail?.reason ?? 'topology_invalid', path: '/hard_dependencies',
|
|
1189
|
+
task_ids: [], next_action: 'correct_extraction_topology' });
|
|
1190
|
+
}
|
|
1191
|
+
if (plannedPlan !== null && dispatchShape !== null) {
|
|
1174
1192
|
violations.push(...extractionFreshnessViolations(repoRoot, extraction));
|
|
1175
1193
|
let manifestPresent = false;
|
|
1176
1194
|
try {
|
|
1177
1195
|
const stats = await lstat(path.join(repoRoot, '.lattice', 'todo', 'manifest.json'));
|
|
1178
1196
|
manifestPresent = stats.isFile() && !stats.isSymbolicLink();
|
|
1179
1197
|
} catch (error) {
|
|
1180
|
-
if (error?.code !== 'ENOENT')
|
|
1198
|
+
if (error?.code !== 'ENOENT') {
|
|
1199
|
+
throw new TodoStoreError('MIGRATION_DRY_RUN_IO_FAILED', 'manifest_status_unreadable',
|
|
1200
|
+
undefined, { path: '.lattice/todo/manifest.json' });
|
|
1201
|
+
}
|
|
1181
1202
|
}
|
|
1182
1203
|
if (manifestPresent) {
|
|
1183
1204
|
const store = await readTodoStore({ repoRoot });
|
|
@@ -1190,9 +1211,6 @@ async function migrateDryRun({ repoRoot, inputRef, serializationReviewed = false
|
|
|
1190
1211
|
next_action: 'choose_a_new_plan_key_or_use_revision' });
|
|
1191
1212
|
}
|
|
1192
1213
|
}
|
|
1193
|
-
} catch (error) {
|
|
1194
|
-
violations.push({ code: error?.detail?.reason ?? 'topology_invalid', path: '/hard_dependencies',
|
|
1195
|
-
task_ids: [], next_action: 'correct_extraction_topology' });
|
|
1196
1214
|
}
|
|
1197
1215
|
}
|
|
1198
1216
|
const bounded = violations.slice(0, 64);
|
|
@@ -2329,7 +2347,11 @@ async function ensureActiveProjectDashboard({ repoRoot, env }) {
|
|
|
2329
2347
|
});
|
|
2330
2348
|
} catch (error) {
|
|
2331
2349
|
throw new TodoStoreError(error?.code ?? 'DASHBOARD_DAEMON_UNAVAILABLE',
|
|
2332
|
-
'dashboard_daemon_ensure_failed', undefined, {
|
|
2350
|
+
'dashboard_daemon_ensure_failed', undefined, {
|
|
2351
|
+
project_id: store.project_id,
|
|
2352
|
+
...(typeof error?.detail?.next_action === 'string'
|
|
2353
|
+
? { next_action: error.detail.next_action } : {}),
|
|
2354
|
+
});
|
|
2333
2355
|
}
|
|
2334
2356
|
}
|
|
2335
2357
|
|
|
@@ -2573,10 +2595,10 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2573
2595
|
action = (repoRoot) => serveGantt({
|
|
2574
2596
|
repoRoot, port: Number(argv[3]), stdout, env, scope: argv[5],
|
|
2575
2597
|
});
|
|
2576
|
-
} else if (argv[0] === 'gantt') {
|
|
2598
|
+
} else if (argv[0] === 'gantt' && argv[1] !== 'serve') {
|
|
2577
2599
|
action = () => {
|
|
2578
2600
|
throw new TodoStoreError('STATIC_GANTT_RETIRED', 'dynamic_dashboard_only', undefined, {
|
|
2579
|
-
next_action: 'lattice todo
|
|
2601
|
+
next_action: 'lattice todo gantt serve --port 0',
|
|
2580
2602
|
});
|
|
2581
2603
|
};
|
|
2582
2604
|
} else if ((argv.length === 5 || argv.length === 6) && argv[0] === 'migrate'
|
|
@@ -2688,17 +2710,20 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2688
2710
|
next_action: 'lattice todo --help',
|
|
2689
2711
|
});
|
|
2690
2712
|
}
|
|
2713
|
+
const argumentHelp = argv[0] === 'gantt' && argv[1] === 'serve'
|
|
2714
|
+
? 'lattice todo gantt serve --help'
|
|
2715
|
+
: command === null ? 'lattice todo --help' : `lattice todo ${command} --help`;
|
|
2691
2716
|
return typedArgumentFailure(stderr, 'INVALID_ARGUMENTS', 'todo_arguments_invalid', {
|
|
2692
|
-
command, next_action:
|
|
2717
|
+
command, next_action: argumentHelp,
|
|
2693
2718
|
});
|
|
2694
2719
|
}
|
|
2695
2720
|
|
|
2696
2721
|
try {
|
|
2697
2722
|
const repoRoot = resolveRepoRoot(cwd);
|
|
2698
|
-
const
|
|
2723
|
+
const ganttCommand = argv[0] === 'gantt';
|
|
2699
2724
|
const dashboardAdopt = argv[0] === 'dashboard' && argv[1] === 'adopt';
|
|
2700
2725
|
const migrationDryRun = argv[0] === 'migrate' && argv.includes('--dry-run');
|
|
2701
|
-
if (!
|
|
2726
|
+
if (!ganttCommand && !dashboardAdopt && !migrationDryRun) {
|
|
2702
2727
|
await ensureActiveProjectDashboard({ repoRoot, env });
|
|
2703
2728
|
}
|
|
2704
2729
|
const result = await action(repoRoot);
|
package/src/todo-contracts.mjs
CHANGED
|
@@ -159,9 +159,34 @@ export function validateTodoNoteContext(value) {
|
|
|
159
159
|
|
|
160
160
|
const nullableDigest = (value) => value === null || isTodoDigest(value);
|
|
161
161
|
const nullableText = (value) => value === null || (typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= 16_384);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
/** 設計メモの拒否理由を、本文をerrorへ複製せずAIが訂正できる形で返す。 */
|
|
163
|
+
export function explainTodoDesignMemo(value) {
|
|
164
|
+
const expected = {
|
|
165
|
+
type: 'string', non_whitespace: true,
|
|
166
|
+
max_characters: TODO_LIMITS.noteBodyBytes, max_utf8_bytes: TODO_LIMITS.noteBodyBytes,
|
|
167
|
+
forbidden_control_characters: false,
|
|
168
|
+
};
|
|
169
|
+
if (typeof value !== 'string') {
|
|
170
|
+
return { valid: false, reason: 'type', expected,
|
|
171
|
+
actual: { type: value === null ? 'null' : typeof value } };
|
|
172
|
+
}
|
|
173
|
+
const byteLength = Buffer.byteLength(value, 'utf8');
|
|
174
|
+
if (value.trim().length === 0) {
|
|
175
|
+
return { valid: false, reason: 'blank', expected,
|
|
176
|
+
actual: { byte_length: byteLength, non_whitespace: false } };
|
|
177
|
+
}
|
|
178
|
+
if (value.length > TODO_LIMITS.noteBodyBytes || byteLength > TODO_LIMITS.noteBodyBytes) {
|
|
179
|
+
return { valid: false, reason: 'too_large', expected,
|
|
180
|
+
actual: { character_length: value.length, byte_length: byteLength } };
|
|
181
|
+
}
|
|
182
|
+
if (NOTE_FORBIDDEN_CONTROL.test(value)) {
|
|
183
|
+
return { valid: false, reason: 'forbidden_control', expected,
|
|
184
|
+
actual: { byte_length: byteLength, contains_forbidden_control: true } };
|
|
185
|
+
}
|
|
186
|
+
return { valid: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const isTodoDesignMemo = (value) => explainTodoDesignMemo(value).valid;
|
|
165
190
|
const actor = (value) => exactRecord(value, ['host', 'session', 'agent'])
|
|
166
191
|
&& [value.host, value.session, value.agent].every(isTodoIdentifier);
|
|
167
192
|
const provenance = (value) => value === null || (exactRecord(value, ['source_commit', 'source_event_digest'])
|
|
@@ -20,6 +20,7 @@ const REGISTRY_SCHEMA = 'lattice.todo_dashboard_registry.v1';
|
|
|
20
20
|
const DAEMON_SCHEMA = 'lattice.todo_dashboard_daemon.v1';
|
|
21
21
|
const DEFAULT_PORT = 0;
|
|
22
22
|
export const TODO_DASHBOARD_STALE_MS = 2 * 60 * 60 * 1_000;
|
|
23
|
+
const TODO_DASHBOARD_ATTENTION_PHASE_STATUS = new Set(['gate_ready', 'reviewing', 'rejected']);
|
|
23
24
|
const LOCK_ATTEMPTS = 240;
|
|
24
25
|
const LOCK_WAIT_MS = 25;
|
|
25
26
|
const LOCK_STALE_MS = 30_000;
|
|
@@ -54,6 +55,12 @@ function validEntry(entry) {
|
|
|
54
55
|
&& Number.isFinite(Date.parse(entry.last_seen_at));
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** active taskが無くても、監査の判断待ち・棄却後なら公開工程から消してはいけない。 */
|
|
59
|
+
export function todoDashboardMemberNeedsVisibility(member) {
|
|
60
|
+
return Array.isArray(member?.phases)
|
|
61
|
+
&& member.phases.some(({ status }) => TODO_DASHBOARD_ATTENTION_PHASE_STATUS.has(status));
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
function validateRegistry(value) {
|
|
58
65
|
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
59
66
|
|| value.schema !== REGISTRY_SCHEMA || !Array.isArray(value.projects)
|
|
@@ -169,7 +176,10 @@ export async function registerTodoDashboardActivity({
|
|
|
169
176
|
error.code = 'PROJECT_ROOT_CONFLICT';
|
|
170
177
|
// 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで人と機械が
|
|
171
178
|
// 衝突対象を特定でき、registry bytesはこの分岐より後で一切変更しない。
|
|
172
|
-
error.detail = {
|
|
179
|
+
error.detail = {
|
|
180
|
+
project_id: projectId,
|
|
181
|
+
next_action: 'lattice todo dashboard adopt --json',
|
|
182
|
+
};
|
|
173
183
|
throw error;
|
|
174
184
|
}
|
|
175
185
|
const projects = current.projects.filter((entry) => entry.project_id !== projectId);
|
|
@@ -95,7 +95,7 @@ export function renderIndependenceNote(ref, node, summary) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export function renderRightPane(
|
|
98
|
-
sections, layout, presentation, readModel, notesEnabled = false, noteWarnings = [],
|
|
98
|
+
sections, layout, presentation, readModel, notesEnabled = false, noteWarnings = [], expandable = false,
|
|
99
99
|
) {
|
|
100
100
|
const lookup = presentationLookup(presentation);
|
|
101
101
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
@@ -160,7 +160,9 @@ export function renderRightPane(
|
|
|
160
160
|
const independenceNote = renderIndependenceNote(section.ref, node, independenceSummary);
|
|
161
161
|
// Say it plainly when the reader will not find this ToDo on the diagram.
|
|
162
162
|
const foldedNote = !folds.has(key) ? ''
|
|
163
|
-
:
|
|
163
|
+
: expandable
|
|
164
|
+
? '<p class="fold-note">完走済みのため既定の図には描いていません。左上の「完走済み」バッジを押すと、このページ内で表示できます。</p>'
|
|
165
|
+
: '<p class="fold-note">完走済みのため図には描いていません。図に出すには動的dashboardを <code>lattice todo gantt serve --port <port> --scope all</code> で起動してください。</p>';
|
|
164
166
|
const workLog = notesEnabled ? renderNoteContext(section.noteContext) : '';
|
|
165
167
|
const designMemo = renderDesignMemo(section.task);
|
|
166
168
|
return `<article class="task-detail" data-detail-key="${escapeHtmlAttribute(key)}" hidden><header><span class="detail-status status-${escapeHtmlAttribute(section.state.status)}">${escapeHtmlText(status.mark)} ${escapeHtmlText(status.label)}</span><span class="detail-reference">${escapeHtmlText(taskReference(section, lookup))}</span></header><h1>${escapeHtmlText(section.task.title)}</h1><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(category)}</p>${categoryDescription}<p><strong>正規ID:</strong> <code>${escapeHtmlText(`${section.ref.plan_key}/${section.task.task_id}`)}</code></p>${blockedReason}${readiness}${independenceNote}${foldedNote}${designMemo}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</section>${workLog}<p class="anchor-status">${escapeHtmlText(anchorText)}</p><details class="task-diagnostics"><summary>開発者向け診断</summary><dl><dt>canonical ref</dt><dd><code>${escapeHtmlText(`${section.ref.project_id}/${section.ref.plan_key}/${section.task.task_id}`)}</code></dd><dt>anchor</dt><dd>${escapeHtmlText(section.anchorOutcome.anchored ? 'verified' : section.anchorOutcome.reason)}</dd></dl></details></article>`;
|
|
@@ -110,22 +110,46 @@ export function renderRelationList(relations, sectionByKey, lookup, emptyText, f
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** Phase states that are over: nothing is dispatched or judged under them again. */
|
|
113
|
-
export const SETTLED_PHASE_STATUS = Object.freeze(['accepted', '
|
|
113
|
+
export const SETTLED_PHASE_STATUS = Object.freeze(['accepted', 'closed_unaudited']);
|
|
114
|
+
|
|
115
|
+
function phaseGuidance(planKey, phase) {
|
|
116
|
+
if (phase.status === 'gate_ready') return {
|
|
117
|
+
reason: '全ToDoは完了していますが、終端監査がまだ受理されていません。',
|
|
118
|
+
next: `lattice todo phase review --plan ${planKey} --phase ${phase.phase_id} --reason <text>`,
|
|
119
|
+
};
|
|
120
|
+
if (phase.status === 'reviewing') return {
|
|
121
|
+
reason: '終端監査を実施中です。受理または棄却の判断がまだ記録されていません。',
|
|
122
|
+
next: `lattice todo phase accept --plan ${planKey} --phase ${phase.phase_id} --input <file>`,
|
|
123
|
+
};
|
|
124
|
+
if (phase.status === 'rejected') return {
|
|
125
|
+
reason: '終端監査で棄却され、修正または再監査が必要です。',
|
|
126
|
+
next: `lattice todo phase reopen --plan ${planKey} --phase ${phase.phase_id} --reason <text>`,
|
|
127
|
+
};
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
114
130
|
|
|
115
131
|
export function renderPhaseProgress(readModel) {
|
|
116
132
|
const rows = [];
|
|
117
133
|
const settledRows = [];
|
|
118
134
|
for (const member of readModel.members) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
// snapshot artifactの形式には縛られない導出ビュー(member.phases)を正本にする(ADR 0147)。
|
|
136
|
+
// plan.phasesは表示metadataだけを補い、暗黙terminal-auditやmetadata欠落を落とさない。
|
|
137
|
+
const metadata = new Map((member.plan.phases ?? []).map((phase) => [phase.phase_id, phase]));
|
|
138
|
+
for (const state of member.phases ?? []) {
|
|
139
|
+
const phase = metadata.get(state.phase_id) ?? {
|
|
140
|
+
phase_id: state.phase_id,
|
|
141
|
+
title: state.phase_id === 'terminal-audit' ? '終端監査(暗黙)' : state.phase_id,
|
|
142
|
+
gate_policy: 'heavy',
|
|
143
|
+
};
|
|
144
|
+
const tasks = metadata.has(state.phase_id)
|
|
145
|
+
? member.plan.tasks.filter((task) => task.phase_id === state.phase_id)
|
|
146
|
+
: member.plan.tasks;
|
|
125
147
|
const states = new Map(member.tasks.map((task) => [task.task_id, task.status]));
|
|
126
148
|
const done = tasks.filter((task) => states.get(task.task_id) === 'done').length;
|
|
127
|
-
const
|
|
128
|
-
const
|
|
149
|
+
const guidance = phaseGuidance(member.plan.plan_key, state);
|
|
150
|
+
const guidanceMarkup = guidance === null ? ''
|
|
151
|
+
: `<p><strong>状態の意味:</strong> ${escapeHtmlText(guidance.reason)}</p><p><strong>次の一歩:</strong> <code>${escapeHtmlText(guidance.next)}</code></p>`;
|
|
152
|
+
const row = `<li class="phase-progress status-${escapeHtmlAttribute(state.status)}"><header><strong>${escapeHtmlText(phase.title ?? phase.phase_id)}</strong><span>${escapeHtmlText(state.status)}</span></header><p><code>${escapeHtmlText(`${member.plan.plan_key}/${phase.phase_id}`)}</code> — policy <code>${escapeHtmlText(phase.gate_policy)}</code> — ToDo ${done}/${tasks.length}</p>${guidanceMarkup}<progress max="${tasks.length}" value="${done}">${done}/${tasks.length}</progress></li>`;
|
|
129
153
|
// A settled Phase is history. It stays reachable, but it does not push the
|
|
130
154
|
// live ones off the first screen.
|
|
131
155
|
(SETTLED_PHASE_STATUS.includes(state.status) ? settledRows : rows).push(row);
|
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -56,6 +56,14 @@ function normalizeSections(readModel, narratives, anchorOutcomes, noteContexts)
|
|
|
56
56
|
const narrative = supplied.get(refKey(ref));
|
|
57
57
|
const markdown = narrative?.markdown ?? '';
|
|
58
58
|
const narrativeRef = narrative?.narrative_ref ?? task.narrative_ref;
|
|
59
|
+
if (typeof task.design_memo === 'string') {
|
|
60
|
+
proseBytes += Buffer.byteLength(task.design_memo, 'utf8');
|
|
61
|
+
if (proseBytes > TODO_GANTT_PROSE_MAX_BYTES) {
|
|
62
|
+
throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt embedded prose limit exceeded', {
|
|
63
|
+
prose_bytes: proseBytes, prose_limit: TODO_GANTT_PROSE_MAX_BYTES,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
59
67
|
// ToDos that share one narrative document count its bytes once.
|
|
60
68
|
const documentKey = narrativeRef === null
|
|
61
69
|
? refKey(ref) : JSON.stringify([member.plan.plan_key, narrativeRef, digest(markdown)]);
|
|
@@ -175,6 +183,7 @@ export function renderTodoGanttHtml({
|
|
|
175
183
|
: `<div data-diagram="live">${svg}</div><div data-diagram="expanded" hidden>${expandedSvg}</div>`;
|
|
176
184
|
const rightPane = renderRightPane(
|
|
177
185
|
normalized.sections, layout, presentation, readModel, noteContexts !== null, noteWarnings,
|
|
186
|
+
expandedSvg !== '',
|
|
178
187
|
);
|
|
179
188
|
const staticData = serializeJsonForScript({
|
|
180
189
|
renderer_version: TODO_GANTT_RENDERER_VERSION,
|
package/src/todo-gantt-scope.mjs
CHANGED
|
@@ -59,7 +59,7 @@ const AUDIT_PENDING_PHASE_STATUSES = new Set(['gate_ready', 'reviewing', 'reject
|
|
|
59
59
|
function auditPending(node) {
|
|
60
60
|
if (!AUDIT_PENDING_PHASE_STATUSES.has(node.phase_status ?? null)) return false;
|
|
61
61
|
const schema = node.plan_schema ?? null;
|
|
62
|
-
return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5'
|
|
62
|
+
return !['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(schema);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
package/src/todo-migration.mjs
CHANGED
|
@@ -257,7 +257,26 @@ export function explainTodoExtraction(value) {
|
|
|
257
257
|
return reject('parent_task_id_unresolved',
|
|
258
258
|
`/tasks/${value.tasks.indexOf(badParent)}/source/parent_task_id`);
|
|
259
259
|
}
|
|
260
|
-
|
|
260
|
+
const excluded = new Set(value.tasks
|
|
261
|
+
.filter((task) => task.disposition.startsWith('exclude_'))
|
|
262
|
+
.map(({ task_id }) => task_id));
|
|
263
|
+
const excludedParent = value.tasks.find((task) => task.disposition.startsWith('register_')
|
|
264
|
+
&& task.source.parent_task_id !== null && excluded.has(task.source.parent_task_id));
|
|
265
|
+
if (excludedParent !== undefined) {
|
|
266
|
+
return reject('registered_parent_task_id_unresolved',
|
|
267
|
+
`/tasks/${value.tasks.indexOf(excludedParent)}/source/parent_task_id`, {
|
|
268
|
+
task_id: excludedParent.task_id,
|
|
269
|
+
expected: 'task_id not excluded from the compiled plan',
|
|
270
|
+
actual: excludedParent.source.parent_task_id,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const localRefViolation = firstUnregisteredLocalRef(value);
|
|
274
|
+
if (localRefViolation !== null) {
|
|
275
|
+
return reject('local_ref_unresolved', localRefViolation.path, {
|
|
276
|
+
task_id: localRefViolation.task_id,
|
|
277
|
+
expected: 'registered task_id', actual: localRefViolation.task_id,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
261
280
|
// ここまでの個別検査を全て通過したのに`validateTodoExtraction`がfalseを返す状況は、
|
|
262
281
|
// このexplainがまだ言い当てられない違反があるということ。捏造せず未特定と申告する。
|
|
263
282
|
return { valid: true };
|
|
@@ -273,11 +292,25 @@ function registeredTaskIds(value) {
|
|
|
273
292
|
}
|
|
274
293
|
|
|
275
294
|
function localRefsResolve(value) {
|
|
295
|
+
return firstUnregisteredLocalRef(value) === null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function firstUnregisteredLocalRef(value) {
|
|
276
299
|
const registered = registeredTaskIds(value);
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
300
|
+
const unresolved = (ref) => ref.project_id === value.project_id && ref.plan_key === value.plan_key
|
|
301
|
+
&& !registered.has(ref.task_id);
|
|
302
|
+
for (const [index, edge] of value.hard_dependencies.entries()) {
|
|
303
|
+
if (unresolved(edge.from)) return { path: `/hard_dependencies/${index}/from`, task_id: edge.from.task_id };
|
|
304
|
+
if (unresolved(edge.to)) return { path: `/hard_dependencies/${index}/to`, task_id: edge.to.task_id };
|
|
305
|
+
}
|
|
306
|
+
for (const [index, join] of value.joins.entries()) {
|
|
307
|
+
if (unresolved(join.before)) return { path: `/joins/${index}/before`, task_id: join.before.task_id };
|
|
308
|
+
const afterIndex = join.after.findIndex(unresolved);
|
|
309
|
+
if (afterIndex >= 0) {
|
|
310
|
+
return { path: `/joins/${index}/after/${afterIndex}`, task_id: join.after[afterIndex].task_id };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
281
314
|
}
|
|
282
315
|
|
|
283
316
|
/** Exact, bounded validation for the AI-authored G4 intermediate artifact. */
|
|
@@ -303,6 +336,11 @@ export function validateTodoExtraction(value) {
|
|
|
303
336
|
const taskIds = new Set(value.tasks.map(({ task_id }) => task_id));
|
|
304
337
|
if (value.tasks.some((task) => task.source.parent_task_id === task.task_id
|
|
305
338
|
|| (task.source.parent_task_id !== null && !taskIds.has(task.source.parent_task_id)))) return false;
|
|
339
|
+
const excluded = new Set(value.tasks
|
|
340
|
+
.filter((task) => task.disposition.startsWith('exclude_'))
|
|
341
|
+
.map(({ task_id }) => task_id));
|
|
342
|
+
if (value.tasks.some((task) => task.disposition.startsWith('register_')
|
|
343
|
+
&& task.source.parent_task_id !== null && excluded.has(task.source.parent_task_id))) return false;
|
|
306
344
|
return localRefsResolve(value);
|
|
307
345
|
} catch {
|
|
308
346
|
return false;
|
|
@@ -326,7 +364,12 @@ export function todoExtractionImportSource(task) {
|
|
|
326
364
|
*/
|
|
327
365
|
export function compileTodoExtraction(value, repoRoot) {
|
|
328
366
|
if (!validateTodoExtraction(value)) {
|
|
329
|
-
|
|
367
|
+
const explained = explainTodoExtraction(value);
|
|
368
|
+
throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'schema_invalid', undefined,
|
|
369
|
+
explained.valid ? undefined : {
|
|
370
|
+
violation_reason: explained.reason, violation_path: explained.path,
|
|
371
|
+
...(explained.task_id === undefined ? {} : { task_id: explained.task_id }),
|
|
372
|
+
});
|
|
330
373
|
}
|
|
331
374
|
const unresolved = value.tasks
|
|
332
375
|
.filter(({ disposition }) => disposition === 'unknown_requires_evidence')
|
package/src/todo-store.mjs
CHANGED
|
@@ -1933,7 +1933,9 @@ function localTaskRef(ref, plan, taskId) {
|
|
|
1933
1933
|
&& ref.task_id === taskId;
|
|
1934
1934
|
}
|
|
1935
1935
|
|
|
1936
|
-
function taskSemantics(plan, taskId, idMap, {
|
|
1936
|
+
function taskSemantics(plan, taskId, idMap, {
|
|
1937
|
+
reconciliationMetadata = false, includeDesignMemo = false,
|
|
1938
|
+
} = {}) {
|
|
1937
1939
|
const task = plan.tasks.find(({ task_id }) => task_id === taskId);
|
|
1938
1940
|
if (!task) return null;
|
|
1939
1941
|
const mapId = (id) => id === null ? null : idMap.get(id) ?? id;
|
|
@@ -1942,6 +1944,7 @@ function taskSemantics(plan, taskId, idMap, { reconciliationMetadata = false } =
|
|
|
1942
1944
|
compile_binding: task.compile_binding,
|
|
1943
1945
|
} : {
|
|
1944
1946
|
task_id: mapId(task.task_id), title: task.title, lane: task.lane,
|
|
1947
|
+
...(includeDesignMemo ? { design_memo: task.design_memo } : {}),
|
|
1945
1948
|
narrative_ref: task.narrative_ref, narrative_anchor: task.narrative_anchor ?? null,
|
|
1946
1949
|
compile_binding: task.compile_binding, parent_task_id: mapId(task.parent_task_id ?? null),
|
|
1947
1950
|
};
|
|
@@ -1967,7 +1970,7 @@ function taskSemantics(plan, taskId, idMap, { reconciliationMetadata = false } =
|
|
|
1967
1970
|
}
|
|
1968
1971
|
|
|
1969
1972
|
function phaseV3CarrySemantics(plan, taskId, taskIdMap, phaseIdMap,
|
|
1970
|
-
{ reconciliationMetadata = false } = {}) {
|
|
1973
|
+
{ reconciliationMetadata = false, includeDesignMemo = false } = {}) {
|
|
1971
1974
|
const task = plan.tasks.find(({ task_id: id }) => id === taskId);
|
|
1972
1975
|
if (task === undefined) return null;
|
|
1973
1976
|
const mapTaskRef = (ref) => ref.project_id === plan.project_id && ref.plan_key === plan.plan_key
|
|
@@ -1982,8 +1985,12 @@ function phaseV3CarrySemantics(plan, taskId, taskIdMap, phaseIdMap,
|
|
|
1982
1985
|
task_id: taskIdMap.get(task.task_id) ?? task.task_id, title: task.title, lane: task.lane,
|
|
1983
1986
|
compile_binding: task.compile_binding,
|
|
1984
1987
|
phase_id: mappedPhaseId,
|
|
1985
|
-
} : {
|
|
1988
|
+
} : {
|
|
1986
1989
|
task_id: taskIdMap.get(task.task_id) ?? task.task_id,
|
|
1990
|
+
title: task.title, lane: task.lane,
|
|
1991
|
+
...(includeDesignMemo ? { design_memo: task.design_memo } : {}),
|
|
1992
|
+
narrative_ref: task.narrative_ref, narrative_anchor: task.narrative_anchor ?? null,
|
|
1993
|
+
compile_binding: task.compile_binding,
|
|
1987
1994
|
phase_id: mappedPhaseId,
|
|
1988
1995
|
parent_task_id: task.parent_task_id === null ? null
|
|
1989
1996
|
: taskIdMap.get(task.parent_task_id) ?? task.parent_task_id,
|
|
@@ -2013,14 +2020,16 @@ function phaseV3CarrySemantics(plan, taskId, taskIdMap, phaseIdMap,
|
|
|
2013
2020
|
|
|
2014
2021
|
function validatePhaseV3Carry(previous, revision, migration, idMap, state) {
|
|
2015
2022
|
const reconciliationMetadata = migration.state_policy === 'carry_reconciled_metadata';
|
|
2023
|
+
const predecessorTask = previous.plan.tasks.find(({ task_id }) => task_id === migration.from_task_id);
|
|
2024
|
+
const includeDesignMemo = !reconciliationMetadata && typeof predecessorTask?.design_memo === 'string';
|
|
2016
2025
|
const phaseIdMap = new Map(revision.phase_migration
|
|
2017
2026
|
.filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
|
|
2018
2027
|
.map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
|
|
2019
2028
|
const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap,
|
|
2020
|
-
{ reconciliationMetadata });
|
|
2029
|
+
{ reconciliationMetadata, includeDesignMemo });
|
|
2021
2030
|
const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id,
|
|
2022
2031
|
new Map(), new Map(),
|
|
2023
|
-
{ reconciliationMetadata });
|
|
2032
|
+
{ reconciliationMetadata, includeDesignMemo });
|
|
2024
2033
|
if (canonicalizeTodoArtifact(before.task) !== canonicalizeTodoArtifact(after.task)
|
|
2025
2034
|
|| canonicalizeTodoArtifact(before.incoming) !== canonicalizeTodoArtifact(after.incoming)) {
|
|
2026
2035
|
fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
|
|
@@ -2048,8 +2057,12 @@ function validateAcquirePhaseCarry(previous, revision, migration, idMap) {
|
|
|
2048
2057
|
const phaseIdMap = new Map(revision.phase_migration
|
|
2049
2058
|
.filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
|
|
2050
2059
|
.map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
|
|
2051
|
-
const
|
|
2052
|
-
const
|
|
2060
|
+
const predecessorTask = previous.plan.tasks.find(({ task_id }) => task_id === migration.from_task_id);
|
|
2061
|
+
const includeDesignMemo = typeof predecessorTask?.design_memo === 'string';
|
|
2062
|
+
const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap,
|
|
2063
|
+
{ includeDesignMemo });
|
|
2064
|
+
const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id, new Map(), new Map(),
|
|
2065
|
+
{ includeDesignMemo });
|
|
2053
2066
|
if (before.task.phase_id !== null) {
|
|
2054
2067
|
fail('REVISION_INVALID', 'acquire_phase_requires_unassigned_predecessor', { from_task_id: migration.from_task_id });
|
|
2055
2068
|
}
|
|
@@ -2068,6 +2081,12 @@ function validateAcquirePhaseCarry(previous, revision, migration, idMap) {
|
|
|
2068
2081
|
}
|
|
2069
2082
|
|
|
2070
2083
|
function stateMigrationFor(previous, revision) {
|
|
2084
|
+
if (['lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(previous.plan.schema)
|
|
2085
|
+
&& !['lattice.todo_plan.v6', 'lattice.todo_plan.v7'].includes(revision.desired_plan.schema)) {
|
|
2086
|
+
fail('REVISION_INVALID', 'design_memo_schema_downgrade', {
|
|
2087
|
+
predecessor_schema: previous.plan.schema, desired_schema: revision.desired_plan.schema,
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2071
2090
|
const oldIds = previous.plan.tasks.map(({ task_id }) => task_id);
|
|
2072
2091
|
const migrationIds = revision.task_migration.map(({ from_task_id }) => from_task_id);
|
|
2073
2092
|
if (canonicalizeTodoArtifact([...oldIds].sort()) !== canonicalizeTodoArtifact([...migrationIds].sort())) {
|
|
@@ -2091,8 +2110,13 @@ function stateMigrationFor(previous, revision) {
|
|
|
2091
2110
|
if (revision.schema === 'lattice.phase_todo_revision.v3') {
|
|
2092
2111
|
validateAcquirePhaseCarry(previous, revision, migration, idMap);
|
|
2093
2112
|
} else {
|
|
2094
|
-
const
|
|
2095
|
-
|
|
2113
|
+
const predecessorTask = previous.plan.tasks
|
|
2114
|
+
.find(({ task_id }) => task_id === migration.from_task_id);
|
|
2115
|
+
const includeDesignMemo = typeof predecessorTask?.design_memo === 'string';
|
|
2116
|
+
const before = taskSemantics(previous.plan, migration.from_task_id, idMap,
|
|
2117
|
+
{ includeDesignMemo });
|
|
2118
|
+
const after = taskSemantics(revision.desired_plan, migration.to_task_id, new Map(),
|
|
2119
|
+
{ includeDesignMemo });
|
|
2096
2120
|
if (canonicalizeTodoArtifact(before) !== canonicalizeTodoArtifact(after)) {
|
|
2097
2121
|
fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
|
|
2098
2122
|
}
|
|
@@ -2100,10 +2124,14 @@ function stateMigrationFor(previous, revision) {
|
|
|
2100
2124
|
} else if (revision.schema === 'lattice.phase_todo_revision.v3') {
|
|
2101
2125
|
validatePhaseV3Carry(previous, revision, migration, idMap, state);
|
|
2102
2126
|
} else {
|
|
2127
|
+
const predecessorTask = previous.plan.tasks
|
|
2128
|
+
.find(({ task_id }) => task_id === migration.from_task_id);
|
|
2129
|
+
const includeDesignMemo = !reconciliationMetadata
|
|
2130
|
+
&& typeof predecessorTask?.design_memo === 'string';
|
|
2103
2131
|
const before = taskSemantics(previous.plan, migration.from_task_id, idMap,
|
|
2104
|
-
{ reconciliationMetadata });
|
|
2132
|
+
{ reconciliationMetadata, includeDesignMemo });
|
|
2105
2133
|
const after = taskSemantics(revision.desired_plan, migration.to_task_id, new Map(),
|
|
2106
|
-
{ reconciliationMetadata });
|
|
2134
|
+
{ reconciliationMetadata, includeDesignMemo });
|
|
2107
2135
|
if (canonicalizeTodoArtifact(before) !== canonicalizeTodoArtifact(after)) {
|
|
2108
2136
|
fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
|
|
2109
2137
|
}
|