@quolu/lattice 0.61.3 → 0.62.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/src/todo-cli.mjs CHANGED
@@ -1,11 +1,20 @@
1
1
  import { gitSync } from './git-process.mjs';
2
2
  import { createHash } from 'node:crypto';
3
+ import {
4
+ hostname,
5
+ } from 'node:os';
3
6
  import {
4
7
  lstat, mkdir, readFile, realpath, writeFile,
5
8
  } from 'node:fs/promises';
6
9
  import path from 'node:path';
7
10
  import { fileURLToPath } from 'node:url';
8
- import { parseTree } from 'jsonc-parser';
11
+ import {
12
+ isAuthoringPathToken,
13
+ matchFlagCommand,
14
+ parseAuthoringJson,
15
+ readAuthoringJsonFile,
16
+ resolveAuthoringInputPath,
17
+ } from './todo-authoring-input.mjs';
9
18
 
10
19
  import {
11
20
  TODO_COORDINATION_MODES,
@@ -169,7 +178,6 @@ import { commitTodoStoreMutation } from './todo-store-git-transaction.mjs';
169
178
 
170
179
  const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
171
180
  const DEFAULT_GANTT_SCOPE = 'live';
172
- const MAX_MIGRATION_INPUT_BYTES = 8_388_608;
173
181
  const MAX_NOTE_INPUT_BYTES = 16_384;
174
182
  const ACTOR_ENV_KEYS = Object.freeze([
175
183
  'LATTICE_TODO_ACTOR_HOST',
@@ -315,25 +323,11 @@ function selectNoteTask(member, requestedTaskId) {
315
323
  }
316
324
 
317
325
  async function readNoteTextInput(repoRoot, inputRef) {
318
- if (!isTodoRef(inputRef)) throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo');
319
- const canonicalRoot = await realpath(repoRoot);
320
- const absolute = path.resolve(canonicalRoot, inputRef);
321
- if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) {
322
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo');
323
- }
324
- let metadata;
325
- try { metadata = await lstat(absolute); } catch {
326
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_missing');
327
- }
328
- if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_NOTE_INPUT_BYTES) {
329
- throw new TodoStoreError('INPUT_UNREADABLE', 'unsafe_or_oversized_note_input');
330
- }
331
- const resolved = await realpath(absolute);
332
- if (resolved !== absolute || !within(canonicalRoot, resolved)) {
333
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_alias_or_escape');
326
+ const located = await resolveAuthoringInputPath(repoRoot, inputRef);
327
+ const bytes = await readFile(located.absolute);
328
+ if (bytes.length > MAX_NOTE_INPUT_BYTES) {
329
+ throw new TodoStoreError('INPUT_TOO_LARGE', 'note_input_too_large');
334
330
  }
335
- const bytes = await readFile(resolved);
336
- if (bytes.length > MAX_NOTE_INPUT_BYTES) throw new TodoStoreError('INPUT_TOO_LARGE', 'note_input_too_large');
337
331
  try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
338
332
  catch { throw new TodoStoreError('INPUT_UNREADABLE', 'note_input_invalid_utf8'); }
339
333
  }
@@ -368,58 +362,8 @@ function within(root, candidate) {
368
362
  return candidate === root || candidate.startsWith(`${root}${path.sep}`);
369
363
  }
370
364
 
371
- function hasDuplicateJsonKey(node) {
372
- if (node?.type === 'object') {
373
- const keys = new Set();
374
- for (const property of node.children ?? []) {
375
- const [key, value] = property.children ?? [];
376
- if (keys.has(key?.value) || hasDuplicateJsonKey(value)) return true;
377
- keys.add(key?.value);
378
- }
379
- } else if (node?.type === 'array') {
380
- return (node.children ?? []).some(hasDuplicateJsonKey);
381
- }
382
- return false;
383
- }
384
-
385
365
  async function readMigrationInput(repoRoot, inputRef, { requireValid = true } = {}) {
386
- const canonicalRoot = await realpath(repoRoot);
387
- const absolute = path.resolve(canonicalRoot, inputRef);
388
- if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) {
389
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
390
- }
391
- let stats;
392
- try { stats = await lstat(absolute); } catch {
393
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_missing', undefined, { input_ref: inputRef });
394
- }
395
- if (stats.isSymbolicLink() || !stats.isFile()) {
396
- throw new TodoStoreError('INPUT_UNREADABLE', 'unsafe_input_path', undefined, { input_ref: inputRef });
397
- }
398
- const resolved = await realpath(absolute);
399
- if (resolved !== absolute || !within(canonicalRoot, resolved)) {
400
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_alias_or_escape', undefined, { input_ref: inputRef });
401
- }
402
- if (stats.size > MAX_MIGRATION_INPUT_BYTES) {
403
- throw new TodoStoreError('INPUT_TOO_LARGE', 'input_size_limit_exceeded');
404
- }
405
- const bytes = await readFile(resolved);
406
- if (bytes.length > MAX_MIGRATION_INPUT_BYTES) {
407
- throw new TodoStoreError('INPUT_TOO_LARGE', 'input_size_limit_exceeded');
408
- }
409
- let text;
410
- try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch {
411
- throw new TodoStoreError('INVALID_JSON', 'invalid_utf8');
412
- }
413
- const parseErrors = [];
414
- const tree = parseTree(text, parseErrors, { allowTrailingComma: false, disallowComments: true });
415
- if (parseErrors.length > 0 || tree === undefined) {
416
- throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
417
- }
418
- if (hasDuplicateJsonKey(tree)) throw new TodoStoreError('INVALID_JSON', 'duplicate_key');
419
- let extraction;
420
- try { extraction = JSON.parse(text); } catch {
421
- throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
422
- }
366
+ const extraction = await readAuthoringJsonFile(repoRoot, inputRef);
423
367
  if (!requireValid) return extraction;
424
368
  if (![TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(extraction?.schema)) {
425
369
  throw new TodoStoreError('INVALID_TODO_EXTRACTION', 'todo_extraction_schema_unsupported', undefined, {
@@ -454,54 +398,14 @@ async function readRevisionInput(repoRoot, inputRef, {
454
398
  invalidReason = 'revision_schema_or_digest_invalid',
455
399
  explain = explainTodoRevision,
456
400
  } = {}) {
457
- const canonicalRoot = await realpath(repoRoot);
458
- const absolute = path.resolve(canonicalRoot, inputRef);
459
- if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) {
460
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
461
- }
462
- let stats;
463
- try { stats = await lstat(absolute); } catch {
464
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_missing', undefined, { input_ref: inputRef });
465
- }
466
- if (stats.isSymbolicLink() || !stats.isFile()) {
467
- throw new TodoStoreError('INPUT_UNREADABLE', 'unsafe_input_path', undefined, { input_ref: inputRef });
468
- }
469
- const resolved = await realpath(absolute);
470
- if (resolved !== absolute || !within(canonicalRoot, resolved)) {
471
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_alias_or_escape', undefined, { input_ref: inputRef });
472
- }
473
- if (stats.size > MAX_MIGRATION_INPUT_BYTES) throw new TodoStoreError('INPUT_TOO_LARGE', 'input_size_limit_exceeded');
474
- const bytes = await readFile(resolved);
475
- if (bytes.length > MAX_MIGRATION_INPUT_BYTES) throw new TodoStoreError('INPUT_TOO_LARGE', 'input_size_limit_exceeded');
476
- let text;
477
- try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch {
478
- throw new TodoStoreError('INVALID_JSON', 'invalid_utf8');
479
- }
480
- if (!text.endsWith('\n') || text.startsWith('\uFEFF') || text.includes('\r')
481
- || text.slice(0, -1).includes('\n')) {
482
- throw new TodoStoreError(invalidCode, 'non_canonical_revision_bytes');
483
- }
484
- const parseErrors = [];
485
- const tree = parseTree(text.slice(0, -1), parseErrors, { allowTrailingComma: false, disallowComments: true });
486
- if (parseErrors.length > 0 || tree === undefined) throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
487
- if (hasDuplicateJsonKey(tree)) throw new TodoStoreError('INVALID_JSON', 'duplicate_key');
488
- let revision;
489
- try { revision = JSON.parse(text.slice(0, -1)); } catch {
490
- throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
491
- }
401
+ const revision = await readAuthoringJsonFile(repoRoot, inputRef, { invalidCode });
492
402
  if (!validate(revision)) {
493
- // 「schema_or_digest_invalid」だけでは何のfieldがどう壊れているか分からない
494
- // (ADR 0130の案内規律)。explainは可否判定を変えず、診断だけを追加する。
495
- // 呼び出し元がexplainを渡さない(phase decision入力等)場合はdetail無しのまま。
496
403
  const explained = explain === null ? null : explain(revision);
497
404
  throw new TodoStoreError(invalidCode, invalidReason, undefined,
498
405
  explained === null || explained.valid ? undefined : {
499
406
  violation_reason: explained.reason, violation_path: explained.path,
500
407
  });
501
408
  }
502
- if (text !== `${canonicalizeTodoArtifact(revision)}\n`) {
503
- throw new TodoStoreError(invalidCode, 'non_canonical_revision_bytes');
504
- }
505
409
  return revision;
506
410
  }
507
411
 
@@ -516,63 +420,97 @@ const EVIDENCE_DESCRIPTOR_EXPECTED = Object.freeze({
516
420
  + 'content_digestはblob bytesのsha256(hex)で得る。refsから到達可能なblobだけが検証を通る',
517
421
  });
518
422
 
519
- async function readEvidenceInput(repoRoot, inputRef) {
520
- return readJsonInput(repoRoot, inputRef, {
521
- validate: validateEvidenceDescriptor, invalidCode: 'INVALID_EVIDENCE',
522
- expected: EVIDENCE_DESCRIPTOR_EXPECTED,
523
- });
423
+ function evidenceIdFor(planKey, taskId) {
424
+ const compact = `ev-${planKey}-${taskId}`.replace(/[^0-9A-Za-z._-]/gu, '-').slice(0, 128);
425
+ return isTodoIdentifier(compact) ? compact : 'evidence';
524
426
  }
525
427
 
526
- async function readJsonInput(repoRoot, inputRef, { validate, invalidCode, expected = null }) {
527
- if (!isTodoRef(inputRef)) {
528
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
529
- }
530
- const canonicalRoot = await realpath(repoRoot);
531
- const absolute = path.resolve(canonicalRoot, inputRef);
532
- if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) {
533
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
534
- }
535
- let stats;
536
- try { stats = await lstat(absolute); } catch {
537
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_missing', undefined, { input_ref: inputRef });
538
- }
539
- if (stats.isSymbolicLink() || !stats.isFile()) {
540
- throw new TodoStoreError('INPUT_UNREADABLE', 'unsafe_input_path', undefined, { input_ref: inputRef });
541
- }
542
- const resolved = await realpath(absolute);
543
- if (resolved !== absolute || !within(canonicalRoot, resolved)) {
544
- throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_alias_or_escape', undefined, { input_ref: inputRef });
545
- }
546
- if (stats.size > MAX_MIGRATION_INPUT_BYTES) {
547
- throw new TodoStoreError('INPUT_TOO_LARGE', 'input_size_limit_exceeded');
548
- }
549
- const bytes = await readFile(resolved);
550
- if (bytes.length > MAX_MIGRATION_INPUT_BYTES) {
551
- throw new TodoStoreError('INPUT_TOO_LARGE', 'input_size_limit_exceeded');
428
+ function mediaTypeFor(ref) {
429
+ if (ref.endsWith('.md')) return 'text/markdown';
430
+ if (ref.endsWith('.json')) return 'application/json';
431
+ return 'text/plain';
432
+ }
433
+
434
+ function looksLikeEvidenceDescriptor(value) {
435
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
436
+ }
437
+
438
+ function writeEvidenceBlob(repoRoot, bytes) {
439
+ const oid = gitSync(['hash-object', '-w', '--stdin'], {
440
+ cwd: repoRoot, input: bytes, encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'],
441
+ }).trim();
442
+ return {
443
+ git_blob_oid: oid,
444
+ content_digest: createHash('sha256').update(bytes).digest('hex'),
445
+ };
446
+ }
447
+
448
+ async function resolveDoneEvidence({ repoRoot, evidenceRef, evidenceMessage, planKey, taskId }) {
449
+ if (typeof evidenceMessage === 'string') {
450
+ const bytes = Buffer.from(evidenceMessage, 'utf8');
451
+ const descriptor = {
452
+ evidence_id: evidenceIdFor(planKey, taskId),
453
+ repo_id: 'self',
454
+ path: `.lattice/todo/evidence/${planKey}/${taskId}.md`,
455
+ ...writeEvidenceBlob(repoRoot, bytes),
456
+ media_type: 'text/markdown',
457
+ anchor_digest: null,
458
+ };
459
+ if (!validateEvidenceDescriptor(descriptor) || !isTodoRef(descriptor.path)) {
460
+ throw new TodoStoreError('INVALID_EVIDENCE', 'authored_evidence_descriptor_invalid');
461
+ }
462
+ return descriptor;
552
463
  }
464
+ const located = await resolveAuthoringInputPath(repoRoot, evidenceRef);
465
+ const bytes = await readFile(located.absolute);
553
466
  let text;
554
- try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch {
555
- throw new TodoStoreError('INVALID_JSON', 'invalid_utf8');
556
- }
557
- if (text.startsWith('\uFEFF') || text.includes('\r')) {
558
- throw new TodoStoreError('INVALID_JSON', 'non_portable_json_bytes');
467
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
468
+ catch { throw new TodoStoreError('INVALID_EVIDENCE', 'evidence_input_invalid_utf8'); }
469
+ let parsed = null;
470
+ try {
471
+ parsed = parseAuthoringJson(text, { invalidCode: 'INVALID_JSON' });
472
+ } catch (error) {
473
+ if (error instanceof TodoStoreError && error.code !== 'INVALID_JSON') throw error;
559
474
  }
560
- // parse失敗は「JSONでないファイル(証拠本体など)をそのまま渡した」誤用が大半なので、
561
- // 期待形を知っている入口では expected を同梱して次の一手を示す(ADR 0130)。
562
- const parseFailureDetail = expected === null ? undefined : { expected };
563
- const parseErrors = [];
564
- const tree = parseTree(text, parseErrors, { allowTrailingComma: false, disallowComments: true });
565
- if (parseErrors.length > 0 || tree === undefined) {
566
- throw new TodoStoreError('INVALID_JSON', 'json_parse_failed', undefined, parseFailureDetail);
475
+ if (looksLikeEvidenceDescriptor(parsed)) {
476
+ if (!validateEvidenceDescriptor(parsed)) {
477
+ throw new TodoStoreError('INVALID_EVIDENCE', 'schema_invalid', undefined, {
478
+ expected: EVIDENCE_DESCRIPTOR_EXPECTED,
479
+ });
480
+ }
481
+ return parsed;
482
+ }
483
+ const descriptor = {
484
+ evidence_id: evidenceIdFor(planKey, taskId),
485
+ repo_id: 'self',
486
+ path: located.inputRef,
487
+ ...writeEvidenceBlob(repoRoot, bytes),
488
+ media_type: mediaTypeFor(located.inputRef),
489
+ anchor_digest: null,
490
+ };
491
+ if (!validateEvidenceDescriptor(descriptor)) {
492
+ throw new TodoStoreError('INVALID_EVIDENCE', 'authored_evidence_descriptor_invalid');
567
493
  }
568
- if (hasDuplicateJsonKey(tree)) throw new TodoStoreError('INVALID_JSON', 'duplicate_key');
494
+ return descriptor;
495
+ }
496
+
497
+ async function readEvidenceInput(repoRoot, inputRef) {
498
+ return resolveDoneEvidence({
499
+ repoRoot, evidenceRef: inputRef, evidenceMessage: null, planKey: 'evidence', taskId: 'body',
500
+ });
501
+ }
502
+
503
+ async function readJsonInput(repoRoot, inputRef, { validate, invalidCode, expected = null }) {
569
504
  let descriptor;
570
- try { descriptor = JSON.parse(text); } catch {
571
- throw new TodoStoreError('INVALID_JSON', 'json_parse_failed', undefined, parseFailureDetail);
505
+ try {
506
+ descriptor = await readAuthoringJsonFile(repoRoot, inputRef, { invalidCode: 'INVALID_JSON' });
507
+ } catch (error) {
508
+ if (error?.code === 'INVALID_JSON' && expected !== null && error.detail?.reason === 'json_parse_failed') {
509
+ throw new TodoStoreError('INVALID_JSON', 'json_parse_failed', undefined, { expected });
510
+ }
511
+ throw error;
572
512
  }
573
513
  if (!validate(descriptor)) {
574
- // 「schema_invalid」だけを返すと、呼び出したAIは何をどう直せばよいか分からない。
575
- // 期待する形を渡されている入口は、それをそのまま返す(ADR 0130の案内規律)。
576
514
  throw new TodoStoreError(invalidCode, 'schema_invalid', undefined,
577
515
  expected === null ? undefined : { expected });
578
516
  }
@@ -607,23 +545,35 @@ async function readStructureSetDraft(repoRoot, inputRef) {
607
545
  });
608
546
  }
609
547
 
548
+ function sanitizeActorIdentifier(raw, fallback) {
549
+ const compact = String(raw ?? '').replace(/[^0-9A-Za-z._-]/gu, '-').replace(/^-+/u, '').slice(0, 128);
550
+ return isTodoIdentifier(compact) ? compact : fallback;
551
+ }
552
+
610
553
  function mutationActor(env) {
611
- const entries = ACTOR_ENV_KEYS.map((key) => ({ key, value: env[key] }));
612
- const missingEnvironment = entries
613
- .filter(({ value }) => typeof value !== 'string' || value.length === 0)
614
- .map(({ key }) => key);
615
- const invalidEnvironment = entries
554
+ const provided = ACTOR_ENV_KEYS.map((key) => ({ key, value: env[key] }));
555
+ const invalidEnvironment = provided
616
556
  .filter(({ value }) => typeof value === 'string' && value.length > 0 && !isTodoIdentifier(value))
617
557
  .map(({ key }) => key);
618
- if (missingEnvironment.length > 0 || invalidEnvironment.length > 0) {
558
+ if (invalidEnvironment.length > 0) {
619
559
  throw new TodoStoreError('ACTOR_UNRESOLVED', 'actor_environment_invalid', undefined, {
620
560
  required_environment: ACTOR_ENV_KEYS,
621
- missing_environment: missingEnvironment,
561
+ missing_environment: [],
622
562
  invalid_environment: invalidEnvironment,
623
563
  next_action: 'set_required_actor_environment_and_retry',
624
564
  });
625
565
  }
626
- return { host: entries[0].value, session: entries[1].value, agent: entries[2].value };
566
+ return {
567
+ host: isTodoIdentifier(env.LATTICE_TODO_ACTOR_HOST)
568
+ ? env.LATTICE_TODO_ACTOR_HOST
569
+ : sanitizeActorIdentifier(hostname(), 'host'),
570
+ session: isTodoIdentifier(env.LATTICE_TODO_ACTOR_SESSION)
571
+ ? env.LATTICE_TODO_ACTOR_SESSION
572
+ : 'session',
573
+ agent: isTodoIdentifier(env.LATTICE_TODO_ACTOR_AGENT)
574
+ ? env.LATTICE_TODO_ACTOR_AGENT
575
+ : sanitizeActorIdentifier(env.USER ?? env.LOGNAME, 'agent'),
576
+ };
627
577
  }
628
578
 
629
579
  /**
@@ -664,11 +614,15 @@ function terminalAuditDoneAdvisory(plan, phases) {
664
614
  }
665
615
 
666
616
  async function mutate({
667
- repoRoot, env, planKey, taskId, kind, payload, evidenceRef, advisory = null,
668
- noteContext = null, structureContext = null, testResultRef = null,
617
+ repoRoot, env, planKey, taskId, kind, payload, evidenceRef, evidenceMessage = null,
618
+ advisory = null, noteContext = null, structureContext = null, testResultRef = null,
669
619
  }) {
670
620
  const actor = mutationActor(env);
671
- const evidence = evidenceRef === null ? null : await readEvidenceInput(repoRoot, evidenceRef);
621
+ const evidence = evidenceRef === null && evidenceMessage === null
622
+ ? null
623
+ : await resolveDoneEvidence({
624
+ repoRoot, evidenceRef, evidenceMessage, planKey, taskId,
625
+ });
672
626
  const testResult = testResultRef === null ? null : await readTestResultInput(repoRoot, testResultRef);
673
627
  let eventPayload = payload;
674
628
  if (kind === 'done' && payload === 'authored') {
@@ -731,13 +685,23 @@ async function startStructureContext({ repoRoot, store, planKey, taskId }) {
731
685
  structure_set_digest: null, task: null, next_actions: [],
732
686
  };
733
687
  if (source.structure_set_digest !== binding.structure_set_digest) {
734
- throw new TodoStoreError('STRUCTURE_LIFECYCLE_GATE_FAILED',
735
- 'enabled_structure_source_unreadable', undefined, { plan_key: planKey });
688
+ return {
689
+ status: 'unreadable', enabled: true, freshness: 'stale',
690
+ stale_reasons: ['enabled_structure_source_unreadable'],
691
+ structure_set_digest: source.structure_set_digest,
692
+ task: null,
693
+ next_actions: [`lattice todo structure compile --plan ${planKey} --input <file>`],
694
+ };
736
695
  }
737
696
  const task = source.tasks.find(({ task_id: id }) => id === taskId);
738
697
  if (task === undefined) {
739
- throw new TodoStoreError('STRUCTURE_LIFECYCLE_GATE_FAILED',
740
- 'enabled_structure_task_missing', undefined, { plan_key: planKey, task_id: taskId });
698
+ return {
699
+ status: 'unreadable', enabled: true, freshness: 'stale',
700
+ stale_reasons: ['enabled_structure_task_missing'],
701
+ structure_set_digest: source.structure_set_digest,
702
+ task: null,
703
+ next_actions: [`lattice todo structure compile --plan ${planKey} --input <file>`],
704
+ };
741
705
  }
742
706
  const state = await readTodoStructureState({ repoRoot, store, planKey });
743
707
  return {
@@ -873,10 +837,26 @@ async function startTask({
873
837
  });
874
838
  }
875
839
  const resolvedTaskId = readyTask?.task_id ?? taskMatches[0].task_id;
876
- // noteはjournal appendより前に読む。読めなければstart自体を止め、部分進行を作らない。
877
- const { context: noteContext } = await readTodoNoteContext({
878
- repoRoot, store, planKey, taskId: resolvedTaskId,
879
- });
840
+ // noteはjournal appendより前に読む。壊れていてもstartは通す(ADR 0166 / 0181)。
841
+ let noteContext;
842
+ try {
843
+ ({ context: noteContext } = await readTodoNoteContext({
844
+ repoRoot, store, planKey, taskId: resolvedTaskId,
845
+ }));
846
+ } catch (error) {
847
+ if (!['NOTE_LOG_CORRUPT', 'NOTE_PROJECTION_INVALID'].includes(error?.code)) {
848
+ throw error;
849
+ }
850
+ noteContext = {
851
+ schema: 'lattice.todo_note_context.unreadable.v1',
852
+ project_id: member.plan.project_id,
853
+ plan_key: planKey,
854
+ task_id: resolvedTaskId,
855
+ code: error.code,
856
+ reason: error.detail?.reason ?? 'note_context_unreadable',
857
+ next_action: `lattice todo note list --plan ${planKey} --json`,
858
+ };
859
+ }
880
860
  // 助言はjournalへ書く前に確定させる。計算できないならstart自体を止める。
881
861
  const advisory = await startAdvisory({
882
862
  repoRoot, store, projection, planKey, taskId: resolvedTaskId,
@@ -1788,25 +1768,6 @@ function changedPathsSince(repoRoot, baseSha) {
1788
1768
  .sort();
1789
1769
  }
1790
1770
 
1791
- function requireCleanWorktree(repoRoot) {
1792
- let porcelain;
1793
- try {
1794
- porcelain = gitSync(['status', '--porcelain'], {
1795
- cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
1796
- });
1797
- } catch {
1798
- throw new TodoStoreError('INDEPENDENCE_BASE_UNRESOLVED', 'git_status_unresolved');
1799
- }
1800
- const dirty = porcelain.split('\n').filter((line) => line.trim().length > 0);
1801
- if (dirty.length > 0) {
1802
- // 未commitの観測を検証済み証拠として固定化しない(ADR 0127 Decision 3)。
1803
- throw new TodoStoreError('INDEPENDENCE_WORKTREE_DIRTY', 'worktree_not_clean', undefined, {
1804
- changed_entries: dirty.length,
1805
- next_action: 'commit_or_stash_then_retry',
1806
- });
1807
- }
1808
- }
1809
-
1810
1771
  function structureGitIdentity(repoRoot, baselineSha, violations) {
1811
1772
  let currentHeadSha = null;
1812
1773
  try {
@@ -1988,7 +1949,7 @@ function parseAutomatedStructureRealizeArgs(argv) {
1988
1949
  let realizedRef = null;
1989
1950
  if (argv[6] === '--planned') {
1990
1951
  cursor = 7; usePlanned = true;
1991
- } else if (argv[6] === '--realized' && isTodoRef(argv[7])) {
1952
+ } else if (argv[6] === '--realized' && isAuthoringPathToken(argv[7])) {
1992
1953
  cursor = 8; usePlanned = false; realizedRef = argv[7];
1993
1954
  } else {
1994
1955
  return null;
@@ -2656,7 +2617,6 @@ async function seamProfile({ repoRoot, planKey, filePath }) {
2656
2617
  }
2657
2618
 
2658
2619
  async function seamProposalCompile({ repoRoot, planKey }) {
2659
- requireCleanWorktree(repoRoot);
2660
2620
  const currentBaseSha = currentHeadSha(repoRoot);
2661
2621
  const store = await readTodoStore({ repoRoot });
2662
2622
  const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
@@ -3421,26 +3381,22 @@ function writesTodoStore(argv) {
3421
3381
 
3422
3382
  async function ensureActiveProjectDashboard({ repoRoot, env }) {
3423
3383
  if (env.LATTICE_DASHBOARD_AUTOSTART === '0') return null;
3424
- const actorIdentity = ACTOR_ENV_KEYS.map((key) => env[key]);
3425
- if (!actorIdentity.every(isTodoIdentifier)) return null;
3426
- const sessionId = env.LATTICE_TODO_ACTOR_SESSION;
3427
- const store = await readTodoStoreStable({ repoRoot });
3384
+ let actor;
3385
+ try { actor = mutationActor(env); } catch { return null; }
3386
+ const sessionId = actor.session;
3387
+ let store;
3388
+ try { store = await readTodoStoreStable({ repoRoot }); }
3389
+ catch { return null; }
3428
3390
  let identity;
3429
- try { identity = await resolveProjectIdentity({ repoRoot, projectId: store.project_id, env }); } catch (error) {
3430
- throw new TodoStoreError(error?.code ?? 'PROJECT_IDENTITY_INVALID',
3431
- 'project_identity_resolve_failed', undefined, error?.detail ?? {});
3432
- }
3391
+ try { identity = await resolveProjectIdentity({ repoRoot, projectId: store.project_id, env }); }
3392
+ catch { return null; }
3433
3393
  try {
3434
3394
  return await ensureTodoDashboardActivity({
3435
3395
  repoRoot, projectId: store.project_id, displayName: identity.displayName, sessionId, env,
3436
3396
  });
3437
- } catch (error) {
3438
- throw new TodoStoreError(error?.code ?? 'DASHBOARD_DAEMON_UNAVAILABLE',
3439
- 'dashboard_daemon_ensure_failed', undefined, {
3440
- project_id: store.project_id,
3441
- ...(typeof error?.detail?.next_action === 'string'
3442
- ? { next_action: error.detail.next_action } : {}),
3443
- });
3397
+ } catch {
3398
+ // dashboardは副作用。start/doneをdashboard故障で止めない(ADR 0181)。
3399
+ return null;
3444
3400
  }
3445
3401
  }
3446
3402
 
@@ -3578,38 +3534,6 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3578
3534
  return atomicStoreCommitUnsupported(stderr, argv);
3579
3535
  }
3580
3536
 
3581
- if (argv[0] === 'migrate' && argv[1] === '--input'
3582
- && typeof argv[2] === 'string' && path.isAbsolute(argv[2])) {
3583
- return typedArgumentFailure(stderr, 'INPUT_OUTSIDE_REPOSITORY', 'absolute_input_path_rejected', {
3584
- argument: '--input', expected: 'repo-relative path', actual: 'absolute path',
3585
- next_action: 'place_the_input_inside_the_repository_and_pass_a_repo_relative_path',
3586
- });
3587
- }
3588
-
3589
- if (argv[0] === 'structure' && ['input', 'compile'].includes(argv[1])
3590
- && argv[4] === '--input' && typeof argv[5] === 'string' && path.isAbsolute(argv[5])) {
3591
- return typedArgumentFailure(stderr, 'INPUT_OUTSIDE_REPOSITORY', 'absolute_input_path_rejected', {
3592
- argument: '--input', expected: 'repo-relative path', actual: 'absolute path',
3593
- next_action: 'place_the_input_inside_the_repository_and_pass_a_repo_relative_path',
3594
- });
3595
- }
3596
-
3597
- if (argv[0] === 'structure' && argv[1] === 'realize'
3598
- && argv[6] === '--input' && typeof argv[7] === 'string' && path.isAbsolute(argv[7])) {
3599
- return typedArgumentFailure(stderr, 'INPUT_OUTSIDE_REPOSITORY', 'absolute_input_path_rejected', {
3600
- argument: '--input', expected: 'repo-relative path', actual: 'absolute path',
3601
- next_action: 'place_the_input_inside_the_repository_and_pass_a_repo_relative_path',
3602
- });
3603
- }
3604
-
3605
- if (argv[0] === 'structure' && argv[1] === 'realize'
3606
- && argv[6] === '--realized' && typeof argv[7] === 'string' && path.isAbsolute(argv[7])) {
3607
- return typedArgumentFailure(stderr, 'INPUT_OUTSIDE_REPOSITORY', 'absolute_input_path_rejected', {
3608
- argument: '--realized', expected: 'repo-relative path', actual: 'absolute path',
3609
- next_action: 'place_the_input_inside_the_repository_and_pass_a_repo_relative_path',
3610
- });
3611
- }
3612
-
3613
3537
  // `--schema --json`はstoreを読まない決定的な出力(`plan create --schema`と同じ規律)。
3614
3538
  // 通常dispatchより前に処理し、repoRoot解決やdashboard daemon起動を経由させない。
3615
3539
  if (argv.length === 3 && argv[1] === '--schema' && argv[2] === '--json'
@@ -3663,33 +3587,25 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3663
3587
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3664
3588
  && (argv.length === 3 || argv[3] === '--json')) {
3665
3589
  action = (repoRoot) => bindings({ repoRoot, requestedPlanKey: argv[2] });
3666
- } else if ((argv.length === 7 || argv.length === 9) && argv[0] === 'note'
3667
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3668
- && argv[3] === '--task' && isTodoIdentifier(argv[4])
3669
- && ['--message', '--input'].includes(argv[5])
3670
- && ((argv[5] === '--message' && argv[6].length > 0)
3671
- || (argv[5] === '--input' && isTodoRef(argv[6])))
3672
- && (argv.length === 7 || (argv[7] === '--supersedes' && isTodoDigest(argv[8])))) {
3673
- action = (repoRoot) => appendNote({
3674
- repoRoot, env, planKey: argv[2], taskId: argv[4],
3675
- message: argv[5] === '--message' ? argv[6] : null,
3676
- inputRef: argv[5] === '--input' ? argv[6] : null,
3677
- supersedes: argv[8] ?? null,
3678
- });
3679
- } else if ((argv.length === 5 || argv.length === 7) && argv[0] === 'note'
3680
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3681
- && ['--message', '--input'].includes(argv[3])
3682
- && ((argv[3] === '--message' && argv[4].length > 0)
3683
- || (argv[3] === '--input' && isTodoRef(argv[4])))
3684
- && (argv.length === 5 || (argv[5] === '--supersedes' && isTodoDigest(argv[6])))) {
3685
- // `--task`省略でplan単位note。工程レベルの義務(順序制約・一度きりの観測が在ること)は
3686
- // 特定のtaskに属さない。
3687
- action = (repoRoot) => appendNote({
3688
- repoRoot, env, planKey: argv[2], taskId: null,
3689
- message: argv[3] === '--message' ? argv[4] : null,
3690
- inputRef: argv[3] === '--input' ? argv[4] : null,
3691
- supersedes: argv[6] ?? null,
3692
- });
3590
+ } else if (argv[0] === 'note' && argv[1] !== 'list') {
3591
+ const flags = matchFlagCommand(argv, ['note'], {
3592
+ known: ['plan', 'task', 'message', 'input', 'supersedes'],
3593
+ required: ['plan'],
3594
+ });
3595
+ const hasMessage = typeof flags?.message === 'string' && flags.message.length > 0;
3596
+ const hasInput = isAuthoringPathToken(flags?.input);
3597
+ if (flags !== null && isTodoIdentifier(flags.plan)
3598
+ && (flags.task === undefined || isTodoIdentifier(flags.task))
3599
+ && (flags.supersedes === undefined || isTodoDigest(flags.supersedes))
3600
+ && hasMessage !== hasInput) {
3601
+ action = (repoRoot) => appendNote({
3602
+ repoRoot, env, planKey: flags.plan,
3603
+ taskId: flags.task ?? null,
3604
+ message: hasMessage ? flags.message : null,
3605
+ inputRef: hasInput ? flags.input : null,
3606
+ supersedes: flags.supersedes ?? null,
3607
+ });
3608
+ }
3693
3609
  } else if (argv.length === 5 && argv[0] === 'note' && argv[1] === 'list'
3694
3610
  && argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--json') {
3695
3611
  action = (repoRoot) => listNotes({ repoRoot, planKey: argv[3], taskId: null });
@@ -3716,34 +3632,43 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3716
3632
  repoRoot, env, fromPlanKey: argv[3], fromTaskId: argv[5],
3717
3633
  toPlanKey: argv[7], toTaskId: argv[9], reason: argv[11],
3718
3634
  });
3719
- } else if (argv.length === 6 && argv[0] === 'independence' && argv[1] === 'compile'
3720
- && argv[2] === '--plan' && isTodoIdentifier(argv[3]) && argv[4] === '--input') {
3721
- action = (repoRoot) => independenceCompile({
3722
- repoRoot, planKey: argv[3], inputRef: argv[5],
3723
- });
3724
- } else if (argv.length === 8 && argv[0] === 'structure' && argv[1] === 'input'
3725
- && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3726
- && argv[4] === '--input' && isTodoRef(argv[5])
3727
- && argv[6] === '--dry-run' && argv[7] === '--json') {
3728
- action = (repoRoot) => structureInputDryRun({
3729
- repoRoot, planKey: argv[3], inputRef: argv[5],
3730
- });
3731
- } else if (argv.length === 6 && argv[0] === 'structure' && argv[1] === 'input'
3732
- && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3733
- && argv[4] === '--input' && isTodoRef(argv[5])) {
3734
- action = (repoRoot) => structureInput({
3735
- repoRoot, planKey: argv[3], inputRef: argv[5],
3635
+ } else if (argv[0] === 'independence' && argv[1] === 'compile') {
3636
+ const flags = matchFlagCommand(argv, ['independence', 'compile'], {
3637
+ known: ['plan', 'input'], required: ['plan', 'input'],
3736
3638
  });
3737
- } else if (argv.length === 6 && argv[0] === 'structure' && argv[1] === 'compile'
3738
- && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3739
- && argv[4] === '--input' && isTodoRef(argv[5])) {
3740
- action = (repoRoot) => structureCompile({
3741
- repoRoot, env, planKey: argv[3], inputRef: argv[5],
3639
+ if (flags !== null && isTodoIdentifier(flags.plan) && isAuthoringPathToken(flags.input)) {
3640
+ action = (repoRoot) => independenceCompile({
3641
+ repoRoot, planKey: flags.plan, inputRef: flags.input,
3642
+ });
3643
+ }
3644
+ } else if (argv[0] === 'structure' && argv[1] === 'input') {
3645
+ const flags = matchFlagCommand(argv, ['structure', 'input'], {
3646
+ known: ['plan', 'input', 'dry-run', 'json'],
3647
+ required: ['plan', 'input'],
3648
+ booleans: ['dry-run', 'json'],
3649
+ });
3650
+ if (flags !== null && isTodoIdentifier(flags.plan) && isAuthoringPathToken(flags.input)) {
3651
+ action = flags['dry-run'] === true
3652
+ ? (repoRoot) => structureInputDryRun({
3653
+ repoRoot, planKey: flags.plan, inputRef: flags.input,
3654
+ })
3655
+ : (repoRoot) => structureInput({
3656
+ repoRoot, planKey: flags.plan, inputRef: flags.input,
3657
+ });
3658
+ }
3659
+ } else if (argv[0] === 'structure' && argv[1] === 'compile') {
3660
+ const flags = matchFlagCommand(argv, ['structure', 'compile'], {
3661
+ known: ['plan', 'input'], required: ['plan', 'input'],
3742
3662
  });
3663
+ if (flags !== null && isTodoIdentifier(flags.plan) && isAuthoringPathToken(flags.input)) {
3664
+ action = (repoRoot) => structureCompile({
3665
+ repoRoot, env, planKey: flags.plan, inputRef: flags.input,
3666
+ });
3667
+ }
3743
3668
  } else if (argv.length === 8 && argv[0] === 'structure' && argv[1] === 'realize'
3744
3669
  && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3745
3670
  && argv[4] === '--task' && isTodoIdentifier(argv[5])
3746
- && argv[6] === '--input' && isTodoRef(argv[7])) {
3671
+ && argv[6] === '--input' && isAuthoringPathToken(argv[7])) {
3747
3672
  action = (repoRoot) => structureRealize({
3748
3673
  repoRoot, env, planKey: argv[3], taskId: argv[5], inputRef: argv[7],
3749
3674
  });
@@ -3771,11 +3696,11 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3771
3696
  action = (repoRoot) => seamProposalApply({ repoRoot, planKey: argv[3] });
3772
3697
  } else if (argv.length === 7 && argv[0] === 'independence' && argv[1] === 'witness'
3773
3698
  && argv[2] === 'scaffold' && argv[3] === '--plan' && isTodoIdentifier(argv[4])
3774
- && argv[5] === '--input' && isTodoRef(argv[6])) {
3699
+ && argv[5] === '--input' && isAuthoringPathToken(argv[6])) {
3775
3700
  action = (repoRoot) => witnessScaffold({ repoRoot, planKey: argv[4], inputRef: argv[6] });
3776
3701
  } else if (argv.length === 6 && argv[0] === 'seam-proposal' && argv[1] === 'land'
3777
3702
  && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3778
- && argv[4] === '--names' && isTodoRef(argv[5])) {
3703
+ && argv[4] === '--names' && isAuthoringPathToken(argv[5])) {
3779
3704
  action = async (repoRoot) => seamProposalApply({
3780
3705
  repoRoot,
3781
3706
  planKey: argv[3],
@@ -3784,7 +3709,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3784
3709
  });
3785
3710
  } else if ((argv.length === 5 || argv.length === 6) && argv[0] === 'seam-profile'
3786
3711
  && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3787
- && argv[3] === '--file' && isTodoRef(argv[4])
3712
+ && argv[3] === '--file' && isAuthoringPathToken(argv[4])
3788
3713
  && (argv.length === 5 || argv[5] === '--json')) {
3789
3714
  action = (repoRoot) => seamProfile({ repoRoot, planKey: argv[2], filePath: argv[4] });
3790
3715
  } else if (argv.length === 4 && argv[0] === 'seam-proposal' && argv[1] === 'compile'
@@ -3824,32 +3749,46 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3824
3749
  next_action: 'lattice todo gantt serve --port 0',
3825
3750
  });
3826
3751
  };
3827
- } else if ((argv.length === 5 || argv.length === 6) && argv[0] === 'migrate'
3828
- && argv[1] === '--input' && isTodoRef(argv[2])
3829
- && argv[3] === '--dry-run' && argv[4] === '--json'
3830
- && (argv.length === 5 || argv[5] === '--serialization-reviewed')) {
3831
- action = (repoRoot) => migrateDryRun({ repoRoot, inputRef: argv[2] });
3832
- } else if ((argv.length === 3 || argv.length === 4 || argv.length === 5)
3833
- && argv[0] === 'migrate' && argv[1] === '--input' && isTodoRef(argv[2])
3834
- && (argv.length === 3
3835
- || (argv.length === 4 && ['--json', '--serialization-reviewed'].includes(argv[3]))
3836
- || (argv.length === 5 && argv[3] === '--serialization-reviewed' && argv[4] === '--json'))) {
3837
- action = (repoRoot) => migrate({ repoRoot, inputRef: argv[2] });
3838
- } else if (argv.length === 5 && argv[0] === 'revise'
3839
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3840
- && argv[3] === '--input' && isTodoRef(argv[4])) {
3841
- action = (repoRoot) => revise({ repoRoot, env, planKey: argv[2], inputRef: argv[4] });
3842
- } else if (argv.length === 5 && argv[0] === 'split'
3843
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3844
- && argv[3] === '--input' && isTodoRef(argv[4])) {
3845
- action = (repoRoot) => splitTodo({ repoRoot, env, planKey: argv[2], inputRef: argv[4] });
3846
- } else if (argv.length === 3 && argv[0] === 'revise-set'
3847
- && argv[1] === '--input' && isTodoRef(argv[2])) {
3848
- action = (repoRoot) => reviseSet({ repoRoot, env, inputRef: argv[2] });
3849
- } else if (argv.length === 5 && argv[0] === 'revise-phase'
3850
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3851
- && argv[3] === '--input' && isTodoRef(argv[4])) {
3852
- action = (repoRoot) => revisePhase({ repoRoot, env, planKey: argv[2], inputRef: argv[4] });
3752
+ } else if (argv[0] === 'migrate' && argv[1] !== '--schema') {
3753
+ const flags = matchFlagCommand(argv, ['migrate'], {
3754
+ known: ['input', 'json', 'serialization-reviewed', 'dry-run'],
3755
+ required: ['input'],
3756
+ booleans: ['json', 'serialization-reviewed', 'dry-run'],
3757
+ });
3758
+ if (flags !== null && isAuthoringPathToken(flags.input)) {
3759
+ action = flags['dry-run'] === true
3760
+ ? (repoRoot) => migrateDryRun({ repoRoot, inputRef: flags.input })
3761
+ : (repoRoot) => migrate({ repoRoot, inputRef: flags.input });
3762
+ }
3763
+ }
3764
+ if (action === null && argv[0] === 'revise') {
3765
+ const flags = matchFlagCommand(argv, ['revise'], {
3766
+ known: ['plan', 'input'], required: ['plan', 'input'],
3767
+ });
3768
+ if (flags !== null && isTodoIdentifier(flags.plan) && isAuthoringPathToken(flags.input)) {
3769
+ action = (repoRoot) => revise({ repoRoot, env, planKey: flags.plan, inputRef: flags.input });
3770
+ }
3771
+ } else if (argv[0] === 'split') {
3772
+ const flags = matchFlagCommand(argv, ['split'], {
3773
+ known: ['plan', 'input'], required: ['plan', 'input'],
3774
+ });
3775
+ if (flags !== null && isTodoIdentifier(flags.plan) && isAuthoringPathToken(flags.input)) {
3776
+ action = (repoRoot) => splitTodo({ repoRoot, env, planKey: flags.plan, inputRef: flags.input });
3777
+ }
3778
+ } else if (argv[0] === 'revise-set') {
3779
+ const flags = matchFlagCommand(argv, ['revise-set'], {
3780
+ known: ['input'], required: ['input'],
3781
+ });
3782
+ if (flags !== null && isAuthoringPathToken(flags.input)) {
3783
+ action = (repoRoot) => reviseSet({ repoRoot, env, inputRef: flags.input });
3784
+ }
3785
+ } else if (argv[0] === 'revise-phase') {
3786
+ const flags = matchFlagCommand(argv, ['revise-phase'], {
3787
+ known: ['plan', 'input'], required: ['plan', 'input'],
3788
+ });
3789
+ if (flags !== null && isTodoIdentifier(flags.plan) && isAuthoringPathToken(flags.input)) {
3790
+ action = (repoRoot) => revisePhase({ repoRoot, env, planKey: flags.plan, inputRef: flags.input });
3791
+ }
3853
3792
  } else if (argv.length === 4 && argv[0] === 'phase' && argv[1] === 'status'
3854
3793
  && argv[2] === '--plan' && isTodoIdentifier(argv[3])) {
3855
3794
  action = (repoRoot) => phaseStatus({ repoRoot, planKey: argv[3] });
@@ -3859,13 +3798,17 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3859
3798
  && argv[6] === '--reason' && argv[7].length > 0) {
3860
3799
  action = (repoRoot) => phaseMutation({ repoRoot, env, planKey: argv[3], phaseId: argv[5],
3861
3800
  kind: 'phase_review', payload: { reason: argv[7] } });
3862
- } else if (argv.length === 8 && argv[0] === 'phase'
3863
- && ['accept', 'reject'].includes(argv[1])
3864
- && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3865
- && argv[4] === '--phase' && isTodoIdentifier(argv[5])
3866
- && argv[6] === '--input' && isTodoRef(argv[7])) {
3867
- action = (repoRoot) => phaseDecision({ repoRoot, env, planKey: argv[3], phaseId: argv[5],
3868
- outcome: argv[1], inputRef: argv[7] });
3801
+ } else if (argv[0] === 'phase' && ['accept', 'reject'].includes(argv[1])) {
3802
+ const flags = matchFlagCommand(argv, ['phase', argv[1]], {
3803
+ known: ['plan', 'phase', 'input'], required: ['plan', 'phase', 'input'],
3804
+ });
3805
+ if (flags !== null && isTodoIdentifier(flags.plan) && isTodoIdentifier(flags.phase)
3806
+ && isAuthoringPathToken(flags.input)) {
3807
+ action = (repoRoot) => phaseDecision({
3808
+ repoRoot, env, planKey: flags.plan, phaseId: flags.phase,
3809
+ outcome: argv[1], inputRef: flags.input,
3810
+ });
3811
+ }
3869
3812
  } else if ((argv.length === 8 || argv.length === 10) && argv[0] === 'phase'
3870
3813
  && argv[1] === 'reopen' && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3871
3814
  && argv[4] === '--phase' && isTodoIdentifier(argv[5])
@@ -3884,56 +3827,98 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3884
3827
  && parseBaselineExceptFlags(argv.slice(4)) !== null) {
3885
3828
  const exceptPlanKeys = parseBaselineExceptFlags(argv.slice(4));
3886
3829
  action = (repoRoot) => phaseBaseline({ repoRoot, env, reason: argv[3], exceptPlanKeys });
3887
- } else if ((argv.length === 5 || argv.length === 6 || argv.length === 7 || argv.length === 8)
3888
- && argv[0] === 'start'
3889
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3890
- && argv[3] === '--task' && isTodoIdentifier(argv[4])
3891
- && (argv.length === 5 || (argv.length === 6 && argv[5] === '--parallel-frontier')
3892
- || ((argv.length === 7 || argv.length === 8)
3893
- && argv[5] === '--override-reason' && argv[6].length > 0
3894
- && (argv.length === 7 || argv[7] === '--serial-confirmed')))) {
3895
- const overrideReason = argv.length >= 7 ? argv[6] : null;
3896
- action = (repoRoot) => startTask({ repoRoot, env, planKey: argv[2], taskId: argv[4],
3897
- overrideReason, parallelFrontier: argv.length === 6 });
3898
- } else if (argv.length === 7 && argv[0] === 'retract'
3899
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3900
- && argv[3] === '--task' && isTodoIdentifier(argv[4])
3901
- && argv[5] === '--reason' && argv[6].length > 0) {
3902
- action = (repoRoot) => retractStart({
3903
- repoRoot, env, planKey: argv[2], taskId: argv[4], reason: argv[6],
3830
+ } else if (argv[0] === 'start') {
3831
+ const flags = matchFlagCommand(argv, ['start'], {
3832
+ known: ['plan', 'task', 'parallel-frontier', 'override-reason', 'serial-confirmed'],
3833
+ required: ['plan', 'task'],
3834
+ booleans: ['parallel-frontier', 'serial-confirmed'],
3835
+ });
3836
+ if (flags !== null && isTodoIdentifier(flags.plan) && isTodoIdentifier(flags.task)
3837
+ && (flags['override-reason'] === undefined || (typeof flags['override-reason'] === 'string'
3838
+ && flags['override-reason'].length > 0))) {
3839
+ action = (repoRoot) => startTask({
3840
+ repoRoot, env, planKey: flags.plan, taskId: flags.task,
3841
+ overrideReason: typeof flags['override-reason'] === 'string' ? flags['override-reason'] : null,
3842
+ parallelFrontier: flags['parallel-frontier'] === true,
3843
+ });
3844
+ }
3845
+ } else if (argv[0] === 'done') {
3846
+ const flags = matchFlagCommand(argv, ['done'], {
3847
+ known: ['plan', 'task', 'evidence', 'test-result', 'message'],
3848
+ required: ['plan', 'task'],
3849
+ });
3850
+ const hasEvidence = isAuthoringPathToken(flags?.evidence);
3851
+ const hasMessage = typeof flags?.message === 'string' && flags.message.length > 0;
3852
+ if (flags !== null && isTodoIdentifier(flags.plan) && isTodoIdentifier(flags.task)
3853
+ && hasEvidence !== hasMessage
3854
+ && (flags['test-result'] === undefined || isAuthoringPathToken(flags['test-result']))) {
3855
+ action = (repoRoot) => mutate({
3856
+ repoRoot, env, planKey: flags.plan, taskId: flags.task,
3857
+ kind: 'done', payload: 'authored',
3858
+ evidenceRef: hasEvidence ? flags.evidence : null,
3859
+ evidenceMessage: hasMessage ? flags.message : null,
3860
+ testResultRef: flags['test-result'] ?? null,
3861
+ });
3862
+ }
3863
+ }
3864
+ if (action === null && argv[0] === 'retract') {
3865
+ const flags = matchFlagCommand(argv, ['retract'], {
3866
+ known: ['plan', 'task', 'reason'], required: ['plan', 'task', 'reason'],
3904
3867
  });
3905
- } else if (argv.length === 7 && argv[0] === 'block'
3906
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3907
- && argv[3] === '--task' && isTodoIdentifier(argv[4])
3908
- && argv[5] === '--reason' && argv[6].length > 0) {
3909
- action = (repoRoot) => mutate({ repoRoot, env, planKey: argv[2], taskId: argv[4],
3910
- kind: 'block', payload: { reason: argv[6] }, evidenceRef: null });
3911
- } else if (argv.length === 5 && argv[0] === 'unblock'
3912
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3913
- && argv[3] === '--task' && isTodoIdentifier(argv[4])) {
3914
- action = (repoRoot) => mutate({ repoRoot, env, planKey: argv[2], taskId: argv[4],
3915
- kind: 'unblock', payload: {}, evidenceRef: null });
3916
- } else if ((argv.length === 7 || argv.length === 9) && argv[0] === 'done'
3917
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3918
- && argv[3] === '--task' && isTodoIdentifier(argv[4])
3919
- && argv[5] === '--evidence' && isTodoRef(argv[6])
3920
- && (argv.length === 7 || (argv[7] === '--test-result' && isTodoRef(argv[8])))) {
3921
- action = (repoRoot) => mutate({ repoRoot, env, planKey: argv[2], taskId: argv[4],
3922
- kind: 'done', payload: 'authored', evidenceRef: argv[6], testResultRef: argv[8] ?? null });
3923
- } else if (argv.length === 8 && argv[0] === 'evidence' && argv[1] === 'promote'
3924
- && argv[2] === '--plan' && isTodoIdentifier(argv[3])
3925
- && argv[4] === '--task' && isTodoIdentifier(argv[5])
3926
- && argv[6] === '--evidence' && isTodoRef(argv[7])) {
3927
- action = (repoRoot) => mutate({ repoRoot, env, planKey: argv[3], taskId: argv[5],
3928
- kind: 'done', payload: 'evidence_promotion', evidenceRef: argv[7] });
3929
- } else if ((argv.length === 7 || argv.length === 9) && argv[0] === 'reopen'
3930
- && argv[1] === '--plan' && isTodoIdentifier(argv[2])
3931
- && argv[3] === '--task' && isTodoIdentifier(argv[4])
3932
- && argv[5] === '--reason' && argv[6].length > 0
3933
- && (argv.length === 7 || (argv[7] === '--override-reason' && argv[8].length > 0))) {
3934
- const overrideReason = argv.length === 9 ? argv[8] : null;
3935
- action = (repoRoot) => mutate({ repoRoot, env, planKey: argv[2], taskId: argv[4],
3936
- kind: 'reopen', payload: { reason: argv[6], override_reason: overrideReason }, evidenceRef: null });
3868
+ if (flags !== null && isTodoIdentifier(flags.plan) && isTodoIdentifier(flags.task)) {
3869
+ action = (repoRoot) => retractStart({
3870
+ repoRoot, env, planKey: flags.plan, taskId: flags.task, reason: flags.reason,
3871
+ });
3872
+ }
3873
+ } else if (argv[0] === 'block') {
3874
+ const flags = matchFlagCommand(argv, ['block'], {
3875
+ known: ['plan', 'task', 'reason'], required: ['plan', 'task', 'reason'],
3876
+ });
3877
+ if (flags !== null && isTodoIdentifier(flags.plan) && isTodoIdentifier(flags.task)) {
3878
+ action = (repoRoot) => mutate({
3879
+ repoRoot, env, planKey: flags.plan, taskId: flags.task,
3880
+ kind: 'block', payload: { reason: flags.reason }, evidenceRef: null,
3881
+ });
3882
+ }
3883
+ } else if (argv[0] === 'unblock') {
3884
+ const flags = matchFlagCommand(argv, ['unblock'], {
3885
+ known: ['plan', 'task'], required: ['plan', 'task'],
3886
+ });
3887
+ if (flags !== null && isTodoIdentifier(flags.plan) && isTodoIdentifier(flags.task)) {
3888
+ action = (repoRoot) => mutate({
3889
+ repoRoot, env, planKey: flags.plan, taskId: flags.task,
3890
+ kind: 'unblock', payload: {}, evidenceRef: null,
3891
+ });
3892
+ }
3893
+ } else if (argv[0] === 'evidence' && argv[1] === 'promote') {
3894
+ const flags = matchFlagCommand(argv, ['evidence', 'promote'], {
3895
+ known: ['plan', 'task', 'evidence'], required: ['plan', 'task', 'evidence'],
3896
+ });
3897
+ if (flags !== null && isTodoIdentifier(flags.plan) && isTodoIdentifier(flags.task)
3898
+ && isAuthoringPathToken(flags.evidence)) {
3899
+ action = (repoRoot) => mutate({
3900
+ repoRoot, env, planKey: flags.plan, taskId: flags.task,
3901
+ kind: 'done', payload: 'evidence_promotion', evidenceRef: flags.evidence,
3902
+ });
3903
+ }
3904
+ } else if (argv[0] === 'reopen') {
3905
+ const flags = matchFlagCommand(argv, ['reopen'], {
3906
+ known: ['plan', 'task', 'reason', 'override-reason'],
3907
+ required: ['plan', 'task', 'reason'],
3908
+ });
3909
+ if (flags !== null && isTodoIdentifier(flags.plan) && isTodoIdentifier(flags.task)
3910
+ && (flags['override-reason'] === undefined
3911
+ || (typeof flags['override-reason'] === 'string' && flags['override-reason'].length > 0))) {
3912
+ action = (repoRoot) => mutate({
3913
+ repoRoot, env, planKey: flags.plan, taskId: flags.task,
3914
+ kind: 'reopen',
3915
+ payload: {
3916
+ reason: flags.reason,
3917
+ override_reason: typeof flags['override-reason'] === 'string' ? flags['override-reason'] : null,
3918
+ },
3919
+ evidenceRef: null,
3920
+ });
3921
+ }
3937
3922
  }
3938
3923
  if (action === null) {
3939
3924
  const command = typeof argv[0] === 'string' ? argv[0] : null;