@tekyzinc/gsd-t 3.18.13 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,7 +79,7 @@ function detectMode(opts, env) {
79
79
  // ─── CLI arg parsing ──────────────────────────────────────────────────────
80
80
 
81
81
  function parseArgv(argv) {
82
- const out = { help: false, dryRun: false, mode: null, milestone: null, domain: null };
82
+ const out = { help: false, dryRun: false, mode: null, milestone: null, domain: null, command: null, maxWorkers: null, stagger: null };
83
83
  for (let i = 0; i < argv.length; i++) {
84
84
  const a = argv[i];
85
85
  if (a === "--help" || a === "-h") out.help = true;
@@ -90,6 +90,12 @@ function parseArgv(argv) {
90
90
  else if (a.startsWith("--milestone=")) out.milestone = a.slice("--milestone=".length);
91
91
  else if (a === "--domain") out.domain = argv[++i] || null;
92
92
  else if (a.startsWith("--domain=")) out.domain = a.slice("--domain=".length);
93
+ else if (a === "--command") out.command = argv[++i] || null;
94
+ else if (a.startsWith("--command=")) out.command = a.slice("--command=".length);
95
+ else if (a === "--max-workers") out.maxWorkers = parseInt(argv[++i], 10);
96
+ else if (a.startsWith("--max-workers=")) out.maxWorkers = parseInt(a.slice("--max-workers=".length), 10);
97
+ else if (a === "--stagger") out.stagger = parseInt(argv[++i], 10);
98
+ else if (a.startsWith("--stagger=")) out.stagger = parseInt(a.slice("--stagger=".length), 10);
93
99
  }
94
100
  return out;
95
101
  }
@@ -107,6 +113,13 @@ Options:
107
113
  --domain <name> Limit planning to a single domain.
108
114
  --dry-run Print the proposed worker plan table
109
115
  and exit without spawning any workers.
116
+ --command <slug> When fan-out is safe (N≥2), spawn N
117
+ detached headless children running the
118
+ named GSD-T command, each with disjoint
119
+ GSD_T_WORKER_TASK_IDS. When N<2, exits 0
120
+ with a "sequential" banner so the caller
121
+ falls through to the in-command flow.
122
+ Omit to get plan-only output.
110
123
  --help, -h Show this message and exit 0.
111
124
 
112
125
  Gates applied before any fan-out (in order):
@@ -301,6 +314,350 @@ function runParallel(opts) {
301
314
  };
302
315
  }
303
316
 
317
+ // ─── runDispatch — the single instrument (M44 D9 Step 3) ──────────────────
318
+
319
+ /**
320
+ * Round-robin partition of task ids into `workerCount` non-empty subsets.
321
+ * Kept tiny + pure so unit tests can exercise it without spinning up spawns.
322
+ */
323
+ function _partitionTaskIds(taskIds, workerCount) {
324
+ const ids = Array.isArray(taskIds) ? taskIds.filter((x) => typeof x === "string" && x.length) : [];
325
+ const n = Math.max(0, Math.min(Number(workerCount) || 0, ids.length));
326
+ if (n === 0) return [];
327
+ const buckets = Array.from({ length: n }, () => []);
328
+ for (let i = 0; i < ids.length; i++) buckets[i % n].push(ids[i]);
329
+ return buckets.filter((b) => b.length > 0);
330
+ }
331
+
332
+ /**
333
+ * _runCacheWarmProbe — fire a single short `claude -p` before fan-out so the
334
+ * Anthropic prompt cache (5-min TTL) is pre-populated with the files every
335
+ * worker will read. When workers spawn within the warm window, their initial
336
+ * Read(CLAUDE.md), Read(progress.md), Read(contracts/*.md) return cache-read
337
+ * tokens (free for ITPM budget, lower rate-limit pressure).
338
+ *
339
+ * Returns `{ok, filesRead, error}`. Best-effort; failures do not block fan-out.
340
+ *
341
+ * The probe reads the existing files (skips missing ones silently) and asks
342
+ * the child to print the literal string "warm" — cheap, deterministic, fast.
343
+ * Cache key matches exactly when `model` equals the workers' model and the
344
+ * workers use the same tool-call shape (Read on the same paths) within TTL.
345
+ */
346
+ function _runCacheWarmProbe(opts) {
347
+ const projectDir = (opts && opts.projectDir) || process.cwd();
348
+ const model = opts && opts.model;
349
+ const timeoutMs = Number.isFinite(opts && opts.timeoutMs) ? opts.timeoutMs : 60000;
350
+
351
+ const candidates = [
352
+ "CLAUDE.md",
353
+ ".gsd-t/progress.md",
354
+ ".gsd-t/contracts/headless-default-contract.md",
355
+ ".gsd-t/contracts/wave-join-contract.md",
356
+ ];
357
+ const filesRead = candidates.filter((rel) => {
358
+ try {
359
+ return fs.statSync(path.join(projectDir, rel)).isFile();
360
+ } catch {
361
+ return false;
362
+ }
363
+ });
364
+ if (filesRead.length === 0) return { ok: false, filesRead: [], error: "no_warm_files" };
365
+
366
+ const { spawnSync } = require("node:child_process");
367
+ const prompt =
368
+ "Read the following files so they enter the prompt cache for subsequent workers, " +
369
+ "then reply with the single word `warm` and nothing else:\n" +
370
+ filesRead.map((f) => `- ${f}`).join("\n");
371
+
372
+ const env = Object.assign({}, process.env);
373
+ if (model) env.ANTHROPIC_MODEL = model;
374
+
375
+ try {
376
+ const r = spawnSync(
377
+ "claude",
378
+ ["-p", prompt, "--dangerously-skip-permissions"],
379
+ {
380
+ cwd: projectDir,
381
+ env,
382
+ encoding: "utf8",
383
+ timeout: timeoutMs,
384
+ stdio: ["ignore", "pipe", "pipe"],
385
+ },
386
+ );
387
+ if (r.error) return { ok: false, filesRead, error: r.error.message };
388
+ if (r.status !== 0) return { ok: false, filesRead, error: `exit_${r.status}` };
389
+ return { ok: true, filesRead };
390
+ } catch (e) {
391
+ return { ok: false, filesRead, error: (e && e.message) || "spawn_error" };
392
+ }
393
+ }
394
+
395
+ /**
396
+ * runDispatch — the single instrument every command delegates to.
397
+ *
398
+ * Probes the planner; if N≥2 with green gates, spawns N detached headless
399
+ * children via `autoSpawnHeadless()` (one per task subset) and returns
400
+ * `{decision:'fan_out', fanOutCount, workerResults, plan}`. If N<2, returns
401
+ * `{decision:'sequential', …}` so the caller falls through to its legacy
402
+ * single-worker path. If planning fails, returns `{decision:'sequential'}`
403
+ * with a warning — purely additive, never throws.
404
+ *
405
+ * Design intent (per user directive 2026-04-23):
406
+ * "create 1 instrument that accomplishes this instead of implementing it
407
+ * in all the commands."
408
+ *
409
+ * Command files invoke this via one bash line; they do not re-implement the
410
+ * probe-and-branch pattern. The unattended supervisor (v1.5.0 §15a) uses the
411
+ * same planner + dep-injected spawn but owns its own heartbeat/watchdog — it
412
+ * does not consume this function.
413
+ *
414
+ * Contract: wave-join-contract.md v1.1.0; headless-default-contract v2.0.0.
415
+ */
416
+ function runDispatch(opts) {
417
+ const projectDir = (opts && opts.projectDir) || process.cwd();
418
+ const command = (opts && opts.command) || null;
419
+ if (!command) {
420
+ return {
421
+ decision: "invalid",
422
+ error: "missing_command",
423
+ fanOutCount: 0,
424
+ workerResults: [],
425
+ plan: [],
426
+ mode: detectMode(opts, opts && opts.env),
427
+ };
428
+ }
429
+
430
+ let result;
431
+ try {
432
+ result = runParallel({
433
+ projectDir,
434
+ mode: (opts && opts.mode) || undefined,
435
+ milestone: (opts && opts.milestone) || undefined,
436
+ domain: (opts && opts.domain) || undefined,
437
+ dryRun: true,
438
+ env: opts && opts.env,
439
+ });
440
+ } catch (e) {
441
+ appendEvent(projectDir, {
442
+ type: "parallelism_reduced",
443
+ source: "dispatch",
444
+ original_count: null,
445
+ reduced_count: 1,
446
+ reason: `planner_error:${(e && e.message) || "unknown"}`,
447
+ ts: new Date().toISOString(),
448
+ });
449
+ return {
450
+ decision: "sequential",
451
+ fanOutCount: 1,
452
+ workerResults: [],
453
+ plan: [],
454
+ mode: detectMode(opts, opts && opts.env),
455
+ error: `planner_error:${(e && e.message) || "unknown"}`,
456
+ };
457
+ }
458
+
459
+ const plannerWorkerCount = Number(result.workerCount) || 0;
460
+ const parallelTasks = Array.isArray(result.parallelTasks) ? result.parallelTasks : [];
461
+
462
+ // Concurrency cap (v3.18.19) — caller may clamp the planner-selected worker
463
+ // count via `opts.maxWorkers`. Motivated by 2026-04-23 incident: the Max
464
+ // subscription concurrent-session throttle rate-limits `claude -p` bursts
465
+ // regardless of model choice (since all spawns inherit the parent's Max
466
+ // OAuth, not an API key — see feedback_anthropic_key_measurement_only). The
467
+ // planner has no knowledge of this throttle; callers who know they're near
468
+ // the ceiling need a direct cap.
469
+ const cap = Number.isFinite(opts && opts.maxWorkers) && opts.maxWorkers > 0
470
+ ? Math.floor(opts.maxWorkers)
471
+ : 2;
472
+ const workerCount = Math.min(plannerWorkerCount, cap);
473
+ if (workerCount < plannerWorkerCount) {
474
+ appendEvent(projectDir, {
475
+ type: "parallelism_reduced",
476
+ source: "dispatch_max_workers_cap",
477
+ original_count: plannerWorkerCount,
478
+ reduced_count: workerCount,
479
+ reason: `max_workers_cap:${cap}`,
480
+ ts: new Date().toISOString(),
481
+ });
482
+ }
483
+ const subsets = workerCount >= 2 ? _partitionTaskIds(parallelTasks, workerCount) : [];
484
+
485
+ if (subsets.length < 2) {
486
+ return {
487
+ decision: "sequential",
488
+ fanOutCount: 1,
489
+ workerResults: [],
490
+ plan: result.plan || [],
491
+ mode: result.mode,
492
+ parallelTasks,
493
+ };
494
+ }
495
+
496
+ // Resolve the spawner — tests inject a stub; production uses the real
497
+ // `autoSpawnHeadless`. Required: `({command, args, projectDir, env}) => {id, pid, …}`.
498
+ let spawnImpl = opts && opts.spawnHeadlessImpl;
499
+ if (!spawnImpl) {
500
+ try {
501
+ spawnImpl = require(path.join(__dirname, "headless-auto-spawn.cjs")).autoSpawnHeadless;
502
+ } catch (e) {
503
+ return {
504
+ decision: "sequential",
505
+ fanOutCount: 1,
506
+ workerResults: [],
507
+ plan: result.plan || [],
508
+ mode: result.mode,
509
+ error: `spawn_load:${(e && e.message) || "unknown"}`,
510
+ parallelTasks,
511
+ };
512
+ }
513
+ }
514
+
515
+ appendEvent(projectDir, {
516
+ type: "fan_out",
517
+ source: "dispatch",
518
+ command,
519
+ fan_out_count: subsets.length,
520
+ task_ids: parallelTasks,
521
+ mode: result.mode,
522
+ ts: new Date().toISOString(),
523
+ });
524
+
525
+ // Worker model selection (v3.18.18) — mechanical fan-out defaults to Sonnet
526
+ // so the orchestrator's Opus bucket isn't the bottleneck. Caller may
527
+ // override via `opts.workerModel` ("opus" | "sonnet" | "haiku" | full ID).
528
+ // A task can opt back to Opus by declaring "[opus]" in its tasks.md line;
529
+ // the planner surfaces this via per-task metadata (future; today the per-
530
+ // subset opt-in is an all-or-nothing knob passed by the caller).
531
+ const DEFAULT_WORKER_MODEL = "claude-sonnet-4-6";
532
+ const modelAlias = {
533
+ opus: "claude-opus-4-7",
534
+ sonnet: "claude-sonnet-4-6",
535
+ haiku: "claude-haiku-4-5-20251001",
536
+ };
537
+ const callerModel = opts && opts.workerModel;
538
+ const workerModel = callerModel === false
539
+ ? null // explicit opt-out: inherit parent's ANTHROPIC_MODEL
540
+ : (modelAlias[callerModel] || callerModel || DEFAULT_WORKER_MODEL);
541
+
542
+ // Stagger between spawns — 10s default empirically-validated against the
543
+ // Max-subscription concurrent-session throttle (2026-04-23 M46 probe: two
544
+ // 10s-staggered 2-parallel rounds of real work, both exit 0, no 429; prior
545
+ // 3s default burst at >2 workers hit rate limits). Caller may override via
546
+ // `opts.spawnStaggerMs` (0 = no delay, previous burst behavior).
547
+ const staggerMs = Number.isFinite(opts && opts.spawnStaggerMs)
548
+ ? Math.max(0, opts.spawnStaggerMs)
549
+ : 10000;
550
+ const busyWait = (ms) => {
551
+ if (!ms) return;
552
+ // Synchronous sleep that releases the CPU (Atomics.wait on a dummy
553
+ // SharedArrayBuffer — pattern used in Node REPL/sync-sleep helpers).
554
+ // Keeps runDispatch's sync return contract without pegging a core.
555
+ // Total wall-clock added to startup: (subsets-1) * staggerMs.
556
+ try {
557
+ const sab = new SharedArrayBuffer(4);
558
+ const view = new Int32Array(sab);
559
+ Atomics.wait(view, 0, 0, ms);
560
+ } catch (_) {
561
+ // Atomics unavailable — fall back to a coarse spin.
562
+ const until = Date.now() + ms;
563
+ while (Date.now() < until) { /* spin */ }
564
+ }
565
+ };
566
+
567
+ // Cache-warming probe (v3.18.19) — opt-in via GSD_T_CACHE_WARM=1 or
568
+ // opts.cacheWarm. Anthropic's prompt cache has a 5-minute TTL keyed on the
569
+ // exact system-prompt + tool-call prefix. One leader probe that reads the
570
+ // same foundational files every worker will read (CLAUDE.md, progress.md,
571
+ // top-level contracts) populates the cache so the first N seconds of every
572
+ // subsequent worker hit cache-read tokens (free for ITPM budget, lower
573
+ // rate-limit pressure). Probe runs synchronously so workers land inside
574
+ // the warm window rather than racing it. Gated behind opt-in until
575
+ // backlog #23 (mitmproxy instrumentation) measures the actual delta.
576
+ const warmEnv = (opts && opts.env) || process.env;
577
+ const cacheWarmEnabled =
578
+ (opts && opts.cacheWarm === true) ||
579
+ (!(opts && opts.cacheWarm === false) && warmEnv.GSD_T_CACHE_WARM === "1");
580
+ if (cacheWarmEnabled) {
581
+ const warmStart = Date.now();
582
+ let warmResult = { ok: false, error: "not_run" };
583
+ try {
584
+ const probeImpl = (opts && opts.cacheWarmProbeImpl) || _runCacheWarmProbe;
585
+ warmResult = probeImpl({
586
+ projectDir,
587
+ model: workerModel, // same model as workers so cache key matches
588
+ timeoutMs: (opts && Number.isFinite(opts.cacheWarmTimeoutMs))
589
+ ? opts.cacheWarmTimeoutMs
590
+ : 60000,
591
+ });
592
+ } catch (e) {
593
+ warmResult = { ok: false, error: (e && e.message) || "unknown" };
594
+ }
595
+ appendEvent(projectDir, {
596
+ type: "cache_warm_probe",
597
+ source: "dispatch",
598
+ ok: !!warmResult.ok,
599
+ duration_ms: Date.now() - warmStart,
600
+ error: warmResult.error,
601
+ files_read: warmResult.filesRead,
602
+ ts: new Date().toISOString(),
603
+ });
604
+ }
605
+
606
+ const workerResults = [];
607
+ for (let i = 0; i < subsets.length; i++) {
608
+ if (i > 0) busyWait(staggerMs);
609
+ const subset = subsets[i];
610
+ const workerEnv = {
611
+ GSD_T_WORKER_TASK_IDS: subset.join(","),
612
+ GSD_T_WORKER_INDEX: String(i),
613
+ GSD_T_WORKER_TOTAL: String(subsets.length),
614
+ };
615
+ let spawnResult = null;
616
+ let spawnError = null;
617
+ try {
618
+ spawnResult = spawnImpl({
619
+ command,
620
+ args: [],
621
+ projectDir,
622
+ env: workerEnv,
623
+ spawnType: "primary",
624
+ workerModel,
625
+ });
626
+ } catch (e) {
627
+ spawnError = (e && e.message) || "unknown";
628
+ }
629
+ appendEvent(projectDir, {
630
+ type: "task_start",
631
+ source: "dispatch",
632
+ worker_index: i,
633
+ worker_total: subsets.length,
634
+ task_ids: subset,
635
+ command,
636
+ spawn_id: spawnResult && spawnResult.id,
637
+ pid: spawnResult && spawnResult.pid,
638
+ error: spawnError,
639
+ ts: new Date().toISOString(),
640
+ });
641
+ workerResults.push({
642
+ idx: i,
643
+ taskIds: subset,
644
+ spawnId: spawnResult && spawnResult.id,
645
+ pid: spawnResult && spawnResult.pid,
646
+ logPath: spawnResult && spawnResult.logPath,
647
+ error: spawnError,
648
+ });
649
+ }
650
+
651
+ return {
652
+ decision: "fan_out",
653
+ fanOutCount: subsets.length,
654
+ workerResults,
655
+ plan: result.plan || [],
656
+ mode: result.mode,
657
+ parallelTasks,
658
+ };
659
+ }
660
+
304
661
  // ─── CLI entry ────────────────────────────────────────────────────────────
305
662
 
306
663
  // ─── dry-run table formatter ──────────────────────────────────────────────
@@ -339,15 +696,17 @@ function runCli(argv, env) {
339
696
  return 0;
340
697
  }
341
698
  const mode = args.mode || detectMode({}, env);
342
- const result = runParallel({
343
- projectDir: process.cwd(),
344
- mode,
345
- milestone: args.milestone,
346
- domain: args.domain,
347
- dryRun: args.dryRun,
348
- env,
349
- });
699
+
700
+ // --dry-run: plan-only output, same as M44 D2 baseline.
350
701
  if (args.dryRun) {
702
+ const result = runParallel({
703
+ projectDir: process.cwd(),
704
+ mode,
705
+ milestone: args.milestone,
706
+ domain: args.domain,
707
+ dryRun: true,
708
+ env,
709
+ });
351
710
  process.stdout.write(formatPlanTable(result.plan));
352
711
  process.stdout.write(
353
712
  `\nTotal workers: ${result.workerCount} Mode: ${result.mode}` +
@@ -358,6 +717,57 @@ function runCli(argv, env) {
358
717
  );
359
718
  return 0;
360
719
  }
720
+
721
+ // --command: live dispatch. The single instrument that command files
722
+ // delegate to instead of re-implementing probe-and-branch logic.
723
+ if (args.command) {
724
+ const dispatchOpts = {
725
+ projectDir: process.cwd(),
726
+ mode,
727
+ milestone: args.milestone,
728
+ domain: args.domain,
729
+ command: args.command,
730
+ env,
731
+ };
732
+ if (Number.isFinite(args.maxWorkers) && args.maxWorkers > 0) {
733
+ dispatchOpts.maxWorkers = args.maxWorkers;
734
+ }
735
+ if (Number.isFinite(args.stagger) && args.stagger >= 0) {
736
+ dispatchOpts.spawnStaggerMs = args.stagger * 1000;
737
+ }
738
+ const dispatch = runDispatch(dispatchOpts);
739
+ if (dispatch.decision === "fan_out") {
740
+ process.stdout.write(
741
+ `gsd-t parallel — fan_out command=${args.command} mode=${dispatch.mode} workers=${dispatch.fanOutCount}\n`,
742
+ );
743
+ for (const w of dispatch.workerResults) {
744
+ process.stdout.write(
745
+ ` worker[${w.idx}] tasks=${w.taskIds.join(",")} spawn=${w.spawnId || "-"} pid=${w.pid || "-"}${w.error ? " error=" + w.error : ""}\n`,
746
+ );
747
+ }
748
+ return 0;
749
+ }
750
+ if (dispatch.decision === "sequential") {
751
+ process.stdout.write(
752
+ `gsd-t parallel — sequential command=${args.command} mode=${dispatch.mode} (N<2, caller falls through)${dispatch.error ? " error=" + dispatch.error : ""}\n`,
753
+ );
754
+ return 2; // non-zero so shell `&&` short-circuits; caller branches on $?
755
+ }
756
+ process.stdout.write(
757
+ `gsd-t parallel — ${dispatch.decision} command=${args.command} mode=${dispatch.mode}\n`,
758
+ );
759
+ return 3;
760
+ }
761
+
762
+ // Legacy path: no --dry-run, no --command. Print plan summary only.
763
+ const result = runParallel({
764
+ projectDir: process.cwd(),
765
+ mode,
766
+ milestone: args.milestone,
767
+ domain: args.domain,
768
+ dryRun: false,
769
+ env,
770
+ });
361
771
  process.stdout.write(
362
772
  `gsd-t parallel — mode=${result.mode} workers=${result.workerCount}\n`,
363
773
  );
@@ -366,6 +776,7 @@ function runCli(argv, env) {
366
776
 
367
777
  module.exports = {
368
778
  runParallel,
779
+ runDispatch,
369
780
  runCli,
370
781
  formatPlanTable,
371
782
  PLAN_HEADER,
@@ -373,6 +784,8 @@ module.exports = {
373
784
  _parseArgv: parseArgv,
374
785
  _detectMode: detectMode,
375
786
  _appendEvent: appendEvent,
787
+ _partitionTaskIds,
788
+ _runCacheWarmProbe,
376
789
  _HELP_TEXT: HELP_TEXT,
377
790
  };
378
791
 
@@ -79,7 +79,7 @@ function parseTasksMd(absPath, domainName) {
79
79
  continue;
80
80
  }
81
81
 
82
- // Task heading: "### M44-D1-T1 — Title" (em-dash, en-dash, hyphen all OK)
82
+ // Task heading (Shape D — parser-canonical): "### M44-D1-T1 — Title"
83
83
  const taskMatch = line.match(/^###\s+([A-Z]\d+-D\d+-T\d+)\s*[—–\-]?\s*(.*)$/);
84
84
  if (taskMatch) {
85
85
  flush();
@@ -92,33 +92,77 @@ function parseTasksMd(absPath, domainName) {
92
92
  deps: [],
93
93
  touches: null, // null = unset (will fall back to scope.md); [] = explicit empty
94
94
  statusWarning: null,
95
+ shape: "D",
96
+ };
97
+ continue;
98
+ }
99
+
100
+ // Task bullet (Shape C — bullet-with-bold-id, checkbox in heading):
101
+ // "- [ ] **M44-D9-T1** — Title"
102
+ // Dependencies absent in Shape C source; touches come from an indented
103
+ // " - touches: a, b" sub-bullet below the task.
104
+ const bulletMatch = line.match(/^-\s+\[(.)\]\s+\*\*([A-Z]\d+-D\d+-T\d+)\*\*\s*[—–\-]?\s*(.*)$/);
105
+ if (bulletMatch) {
106
+ flush();
107
+ const marker = bulletMatch[1];
108
+ let status = "pending";
109
+ let statusWarning = null;
110
+ if (STATUS_MAP[marker]) {
111
+ status = STATUS_MAP[marker];
112
+ } else {
113
+ statusWarning = `unknown status marker '[${marker}]' on ${bulletMatch[2]} — treating as pending`;
114
+ }
115
+ cur = {
116
+ id: bulletMatch[2],
117
+ domain: domainName,
118
+ wave: currentWave,
119
+ title: (bulletMatch[3] || "").trim(),
120
+ status,
121
+ deps: [],
122
+ touches: null,
123
+ statusWarning,
124
+ shape: "C",
95
125
  };
96
126
  continue;
97
127
  }
98
128
 
99
129
  if (!cur) continue;
100
130
 
101
- // Field lines look like: "- **Status**: [ ] pending"
131
+ // Shape D field line: "- **Status**: [ ] pending"
102
132
  const fieldMatch = line.match(/^\s*-\s+\*\*([A-Za-z][\w\s]*?)\*\*\s*:\s*(.*)$/);
103
- if (!fieldMatch) continue;
104
- const key = fieldMatch[1].trim().toLowerCase();
105
- const val = fieldMatch[2].trim();
106
-
107
- if (key === "status") {
108
- const m = val.match(/\[(.)\]/);
109
- if (m) {
110
- const marker = m[1];
111
- if (STATUS_MAP[marker]) {
112
- cur.status = STATUS_MAP[marker];
113
- } else {
114
- cur.status = "pending";
115
- cur.statusWarning = `unknown status marker '[${marker}]' on ${cur.id} — treating as pending`;
133
+ if (fieldMatch) {
134
+ const key = fieldMatch[1].trim().toLowerCase();
135
+ const val = fieldMatch[2].trim();
136
+
137
+ if (key === "status") {
138
+ const m = val.match(/\[(.)\]/);
139
+ if (m) {
140
+ const marker = m[1];
141
+ if (STATUS_MAP[marker]) {
142
+ cur.status = STATUS_MAP[marker];
143
+ } else {
144
+ cur.status = "pending";
145
+ cur.statusWarning = `unknown status marker '[${marker}]' on ${cur.id} — treating as pending`;
146
+ }
116
147
  }
148
+ } else if (key === "dependencies" || key === "deps") {
149
+ cur.deps = parseDepList(val);
150
+ } else if (key === "touches" || key === "files touched" || key === "touched") {
151
+ cur.touches = parseFileList(val);
152
+ }
153
+ continue;
154
+ }
155
+
156
+ // Shape C sub-bullet field: " - touches: a, b" or " - deps: X, Y"
157
+ const subFieldMatch = line.match(/^\s+-\s+([a-zA-Z][\w\s]*?)\s*:\s*(.*)$/);
158
+ if (subFieldMatch && cur.shape === "C") {
159
+ const key = subFieldMatch[1].trim().toLowerCase();
160
+ const val = subFieldMatch[2].trim();
161
+ if (key === "touches" || key === "files touched" || key === "touched") {
162
+ cur.touches = parseFileList(val);
163
+ } else if (key === "dependencies" || key === "deps") {
164
+ cur.deps = parseDepList(val);
117
165
  }
118
- } else if (key === "dependencies" || key === "deps") {
119
- cur.deps = parseDepList(val);
120
- } else if (key === "touches" || key === "files touched" || key === "touched") {
121
- cur.touches = parseFileList(val);
122
166
  }
123
167
  }
124
168
  flush();
@@ -126,6 +170,7 @@ function parseTasksMd(absPath, domainName) {
126
170
  for (const t of tasks) {
127
171
  if (t.statusWarning) warnings.push(t.statusWarning);
128
172
  delete t.statusWarning;
173
+ delete t.shape;
129
174
  }
130
175
  return { tasks, warnings };
131
176
  }
@@ -292,6 +337,22 @@ function buildTaskGraph(opts) {
292
337
  if (!fs.existsSync(tasksPath)) continue;
293
338
  const { tasks, warnings: ws } = parseTasksMd(tasksPath, domain);
294
339
  for (const w of ws) warnings.push(w);
340
+ if (tasks.length === 0) {
341
+ // tasks.md exists but no tasks matched any known shape. Heuristic check
342
+ // for an unsupported shape so the caller knows to author in Shape C or D.
343
+ let src = "";
344
+ try { src = fs.readFileSync(tasksPath, "utf8"); } catch {}
345
+ const hasLegacyNoMilestoneH3 = /^###\s+D\d+-T\d+\b/m.test(src);
346
+ const hasBareSection = /^##\s+T-\d+\b/m.test(src);
347
+ if (hasLegacyNoMilestoneH3) {
348
+ warnings.push(`${domain}/tasks.md uses '### D1-T1' (legacy, no milestone prefix) — parser requires '### Mxx-D1-T1' or '- [.] **Mxx-D1-T1**' form; 0 tasks read`);
349
+ } else if (hasBareSection) {
350
+ warnings.push(`${domain}/tasks.md uses '## T-1:' section headings — parser requires task-id shape 'Mxx-Dx-Tx' in '###' heading or '- [.] **...**' bullet; 0 tasks read`);
351
+ } else {
352
+ warnings.push(`${domain}/tasks.md parsed 0 tasks — no '### Mxx-Dx-Tx' heading nor '- [.] **Mxx-Dx-Tx**' bullet found`);
353
+ }
354
+ continue;
355
+ }
295
356
  for (const t of tasks) {
296
357
  if (byId[t.id]) {
297
358
  warnings.push(`duplicate task id ${t.id} (domain ${domain}) — first wins`);