@hegemonart/get-design-done 1.60.2 → 1.60.4

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Get Design Done — 5-stage agent-orchestrated design pipeline (Brief → Explore → Plan → Design → Verify) for AI coding agents. 64 agents, 96 skills, 39 connection integrations, two MCP servers, opt-in SQLite state backbone, bidirectional Figma write-back, and a reflector-driven self-improvement loop. Cross-runtime install for Claude Code, Codex, Cursor, OpenCode, Gemini, and more.",
8
- "version": "1.60.2"
8
+ "version": "1.60.4"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "get-design-done",
13
13
  "source": "./",
14
14
  "description": "Agent-orchestrated 5-stage design pipeline (Brief → Explore → Plan → Design → Verify) for AI coding agents. 64 specialized agents, 96 skills, 39 connection integrations (Figma, Refero, Preview, Storybook, Chromatic, Graphify, Linear, Jira, Notion, …), bidirectional Figma write-back, queryable intel store, opt-in SQLite state backbone, and a reflector-driven self-improvement loop. Two MCP servers (gdd-state for typed STATE mutators, gdd-mcp for 13 read-only project-priming tools), tier-aware routing with cost telemetry, and defense-in-depth hooks (protected paths, MCP circuit breaker, injection scanner, budget enforcer). Cross-runtime install for Claude Code, Codex, Cursor, OpenCode, Gemini, Copilot, and more.",
15
- "version": "1.60.2",
15
+ "version": "1.60.4",
16
16
  "author": {
17
17
  "name": "hegemonart"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "get-design-done",
3
3
  "short_name": "gdd",
4
- "version": "1.60.2",
4
+ "version": "1.60.4",
5
5
  "description": "Agent-orchestrated 5-stage design pipeline (Brief → Explore → Plan → Design → Verify) for AI coding agents. 64 specialized agents, 96 skills, 39 connection integrations (Figma, Refero, Preview, Storybook, Chromatic, Graphify, Linear, Jira, Notion, …), bidirectional Figma write-back, queryable intel store for O(1) design-surface lookups, opt-in SQLite state backbone, and a reflector-driven self-improvement loop. Two MCP servers (`gdd-state` for typed STATE mutators, `gdd-mcp` for 13 read-only project-priming tools), tier-aware agent routing with cost telemetry, defense-in-depth hooks (protected paths, MCP circuit breaker, injection scanner, budget enforcer), and a cross-runtime install layer for Claude Code, Codex, Cursor, OpenCode, Gemini, Copilot, and more.",
6
6
  "author": {
7
7
  "name": "hegemonart",
package/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ All notable changes to get-design-done are documented here. Versions follow [sem
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.60.4] - 2026-07-06
8
+
9
+ **Multi-runtime install fix (Tier-2 bundles)** - follow-up to 1.60.3. The same Claude-only `model:` frontmatter directive is now stripped from the Tier-2 marketplace distribution bundles, not just the Tier-1 file-drop command files.
10
+
11
+ ### Fixed
12
+
13
+ - **`model:` frontmatter stripped from Tier-2 marketplace bundles** (`scripts/lib/install/converters/codex-plugin.cjs`, `scripts/lib/install/converters/cursor-marketplace.cjs`). These converters copied the `skills/` tree verbatim, so `model: inherit` still leaked into the Codex-plugin and Cursor-marketplace bundles and would crash non-Claude consumers exactly as the file-drop path did before 1.60.3 (`Model not found: inherit/.`). A new shared helper `stripModelFromFrontmatter` drops the `model:` line from each `SKILL.md` frontmatter during the copy; files that carry no `model:` line are still copied byte-for-byte, so the verbatim-copy guarantee holds for everything else. `buildFrontmatter` and the new helper share a single `MODEL_FIELD_RE` so the Tier-1 and Tier-2 paths strip identically. Regression coverage in `test/suite/converters/codex-plugin.test.cjs`, `test/suite/converters/cursor-marketplace.test.cjs`, and a `stripModelFromFrontmatter` unit in `test/suite/converters-wave4.test.cjs`.
14
+
15
+ ### Breaking changes
16
+
17
+ None.
18
+
19
+ 5,148/5,148 tests pass.
20
+
21
+ ---
22
+
23
+ ## [1.60.3] - 2026-07-06
24
+
25
+ **Multi-runtime install fix** - the Claude-only `model:` frontmatter directive no longer leaks into the command files generated for non-Claude runtimes. Running any `/gdd-*` command on Kilo Code (Qwen) crashed with `Model not found: inherit/.` because the installer round-tripped `model: inherit` verbatim into the command file and Kilo parsed the value as a literal model id.
26
+
27
+ ### Fixed
28
+
29
+ - **`model:` frontmatter stripped from non-Claude command files** (`scripts/lib/install/converters/shared.cjs`). `buildFrontmatter` now drops the `model:` line when emitting artifacts for the 12 non-Claude command-format runtimes (Kilo, Qwen, OpenCode, Gemini, Cursor, Codex, Copilot, Antigravity, Augment, CodeBuddy, Windsurf, Trae). `model: inherit` is a Claude-Code directive meaning "defer to the session model"; it - and the Claude tier names `opus` / `sonnet` / `haiku` - are not valid model ids on any other runtime, so Kilo read `inherit` as `<provider>/<model>` (`inherit/`) and failed with `Model not found: inherit/.`. Cross-runtime model selection remains the job of `default-tier` / `reasoning-class` + `tier-resolver.cjs`, which round-trip untouched; sibling keys such as `default-tier:` and `model-notes:` are preserved. Agents are Claude-local-only, so no agent artifact was affected. Regression coverage added in `test/suite/converters-wave4.test.cjs` (Kilo / OpenCode / Gemini integration plus a direct `buildFrontmatter` unit).
30
+
31
+ ### Breaking changes
32
+
33
+ None.
34
+
35
+ 5,145/5,145 tests pass.
36
+
37
+ ---
38
+
7
39
  ## [1.60.2] - 2026-06-13
8
40
 
9
41
  **Security & CI hardening** - bring the SAST/dependency-audit gates the project lacked, and close the one untrusted-link gap in the injection scanner, *before* the detection engine lands its large new surface. Sourced from a reconciliation against the upstream framework's recent releases (`.planning/audits/UPSTREAM-GSD-CORE-DIFF-2026-06-13.md`).
@@ -148,11 +148,16 @@ export function stateFileHasPausedBlock(): boolean {
148
148
  }
149
149
 
150
150
  function appendPausedBlock(block: string): void {
151
- if (!existsSync(dirname(STATE_PATH))) {
152
- mkdirSync(dirname(STATE_PATH), { recursive: true });
153
- }
154
- if (!existsSync(STATE_PATH)) {
155
- writeFileSync(STATE_PATH, '# Design State\n\n', 'utf8');
151
+ // mkdir recursive is idempotent — no existsSync check needed.
152
+ mkdirSync(dirname(STATE_PATH), { recursive: true });
153
+ // Seed the header only when the file is new, atomically via the 'wx' flag:
154
+ // an EEXIST means STATE.md already exists, so we skip the header and append
155
+ // to it. This collapses the existsSync→writeFileSync/appendFileSync TOCTOU
156
+ // race that previously sat between the check and the writes.
157
+ try {
158
+ writeFileSync(STATE_PATH, '# Design State\n\n', { encoding: 'utf8', flag: 'wx' });
159
+ } catch (e) {
160
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
156
161
  }
157
162
  appendFileSync(STATE_PATH, block, 'utf8');
158
163
  }
@@ -197,9 +197,24 @@ function appendJsonl(filePath, row) {
197
197
 
198
198
  function appendStateBlocker(cwd, message) {
199
199
  const statePath = path.join(cwd, '.design', 'STATE.md');
200
- if (!fs.existsSync(statePath)) return; // silent if STATE missing
201
200
  const line = `\n<!-- mcp-circuit-breaker: ${new Date().toISOString()} --> 🛑 BLOCKER: ${message}\n`;
202
- try { fs.appendFileSync(statePath, line, 'utf8'); } catch { /* best-effort */ }
201
+ // Open with 'r+' (no-create) so we append ONLY to an already-existing STATE
202
+ // and never create it — opening fails with ENOENT when STATE is missing,
203
+ // which we swallow as "silent if STATE missing". This collapses the old
204
+ // existsSync→appendFileSync TOCTOU race into a single atomic open.
205
+ let fd;
206
+ try {
207
+ fd = fs.openSync(statePath, 'r+');
208
+ } catch {
209
+ return; // STATE missing (ENOENT) or otherwise unopenable — best-effort, stay silent
210
+ }
211
+ try {
212
+ fs.writeSync(fd, line, fs.fstatSync(fd).size, 'utf8');
213
+ } catch {
214
+ /* best-effort */
215
+ } finally {
216
+ try { fs.closeSync(fd); } catch { /* best-effort */ }
217
+ }
203
218
  }
204
219
 
205
220
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hegemonart/get-design-done",
3
- "version": "1.60.2",
3
+ "version": "1.60.4",
4
4
  "description": "A design-quality pipeline for AI coding agents: brief, explore, plan, design, and verify UI work against your design system.",
5
5
  "author": "Hegemon",
6
6
  "homepage": "https://github.com/hegemonart/get-design-done",
@@ -29,7 +29,7 @@
29
29
  //
30
30
  // Default --path is `.design/telemetry/events.jsonl` (relative to cwd).
31
31
 
32
- import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
32
+ import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
33
33
  import { resolve, isAbsolute } from 'node:path';
34
34
  import { pathToFileURL } from 'node:url';
35
35
  import { argv, exit, stdout, stderr } from 'node:process';
@@ -146,23 +146,41 @@ async function cmdTail(parsed) {
146
146
  return 0;
147
147
  }
148
148
  // Follow mode: stream existing content, then poll for appends.
149
+ // Read directly and treat ENOENT as "file not present yet" (offset stays 0,
150
+ // the poll loop below picks it up once it appears) — avoids the
151
+ // existsSync→read/statSync TOCTOU race.
149
152
  let offset = 0;
150
- if (existsSync(path)) {
153
+ try {
151
154
  for await (const ev of readEvents({ path })) {
152
155
  stdout.write(JSON.stringify(ev) + '\n');
153
156
  }
154
- offset = statSync(path).size;
157
+ // Capture the EOF offset from a short-lived fd (open+fstat) instead of
158
+ // statSync(path): keeping every size check on a descriptor — never on the
159
+ // path — leaves no stat→open pair for the poll loop below to race on.
160
+ const fd0 = openSync(path, 'r');
161
+ try { offset = fstatSync(fd0).size; } finally { closeSync(fd0); }
162
+ } catch (e) {
163
+ if (e.code !== 'ENOENT') throw e;
164
+ offset = 0;
155
165
  }
156
166
  // Poll loop. Reads new bytes since last offset, splits on \n, writes each.
157
167
  let buf = '';
158
168
  // eslint-disable-next-line no-constant-condition
159
169
  while (true) {
160
170
  await new Promise((r) => setTimeout(r, 250));
161
- if (!existsSync(path)) continue;
162
- const size = statSync(path).size;
163
- if (size <= offset) continue;
164
- const fd = openSync(path, 'r');
171
+ // Open the file first and fstat the handle — both the size check and the
172
+ // read target the descriptor, not the path, so there is no stat→open TOCTOU
173
+ // window. ENOENT means the file isn't there this tick: skip and re-poll.
174
+ let fd;
175
+ try {
176
+ fd = openSync(path, 'r');
177
+ } catch (e) {
178
+ if (e.code === 'ENOENT') continue;
179
+ throw e;
180
+ }
165
181
  try {
182
+ const size = fstatSync(fd).size;
183
+ if (size <= offset) continue;
166
184
  const need = size - offset;
167
185
  const chunk = Buffer.allocUnsafe(need);
168
186
  const n = readSync(fd, chunk, 0, need, offset);
@@ -202,7 +202,15 @@ function modeForward(check) {
202
202
  }
203
203
 
204
204
  function modeExtract() {
205
- const existing = fs.existsSync(SKILLS_JSON) ? readSkillsJson() : { schema_version: 1, skills: [] };
205
+ // Read directly and treat a missing skills.json as the empty-default seed
206
+ // avoids the existsSync→readFileSync TOCTOU race.
207
+ let existing;
208
+ try {
209
+ existing = readSkillsJson();
210
+ } catch (e) {
211
+ if (e.code !== 'ENOENT') throw e;
212
+ existing = { schema_version: 1, skills: [] };
213
+ }
206
214
  const prevMap = recordMap(existing);
207
215
  const dirs = listSkillDirs();
208
216
  const skills = [];
@@ -23,6 +23,10 @@ const DEFAULT_ARMS_PATH = '.design/telemetry/design-arms.json';
23
23
  // user-outcome data shifts it (D-03 — advisory, never directive).
24
24
  const DESIGN_ARM_PRIOR = Object.freeze({ alpha: 2, beta: 8 });
25
25
 
26
+ // Monotonic per-process counter so two saves within the same millisecond still get
27
+ // distinct tmp names (no `crypto` — this module is "no crypto, no egress").
28
+ let _tmpSeq = 0;
29
+
26
30
  /** Inline FNV-1a (32-bit) hash → 8-char hex. Deterministic, dependency-free. */
27
31
  function fnv1a(str) {
28
32
  let h = 0x811c9dc5;
@@ -62,7 +66,10 @@ function save(store, opts = {}) {
62
66
  fs.mkdirSync(path.dirname(p), { recursive: true });
63
67
  store.schema_version = SCHEMA_VERSION;
64
68
  store.generated_at = new Date().toISOString();
65
- const tmp = p + '.tmp';
69
+ // Unique per-writer tmp name: prevents symlink/race attacks on a static `.tmp`
70
+ // path AND stops concurrent writers from clobbering each other's tmp file before
71
+ // the atomic rename. pid + time + counter — no crypto (see module header).
72
+ const tmp = `${p}.${process.pid}.${Date.now()}.${_tmpSeq++}.tmp`;
66
73
  fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
67
74
  fs.renameSync(tmp, p);
68
75
  }
@@ -44,6 +44,7 @@
44
44
 
45
45
  const fs = require('node:fs');
46
46
  const path = require('node:path');
47
+ const shared = require('./shared.cjs');
47
48
 
48
49
  // Per research § Top-level fields: name, version, description are the only
49
50
  // strictly-required spec fields. All other manifest fields are optional.
@@ -112,6 +113,12 @@ function curateKeywords(arr) {
112
113
  /**
113
114
  * Copy a directory tree recursively. Vanilla fs only — no deps. Mirrors
114
115
  * the helper used by cursor-marketplace.cjs (Plan 28-8-B1).
116
+ *
117
+ * `SKILL.md` files are sanitized in transit: the Claude-only `model:`
118
+ * frontmatter directive is stripped so non-Claude marketplace consumers do
119
+ * not choke on `model: inherit` (Kilo: `Model not found: inherit/.`). Files
120
+ * that carry no `model:` line are copied byte-for-byte (copyFileSync), so the
121
+ * verbatim-copy guarantee still holds for everything else.
115
122
  */
116
123
  function copyDirRecursive(src, dest) {
117
124
  fs.mkdirSync(dest, { recursive: true });
@@ -121,7 +128,17 @@ function copyDirRecursive(src, dest) {
121
128
  if (entry.isDirectory()) {
122
129
  copyDirRecursive(srcPath, destPath);
123
130
  } else if (entry.isFile()) {
124
- fs.copyFileSync(srcPath, destPath);
131
+ if (entry.name === 'SKILL.md') {
132
+ const original = fs.readFileSync(srcPath, 'utf8');
133
+ const stripped = shared.stripModelFromFrontmatter(original);
134
+ if (stripped === original) {
135
+ fs.copyFileSync(srcPath, destPath); // byte-exact, no re-encode
136
+ } else {
137
+ fs.writeFileSync(destPath, stripped, 'utf8');
138
+ }
139
+ } else {
140
+ fs.copyFileSync(srcPath, destPath);
141
+ }
125
142
  }
126
143
  // symlinks + other: ignored (skills tree is regular files only).
127
144
  }
@@ -34,6 +34,7 @@
34
34
 
35
35
  const fs = require('node:fs');
36
36
  const path = require('node:path');
37
+ const shared = require('./shared.cjs');
37
38
 
38
39
  // Curated keyword subset for Cursor's marketplace card display.
39
40
  // Per Wave A research § Schema Mapping `keywords` row: marketplace card
@@ -201,6 +202,12 @@ function buildManifest(sources, opts) {
201
202
  /**
202
203
  * Copy a directory tree recursively. Vanilla fs only — no deps.
203
204
  * Returns the list of relative paths written (relative to `dest`).
205
+ *
206
+ * `SKILL.md` files are sanitized in transit: the Claude-only `model:`
207
+ * frontmatter directive is stripped so non-Claude marketplace consumers do
208
+ * not choke on `model: inherit` (Kilo: `Model not found: inherit/.`). Files
209
+ * that carry no `model:` line are copied byte-for-byte (copyFileSync), so the
210
+ * verbatim-copy guarantee still holds for everything else.
204
211
  */
205
212
  function copyDirRecursive(src, dest, relPrefix) {
206
213
  const written = [];
@@ -208,16 +215,30 @@ function copyDirRecursive(src, dest, relPrefix) {
208
215
  while (stack.length > 0) {
209
216
  const { s, d, rel } = stack.pop();
210
217
  fs.mkdirSync(d, { recursive: true });
211
- const entries = fs.readdirSync(s);
218
+ // Use withFileTypes so dir/file classification comes from the single
219
+ // readdir syscall (the dirent), not a follow-up statSync — this collapses
220
+ // the check-then-use file race (CodeQL js/file-system-race) between the
221
+ // stat and the readFileSync/copyFileSync. Mirrors codex-plugin.cjs.
222
+ const entries = fs.readdirSync(s, { withFileTypes: true });
212
223
  for (const entry of entries) {
213
- const sp = path.join(s, entry);
214
- const dp = path.join(d, entry);
215
- const relPath = rel ? `${rel}/${entry}` : entry;
216
- const stat = fs.statSync(sp);
217
- if (stat.isDirectory()) {
224
+ const name = entry.name;
225
+ const sp = path.join(s, name);
226
+ const dp = path.join(d, name);
227
+ const relPath = rel ? `${rel}/${name}` : name;
228
+ if (entry.isDirectory()) {
218
229
  stack.push({ s: sp, d: dp, rel: relPath });
219
- } else if (stat.isFile()) {
220
- fs.copyFileSync(sp, dp);
230
+ } else if (entry.isFile()) {
231
+ if (name === 'SKILL.md') {
232
+ const original = fs.readFileSync(sp, 'utf8');
233
+ const stripped = shared.stripModelFromFrontmatter(original);
234
+ if (stripped === original) {
235
+ fs.copyFileSync(sp, dp); // byte-exact, no re-encode
236
+ } else {
237
+ fs.writeFileSync(dp, stripped, 'utf8');
238
+ }
239
+ } else {
240
+ fs.copyFileSync(sp, dp);
241
+ }
221
242
  written.push(relPath);
222
243
  }
223
244
  // symlinks + other: ignored (skills tree is regular files only)
@@ -101,6 +101,21 @@ const CODEX_TOOL_MAP = Object.freeze({
101
101
  WebFetch: 'shell',
102
102
  });
103
103
 
104
+ // ---------------------------------------------------------------------------
105
+ // MODEL_FIELD_RE — the Claude-only `model:` frontmatter key
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Matches a `model:` frontmatter line (optional leading whitespace, exact key).
110
+ * The `model:` directive is Claude-Code-only: its values (`inherit`, or a
111
+ * Claude tier name `opus`/`sonnet`/`haiku`) are not valid model ids on any
112
+ * non-Claude runtime, whose command loaders parse the value as a literal
113
+ * `<provider>/<model>` and fail (Kilo: `Model not found: inherit/.`). Every
114
+ * converter that emits artifacts for a non-Claude consumer strips this line.
115
+ * Anchored to the exact key so `default-tier:` / `model-notes:` never match.
116
+ */
117
+ const MODEL_FIELD_RE = /^\s*model\s*:/;
118
+
104
119
  // ---------------------------------------------------------------------------
105
120
  // extractFrontmatterAndBody — YAML frontmatter parser
106
121
  // ---------------------------------------------------------------------------
@@ -316,6 +331,10 @@ function ensureAdapterHeader(body, runtimeDisplay) {
316
331
  * `gsd-` prefix on the existing name to avoid `gdd-gdd-`-style
317
332
  * duplication). All other fields round-trip verbatim — we do NOT
318
333
  * parse YAML; we operate on the raw text with a line-by-line scan.
334
+ * - EXCEPTION: the Claude-Code-only `model:` line is dropped. Its
335
+ * values (`inherit` / a Claude tier name) are invalid model ids on
336
+ * non-Claude runtimes and crash their command loaders (Kilo:
337
+ * `Model not found: inherit/.`). See the inline note below.
319
338
  * - If the original has no `name:` field, prepend one.
320
339
  *
321
340
  * Returns a complete frontmatter string with leading/trailing `---`
@@ -339,8 +358,24 @@ function buildFrontmatter(originalFrontmatter, skillName, runtimePrefix) {
339
358
  }
340
359
 
341
360
  // Line-by-line rewrite of the `name:` field. We never touch description,
342
- // tools, or any other field — they round-trip verbatim.
343
- const lines = originalFrontmatter.split(/\r?\n/);
361
+ // tools, or any other field — they round-trip verbatim, with ONE
362
+ // exception: the Claude-Code-only `model:` directive is dropped.
363
+ //
364
+ // `model:` is a Claude harness directive ("defer to the session model" for
365
+ // `inherit`, or pin a Claude tier for `opus`/`sonnet`/`haiku`). None of
366
+ // those values are valid model ids on the non-Claude runtimes this
367
+ // converter targets. Kilo (and other command-runners) read the `model:`
368
+ // frontmatter and parse the value as a literal `<provider>/<model>` — so
369
+ // `model: inherit` becomes `inherit/` (empty model) and the command dies
370
+ // with `Model not found: inherit/.`. Cross-runtime model selection is the
371
+ // job of `default-tier`/`reasoning-class` + tier-resolver.cjs, NOT this
372
+ // per-command harness directive, so we strip the whole line here.
373
+ //
374
+ // `MODEL_FIELD_RE` matches only the exact `model` key — sibling keys like
375
+ // `model-notes:` or `default-tier:` are preserved.
376
+ const lines = originalFrontmatter
377
+ .split(/\r?\n/)
378
+ .filter((line) => !MODEL_FIELD_RE.test(line));
344
379
  let nameSeen = false;
345
380
  for (let i = 0; i < lines.length; i++) {
346
381
  const m = lines[i].match(/^(\s*name\s*:\s*)(.*)$/);
@@ -363,6 +398,41 @@ function buildFrontmatter(originalFrontmatter, skillName, runtimePrefix) {
363
398
  return '---\n' + lines.join('\n') + '\n---\n';
364
399
  }
365
400
 
401
+ // ---------------------------------------------------------------------------
402
+ // stripModelFromFrontmatter — drop the Claude-only `model:` line, keep body
403
+ // ---------------------------------------------------------------------------
404
+
405
+ /**
406
+ * Strip the Claude-only `model:` directive from a full SKILL.md string's
407
+ * frontmatter, leaving the body untouched.
408
+ *
409
+ * Used by the Tier-2 marketplace bundle emitters (codex-plugin.cjs,
410
+ * cursor-marketplace.cjs), which copy the `skills/` tree for non-Claude
411
+ * consumers. A verbatim copy would ship `model: inherit` and crash those
412
+ * consumers exactly as the Tier-1 file-drop path did before `buildFrontmatter`
413
+ * learned to drop it (Kilo: `Model not found: inherit/.`).
414
+ *
415
+ * Byte-preserving when there is nothing to strip: if `content` has no
416
+ * frontmatter, or the frontmatter carries no `model:` line, the ORIGINAL
417
+ * string is returned unchanged. Callers can therefore compare `stripped ===
418
+ * original` and fall back to a byte-exact `copyFileSync` for untouched files.
419
+ *
420
+ * The `model:` field is always a single-line scalar (`model: inherit`) in the
421
+ * GDD source set, so a line filter is sufficient — no block-scalar handling.
422
+ *
423
+ * @param {string} content Full SKILL.md content (frontmatter + body).
424
+ * @returns {string}
425
+ */
426
+ function stripModelFromFrontmatter(content) {
427
+ if (typeof content !== 'string' || content === '') return content;
428
+ const { frontmatter, body } = extractFrontmatterAndBody(content);
429
+ if (frontmatter == null) return content; // no frontmatter → nothing to strip
430
+ const lines = frontmatter.split(/\r?\n/);
431
+ const kept = lines.filter((line) => !MODEL_FIELD_RE.test(line));
432
+ if (kept.length === lines.length) return content; // no model line → unchanged
433
+ return '---\n' + kept.join('\n') + '\n---\n' + body;
434
+ }
435
+
366
436
  // ---------------------------------------------------------------------------
367
437
  // Exports
368
438
  // ---------------------------------------------------------------------------
@@ -373,5 +443,7 @@ module.exports = {
373
443
  rewriteCodeFenceTools,
374
444
  ensureAdapterHeader,
375
445
  buildFrontmatter,
446
+ stripModelFromFrontmatter,
447
+ MODEL_FIELD_RE,
376
448
  CODEX_TOOL_MAP,
377
449
  };
@@ -31,20 +31,29 @@ function load(name, opts) {
31
31
  const fallback = Object.prototype.hasOwnProperty.call(o, 'fallback') ? o.fallback : {};
32
32
  const abs = path.join(dir, `${name}.json`);
33
33
 
34
- let stat;
35
- try { stat = fs.statSync(abs); } catch {
34
+ // Open once and operate on the file descriptor (fstat for the mtime-cache key,
35
+ // then read from the same fd). A single handle resolves the path exactly once,
36
+ // at open, so there is no statSync→readFileSync TOCTOU window.
37
+ let fd;
38
+ try { fd = fs.openSync(abs, 'r'); } catch {
36
39
  if (!o.quiet) process.stderr.write(`manifest: ${name}.json not found — using empty fallback (a consumer phase may not have shipped its data yet)\n`);
37
40
  return fallback;
38
41
  }
39
- const cached = _cache.get(abs);
40
- if (cached && cached.mtimeMs === stat.mtimeMs) return cached.data;
41
42
  try {
42
- const data = JSON.parse(fs.readFileSync(abs, 'utf8'));
43
- _cache.set(abs, { mtimeMs: stat.mtimeMs, data });
44
- return data;
45
- } catch (e) {
46
- if (!o.quiet) process.stderr.write(`manifest: ${name}.json parse error (${e.message}) — using empty fallback\n`);
47
- return fallback;
43
+ const stat = fs.fstatSync(fd);
44
+ const cached = _cache.get(abs);
45
+ if (cached && cached.mtimeMs === stat.mtimeMs) return cached.data;
46
+ const raw = fs.readFileSync(fd, 'utf8');
47
+ try {
48
+ const data = JSON.parse(raw);
49
+ _cache.set(abs, { mtimeMs: stat.mtimeMs, data });
50
+ return data;
51
+ } catch (e) {
52
+ if (!o.quiet) process.stderr.write(`manifest: ${name}.json parse error (${e.message}) — using empty fallback\n`);
53
+ return fallback;
54
+ }
55
+ } finally {
56
+ fs.closeSync(fd);
48
57
  }
49
58
  }
50
59
 
@@ -425,11 +425,16 @@ function applyReject(draftPath, _options) {
425
425
  */
426
426
  function applyDefer(draftPath, options) {
427
427
  const opts = options || {};
428
- if (!fs.existsSync(draftPath)) {
429
- throw new Error(`KFM draft not found: ${draftPath}`);
430
- }
431
428
  const deferredUntil = opts.deferredUntil || new Date(Date.now() + 30 * 86_400_000).toISOString().slice(0, 10);
432
- const orig = fs.readFileSync(draftPath, 'utf8');
429
+ // Read directly and treat ENOENT as "draft not found" — avoids the
430
+ // existsSync→readFileSync TOCTOU race.
431
+ let orig;
432
+ try {
433
+ orig = fs.readFileSync(draftPath, 'utf8');
434
+ } catch (e) {
435
+ if (e.code === 'ENOENT') throw new Error(`KFM draft not found: ${draftPath}`);
436
+ throw e;
437
+ }
433
438
  let updated;
434
439
  if (/^deferred_until:/m.test(orig)) {
435
440
  updated = orig.replace(/^deferred_until:.*$/m, `deferred_until: ${deferredUntil}`);
@@ -73,7 +73,7 @@ const FILE_SENSITIVITY = Object.freeze([
73
73
  // De-risking: tests + fixtures are low-stakes.
74
74
  { test: /(^|\/)(tests?|fixtures?|__tests__|__fixtures__)\//i, mult: 0.6, add: 0, label: 'test-or-fixture' },
75
75
  // De-risking: docs / markdown.
76
- { test: /(^|\/)docs?\/|\.mdx?$/i, mult: 0.5, add: 0, label: 'docs' },
76
+ { test: /(?:(?:^|\/)docs?\/)|(?:\.mdx?$)/i, mult: 0.5, add: 0, label: 'docs' },
77
77
  ]);
78
78
 
79
79
  // ── Severity -> addend for destructive bash (via dangerous-patterns.cjs) ────
package/sdk/cli/index.js CHANGED
@@ -5,11 +5,20 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __esm = (fn, res) => function __init() {
9
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ var __esm = (fn, res, err) => function __init() {
9
+ if (err) throw err[0];
10
+ try {
11
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
12
+ } catch (e) {
13
+ throw err = [e], e;
14
+ }
10
15
  };
11
16
  var __commonJS = (cb, mod) => function __require() {
12
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
17
+ try {
18
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
19
+ } catch (e) {
20
+ throw mod = 0, e;
21
+ }
13
22
  };
14
23
  var __export = (target, all) => {
15
24
  for (var name in all)
@@ -7,7 +7,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
9
  var __commonJS = (cb, mod) => function __require() {
10
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
+ try {
11
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
12
+ } catch (e) {
13
+ throw mod = 0, e;
14
+ }
11
15
  };
12
16
  var __export = (target, all) => {
13
17
  for (var name14 in all)
@@ -428,24 +432,30 @@ var require_loader = __commonJS({
428
432
  const dir = o.dir || MANIFEST_DIR;
429
433
  const fallback = Object.prototype.hasOwnProperty.call(o, "fallback") ? o.fallback : {};
430
434
  const abs = path.join(dir, `${name14}.json`);
431
- let stat;
435
+ let fd;
432
436
  try {
433
- stat = fs.statSync(abs);
437
+ fd = fs.openSync(abs, "r");
434
438
  } catch {
435
439
  if (!o.quiet) process.stderr.write(`manifest: ${name14}.json not found \u2014 using empty fallback (a consumer phase may not have shipped its data yet)
436
440
  `);
437
441
  return fallback;
438
442
  }
439
- const cached = _cache.get(abs);
440
- if (cached && cached.mtimeMs === stat.mtimeMs) return cached.data;
441
443
  try {
442
- const data = JSON.parse(fs.readFileSync(abs, "utf8"));
443
- _cache.set(abs, { mtimeMs: stat.mtimeMs, data });
444
- return data;
445
- } catch (e) {
446
- if (!o.quiet) process.stderr.write(`manifest: ${name14}.json parse error (${e.message}) \u2014 using empty fallback
444
+ const stat = fs.fstatSync(fd);
445
+ const cached = _cache.get(abs);
446
+ if (cached && cached.mtimeMs === stat.mtimeMs) return cached.data;
447
+ const raw = fs.readFileSync(fd, "utf8");
448
+ try {
449
+ const data = JSON.parse(raw);
450
+ _cache.set(abs, { mtimeMs: stat.mtimeMs, data });
451
+ return data;
452
+ } catch (e) {
453
+ if (!o.quiet) process.stderr.write(`manifest: ${name14}.json parse error (${e.message}) \u2014 using empty fallback
447
454
  `);
448
- return fallback;
455
+ return fallback;
456
+ }
457
+ } finally {
458
+ fs.closeSync(fd);
449
459
  }
450
460
  }
451
461
  module2.exports = { load: load2, reset, MANIFEST_DIR };