@tekyzinc/gsd-t 3.29.10 → 4.0.12

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.
Files changed (112) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/bin/gsd-t-parallel.cjs +64 -13
  3. package/bin/gsd-t-test-data-adapters/file-json-array.cjs +27 -3
  4. package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +6 -1
  5. package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +16 -1
  6. package/bin/gsd-t-test-data-ledger.cjs +1 -0
  7. package/bin/gsd-t.js +48 -376
  8. package/bin/journey-coverage.cjs +6 -3
  9. package/bin/parallel-cli.cjs +13 -1
  10. package/commands/gsd-t-debug.md +47 -533
  11. package/commands/gsd-t-design-decompose.md +24 -497
  12. package/commands/gsd-t-doc-ripple.md +23 -139
  13. package/commands/gsd-t-execute.md +41 -958
  14. package/commands/gsd-t-help.md +0 -2
  15. package/commands/gsd-t-impact.md +26 -295
  16. package/commands/gsd-t-integrate.md +44 -369
  17. package/commands/gsd-t-milestone.md +25 -124
  18. package/commands/gsd-t-partition.md +28 -539
  19. package/commands/gsd-t-plan.md +26 -472
  20. package/commands/gsd-t-prd.md +25 -311
  21. package/commands/gsd-t-quick.md +1 -1
  22. package/commands/gsd-t-resume.md +0 -33
  23. package/commands/gsd-t-verify.md +52 -569
  24. package/commands/gsd-t-wave.md +41 -430
  25. package/package.json +1 -1
  26. package/scripts/gsd-t-calibration-hook.js +3 -1
  27. package/scripts/hooks/pre-commit-journey-coverage +0 -2
  28. package/scripts/hooks/pre-commit-playwright-gate +0 -2
  29. package/templates/CLAUDE-global.md +43 -173
  30. package/templates/CLAUDE-project.md +118 -104
  31. package/templates/prompts/design-verify-subagent.md +3 -0
  32. package/templates/prompts/qa-subagent.md +3 -0
  33. package/templates/prompts/red-team-subagent.md +5 -2
  34. package/templates/workflows/_lib.js +176 -0
  35. package/templates/workflows/gsd-t-debug.workflow.js +86 -0
  36. package/templates/workflows/gsd-t-execute.workflow.js +206 -0
  37. package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
  38. package/templates/workflows/gsd-t-phase.workflow.js +94 -0
  39. package/templates/workflows/gsd-t-quick.workflow.js +72 -0
  40. package/templates/workflows/gsd-t-verify.workflow.js +348 -0
  41. package/templates/workflows/gsd-t-wave.workflow.js +43 -0
  42. package/bin/check-headless-sessions.js +0 -140
  43. package/bin/context-budget-audit.cjs +0 -447
  44. package/bin/context-meter-config.cjs +0 -101
  45. package/bin/context-meter-config.test.cjs +0 -101
  46. package/bin/event-stream.cjs +0 -205
  47. package/bin/gsd-t-benchmark-orchestrator.js +0 -437
  48. package/bin/gsd-t-capture-lint.cjs +0 -440
  49. package/bin/gsd-t-economics.cjs +0 -315
  50. package/bin/gsd-t-in-session-usage.cjs +0 -213
  51. package/bin/gsd-t-orchestrator-config.cjs +0 -161
  52. package/bin/gsd-t-orchestrator-queue.cjs +0 -180
  53. package/bin/gsd-t-orchestrator-recover.cjs +0 -231
  54. package/bin/gsd-t-orchestrator-worker.cjs +0 -219
  55. package/bin/gsd-t-orchestrator.js +0 -535
  56. package/bin/gsd-t-parallel-probe.cjs +0 -132
  57. package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
  58. package/bin/gsd-t-ratelimit-probe.cjs +0 -648
  59. package/bin/gsd-t-report-tokens.cjs +0 -549
  60. package/bin/gsd-t-stream-feed-client.cjs +0 -151
  61. package/bin/gsd-t-token-backfill.cjs +0 -366
  62. package/bin/gsd-t-token-capture.cjs +0 -321
  63. package/bin/gsd-t-token-dashboard.cjs +0 -353
  64. package/bin/gsd-t-token-regenerate-log.cjs +0 -129
  65. package/bin/gsd-t-tool-attribution.cjs +0 -377
  66. package/bin/gsd-t-tool-cost.cjs +0 -195
  67. package/bin/gsd-t-transcript-tee.cjs +0 -246
  68. package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
  69. package/bin/gsd-t-unattended-platform.cjs +0 -551
  70. package/bin/gsd-t-unattended-platform.js +0 -381
  71. package/bin/gsd-t-unattended-safety.cjs +0 -773
  72. package/bin/gsd-t-unattended-safety.js +0 -788
  73. package/bin/gsd-t-unattended.cjs +0 -2109
  74. package/bin/gsd-t-unattended.js +0 -1367
  75. package/bin/gsd-t-worker-dispatch.cjs +0 -211
  76. package/bin/handoff-lock.cjs +0 -249
  77. package/bin/handoff-lock.js +0 -249
  78. package/bin/headless-auto-spawn.cjs +0 -711
  79. package/bin/headless-auto-spawn.js +0 -373
  80. package/bin/headless-exit-codes.cjs +0 -67
  81. package/bin/live-activity-report.cjs +0 -615
  82. package/bin/log-tail.cjs +0 -81
  83. package/bin/m44-proof-measure.cjs +0 -285
  84. package/bin/m46-iter-proof.cjs +0 -149
  85. package/bin/m46-worker-proof.cjs +0 -201
  86. package/bin/m55-substrate-proof.cjs +0 -134
  87. package/bin/metrics-rollup.js +0 -200
  88. package/bin/model-windows.cjs +0 -99
  89. package/bin/model-windows.test.cjs +0 -75
  90. package/bin/parallelism-report.cjs +0 -535
  91. package/bin/runway-estimator.cjs +0 -242
  92. package/bin/spawn-plan-derive.cjs +0 -163
  93. package/bin/spawn-plan-status-updater.cjs +0 -292
  94. package/bin/spawn-plan-writer.cjs +0 -204
  95. package/bin/supervisor-pid-fingerprint.cjs +0 -126
  96. package/bin/token-budget.cjs +0 -265
  97. package/bin/unattended-watch-format.cjs +0 -178
  98. package/bin/watch-progress.js +0 -155
  99. package/commands/gsd-t-unattended-stop.md +0 -85
  100. package/commands/gsd-t-unattended-watch.md +0 -465
  101. package/commands/gsd-t-unattended.md +0 -476
  102. package/commands/gsd-t-visualize.md +0 -116
  103. package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
  104. package/scripts/gsd-t-context-meter.js +0 -341
  105. package/scripts/gsd-t-context-meter.test.js +0 -471
  106. package/scripts/gsd-t-dashboard-server.js +0 -1203
  107. package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
  108. package/scripts/gsd-t-transcript.html +0 -1821
  109. package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
  110. package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
  111. package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
  112. package/templates/hooks/post-commit-spawn-plan.sh +0 -85
@@ -1,242 +0,0 @@
1
- 'use strict';
2
- /**
3
- * GSD-T Runway Estimator (M43 D5 — dialog-channel growth meter)
4
- *
5
- * Under M43's channel-separation model, the only thing that runs in the
6
- * in-session channel is the `/gsd` router dialog. Everything else spawns.
7
- * This module reads the per-turn usage rows the M43 D1 capture writes to
8
- * `.gsd-t/metrics/token-usage.jsonl` (schema v2) and surfaces a one-line
9
- * "~N turns to `/compact`" warning to the router.
10
- *
11
- * Scope (revised 2026-04-21 per M43 partition.md §D5):
12
- * - Read-only. Never refuses, never reroutes. Under always-headless there
13
- * is nothing to reroute *to*.
14
- * - Median-of-deltas growth slope (outlier-resistant; a single spike turn
15
- * does not flip `shouldWarn`).
16
- * - Zero external deps. `.cjs` so it loads in both ESM-default projects
17
- * and CJS projects without transpilation.
18
- *
19
- * Contracts:
20
- * - .gsd-t/contracts/context-meter-contract.md §Dialog Growth Meter
21
- * - .gsd-t/contracts/metrics-schema-contract.md (schema v2)
22
- *
23
- * Consumers:
24
- * - commands/gsd.md (router warning footer)
25
- */
26
-
27
- const fs = require('fs');
28
- const path = require('path');
29
- const { SAFE_DEFAULT_WINDOW } = require('./model-windows.cjs');
30
-
31
- const DEFAULT_K = 5;
32
- // The model context cap IS the model's true window. Default to the model-aware
33
- // safe LARGE window (1M) — the old 200K literal was correct only for pre-4
34
- // models and made turn-to-compact predictions fire 5× too early on Opus/Sonnet.
35
- // Callers may still pass an explicit `modelContextCap`, and resolveContextCap()
36
- // below prefers a fresh Context Meter reading when available.
37
- const DEFAULT_MODEL_CONTEXT_CAP = SAFE_DEFAULT_WINDOW;
38
- // Claude Code starts auto-compacting ~8% before the model window fills, so the
39
- // effective dialog ceiling is 0.92 × modelContextCap.
40
- const PRE_COMPACT_HEADROOM = 0.92;
41
- // Context Meter state — its modelWindowSize is model-aware (bin/model-windows.cjs).
42
- const METER_STATE_REL = '.gsd-t/.context-meter-state.json';
43
- const METER_STATE_STALE_MS = 5 * 60 * 1000;
44
-
45
- /**
46
- * Resolve the effective model context cap. Priority:
47
- * 1. explicit opts.modelContextCap (caller override)
48
- * 2. fresh Context Meter state modelWindowSize (model-aware, written by the
49
- * meter hook from the running model)
50
- * 3. DEFAULT_MODEL_CONTEXT_CAP (safe large fallback)
51
- */
52
- function resolveContextCap(projectDir, optCap) {
53
- if (Number.isFinite(optCap) && optCap > 0) return optCap;
54
- try {
55
- const fp = path.join(projectDir || '.', METER_STATE_REL);
56
- const s = JSON.parse(fs.readFileSync(fp, 'utf8'));
57
- if (s && typeof s.modelWindowSize === 'number' && s.modelWindowSize > 0 && s.timestamp) {
58
- const age = Date.now() - Date.parse(s.timestamp);
59
- if (!isNaN(age) && age >= 0 && age <= METER_STATE_STALE_MS) {
60
- return s.modelWindowSize;
61
- }
62
- }
63
- } catch (_) { /* fall through to default */ }
64
- return DEFAULT_MODEL_CONTEXT_CAP;
65
- }
66
- const DEFAULT_WARN_THRESHOLD_TURNS = 5;
67
- const MIN_HISTORY = 3;
68
-
69
- // ── Row loading ──────────────────────────────────────────────────────
70
-
71
- function _safeParse(line) {
72
- const s = String(line || '').trim();
73
- if (!s || s[0] !== '{') return null;
74
- try { return JSON.parse(s); } catch (_) { return null; }
75
- }
76
-
77
- /**
78
- * Load in-session rows for the given session from the canonical sink.
79
- *
80
- * @param {string} projectDir
81
- * @param {string} sessionId
82
- * @returns {object[]} schema-v2 rows, unsorted
83
- */
84
- function _loadInSessionRows(projectDir, sessionId) {
85
- const p = path.join(projectDir, '.gsd-t', 'metrics', 'token-usage.jsonl');
86
- if (!fs.existsSync(p)) return [];
87
- let text;
88
- try { text = fs.readFileSync(p, 'utf8'); } catch (_) { return []; }
89
- const rows = [];
90
- for (const line of text.split(/\r?\n/)) {
91
- const j = _safeParse(line);
92
- if (!j) continue;
93
- if (j.sessionType !== 'in-session') continue;
94
- if (sessionId != null && j.session_id !== sessionId) continue;
95
- rows.push(j);
96
- }
97
- return rows;
98
- }
99
-
100
- // ── Math helpers ─────────────────────────────────────────────────────
101
-
102
- function _median(arr) {
103
- if (!arr.length) return 0;
104
- const sorted = arr.slice().sort((a, b) => a - b);
105
- const mid = Math.floor(sorted.length / 2);
106
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
107
- }
108
-
109
- /**
110
- * Deterministic ordering for per-turn rows. Prefers timestamp (`ts`) when
111
- * present (monotonic per session), falls back to `turn_id` string compare.
112
- */
113
- function _sortTurns(rows) {
114
- return rows.slice().sort((a, b) => {
115
- const ta = a.ts || '';
116
- const tb = b.ts || '';
117
- if (ta !== tb) return ta < tb ? -1 : 1;
118
- const ia = String(a.turn_id || '');
119
- const ib = String(b.turn_id || '');
120
- if (ia !== ib) return ia < ib ? -1 : 1;
121
- return 0;
122
- });
123
- }
124
-
125
- /**
126
- * Compute the dialog-growth signal for one session.
127
- *
128
- * Reads the last K in-session turns for `sessionId` from
129
- * `.gsd-t/metrics/token-usage.jsonl`, computes the median of turn-over-turn
130
- * `input_tokens` deltas (robust to single-turn spikes), then predicts how
131
- * many turns remain before dialog input crosses the pre-auto-compact ceiling
132
- * (`modelContextCap × 0.92`).
133
- *
134
- * Returns `{ shouldWarn: false, reason: 'insufficient_history' }` when fewer
135
- * than `MIN_HISTORY` in-session turns exist for the session.
136
- *
137
- * @param {object} opts
138
- * @param {string} opts.projectDir
139
- * @param {string} opts.sessionId required
140
- * @param {number} [opts.k] default 5 (last K turns)
141
- * @param {number} [opts.modelContextCap] default: fresh Context Meter
142
- * modelWindowSize if available, else the model-aware safe window (1M)
143
- * @param {number} [opts.warnThresholdTurns] default 5
144
- * @returns {{
145
- * shouldWarn: boolean,
146
- * slope: number,
147
- * median_delta: number,
148
- * latest_input_tokens: number,
149
- * predicted_turns_to_compact: number,
150
- * k: number,
151
- * history_len: number,
152
- * reason?: string
153
- * }}
154
- */
155
- function estimateDialogGrowth(opts) {
156
- const projectDir = (opts && opts.projectDir) || '.';
157
- const sessionId = opts && opts.sessionId;
158
- const k = (opts && Number.isFinite(opts.k) && opts.k > 0) ? Math.floor(opts.k) : DEFAULT_K;
159
- const cap = resolveContextCap(projectDir, opts && opts.modelContextCap);
160
- const warnThreshold = (opts && Number.isFinite(opts.warnThresholdTurns) && opts.warnThresholdTurns > 0)
161
- ? opts.warnThresholdTurns
162
- : DEFAULT_WARN_THRESHOLD_TURNS;
163
-
164
- const empty = {
165
- shouldWarn: false,
166
- slope: 0,
167
- median_delta: 0,
168
- latest_input_tokens: 0,
169
- predicted_turns_to_compact: Infinity,
170
- k,
171
- history_len: 0,
172
- };
173
-
174
- if (!sessionId) {
175
- return { ...empty, reason: 'missing_session_id' };
176
- }
177
-
178
- const allRows = _loadInSessionRows(projectDir, sessionId);
179
- if (allRows.length === 0) {
180
- return { ...empty, reason: 'no_rows' };
181
- }
182
-
183
- const sorted = _sortTurns(allRows);
184
- const window = sorted.slice(-k);
185
-
186
- if (window.length < MIN_HISTORY) {
187
- return { ...empty, history_len: window.length, reason: 'insufficient_history' };
188
- }
189
-
190
- // Per-turn input token footprint. Schema v2 writes `inputTokens` (camel)
191
- // via the token-capture wrapper; older rows may carry the raw envelope.
192
- const inputs = window.map(r => {
193
- if (Number.isFinite(r.inputTokens)) return r.inputTokens;
194
- if (r.usage && Number.isFinite(r.usage.input_tokens)) return r.usage.input_tokens;
195
- return 0;
196
- });
197
-
198
- const deltas = [];
199
- for (let i = 1; i < inputs.length; i++) {
200
- deltas.push(inputs[i] - inputs[i - 1]);
201
- }
202
-
203
- const median_delta = _median(deltas);
204
- const slope = median_delta;
205
- const latest_input_tokens = inputs[inputs.length - 1];
206
-
207
- const ceiling = cap * PRE_COMPACT_HEADROOM;
208
- let predicted_turns_to_compact;
209
- if (slope > 0) {
210
- const headroom = ceiling - latest_input_tokens;
211
- predicted_turns_to_compact = headroom <= 0 ? 0 : Math.ceil(headroom / slope);
212
- } else {
213
- predicted_turns_to_compact = Infinity;
214
- }
215
-
216
- const shouldWarn = Number.isFinite(predicted_turns_to_compact) && predicted_turns_to_compact <= warnThreshold;
217
-
218
- return {
219
- shouldWarn,
220
- slope,
221
- median_delta,
222
- latest_input_tokens,
223
- predicted_turns_to_compact,
224
- k,
225
- history_len: window.length,
226
- };
227
- }
228
-
229
- module.exports = {
230
- estimateDialogGrowth,
231
- // Exposed for unit tests; not part of the stable contract.
232
- _internal: {
233
- _median,
234
- _sortTurns,
235
- _loadInSessionRows,
236
- DEFAULT_K,
237
- DEFAULT_MODEL_CONTEXT_CAP,
238
- PRE_COMPACT_HEADROOM,
239
- DEFAULT_WARN_THRESHOLD_TURNS,
240
- MIN_HISTORY,
241
- },
242
- };
@@ -1,163 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * GSD-T Spawn Plan Derive (M44 D8 T4)
5
- *
6
- * Deterministic projection of `.gsd-t/partition.md` + `.gsd-t/domains/*\/tasks.md`
7
- * into the current "incomplete tasks" slice. No LLM calls, no prompts, no
8
- * heuristics — just parsing + slicing.
9
- *
10
- * Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
11
- *
12
- * Consumed by: `bin/spawn-plan-writer.cjs` (when caller does NOT pass an
13
- * explicit `tasks: [...]` array).
14
- */
15
-
16
- const fs = require('fs');
17
- const path = require('path');
18
-
19
- /**
20
- * Derive a `{milestone, wave, domains, tasks}` slice from on-disk partition
21
- * + tasks. The "current incomplete slice" is the next contiguous run of
22
- * tasks whose checkboxes are still unchecked (`[ ]`) across all domains in
23
- * the current wave.
24
- *
25
- * Gracefully returns an empty slice when partition.md is absent or no
26
- * incomplete tasks remain. Never throws for missing files.
27
- *
28
- * @param {object} [opts]
29
- * @param {string} [opts.projectDir='.']
30
- * @param {string} [opts.milestone] filter hint (e.g. 'M44'); defaults to parsed from partition
31
- * @param {string|number} [opts.currentIter] unused today; reserved for future iter-slicing
32
- * @returns {{ milestone: string|null, wave: string|null, domains: string[], tasks: Array<{id:string,title:string,status:string}> }}
33
- */
34
- function derivePlanFromPartition(opts) {
35
- const projectDir = (opts && opts.projectDir) || '.';
36
- const result = { milestone: null, wave: null, domains: [], tasks: [] };
37
-
38
- const partitionPath = path.join(projectDir, '.gsd-t', 'partition.md');
39
- if (!fs.existsSync(partitionPath)) {
40
- return result;
41
- }
42
-
43
- let partitionText;
44
- try {
45
- partitionText = fs.readFileSync(partitionPath, 'utf8');
46
- } catch (_) {
47
- return result;
48
- }
49
-
50
- // Parse the milestone id (first `M\d+` in heading) and the wave we care
51
- // about. For now we pick the first wave with any incomplete tasks.
52
- const milestoneMatch = partitionText.match(/\b(M\d+)\b/);
53
- if (milestoneMatch) result.milestone = milestoneMatch[1];
54
- if (opts && opts.milestone) result.milestone = String(opts.milestone);
55
-
56
- // Enumerate domain dirs under .gsd-t/domains/
57
- const domainsRoot = path.join(projectDir, '.gsd-t', 'domains');
58
- const domains = _listDomains(domainsRoot, result.milestone);
59
- if (!domains.length) return result;
60
-
61
- // Collect incomplete tasks from each domain's tasks.md, grouped by wave.
62
- // If no explicit wave header is present, "unknown" groups them.
63
- const byWave = new Map();
64
- for (const d of domains) {
65
- const tasks = _parseTasks(path.join(domainsRoot, d, 'tasks.md'));
66
- for (const t of tasks) {
67
- if (!byWave.has(t.wave)) byWave.set(t.wave, []);
68
- byWave.get(t.wave).push({ ...t, domain: d });
69
- }
70
- }
71
-
72
- // Pick the wave with the most incomplete tasks; ties: first seen.
73
- let pickWave = null;
74
- let pickCount = -1;
75
- for (const [w, arr] of byWave) {
76
- const incomplete = arr.filter((t) => t.status !== 'done').length;
77
- if (incomplete > pickCount) {
78
- pickCount = incomplete;
79
- pickWave = w;
80
- }
81
- }
82
- if (pickWave == null) return result;
83
-
84
- const waveTasks = byWave.get(pickWave) || [];
85
- result.wave = pickWave;
86
- const seenDomains = new Set();
87
- for (const t of waveTasks) {
88
- if (t.domain) seenDomains.add(t.domain);
89
- result.tasks.push({
90
- id: t.id,
91
- title: t.title,
92
- status: t.status,
93
- });
94
- }
95
- result.domains = [...seenDomains];
96
- return result;
97
- }
98
-
99
- // ── internal parsers ──────────────────────────────────────────────────────
100
-
101
- function _listDomains(dir, milestone) {
102
- if (!fs.existsSync(dir)) return [];
103
- let entries;
104
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return []; }
105
- const names = [];
106
- for (const e of entries) {
107
- if (!e.isDirectory()) continue;
108
- if (milestone) {
109
- // Normalize to lowercase m-prefix so we match `m44-d8-…`
110
- const prefix = String(milestone).toLowerCase() + '-';
111
- if (!e.name.toLowerCase().startsWith(prefix)) continue;
112
- }
113
- names.push(e.name);
114
- }
115
- return names.sort();
116
- }
117
-
118
- /**
119
- * Parse a domain tasks.md into a list of `{id, title, status, wave}`.
120
- * Recognizes:
121
- * `- [ ] **M44-D8-T1** — title…`
122
- * `- [x] **M44-D8-T1** — title…`
123
- * `- [x] done (2026-04-23 · commit abc123) **M44-D8-T1** — title…`
124
- * Task-id pattern is `M\d+-D\d+-T\d+`. Wave comes from the nearest
125
- * preceding `## Wave N` header.
126
- */
127
- function _parseTasks(tasksPath) {
128
- if (!fs.existsSync(tasksPath)) return [];
129
- let text;
130
- try { text = fs.readFileSync(tasksPath, 'utf8'); } catch (_) { return []; }
131
-
132
- const out = [];
133
- let currentWave = 'unknown';
134
- const lines = text.split('\n');
135
- for (const line of lines) {
136
- const waveMatch = line.match(/^##\s+Wave\s+(\d+)\b/i);
137
- if (waveMatch) {
138
- currentWave = 'wave-' + waveMatch[1];
139
- continue;
140
- }
141
- const m = line.match(/^-\s*\[([ xX])\][^\*]*\*\*(M\d+-D\d+-T\d+)\*\*\s*[—-]\s*(.+)$/);
142
- if (!m) continue;
143
- const checked = m[1].toLowerCase() === 'x';
144
- const id = m[2];
145
- let title = m[3].trim();
146
- // Strip trailing " — sub-note" if present; keep only the primary title.
147
- // The task title may contain em-dashes; safer to just use the first line.
148
- title = title.split(/\s{2,}/)[0].trim();
149
- out.push({
150
- id,
151
- title,
152
- status: checked ? 'done' : 'pending',
153
- wave: currentWave,
154
- });
155
- }
156
- return out;
157
- }
158
-
159
- module.exports = {
160
- derivePlanFromPartition,
161
- _parseTasks,
162
- _listDomains,
163
- };
@@ -1,292 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * GSD-T Spawn Plan Status Updater (M44 D8 T2)
5
- *
6
- * Pure module — patches spawn-plan files written by spawn-plan-writer.cjs.
7
- * Called by the post-commit git hook and by `captureSpawn` on completion.
8
- *
9
- * Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
10
- *
11
- * Hard rules:
12
- * - Atomic rewrites only (temp file + rename)
13
- * - No-op on unknown spawnId or unknown taskId — observability is best-effort
14
- * - Never throw for missing files; callers rely on silent-fail behavior
15
- */
16
-
17
- const fs = require('fs');
18
- const path = require('path');
19
-
20
- const SPAWNS_SUBDIR = path.join('.gsd-t', 'spawns');
21
-
22
- /**
23
- * Patch `{spawnId}.json`: find the task with the given id and flip its
24
- * status to `done`, recording the commit SHA and (optional) token
25
- * attribution. If the next pending task exists, promote it to
26
- * `in_progress` so the single-active-task invariant is preserved.
27
- *
28
- * @param {object} opts
29
- * @param {string} opts.spawnId
30
- * @param {string} opts.taskId
31
- * @param {string} [opts.commit] short or full SHA
32
- * @param {object|null} [opts.tokens] `{in,out,cr,cc,cost_usd}` or null
33
- * @param {string} [opts.projectDir='.']
34
- * @returns {{ patched: boolean, path: string }}
35
- */
36
- function markTaskDone(opts) {
37
- if (!opts || typeof opts !== 'object') return { patched: false, path: null };
38
- const { spawnId, taskId } = opts;
39
- if (!spawnId || !taskId) return { patched: false, path: null };
40
-
41
- const projectDir = opts.projectDir || '.';
42
- const fp = _planPath(projectDir, spawnId);
43
- const plan = _readPlan(fp);
44
- if (!plan) return { patched: false, path: fp };
45
-
46
- const tasks = Array.isArray(plan.tasks) ? plan.tasks : [];
47
- const idx = tasks.findIndex((t) => t && t.id === taskId);
48
- if (idx < 0) return { patched: false, path: fp };
49
-
50
- const task = tasks[idx];
51
- task.status = 'done';
52
- if (typeof opts.commit === 'string' && opts.commit) task.commit = opts.commit;
53
- // Token attribution is "null when absent" per constraints §Format rules.
54
- // The caller passes `null` or an object; we never silently overwrite
55
- // an already-populated tokens field with null.
56
- if (Object.prototype.hasOwnProperty.call(opts, 'tokens')) {
57
- if (opts.tokens && typeof opts.tokens === 'object') {
58
- task.tokens = _normalizeTokens(opts.tokens);
59
- } else if (task.tokens == null) {
60
- task.tokens = null;
61
- }
62
- }
63
-
64
- // Promote the next pending task to in_progress (single-active invariant).
65
- const anyInProgress = tasks.some((t) => t && t.status === 'in_progress');
66
- if (!anyInProgress) {
67
- for (let j = idx + 1; j < tasks.length; j++) {
68
- if (tasks[j] && tasks[j].status === 'pending') {
69
- tasks[j].status = 'in_progress';
70
- break;
71
- }
72
- }
73
- }
74
-
75
- _atomicWriteJson(fp, plan);
76
- return { patched: true, path: fp };
77
- }
78
-
79
- /**
80
- * Mark a spawn plan as ended. Sets `endedAt` to now-ISO and records
81
- * `endedReason` ('success' | 'error' | caller-supplied). No-op on missing
82
- * file. Atomic rewrite.
83
- *
84
- * @param {object} opts
85
- * @param {string} opts.spawnId
86
- * @param {string} [opts.endedReason='success']
87
- * @param {string} [opts.projectDir='.']
88
- * @param {Date} [opts.now]
89
- * @returns {{ patched: boolean, path: string }}
90
- */
91
- function markSpawnEnded(opts) {
92
- if (!opts || typeof opts !== 'object') return { patched: false, path: null };
93
- const { spawnId } = opts;
94
- if (!spawnId) return { patched: false, path: null };
95
-
96
- const projectDir = opts.projectDir || '.';
97
- const fp = _planPath(projectDir, spawnId);
98
- const plan = _readPlan(fp);
99
- if (!plan) return { patched: false, path: fp };
100
-
101
- const now = opts.now instanceof Date ? opts.now : new Date();
102
- plan.endedAt = now.toISOString();
103
- plan.endedReason = opts.endedReason || 'success';
104
- _atomicWriteJson(fp, plan);
105
- return { patched: true, path: fp };
106
- }
107
-
108
- /**
109
- * Enumerate active plan files (those where `endedAt === null`). Returns
110
- * absolute file paths, sorted by startedAt descending.
111
- *
112
- * @param {string} [projectDir='.']
113
- * @returns {string[]}
114
- */
115
- function listActivePlans(projectDir) {
116
- const dir = path.join(projectDir || '.', SPAWNS_SUBDIR);
117
- if (!fs.existsSync(dir)) return [];
118
- let files;
119
- try { files = fs.readdirSync(dir); } catch (_) { return []; }
120
- const results = [];
121
- for (const f of files) {
122
- if (!f.endsWith('.json')) continue;
123
- const fp = path.resolve(path.join(dir, f));
124
- const plan = _readPlan(fp);
125
- if (plan && plan.endedAt == null) results.push({ fp, plan });
126
- }
127
- results.sort((a, b) => {
128
- const ta = Date.parse(a.plan && a.plan.startedAt || '') || 0;
129
- const tb = Date.parse(b.plan && b.plan.startedAt || '') || 0;
130
- return tb - ta;
131
- });
132
- return results.map((r) => r.fp);
133
- }
134
-
135
- // ── token-log attribution lookup ───────────────────────────────────────────
136
-
137
- /**
138
- * Parse `.gsd-t/token-log.md` and return the sum of `{in,out,cr,cc,cost_usd}`
139
- * across all rows whose `Task` column matches the given id and whose
140
- * `Datetime-start` is >= the spawn's startedAt.
141
- *
142
- * Returns `null` when no matching rows exist (per the "zero is a
143
- * measurement, dash is acknowledged gap" rule).
144
- *
145
- * @param {object} opts
146
- * @param {string} opts.projectDir
147
- * @param {string} opts.taskId
148
- * @param {string} opts.spawnStartedAt ISO-8601 (matches plan.startedAt)
149
- * @param {string} [opts.tokenLogPath] override for tests
150
- * @returns {{in:number, out:number, cr:number, cc:number, cost_usd:number}|null}
151
- */
152
- function sumTokensForTask(opts) {
153
- if (!opts || typeof opts !== 'object') return null;
154
- const { projectDir, taskId } = opts;
155
- if (!projectDir || !taskId) return null;
156
-
157
- const fp = opts.tokenLogPath || path.join(projectDir, '.gsd-t', 'token-log.md');
158
- if (!fs.existsSync(fp)) return null;
159
-
160
- const startMs = _parseDatetime(opts.spawnStartedAt);
161
-
162
- let text;
163
- try { text = fs.readFileSync(fp, 'utf8'); } catch (_) { return null; }
164
-
165
- const lines = text.split('\n');
166
- // Find header row to locate column indices.
167
- const headerIdx = lines.findIndex((l) => /^\|\s*Datetime-start\s*\|/.test(l));
168
- if (headerIdx < 0) return null;
169
- const header = lines[headerIdx].split('|').map((s) => s.trim());
170
- const iStart = header.indexOf('Datetime-start');
171
- const iTokens = header.indexOf('Tokens');
172
- const iTask = header.indexOf('Task');
173
- if (iStart < 0 || iTask < 0) return null;
174
-
175
- const sum = { in: 0, out: 0, cr: 0, cc: 0, cost_usd: 0 };
176
- let matched = 0;
177
- for (let i = headerIdx + 2; i < lines.length; i++) {
178
- const row = lines[i];
179
- if (!row || !row.startsWith('|')) continue;
180
- const cells = row.split('|').map((s) => s.trim());
181
- if (cells.length < header.length) continue;
182
- const startedAt = cells[iStart] || '';
183
- const task = cells[iTask] || '';
184
- if (task !== taskId) continue;
185
- // Only rows whose Datetime-start >= spawn.startedAt count.
186
- if (startMs != null) {
187
- const rowMs = _parseDatetime(startedAt);
188
- if (rowMs != null && rowMs < startMs) continue;
189
- }
190
- const tokensCell = iTokens >= 0 ? (cells[iTokens] || '') : '';
191
- const parsed = _parseTokensCell(tokensCell);
192
- if (parsed) {
193
- sum.in += parsed.in;
194
- sum.out += parsed.out;
195
- sum.cr += parsed.cr;
196
- sum.cc += parsed.cc;
197
- sum.cost_usd += parsed.cost_usd;
198
- matched++;
199
- } else {
200
- // No token data in the row; still counts as a "match" for the task
201
- // but contributes zero. Keep going.
202
- matched++;
203
- }
204
- }
205
- if (matched === 0) return null;
206
- // Round cost to 2 decimals for determinism.
207
- sum.cost_usd = Math.round(sum.cost_usd * 100) / 100;
208
- return sum;
209
- }
210
-
211
- // ── internal helpers ───────────────────────────────────────────────────────
212
-
213
- function _planPath(projectDir, spawnId) {
214
- return path.resolve(path.join(projectDir, SPAWNS_SUBDIR, spawnId + '.json'));
215
- }
216
-
217
- function _readPlan(fp) {
218
- try {
219
- const raw = fs.readFileSync(fp, 'utf8');
220
- const parsed = JSON.parse(raw);
221
- if (!parsed || typeof parsed !== 'object') return null;
222
- return parsed;
223
- } catch (_) {
224
- return null;
225
- }
226
- }
227
-
228
- function _atomicWriteJson(targetPath, obj) {
229
- const dir = path.dirname(targetPath);
230
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
231
- const tmp = targetPath + '.tmp-' + process.pid + '-' + Date.now();
232
- const json = JSON.stringify(obj, null, 2) + '\n';
233
- fs.writeFileSync(tmp, json);
234
- fs.renameSync(tmp, targetPath);
235
- }
236
-
237
- function _normalizeTokens(t) {
238
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : Number(v) || 0);
239
- return {
240
- in: num(t.in != null ? t.in : t.input_tokens),
241
- out: num(t.out != null ? t.out : t.output_tokens),
242
- cr: num(t.cr != null ? t.cr : t.cache_read_input_tokens),
243
- cc: num(t.cc != null ? t.cc : t.cache_creation_input_tokens),
244
- cost_usd: num(
245
- t.cost_usd != null
246
- ? t.cost_usd
247
- : (t.total_cost_usd != null ? t.total_cost_usd : 0)
248
- ),
249
- };
250
- }
251
-
252
- function _parseDatetime(s) {
253
- if (!s) return null;
254
- if (typeof s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)) {
255
- const [d, t] = s.split(' ');
256
- const ms = Date.parse(`${d}T${t}:00`);
257
- return Number.isFinite(ms) ? ms : null;
258
- }
259
- const ms = Date.parse(s);
260
- return Number.isFinite(ms) ? ms : null;
261
- }
262
-
263
- /**
264
- * Parse a tokens cell of the form `in=N out=N cr=N cc=N $X.XX` or `—`
265
- * or a mixed form. Returns null when no numeric data is present.
266
- */
267
- function _parseTokensCell(cell) {
268
- if (!cell || cell === '—' || cell === '-') return null;
269
- const num = (re) => {
270
- const m = cell.match(re);
271
- return m ? Number(m[1]) : 0;
272
- };
273
- const inp = num(/\bin=(\d+)/);
274
- const out = num(/\bout=(\d+)/);
275
- const cr = num(/\bcr=(\d+)/);
276
- const cc = num(/\bcc=(\d+)/);
277
- const costM = cell.match(/\$(\d+(?:\.\d+)?)/);
278
- const cost_usd = costM ? Number(costM[1]) : 0;
279
- if (!inp && !out && !cr && !cc && !cost_usd) return null;
280
- return { in: inp, out, cr, cc, cost_usd };
281
- }
282
-
283
- module.exports = {
284
- markTaskDone,
285
- markSpawnEnded,
286
- listActivePlans,
287
- sumTokensForTask,
288
- // test-only
289
- _parseTokensCell,
290
- _parseDatetime,
291
- _normalizeTokens,
292
- };