@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.
- package/CHANGELOG.md +114 -0
- package/bin/gsd-t-parallel-probe.cjs +132 -0
- package/bin/gsd-t-parallel.cjs +422 -9
- package/bin/gsd-t-task-graph.cjs +80 -19
- package/bin/gsd-t-unattended.cjs +634 -229
- package/bin/gsd-t-worker-dispatch.cjs +211 -0
- package/bin/headless-auto-spawn.cjs +44 -1
- package/bin/headless-exit-codes.cjs +36 -18
- package/bin/m44-proof-measure.cjs +285 -0
- package/bin/m46-iter-proof.cjs +149 -0
- package/bin/m46-worker-proof.cjs +201 -0
- package/bin/parallelism-report.cjs +535 -0
- package/bin/spawn-plan-writer.cjs +1 -1
- package/commands/gsd-t-debug.md +10 -14
- package/commands/gsd-t-execute.md +10 -16
- package/commands/gsd-t-help.md +1 -0
- package/commands/gsd-t-integrate.md +8 -14
- package/commands/gsd-t-quick.md +10 -14
- package/commands/gsd-t-resume.md +32 -0
- package/commands/gsd-t-status.md +10 -0
- package/commands/gsd-t-unattended-watch.md +58 -1
- package/commands/gsd-t-visualize.md +15 -12
- package/commands/gsd-t-wave.md +2 -11
- package/docs/architecture.md +82 -0
- package/docs/requirements.md +20 -0
- package/package.json +1 -1
- package/scripts/gsd-t-compact-detector.js +51 -8
- package/scripts/gsd-t-dashboard-server.js +138 -85
- package/scripts/gsd-t-transcript.html +152 -1
- package/scripts/gsd-t-update-check.js +13 -4
- package/scripts/hooks/gsd-t-conversation-capture.js +258 -0
- package/templates/CLAUDE-global.md +54 -0
package/bin/gsd-t-parallel.cjs
CHANGED
|
@@ -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
|
-
|
|
343
|
-
|
|
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
|
|
package/bin/gsd-t-task-graph.cjs
CHANGED
|
@@ -79,7 +79,7 @@ function parseTasksMd(absPath, domainName) {
|
|
|
79
79
|
continue;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// Task heading: "### M44-D1-T1 — Title"
|
|
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
|
-
//
|
|
131
|
+
// Shape D field line: "- **Status**: [ ] pending"
|
|
102
132
|
const fieldMatch = line.match(/^\s*-\s+\*\*([A-Za-z][\w\s]*?)\*\*\s*:\s*(.*)$/);
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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`);
|