@mengyuly/dsh-ponytail 0.1.2 → 0.1.3

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 (3) hide show
  1. package/README.md +17 -5
  2. package/lib/index.js +197 -45
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,10 @@ dsh plugin --profile web add link:$(pwd)
16
16
  dsh plugin --profile web add github:MengYuil/dsh-ponytail
17
17
 
18
18
  # 方式三:Release 打包件(先下 tgz)
19
- dsh plugin --profile web add file:./mengyuly-dsh-ponytail-0.1.2.tgz
19
+ dsh plugin --profile web add file:./mengyuly-dsh-ponytail-0.1.3.tgz
20
+
21
+ # 方式四:npm
22
+ dsh plugin --profile web add @mengyuly/dsh-ponytail
20
23
  ```
21
24
 
22
25
  装完重启 profile 生效(`dsh web` / `dsh tui`)。装载完成后,会话技能目录里会出现 6 个 `ponytail*` 技能,发 `/ponytail-help` 立即验证。
@@ -28,17 +31,21 @@ dsh plugin --profile web add file:./mengyuly-dsh-ponytail-0.1.2.tgz
28
31
  ## 功能
29
32
 
30
33
  - **核心模式** `/ponytail` — 每轮注入「懒惰阶梯」:能不做就不做(YAGNI)→ 代码库已有 → 标准库 → 平台原生 → 已装依赖 → 一行能解决 → 才是最少代码。
31
- - `full`(默认)/ `lite` / `ultra` / `off` 四档,会话级。
32
- - `/ponytail default <mode>` 持久化默认值。
34
+ - `full`(默认)/ `lite` / `ultra` / `off` 四档,**会话级**(会话 A 的档位不影响会话 B,会话结束自动释放)。
35
+ - `/ponytail`:已启用时只报告;会话为 `off` 时恢复到有效默认档(有效默认也是 `off` 则回 `full`)。
36
+ - `/ponytail status`:只查询、永不修改。
37
+ - `/ponytail lite|full|ultra|off`:显式切换。
38
+ - `/ponytail default <mode>`:持久化默认值到配置文件(环境变量仍优先)。
33
39
  - **一次性技能**(用哪个载哪个,不进常驻 prompt):
34
40
  - `/ponytail-review` — 针对最近改动找过度工程,一行一条:位置 + 删什么 + 替代。
35
41
  - `/ponytail-audit` — 全仓库过度工程审计,排序清单。
36
42
  - `/ponytail-debt` — 收割所有 `ponytail:` 注释成债务账本。
37
43
  - `/ponytail-gain` — 收益计分板(更少代码/更省成本/更快)。
38
44
  - `/ponytail-help` — 参考卡。
39
- - **停用**:说 `stop ponytail` 或 `normal mode`;随时 `/ponytail` 恢复。
40
- - **默认值**:环境变量 `PONYTAIL_DEFAULT_MODE` > `~/.config/ponytail/config.json` `{"defaultMode": "lite"}` > `full`。
45
+ - **停用**:说 `stop ponytail` 或 `normal mode`(兼容中英文句末标点);随时 `/ponytail` 恢复。
46
+ - **默认值**:环境变量 `PONYTAIL_DEFAULT_MODE` > `~/.config/ponytail/config.json`(Windows:`%APPDATA%\ponytail\config.json`)的 `{"defaultMode": "lite"}` > `full`。`/ponytail default` 写入的是配置文件,**环境变量设置且合法时仍压过保存值**(命令会分别提示 saved 与 effective)。
41
47
  - **子代理**:常驻段作用于当前会话自身;DSH 内置 `subagent` 工具跑的是隔离的全新子代理、不继承本 persona。`PONYTAIL_SUBAGENT_MATCHER`(匹配子代理 `agentPreset` 的正则)用于在 harness 会下发给子代理的场景里排除指定子代理;缺省全部注入。
48
+ - **配置错误**:非法 JSON / 非法 `defaultMode` / 读取失败 / 非法正则只告警一次(不刷屏);配置文件不存在属正常、不告警;热更新遇到临时非法内容保留上一个合法默认值。
42
49
 
43
50
  ## 效率
44
51
 
@@ -53,6 +60,11 @@ dsh plugin --profile web add file:./mengyuly-dsh-ponytail-0.1.2.tgz
53
60
  - 配置文件的默认档位热更新(fs 轮询 ~1s,作用于无覆盖的会话);环境变量改动仍需重启。
54
61
  - 发行 `lib/` 是预编译产物;改源码请回主仓重建后同步。
55
62
 
63
+ ## 测试环境与权威关系
64
+
65
+ - 用 Node.js **v24.16.0** 在本机 deepseek-harness checkout 上测试(`tsc -b` + `tsdown` host 构建通过)。与之精确匹配的已发布 DSH/Cordis 版本**待确认**——checkout 是预发布工作树,非发布 tag。
66
+ - 权威源码在 deepseek-harness monorepo 的 `packages/community/ponytail`(`@deepseek-ai/dsh-ponytail`);本仓库(`@mengyuly/dsh-ponytail`)是**发行镜像**:随包附构建产物,`scripts/build.sh` 只做源码类型检查,不是独立真源。
67
+
56
68
  ## 许可
57
69
 
58
70
  MIT,© 2026 DietrichGebert(上游)+ MengYuil(移植)。详见 [LICENSE](LICENSE)。
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Service } from "@deepseek-ai/cordis";
3
- import { mkdirSync, readFileSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
3
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  //#region ../../llm/llm/src/brand.ts
@@ -2007,7 +2007,9 @@ You can also load any of these with the \`skill\` tool.
2007
2007
 
2008
2008
  ## Deactivate
2009
2009
 
2010
- Say "stop ponytail" or "normal mode". Resume anytime with \`/ponytail\`.
2010
+ Say "stop ponytail" or "normal mode". Resume anytime with \`/ponytail\` —
2011
+ it re-enables at the effective default (or \`full\` when that is off too).
2012
+ \`/ponytail status\` only shows the current level, never changes it.
2011
2013
  \`/ponytail off\` also works. Level is session-scoped; a new session starts
2012
2014
  from the configured default.
2013
2015
 
@@ -2027,7 +2029,8 @@ export PONYTAIL_DEFAULT_MODE=ultra
2027
2029
 
2028
2030
  Set \`"off"\` to disable auto-activation on session start, activate manually
2029
2031
  with \`/ponytail\` when wanted. \`/ponytail default <mode>\` persists a new
2030
- default from inside a session.
2032
+ default to the config file; an exported \`PONYTAIL_DEFAULT_MODE\` still
2033
+ outranks the saved value for new sessions.
2031
2034
 
2032
2035
  Resolution: env var > config file > \`full\`.
2033
2036
 
@@ -2140,7 +2143,7 @@ function normalizeRuntimeMode(mode) {
2140
2143
  * toggle".
2141
2144
  */
2142
2145
  function isDeactivationCommand(text) {
2143
- const normalized = (typeof text === "string" ? text : "").trim().toLowerCase().replace(/[.!?\s]+$/, "");
2146
+ const normalized = (typeof text === "string" ? text : "").trim().toLowerCase().replace(/[\s.!?。?!]+$/, "");
2144
2147
  return normalized === "stop ponytail" || normalized === "normal mode";
2145
2148
  }
2146
2149
  /** Config directory: `$XDG_CONFIG_HOME/ponytail`, `%APPDATA%\ponytail`, else `~/.config/ponytail`. */
@@ -2154,38 +2157,70 @@ function configPath(env = process.env) {
2154
2157
  return join(configDir(env), "config.json");
2155
2158
  }
2156
2159
  /**
2157
- * Resolve a default from the environment value, then a parsed config document,
2158
- * then {@link DEFAULT_MODE}. Pure so callers can supply fixtures.
2160
+ * Read the configured default with diagnostics: environment variable first,
2161
+ * then the config file, then `full`. A missing config file is normal and
2162
+ * yields no issue; a broken one yields the fallback mode plus one issue for
2163
+ * the caller to warn about once.
2159
2164
  */
2160
- function resolveDefaultMode(envMode, configText) {
2161
- const fromEnv = normalizeRuntimeMode(envMode);
2162
- if (fromEnv) return fromEnv;
2163
- if (configText !== void 0) try {
2164
- const config = JSON.parse(stripBom(configText));
2165
- if (config && typeof config === "object" && !Array.isArray(config)) {
2166
- const fromConfig = normalizeRuntimeMode(config.defaultMode);
2167
- if (fromConfig) return fromConfig;
2165
+ function readDefaultModeInfo(env = process.env) {
2166
+ const path = configPath(env);
2167
+ const envMode = normalizeRuntimeMode(env.PONYTAIL_DEFAULT_MODE);
2168
+ let configText;
2169
+ try {
2170
+ configText = readFileSync(path, "utf8");
2171
+ } catch (error) {
2172
+ if (error.code === "ENOENT") return { mode: envMode ?? "full" };
2173
+ return {
2174
+ mode: envMode ?? "full",
2175
+ issue: {
2176
+ kind: "read",
2177
+ detail: `${path}: ${error.message}`
2178
+ }
2179
+ };
2180
+ }
2181
+ let configIssue;
2182
+ let fromConfig = null;
2183
+ try {
2184
+ const parsed = JSON.parse(stripBom(configText));
2185
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) configIssue = {
2186
+ kind: "shape",
2187
+ detail: `${path}: root must be a JSON object`
2188
+ };
2189
+ else if ("defaultMode" in parsed) {
2190
+ fromConfig = normalizeRuntimeMode(parsed.defaultMode);
2191
+ if (!fromConfig) configIssue = {
2192
+ kind: "value",
2193
+ detail: `${path}: defaultMode is not lite|full|ultra|off`
2194
+ };
2168
2195
  }
2169
- } catch {}
2170
- return DEFAULT_MODE;
2196
+ } catch (error) {
2197
+ configIssue = {
2198
+ kind: "json",
2199
+ detail: `${path}: ${error.message}`
2200
+ };
2201
+ }
2202
+ if (envMode) return {
2203
+ mode: envMode,
2204
+ ...configIssue ? { issue: configIssue } : {}
2205
+ };
2206
+ if (configIssue) return {
2207
+ mode: DEFAULT_MODE,
2208
+ issue: configIssue
2209
+ };
2210
+ return { mode: fromConfig ?? "full" };
2171
2211
  }
2172
2212
  /**
2173
2213
  * Read the configured default for this host: environment variable first, then
2174
2214
  * the config file, then `full`.
2175
2215
  */
2176
2216
  function readDefaultMode(env = process.env) {
2177
- const path = configPath(env);
2178
- let configText;
2179
- try {
2180
- configText = readFileSync(path, "utf8");
2181
- } catch {
2182
- configText = void 0;
2183
- }
2184
- return resolveDefaultMode(env.PONYTAIL_DEFAULT_MODE, configText);
2217
+ return readDefaultModeInfo(env).mode;
2185
2218
  }
2186
2219
  /**
2187
2220
  * Persist a new default level to the config file, preserving other fields.
2188
2221
  * Returns the normalized mode, or `null` when the value is not a runtime mode.
2222
+ * Throws when the write itself fails, so callers never report success for a
2223
+ * file that was not written.
2189
2224
  */
2190
2225
  function writeDefaultMode(mode, env = process.env) {
2191
2226
  const normalized = normalizeRuntimeMode(mode);
@@ -2197,8 +2232,18 @@ function writeDefaultMode(mode, env = process.env) {
2197
2232
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) config = parsed;
2198
2233
  } catch {}
2199
2234
  config.defaultMode = normalized;
2235
+ const text = `${JSON.stringify(config, null, 2)}\n`;
2200
2236
  mkdirSync(dirname(path), { recursive: true });
2201
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
2237
+ const temp = join(dirname(path), `.config-${process.pid}-${Date.now()}.tmp`);
2238
+ try {
2239
+ writeFileSync(temp, text, "utf8");
2240
+ renameSync(temp, path);
2241
+ } catch (error) {
2242
+ try {
2243
+ unlinkSync(temp);
2244
+ } catch {}
2245
+ throw new Error(`failed to write ${path}: ${error.message}`);
2246
+ }
2202
2247
  return normalized;
2203
2248
  }
2204
2249
  /**
@@ -2222,18 +2267,37 @@ var ModeStore = class {
2222
2267
  }
2223
2268
  };
2224
2269
  /**
2225
- * Compile `PONYTAIL_SUBAGENT_MATCHER` into a case-insensitive regex, or `null`
2226
- * for "no matcher" and for invalid patterns, both of which mean the ruleset
2227
- * applies to every agent (fail open, like upstream).
2270
+ * Compile `PONYTAIL_SUBAGENT_MATCHER` into a case-insensitive regex. An unset
2271
+ * matcher yields `null`; an invalid pattern stays fail-open (every agent gets
2272
+ * the ruleset) but is reported so the caller can warn exactly once.
2228
2273
  */
2229
2274
  function compileSubagentMatcher(raw) {
2230
- if (!raw) return null;
2275
+ if (!raw) return {
2276
+ matcher: null,
2277
+ invalid: false
2278
+ };
2231
2279
  try {
2232
- return new RegExp(raw, "i");
2280
+ return {
2281
+ matcher: new RegExp(raw, "i"),
2282
+ invalid: false
2283
+ };
2233
2284
  } catch {
2234
- return null;
2285
+ return {
2286
+ matcher: null,
2287
+ invalid: true
2288
+ };
2235
2289
  }
2236
2290
  }
2291
+ /**
2292
+ * The stable per-session identity backing every mode override. DSH's `Agent`
2293
+ * type documents `id` as "the single identity shared with session", so the
2294
+ * agent id IS the SessionId: one entry per live session, never shared between
2295
+ * two sessions, and stable across the session's lifetime. Centralized so the
2296
+ * key choice lives in exactly one place.
2297
+ */
2298
+ function sessionKey(agent) {
2299
+ return agent.id;
2300
+ }
2237
2301
  /** Whether a session is a subagent child (origin, or any delegation depth with no origin). */
2238
2302
  function isSubagentSession(header) {
2239
2303
  return header.origin === "subagent" || (header.delegationDepth ?? 0) > 0;
@@ -2331,7 +2395,7 @@ function containsDeactivation(messages) {
2331
2395
  }
2332
2396
  /** Mode visible to one agent: its session override, else the configured default. */
2333
2397
  function modeFor(deps, agent) {
2334
- return deps.store.modeFor(String(agent.id), deps.defaultMode());
2398
+ return deps.store.modeFor(sessionKey(agent), deps.defaultMode());
2335
2399
  }
2336
2400
  /**
2337
2401
  * Queue one skill's full `<skill_content>` rendering as the model's next
@@ -2369,21 +2433,46 @@ function registerCommands(deps, commandCtx) {
2369
2433
  commandCtx.commands.register({
2370
2434
  name: "ponytail",
2371
2435
  description: "Set or show Ponytail lazy senior dev intensity",
2372
- input: { hint: "[lite|full|ultra|off|default <mode>]" },
2436
+ input: { hint: "[status|default <mode>|lite|full|ultra|off]" },
2373
2437
  handler: ({ agent, rawInput }) => {
2374
2438
  const input = rawInput.trim().toLowerCase();
2375
2439
  const [head, ...rest] = input.split(/\s+/).filter(Boolean);
2376
2440
  if ((head ?? "") === "default") {
2377
- const written = writeDefaultMode(rest[0]);
2441
+ let written;
2442
+ try {
2443
+ written = writeDefaultMode(rest[0]);
2444
+ } catch (error) {
2445
+ return {
2446
+ kind: "error",
2447
+ text: `Failed to save default: ${error.message}`
2448
+ };
2449
+ }
2378
2450
  if (!written) return {
2379
2451
  kind: "error",
2380
2452
  text: "Usage: /ponytail default [lite|full|ultra|off]"
2381
2453
  };
2382
- deps.setDefault(written);
2454
+ const effective = readDefaultMode();
2455
+ deps.setDefault(effective);
2456
+ if (written === effective) {
2457
+ agent.steer(createUserMessage({
2458
+ content: [{
2459
+ type: "text",
2460
+ text: `PONYTAIL DEFAULT SET — new sessions start in ${written}.`
2461
+ }],
2462
+ source: {
2463
+ kind: "plugin",
2464
+ plugin: name
2465
+ }
2466
+ }));
2467
+ return {
2468
+ kind: "success",
2469
+ text: `Ponytail default set — new sessions start in ${written}.`
2470
+ };
2471
+ }
2383
2472
  agent.steer(createUserMessage({
2384
2473
  content: [{
2385
2474
  type: "text",
2386
- text: `PONYTAIL DEFAULT SET — new sessions start in ${written}.`
2475
+ text: `PONYTAIL DEFAULT SET — saved ${written}, effective ${effective} (PONYTAIL_DEFAULT_MODE).`
2387
2476
  }],
2388
2477
  source: {
2389
2478
  kind: "plugin",
@@ -2392,11 +2481,50 @@ function registerCommands(deps, commandCtx) {
2392
2481
  }));
2393
2482
  return {
2394
2483
  kind: "success",
2395
- text: `Ponyytail default set new sessions start in ${written}.`
2484
+ text: `Saved default: ${written}. Effective default: ${effective}, overridden by PONYTAIL_DEFAULT_MODE.`
2396
2485
  };
2397
2486
  }
2487
+ if (input === "status") return {
2488
+ kind: "success",
2489
+ text: `Ponytail mode: ${modeFor(deps, agent)}. Use /ponytail lite|full|ultra|off.`
2490
+ };
2398
2491
  if (input === "") {
2399
2492
  const current = modeFor(deps, agent);
2493
+ const effectiveDefault = deps.defaultMode();
2494
+ if (current === "off") {
2495
+ if (effectiveDefault === "off") {
2496
+ deps.store.set(sessionKey(agent), "full");
2497
+ agent.steer(createUserMessage({
2498
+ content: [{
2499
+ type: "text",
2500
+ text: "PONYTAIL MODE CHANGED — level: full"
2501
+ }],
2502
+ source: {
2503
+ kind: "plugin",
2504
+ plugin: name
2505
+ }
2506
+ }));
2507
+ return {
2508
+ kind: "success",
2509
+ text: "Ponytail re-enabled at full (the effective default is off)."
2510
+ };
2511
+ }
2512
+ deps.store.clear(sessionKey(agent));
2513
+ agent.steer(createUserMessage({
2514
+ content: [{
2515
+ type: "text",
2516
+ text: `PONYTAIL MODE ACTIVE — level: ${effectiveDefault}`
2517
+ }],
2518
+ source: {
2519
+ kind: "plugin",
2520
+ plugin: name
2521
+ }
2522
+ }));
2523
+ return {
2524
+ kind: "success",
2525
+ text: `Ponytail re-enabled. Effective default: ${effectiveDefault}.`
2526
+ };
2527
+ }
2400
2528
  agent.steer(createUserMessage({
2401
2529
  content: [{
2402
2530
  type: "text",
@@ -2415,9 +2543,9 @@ function registerCommands(deps, commandCtx) {
2415
2543
  const mode = normalizeRuntimeMode(input);
2416
2544
  if (!mode) return {
2417
2545
  kind: "error",
2418
- text: "Usage: /ponytail [lite|full|ultra|off]"
2546
+ text: "Usage: /ponytail [status|default <mode>|lite|full|ultra|off]"
2419
2547
  };
2420
- deps.store.set(String(agent.id), mode);
2548
+ deps.store.set(sessionKey(agent), mode);
2421
2549
  agent.steer(createUserMessage({
2422
2550
  content: [{
2423
2551
  type: "text",
@@ -2464,18 +2592,42 @@ function descriptionFor(skill) {
2464
2592
  */
2465
2593
  function apply(ctx) {
2466
2594
  let defaultMode = null;
2467
- const readDefault = () => defaultMode ??= readDefaultMode();
2595
+ const warned = /* @__PURE__ */ new Set();
2596
+ const warnOnce = (key, message) => {
2597
+ if (warned.has(key)) return;
2598
+ warned.add(key);
2599
+ ctx.logger.warn(`[ponytail] ${message}`);
2600
+ };
2601
+ const refreshDefault = () => {
2602
+ const resolution = readDefaultModeInfo();
2603
+ if (resolution.issue) warnOnce(`default:${resolution.issue.kind}`, `${resolution.issue.detail}; using ${resolution.mode}`);
2604
+ defaultMode = resolution.mode;
2605
+ return defaultMode;
2606
+ };
2607
+ const readDefault = () => defaultMode ?? refreshDefault();
2468
2608
  const setDefault = (mode) => {
2469
2609
  defaultMode = mode;
2470
2610
  };
2471
2611
  const store = new ModeStore();
2472
- const matcher = compileSubagentMatcher(process.env.PONYTAIL_SUBAGENT_MATCHER);
2612
+ const matcherResult = compileSubagentMatcher(process.env.PONYTAIL_SUBAGENT_MATCHER);
2613
+ const matcher = matcherResult.matcher;
2614
+ if (matcherResult.invalid) warnOnce("matcher:invalid", "PONYTAIL_SUBAGENT_MATCHER is not a valid regular expression; ignoring it (fail-open).");
2473
2615
  const configFile = configPath();
2474
2616
  const onConfigChange = () => {
2475
- defaultMode = readDefaultMode();
2617
+ const resolution = readDefaultModeInfo();
2618
+ if (resolution.issue) {
2619
+ warnOnce(`config:${resolution.issue.kind}`, `${resolution.issue.detail}; keeping the previous default`);
2620
+ return;
2621
+ }
2622
+ defaultMode = resolution.mode;
2476
2623
  };
2477
2624
  watchFile(configFile, { interval: 1e3 }, onConfigChange).unref();
2478
- ctx.effect(() => () => unwatchFile(configFile, onConfigChange), "ponytail: config hot reload");
2625
+ ctx.effect(() => () => {
2626
+ unwatchFile(configFile, onConfigChange);
2627
+ }, "ponytail: config hot reload");
2628
+ ctx.on("agent/disposed", ({ agent }) => {
2629
+ store.clear(sessionKey(agent));
2630
+ });
2479
2631
  ctx.systemPrompt.section({
2480
2632
  name: "ponytail",
2481
2633
  order: SECTION_ORDER,
@@ -2484,7 +2636,7 @@ function apply(ctx) {
2484
2636
  const preset = agent.session.header.agentPreset;
2485
2637
  if (preset && !matcher.test(preset)) return "";
2486
2638
  }
2487
- return getPonytailInstructions(agent ? store.modeFor(String(agent.id), readDefault()) : readDefault());
2639
+ return getPonytailInstructions(agent ? store.modeFor(sessionKey(agent), readDefault()) : readDefault());
2488
2640
  }
2489
2641
  });
2490
2642
  for (const skill of ponytailSkills()) ctx.skills.register(skill);
@@ -2498,7 +2650,7 @@ function apply(ctx) {
2498
2650
  });
2499
2651
  ctx.on("agent/pre-step", async (payload, next) => {
2500
2652
  const deactivated = containsDeactivation(payload.messages);
2501
- if (deactivated) store.set(String(payload.agent.id), "off");
2653
+ if (deactivated) store.set(sessionKey(payload.agent), "off");
2502
2654
  const decision = await next();
2503
2655
  if (deactivated && decision.kind === "enter") return {
2504
2656
  kind: "enter",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mengyuly/dsh-ponytail",
3
3
  "description": "Lazy senior dev mode for DeepSeek Harness: always-on minimal-code ruleset, intensity switching, and short review/audit/debt/gain/help skills",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },