@seanmars/tospec 0.14.1 → 0.14.2

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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Skill duration metrics, derived from Claude Code transcripts.
3
+ *
4
+ * There is no tospec-side event to measure. The CLI is stateless by design
5
+ * (status comes from file existence), so it never learns when a workflow
6
+ * starts or ends — and CLI-call telemetry would miss the workflows that
7
+ * barely touch the CLI at all: `grill` only ever runs `tospec list`, and
8
+ * `explore` only an optional `tospec decision new`.
9
+ *
10
+ * Claude Code already records what we need. Every transcript entry in
11
+ * `<config>/projects/<encoded-cwd>/<session>.jsonl` carries an
12
+ * `attributionSkill` field naming the skill that entry belongs to, so skill
13
+ * timing is a read-only analysis of data that already exists — retroactively,
14
+ * with nothing to install. The cost is that the field is recent: sessions
15
+ * predating it yield no runs, which is reported rather than hidden.
16
+ *
17
+ * Two durations are reported per run because they answer different questions
18
+ * and diverge sharply in conversational workflows:
19
+ *
20
+ * - `span` — wall clock, first to last entry. "How long did this take?"
21
+ * - `engaged` — span minus gaps over `idleGapMs`. "How much of that was work?"
22
+ *
23
+ * An interview loop like `grill` idles while the user thinks, so its span runs
24
+ * well above its engaged time; a continuous `apply` pass has the two nearly
25
+ * equal. Reporting only one of them would silently answer the other question.
26
+ *
27
+ * Pure parsing/aggregation is split from the directory-reading wrapper so
28
+ * vitest can exercise it without a transcript store on disk.
29
+ */
30
+ /** Gap above which the user is considered away, excluded from `engaged`. */
31
+ export declare const DEFAULT_IDLE_GAP_MS: number;
32
+ /**
33
+ * Gap above which one skill's entries are treated as a second, separate run
34
+ * rather than one very long one. Without this, resuming `apply` the next
35
+ * morning reads as a single 14-hour run.
36
+ */
37
+ export declare const DEFAULT_SPLIT_GAP_MS: number;
38
+ /** One timestamped transcript line, reduced to what timing needs. */
39
+ export interface TranscriptEntry {
40
+ timeMs: number;
41
+ /** Normalized skill name, or null for entries Claude Code did not attribute. */
42
+ skill: string | null;
43
+ }
44
+ /**
45
+ * Which tool a run's timing was derived from. Claude Code's `attributionSkill`
46
+ * is read directly from recorded data; Codex's is inferred by a heuristic
47
+ * (see codex-metrics.ts) — the two are never combined into one figure because
48
+ * they carry different confidence.
49
+ */
50
+ export type MetricsSourceKind = 'claude-code' | 'codex';
51
+ export interface SkillRun {
52
+ skill: string;
53
+ source: MetricsSourceKind;
54
+ /** Transcript file stem (the Claude Code session id, or the Codex rollout id). */
55
+ session: string;
56
+ startMs: number;
57
+ endMs: number;
58
+ spanMs: number;
59
+ engagedMs: number;
60
+ /** Entries inside the run window, including unattributed ones. */
61
+ entries: number;
62
+ }
63
+ export interface DurationStats {
64
+ medianMs: number;
65
+ maxMs: number;
66
+ totalMs: number;
67
+ }
68
+ export interface SkillAggregate {
69
+ skill: string;
70
+ source: MetricsSourceKind;
71
+ /** True when the skill is one of tospec's own generated workflows. */
72
+ workflow: boolean;
73
+ runs: number;
74
+ entries: number;
75
+ span: DurationStats;
76
+ engaged: DurationStats;
77
+ }
78
+ export interface MetricsThresholds {
79
+ idleGapMs: number;
80
+ splitGapMs: number;
81
+ }
82
+ export interface MetricsSource {
83
+ transcriptDir: string;
84
+ /** Null when the directory does not exist (no Claude Code history here). */
85
+ available: boolean;
86
+ sessions: number;
87
+ attributedSessions: number;
88
+ firstSeen: string | null;
89
+ lastSeen: string | null;
90
+ }
91
+ export interface SkillMetrics {
92
+ source: MetricsSource;
93
+ thresholds: MetricsThresholds;
94
+ skills: SkillAggregate[];
95
+ runs: SkillRun[];
96
+ }
97
+ export interface RunGroupingOptions {
98
+ idleGapMs?: number;
99
+ splitGapMs?: number;
100
+ /** Defaults to `'claude-code'` — every existing caller is that source. */
101
+ source?: MetricsSourceKind;
102
+ }
103
+ /**
104
+ * Claude Code's per-project transcript directory name: the absolute cwd with
105
+ * every non-alphanumeric character replaced by `-`. `D:\workspace\a.b` becomes
106
+ * `D--workspace-a-b` (the drive colon and the separator each contribute a dash,
107
+ * which is why the doubled dash is correct and not a bug).
108
+ */
109
+ export declare function encodeProjectDirName(rootPath: string): string;
110
+ export interface TranscriptDirOptions {
111
+ env?: NodeJS.ProcessEnv;
112
+ homedir?: string;
113
+ }
114
+ /**
115
+ * Locates the transcript directory for a project root. `CLAUDE_CONFIG_DIR` is
116
+ * honoured because Claude Code itself does; without it the default is
117
+ * `~/.claude`. Injectable env/homedir follows global-config.ts so path
118
+ * resolution stays testable.
119
+ */
120
+ export declare function resolveTranscriptDir(rootPath: string, options?: TranscriptDirOptions): string;
121
+ /**
122
+ * Collapses the two names one workflow is recorded under — the slash command
123
+ * (`tosx:apply`) and the skill (`tospec-apply`) — onto the workflow id. Without
124
+ * this every workflow's history is split across two unrelated-looking rows.
125
+ */
126
+ export declare function normalizeSkillName(raw: string): string;
127
+ export declare function isWorkflowSkill(skill: string): boolean;
128
+ /**
129
+ * Parses one `.jsonl` transcript into timestamped entries, sorted by time.
130
+ * Malformed lines are skipped rather than fatal: transcripts are an append-only
131
+ * log written by another process and can be torn at the tail while a session
132
+ * is live.
133
+ */
134
+ export declare function parseTranscript(text: string): TranscriptEntry[];
135
+ /**
136
+ * Groups one session's entries into runs.
137
+ *
138
+ * Two rules carry the whole design:
139
+ *
140
+ * - An **unattributed** entry does not end a run. Tool results and the user's
141
+ * own messages carry no `attributionSkill`, so treating them as boundaries
142
+ * shatters a single 30-minute `apply` into a hundred two-entry fragments.
143
+ * They are folded into the open run instead, because they are its work.
144
+ * - A **different skill** does end a run. Grouping all of one skill's entries
145
+ * by min/max instead would make an A → B → A sequence count B's elapsed time
146
+ * inside A's engaged total. Splitting keeps run windows disjoint, at the cost
147
+ * of reporting a re-entered skill as several runs.
148
+ *
149
+ * A run is additionally split at any internal gap over `splitGapMs`.
150
+ */
151
+ export declare function buildRuns(entries: TranscriptEntry[], session: string, options?: RunGroupingOptions): SkillRun[];
152
+ /**
153
+ * The composite key identifying one report row: a skill under one source.
154
+ * Shared by aggregation, the daily/weekly/monthly series, and the frontend
155
+ * (which reproduces this exact format in JS, since it cannot import a TS
156
+ * module) — one definition of "row" everywhere a skill and its source must
157
+ * be kept apart rather than merged.
158
+ */
159
+ export declare function skillRowKey(skill: string, source: MetricsSourceKind): string;
160
+ /**
161
+ * Aggregates runs per skill-and-source pair, ordered by total engaged time
162
+ * (descending). Grouping by source too, not by skill alone, is what keeps a
163
+ * skill run under both tools from being summed into one figure — Claude
164
+ * Code's attribution and Codex's heuristic one carry different confidence and
165
+ * must stay distinguishable everywhere a skill is reported.
166
+ */
167
+ export declare function aggregateRuns(runs: SkillRun[]): SkillAggregate[];
168
+ export interface CollectMetricsOptions extends RunGroupingOptions, TranscriptDirOptions {
169
+ /** Drop runs that ended before this instant. */
170
+ sinceMs?: number;
171
+ }
172
+ /**
173
+ * Reads every transcript for a project root and derives skill timings.
174
+ *
175
+ * Every skill found is reported, tospec's own workflows and unrelated ones
176
+ * alike — there is deliberately no filtering by skill name. `workflow` on each
177
+ * aggregate labels which is which, so a caller that cares can tell them apart
178
+ * without this function deciding for it.
179
+ *
180
+ * A missing transcript directory is a reportable state (`available: false`),
181
+ * not an error: the project may simply never have been opened in Claude Code,
182
+ * and the caller renders that far better than a stack trace.
183
+ */
184
+ export declare function collectSkillMetrics(rootPath: string, options?: CollectMetricsOptions): Promise<SkillMetrics>;
185
+ //# sourceMappingURL=skill-metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill-metrics.d.ts","sourceRoot":"","sources":["../../src/core/skill-metrics.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Skill duration metrics, derived from Claude Code transcripts.
3
+ *
4
+ * There is no tospec-side event to measure. The CLI is stateless by design
5
+ * (status comes from file existence), so it never learns when a workflow
6
+ * starts or ends — and CLI-call telemetry would miss the workflows that
7
+ * barely touch the CLI at all: `grill` only ever runs `tospec list`, and
8
+ * `explore` only an optional `tospec decision new`.
9
+ *
10
+ * Claude Code already records what we need. Every transcript entry in
11
+ * `<config>/projects/<encoded-cwd>/<session>.jsonl` carries an
12
+ * `attributionSkill` field naming the skill that entry belongs to, so skill
13
+ * timing is a read-only analysis of data that already exists — retroactively,
14
+ * with nothing to install. The cost is that the field is recent: sessions
15
+ * predating it yield no runs, which is reported rather than hidden.
16
+ *
17
+ * Two durations are reported per run because they answer different questions
18
+ * and diverge sharply in conversational workflows:
19
+ *
20
+ * - `span` — wall clock, first to last entry. "How long did this take?"
21
+ * - `engaged` — span minus gaps over `idleGapMs`. "How much of that was work?"
22
+ *
23
+ * An interview loop like `grill` idles while the user thinks, so its span runs
24
+ * well above its engaged time; a continuous `apply` pass has the two nearly
25
+ * equal. Reporting only one of them would silently answer the other question.
26
+ *
27
+ * Pure parsing/aggregation is split from the directory-reading wrapper so
28
+ * vitest can exercise it without a transcript store on disk.
29
+ */
30
+ import { promises as fs } from 'node:fs';
31
+ import * as os from 'node:os';
32
+ import * as path from 'node:path';
33
+ import { ALL_WORKFLOWS } from './shared/skill-generation.js';
34
+ /** Gap above which the user is considered away, excluded from `engaged`. */
35
+ export const DEFAULT_IDLE_GAP_MS = 5 * 60 * 1000;
36
+ /**
37
+ * Gap above which one skill's entries are treated as a second, separate run
38
+ * rather than one very long one. Without this, resuming `apply` the next
39
+ * morning reads as a single 14-hour run.
40
+ */
41
+ export const DEFAULT_SPLIT_GAP_MS = 30 * 60 * 1000;
42
+ const CLAUDE_CONFIG_DIR_NAME = '.claude';
43
+ // -----------------------------------------------------------------------------
44
+ // Pure: paths
45
+ // -----------------------------------------------------------------------------
46
+ /**
47
+ * Claude Code's per-project transcript directory name: the absolute cwd with
48
+ * every non-alphanumeric character replaced by `-`. `D:\workspace\a.b` becomes
49
+ * `D--workspace-a-b` (the drive colon and the separator each contribute a dash,
50
+ * which is why the doubled dash is correct and not a bug).
51
+ */
52
+ export function encodeProjectDirName(rootPath) {
53
+ return path.resolve(rootPath).replace(/[^a-zA-Z0-9]/g, '-');
54
+ }
55
+ /**
56
+ * Locates the transcript directory for a project root. `CLAUDE_CONFIG_DIR` is
57
+ * honoured because Claude Code itself does; without it the default is
58
+ * `~/.claude`. Injectable env/homedir follows global-config.ts so path
59
+ * resolution stays testable.
60
+ */
61
+ export function resolveTranscriptDir(rootPath, options = {}) {
62
+ const env = options.env ?? process.env;
63
+ const configDir = env.CLAUDE_CONFIG_DIR
64
+ ? path.resolve(env.CLAUDE_CONFIG_DIR)
65
+ : path.join(options.homedir ?? os.homedir(), CLAUDE_CONFIG_DIR_NAME);
66
+ return path.join(configDir, 'projects', encodeProjectDirName(rootPath));
67
+ }
68
+ // -----------------------------------------------------------------------------
69
+ // Pure: parsing
70
+ // -----------------------------------------------------------------------------
71
+ const WORKFLOW_IDS = new Set(ALL_WORKFLOWS);
72
+ /**
73
+ * Collapses the two names one workflow is recorded under — the slash command
74
+ * (`tosx:apply`) and the skill (`tospec-apply`) — onto the workflow id. Without
75
+ * this every workflow's history is split across two unrelated-looking rows.
76
+ */
77
+ export function normalizeSkillName(raw) {
78
+ return raw.replace(/^tosx:/, '').replace(/^tospec-/, '');
79
+ }
80
+ export function isWorkflowSkill(skill) {
81
+ return WORKFLOW_IDS.has(skill);
82
+ }
83
+ /**
84
+ * Parses one `.jsonl` transcript into timestamped entries, sorted by time.
85
+ * Malformed lines are skipped rather than fatal: transcripts are an append-only
86
+ * log written by another process and can be torn at the tail while a session
87
+ * is live.
88
+ */
89
+ export function parseTranscript(text) {
90
+ const entries = [];
91
+ for (const line of text.split(/\r?\n/)) {
92
+ if (!line.trim())
93
+ continue;
94
+ let record;
95
+ try {
96
+ record = JSON.parse(line);
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ if (typeof record.timestamp !== 'string')
102
+ continue;
103
+ const timeMs = Date.parse(record.timestamp);
104
+ if (Number.isNaN(timeMs))
105
+ continue;
106
+ entries.push({
107
+ timeMs,
108
+ skill: typeof record.attributionSkill === 'string' && record.attributionSkill
109
+ ? normalizeSkillName(record.attributionSkill)
110
+ : null,
111
+ });
112
+ }
113
+ return entries.sort((a, b) => a.timeMs - b.timeMs);
114
+ }
115
+ // -----------------------------------------------------------------------------
116
+ // Pure: run grouping
117
+ // -----------------------------------------------------------------------------
118
+ /**
119
+ * Groups one session's entries into runs.
120
+ *
121
+ * Two rules carry the whole design:
122
+ *
123
+ * - An **unattributed** entry does not end a run. Tool results and the user's
124
+ * own messages carry no `attributionSkill`, so treating them as boundaries
125
+ * shatters a single 30-minute `apply` into a hundred two-entry fragments.
126
+ * They are folded into the open run instead, because they are its work.
127
+ * - A **different skill** does end a run. Grouping all of one skill's entries
128
+ * by min/max instead would make an A → B → A sequence count B's elapsed time
129
+ * inside A's engaged total. Splitting keeps run windows disjoint, at the cost
130
+ * of reporting a re-entered skill as several runs.
131
+ *
132
+ * A run is additionally split at any internal gap over `splitGapMs`.
133
+ */
134
+ export function buildRuns(entries, session, options = {}) {
135
+ const idleGapMs = options.idleGapMs ?? DEFAULT_IDLE_GAP_MS;
136
+ const splitGapMs = options.splitGapMs ?? DEFAULT_SPLIT_GAP_MS;
137
+ const source = options.source ?? 'claude-code';
138
+ const runs = [];
139
+ let skill = null;
140
+ let window = [];
141
+ const flush = () => {
142
+ if (skill === null || window.length === 0) {
143
+ window = [];
144
+ return;
145
+ }
146
+ let engagedMs = 0;
147
+ for (let i = 1; i < window.length; i++) {
148
+ const gap = window[i] - window[i - 1];
149
+ if (gap < idleGapMs)
150
+ engagedMs += gap;
151
+ }
152
+ const startMs = window[0];
153
+ const endMs = window[window.length - 1];
154
+ runs.push({
155
+ skill,
156
+ source,
157
+ session,
158
+ startMs,
159
+ endMs,
160
+ spanMs: endMs - startMs,
161
+ engagedMs,
162
+ entries: window.length,
163
+ });
164
+ window = [];
165
+ };
166
+ for (const entry of entries) {
167
+ if (entry.skill !== null && entry.skill !== skill) {
168
+ flush();
169
+ skill = entry.skill;
170
+ }
171
+ else if (window.length > 0 &&
172
+ entry.timeMs - window[window.length - 1] > splitGapMs) {
173
+ // Long silence: the same skill resumed later is a second run, not one
174
+ // run spanning the intervening hours.
175
+ flush();
176
+ }
177
+ // Entries before any attributed one belong to no skill; drop them.
178
+ if (skill === null)
179
+ continue;
180
+ window.push(entry.timeMs);
181
+ }
182
+ flush();
183
+ return runs;
184
+ }
185
+ // -----------------------------------------------------------------------------
186
+ // Pure: aggregation
187
+ // -----------------------------------------------------------------------------
188
+ /** Lower median: for an even count, the lower of the two middle values. */
189
+ function median(values) {
190
+ if (values.length === 0)
191
+ return 0;
192
+ const sorted = [...values].sort((a, b) => a - b);
193
+ return sorted[Math.floor((sorted.length - 1) / 2)];
194
+ }
195
+ /**
196
+ * The composite key identifying one report row: a skill under one source.
197
+ * Shared by aggregation, the daily/weekly/monthly series, and the frontend
198
+ * (which reproduces this exact format in JS, since it cannot import a TS
199
+ * module) — one definition of "row" everywhere a skill and its source must
200
+ * be kept apart rather than merged.
201
+ */
202
+ export function skillRowKey(skill, source) {
203
+ return `${skill}::${source}`;
204
+ }
205
+ function describe(values) {
206
+ return {
207
+ medianMs: median(values),
208
+ maxMs: values.length === 0 ? 0 : Math.max(...values),
209
+ totalMs: values.reduce((sum, v) => sum + v, 0),
210
+ };
211
+ }
212
+ /**
213
+ * Aggregates runs per skill-and-source pair, ordered by total engaged time
214
+ * (descending). Grouping by source too, not by skill alone, is what keeps a
215
+ * skill run under both tools from being summed into one figure — Claude
216
+ * Code's attribution and Codex's heuristic one carry different confidence and
217
+ * must stay distinguishable everywhere a skill is reported.
218
+ */
219
+ export function aggregateRuns(runs) {
220
+ const bySkillSource = new Map();
221
+ for (const run of runs) {
222
+ const key = skillRowKey(run.skill, run.source);
223
+ const bucket = bySkillSource.get(key);
224
+ if (bucket)
225
+ bucket.push(run);
226
+ else
227
+ bySkillSource.set(key, [run]);
228
+ }
229
+ const aggregates = [];
230
+ for (const skillRuns of bySkillSource.values()) {
231
+ const skill = skillRuns[0].skill;
232
+ aggregates.push({
233
+ skill,
234
+ source: skillRuns[0].source,
235
+ workflow: isWorkflowSkill(skill),
236
+ runs: skillRuns.length,
237
+ entries: skillRuns.reduce((sum, r) => sum + r.entries, 0),
238
+ span: describe(skillRuns.map((r) => r.spanMs)),
239
+ engaged: describe(skillRuns.map((r) => r.engagedMs)),
240
+ });
241
+ }
242
+ return aggregates.sort((a, b) => b.engaged.totalMs - a.engaged.totalMs);
243
+ }
244
+ /**
245
+ * Reads every transcript for a project root and derives skill timings.
246
+ *
247
+ * Every skill found is reported, tospec's own workflows and unrelated ones
248
+ * alike — there is deliberately no filtering by skill name. `workflow` on each
249
+ * aggregate labels which is which, so a caller that cares can tell them apart
250
+ * without this function deciding for it.
251
+ *
252
+ * A missing transcript directory is a reportable state (`available: false`),
253
+ * not an error: the project may simply never have been opened in Claude Code,
254
+ * and the caller renders that far better than a stack trace.
255
+ */
256
+ export async function collectSkillMetrics(rootPath, options = {}) {
257
+ const transcriptDir = resolveTranscriptDir(rootPath, options);
258
+ const thresholds = {
259
+ idleGapMs: options.idleGapMs ?? DEFAULT_IDLE_GAP_MS,
260
+ splitGapMs: options.splitGapMs ?? DEFAULT_SPLIT_GAP_MS,
261
+ };
262
+ const empty = {
263
+ source: {
264
+ transcriptDir,
265
+ available: false,
266
+ sessions: 0,
267
+ attributedSessions: 0,
268
+ firstSeen: null,
269
+ lastSeen: null,
270
+ },
271
+ thresholds,
272
+ skills: [],
273
+ runs: [],
274
+ };
275
+ let fileNames;
276
+ try {
277
+ fileNames = (await fs.readdir(transcriptDir)).filter((f) => f.endsWith('.jsonl'));
278
+ }
279
+ catch (error) {
280
+ if (error.code === 'ENOENT')
281
+ return empty;
282
+ throw error;
283
+ }
284
+ const allRuns = [];
285
+ let attributedSessions = 0;
286
+ for (const fileName of fileNames) {
287
+ let text;
288
+ try {
289
+ text = await fs.readFile(path.join(transcriptDir, fileName), 'utf8');
290
+ }
291
+ catch {
292
+ // A session file can vanish between readdir and read; skip it.
293
+ continue;
294
+ }
295
+ const runs = buildRuns(parseTranscript(text), path.basename(fileName, '.jsonl'), thresholds);
296
+ if (runs.length > 0)
297
+ attributedSessions++;
298
+ for (const run of runs) {
299
+ if (options.sinceMs !== undefined && run.endMs < options.sinceMs)
300
+ continue;
301
+ allRuns.push(run);
302
+ }
303
+ }
304
+ allRuns.sort((a, b) => a.startMs - b.startMs);
305
+ return {
306
+ source: {
307
+ transcriptDir,
308
+ available: true,
309
+ sessions: fileNames.length,
310
+ attributedSessions,
311
+ firstSeen: allRuns.length > 0 ? new Date(allRuns[0].startMs).toISOString() : null,
312
+ lastSeen: allRuns.length > 0
313
+ ? new Date(Math.max(...allRuns.map((r) => r.endMs))).toISOString()
314
+ : null,
315
+ },
316
+ thresholds,
317
+ skills: aggregateRuns(allRuns),
318
+ runs: allRuns,
319
+ };
320
+ }
321
+ //# sourceMappingURL=skill-metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill-metrics.js","sourceRoot":"","sources":["../../src/core/skill-metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAC9B,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAE7D,4EAA4E;AAC5E,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAEjD;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAEnD,MAAM,sBAAsB,GAAG,SAAS,CAAC;AAgFzC,gFAAgF;AAChF,cAAc;AACd,gFAAgF;AAEhF;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,QAAgB;IACnD,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC,CAAC;AAC9D,CAAC;AAOD;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAClC,QAAgB,EAChB,UAAgC,EAAE;IAElC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC;IACvC,MAAM,SAAS,GAAG,GAAG,CAAC,iBAAiB;QACrC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;QACrC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC,OAAO,EAAE,EAAE,sBAAsB,CAAC,CAAC;IACvE,OAAO,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,UAAU,EAAE,oBAAoB,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC1E,CAAC;AAED,gFAAgF;AAChF,gBAAgB;AAChB,gFAAgF;AAEhF,MAAM,YAAY,GAAG,IAAI,GAAG,CAAS,aAAa,CAAC,CAAC;AAEpD;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,OAAO,GAAG,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;AAC3D,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,OAAO,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;AACjC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,MAAM,OAAO,GAAsB,EAAE,CAAC;IACtC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QACvC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;YAAE,SAAS;QAC3B,IAAI,MAA2D,CAAC;QAChE,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC5B,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,IAAI,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ;YAAE,SAAS;QACnD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAC5C,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;YAAE,SAAS;QACnC,OAAO,CAAC,IAAI,CAAC;YACX,MAAM;YACN,KAAK,EACH,OAAO,MAAM,CAAC,gBAAgB,KAAK,QAAQ,IAAI,MAAM,CAAC,gBAAgB;gBACpE,CAAC,CAAC,kBAAkB,CAAC,MAAM,CAAC,gBAAgB,CAAC;gBAC7C,CAAC,CAAC,IAAI;SACX,CAAC,CAAC;IACL,CAAC;IACD,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC;AACrD,CAAC;AAED,gFAAgF;AAChF,qBAAqB;AACrB,gFAAgF;AAEhF;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,SAAS,CACvB,OAA0B,EAC1B,OAAe,EACf,UAA8B,EAAE;IAEhC,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,mBAAmB,CAAC;IAC3D,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,oBAAoB,CAAC;IAC9D,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,aAAa,CAAC;IAE/C,MAAM,IAAI,GAAe,EAAE,CAAC;IAC5B,IAAI,KAAK,GAAkB,IAAI,CAAC;IAChC,IAAI,MAAM,GAAa,EAAE,CAAC;IAE1B,MAAM,KAAK,GAAG,GAAS,EAAE;QACvB,IAAI,KAAK,KAAK,IAAI,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC1C,MAAM,GAAG,EAAE,CAAC;YACZ,OAAO;QACT,CAAC;QACD,IAAI,SAAS,GAAG,CAAC,CAAC;QAClB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YACtC,IAAI,GAAG,GAAG,SAAS;gBAAE,SAAS,IAAI,GAAG,CAAC;QACxC,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QAC1B,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACxC,IAAI,CAAC,IAAI,CAAC;YACR,KAAK;YACL,MAAM;YACN,OAAO;YACP,OAAO;YACP,KAAK;YACL,MAAM,EAAE,KAAK,GAAG,OAAO;YACvB,SAAS;YACT,OAAO,EAAE,MAAM,CAAC,MAAM;SACvB,CAAC,CAAC;QACH,MAAM,GAAG,EAAE,CAAC;IACd,CAAC,CAAC;IAEF,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,KAAK,CAAC,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;YAClD,KAAK,EAAE,CAAC;YACR,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;QACtB,CAAC;aAAM,IACL,MAAM,CAAC,MAAM,GAAG,CAAC;YACjB,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,UAAU,EACrD,CAAC;YACD,sEAAsE;YACtE,sCAAsC;YACtC,KAAK,EAAE,CAAC;QACV,CAAC;QACD,mEAAmE;QACnE,IAAI,KAAK,KAAK,IAAI;YAAE,SAAS;QAC7B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAC5B,CAAC;IACD,KAAK,EAAE,CAAC;IAER,OAAO,IAAI,CAAC;AACd,CAAC;AAED,gFAAgF;AAChF,oBAAoB;AACpB,gFAAgF;AAEhF,2EAA2E;AAC3E,SAAS,MAAM,CAAC,MAAgB;IAC9B,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACjD,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AACrD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa,EAAE,MAAyB;IAClE,OAAO,GAAG,KAAK,KAAK,MAAM,EAAE,CAAC;AAC/B,CAAC;AAED,SAAS,QAAQ,CAAC,MAAgB;IAChC,OAAO;QACL,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC;QACxB,KAAK,EAAE,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;QACpD,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,EAAE,CAAC,CAAC;KAC/C,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,IAAgB;IAC5C,MAAM,aAAa,GAAG,IAAI,GAAG,EAAsB,CAAC;IACpD,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,GAAG,GAAG,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;QAC/C,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACtC,IAAI,MAAM;YAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;;YACxB,aAAa,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACrC,CAAC;IAED,MAAM,UAAU,GAAqB,EAAE,CAAC;IACxC,KAAK,MAAM,SAAS,IAAI,aAAa,CAAC,MAAM,EAAE,EAAE,CAAC;QAC/C,MAAM,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;QACjC,UAAU,CAAC,IAAI,CAAC;YACd,KAAK;YACL,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM;YAC3B,QAAQ,EAAE,eAAe,CAAC,KAAK,CAAC;YAChC,IAAI,EAAE,SAAS,CAAC,MAAM;YACtB,OAAO,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;YACzD,IAAI,EAAE,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YAC9C,OAAO,EAAE,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;SACrD,CAAC,CAAC;IACL,CAAC;IAED,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;AAC1E,CAAC;AAWD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,QAAgB,EAChB,UAAiC,EAAE;IAEnC,MAAM,aAAa,GAAG,oBAAoB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAC9D,MAAM,UAAU,GAAsB;QACpC,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,mBAAmB;QACnD,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,oBAAoB;KACvD,CAAC;IACF,MAAM,KAAK,GAAiB;QAC1B,MAAM,EAAE;YACN,aAAa;YACb,SAAS,EAAE,KAAK;YAChB,QAAQ,EAAE,CAAC;YACX,kBAAkB,EAAE,CAAC;YACrB,SAAS,EAAE,IAAI;YACf,QAAQ,EAAE,IAAI;SACf;QACD,UAAU;QACV,MAAM,EAAE,EAAE;QACV,IAAI,EAAE,EAAE;KACT,CAAC;IAEF,IAAI,SAAmB,CAAC;IACxB,IAAI,CAAC;QACH,SAAS,GAAG,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;IACpF,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC;QACrE,MAAM,KAAK,CAAC;IACd,CAAC;IAED,MAAM,OAAO,GAAe,EAAE,CAAC;IAC/B,IAAI,kBAAkB,GAAG,CAAC,CAAC;IAE3B,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,IAAI,IAAY,CAAC;QACjB,IAAI,CAAC;YACH,IAAI,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,CAAC;QACvE,CAAC;QAAC,MAAM,CAAC;YACP,+DAA+D;YAC/D,SAAS;QACX,CAAC;QACD,MAAM,IAAI,GAAG,SAAS,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,EAAE,UAAU,CAAC,CAAC;QAC7F,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;YAAE,kBAAkB,EAAE,CAAC;QAC1C,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS,IAAI,GAAG,CAAC,KAAK,GAAG,OAAO,CAAC,OAAO;gBAAE,SAAS;YAC3E,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACpB,CAAC;IACH,CAAC;IAED,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC;IAE9C,OAAO;QACL,MAAM,EAAE;YACN,aAAa;YACb,SAAS,EAAE,IAAI;YACf,QAAQ,EAAE,SAAS,CAAC,MAAM;YAC1B,kBAAkB;YAClB,SAAS,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,IAAI;YACjF,QAAQ,EACN,OAAO,CAAC,MAAM,GAAG,CAAC;gBAChB,CAAC,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE;gBAClE,CAAC,CAAC,IAAI;SACX;QACD,UAAU;QACV,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC;QAC9B,IAAI,EAAE,OAAO;KACd,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmars/tospec",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Spec-driven development CLI for structured requirements and issue workflows",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.npmjs.com/package/@seanmars/tospec",