diffing 0.18.2 → 0.18.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.
@@ -1,6 +1,6 @@
1
1
  import { f as getProjectStorageDir, h as getRepoRoot } from "./git-BGj31GX-.mjs";
2
2
  import { i as loadSettings, n as formatModeLabel, s as saveSettings } from "./settings-DIa-3C8o.mjs";
3
- import { f as slugifyScreenId, r as formatPlanReview, t as formatMockupReview } from "./mockup-format-Dzc3e_10.mjs";
3
+ import { a as formatPlanReview, m as slugifyScreenId, r as formatMockupReview, t as formatSubmitHints } from "./mockup-lint-BRjhxXSw.mjs";
4
4
  import { t as formatComments } from "./comment-format-DS1wOWj_.mjs";
5
5
  import { u as resolveActiveServerLock, x as SESSION_TOKEN_HEADER } from "./server-lock-ACw2cKDI.mjs";
6
6
  import { i as reviewSessionUrl, t as appendSessionToken } from "./session-url-CyBpcLAH.mjs";
@@ -1146,6 +1146,13 @@ function warnMockupPathsInRepo(paths) {
1146
1146
  if (abs === root || abs.startsWith(rootPrefix)) console.error(`Warning: ${abs} is inside the consumer repo. Never write diffing mockups into the working tree. Submit HTML via stdin or MCP submit_mockup({ html }), or stage files under ${storage}/mockup-sources/.`);
1147
1147
  }
1148
1148
  }
1149
+ function printMockupHints(payload) {
1150
+ if (!payload || typeof payload !== "object") return;
1151
+ const hints = payload.hints;
1152
+ if (!hints?.length) return;
1153
+ const text = formatSubmitHints(hints).trim();
1154
+ if (text) console.error(text);
1155
+ }
1149
1156
  async function mockupSubmit(args) {
1150
1157
  const { values, positionals } = parseArgs({
1151
1158
  args,
@@ -1218,6 +1225,7 @@ async function mockupSubmit(args) {
1218
1225
  const mockup = await submitRes.json();
1219
1226
  console.error(`Submitted mockup ${mockup.id} (v${mockup.version}) — review at ${appendSessionToken(`${base}/mockup/${mockup.id}`, activeAuthToken)}`);
1220
1227
  if (mockup.sourcePath) console.error(`Source path: ${mockup.sourcePath}`);
1228
+ printMockupHints(mockup);
1221
1229
  if (!values.wait) {
1222
1230
  console.error(CLI_MOCKUP_SUBMIT_PARK_HINT);
1223
1231
  process.stdout.write(mockup.id + "\n");
@@ -1579,6 +1587,7 @@ One-screen revision. Version bumps on success; an --expected-version guard abort
1579
1587
  }
1580
1588
  const mockup = await res.json();
1581
1589
  console.error(`Upserted screen ${screenId} on ${mockupId} → v${mockup.version} (${mockup.screens.length} screens).`);
1590
+ printMockupHints(mockup);
1582
1591
  return EXIT_OK;
1583
1592
  }
1584
1593
  if (action === "remove") {
@@ -1616,6 +1625,7 @@ One-screen revision. Version bumps on success; an --expected-version guard abort
1616
1625
  }
1617
1626
  const data = await res.json();
1618
1627
  console.error(`Replaced region "${region}" on ${screenId} (${data.occurrences} match(es)) → v${data.mockup.version}.`);
1628
+ printMockupHints(data.mockup);
1619
1629
  return EXIT_OK;
1620
1630
  }
1621
1631
  const text = values.text;
@@ -1640,6 +1650,7 @@ One-screen revision. Version bumps on success; an --expected-version guard abort
1640
1650
  }
1641
1651
  const data = await res.json();
1642
1652
  console.error(`Patched ${screenId} on ${mockupId} (${data.occurrences} exact match(es)) → v${data.mockup.version}.`);
1653
+ printMockupHints(data.mockup);
1643
1654
  return EXIT_OK;
1644
1655
  }
1645
1656
  async function mockupThreadsCmd(args) {
@@ -1792,6 +1803,7 @@ Staging files, if needed, go under ~/.diffing/<repo>/mockup-sources/ only.
1792
1803
  One state per screen: never tabs/accordions/toggles/modals/JS content-swapping — each variant is a separate screen.
1793
1804
 
1794
1805
  submit [-] [--title T] [--screen id=path]... [--id ID] [--model M] [--wait]
1806
+ [--mode fragment|document] [--system ID] [--plan-id ID]
1795
1807
  # or a path already under ~/.diffing/.../mockup-sources/
1796
1808
  await [--timeout N]
1797
1809
  list [--json]
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { c as parseDiffOptions, i as validateEnvironment, l as printHelp, o as buildTuiGitDiffArgs, r as runTerminalDiff, s as intoShowMode, t as startServer } from "./server-BqCIWbuc.mjs";
2
+ import { a as buildTuiGitDiffArgs, c as printHelp, n as runTerminalDiff, o as intoShowMode, r as validateEnvironment, s as parseDiffOptions, t as startServer } from "./server-CH-UPGFR.mjs";
3
3
  import { h as getRepoRoot, r as getBranchName } from "./git-BGj31GX-.mjs";
4
4
  import { i as loadSettings } from "./settings-DIa-3C8o.mjs";
5
5
  import { _ as generateSessionToken, b as isWildcardBindHost, c as removeServerLockIfOwned, p as writeServerLock, r as diffScopeKey, t as acquireServerStartupLease, u as resolveActiveServerLock } from "./server-lock-ACw2cKDI.mjs";
@@ -563,11 +563,11 @@ See docs/cli.md §5 (MCP) for the full tool table.`);
563
563
  console.error("diffing mcp: --repo must be an absolute path");
564
564
  process.exit(5);
565
565
  }
566
- const { startMcpServer } = await import("./mcp-ClEsxza5.mjs");
566
+ const { startMcpServer } = await import("./mcp-45VPdWIr.mjs");
567
567
  await startMcpServer({ repoPath });
568
568
  await new Promise(() => {});
569
569
  }
570
- const { runSubcommand } = await import("./cli-agent-Cpusc3Fl.mjs");
570
+ const { runSubcommand } = await import("./cli-agent-DV5ZhMX1.mjs");
571
571
  const code = await runSubcommand(args[0], args.slice(1));
572
572
  process.exit(code);
573
573
  }
@@ -1,5 +1,5 @@
1
- import { a as buildGitDiffArgs, c as parseDiffOptions, n as formatSubmitHints, t as startServer } from "./server-BqCIWbuc.mjs";
2
- import { r as formatPlanReview, t as formatMockupReview } from "./mockup-format-Dzc3e_10.mjs";
1
+ import { i as buildGitDiffArgs, s as parseDiffOptions, t as startServer } from "./server-CH-UPGFR.mjs";
2
+ import { a as formatPlanReview, r as formatMockupReview, t as formatSubmitHints } from "./mockup-lint-BRjhxXSw.mjs";
3
3
  import { t as formatComments } from "./comment-format-DS1wOWj_.mjs";
4
4
  import { _ as generateSessionToken, c as removeServerLockIfOwned, i as isLockAlive, p as writeServerLock, r as diffScopeKey, s as removeServerLock, t as acquireServerStartupLease, u as resolveActiveServerLock, x as SESSION_TOKEN_HEADER, y as isLoopbackHost } from "./server-lock-ACw2cKDI.mjs";
5
5
  import { i as reviewSessionUrl, n as joinSessionApiUrl, r as reviewSessionBaseUrl, t as appendSessionToken } from "./session-url-CyBpcLAH.mjs";
@@ -326,4 +326,155 @@ function formatComment(comment, fallbackVersion, includeContext) {
326
326
  return parts.join("\n");
327
327
  }
328
328
  //#endregion
329
- export { MOCKUP_MAX_SCREEN_BYTES as a, defaultScreenLabel as c, normalizeSubmitScreens as d, slugifyScreenId as f, sectionTitleForLine as i, isMockupViewport as l, extractPlanLines as n, MOCKUP_SCREEN_ID_RE as o, formatPlanReview as r, commentViewport as s, formatMockupReview as t, normalizeMockupViewport as u };
329
+ //#region src/lib/mockup-lint.ts
330
+ const RULES = [
331
+ {
332
+ label: "tabs",
333
+ re: /role\s*=\s*["']tab(list|panel)?["']/i
334
+ },
335
+ {
336
+ label: "tabs",
337
+ re: /aria-selected\s*=/i
338
+ },
339
+ {
340
+ label: "tabs",
341
+ re: /data-(?:bs-)?toggle\s*=\s*["'](?:tab|pill)["']/i
342
+ },
343
+ {
344
+ label: "tabs",
345
+ re: /\bdata-tabs?\s*=/i
346
+ },
347
+ {
348
+ label: "accordion",
349
+ re: /<details[\s>]/i
350
+ },
351
+ {
352
+ label: "accordion",
353
+ re: /data-(?:bs-)?toggle\s*=\s*["']collapse["']/i
354
+ },
355
+ {
356
+ label: "accordion",
357
+ re: /class\s*=\s*["'][^"']*\baccordion\b/i
358
+ },
359
+ {
360
+ label: "modal",
361
+ re: /role\s*=\s*["'](?:alert)?dialog["']/i
362
+ },
363
+ {
364
+ label: "modal",
365
+ re: /data-(?:bs-)?toggle\s*=\s*["']modal["']/i
366
+ },
367
+ {
368
+ label: "modal",
369
+ re: /class\s*=\s*["'][^"']*\bmodal\b/i
370
+ },
371
+ {
372
+ label: "dropdown",
373
+ re: /role\s*=\s*["']menu(?:bar)?["']/i
374
+ },
375
+ {
376
+ label: "dropdown",
377
+ re: /data-(?:bs-)?toggle\s*=\s*["']dropdown["']/i
378
+ },
379
+ {
380
+ label: "dropdown",
381
+ re: /class\s*=\s*["'][^"']*\bdropdown\b/i
382
+ },
383
+ {
384
+ label: "toggle",
385
+ re: /role\s*=\s*["']switch["']/i
386
+ },
387
+ {
388
+ label: "toggle",
389
+ re: /class\s*=\s*["'][^"']*\b(?:toggle|switch)\b/i
390
+ }
391
+ ];
392
+ const ORDERED_LABELS = [
393
+ "tabs",
394
+ "accordion",
395
+ "modal",
396
+ "dropdown",
397
+ "toggle"
398
+ ];
399
+ /** Find the in-page state UI patterns present in one screen's HTML. */
400
+ function detectInPageState(html) {
401
+ const found = /* @__PURE__ */ new Set();
402
+ for (const rule of RULES) {
403
+ if (found.has(rule.label)) continue;
404
+ if (rule.re.test(html)) found.add(rule.label);
405
+ }
406
+ return ORDERED_LABELS.filter((label) => found.has(label));
407
+ }
408
+ const STYLE_RULES = [
409
+ {
410
+ label: "tailwind-cdn",
411
+ re: /cdn\.tailwindcss\.com/i
412
+ },
413
+ {
414
+ label: "tailwind-cdn",
415
+ re: /(?:unpkg\.com|cdn\.jsdelivr\.net|esm\.sh)\/(?:npm\/)?tailwindcss/i
416
+ },
417
+ {
418
+ label: "google-fonts",
419
+ re: /fonts\.googleapis\.com/i
420
+ },
421
+ {
422
+ label: "google-fonts",
423
+ re: /fonts\.gstatic\.com/i
424
+ }
425
+ ];
426
+ const GENERIC_FONT_RE = /font-family\s*:\s*[^;{]*\bInter\b/i;
427
+ const GENERIC_INDIGO_RE = /#(?:4f46e5|4338ca|6366f1|818cf8|7c3aed|8b5cf6|a78bfa)\b|\b(?:indigo|violet)-(?:[4-9]00|950)\b/i;
428
+ /** Off-brand / generic-AI styling that should not ship as the product look. */
429
+ function detectGenericStyle(html) {
430
+ const found = /* @__PURE__ */ new Set();
431
+ for (const rule of STYLE_RULES) {
432
+ if (found.has(rule.label)) continue;
433
+ if (rule.re.test(html)) found.add(rule.label);
434
+ }
435
+ if (GENERIC_FONT_RE.test(html) && GENERIC_INDIGO_RE.test(html)) found.add("generic-palette");
436
+ return [
437
+ "tailwind-cdn",
438
+ "google-fonts",
439
+ "generic-palette"
440
+ ].filter((label) => found.has(label));
441
+ }
442
+ /** Build submit-time hints for a set of screens (empty when all clean). */
443
+ function lintMockupScreens(screens) {
444
+ const hints = [];
445
+ for (const screen of screens) {
446
+ const state = detectInPageState(screen.html);
447
+ if (state.length > 0) hints.push({
448
+ screenId: screen.id,
449
+ kind: "state",
450
+ patterns: state,
451
+ message: `Screen "${screen.id}" contains in-page state UI (${state.join(", ")}). Split each state into its own screen instead.`
452
+ });
453
+ const style = detectGenericStyle(screen.html);
454
+ if (style.length > 0) hints.push({
455
+ screenId: screen.id,
456
+ kind: "style",
457
+ patterns: style,
458
+ message: `Screen "${screen.id}" looks generic (${style.join(", ")}). Drop CDN Tailwind / Google Fonts and do not invent Inter + indigo — match the product.`
459
+ });
460
+ }
461
+ return hints;
462
+ }
463
+ /** Compact submit/revise warning for CLI and MCP. */
464
+ function formatSubmitHints(hints) {
465
+ if (hints.length === 0) return "";
466
+ const state = hints.filter((h) => h.kind !== "style");
467
+ const style = hints.filter((h) => h.kind === "style");
468
+ const lines = [];
469
+ if (state.length > 0) {
470
+ lines.push("⚠️ In-page state UI detected — split each state into its own screen:");
471
+ for (const hint of state) lines.push(` • ${hint.screenId}: ${hint.patterns.join(", ")}`);
472
+ }
473
+ if (style.length > 0) {
474
+ lines.push("⚠️ Generic styling — match the product (no CDN Tailwind, Google Fonts, or Inter+indigo):");
475
+ for (const hint of style) lines.push(` • ${hint.screenId}: ${hint.patterns.join(", ")}`);
476
+ }
477
+ return `\n${lines.join("\n")}`;
478
+ }
479
+ //#endregion
480
+ export { formatPlanReview as a, MOCKUP_SCREEN_ID_RE as c, isMockupViewport as d, normalizeMockupViewport as f, extractPlanLines as i, commentViewport as l, slugifyScreenId as m, lintMockupScreens as n, sectionTitleForLine as o, normalizeSubmitScreens as p, formatMockupReview as r, MOCKUP_MAX_SCREEN_BYTES as s, formatSubmitHints as t, defaultScreenLabel as u };
@@ -1,7 +1,7 @@
1
1
  import { C as listRepoFiles, D as toSafeRelativePath, E as isSafePath, S as isGitRepo, T as revertHunk, _ as getShowDiff, a as getCommitSeriesSummary, b as getUntrackedFilePathsAsync, d as getMergeStatus, f as getProjectStorageDir, h as getRepoRoot, l as getGitDiffAsync, m as getRepoMetadataAsync, o as getCustomGitDiffAsync, s as getFileContent, u as getHunkHistory, x as gitAddFile, y as getTabSizeForFilesAsync } from "./git-BGj31GX-.mjs";
2
2
  import { a as searchFiles, i as searchContent, o as searchSymbols, r as searchAll, s as trackSelection, t as getSearchStatus } from "./search-3R2E5xxr.mjs";
3
3
  import { c as writeJsonAtomically, i as loadSettings, s as saveSettings } from "./settings-DIa-3C8o.mjs";
4
- import { a as MOCKUP_MAX_SCREEN_BYTES, c as defaultScreenLabel, d as normalizeSubmitScreens, i as sectionTitleForLine, l as isMockupViewport, n as extractPlanLines, o as MOCKUP_SCREEN_ID_RE, r as formatPlanReview, s as commentViewport, t as formatMockupReview, u as normalizeMockupViewport } from "./mockup-format-Dzc3e_10.mjs";
4
+ import { a as formatPlanReview, c as MOCKUP_SCREEN_ID_RE, d as isMockupViewport, f as normalizeMockupViewport, i as extractPlanLines, l as commentViewport, n as lintMockupScreens, o as sectionTitleForLine, p as normalizeSubmitScreens, r as formatMockupReview, s as MOCKUP_MAX_SCREEN_BYTES, u as defaultScreenLabel } from "./mockup-lint-BRjhxXSw.mjs";
5
5
  import { a as slugifyDesignId, i as emptyTokens, o as snapshotRevision, r as cloneTokens, s as tokensToCss, t as DEFAULT_DESIGN_SYSTEM_ID } from "./design-system-types-uiP9W2uj.mjs";
6
6
  import { t as formatComments } from "./comment-format-DS1wOWj_.mjs";
7
7
  import { g as createServerAuthMiddleware, h as buildSessionTokenSetCookieValue, v as injectSessionTokenIntoHtml } from "./server-lock-ACw2cKDI.mjs";
@@ -3006,157 +3006,6 @@ function injectMockupProbe(html, opts = {}) {
3006
3006
  return script + html;
3007
3007
  }
3008
3008
  //#endregion
3009
- //#region src/lib/mockup-lint.ts
3010
- const RULES$1 = [
3011
- {
3012
- label: "tabs",
3013
- re: /role\s*=\s*["']tab(list|panel)?["']/i
3014
- },
3015
- {
3016
- label: "tabs",
3017
- re: /aria-selected\s*=/i
3018
- },
3019
- {
3020
- label: "tabs",
3021
- re: /data-(?:bs-)?toggle\s*=\s*["'](?:tab|pill)["']/i
3022
- },
3023
- {
3024
- label: "tabs",
3025
- re: /\bdata-tabs?\s*=/i
3026
- },
3027
- {
3028
- label: "accordion",
3029
- re: /<details[\s>]/i
3030
- },
3031
- {
3032
- label: "accordion",
3033
- re: /data-(?:bs-)?toggle\s*=\s*["']collapse["']/i
3034
- },
3035
- {
3036
- label: "accordion",
3037
- re: /class\s*=\s*["'][^"']*\baccordion\b/i
3038
- },
3039
- {
3040
- label: "modal",
3041
- re: /role\s*=\s*["'](?:alert)?dialog["']/i
3042
- },
3043
- {
3044
- label: "modal",
3045
- re: /data-(?:bs-)?toggle\s*=\s*["']modal["']/i
3046
- },
3047
- {
3048
- label: "modal",
3049
- re: /class\s*=\s*["'][^"']*\bmodal\b/i
3050
- },
3051
- {
3052
- label: "dropdown",
3053
- re: /role\s*=\s*["']menu(?:bar)?["']/i
3054
- },
3055
- {
3056
- label: "dropdown",
3057
- re: /data-(?:bs-)?toggle\s*=\s*["']dropdown["']/i
3058
- },
3059
- {
3060
- label: "dropdown",
3061
- re: /class\s*=\s*["'][^"']*\bdropdown\b/i
3062
- },
3063
- {
3064
- label: "toggle",
3065
- re: /role\s*=\s*["']switch["']/i
3066
- },
3067
- {
3068
- label: "toggle",
3069
- re: /class\s*=\s*["'][^"']*\b(?:toggle|switch)\b/i
3070
- }
3071
- ];
3072
- const ORDERED_LABELS = [
3073
- "tabs",
3074
- "accordion",
3075
- "modal",
3076
- "dropdown",
3077
- "toggle"
3078
- ];
3079
- /** Find the in-page state UI patterns present in one screen's HTML. */
3080
- function detectInPageState(html) {
3081
- const found = /* @__PURE__ */ new Set();
3082
- for (const rule of RULES$1) {
3083
- if (found.has(rule.label)) continue;
3084
- if (rule.re.test(html)) found.add(rule.label);
3085
- }
3086
- return ORDERED_LABELS.filter((label) => found.has(label));
3087
- }
3088
- const STYLE_RULES = [
3089
- {
3090
- label: "tailwind-cdn",
3091
- re: /cdn\.tailwindcss\.com/i
3092
- },
3093
- {
3094
- label: "tailwind-cdn",
3095
- re: /(?:unpkg\.com|cdn\.jsdelivr\.net|esm\.sh)\/(?:npm\/)?tailwindcss/i
3096
- },
3097
- {
3098
- label: "google-fonts",
3099
- re: /fonts\.googleapis\.com/i
3100
- },
3101
- {
3102
- label: "google-fonts",
3103
- re: /fonts\.gstatic\.com/i
3104
- }
3105
- ];
3106
- const GENERIC_FONT_RE = /font-family\s*:\s*[^;{]*\bInter\b/i;
3107
- const GENERIC_INDIGO_RE = /#(?:4f46e5|4338ca|6366f1|818cf8|7c3aed|8b5cf6|a78bfa)\b|\b(?:indigo|violet)-(?:[4-9]00|950)\b/i;
3108
- /** Off-brand / generic-AI styling that should not ship as the product look. */
3109
- function detectGenericStyle(html) {
3110
- const found = /* @__PURE__ */ new Set();
3111
- for (const rule of STYLE_RULES) {
3112
- if (found.has(rule.label)) continue;
3113
- if (rule.re.test(html)) found.add(rule.label);
3114
- }
3115
- if (GENERIC_FONT_RE.test(html) && GENERIC_INDIGO_RE.test(html)) found.add("generic-palette");
3116
- return [
3117
- "tailwind-cdn",
3118
- "google-fonts",
3119
- "generic-palette"
3120
- ].filter((label) => found.has(label));
3121
- }
3122
- /** Build submit-time hints for a set of screens (empty when all clean). */
3123
- function lintMockupScreens(screens) {
3124
- const hints = [];
3125
- for (const screen of screens) {
3126
- const state = detectInPageState(screen.html);
3127
- if (state.length > 0) hints.push({
3128
- screenId: screen.id,
3129
- kind: "state",
3130
- patterns: state,
3131
- message: `Screen "${screen.id}" contains in-page state UI (${state.join(", ")}). Split each state into its own screen instead.`
3132
- });
3133
- const style = detectGenericStyle(screen.html);
3134
- if (style.length > 0) hints.push({
3135
- screenId: screen.id,
3136
- kind: "style",
3137
- patterns: style,
3138
- message: `Screen "${screen.id}" looks generic (${style.join(", ")}). Drop CDN Tailwind / Google Fonts and do not invent Inter + indigo — match the product.`
3139
- });
3140
- }
3141
- return hints;
3142
- }
3143
- /** Compact submit/revise warning for CLI and MCP. */
3144
- function formatSubmitHints(hints) {
3145
- if (hints.length === 0) return "";
3146
- const state = hints.filter((h) => h.kind !== "style");
3147
- const style = hints.filter((h) => h.kind === "style");
3148
- const lines = [];
3149
- if (state.length > 0) {
3150
- lines.push("⚠️ In-page state UI detected — split each state into its own screen:");
3151
- for (const hint of state) lines.push(` • ${hint.screenId}: ${hint.patterns.join(", ")}`);
3152
- }
3153
- if (style.length > 0) {
3154
- lines.push("⚠️ Generic styling — match the product (no CDN Tailwind, Google Fonts, or Inter+indigo):");
3155
- for (const hint of style) lines.push(` • ${hint.screenId}: ${hint.patterns.join(", ")}`);
3156
- }
3157
- return `\n${lines.join("\n")}`;
3158
- }
3159
- //#endregion
3160
3009
  //#region src/lib/design-system.ts
3161
3010
  function newId() {
3162
3011
  return crypto.randomUUID();
@@ -7769,4 +7618,4 @@ async function startServer(options) {
7769
7618
  });
7770
7619
  }
7771
7620
  //#endregion
7772
- export { buildGitDiffArgs as a, parseDiffOptions as c, validateEnvironment as i, printHelp as l, formatSubmitHints as n, buildTuiGitDiffArgs as o, runTerminalDiff as r, intoShowMode as s, startServer as t };
7621
+ export { buildTuiGitDiffArgs as a, printHelp as c, buildGitDiffArgs as i, runTerminalDiff as n, intoShowMode as o, validateEnvironment as r, parseDiffOptions as s, startServer as t };
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Bridges the local-first diffing review loop into pi:
5
5
  * - Structured LLM tools mirroring the diffing MCP surface (status, review
6
- * start, comments, reply/resolve, plan loop, mockup loop, progress,
7
- * sessions, GH PR).
6
+ * start, comments, reply/resolve, plan loop, mockup loop, design system,
7
+ * progress, sessions, GH PR).
8
8
  * - `/diffing` command to open/reuse the review UI for the current repo.
9
9
  * - Footer status showing the active review session.
10
10
  * - Skill self-heal: keeps `~/.agents/skills/diffing*` as symlinks to the
@@ -20,12 +20,9 @@
20
20
  import { spawn } from "node:child_process";
21
21
  import {
22
22
  existsSync,
23
- lstatSync,
24
23
  mkdtempSync,
25
24
  readFileSync,
26
- realpathSync,
27
25
  rmSync,
28
- symlinkSync,
29
26
  writeFileSync,
30
27
  } from "node:fs";
31
28
  import { homedir, tmpdir } from "node:os";
@@ -37,30 +34,19 @@ import type {
37
34
  ExtensionContext,
38
35
  } from "@earendil-works/pi-coding-agent";
39
36
  import { Type } from "typebox";
37
+ import {
38
+ selfHealSkillLinks,
39
+ SKILLS_REL,
40
+ } from "./skill-heal.ts";
40
41
 
41
42
  // ────────────────────────────────────────────────────────────────────────────
42
43
  // Constants
43
44
  // ────────────────────────────────────────────────────────────────────────────
44
45
 
45
- const SKILL_NAMES = [
46
- "diffing",
47
- "diffing-finish-review",
48
- "diffing-mockup-author",
49
- "diffing-mockup-review",
50
- "diffing-plan-review",
51
- "diffing-pr-address",
52
- "diffing-pr-read",
53
- "diffing-release",
54
- "diffing-review",
55
- "diffing-start-review",
56
- ] as const;
57
-
58
46
  const MAX_OUTPUT_BYTES = 48 * 1024;
59
47
  const REVIEW_START_TIMEOUT_MS = 15_000;
60
48
  const REVIEW_START_POLL_MS = 400;
61
49
 
62
- const SKILLS_REL = join(".agents", "skills");
63
-
64
50
  // ────────────────────────────────────────────────────────────────────────────
65
51
  // CLI runner
66
52
  // ────────────────────────────────────────────────────────────────────────────
@@ -248,35 +234,6 @@ function findCanonicalRoot(cwd: string): string | null {
248
234
  return null;
249
235
  }
250
236
 
251
- /**
252
- * Keep the `~/.agents/skills/diffing*` entries as symlinks to the canonical
253
- * checkout's `.agents/skills`. pi dedupes skills by canonical realpath, so
254
- * symlinked home entries merge silently with the repo's project skills and no
255
- * `[Skill conflicts]` banner appears. Repairs stale real copies left behind by
256
- * `diffing setup skills` / `npx skills add --copy`. Only touches the known
257
- * names in SKILL_NAMES; never deletes anything else.
258
- */
259
- function selfHealSkillLinks(canonical: string): void {
260
- const homeSkills = join(homedir(), ".agents", "skills");
261
- if (!existsSync(homeSkills)) return;
262
- for (const name of SKILL_NAMES) {
263
- const target = join(canonical, SKILLS_REL, name);
264
- if (!existsSync(target)) continue;
265
- const link = join(homeSkills, name);
266
- try {
267
- if (existsSync(link) || lstatSync(link)) {
268
- if (lstatSync(link).isSymbolicLink()) {
269
- const real = realpathSync(link);
270
- if (real === realpathSync(target)) continue; // already correct
271
- }
272
- rmSync(link, { recursive: true, force: true });
273
- }
274
- symlinkSync(target, link, "dir");
275
- } catch {
276
- // best-effort; a failed heal must not break the session
277
- }
278
- }
279
- }
280
237
 
281
238
  // ────────────────────────────────────────────────────────────────────────────
282
239
  // Detached review server start
@@ -340,7 +297,9 @@ export default function (pi: ExtensionAPI) {
340
297
  // Keep the home skill links canonical for every session (idempotent).
341
298
  pi.on("session_start", async (_event, ctx) => {
342
299
  const canonical = findCanonicalRoot(ctx.cwd);
343
- if (canonical) selfHealSkillLinks(canonical);
300
+ if (canonical) {
301
+ selfHealSkillLinks(canonical, join(homedir(), ".agents", "skills"));
302
+ }
344
303
  await refreshStatus(ctx);
345
304
  });
346
305
 
@@ -825,7 +784,7 @@ export default function (pi: ExtensionAPI) {
825
784
  name: "diffing_mockup_submit",
826
785
  label: "Diffing Mockup Submit",
827
786
  description:
828
- "Submit HTML mockup screen(s) for visual review. Pass a single html body, a file/dir path, or an inline screens array [{id, html}]. Share the URL and park unless asked to wait. Resubmit with mockupId to bump the version and reset the verdict.",
787
+ "Submit HTML mockup screen(s) for visual review. HARD RULE one state per screen: never tabs/accordions/toggles/modals/JS swaps. Pass html, a file/dir already under ~/.diffing/…/mockup-sources/, or screens [{id, html}]. Call diffing_design show first. Share the URL and park unless asked to wait. Resubmit with mockupId to bump the version. Fix any in-page-state or generic-style hints before parking.",
829
788
  parameters: Type.Object({
830
789
  html: Type.Optional(
831
790
  Type.String({ description: "Single-screen HTML body." }),
@@ -854,6 +813,23 @@ export default function (pi: ExtensionAPI) {
854
813
  ),
855
814
  model: Type.Optional(Type.String()),
856
815
  source: Type.Optional(Type.String()),
816
+ mode: Type.Optional(
817
+ StringEnum(["fragment", "document"] as const, {
818
+ description:
819
+ "fragment = body contents wrapped by the published design-system shell. document = full HTML, no wrap. Default: server default.",
820
+ }),
821
+ ),
822
+ designSystem: Type.Optional(
823
+ Type.String({
824
+ description:
825
+ "Design-system id to bind (default system if omitted).",
826
+ }),
827
+ ),
828
+ planId: Type.Optional(
829
+ Type.String({
830
+ description: "Optional plan id this mockup illustrates.",
831
+ }),
832
+ ),
857
833
  }),
858
834
  async execute(_id, params, _signal, _onUpdate, ctx) {
859
835
  const args = ["mockup", "submit"];
@@ -896,6 +872,9 @@ export default function (pi: ExtensionAPI) {
896
872
  if (params.mockupId) args.push("--id", params.mockupId);
897
873
  if (params.model) args.push("--model", params.model);
898
874
  if (params.source) args.push("--source", params.source);
875
+ if (params.mode) args.push("--mode", params.mode);
876
+ if (params.designSystem) args.push("--system", params.designSystem);
877
+ if (params.planId) args.push("--plan-id", params.planId);
899
878
  try {
900
879
  const result = await runDiffing(args, ctx.cwd, { stdin });
901
880
  const stdout = result.stdout.trim();
@@ -1079,12 +1058,15 @@ export default function (pi: ExtensionAPI) {
1079
1058
  name: "diffing_mockup_inspect",
1080
1059
  label: "Diffing Mockup Inspect",
1081
1060
  description:
1082
- "Read compact, bounded mockup data without transferring screen HTML. view=summary (headline stats), comments (paged comment list), comment (one thread), screen (screen source). Filter by comment scope: status, screenId, viewport (desktop|tablet|mobile), version. context=none|anchor|source controls anchor detail. Prefer this over diffing_mockup_show.",
1061
+ "Read compact, bounded mockup data without transferring screen HTML. view=summary (headline stats), comments (paged comment list), comment (one thread), screen (screen source), preview (rendered preview metadata). Filter by comment scope: status, screenId, viewport (desktop|tablet|mobile), version. context=none|anchor|source controls anchor detail. Prefer this over diffing_mockup_show.",
1083
1062
  parameters: Type.Object({
1084
- view: StringEnum(["summary", "comments", "comment", "screen"] as const, {
1085
- description: "What to read. Default: summary.",
1086
- default: "summary",
1087
- }),
1063
+ view: StringEnum(
1064
+ ["summary", "comments", "comment", "screen", "preview"] as const,
1065
+ {
1066
+ description: "What to read. Default: summary.",
1067
+ default: "summary",
1068
+ },
1069
+ ),
1088
1070
  mockupId: Type.Optional(
1089
1071
  Type.String({ description: "Mockup id. Default: latest." }),
1090
1072
  ),
@@ -1152,11 +1134,12 @@ export default function (pi: ExtensionAPI) {
1152
1134
  name: "diffing_mockup_screen",
1153
1135
  label: "Diffing Mockup Screen",
1154
1136
  description:
1155
- "One-screen revision of an HTML mockup. action=upsert adds/replaces a screen (html or file), remove deletes a screen, patch replaces the first exact occurrence of text with replacement. Every success bumps the mockup version; pass expectedVersion to guard racing edits (409 version-mismatch, nothing applied). For multi-screen revisions, resubmit via diffing_mockup_submit with the same mockupId.",
1137
+ "One-screen revision of an HTML mockup. action=upsert adds/replaces a screen (html or file), remove deletes a screen, patch replaces the first exact occurrence of text with replacement, replace-region replaces the inner HTML of the first [data-diffing=region] element (prefer this when the comment has a data-diffing target). Every success bumps the mockup version; pass expectedVersion to guard racing edits (409 version-mismatch, nothing applied). For multi-screen revisions, resubmit via diffing_mockup_submit with the same mockupId.",
1156
1138
  parameters: Type.Object({
1157
- action: StringEnum(["upsert", "remove", "patch"] as const, {
1158
- description: "Revision op.",
1159
- }),
1139
+ action: StringEnum(
1140
+ ["upsert", "remove", "patch", "replace-region"] as const,
1141
+ { description: "Revision op." },
1142
+ ),
1160
1143
  mockupId: Type.String({ description: "Mockup id." }),
1161
1144
  screenId: Type.String({ description: "Screen id." }),
1162
1145
  html: Type.Optional(
@@ -1177,8 +1160,17 @@ export default function (pi: ExtensionAPI) {
1177
1160
  description: "For patch: exact text to replace (first occurrence).",
1178
1161
  }),
1179
1162
  ),
1163
+ region: Type.Optional(
1164
+ Type.String({
1165
+ description:
1166
+ "For replace-region: data-diffing attribute value of the block to replace.",
1167
+ }),
1168
+ ),
1180
1169
  replacement: Type.Optional(
1181
- Type.String({ description: "For patch: replacement text." }),
1170
+ Type.String({
1171
+ description:
1172
+ "For patch: replacement text. For replace-region: new inner HTML.",
1173
+ }),
1182
1174
  ),
1183
1175
  expectedVersion: Type.Optional(
1184
1176
  Type.Number({
@@ -1202,6 +1194,10 @@ export default function (pi: ExtensionAPI) {
1202
1194
  stdin = params.html;
1203
1195
  }
1204
1196
  if (params.label) args.push("--label", params.label);
1197
+ } else if (params.action === "replace-region") {
1198
+ if (params.region) args.push("--region", params.region);
1199
+ if (params.replacement !== undefined)
1200
+ args.push("--replacement", params.replacement);
1205
1201
  } else if (params.action === "patch") {
1206
1202
  if (params.text !== undefined) args.push("--text", params.text);
1207
1203
  if (params.replacement !== undefined)
@@ -1264,6 +1260,77 @@ export default function (pi: ExtensionAPI) {
1264
1260
  },
1265
1261
  });
1266
1262
 
1263
+ pi.registerTool({
1264
+ name: "diffing_mockup_handoff",
1265
+ label: "Diffing Mockup Handoff",
1266
+ description:
1267
+ "Compact implementation handoff after a mockup is approved: tokens, screens with intent, components used, leftover nits. Prefer this over dumping every screen's HTML. Call after decision=approved, before writing product UI.",
1268
+ parameters: Type.Object({
1269
+ mockupId: Type.Optional(
1270
+ Type.String({ description: "Mockup id. Default: latest." }),
1271
+ ),
1272
+ json: Type.Optional(
1273
+ Type.Boolean({ description: "Emit raw JSON. Default: XML handoff." }),
1274
+ ),
1275
+ }),
1276
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1277
+ const args = ["mockup", "handoff"];
1278
+ if (params.mockupId) args.push(params.mockupId);
1279
+ if (params.json) args.push("--json");
1280
+ const result = await runDiffing(args, ctx.cwd);
1281
+ return textResult(describe(result, "diffing mockup handoff"), {
1282
+ exitCode: result.exitCode,
1283
+ stderr: result.stderr.trim() || undefined,
1284
+ });
1285
+ },
1286
+ });
1287
+
1288
+ pi.registerTool({
1289
+ name: "diffing_design",
1290
+ label: "Diffing Design System",
1291
+ description:
1292
+ "Per-repo design system used before authoring mockup HTML. show/list: read tokens, guidelines, components. extract: scan the consumer repo and write a draft (does not publish). propose: update the draft. publish: human action only — do not publish unless the human asked. Omit id for the default system.",
1293
+ parameters: Type.Object({
1294
+ action: StringEnum(
1295
+ ["show", "list", "extract", "propose", "publish"] as const,
1296
+ { description: "Design-system op. Default: show." },
1297
+ ),
1298
+ id: Type.Optional(
1299
+ Type.String({ description: "System id. Default: default." }),
1300
+ ),
1301
+ json: Type.Optional(
1302
+ Type.Boolean({ description: "Emit raw JSON. Default: false." }),
1303
+ ),
1304
+ from: Type.Optional(
1305
+ StringEnum(["css", "text"] as const, {
1306
+ description: "For extract: scan source. Default: css.",
1307
+ }),
1308
+ ),
1309
+ title: Type.Optional(
1310
+ Type.String({ description: "For extract/propose: system title." }),
1311
+ ),
1312
+ guidelines: Type.Optional(
1313
+ Type.String({
1314
+ description: "For propose: markdown guidelines to write on the draft.",
1315
+ }),
1316
+ ),
1317
+ }),
1318
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1319
+ const action = params.action ?? "show";
1320
+ const args = ["design", action];
1321
+ if (params.id) args.push(params.id);
1322
+ if (params.json) args.push("--json");
1323
+ if (params.from) args.push("--from", params.from);
1324
+ if (params.title) args.push("--title", params.title);
1325
+ if (params.guidelines) args.push("--guidelines", params.guidelines);
1326
+ const result = await runDiffing(args, ctx.cwd);
1327
+ return textResult(describe(result, `diffing design ${action}`), {
1328
+ exitCode: result.exitCode,
1329
+ stderr: result.stderr.trim() || undefined,
1330
+ });
1331
+ },
1332
+ });
1333
+
1267
1334
  pi.registerTool({
1268
1335
  name: "diffing_url",
1269
1336
  label: "Diffing URL",
@@ -0,0 +1,73 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ realpathSync,
5
+ rmSync,
6
+ symlinkSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export const DIFFING_SKILL_NAMES = [
11
+ "diffing",
12
+ "diffing-finish-review",
13
+ "diffing-mockup-author",
14
+ "diffing-mockup-review",
15
+ "diffing-plan-review",
16
+ "diffing-pr-address",
17
+ "diffing-pr-read",
18
+ "diffing-release",
19
+ "diffing-review",
20
+ "diffing-start-review",
21
+ ] as const;
22
+
23
+ export const SKILLS_REL = join(".agents", "skills");
24
+
25
+ /**
26
+ * Point `link` at `target`. Creates a missing link, repairs a stale/dangling
27
+ * one, and leaves a correct symlink alone. `existsSync` is false for both
28
+ * missing paths and dangling symlinks, so this uses `lstat` and treats ENOENT
29
+ * as "create".
30
+ */
31
+ export function healSkillLink(
32
+ link: string,
33
+ target: string,
34
+ ): "ok" | "created" | "repaired" {
35
+ if (!existsSync(target)) return "ok";
36
+ let st: ReturnType<typeof lstatSync> | null = null;
37
+ try {
38
+ st = lstatSync(link);
39
+ } catch {
40
+ st = null;
41
+ }
42
+ if (st) {
43
+ if (st.isSymbolicLink()) {
44
+ try {
45
+ if (realpathSync(link) === realpathSync(target)) return "ok";
46
+ } catch {
47
+ // dangling or unreadable — fall through and replace
48
+ }
49
+ }
50
+ rmSync(link, { recursive: true, force: true });
51
+ symlinkSync(target, link, "dir");
52
+ return "repaired";
53
+ }
54
+ symlinkSync(target, link, "dir");
55
+ return "created";
56
+ }
57
+
58
+ /** Keep `~/.agents/skills/diffing*` as symlinks to the canonical checkout. */
59
+ export function selfHealSkillLinks(
60
+ canonical: string,
61
+ homeSkills: string,
62
+ ): void {
63
+ if (!existsSync(homeSkills)) return;
64
+ for (const name of DIFFING_SKILL_NAMES) {
65
+ const target = join(canonical, SKILLS_REL, name);
66
+ if (!existsSync(target)) continue;
67
+ try {
68
+ healSkillLink(join(homeSkills, name), target);
69
+ } catch {
70
+ // best-effort; a failed heal must not break the session
71
+ }
72
+ }
73
+ }
@@ -5,10 +5,11 @@
5
5
  "moduleResolution": "bundler",
6
6
  "strict": true,
7
7
  "noEmit": true,
8
+ "allowImportingTsExtensions": true,
8
9
  "skipLibCheck": true,
9
10
  "esModuleInterop": true,
10
11
  "forceConsistentCasingInFileNames": true,
11
12
  "types": ["node"]
12
13
  },
13
- "include": ["index.ts", "pi-modules.d.ts"]
14
+ "include": ["index.ts", "skill-heal.ts", "pi-modules.d.ts"]
14
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffing",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "local-first CLI for reviewing, navigating, and discussing git diffs with AI",
5
5
  "type": "module",
6
6
  "license": "MIT",