@hegemonart/get-design-done 1.59.7 → 1.59.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/agents/design-authority-watcher.md +24 -5
- package/bin/gdd-graph +4 -1
- package/hooks/_hook-emit.js +113 -29
- package/hooks/budget-enforcer.ts +44 -5
- package/hooks/gdd-mcp-circuit-breaker.js +72 -3
- package/hooks/gdd-sessionstart-recap.js +23 -14
- package/hooks/hooks.json +2 -2
- package/package.json +2 -2
- package/reference/bandit-integration.md +13 -2
- package/scripts/bootstrap.cjs +40 -8
- package/scripts/install.cjs +23 -1
- package/scripts/lib/bandit-router.cjs +47 -5
- package/scripts/lib/detect/cli.cjs +13 -3
- package/scripts/lib/install/converters/cursor.cjs +11 -19
- package/scripts/lib/install/installer.cjs +72 -21
- package/scripts/lib/install/merge.cjs +31 -3
- package/scripts/lib/install/runtime-artifact-layout.cjs +42 -8
- package/scripts/lib/manifest/harnesses.json +29 -1
- package/scripts/lib/manifest/skills.json +1 -1
- package/scripts/skill-templates/bandit-reset/SKILL.md +2 -0
- package/scripts/skill-templates/bandit-status/SKILL.md +4 -1
- package/scripts/skill-templates/darkmode/SKILL.md +1 -1
- package/scripts/skill-templates/graphify/SKILL.md +6 -6
- package/scripts/skill-templates/quick/SKILL.md +3 -1
- package/scripts/skill-templates/reflect/SKILL.md +1 -1
- package/scripts/skill-templates/router/SKILL.md +4 -2
- package/sdk/cli/index.js +114 -47
- package/sdk/dashboard/data/source.cjs +50 -4
- package/sdk/event-stream/writer.ts +112 -30
- package/sdk/mcp/gdd-mcp/server.js +49 -36
- package/sdk/mcp/gdd-mcp/tools/shared.ts +20 -2
- package/sdk/mcp/gdd-state/server.js +107 -41
- package/sdk/primitives/lockfile.cjs +26 -5
- package/sdk/state/index.ts +91 -17
- package/sdk/state/lockfile.ts +47 -8
- package/skills/bandit-reset/SKILL.md +2 -0
- package/skills/bandit-status/SKILL.md +4 -1
- package/skills/darkmode/SKILL.md +1 -1
- package/skills/graphify/SKILL.md +6 -6
- package/skills/quick/SKILL.md +3 -1
- package/skills/reflect/SKILL.md +1 -1
- package/skills/router/SKILL.md +4 -2
|
@@ -57,17 +57,21 @@ function detectHarness() {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// ---------------------------------------------------------------------------
|
|
60
|
-
//
|
|
60
|
+
// Event emit (best-effort) — delegate to the shared _hook-emit helper, which
|
|
61
|
+
// uses the SDK writer when loadable (modern Node) and an inline JSONL appender
|
|
62
|
+
// otherwise. The previous direct `require('../sdk/event-stream')` resolved to
|
|
63
|
+
// the `.ts` ESM index and threw under plain `node` on Node 22.0–22.17, leaving
|
|
64
|
+
// recap.emitted permanently no-op'd. emitEvent lands the line on every Node.
|
|
61
65
|
// ---------------------------------------------------------------------------
|
|
62
66
|
|
|
63
|
-
function
|
|
67
|
+
function getEmitEvent() {
|
|
64
68
|
try {
|
|
65
|
-
const m = require('
|
|
66
|
-
if (m && typeof m.
|
|
69
|
+
const m = require('./_hook-emit.js');
|
|
70
|
+
if (m && typeof m.emitEvent === 'function') return m.emitEvent;
|
|
67
71
|
} catch {
|
|
68
|
-
/* swallow —
|
|
72
|
+
/* swallow — telemetry is optional infrastructure */
|
|
69
73
|
}
|
|
70
|
-
return function
|
|
74
|
+
return function noopEmit(_ev) {
|
|
71
75
|
/* no-op */
|
|
72
76
|
};
|
|
73
77
|
}
|
|
@@ -87,9 +91,12 @@ function readStateMd(paths) {
|
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
const frontmatter = {};
|
|
90
|
-
|
|
94
|
+
// Tolerate CRLF line endings — the STATE.md mutator preserves CRLF, so a
|
|
95
|
+
// strict `\n`-only anchor fails to match the frontmatter block on Windows
|
|
96
|
+
// checkouts and the recap silently reports an empty cycle/decisions diff.
|
|
97
|
+
const fmMatch = body.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
91
98
|
if (fmMatch) {
|
|
92
|
-
for (const line of fmMatch[1].split(
|
|
99
|
+
for (const line of fmMatch[1].split(/\r?\n/)) {
|
|
93
100
|
const m = line.match(/^(\w+):\s*(.+)$/);
|
|
94
101
|
if (m) frontmatter[m[1]] = m[2].trim();
|
|
95
102
|
}
|
|
@@ -273,9 +280,9 @@ async function main() {
|
|
|
273
280
|
}
|
|
274
281
|
|
|
275
282
|
// Best-effort event emit.
|
|
276
|
-
const
|
|
283
|
+
const emitEvent = getEmitEvent();
|
|
277
284
|
try {
|
|
278
|
-
|
|
285
|
+
emitEvent({
|
|
279
286
|
type: 'recap.emitted',
|
|
280
287
|
timestamp: new Date().toISOString(),
|
|
281
288
|
sessionId: process.env.GDD_SESSION_ID || 'sessionstart-hook',
|
|
@@ -300,9 +307,11 @@ async function main() {
|
|
|
300
307
|
process.exit(0);
|
|
301
308
|
}
|
|
302
309
|
|
|
303
|
-
try
|
|
304
|
-
|
|
305
|
-
|
|
310
|
+
// `main` is async: a sync try/catch cannot observe a rejected promise, so a
|
|
311
|
+
// throw inside an `await` boundary would escape as an unhandled rejection and
|
|
312
|
+
// exit non-zero — violating the silent-exit-0 contract for SessionStart hooks.
|
|
313
|
+
// Attach `.catch` so every failure mode is swallowed and we exit 0.
|
|
314
|
+
main().catch((err) => {
|
|
306
315
|
try {
|
|
307
316
|
process.stderr.write(
|
|
308
317
|
'[gdd-sessionstart-recap] uncaught: ' +
|
|
@@ -313,4 +322,4 @@ try {
|
|
|
313
322
|
/* swallow */
|
|
314
323
|
}
|
|
315
324
|
process.exit(0);
|
|
316
|
-
}
|
|
325
|
+
});
|
package/hooks/hooks.json
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
],
|
|
46
46
|
"PreToolUse": [
|
|
47
47
|
{
|
|
48
|
-
"matcher": "Agent",
|
|
48
|
+
"matcher": "Task|Agent",
|
|
49
49
|
"hooks": [
|
|
50
50
|
{
|
|
51
51
|
"type": "command",
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
]
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
|
-
"matcher": "Agent",
|
|
122
|
+
"matcher": "Task|Agent",
|
|
123
123
|
"hooks": [
|
|
124
124
|
{
|
|
125
125
|
"type": "command",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hegemonart/get-design-done",
|
|
3
|
-
"version": "1.59.
|
|
3
|
+
"version": "1.59.8",
|
|
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",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"engines": {
|
|
13
|
-
"node": ">=22"
|
|
13
|
+
"node": ">=22.6.0"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
".claude-plugin/",
|
|
@@ -10,7 +10,7 @@ description: Bandit posterior + production-integration shim cheat sheet - signat
|
|
|
10
10
|
|
|
11
11
|
**Phase 27.5 (v1.27.5).** Reference for the bandit production-integration surface. Authoring or modifying a caller of the bandit posterior? Debugging a routing decision at the code level? Start here.
|
|
12
12
|
|
|
13
|
-
For ops-level guidance (when bandit fires, how to disable, posterior inspection),
|
|
13
|
+
For ops-level guidance (when bandit fires, how to disable, posterior inspection), use the read-only diagnostic surfaces: `/gdd:bandit-status` (per-arm posterior snapshots) and `/gdd:bandit-reset` (confirm-then-reset). The `adaptive_mode` gate below covers enable/disable.
|
|
14
14
|
|
|
15
15
|
In-scope modules:
|
|
16
16
|
|
|
@@ -104,6 +104,17 @@ Phase 27.5 passes `wallTimeMs: 0` always (D-08 unchanged from Phase 23.5).
|
|
|
104
104
|
|
|
105
105
|
---
|
|
106
106
|
|
|
107
|
+
## Where adaptive routing actually learns
|
|
108
|
+
|
|
109
|
+
This is a deliberate design boundary, not a bug - read it before assuming the bandit "learns" in every runtime.
|
|
110
|
+
|
|
111
|
+
- **The posterior is updated only on the SDK / headless path.** `recordOutcome` (the learning update that moves `alpha`/`beta`) is called from `scripts/lib/session-runner/index.ts` after a session terminates. That path runs in the SDK / headless `session-runner` execution model. It is the only place a reward is folded back into the posterior.
|
|
112
|
+
- **In interactive Claude Code with `adaptive_mode: full`, the bandit samples but does not currently learn from in-session outcomes.** When a plugin/interactive run consults the bandit, `consultBandit` performs a Thompson sample from the *configured priors* (and whatever the SDK path has already written), and `pull()` bumps `last_used` + `count` - but no `recordOutcome` fires from an interactive Claude Code hook, so the success/fail posterior does not move within the interactive session. With an un-seeded posterior, sampling therefore reflects the informed `TIER_PRIOR` (which leans toward the higher tiers, e.g. opus). Wiring `recordOutcome` into an interactive hook is intentionally out of scope for this phase.
|
|
113
|
+
- **`adaptive_mode` defaults to `static` - the feature is opt-in.** Per `scripts/lib/adaptive-mode.cjs`, the default mode is `static`, in which the bandit is fully silent (no reads, no writes) and `default-tier:` is authoritative. Adaptive routing only engages when an operator explicitly sets `adaptive_mode: full` in `.design/budget.json`.
|
|
114
|
+
- **Contextual dimensions are supplied by the caller, not inferred here.** The `bin` (glob-count bucket via `binForGlobCount`) and `delegate` dimensions are passed in at the call site; the router does not derive them from ambient session state.
|
|
115
|
+
|
|
116
|
+
Net: enable `adaptive_mode: full` and run the SDK/headless `session-runner` path to accumulate a posterior that genuinely reflects observed outcomes. In interactive Claude Code, `full` mode gives you prior-driven Thompson sampling, not in-session reinforcement.
|
|
117
|
+
|
|
107
118
|
## `adaptive_mode` gate semantics
|
|
108
119
|
|
|
109
120
|
Phase 23.5 ladder (D-07):
|
|
@@ -154,7 +165,7 @@ Phase 27.5 wires these consumers:
|
|
|
154
165
|
|
|
155
166
|
## Cross-references
|
|
156
167
|
|
|
157
|
-
- `
|
|
168
|
+
- `/gdd:bandit-status` + `/gdd:bandit-reset` - read-only operator surfaces (when bandit fires, posterior inspection, reset). Disable/enable is the `adaptive_mode` gate in `.design/budget.json` (see above).
|
|
158
169
|
- `reference/peer-protocols.md` - Phase 27 ACP/ASP cheat sheet (peer-CLI delegation transport).
|
|
159
170
|
- `scripts/lib/bandit-router.cjs` - Phase 23.5 primitives surface.
|
|
160
171
|
- `scripts/lib/bandit-router/integration.cjs` - Phase 27.5 production shim.
|
package/scripts/bootstrap.cjs
CHANGED
|
@@ -148,6 +148,14 @@ function filesEqual(a, b) {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Network timeout (ms) for the git clone/pull. SessionStart hooks must never
|
|
153
|
+
* block the harness: without a timeout, a hung network connection would stall
|
|
154
|
+
* the whole session-start sequence indefinitely. spawnSync kills the child
|
|
155
|
+
* with `killSignal` once this elapses and reports it as a failure.
|
|
156
|
+
*/
|
|
157
|
+
const GIT_TIMEOUT_MS = 15000;
|
|
158
|
+
|
|
151
159
|
/**
|
|
152
160
|
* Match the .sh `clone_or_update`:
|
|
153
161
|
* - target/.git exists → `git -C target pull --quiet --ff-only`, log on fail
|
|
@@ -157,8 +165,14 @@ function filesEqual(a, b) {
|
|
|
157
165
|
* We invoke the `git` CLI directly via spawnSync. spawnSync('git', …) is fine —
|
|
158
166
|
* the prohibition is on spawnSync('bash', …).
|
|
159
167
|
*
|
|
168
|
+
* Returns true ONLY when the repo is in a good post-condition (pull/clone
|
|
169
|
+
* succeeded, or a pre-existing non-git dir we intentionally skip). Returns
|
|
170
|
+
* false when a network op failed or timed out — so the caller can withhold the
|
|
171
|
+
* success marker and retry next session instead of recording failure as done.
|
|
172
|
+
*
|
|
160
173
|
* @param {string} repoUrl
|
|
161
174
|
* @param {string} target
|
|
175
|
+
* @returns {boolean} success
|
|
162
176
|
*/
|
|
163
177
|
function cloneOrUpdate(repoUrl, target) {
|
|
164
178
|
let isGitCheckout = false;
|
|
@@ -177,16 +191,22 @@ function cloneOrUpdate(repoUrl, target) {
|
|
|
177
191
|
const r = spawnSync('git', ['-C', target, 'pull', '--quiet', '--ff-only'], {
|
|
178
192
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
179
193
|
windowsHide: true,
|
|
194
|
+
timeout: GIT_TIMEOUT_MS,
|
|
195
|
+
killSignal: 'SIGKILL',
|
|
180
196
|
});
|
|
181
197
|
if (r.error || r.status !== 0) {
|
|
182
|
-
|
|
198
|
+
const why = r.error && r.error.code === 'ETIMEDOUT' ? 'timed out' : 'failed';
|
|
199
|
+
log(`pull ${why} for ${target} (continuing)`);
|
|
200
|
+
return false;
|
|
183
201
|
}
|
|
184
|
-
return;
|
|
202
|
+
return true;
|
|
185
203
|
}
|
|
186
204
|
|
|
187
205
|
if (targetExists) {
|
|
188
206
|
log(`${target} exists and is not a git checkout — skipping`);
|
|
189
|
-
|
|
207
|
+
// A pre-existing non-git dir is a stable post-condition, not a failure:
|
|
208
|
+
// re-running won't change it, so don't force a retry every session.
|
|
209
|
+
return true;
|
|
190
210
|
}
|
|
191
211
|
|
|
192
212
|
// Defense in depth: refuse repoUrl / target arguments that look like git
|
|
@@ -196,7 +216,7 @@ function cloneOrUpdate(repoUrl, target) {
|
|
|
196
216
|
if (typeof repoUrl !== 'string' || repoUrl.startsWith('-') ||
|
|
197
217
|
typeof target !== 'string' || target.startsWith('-')) {
|
|
198
218
|
log(`refusing suspicious clone args for ${repoUrl} -> ${target}`);
|
|
199
|
-
return;
|
|
219
|
+
return false;
|
|
200
220
|
}
|
|
201
221
|
|
|
202
222
|
log(`cloning ${repoUrl} -> ${target}`);
|
|
@@ -205,10 +225,15 @@ function cloneOrUpdate(repoUrl, target) {
|
|
|
205
225
|
const r = spawnSync('git', ['clone', '--quiet', '--depth', '1', '--', repoUrl, target], {
|
|
206
226
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
207
227
|
windowsHide: true,
|
|
228
|
+
timeout: GIT_TIMEOUT_MS,
|
|
229
|
+
killSignal: 'SIGKILL',
|
|
208
230
|
});
|
|
209
231
|
if (r.error || r.status !== 0) {
|
|
210
|
-
|
|
232
|
+
const why = r.error && r.error.code === 'ETIMEDOUT' ? 'timed out' : 'failed';
|
|
233
|
+
log(`clone ${why} for ${repoUrl}`);
|
|
234
|
+
return false;
|
|
211
235
|
}
|
|
236
|
+
return true;
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
/**
|
|
@@ -315,7 +340,7 @@ function run(opts = {}) {
|
|
|
315
340
|
}
|
|
316
341
|
|
|
317
342
|
// Required library: VoltAgent/awesome-design-md.
|
|
318
|
-
cloneOrUpdate(
|
|
343
|
+
const repoOk = cloneOrUpdate(
|
|
319
344
|
'https://github.com/VoltAgent/awesome-design-md.git',
|
|
320
345
|
ctx.awesomeRepoTarget
|
|
321
346
|
);
|
|
@@ -332,8 +357,15 @@ function run(opts = {}) {
|
|
|
332
357
|
// Phase 10.1: .design/budget.json + .design/telemetry/ (D-12).
|
|
333
358
|
ensureDesignDir(cwd);
|
|
334
359
|
|
|
335
|
-
// Record success
|
|
336
|
-
|
|
360
|
+
// Record success ONLY when the network provisioning actually succeeded.
|
|
361
|
+
// Writing the marker unconditionally records a failed clone as "done" and
|
|
362
|
+
// never retries — leaving the required library permanently absent. Gating on
|
|
363
|
+
// repoOk means a transient network failure/timeout is retried next session.
|
|
364
|
+
if (repoOk) {
|
|
365
|
+
copyManifestToMarker(ctx.manifest, ctx.marker);
|
|
366
|
+
} else {
|
|
367
|
+
log('skipping success marker — provisioning incomplete, will retry next session');
|
|
368
|
+
}
|
|
337
369
|
|
|
338
370
|
return 0;
|
|
339
371
|
}
|
package/scripts/install.cjs
CHANGED
|
@@ -211,6 +211,28 @@ async function main() {
|
|
|
211
211
|
}
|
|
212
212
|
runtimes = picked.runtimes;
|
|
213
213
|
if (picked.location) location = picked.location;
|
|
214
|
+
} else if (uninstall) {
|
|
215
|
+
// B4 fix (Phase 59.8): bare `--uninstall` in a non-TTY context must NOT
|
|
216
|
+
// silently default to removing claude. The interactive path is the only
|
|
217
|
+
// safe way to pick what to remove without an explicit flag; in non-TTY
|
|
218
|
+
// we refuse and require an explicit runtime flag so a scripted/CI
|
|
219
|
+
// invocation can never destroy an install the operator didn't name.
|
|
220
|
+
// (See the comment at shouldUseInteractive: bare --uninstall is meant to
|
|
221
|
+
// trigger the interactive select-which-to-remove flow.)
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
[
|
|
224
|
+
'Refusing to uninstall: no runtime specified and not running in an',
|
|
225
|
+
'interactive terminal.',
|
|
226
|
+
'',
|
|
227
|
+
'Re-run with an explicit runtime flag, e.g.:',
|
|
228
|
+
' npx @hegemonart/get-design-done --uninstall --claude',
|
|
229
|
+
' npx @hegemonart/get-design-done --uninstall --all',
|
|
230
|
+
'',
|
|
231
|
+
'Run with --help to list available runtime flags.',
|
|
232
|
+
'',
|
|
233
|
+
].join('\n'),
|
|
234
|
+
);
|
|
235
|
+
process.exit(2);
|
|
214
236
|
} else {
|
|
215
237
|
// Non-TTY zero-flag fallback: back-compat with v1.23.5 behaviour.
|
|
216
238
|
runtimes = ['claude'];
|
|
@@ -359,7 +381,7 @@ async function maybeNudgePeerCli({ flags }) {
|
|
|
359
381
|
'✓ Detected peer CLIs: ' + detectedDisplay,
|
|
360
382
|
'',
|
|
361
383
|
'gdd v1.27.0 introduced optional peer-CLI delegation. With your',
|
|
362
|
-
'
|
|
384
|
+
"agents' frontmatter `delegate_to:` set, gdd can route specific",
|
|
363
385
|
'roles through these peer CLIs (cost or quality wins per Phase 23.5',
|
|
364
386
|
'bandit). You can change this anytime via .design/config.json.',
|
|
365
387
|
'',
|
|
@@ -38,7 +38,9 @@
|
|
|
38
38
|
* - The `prior_class` value is persisted on the arm so subsequent
|
|
39
39
|
* reads + decay calculations preserve it (forward-compat).
|
|
40
40
|
*
|
|
41
|
-
* Atomic .tmp + rename
|
|
41
|
+
* Atomic per-pid-unique .tmp + rename (Phase 59-8 C2: unique tmp name per
|
|
42
|
+
* process so parallel waves never interleave writes on one scratch file).
|
|
43
|
+
* Discounted Thompson via per-arm time-decay
|
|
42
44
|
* factor `rho^days_since_last_use` applied at sample time, not stored.
|
|
43
45
|
*
|
|
44
46
|
* Reward computation (D-06): two-stage lexicographic — UNCHANGED.
|
|
@@ -57,6 +59,17 @@ const path = require('node:path');
|
|
|
57
59
|
const DEFAULT_POSTERIOR_PATH = '.design/telemetry/posterior.json';
|
|
58
60
|
const SCHEMA_VERSION = '1.0.0';
|
|
59
61
|
|
|
62
|
+
// C2 fix (Phase 59-8): monotonic per-process counter for tmp-file naming.
|
|
63
|
+
// Combined with process.pid it guarantees that two concurrent writers — even
|
|
64
|
+
// within the same process, even firing in the same millisecond — never target
|
|
65
|
+
// the same `.tmp` path. The old fixed `p + '.tmp'` name let parallel agent
|
|
66
|
+
// waves interleave partial writes on one tmp file, producing truncated JSON
|
|
67
|
+
// that loadPosterior() then silently reset to an empty posterior (losing all
|
|
68
|
+
// learned arms). Unique tmp + atomic rename makes a half-written file
|
|
69
|
+
// invisible to readers: rename is atomic on the same filesystem, so a reader
|
|
70
|
+
// sees either the old complete file or the new complete file, never a partial.
|
|
71
|
+
let _tmpCounter = 0;
|
|
72
|
+
|
|
60
73
|
// Decay factor — 60-day half-life.
|
|
61
74
|
const DEFAULT_DECAY = 0.988;
|
|
62
75
|
|
|
@@ -136,6 +149,12 @@ function loadPosterior(opts = {}) {
|
|
|
136
149
|
}
|
|
137
150
|
return data;
|
|
138
151
|
} catch {
|
|
152
|
+
// Corrupt-JSON recovery (preserved, Phase 59-8 C2): fall back to an empty
|
|
153
|
+
// posterior. With the per-pid unique-tmp + atomic-rename write discipline
|
|
154
|
+
// (see savePosterior), a reader can no longer observe a half-written file
|
|
155
|
+
// — rename publishes the complete file in one step — so this branch should
|
|
156
|
+
// now only fire on genuine on-disk corruption (e.g. external truncation),
|
|
157
|
+
// not on a write/read race during a parallel agent wave.
|
|
139
158
|
return { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), arms: [] };
|
|
140
159
|
}
|
|
141
160
|
}
|
|
@@ -159,9 +178,19 @@ function savePosterior(posterior, opts = {}) {
|
|
|
159
178
|
const p = resolvePath(opts);
|
|
160
179
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
161
180
|
posterior.generated_at = new Date().toISOString();
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
181
|
+
// C2 fix (Phase 59-8): per-process-unique tmp name (pid + monotonic
|
|
182
|
+
// counter) so concurrent writers never collide on the same scratch file.
|
|
183
|
+
// The atomic rename then publishes the fully-written file in one step.
|
|
184
|
+
const tmp = `${p}.${process.pid}.${_tmpCounter++}.tmp`;
|
|
185
|
+
try {
|
|
186
|
+
fs.writeFileSync(tmp, JSON.stringify(posterior, null, 2));
|
|
187
|
+
fs.renameSync(tmp, p);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// Best-effort cleanup of the orphaned tmp on failure so a crashed
|
|
190
|
+
// write never leaves stale scratch files behind. ENOENT is fine.
|
|
191
|
+
try { fs.unlinkSync(tmp); } catch { /* already gone */ }
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
165
194
|
return p;
|
|
166
195
|
}
|
|
167
196
|
|
|
@@ -347,7 +376,20 @@ function decayArm(arm, opts = {}) {
|
|
|
347
376
|
const factor = Math.pow(decay, days);
|
|
348
377
|
// Decay shrinks both α and β toward the prior. We never go below the
|
|
349
378
|
// initial prior strength — caller can rebuild a fresh prior via reset().
|
|
350
|
-
|
|
379
|
+
//
|
|
380
|
+
// C1 fix (Phase 59-8): decay MUST target the SAME prior the arm was
|
|
381
|
+
// bootstrapped with. The arm persists `prior_class` (Phase 29 Plan 06 /
|
|
382
|
+
// D-04), so pass it through to priorFor — otherwise a promoted-incubator
|
|
383
|
+
// arm (Beta(2,8)) would drift back toward the informed TIER_PRIOR while
|
|
384
|
+
// idle, undoing the D-04 preferential-selection suppression. Default-class
|
|
385
|
+
// arms have no `prior_class` field, so `arm.prior_class` is undefined and
|
|
386
|
+
// priorFor falls through to the Phase 23.5 informed prior (byte-for-byte
|
|
387
|
+
// unchanged).
|
|
388
|
+
const { alpha: pa, beta: pb } = priorFor(
|
|
389
|
+
arm.tier,
|
|
390
|
+
opts.strength ?? PRIOR_STRENGTH,
|
|
391
|
+
arm.prior_class,
|
|
392
|
+
);
|
|
351
393
|
return {
|
|
352
394
|
alpha: pa + factor * Math.max(0, arm.alpha - pa),
|
|
353
395
|
beta: pb + factor * Math.max(0, arm.beta - pb),
|
|
@@ -45,13 +45,23 @@ function isUrl(p) {
|
|
|
45
45
|
return /^https?:\/\//i.test(String(p || ''));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
29
|
-
* The
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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:
|
|
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
|
-
//
|
|
452
|
-
// SKILL.md
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
//
|
|
590
|
-
//
|
|
591
|
-
// plugin-owned siblings so a now-empty dir can be trimmed
|
|
592
|
-
// user-authored siblings are left in place (foreign-file
|
|
593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|