@hegemonart/get-design-done 1.59.7 → 1.59.9

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +59 -0
  4. package/README.md +2 -2
  5. package/SKILL.md +1 -1
  6. package/agents/design-authority-watcher.md +24 -5
  7. package/bin/gdd-graph +4 -1
  8. package/hooks/_hook-emit.js +113 -29
  9. package/hooks/budget-enforcer.ts +104 -5
  10. package/hooks/gdd-mcp-circuit-breaker.js +72 -3
  11. package/hooks/gdd-sessionstart-recap.js +23 -14
  12. package/hooks/hooks.json +2 -2
  13. package/package.json +2 -2
  14. package/reference/bandit-integration.md +13 -2
  15. package/reference/prices/claude.md +11 -0
  16. package/reference/runtime-models.md +9 -9
  17. package/reference/schemas/generated.d.ts +4 -0
  18. package/reference/schemas/runtime-models.schema.json +5 -0
  19. package/scripts/bootstrap.cjs +40 -8
  20. package/scripts/install.cjs +23 -1
  21. package/scripts/lib/bandit-router.cjs +47 -5
  22. package/scripts/lib/budget-enforcer.cjs +34 -5
  23. package/scripts/lib/detect/cli.cjs +13 -3
  24. package/scripts/lib/install/converters/cursor.cjs +11 -19
  25. package/scripts/lib/install/installer.cjs +72 -21
  26. package/scripts/lib/install/merge.cjs +31 -3
  27. package/scripts/lib/install/parse-runtime-models.cjs +9 -1
  28. package/scripts/lib/install/runtime-artifact-layout.cjs +42 -8
  29. package/scripts/lib/manifest/harnesses.json +29 -1
  30. package/scripts/lib/manifest/skills.json +1 -1
  31. package/scripts/lib/model-id.cjs +141 -0
  32. package/scripts/lib/session-runner/index.ts +87 -16
  33. package/scripts/skill-templates/bandit-reset/SKILL.md +2 -0
  34. package/scripts/skill-templates/bandit-status/SKILL.md +4 -1
  35. package/scripts/skill-templates/darkmode/SKILL.md +1 -1
  36. package/scripts/skill-templates/graphify/SKILL.md +6 -6
  37. package/scripts/skill-templates/quick/SKILL.md +3 -1
  38. package/scripts/skill-templates/reflect/SKILL.md +1 -1
  39. package/scripts/skill-templates/router/SKILL.md +4 -2
  40. package/sdk/cli/index.js +132 -55
  41. package/sdk/dashboard/data/source.cjs +50 -4
  42. package/sdk/event-stream/writer.ts +112 -30
  43. package/sdk/mcp/gdd-mcp/server.js +49 -36
  44. package/sdk/mcp/gdd-mcp/tools/shared.ts +20 -2
  45. package/sdk/mcp/gdd-state/server.js +107 -41
  46. package/sdk/primitives/lockfile.cjs +26 -5
  47. package/sdk/state/index.ts +91 -17
  48. package/sdk/state/lockfile.ts +47 -8
  49. package/skills/bandit-reset/SKILL.md +2 -0
  50. package/skills/bandit-status/SKILL.md +4 -1
  51. package/skills/darkmode/SKILL.md +1 -1
  52. package/skills/graphify/SKILL.md +6 -6
  53. package/skills/quick/SKILL.md +3 -1
  54. package/skills/reflect/SKILL.md +1 -1
  55. package/skills/router/SKILL.md +4 -2
@@ -45,13 +45,23 @@ function isUrl(p) {
45
45
  return /^https?:\/\//i.test(String(p || ''));
46
46
  }
47
47
 
48
- /** Select the detection engine. Returns { mode, warning }. Regex-fast is the dep-free default. */
48
+ /**
49
+ * Select the detection engine. Returns { mode, warning }.
50
+ *
51
+ * There is exactly one engine path: regex over file text (see engine.cjs#run, which takes no
52
+ * jsdom/DOM parameter and is byte-identical whether or not jsdom is installed). So the truthful
53
+ * mode is always 'regex-fast'. We still probe jsdom (unless --fast) to surface a one-line hint
54
+ * that a DOM-aware path is not wired in this build — but we no longer claim a 'dom-aware' mode the
55
+ * engine does not have.
56
+ */
49
57
  function selectEngine(opts, requireFn) {
50
58
  if (opts.fast) return { mode: 'regex-fast', warning: null };
51
59
  let hasJsdom = false;
52
60
  try { requireFn('jsdom'); hasJsdom = true; } catch { hasJsdom = false; }
53
- if (hasJsdom) return { mode: 'dom-aware', warning: null };
54
- return { mode: 'regex-fast', warning: 'jsdom not installed — using regex-fast (install jsdom for DOM-aware mode, or pass --fast to silence this).' };
61
+ // jsdom presence does not change the engine — only emit a hint when it's absent, and never
62
+ // promise a mode we can't deliver.
63
+ const warning = hasJsdom ? null : 'jsdom not installed — running regex-fast (the only wired mode; a DOM-aware path is not implemented). Pass --fast to silence this.';
64
+ return { mode: 'regex-fast', warning };
55
65
  }
56
66
 
57
67
  function renderHuman(result, mode) {
@@ -25,25 +25,17 @@
25
25
  * Pure / side-effect-free: no fs, no env, no path. `convert` is a
26
26
  * deterministic string → string transform.
27
27
  *
28
- * KNOWN LIMITATIONsibling .md files (audit batch H6, 2026-06-04):
29
- * The Cursor install drops ONLY `skills/<name>/SKILL.md`. Sibling
30
- * `.md` files living next to SKILL.md (e.g. `discover-procedure.md`,
31
- * `explore-procedure.md`, `cache-policy.md`) are NOT enumerated by
32
- * `runtime-artifact-layout.cjs#skillsKind` and therefore NOT installed.
33
- * Source SKILL.md files reference siblings via relative paths
34
- * (e.g. `./discover-procedure.md`); on Cursor those links resolve to
35
- * nothing.
36
- *
37
- * This is a systemic limitation of the current `multi-artifact`
38
- * pipeline, not a Cursor-specific bug it affects every runtime that
39
- * uses `skillsKind` (claude global, cursor, codex, copilot, antigravity,
40
- * windsurf, augment, trae, qwen, codebuddy). Fix requires extending the
41
- * StagedArtifact contract to emit multiple files per skill (one
42
- * SKILL.md + N siblings), updating `computeDestPath`, the foreign-file
43
- * detection in `detectMultiArtifactInstalled`, and the uninstall
44
- * enumeration in `uninstallMultiArtifact`. Tracked as a follow-up
45
- * beyond batch H6 scope. See `connections/cursor.md` for user-facing
46
- * guidance.
28
+ * SIBLING .md FILES RESOLVED (audit AR6, Phase 59.8):
29
+ * The install now carries co-located sibling `*.md` reference files
30
+ * (e.g. `discover-procedure.md`, `cache-policy.md`) alongside SKILL.md
31
+ * for EVERY skillsKind runtime, not just Cursor. The carry happens in
32
+ * `installer.cjs#installMultiArtifact` (gated on `kind.kind === 'skills'`
33
+ * plus `item.srcPath`), with symmetric removal in `uninstallMultiArtifact`.
34
+ * Siblings are passthrough copies fingerprinted via `fingerprintSiblingRef`
35
+ * so foreign-file protection + uninstall treat them as plugin-owned. Only
36
+ * top-level `*.md` siblings are carried; nested subdirectories are out of
37
+ * scope. Previously this was a Batch-H6 cursor-only patch (the audit AR6
38
+ * finding); it is now generalized across all skillsKind runtimes.
47
39
  */
48
40
 
49
41
  const shared = require('./shared.cjs');
@@ -163,12 +163,17 @@ function installClaudeMarketplace(runtime, configDir, dryRun) {
163
163
  dryRun,
164
164
  };
165
165
  }
166
+ // B1 fix (Phase 59.8): decide created-vs-updated BEFORE the write. The
167
+ // settings.json file is written by atomicWrite below, so testing
168
+ // `existsSync(settingsPath)` afterwards always returned 'updated' (the file
169
+ // we just wrote exists). Capture the pre-write existence instead.
170
+ const existedBefore = fs.existsSync(settingsPath);
166
171
  const formatted = `${JSON.stringify(next, null, 2)}\n`;
167
172
  if (!dryRun) atomicWrite(settingsPath, formatted);
168
173
  return {
169
174
  runtime: runtime.id,
170
175
  path: settingsPath,
171
- action: fs.existsSync(settingsPath) ? 'updated' : 'created',
176
+ action: existedBefore ? 'updated' : 'created',
172
177
  dryRun,
173
178
  };
174
179
  }
@@ -439,6 +444,20 @@ function installMultiArtifact(runtime, configDir, dryRun, opts) {
439
444
  continue;
440
445
  }
441
446
  for (const item of staged) {
447
+ // AR7 fix (Phase 59.8): never write a 0-byte / empty artifact. The old
448
+ // agents path staged `content: ''` for every skill name that had no
449
+ // matching agent file, producing empty `gdd-<name>.md` placeholders.
450
+ // Even with the layout-side enumeration fix, guard defensively here so
451
+ // no converter/kind can ever emit an empty file to disk.
452
+ if (!item.content || !String(item.content).trim()) {
453
+ perFile.push({
454
+ kind: kind.kind,
455
+ path: computeDestPath(configDir, kind, item.name),
456
+ action: 'skipped-empty',
457
+ reason: `Refusing to write empty artifact ${item.name}`,
458
+ });
459
+ continue;
460
+ }
442
461
  const destPath = computeDestPath(configDir, kind, item.name);
443
462
  const writeResult = writeFingerprinted(destPath, item.content, dryRun);
444
463
  perFile.push({
@@ -448,15 +467,16 @@ function installMultiArtifact(runtime, configDir, dryRun, opts) {
448
467
  ...(writeResult.reason ? { reason: writeResult.reason } : {}),
449
468
  });
450
469
 
451
- // Batch H6: carry co-located sibling `*.md` reference files alongside
452
- // SKILL.md. The skills layout only stages SKILL.md per skill, so
453
- // reference siblings (e.g. `<name>-procedure.md`) are otherwise lost.
454
- // Scoped to cursor (the audited flat-layout runtime); other runtimes
455
- // keep their prior single-SKILL.md behavior. Siblings are passthrough
456
- // copies fingerprinted so foreign-file protection + uninstall treat
457
- // them as plugin-owned. Broader skillsKind-runtime carry is deferred
458
- // (see converters/cursor.cjs KNOWN LIMITATION).
459
- if (kind.kind === 'skills' && runtime.id === 'cursor' && item.srcPath) {
470
+ // AR6 fix (Phase 59.8): carry co-located sibling `*.md` reference files
471
+ // alongside SKILL.md for EVERY skillsKind runtime (cursor, codex,
472
+ // copilot, antigravity, windsurf, augment, trae, qwen, codebuddy, and
473
+ // claude global). The skills layout only stages SKILL.md per skill, so
474
+ // reference siblings (e.g. `<name>-procedure.md`) were otherwise lost on
475
+ // every runtime except cursor shipping dead relative links. Siblings
476
+ // are passthrough copies fingerprinted so foreign-file protection +
477
+ // uninstall treat them as plugin-owned. Was previously scoped to cursor
478
+ // only (Batch H6); see converters/cursor.cjs former KNOWN LIMITATION.
479
+ if (kind.kind === 'skills' && item.srcPath) {
460
480
  const skillSrcDir = path.dirname(item.srcPath);
461
481
  const skillDestDir = path.dirname(destPath);
462
482
  for (const sibling of listSiblingRefFiles(skillSrcDir)) {
@@ -550,8 +570,27 @@ function uninstallMultiArtifact(runtime, configDir, dryRun, opts) {
550
570
  const skillDirsToTrim = [];
551
571
 
552
572
  for (const kind of layout.kinds) {
553
- for (const bareName of skillNames) {
554
- const itemName = (kind.prefix || '') + bareName;
573
+ // AR7 fix (Phase 59.8): derive the artifact names from the SAME staging
574
+ // pass install uses, so uninstall stays symmetric. The `agents` kind
575
+ // enumerates `agents/*.md` (real agent role names), NOT skill names — the
576
+ // old `gdd-<skillName>.md` derivation never matched any installed agent
577
+ // file and left every real agent on disk after `--uninstall`.
578
+ let stagedNames;
579
+ try {
580
+ const staged = kind.stage({
581
+ skillsRoot,
582
+ skillNames,
583
+ scope,
584
+ runtime: runtime.id,
585
+ configDir,
586
+ });
587
+ stagedNames = staged.map((item) => item.name);
588
+ } catch {
589
+ // Fall back to the prior skill-name derivation if staging fails (e.g.
590
+ // a converter throws); skills/commands kinds match this shape exactly.
591
+ stagedNames = skillNames.map((n) => (kind.prefix || '') + n);
592
+ }
593
+ for (const itemName of stagedNames) {
555
594
  const destPath = computeDestPath(configDir, kind, itemName);
556
595
  if (!fs.existsSync(destPath)) {
557
596
  perFile.push({ kind: kind.kind, path: destPath, action: 'unchanged' });
@@ -586,11 +625,12 @@ function uninstallMultiArtifact(runtime, configDir, dryRun, opts) {
586
625
  const skillDestDir = path.dirname(destPath);
587
626
  skillDirsToTrim.push(skillDestDir);
588
627
 
589
- // Batch H6: symmetric cleanup for the sibling reference files the
590
- // cursor install carries alongside SKILL.md. Remove only the
591
- // plugin-owned siblings so a now-empty dir can be trimmed below;
592
- // user-authored siblings are left in place (foreign-file discipline).
593
- if (runtime.id === 'cursor') {
628
+ // AR6 fix (Phase 59.8): symmetric cleanup for the sibling reference
629
+ // files every skillsKind install carries alongside SKILL.md. Remove
630
+ // only the plugin-owned siblings so a now-empty dir can be trimmed
631
+ // below; user-authored siblings are left in place (foreign-file
632
+ // discipline). Was previously scoped to cursor only (Batch H6).
633
+ if (kind.kind === 'skills') {
594
634
  for (const sibling of listSiblingRefFiles(skillDestDir)) {
595
635
  const siblingPath = path.join(skillDestDir, sibling);
596
636
  let siblingContent;
@@ -682,11 +722,22 @@ function installCline(runtime, configDir, skillsRoot, skillNames, dryRun) {
682
722
  const cline = require('./converters/cline.cjs');
683
723
  ensureDir(configDir, dryRun);
684
724
 
685
- const blocks = skillNames.map((name) => {
725
+ // B2 fix (Phase 59.8): wrap the per-skill read in try/catch. Previously a
726
+ // single unreadable SKILL.md threw out of `installCline`, aborting the
727
+ // entire cline install (and, when cline is one runtime in a multi-runtime
728
+ // batch, every runtime queued after it). Skip the unreadable skill and keep
729
+ // going, mirroring the best-effort sibling reads elsewhere in this file.
730
+ const blocks = [];
731
+ for (const name of skillNames) {
686
732
  const srcPath = path.join(skillsRoot, name, 'SKILL.md');
687
- const raw = fs.readFileSync(srcPath, 'utf8');
688
- return { name, block: cline.convert(raw, name, { runtime: 'cline' }) };
689
- });
733
+ let raw;
734
+ try {
735
+ raw = fs.readFileSync(srcPath, 'utf8');
736
+ } catch {
737
+ continue; // unreadable skill — skip, don't abort the whole install
738
+ }
739
+ blocks.push({ name, block: cline.convert(raw, name, { runtime: 'cline' }) });
740
+ }
690
741
 
691
742
  const desired = cline.buildClinerulesFile(blocks);
692
743
  const target = path.join(configDir, '.clinerules');
@@ -111,11 +111,39 @@ function buildAgentsFileContent(runtime, payloadHeader) {
111
111
  const GDD_ADAPTER_FINGERPRINT = 'gdd: auto-generated from Claude SKILL.md';
112
112
  const CLINERULES_HEADER_FINGERPRINT = '# get-design-done rules';
113
113
 
114
+ // B5/S4 fix (Phase 59.8): ownership detection is WHOLE-LINE anchored, not a
115
+ // loose `String.includes` substring scan. The old substring match treated any
116
+ // user-authored file that merely *mentioned* a marker string (e.g. a doc that
117
+ // quotes "get-design-done plugin instructions", or a code fence containing
118
+ // "gdd: auto-generated from Claude SKILL.md") as plugin-owned — so install
119
+ // would overwrite it and uninstall would delete it. We now require the marker
120
+ // to appear on a recognized GENERATED line:
121
+ //
122
+ // - `<!-- ... <fingerprint> ... -->` HTML-comment marker line. Both the
123
+ // Phase-24 plugin fingerprint and the per-runtime/sibling adapter header
124
+ // are emitted as a standalone HTML comment line; we accept the marker only
125
+ // when it sits inside an HTML comment that occupies the whole (trimmed)
126
+ // line. A bare prose mention of the same words no longer qualifies.
127
+ // - `# get-design-done rules` cline rules header — must be the exact, whole
128
+ // trimmed line (a Markdown H1), matching converters/cline.cjs.
129
+ //
130
+ // Scanning line-by-line keeps detection of genuinely plugin-owned files intact
131
+ // (the generated marker line is always present near the top) while refusing to
132
+ // claim ownership of user files that merely contain the words somewhere.
133
+ function isHtmlCommentMarkerLine(line, fingerprint) {
134
+ const t = line.trim();
135
+ if (!t.startsWith('<!--') || !t.endsWith('-->')) return false;
136
+ return t.includes(fingerprint);
137
+ }
138
+
114
139
  function isPluginOwned(content) {
115
140
  if (!content || typeof content !== 'string') return false;
116
- if (content.includes(PLUGIN_FINGERPRINT)) return true;
117
- if (content.includes(GDD_ADAPTER_FINGERPRINT)) return true;
118
- if (content.includes(CLINERULES_HEADER_FINGERPRINT)) return true;
141
+ const lines = content.split(/\r?\n/);
142
+ for (const line of lines) {
143
+ if (isHtmlCommentMarkerLine(line, PLUGIN_FINGERPRINT)) return true;
144
+ if (isHtmlCommentMarkerLine(line, GDD_ADAPTER_FINGERPRINT)) return true;
145
+ if (line.trim() === CLINERULES_HEADER_FINGERPRINT) return true;
146
+ }
119
147
  return false;
120
148
  }
121
149
 
@@ -78,7 +78,7 @@ function validateModelRow(row, where) {
78
78
  if (typeof row.model !== 'string' || row.model.length === 0) {
79
79
  throw new Error(`${where}: 'model' must be a non-empty string`);
80
80
  }
81
- const allowedKeys = new Set(['model', 'provider_model_id']);
81
+ const allowedKeys = new Set(['model', 'provider_model_id', 'context_window']);
82
82
  for (const k of Object.keys(row)) {
83
83
  if (!allowedKeys.has(k)) {
84
84
  throw new Error(`${where}: unknown key '${k}' (allowed: ${[...allowedKeys].join(', ')})`);
@@ -89,6 +89,14 @@ function validateModelRow(row, where) {
89
89
  throw new Error(`${where}: 'provider_model_id' must be a non-empty string when present`);
90
90
  }
91
91
  }
92
+ // Optional context-window size — mirror the schema (integer >= 1). Recorded as
93
+ // machine-readable metadata (the 1M-context [1m] opus variant); not yet a
94
+ // budgeting driver (deferred — no consumer wired this cycle).
95
+ if (row.context_window !== undefined) {
96
+ if (typeof row.context_window !== 'number' || !Number.isInteger(row.context_window) || row.context_window < 1) {
97
+ throw new Error(`${where}: 'context_window' must be a positive integer when present`);
98
+ }
99
+ }
92
100
  }
93
101
 
94
102
  function validateProvenance(arr, where) {
@@ -226,9 +226,21 @@ function commandsKind(destSubpath, prefix, converterPath, runtime) {
226
226
  /**
227
227
  * Build an `agents` artifact-kind descriptor.
228
228
  *
229
- * claude local only — passthrough copy from `<repo>/agents/*` into
229
+ * claude local only — passthrough copy from `<repo>/agents/*.md` into
230
230
  * `<configDir>/agents/`. No converter.
231
231
  *
232
+ * AR7 fix (Phase 59.8): the agent set is ENUMERATED from the `agents/`
233
+ * directory on disk — NOT derived from `ctx.skillNames`. Real agent files
234
+ * are named after agent roles (`design-planner.md`, `a11y-mapper.md`, …),
235
+ * which never coincide with skill directory names. The old skill-name-derived
236
+ * path read `agents/<skillName>.md`, found nothing for any skill, and staged
237
+ * ~96 empty `gdd-<skillName>.md` artifacts while installing ZERO real agents.
238
+ *
239
+ * Enumeration rules:
240
+ * - top-level `*.md` files in `agents/` only (no nested dirs),
241
+ * - `README.md` is excluded (it is documentation, not an agent),
242
+ * - empty / unreadable files are skipped (best-effort; never throws).
243
+ *
232
244
  * @param {string} destSubpath
233
245
  * @param {string} prefix
234
246
  * @returns {ArtifactKind}
@@ -243,13 +255,35 @@ function agentsKind(destSubpath, prefix) {
243
255
  path.dirname(ctx.skillsRoot),
244
256
  'agents'
245
257
  );
246
- return ctx.skillNames.map((name) => {
247
- const srcPath = path.join(agentsRoot, name + '.md');
248
- const raw = fs.existsSync(srcPath)
249
- ? fs.readFileSync(srcPath, 'utf8')
250
- : '';
251
- return { srcPath, content: raw, name: prefix + name };
252
- });
258
+ let entries;
259
+ try {
260
+ entries = fs.readdirSync(agentsRoot, { withFileTypes: true });
261
+ } catch {
262
+ // No agents/ dir on disk — stage nothing (never throw).
263
+ return [];
264
+ }
265
+ const staged = [];
266
+ for (const ent of entries) {
267
+ if (!ent.isFile()) continue;
268
+ if (!ent.name.toLowerCase().endsWith('.md')) continue;
269
+ if (ent.name.toLowerCase() === 'readme.md') continue;
270
+ // Strip any pre-existing gdd-/gsd- prefix on the agent filename before
271
+ // re-applying `prefix`, so an agent already named `gdd-foo.md` does not
272
+ // become `gdd-gdd-foo.md`. Real agents ship un-prefixed
273
+ // (`a11y-mapper.md`); this guard keeps both shapes correct.
274
+ const fileBase = ent.name.slice(0, -'.md'.length);
275
+ const bareName = fileBase.replace(/^(gdd-|gsd-)/i, '');
276
+ const srcPath = path.join(agentsRoot, ent.name);
277
+ let raw = '';
278
+ try {
279
+ raw = fs.readFileSync(srcPath, 'utf8');
280
+ } catch {
281
+ continue;
282
+ }
283
+ if (!raw.trim()) continue; // skip empty agent files
284
+ staged.push({ srcPath, content: raw, name: prefix + bareName });
285
+ }
286
+ return staged;
253
287
  },
254
288
  };
255
289
  }
@@ -20,11 +20,13 @@
20
20
  "command_syntax": "/gdd:<skill>",
21
21
  "mcp_support": true,
22
22
  "placeholder_substitution": true,
23
+ "agents_support": true,
24
+ "hooks_support": true,
23
25
  "install_path": "dist/claude-code/.claude/skills/",
24
26
  "status": "tested"
25
27
  },
26
28
  "last_verified": "2026-06-02",
27
- "capability_notes": "Host runtime. Marketplace-registered, end-to-end documented, Phase 42 golden baseline.",
29
+ "capability_notes": "Host runtime. Marketplace-registered, end-to-end documented, Phase 42 golden baseline. Sole runtime that receives the 64 sub-agents (claude --local installs agents/) and the hook layer (SessionStart / PostToolUse / statusLine).",
28
30
  "fragment_links": [
29
31
  "reference/runtime-models.md#claude---claude-code"
30
32
  ]
@@ -44,6 +46,8 @@
44
46
  "command_syntax": "/gdd-<skill>",
45
47
  "mcp_support": true,
46
48
  "placeholder_substitution": true,
49
+ "agents_support": false,
50
+ "hooks_support": false,
47
51
  "install_path": "dist/codex/.codex/skills/",
48
52
  "status": "experimental"
49
53
  },
@@ -71,6 +75,8 @@
71
75
  "command_syntax": "/gdd:<skill>",
72
76
  "mcp_support": true,
73
77
  "placeholder_substitution": true,
78
+ "agents_support": false,
79
+ "hooks_support": false,
74
80
  "install_path": "dist/gemini/.gemini/skills/",
75
81
  "status": "experimental"
76
82
  },
@@ -98,6 +104,8 @@
98
104
  "command_syntax": "/gdd:<skill>",
99
105
  "mcp_support": false,
100
106
  "placeholder_substitution": true,
107
+ "agents_support": false,
108
+ "hooks_support": false,
101
109
  "install_path": "dist/qwen/.qwen/skills/",
102
110
  "status": "experimental"
103
111
  },
@@ -124,6 +132,8 @@
124
132
  "command_syntax": "/gdd:<skill>",
125
133
  "mcp_support": false,
126
134
  "placeholder_substitution": true,
135
+ "agents_support": false,
136
+ "hooks_support": false,
127
137
  "install_path": "dist/kilo/.kilo/skills/",
128
138
  "status": "untested"
129
139
  },
@@ -148,6 +158,8 @@
148
158
  "command_syntax": "/gdd:<skill>",
149
159
  "mcp_support": false,
150
160
  "placeholder_substitution": true,
161
+ "agents_support": false,
162
+ "hooks_support": false,
151
163
  "install_path": "dist/copilot/.copilot/skills/",
152
164
  "status": "experimental"
153
165
  },
@@ -174,6 +186,8 @@
174
186
  "command_syntax": "/gdd:<skill>",
175
187
  "mcp_support": false,
176
188
  "placeholder_substitution": true,
189
+ "agents_support": false,
190
+ "hooks_support": false,
177
191
  "install_path": "dist/cursor/.cursor/skills/",
178
192
  "status": "experimental"
179
193
  },
@@ -200,6 +214,8 @@
200
214
  "command_syntax": "/gdd:<skill>",
201
215
  "mcp_support": false,
202
216
  "placeholder_substitution": true,
217
+ "agents_support": false,
218
+ "hooks_support": false,
203
219
  "install_path": "dist/windsurf/.windsurf/skills/",
204
220
  "status": "untested"
205
221
  },
@@ -224,6 +240,8 @@
224
240
  "command_syntax": "/gdd:<skill>",
225
241
  "mcp_support": false,
226
242
  "placeholder_substitution": true,
243
+ "agents_support": false,
244
+ "hooks_support": false,
227
245
  "install_path": "dist/antigravity/.antigravity/skills/",
228
246
  "status": "untested"
229
247
  },
@@ -248,6 +266,8 @@
248
266
  "command_syntax": "/gdd:<skill>",
249
267
  "mcp_support": false,
250
268
  "placeholder_substitution": true,
269
+ "agents_support": false,
270
+ "hooks_support": false,
251
271
  "install_path": "dist/augment/.augment/skills/",
252
272
  "status": "untested"
253
273
  },
@@ -272,6 +292,8 @@
272
292
  "command_syntax": "/gdd:<skill>",
273
293
  "mcp_support": false,
274
294
  "placeholder_substitution": true,
295
+ "agents_support": false,
296
+ "hooks_support": false,
275
297
  "install_path": "dist/trae/.trae/skills/",
276
298
  "status": "untested"
277
299
  },
@@ -296,6 +318,8 @@
296
318
  "command_syntax": "/gdd:<skill>",
297
319
  "mcp_support": false,
298
320
  "placeholder_substitution": true,
321
+ "agents_support": false,
322
+ "hooks_support": false,
299
323
  "install_path": "dist/codebuddy/.codebuddy/skills/",
300
324
  "status": "untested"
301
325
  },
@@ -320,6 +344,8 @@
320
344
  "command_syntax": "/gdd:<skill>",
321
345
  "mcp_support": false,
322
346
  "placeholder_substitution": true,
347
+ "agents_support": false,
348
+ "hooks_support": false,
323
349
  "install_path": "dist/cline/.cline/skills/",
324
350
  "status": "untested"
325
351
  },
@@ -344,6 +370,8 @@
344
370
  "command_syntax": "/gdd:<skill>",
345
371
  "mcp_support": false,
346
372
  "placeholder_substitution": true,
373
+ "agents_support": false,
374
+ "hooks_support": false,
347
375
  "install_path": "dist/opencode/.opencode/skills/",
348
376
  "status": "untested"
349
377
  },
@@ -541,7 +541,7 @@
541
541
  },
542
542
  {
543
543
  "name": "router",
544
- "description": "Routes a /gdd command to fast|quick|full path + S|M|L|XL complexity_class and returns {path, complexity_class, model_tier_overrides, resolved_models, estimated_cost_usd, cache_hits}. Deterministic - no model call. Invoked once at command entry before any Agent spawn. Read by hooks/budget-enforcer.ts.",
544
+ "description": "Routes a /gdd command to fast|quick|full path + S|M|L|XL complexity_class and returns {path, complexity_class, model_tier_overrides, resolved_models, estimated_cost_usd, cache_hits}. A SKILL.md prompt the model executes to emit a routing-decision JSON from rule tables (no separate agent spawn). Optional/advisory - invoked only by the skills that opt into routing; the budget-enforcer hook tolerates its absence. Read by hooks/budget-enforcer.ts.",
545
545
  "argument_hint": "<intent-string> [<target-artifacts-csv>]",
546
546
  "tools": "Read, Bash, Grep"
547
547
  },
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+ /*
3
+ * scripts/lib/model-id.cjs — model-id normalization + tiering (pure, dependency-free).
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * Two unrelated callers need to reason about model ids in identical ways:
8
+ * - scripts/lib/session-runner/index.ts (routing: which tier am I running?)
9
+ * - scripts/lib/budget-enforcer.cjs (pricing: what does this model cost?)
10
+ * Each previously carried its own ad-hoc parsing, which drifted. This module is
11
+ * the single source of truth so a new model family is a DATA edit here (or in the
12
+ * price tables), never a logic change scattered across callers.
13
+ *
14
+ * DESIGN PRINCIPLES
15
+ * -----------------
16
+ * 1. TIER IS FOR ROUTING. `tierForModelId` answers "opus | sonnet | haiku" so the
17
+ * router can pick an agent class. It is NOT a pricing key on its own — pricing
18
+ * also depends on the exact id and (later) the context-window variant.
19
+ *
20
+ * 2. NULL MEANS UNKNOWN — PRICE CONSERVATIVELY + LOUDLY. We deliberately return
21
+ * `null` for ids we cannot confidently classify rather than guessing a tier.
22
+ * A wrong tier guess silently mis-routes or mis-prices. Callers MUST treat
23
+ * null as "unknown model — assume the most expensive plausible price AND warn",
24
+ * never as a tier and never as free. Do NOT add heuristic fallbacks that
25
+ * invent a tier for arbitrary strings.
26
+ *
27
+ * 3. VARIANT SUFFIX IS FOR CONTEXT-WINDOW-AWARE PRICING (LATER). Ids may carry a
28
+ * bracketed variant such as `claude-opus-4-8[1m]` or `...[200k]`. The variant
29
+ * encodes a context-window SKU that can have different per-token pricing. We
30
+ * split it off cleanly (`{ base, variant }`) so tiering operates on `base`
31
+ * while a future price table can key on `(base, variant)`. Date stamps in the
32
+ * base (e.g. `claude-opus-4-8-20260101`) are NOT variants and are left intact.
33
+ *
34
+ * 4. NEW FAMILIES ARE A DATA EDIT, NOT A CODE CHANGE. To onboard a new model:
35
+ * - if its id contains the tier word (opus/sonnet/haiku), the family-pattern
36
+ * rule already handles it — optionally pin it in KNOWN_TIER_BY_ID;
37
+ * - if its id does NOT contain the tier word (e.g. a hypothetical
38
+ * `claude-fable-5`), add one line to ALIAS_MAP (see comment there);
39
+ * - pricing specifics go in the caller's price table keyed on the exact id.
40
+ */
41
+
42
+ /**
43
+ * KNOWN_TIER_BY_ID — explicit, exact-id → tier pins.
44
+ * Seeded with the currently-shipping ids. Exact matches win over pattern rules,
45
+ * so this is also the place to OVERRIDE a family-pattern result if a specific
46
+ * sku is mis-classified by the generic regex. Keys are the normalized `base`
47
+ * (no bracket variant).
48
+ */
49
+ const KNOWN_TIER_BY_ID = Object.freeze({
50
+ 'claude-opus-4-8': 'opus',
51
+ 'claude-opus-4-7': 'opus',
52
+ 'claude-sonnet-4-7': 'sonnet',
53
+ 'claude-sonnet-4-6': 'sonnet',
54
+ 'claude-sonnet-4-5': 'sonnet',
55
+ 'claude-haiku-4-5': 'haiku',
56
+ });
57
+
58
+ /**
59
+ * ALIAS_MAP — extension point for families whose id does NOT contain the tier word.
60
+ *
61
+ * Currently EMPTY by design. The family-pattern rule (step c in tierForModelId)
62
+ * already covers any id literally containing `opus`/`sonnet`/`haiku`. Use this map
63
+ * ONLY for a future lineup whose product name omits the tier word.
64
+ *
65
+ * Example — when Anthropic publishes the `claude-fable-5` sku lineup and we learn
66
+ * it maps to opus-class routing, add (keyed on normalized base):
67
+ *
68
+ * 'claude-fable-5': 'opus',
69
+ *
70
+ * Until the lineup is public we leave it empty rather than guess — an unknown
71
+ * `claude-fable-5` correctly resolves to null (conservative pricing + warning).
72
+ */
73
+ const ALIAS_MAP = Object.freeze({
74
+ // 'claude-fable-5': 'opus', // <- add when the fable-5 sku lineup is public
75
+ });
76
+
77
+ const VARIANT_RE = /\[([^\]]*)\]\s*$/; // trailing bracketed suffix, e.g. [1m] / [200k]
78
+ const FAMILY_RE = /(?:^|-)(opus|sonnet|haiku)(?:-|$)/;
79
+
80
+ /**
81
+ * normalizeModelId(id) → { base, variant }
82
+ *
83
+ * Splits off a single trailing bracketed variant suffix (e.g. `[1m]`, `[200k]`),
84
+ * returning it lowercased with brackets removed as `variant`, and the remaining
85
+ * trimmed id as `base`. Date stamps in the base are preserved. Null/empty/
86
+ * undefined input yields `{ base: '', variant: null }`.
87
+ *
88
+ * @param {string|null|undefined} id
89
+ * @returns {{ base: string, variant: string|null }}
90
+ */
91
+ function normalizeModelId(id) {
92
+ if (id == null) return { base: '', variant: null };
93
+ const s = String(id).trim();
94
+ if (s === '') return { base: '', variant: null };
95
+
96
+ const m = s.match(VARIANT_RE);
97
+ if (m) {
98
+ const variant = m[1].trim().toLowerCase();
99
+ const base = s.slice(0, m.index).trim();
100
+ return { base, variant: variant === '' ? null : variant };
101
+ }
102
+ return { base: s, variant: null };
103
+ }
104
+
105
+ /**
106
+ * tierForModelId(id) → 'opus' | 'sonnet' | 'haiku' | null
107
+ *
108
+ * Resolution order:
109
+ * (a) normalize → work on `base`;
110
+ * (b) exact match in KNOWN_TIER_BY_ID;
111
+ * (c) family-pattern: base contains the tier word as a token;
112
+ * (d) ALIAS_MAP (families whose id omits the tier word);
113
+ * (e) otherwise null — UNKNOWN. Callers must price conservatively + loudly,
114
+ * NOT treat null as a tier.
115
+ *
116
+ * @param {string|null|undefined} id
117
+ * @returns {'opus'|'sonnet'|'haiku'|null}
118
+ */
119
+ function tierForModelId(id) {
120
+ const { base } = normalizeModelId(id);
121
+ if (base === '') return null;
122
+
123
+ // (b) exact known-id pin
124
+ if (Object.prototype.hasOwnProperty.call(KNOWN_TIER_BY_ID, base)) {
125
+ return KNOWN_TIER_BY_ID[base];
126
+ }
127
+
128
+ // (c) family-pattern (tier word appears as a token in the id)
129
+ const fam = base.match(FAMILY_RE);
130
+ if (fam) return fam[1];
131
+
132
+ // (d) alias for families whose id omits the tier word
133
+ if (Object.prototype.hasOwnProperty.call(ALIAS_MAP, base)) {
134
+ return ALIAS_MAP[base];
135
+ }
136
+
137
+ // (e) unknown → null (conservative pricing + loud warning is the caller's job)
138
+ return null;
139
+ }
140
+
141
+ module.exports = { normalizeModelId, tierForModelId, KNOWN_TIER_BY_ID, ALIAS_MAP };