@tekyzinc/gsd-t 3.18.12 → 3.18.17
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 +51 -0
- package/bin/gsd-t-parallel-probe.cjs +132 -0
- package/bin/gsd-t-parallel.cjs +242 -9
- package/bin/gsd-t-task-graph.cjs +80 -19
- package/bin/gsd-t-unattended.cjs +210 -9
- package/bin/headless-auto-spawn.cjs +17 -1
- package/bin/headless-exit-codes.cjs +36 -18
- package/bin/m44-proof-measure.cjs +285 -0
- package/bin/parallelism-report.cjs +535 -0
- 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-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 +66 -0
- package/package.json +1 -1
- package/scripts/gsd-t-compact-detector.js +51 -8
- package/scripts/gsd-t-dashboard-server.js +143 -2
- package/scripts/gsd-t-transcript.html +152 -1
- package/scripts/hooks/gsd-t-conversation-capture.js +258 -0
- package/templates/CLAUDE-global.md +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [3.18.17] - 2026-04-23
|
|
6
|
+
|
|
7
|
+
### Fixed — `npm test` picks up `worker-sim.js` fixture
|
|
8
|
+
|
|
9
|
+
- **`test/fixtures/m44-proof/worker-sim.js`** was being globbed by `node --test`'s default test-directory matcher (anything under `test/` with a `.js` extension), and failed because the fixture requires `OUT_DIR` to be set. The fixture now exits `0` when env vars are absent instead of `2` — it's a worker fixture, not a test. Full suite back to 2016/2016 green. Required to unblock the v3.18.16 publish.
|
|
10
|
+
|
|
11
|
+
## [3.18.16] - 2026-04-23
|
|
12
|
+
|
|
13
|
+
### Added — Proof measurement `--visualize` flag
|
|
14
|
+
|
|
15
|
+
- **`bin/m44-proof-measure.cjs --visualize`** writes synthetic spawn-plan files into the project's `.gsd-t/spawns/` directory as each simulated worker launches and calls `markTaskDone` + `markSpawnEnded` when they finish, so the M44 D9 parallelism panel (`scripts/gsd-t-transcript.html`, endpoint `/api/parallelism`) renders the fan-out live. Off by default — the unflagged measurement still writes spawn-plans only under the temp fixture root. Enables end-to-end visualizer observation of the dispatcher without burning API tokens.
|
|
16
|
+
- **Reproducibility**: three consecutive 20s-worker runs (13:08, 13:09, 13:27 local) produced identical `T_par / T_seq ≈ 0.251`, `speedup ≈ 3.98×`, `parallelism_factor ≈ 3.97`, `parallelism_factor_mode: "live"` with `activeWorkers: 4` for the full 20s parallel window. Panel transitions IDLE → live → IDLE confirmed by `/api/parallelism` polling.
|
|
17
|
+
|
|
18
|
+
## [Unreleased] — v3.19.00 pending
|
|
19
|
+
|
|
20
|
+
### Measured — Dispatcher T/2 criterion (backlog #15, leg 1 of 2)
|
|
21
|
+
|
|
22
|
+
- **`bin/m44-proof-measure.cjs`** runs a falsifiable measurement of the v3.19.00 parallel dispatcher using a synthetic spawner (`test/fixtures/m44-proof/worker-sim.js`) injected into `runDispatch` via `opts.spawnHeadlessImpl`. Fixture (`test/fixtures/m44-proof/fixture.tasks.md`): 4 file-disjoint tasks with explicit `- touches:` sub-bullets (D5 disjointness requirement). Each worker sleeps `WORKER_DURATION_MS` (default 8000ms) then writes a JSON `.done` marker — zero LLM calls, zero network, zero side effects outside `OUT_DIR`. **Result**: T_par = **8111.1 ms**, T_seq = **32146.1 ms**, speedup **3.96×**, parallelism_factor **3.95** (ideal = 4), dispatch overhead **8.2 ms**. Criterion `T_par ≤ T_seq/2` → **MET ✓**. Report JSON at `.gsd-t/m44-proof-report.json`. This proves the dispatcher fans out concurrently; it does NOT prove N Claude workers produce correct code in T/N (a separate experiment, deferred to a follow-up backlog item).
|
|
23
|
+
|
|
24
|
+
### Pending — Zero-compaction criterion (backlog #15, leg 2 of 2)
|
|
25
|
+
|
|
26
|
+
- **NOT YET MEASURED.** Requires an unattended supervisor run over a workload that historically would have triggered mid-run `/compact`, producing zero `type:"compaction_post_spawn"` rows in `.gsd-t/metrics/compactions.jsonl` under the fully-wired v3.19.00 surface (`ca20477` supervisor→planner + `799a8af` single-instrument + `19eb3eb` D9 observability panel). Existing 81 rows in the compactions log contain 0 `compaction_post_spawn` entries, but the only post-19eb3eb unattended state predates the D9 landing and is therefore not a valid sample.
|
|
27
|
+
- **`v3.19.00` tag deferred** until the zero-compaction leg completes. Per user standing directive `feedback_measure_dont_claim.md`: "milestones with measurable success criteria are not complete until measurement is run AND reported."
|
|
28
|
+
|
|
29
|
+
## [3.18.15] - 2026-04-23
|
|
30
|
+
|
|
31
|
+
### Fixed — Supervisor false-failed marker (M45 follow-up)
|
|
32
|
+
|
|
33
|
+
- **`bin/headless-exit-codes.cjs::mapHeadlessExitCode` polarity discipline** — the pre-fix matcher did `lower.includes("tests failed")` / `"verification failed"` / `"context budget exceeded"`, which fired on free-form narration like `"0 tests failed"`, `"no tests failed"`, and quoted phrases inside worker output. During the M45 run the worker's clean output contained `"tests failed"` 6× in healthy prose, flipping its mapped exit code 0 → 1 and causing the supervisor to finalize `status=failed` despite the milestone having been completed and archived. The matchers now require either a non-zero numeric count (`/([1-9]\d*)\s+(?:tests?|specs?|assertions?|examples?|suites?)\s+failed\b/i`), a structured terminal marker (`/^FAIL[:\s]/m`, Jest-style `/^Tests:\s+\d+\s+failed/m`), or a line-boundary / sentence-start anchor for free-form verification/context-budget phrases. 27 new polarity regression tests in `test/m45-fix-headless-exit-polarity.test.js`; all existing `headless.test.js` assertions preserved.
|
|
34
|
+
- **`commands/gsd-t-unattended-watch.md` Step 3 reconciliation** — when the supervisor PID file is absent AND `state.status=failed` AND a fresh milestone archive exists under `.gsd-t/milestones/` (mtime ≥ supervisor `startedAt`), the watch tick now renders a reconciled success report noting the archive as the source of truth instead of the contradictory ✅-cleanly-finalized + failed-status block the previous logic would emit. Raw final report preserved for genuinely failed runs with no archive.
|
|
35
|
+
|
|
36
|
+
## [3.18.14] - 2026-04-23
|
|
37
|
+
|
|
38
|
+
### Added — M45 Conversation-Stream Observability
|
|
39
|
+
|
|
40
|
+
- **Orchestrator dialog visible in the transcript viewer** — new hook `scripts/hooks/gsd-t-conversation-capture.js` (SessionStart + UserPromptSubmit + Stop + opt-in PostToolUse via `GSD_T_CAPTURE_TOOL_USES=1`) writes typed NDJSON frames to `.gsd-t/transcripts/in-session-{sessionId}.ndjson` for every human↔Claude turn. The visualizer's left rail now lists those entries with a `💬 conversation` badge alongside the `▶ spawn` entries, so users can watch their own dialog in the same surface as spawned work.
|
|
41
|
+
- **Compact marker fallback target-selection** — `scripts/gsd-t-compact-detector.js::findActiveTranscript` now prefers a fresh spawn NDJSON when one has been modified within 30s, and falls back to the most recent `in-session-*.ndjson` otherwise. Mid-conversation `/compact` events land in the correct transcript instead of a random stale spawn file.
|
|
42
|
+
- **New contract** `.gsd-t/contracts/conversation-capture-contract.md` v1.0.0 — documents the frame schema (`session_start` / `user_turn` / `assistant_turn` / `tool_use`), file-naming (`in-session-` prefix as the viewer + compact-detector discriminator), hook entry points, session-id source + fallback, and 16 KB content cap.
|
|
43
|
+
- **Settings.json hook wiring documented** — `templates/CLAUDE-global.md` gains an "In-Session Conversation Capture (M45 D2)" section so users who install/update pick up the hook alongside the existing in-session token-usage hook.
|
|
44
|
+
|
|
45
|
+
### Fixed — M45 D1 Viewer Route
|
|
46
|
+
|
|
47
|
+
- **`GET /transcripts` now serves the real transcript viewer** — reverts the standalone `renderTranscriptsHtml` index page shipped in v3.18.13. The route now reads `scripts/gsd-t-transcript.html` with `__SPAWN_ID__` → `""`, giving users the same left-rail + main + right-panel surface they get at `/transcript/:id`. JSON back-compat preserved: `Accept: application/json` and `*/*` continue to return `{spawns: [...]}`.
|
|
48
|
+
- **Session-id path-separator sanitization (Red Team BUG-1)** — `_resolveSessionId` in the new conversation-capture hook now rejects session_ids containing `/`, `\`, `\0`, or `..` and falls through to the pid-hash fallback. Prior behavior let `session_id="a/../b"` lexically collapse via `path.join` to produce `transcripts/b.ndjson` without the `in-session-` prefix, breaking the filename-prefix discriminator contract with the viewer + compact-detector.
|
|
49
|
+
|
|
50
|
+
## [3.18.13] - 2026-04-23
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **Dashboard `/transcripts` returned raw JSON to browsers** — after the v3.18.12 always-enabled Live Stream button fix, opening the dashboard with no spawn data and clicking Live Stream landed the user on `{"spawns":[]}` because `/transcripts` always served JSON. The route now does Accept-header content negotiation: browsers (`Accept: text/html`) get a proper dark-themed HTML index page with a sortable table of spawns (or a friendly empty state with a `/gsd-t-quick` CTA when no transcripts exist); programmatic clients (`fetch()` default `*/*`, or explicit `application/json`) keep getting the JSON shape the dashboard polling code already consumes — full back-compat.
|
|
55
|
+
|
|
5
56
|
## [3.18.12] - 2026-04-23
|
|
6
57
|
|
|
7
58
|
### Fixed
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-parallel-probe — M44 D9 Step 3
|
|
5
|
+
*
|
|
6
|
+
* Deterministic in-session probe that runs the parallel planner in dry-run mode
|
|
7
|
+
* and emits a single JSON line to stdout. Command files (execute / quick /
|
|
8
|
+
* debug / integrate) invoke this as a bash shim to get a mechanical answer —
|
|
9
|
+
* replacing LLM prose judgment with a JSON shape that branching logic can read.
|
|
10
|
+
*
|
|
11
|
+
* Per user memory `feedback_deterministic_orchestration.md`:
|
|
12
|
+
* "prompt-based blocking doesn't work; use JS orchestrators for gates/waits"
|
|
13
|
+
*
|
|
14
|
+
* Contract: wave-join-contract.md v1.1.0; unattended-supervisor-contract.md v1.5.0
|
|
15
|
+
*
|
|
16
|
+
* Output shape (stdout, one line):
|
|
17
|
+
* {"workerCount":N,"parallelTasks":["M44-D9-T1",...],"mode":"in-session",
|
|
18
|
+
* "reducedCount":null|N,"warnings":[...],"ok":true}
|
|
19
|
+
*
|
|
20
|
+
* On unexpected error (planner throw, missing repo state, etc.), emits a safe
|
|
21
|
+
* fallback shape so shell callers never have to parse stderr:
|
|
22
|
+
* {"workerCount":1,"parallelTasks":[],"mode":"in-session","ok":false,"error":"..."}
|
|
23
|
+
*
|
|
24
|
+
* Exit code 0 on both success and safe-fallback. This is intentional: the shim
|
|
25
|
+
* is a decision probe, not a command; non-zero would force command files to
|
|
26
|
+
* defensively wrap it and add LLM noise.
|
|
27
|
+
*
|
|
28
|
+
* Hard rules:
|
|
29
|
+
* - Zero external runtime deps
|
|
30
|
+
* - Never writes to stderr by default (shell shim relies on quiet)
|
|
31
|
+
* - Never writes to `.gsd-t/events/*` — that's runParallel's job
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const path = require("node:path");
|
|
35
|
+
|
|
36
|
+
function parseArgv(argv) {
|
|
37
|
+
const out = { milestone: null, domain: null, mode: null };
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const a = argv[i];
|
|
40
|
+
if (a === "--milestone" && argv[i + 1]) {
|
|
41
|
+
out.milestone = argv[++i];
|
|
42
|
+
} else if (a === "--domain" && argv[i + 1]) {
|
|
43
|
+
out.domain = argv[++i];
|
|
44
|
+
} else if (a === "--mode" && argv[i + 1]) {
|
|
45
|
+
out.mode = argv[++i];
|
|
46
|
+
} else if (a === "--help" || a === "-h") {
|
|
47
|
+
out.help = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HELP = `gsd-t-parallel-probe — deterministic planner probe (JSON out)
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
node bin/gsd-t-parallel-probe.cjs [--milestone Mxx] [--domain name] [--mode in-session|unattended]
|
|
57
|
+
|
|
58
|
+
Output: one JSON line to stdout with keys workerCount, parallelTasks, mode,
|
|
59
|
+
reducedCount, warnings, ok. Exit 0 always. Use with in-session command files
|
|
60
|
+
(execute, quick, debug, integrate) to replace prose-based parallel dispatch
|
|
61
|
+
decisions with a mechanical branch on workerCount.
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
function probe(argv, env) {
|
|
65
|
+
const args = parseArgv(argv || []);
|
|
66
|
+
if (args.help) {
|
|
67
|
+
process.stdout.write(HELP);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let runParallel;
|
|
72
|
+
try {
|
|
73
|
+
({ runParallel } = require(path.join(__dirname, "gsd-t-parallel.cjs")));
|
|
74
|
+
} catch (e) {
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
workerCount: 1,
|
|
78
|
+
parallelTasks: [],
|
|
79
|
+
mode: args.mode || "in-session",
|
|
80
|
+
reducedCount: null,
|
|
81
|
+
warnings: [],
|
|
82
|
+
ok: false,
|
|
83
|
+
error: `planner_load:${(e && e.message) || "unknown"}`,
|
|
84
|
+
}) + "\n",
|
|
85
|
+
);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let result;
|
|
90
|
+
try {
|
|
91
|
+
result = runParallel({
|
|
92
|
+
projectDir: process.cwd(),
|
|
93
|
+
mode: args.mode || undefined,
|
|
94
|
+
milestone: args.milestone || undefined,
|
|
95
|
+
domain: args.domain || undefined,
|
|
96
|
+
dryRun: true,
|
|
97
|
+
env: env || process.env,
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
process.stdout.write(
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
workerCount: 1,
|
|
103
|
+
parallelTasks: [],
|
|
104
|
+
mode: args.mode || "in-session",
|
|
105
|
+
reducedCount: null,
|
|
106
|
+
warnings: [],
|
|
107
|
+
ok: false,
|
|
108
|
+
error: `planner_error:${(e && e.message) || "unknown"}`,
|
|
109
|
+
}) + "\n",
|
|
110
|
+
);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.stdout.write(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
workerCount: Number(result.workerCount) || 0,
|
|
117
|
+
parallelTasks: Array.isArray(result.parallelTasks) ? result.parallelTasks : [],
|
|
118
|
+
mode: result.mode || args.mode || "in-session",
|
|
119
|
+
reducedCount: typeof result.reducedCount === "number" ? result.reducedCount : null,
|
|
120
|
+
warnings: Array.isArray(result.warnings) ? result.warnings : [],
|
|
121
|
+
ok: true,
|
|
122
|
+
}) + "\n",
|
|
123
|
+
);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { probe, _parseArgv: parseArgv, _HELP: HELP };
|
|
128
|
+
|
|
129
|
+
if (require.main === module) {
|
|
130
|
+
const code = probe(process.argv.slice(2), process.env);
|
|
131
|
+
process.exit(code);
|
|
132
|
+
}
|
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 };
|
|
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,8 @@ 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);
|
|
93
95
|
}
|
|
94
96
|
return out;
|
|
95
97
|
}
|
|
@@ -107,6 +109,13 @@ Options:
|
|
|
107
109
|
--domain <name> Limit planning to a single domain.
|
|
108
110
|
--dry-run Print the proposed worker plan table
|
|
109
111
|
and exit without spawning any workers.
|
|
112
|
+
--command <slug> When fan-out is safe (N≥2), spawn N
|
|
113
|
+
detached headless children running the
|
|
114
|
+
named GSD-T command, each with disjoint
|
|
115
|
+
GSD_T_WORKER_TASK_IDS. When N<2, exits 0
|
|
116
|
+
with a "sequential" banner so the caller
|
|
117
|
+
falls through to the in-command flow.
|
|
118
|
+
Omit to get plan-only output.
|
|
110
119
|
--help, -h Show this message and exit 0.
|
|
111
120
|
|
|
112
121
|
Gates applied before any fan-out (in order):
|
|
@@ -301,6 +310,182 @@ function runParallel(opts) {
|
|
|
301
310
|
};
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
// ─── runDispatch — the single instrument (M44 D9 Step 3) ──────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Round-robin partition of task ids into `workerCount` non-empty subsets.
|
|
317
|
+
* Kept tiny + pure so unit tests can exercise it without spinning up spawns.
|
|
318
|
+
*/
|
|
319
|
+
function _partitionTaskIds(taskIds, workerCount) {
|
|
320
|
+
const ids = Array.isArray(taskIds) ? taskIds.filter((x) => typeof x === "string" && x.length) : [];
|
|
321
|
+
const n = Math.max(0, Math.min(Number(workerCount) || 0, ids.length));
|
|
322
|
+
if (n === 0) return [];
|
|
323
|
+
const buckets = Array.from({ length: n }, () => []);
|
|
324
|
+
for (let i = 0; i < ids.length; i++) buckets[i % n].push(ids[i]);
|
|
325
|
+
return buckets.filter((b) => b.length > 0);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* runDispatch — the single instrument every command delegates to.
|
|
330
|
+
*
|
|
331
|
+
* Probes the planner; if N≥2 with green gates, spawns N detached headless
|
|
332
|
+
* children via `autoSpawnHeadless()` (one per task subset) and returns
|
|
333
|
+
* `{decision:'fan_out', fanOutCount, workerResults, plan}`. If N<2, returns
|
|
334
|
+
* `{decision:'sequential', …}` so the caller falls through to its legacy
|
|
335
|
+
* single-worker path. If planning fails, returns `{decision:'sequential'}`
|
|
336
|
+
* with a warning — purely additive, never throws.
|
|
337
|
+
*
|
|
338
|
+
* Design intent (per user directive 2026-04-23):
|
|
339
|
+
* "create 1 instrument that accomplishes this instead of implementing it
|
|
340
|
+
* in all the commands."
|
|
341
|
+
*
|
|
342
|
+
* Command files invoke this via one bash line; they do not re-implement the
|
|
343
|
+
* probe-and-branch pattern. The unattended supervisor (v1.5.0 §15a) uses the
|
|
344
|
+
* same planner + dep-injected spawn but owns its own heartbeat/watchdog — it
|
|
345
|
+
* does not consume this function.
|
|
346
|
+
*
|
|
347
|
+
* Contract: wave-join-contract.md v1.1.0; headless-default-contract v2.0.0.
|
|
348
|
+
*/
|
|
349
|
+
function runDispatch(opts) {
|
|
350
|
+
const projectDir = (opts && opts.projectDir) || process.cwd();
|
|
351
|
+
const command = (opts && opts.command) || null;
|
|
352
|
+
if (!command) {
|
|
353
|
+
return {
|
|
354
|
+
decision: "invalid",
|
|
355
|
+
error: "missing_command",
|
|
356
|
+
fanOutCount: 0,
|
|
357
|
+
workerResults: [],
|
|
358
|
+
plan: [],
|
|
359
|
+
mode: detectMode(opts, opts && opts.env),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let result;
|
|
364
|
+
try {
|
|
365
|
+
result = runParallel({
|
|
366
|
+
projectDir,
|
|
367
|
+
mode: (opts && opts.mode) || undefined,
|
|
368
|
+
milestone: (opts && opts.milestone) || undefined,
|
|
369
|
+
domain: (opts && opts.domain) || undefined,
|
|
370
|
+
dryRun: true,
|
|
371
|
+
env: opts && opts.env,
|
|
372
|
+
});
|
|
373
|
+
} catch (e) {
|
|
374
|
+
appendEvent(projectDir, {
|
|
375
|
+
type: "parallelism_reduced",
|
|
376
|
+
source: "dispatch",
|
|
377
|
+
original_count: null,
|
|
378
|
+
reduced_count: 1,
|
|
379
|
+
reason: `planner_error:${(e && e.message) || "unknown"}`,
|
|
380
|
+
ts: new Date().toISOString(),
|
|
381
|
+
});
|
|
382
|
+
return {
|
|
383
|
+
decision: "sequential",
|
|
384
|
+
fanOutCount: 1,
|
|
385
|
+
workerResults: [],
|
|
386
|
+
plan: [],
|
|
387
|
+
mode: detectMode(opts, opts && opts.env),
|
|
388
|
+
error: `planner_error:${(e && e.message) || "unknown"}`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const workerCount = Number(result.workerCount) || 0;
|
|
393
|
+
const parallelTasks = Array.isArray(result.parallelTasks) ? result.parallelTasks : [];
|
|
394
|
+
const subsets = workerCount >= 2 ? _partitionTaskIds(parallelTasks, workerCount) : [];
|
|
395
|
+
|
|
396
|
+
if (subsets.length < 2) {
|
|
397
|
+
return {
|
|
398
|
+
decision: "sequential",
|
|
399
|
+
fanOutCount: 1,
|
|
400
|
+
workerResults: [],
|
|
401
|
+
plan: result.plan || [],
|
|
402
|
+
mode: result.mode,
|
|
403
|
+
parallelTasks,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Resolve the spawner — tests inject a stub; production uses the real
|
|
408
|
+
// `autoSpawnHeadless`. Required: `({command, args, projectDir, env}) => {id, pid, …}`.
|
|
409
|
+
let spawnImpl = opts && opts.spawnHeadlessImpl;
|
|
410
|
+
if (!spawnImpl) {
|
|
411
|
+
try {
|
|
412
|
+
spawnImpl = require(path.join(__dirname, "headless-auto-spawn.cjs")).autoSpawnHeadless;
|
|
413
|
+
} catch (e) {
|
|
414
|
+
return {
|
|
415
|
+
decision: "sequential",
|
|
416
|
+
fanOutCount: 1,
|
|
417
|
+
workerResults: [],
|
|
418
|
+
plan: result.plan || [],
|
|
419
|
+
mode: result.mode,
|
|
420
|
+
error: `spawn_load:${(e && e.message) || "unknown"}`,
|
|
421
|
+
parallelTasks,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
appendEvent(projectDir, {
|
|
427
|
+
type: "fan_out",
|
|
428
|
+
source: "dispatch",
|
|
429
|
+
command,
|
|
430
|
+
fan_out_count: subsets.length,
|
|
431
|
+
task_ids: parallelTasks,
|
|
432
|
+
mode: result.mode,
|
|
433
|
+
ts: new Date().toISOString(),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const workerResults = [];
|
|
437
|
+
for (let i = 0; i < subsets.length; i++) {
|
|
438
|
+
const subset = subsets[i];
|
|
439
|
+
const workerEnv = {
|
|
440
|
+
GSD_T_WORKER_TASK_IDS: subset.join(","),
|
|
441
|
+
GSD_T_WORKER_INDEX: String(i),
|
|
442
|
+
GSD_T_WORKER_TOTAL: String(subsets.length),
|
|
443
|
+
};
|
|
444
|
+
let spawnResult = null;
|
|
445
|
+
let spawnError = null;
|
|
446
|
+
try {
|
|
447
|
+
spawnResult = spawnImpl({
|
|
448
|
+
command,
|
|
449
|
+
args: [],
|
|
450
|
+
projectDir,
|
|
451
|
+
env: workerEnv,
|
|
452
|
+
spawnType: "primary",
|
|
453
|
+
});
|
|
454
|
+
} catch (e) {
|
|
455
|
+
spawnError = (e && e.message) || "unknown";
|
|
456
|
+
}
|
|
457
|
+
appendEvent(projectDir, {
|
|
458
|
+
type: "task_start",
|
|
459
|
+
source: "dispatch",
|
|
460
|
+
worker_index: i,
|
|
461
|
+
worker_total: subsets.length,
|
|
462
|
+
task_ids: subset,
|
|
463
|
+
command,
|
|
464
|
+
spawn_id: spawnResult && spawnResult.id,
|
|
465
|
+
pid: spawnResult && spawnResult.pid,
|
|
466
|
+
error: spawnError,
|
|
467
|
+
ts: new Date().toISOString(),
|
|
468
|
+
});
|
|
469
|
+
workerResults.push({
|
|
470
|
+
idx: i,
|
|
471
|
+
taskIds: subset,
|
|
472
|
+
spawnId: spawnResult && spawnResult.id,
|
|
473
|
+
pid: spawnResult && spawnResult.pid,
|
|
474
|
+
logPath: spawnResult && spawnResult.logPath,
|
|
475
|
+
error: spawnError,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
decision: "fan_out",
|
|
481
|
+
fanOutCount: subsets.length,
|
|
482
|
+
workerResults,
|
|
483
|
+
plan: result.plan || [],
|
|
484
|
+
mode: result.mode,
|
|
485
|
+
parallelTasks,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
304
489
|
// ─── CLI entry ────────────────────────────────────────────────────────────
|
|
305
490
|
|
|
306
491
|
// ─── dry-run table formatter ──────────────────────────────────────────────
|
|
@@ -339,15 +524,17 @@ function runCli(argv, env) {
|
|
|
339
524
|
return 0;
|
|
340
525
|
}
|
|
341
526
|
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
|
-
});
|
|
527
|
+
|
|
528
|
+
// --dry-run: plan-only output, same as M44 D2 baseline.
|
|
350
529
|
if (args.dryRun) {
|
|
530
|
+
const result = runParallel({
|
|
531
|
+
projectDir: process.cwd(),
|
|
532
|
+
mode,
|
|
533
|
+
milestone: args.milestone,
|
|
534
|
+
domain: args.domain,
|
|
535
|
+
dryRun: true,
|
|
536
|
+
env,
|
|
537
|
+
});
|
|
351
538
|
process.stdout.write(formatPlanTable(result.plan));
|
|
352
539
|
process.stdout.write(
|
|
353
540
|
`\nTotal workers: ${result.workerCount} Mode: ${result.mode}` +
|
|
@@ -358,6 +545,50 @@ function runCli(argv, env) {
|
|
|
358
545
|
);
|
|
359
546
|
return 0;
|
|
360
547
|
}
|
|
548
|
+
|
|
549
|
+
// --command: live dispatch. The single instrument that command files
|
|
550
|
+
// delegate to instead of re-implementing probe-and-branch logic.
|
|
551
|
+
if (args.command) {
|
|
552
|
+
const dispatch = runDispatch({
|
|
553
|
+
projectDir: process.cwd(),
|
|
554
|
+
mode,
|
|
555
|
+
milestone: args.milestone,
|
|
556
|
+
domain: args.domain,
|
|
557
|
+
command: args.command,
|
|
558
|
+
env,
|
|
559
|
+
});
|
|
560
|
+
if (dispatch.decision === "fan_out") {
|
|
561
|
+
process.stdout.write(
|
|
562
|
+
`gsd-t parallel — fan_out command=${args.command} mode=${dispatch.mode} workers=${dispatch.fanOutCount}\n`,
|
|
563
|
+
);
|
|
564
|
+
for (const w of dispatch.workerResults) {
|
|
565
|
+
process.stdout.write(
|
|
566
|
+
` worker[${w.idx}] tasks=${w.taskIds.join(",")} spawn=${w.spawnId || "-"} pid=${w.pid || "-"}${w.error ? " error=" + w.error : ""}\n`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
if (dispatch.decision === "sequential") {
|
|
572
|
+
process.stdout.write(
|
|
573
|
+
`gsd-t parallel — sequential command=${args.command} mode=${dispatch.mode} (N<2, caller falls through)${dispatch.error ? " error=" + dispatch.error : ""}\n`,
|
|
574
|
+
);
|
|
575
|
+
return 2; // non-zero so shell `&&` short-circuits; caller branches on $?
|
|
576
|
+
}
|
|
577
|
+
process.stdout.write(
|
|
578
|
+
`gsd-t parallel — ${dispatch.decision} command=${args.command} mode=${dispatch.mode}\n`,
|
|
579
|
+
);
|
|
580
|
+
return 3;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Legacy path: no --dry-run, no --command. Print plan summary only.
|
|
584
|
+
const result = runParallel({
|
|
585
|
+
projectDir: process.cwd(),
|
|
586
|
+
mode,
|
|
587
|
+
milestone: args.milestone,
|
|
588
|
+
domain: args.domain,
|
|
589
|
+
dryRun: false,
|
|
590
|
+
env,
|
|
591
|
+
});
|
|
361
592
|
process.stdout.write(
|
|
362
593
|
`gsd-t parallel — mode=${result.mode} workers=${result.workerCount}\n`,
|
|
363
594
|
);
|
|
@@ -366,6 +597,7 @@ function runCli(argv, env) {
|
|
|
366
597
|
|
|
367
598
|
module.exports = {
|
|
368
599
|
runParallel,
|
|
600
|
+
runDispatch,
|
|
369
601
|
runCli,
|
|
370
602
|
formatPlanTable,
|
|
371
603
|
PLAN_HEADER,
|
|
@@ -373,6 +605,7 @@ module.exports = {
|
|
|
373
605
|
_parseArgv: parseArgv,
|
|
374
606
|
_detectMode: detectMode,
|
|
375
607
|
_appendEvent: appendEvent,
|
|
608
|
+
_partitionTaskIds,
|
|
376
609
|
_HELP_TEXT: HELP_TEXT,
|
|
377
610
|
};
|
|
378
611
|
|
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`);
|