@tekyzinc/gsd-t 3.29.11 → 4.0.14
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 +171 -0
- package/bin/gsd-t-parallel.cjs +64 -13
- package/bin/gsd-t.js +48 -376
- package/bin/journey-coverage.cjs +6 -3
- package/bin/parallel-cli.cjs +13 -1
- package/commands/gsd-t-complete-milestone.md +6 -12
- package/commands/gsd-t-debug.md +47 -533
- package/commands/gsd-t-design-decompose.md +24 -497
- package/commands/gsd-t-doc-ripple.md +23 -139
- package/commands/gsd-t-execute.md +41 -958
- package/commands/gsd-t-feature.md +2 -6
- package/commands/gsd-t-gap-analysis.md +3 -6
- package/commands/gsd-t-help.md +2 -4
- package/commands/gsd-t-impact.md +26 -295
- package/commands/gsd-t-init-scan-setup.md +7 -7
- package/commands/gsd-t-integrate.md +44 -369
- package/commands/gsd-t-milestone.md +25 -124
- package/commands/gsd-t-partition.md +28 -539
- package/commands/gsd-t-plan.md +26 -472
- package/commands/gsd-t-prd.md +25 -311
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-resume.md +0 -33
- package/commands/gsd-t-scan.md +45 -652
- package/commands/gsd-t-verify.md +52 -569
- package/commands/gsd-t-wave.md +41 -430
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +3 -1
- package/scripts/hooks/pre-commit-journey-coverage +0 -2
- package/scripts/hooks/pre-commit-playwright-gate +0 -2
- package/templates/CLAUDE-global.md +44 -173
- package/templates/CLAUDE-project.md +118 -104
- package/templates/prompts/design-verify-subagent.md +3 -0
- package/templates/prompts/qa-subagent.md +3 -0
- package/templates/prompts/red-team-subagent.md +5 -2
- package/templates/workflows/_lib.js +176 -0
- package/templates/workflows/gsd-t-debug.workflow.js +86 -0
- package/templates/workflows/gsd-t-execute.workflow.js +206 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
- package/templates/workflows/gsd-t-phase.workflow.js +94 -0
- package/templates/workflows/gsd-t-quick.workflow.js +72 -0
- package/templates/workflows/gsd-t-scan.workflow.js +711 -0
- package/templates/workflows/gsd-t-verify.workflow.js +348 -0
- package/templates/workflows/gsd-t-wave.workflow.js +43 -0
- package/bin/check-headless-sessions.js +0 -140
- package/bin/context-budget-audit.cjs +0 -447
- package/bin/context-meter-config.cjs +0 -101
- package/bin/context-meter-config.test.cjs +0 -101
- package/bin/event-stream.cjs +0 -205
- package/bin/gsd-t-benchmark-orchestrator.js +0 -437
- package/bin/gsd-t-capture-lint.cjs +0 -440
- package/bin/gsd-t-economics.cjs +0 -315
- package/bin/gsd-t-in-session-usage.cjs +0 -213
- package/bin/gsd-t-orchestrator-config.cjs +0 -161
- package/bin/gsd-t-orchestrator-queue.cjs +0 -180
- package/bin/gsd-t-orchestrator-recover.cjs +0 -231
- package/bin/gsd-t-orchestrator-worker.cjs +0 -219
- package/bin/gsd-t-orchestrator.js +0 -535
- package/bin/gsd-t-parallel-probe.cjs +0 -132
- package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
- package/bin/gsd-t-ratelimit-probe.cjs +0 -648
- package/bin/gsd-t-report-tokens.cjs +0 -549
- package/bin/gsd-t-stream-feed-client.cjs +0 -151
- package/bin/gsd-t-token-backfill.cjs +0 -366
- package/bin/gsd-t-token-capture.cjs +0 -321
- package/bin/gsd-t-token-dashboard.cjs +0 -353
- package/bin/gsd-t-token-regenerate-log.cjs +0 -129
- package/bin/gsd-t-tool-attribution.cjs +0 -377
- package/bin/gsd-t-tool-cost.cjs +0 -195
- package/bin/gsd-t-transcript-tee.cjs +0 -246
- package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
- package/bin/gsd-t-unattended-platform.cjs +0 -551
- package/bin/gsd-t-unattended-platform.js +0 -381
- package/bin/gsd-t-unattended-safety.cjs +0 -773
- package/bin/gsd-t-unattended-safety.js +0 -788
- package/bin/gsd-t-unattended.cjs +0 -2109
- package/bin/gsd-t-unattended.js +0 -1367
- package/bin/gsd-t-worker-dispatch.cjs +0 -211
- package/bin/handoff-lock.cjs +0 -249
- package/bin/handoff-lock.js +0 -249
- package/bin/headless-auto-spawn.cjs +0 -711
- package/bin/headless-auto-spawn.js +0 -373
- package/bin/headless-exit-codes.cjs +0 -67
- package/bin/live-activity-report.cjs +0 -615
- package/bin/log-tail.cjs +0 -81
- package/bin/m44-proof-measure.cjs +0 -285
- package/bin/m46-iter-proof.cjs +0 -149
- package/bin/m46-worker-proof.cjs +0 -201
- package/bin/m55-substrate-proof.cjs +0 -134
- package/bin/metrics-rollup.js +0 -200
- package/bin/model-windows.cjs +0 -99
- package/bin/model-windows.test.cjs +0 -75
- package/bin/parallelism-report.cjs +0 -535
- package/bin/runway-estimator.cjs +0 -242
- package/bin/spawn-plan-derive.cjs +0 -163
- package/bin/spawn-plan-status-updater.cjs +0 -292
- package/bin/spawn-plan-writer.cjs +0 -204
- package/bin/supervisor-pid-fingerprint.cjs +0 -126
- package/bin/token-budget.cjs +0 -265
- package/bin/unattended-watch-format.cjs +0 -178
- package/bin/watch-progress.js +0 -155
- package/commands/gsd-t-unattended-stop.md +0 -85
- package/commands/gsd-t-unattended-watch.md +0 -465
- package/commands/gsd-t-unattended.md +0 -476
- package/commands/gsd-t-visualize.md +0 -116
- package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
- package/scripts/gsd-t-context-meter.js +0 -341
- package/scripts/gsd-t-context-meter.test.js +0 -471
- package/scripts/gsd-t-dashboard-server.js +0 -1203
- package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
- package/scripts/gsd-t-transcript.html +0 -1821
- package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
- package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
- package/templates/hooks/post-commit-spawn-plan.sh +0 -85
package/bin/runway-estimator.cjs
DELETED
|
@@ -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
|
-
};
|