quiver-cli 1.2.0 → 1.3.1

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 (121) hide show
  1. package/dist/cli.js +237 -10
  2. package/package.json +1 -1
  3. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  4. package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
  5. package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
  6. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
  7. package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
  8. package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
  9. package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
  10. package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
  11. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
  12. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
  13. package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
  14. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
  15. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
  16. package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
  17. package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
  18. package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
  19. package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
  21. package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
  22. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
  23. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
  24. package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
  25. package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
  26. package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
  27. package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
  28. package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
  29. package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
  30. package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
  31. package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
  32. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
  33. package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
  34. package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
  35. package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
  36. package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
  37. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
  38. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
  39. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
  40. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
  41. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
  42. package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
  43. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
  44. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
  45. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
  46. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
  47. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
  48. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
  49. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
  50. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
  51. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
  52. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
  53. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
  54. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
  55. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
  56. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
  57. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
  58. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
  59. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
  60. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
  61. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
  62. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
  63. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
  64. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
  65. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
  66. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
  67. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
  68. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
  69. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
  70. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
  71. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
  72. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
  73. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
  74. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
  76. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
  77. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
  78. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
  79. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
  85. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
  86. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
  88. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
  89. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
  90. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
  91. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
  92. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
  93. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
  95. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
  96. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
  97. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
  98. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
  99. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
  100. package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
  101. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
  102. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
  103. package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
  104. package/template/.agents/skills/hono/SKILL.md +17 -3
  105. package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
  106. package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
  107. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
  108. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
  109. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
  110. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
  111. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
  112. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
  113. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
  114. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
  115. package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
  116. package/template/.agents/skills/supabase/SKILL.md +5 -1
  117. package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
  118. package/template/.agents/skills/writing/humanizer/README.md +209 -0
  119. package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
  120. package/template/.agents/upstreams.json +21 -22
  121. package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
@@ -27,6 +27,7 @@
27
27
  * shape rather than the markdown block.
28
28
  */
29
29
  import fs from 'node:fs';
30
+ import { spawnSync } from 'node:child_process';
30
31
  import os from 'node:os';
31
32
  import path from 'node:path';
32
33
  import { fileURLToPath } from 'node:url';
@@ -1012,14 +1013,43 @@ async function fetchLatestSkillVersion() {
1012
1013
  }
1013
1014
  }
1014
1015
 
1016
+ // Destroy fetch's global undici dispatcher before process.exit(): a live
1017
+ // keep-alive socket trips a libuv assertion on Windows/Node 24 after a
1018
+ // successful boot (nodejs/node#56645, issue #573).
1019
+ async function destroyFetchDispatcher() {
1020
+ const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
1021
+ if (dispatcher && typeof dispatcher.destroy === 'function') {
1022
+ try { await dispatcher.destroy(); } catch { /* exit regardless */ }
1023
+ }
1024
+ }
1025
+
1026
+ // Drain the boot payload before process.exit(): a live pipe that has not
1027
+ // flushed yet is truncated when Node tears down (issue #573 review). Then
1028
+ // close fetch so Windows teardown does not abort on the keep-alive socket.
1029
+ async function finishCli(output) {
1030
+ await new Promise((resolve) => {
1031
+ process.stdout.write(output, () => resolve());
1032
+ });
1033
+ await destroyFetchDispatcher();
1034
+ process.exit(0);
1035
+ }
1036
+
1037
+ // Two instructions used to sit in one directive: ask, and "if they agree, run
1038
+ // it". Nothing gated the second on an answer, and the same sentence said to
1039
+ // continue without waiting, so a run that could never establish agreement was
1040
+ // still spelled out as the next command. The offer stays; the command leaves
1041
+ // this turn entirely, because installing over the skill mid-session changes
1042
+ // files the session is reading and only takes effect in the next one anyway.
1015
1043
  function buildUpdateDirective(localVersion, latestVersion) {
1016
1044
  return (
1017
1045
  `UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
1018
1046
  `(installed v${localVersion}, latest v${latestVersion}). ` +
1019
- `Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
1047
+ `Mention it once, in this form: "A newer Impeccable (v${latestVersion}) is available. ` +
1020
1048
  `Update now? It runs \`npx impeccable update\`." ` +
1021
- `If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
1022
- `Either way, continue the current task without waiting, and do not raise this again.`
1049
+ `Do not run \`npx impeccable update\` in this turn, whatever the user answers: it rewrites the skill files ` +
1050
+ `this session is reading, and the update only takes effect in the next session, so there is nothing to gain now. ` +
1051
+ `Run it in a later turn, only after the user has asked for it in their own words. ` +
1052
+ `Continue the current task now without waiting, and do not raise this again.`
1023
1053
  );
1024
1054
  }
1025
1055
 
@@ -1141,15 +1171,17 @@ async function cli() {
1141
1171
  parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
1142
1172
  appendDetectorFallback(parts, ctx);
1143
1173
  appendImageGenDirective(parts);
1174
+ appendBuildPathDirective(parts, ctx);
1175
+ await appendCompRoundOpenDirective(parts, ctx);
1144
1176
  appendAutonomyCounterDirective(parts);
1145
1177
  appendSubagentAuthorizationDirective(parts);
1146
1178
  if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
1147
1179
  parts.push(buildMissingTargetDirective());
1148
1180
  }
1181
+ appendImageToolsDirective(parts);
1149
1182
  appendStalenessDirective(parts, ctx, cliOptions);
1150
1183
  if (updateDirective) parts.push(updateDirective);
1151
- process.stdout.write(parts.join('\n\n---\n\n') + '\n');
1152
- process.exit(0);
1184
+ await finishCli(parts.join('\n\n---\n\n') + '\n');
1153
1185
  }
1154
1186
  const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
1155
1187
  if (ctx.hasDesign) {
@@ -1159,6 +1191,8 @@ async function cli() {
1159
1191
  parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
1160
1192
  appendDetectorFallback(parts, ctx);
1161
1193
  appendImageGenDirective(parts);
1194
+ appendBuildPathDirective(parts, ctx);
1195
+ await appendCompRoundOpenDirective(parts, ctx);
1162
1196
  appendAutonomyCounterDirective(parts);
1163
1197
  appendSubagentAuthorizationDirective(parts);
1164
1198
  if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
@@ -1180,6 +1214,7 @@ async function cli() {
1180
1214
  `# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
1181
1215
  );
1182
1216
  }
1217
+ appendImageToolsDirective(parts);
1183
1218
  appendStalenessDirective(parts, ctx, cliOptions);
1184
1219
  if (!ctx.platform) {
1185
1220
  // A `## Platform` section that names something we don't recognize (a
@@ -1193,7 +1228,7 @@ async function cli() {
1193
1228
  }
1194
1229
  }
1195
1230
  if (updateDirective) parts.push(updateDirective);
1196
- process.stdout.write(parts.join('\n\n---\n\n') + '\n');
1231
+ await finishCli(parts.join('\n\n---\n\n') + '\n');
1197
1232
  }
1198
1233
 
1199
1234
  function parseCliOptions(args) {
@@ -1266,6 +1301,72 @@ function automaticHookMode(ctx) {
1266
1301
  }
1267
1302
 
1268
1303
 
1304
+ // Build-path preference: a workflow setting (comp-led vs code-led), read here
1305
+ // so every session starts knowing it without a file hunt. It rides the unified
1306
+ // config beside the hook and detector settings, and the gitignored local file
1307
+ // wins, because whether a machine has an image tool is a property of that
1308
+ // machine, not of the team's committed default. Absence stays silent;
1309
+ // new-work's own default applies, and the decision page toggle can flip the
1310
+ // value for a single session.
1311
+ function readBuildPathAt(root) {
1312
+ let value = null;
1313
+ let source = null;
1314
+ for (const name of ['config.json', 'config.local.json']) {
1315
+ const raw = readJson(path.join(root, '.impeccable', name));
1316
+ if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') {
1317
+ value = raw.buildPath;
1318
+ source = `.impeccable/${name}`;
1319
+ }
1320
+ }
1321
+ return value ? { value, source } : null;
1322
+ }
1323
+
1324
+ // Roots in precedence order, nearest first: the resolved project decides, and
1325
+ // the repo root is the fallback a monorepo commits once for every app in it.
1326
+ // `checkBuildPathUnset` reads exactly these two, and the pair has to match:
1327
+ // when they disagree the finding goes silent because a value exists while the
1328
+ // directive never names it, which is the one combination nobody can debug.
1329
+ //
1330
+ // The invoking directory is deliberately not in the chain. With `--target`
1331
+ // selecting another workspace, cwd is the caller's app, not the target's, and
1332
+ // letting it rank above the repo root hands one workspace another's workflow.
1333
+ // It stands in only when no project resolved at all.
1334
+ // A direction was dealt for a comp-led build and the phase machine never
1335
+ // started, or stopped short of the hero gate: the comp round is open. Said
1336
+ // here because every model in the corpus ran context.mjs unprompted, and
1337
+ // the run that skipped the round did so between the roll and the first
1338
+ // write; a boot that names the open round is a boot the write cannot claim
1339
+ // it never saw. Reads build-phase's own helper so the two agree.
1340
+ async function appendCompRoundOpenDirective(parts, ctx) {
1341
+ try {
1342
+ const { compRoundOpen } = await import('./build-phase.mjs');
1343
+ const roots = [...new Set([ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((r) => path.resolve(r)))];
1344
+ for (const root of roots) {
1345
+ const open = compRoundOpen(root);
1346
+ if (!open) continue;
1347
+ parts.push(`COMP_ROUND_OPEN: ${open.reason}. On a comp-led build no page code is written before build-phase.mjs closes the comps, spec, plates, and hero gates; run \`node ${path.dirname(fileURLToPath(import.meta.url))}/build-phase.mjs status\` and follow its NEXT line. A page written past an open round is what the finish reviewer sends back.`);
1348
+ return;
1349
+ }
1350
+ } catch { /* build-phase absent: nothing to say */ }
1351
+ }
1352
+
1353
+ function appendBuildPathDirective(parts, ctx) {
1354
+ const roots = [...new Set(
1355
+ [ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
1356
+ )];
1357
+ for (const root of roots) {
1358
+ const found = readBuildPathAt(root);
1359
+ if (!found) continue;
1360
+ // "Never written back" is scoped by the fact that this directive exists at
1361
+ // all: it is emitted only where a value is already recorded, which is the
1362
+ // case where a flip really is session-only. Saying so inline because the
1363
+ // bare absolute reads as a rule that overrides new-work's one-time offer,
1364
+ // which is exactly how the same wording misfired in serve-question.
1365
+ parts.push(`BUILD_PATH_DEFAULT: ${found.value} (from ${found.source}). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back, because a default is already recorded here. New-work's one-time offer to record a flipped value applies only where no default exists, which is why you are not seeing this line on those projects.`);
1366
+ return;
1367
+ }
1368
+ }
1369
+
1269
1370
  // Image generation availability: harness-native tools always win, but when the
1270
1371
  // environment carries an OpenAI key the API fallback works everywhere. The
1271
1372
  // flag only reports capability, positively: absence stays silent, because a
@@ -1275,9 +1376,10 @@ function appendImageGenDirective(parts) {
1275
1376
  if (!process.env.OPENAI_API_KEY) return;
1276
1377
  const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
1277
1378
  parts.push([
1278
- 'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
1279
- `\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
1280
- 'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
1379
+ 'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
1380
+ 'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
1381
+ `\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
1382
+ 'Visualizing a direction before building it measurably strengthens the result.',
1281
1383
  ].join(' '));
1282
1384
  }
1283
1385
 
@@ -1332,6 +1434,19 @@ function appendDetectorFallback(parts, ctx) {
1332
1434
  // markdown already in memory, a bounded set of stats, or one of the small JSON
1333
1435
  // files the boot reads regardless. The deep pass (git drift, token divergence,
1334
1436
  // cross-workspace sweep) belongs to the doctor command, not to every session.
1437
+ // One boot-time probe replaces every session re-deriving its image toolchain:
1438
+ // harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
1439
+ // agent should read this line instead of running command -v per image.
1440
+ function appendImageToolsDirective(parts) {
1441
+ const probe = process.platform === 'win32' ? 'where' : 'which';
1442
+ const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
1443
+ try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
1444
+ });
1445
+ parts.push(found.length
1446
+ ? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
1447
+ : 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
1448
+ }
1449
+
1335
1450
  function appendStalenessDirective(parts, ctx, options) {
1336
1451
  const projectRoot = ctx.projectRoot || process.cwd();
1337
1452
  if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
@@ -16,8 +16,9 @@
16
16
  * CLI entry points (called from skill instructions):
17
17
  * node critique-storage.mjs slug <resolved-target>
18
18
  * node critique-storage.mjs write <slug> <snapshot-body-file>
19
- * node critique-storage.mjs latest <slug>
19
+ * node critique-storage.mjs latest <slug> [--json]
20
20
  * node critique-storage.mjs trend <slug> [limit]
21
+ * node critique-storage.mjs close <resolved-target> <snapshot-file>
21
22
  *
22
23
  * Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
23
24
  * markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
27
28
 
28
29
  import fs from 'node:fs';
29
30
  import path from 'node:path';
31
+ import { createHash } from 'node:crypto';
30
32
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
33
  import { getCritiqueDir } from './lib/impeccable-paths.mjs';
32
34
  import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
50
52
  return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
51
53
  }
52
54
 
55
+ /**
56
+ * Return an exact content fingerprint for a local file target. URLs and
57
+ * non-files return null because their content is not available here.
58
+ *
59
+ * The fingerprint deliberately describes bytes, not Git state or mtimes:
60
+ * critique often assesses an uncommitted file, and a later polish run should
61
+ * inherit that backlog when the bytes are unchanged regardless of staging.
62
+ */
63
+ function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
64
+ if (!target || /^https?:\/\//i.test(target)) return null;
65
+ return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
66
+ }
67
+
68
+ function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
69
+ if (!target || typeof target !== 'string') return null;
70
+ if (/^https?:\/\//i.test(target)) {
71
+ try {
72
+ const url = new URL(target);
73
+ const pathname = url.pathname.replace(/\/+$/, '') || '/';
74
+ return `url:${url.origin}${pathname}`;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+ const filePath = resolveLocalTargetPath(target, { cwd });
80
+ return filePath ? `file:${filePath}` : null;
81
+ }
82
+
83
+ export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
84
+ const filePath = resolveLocalTargetPath(target, { cwd });
85
+ if (!filePath) return null;
86
+ try {
87
+ if (!fs.statSync(filePath).isFile()) return null;
88
+ return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
53
94
  /**
54
95
  * Write a snapshot for `slug`. `meta` carries the small structured frontmatter
55
96
  * keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
62
103
  const dir = getCritiqueDir(cwd);
63
104
  fs.mkdirSync(dir, { recursive: true });
64
105
  const timestamp = nowFilenameStamp(now);
65
- const filePath = path.join(dir, `${timestamp}__${slug}.md`);
66
106
  // Spread `meta` first so internally computed `timestamp` and `slug`
67
107
  // always win. Otherwise a caller-supplied meta blob (parsed from the
68
108
  // IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
69
109
  // filename in disagreement with its frontmatter and corrupting trends.
70
110
  const front = serializeFrontmatter({ ...meta, timestamp, slug });
71
- fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
72
- return filePath;
111
+ const contents = `${front}\n${body.trim()}\n`;
112
+
113
+ // A second critique can finish in the same UTC second. Use exclusive
114
+ // creation and a fixed-width suffix so concurrent writers cannot replace
115
+ // history and lexical ordering still keeps collision entries newest.
116
+ for (let collision = 0; collision <= 9999; collision += 1) {
117
+ const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
118
+ const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
119
+ try {
120
+ fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
121
+ return filePath;
122
+ } catch (error) {
123
+ if (error?.code !== 'EEXIST') throw error;
124
+ }
125
+ }
126
+ throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
73
127
  }
74
128
 
75
129
  function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
98
152
  try { value = JSON.parse(value); } catch { /* leave as-is */ }
99
153
  } else if (/^-?\d+$/.test(value)) {
100
154
  value = Number(value);
155
+ } else if (value === 'true' || value === 'false') {
156
+ value = value === 'true';
101
157
  }
102
158
  out[key] = value;
103
159
  }
@@ -105,28 +161,118 @@ function parseFrontmatter(text) {
105
161
  }
106
162
 
107
163
  /**
108
- * Return all snapshot files for `slug`, sorted oldest → newest.
164
+ * Return snapshot files matching `suffix`, sorted oldest → newest.
109
165
  */
110
- function listSnapshotsForSlug(slug, cwd) {
166
+ const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
167
+
168
+ function listSnapshots(suffix, cwd) {
111
169
  const dir = getCritiqueDir(cwd);
112
170
  if (!fs.existsSync(dir)) return [];
113
- const suffix = `__${slug}.md`;
114
171
  return fs.readdirSync(dir)
115
- .filter((f) => f.endsWith(suffix))
172
+ .filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
116
173
  .sort()
117
174
  .map((f) => path.join(dir, f));
118
175
  }
119
176
 
177
+ function readSnapshot(filePath) {
178
+ if (!filePath) return null;
179
+ const body = fs.readFileSync(filePath, 'utf-8');
180
+ return { path: filePath, body, meta: parseFrontmatter(body) };
181
+ }
182
+
183
+ function snapshotTargetIdentity(snapshot) {
184
+ const targetPath = snapshot?.meta.target_path;
185
+ return snapshot?.meta.target_identity
186
+ || (targetPath ? `file:${targetPath}` : null);
187
+ }
188
+
189
+ function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
190
+ return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
191
+ }
192
+
193
+ function readNewestSnapshotForIdentity(
194
+ slug,
195
+ targetIdentity,
196
+ { cwd = process.cwd() } = {},
197
+ ) {
198
+ const matches = listSnapshots(`__${slug}.md`, cwd)
199
+ .map(readSnapshot)
200
+ .filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
201
+ return matches.at(-1) || null;
202
+ }
203
+
120
204
  /**
121
205
  * Return the most recent snapshot for `slug`, or null. Polish reads this
122
206
  * to find its fix backlog when the slug matches.
123
207
  */
124
208
  export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
125
- const all = listSnapshotsForSlug(slug, cwd);
126
- if (!all.length) return null;
127
- const latest = all[all.length - 1];
128
- const body = fs.readFileSync(latest, 'utf-8');
129
- return { path: latest, body, meta: parseFrontmatter(body) };
209
+ const latest = readNewestSnapshot(slug, { cwd });
210
+ return latest?.meta.closed === true ? null : latest;
211
+ }
212
+
213
+ /**
214
+ * Mark one exact snapshot closed without deleting the score history consumed
215
+ * by `trend`. Exact identity matters: a newer critique may land after polish
216
+ * reads its backlog, and that newer snapshot must remain live. `snapshotFile`
217
+ * may be the absolute path returned by readLatestSnapshot() or the basename
218
+ * emitted by `latest --json`. Returns the path marked closed, or null.
219
+ */
220
+ export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
221
+ if (!snapshotFile || typeof snapshotFile !== 'string') return null;
222
+ const dir = path.resolve(getCritiqueDir(cwd));
223
+ const snapshotPath = path.isAbsolute(snapshotFile)
224
+ ? path.resolve(snapshotFile)
225
+ : path.resolve(dir, snapshotFile);
226
+ const filename = path.basename(snapshotPath);
227
+ if (
228
+ path.dirname(snapshotPath) !== dir
229
+ || !SNAPSHOT_FILENAME.test(filename)
230
+ ) return null;
231
+
232
+ let snapshot;
233
+ try {
234
+ if (!fs.lstatSync(snapshotPath).isFile()) return null;
235
+ snapshot = readSnapshot(snapshotPath);
236
+ } catch {
237
+ return null;
238
+ }
239
+ if (!snapshot || snapshot.meta.closed === true) return null;
240
+ const closedBody = snapshot.body.replace(
241
+ /^(---\r?\n[\s\S]*?)(\r?\n---)/,
242
+ '$1\nclosed: true$2',
243
+ );
244
+ if (closedBody === snapshot.body) {
245
+ throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
246
+ }
247
+ fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
248
+ return snapshot.path;
249
+ }
250
+
251
+ /** Return the most recent snapshot across all targets, or null. */
252
+ export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
253
+ const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
254
+ const identifiedSlugs = new Set(
255
+ snapshots
256
+ .filter((snapshot) => snapshotTargetIdentity(snapshot))
257
+ .map((snapshot) => snapshot.meta.slug),
258
+ );
259
+ const latestByTarget = new Map();
260
+ for (const snapshot of snapshots) {
261
+ if (!snapshot?.meta.slug) continue;
262
+ // Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
263
+ // one. Keep each known identity's latest open/closed state independent so
264
+ // closing one target cannot hide another target's live backlog. Once a
265
+ // slug has any identity-aware snapshot, its older legacy records are no
266
+ // longer independently routable and must not resurface as zombie work.
267
+ const targetIdentity = snapshotTargetIdentity(snapshot);
268
+ if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
269
+ const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
270
+ latestByTarget.set(streamKey, snapshot);
271
+ }
272
+ return [...latestByTarget.values()]
273
+ .filter((snapshot) => snapshot.meta.closed !== true)
274
+ .sort((a, b) => a.path.localeCompare(b.path))
275
+ .at(-1) || null;
130
276
  }
131
277
 
132
278
  /**
@@ -134,7 +280,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
134
280
  * Critique appends a one-line trend to its output using this.
135
281
  */
136
282
  export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
137
- const all = listSnapshotsForSlug(slug, cwd);
283
+ const all = listSnapshots(`__${slug}.md`, cwd);
138
284
  const slice = all.slice(-limit);
139
285
  return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
140
286
  }
@@ -144,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
144
290
  // Accept either a ready slug or a concrete target (path/URL) everywhere, so
145
291
  // callers never have to run the slug step separately. Anything containing a
146
292
  // path or URL marker is resolved through slugFromTarget.
293
+ function isReadySlug(value) {
294
+ return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
295
+ }
296
+
147
297
  function coerceSlug(value) {
148
298
  if (!value) return null;
149
- if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
299
+ if (isReadySlug(value)) return value;
150
300
  return slugFromTarget(value);
151
301
  }
152
302
 
@@ -172,14 +322,124 @@ function main(argv) {
172
322
  if (metaArg) {
173
323
  try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
174
324
  }
325
+ // The helper, not caller-provided metadata, owns the target fingerprint.
326
+ // This makes the snapshot describe the exact file bytes critique saw.
327
+ delete meta.target_fingerprint;
328
+ delete meta.target_path;
329
+ delete meta.target_identity;
330
+ const targetIdentity = resolveTargetIdentity(slugArg);
331
+ if (targetIdentity) meta.target_identity = targetIdentity;
332
+ const targetFingerprint = fingerprintTarget(slugArg);
333
+ if (targetFingerprint) {
334
+ meta.target_fingerprint = targetFingerprint;
335
+ meta.target_path = resolveLocalTargetPath(slugArg);
336
+ }
175
337
  const out = writeSnapshot({ slug, meta, body: raw });
176
338
  process.stdout.write(`${out}\n`);
177
339
  return;
178
340
  }
179
341
  case 'latest': {
180
- const latest = readLatestSnapshot(coerceSlug(args[0]));
181
- if (!latest) { process.exit(2); }
182
- process.stdout.write(latest.body);
342
+ const target = args[0];
343
+ const format = args[1];
344
+ const slug = coerceSlug(target);
345
+ if (!slug || (format && format !== '--json')) {
346
+ process.stderr.write('usage: latest <slug-or-target> [--json]\n');
347
+ process.exit(1);
348
+ }
349
+ const targetFingerprint = fingerprintTarget(target);
350
+ const targetPath = resolveLocalTargetPath(target);
351
+ const targetIdentity = resolveTargetIdentity(target);
352
+ const readySlug = isReadySlug(target);
353
+ const newestForSlug = readNewestSnapshot(slug);
354
+ if (!newestForSlug) { process.exit(2); }
355
+
356
+ // Concrete targets select the newest snapshot for their exact identity,
357
+ // not merely the newest filename for a lossy slug. This keeps distinct
358
+ // targets such as foo/bar and foo-bar from hiding each other's backlog.
359
+ const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
360
+ let latest = exactSnapshot;
361
+ if (!latest && !readySlug) {
362
+ // Legacy snapshots have no identity. Preserve their old explicit
363
+ // path/URL behavior only when no known target identity was selected.
364
+ latest = readNewestSnapshotForIdentity(slug, null);
365
+ }
366
+ if (!latest) latest = newestForSlug;
367
+ if (latest.meta.closed === true) { process.exit(2); }
368
+
369
+ const recordedTargetPath = latest.meta.target_path;
370
+ const recordedTargetIdentity = snapshotTargetIdentity(latest);
371
+ const matchingIdentity = recordedTargetIdentity === targetIdentity;
372
+
373
+ // Bare slugs remain a supported lookup mode, including for URL
374
+ // snapshots. But when a same-named local file exists, the request is
375
+ // ambiguous unless that exact file owns the snapshot identity.
376
+ if (readySlug && !recordedTargetIdentity) {
377
+ process.stderr.write(
378
+ 'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
379
+ );
380
+ process.exit(2);
381
+ }
382
+ if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
383
+ process.stderr.write(
384
+ 'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
385
+ );
386
+ process.exit(2);
387
+ }
388
+
389
+ const concreteTarget = !readySlug || matchingIdentity;
390
+ if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
391
+ process.exit(2);
392
+ }
393
+ const concreteLocalTarget = concreteTarget && targetPath;
394
+ if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
395
+ closeSnapshot(latest.path);
396
+ process.exit(2);
397
+ }
398
+ if (format === '--json') {
399
+ process.stdout.write(JSON.stringify({
400
+ snapshot_file: path.basename(latest.path),
401
+ body: latest.body,
402
+ }, null, 2) + '\n');
403
+ } else {
404
+ process.stdout.write(latest.body);
405
+ }
406
+ return;
407
+ }
408
+ case 'close': {
409
+ const [slugArg, snapshotFile, ...extra] = args;
410
+ const slug = coerceSlug(slugArg);
411
+ if (!slug || !snapshotFile || extra.length > 0) {
412
+ process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
413
+ process.exit(1);
414
+ }
415
+ if (
416
+ path.basename(snapshotFile) !== snapshotFile
417
+ || !SNAPSHOT_FILENAME.test(snapshotFile)
418
+ || !snapshotFile.endsWith(`__${slug}.md`)
419
+ ) process.exit(2);
420
+
421
+ // A slug and filename are not enough to prove ownership because two
422
+ // distinct targets can normalize to the same slug. Modern snapshots
423
+ // carry a canonical identity, so require the supplied resolved target
424
+ // to match it before allowing the exact snapshot to be closed. Legacy
425
+ // snapshots without identity retain their historical close behavior.
426
+ const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
427
+ let snapshot;
428
+ try {
429
+ if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
430
+ snapshot = readSnapshot(snapshotPath);
431
+ } catch {
432
+ process.exit(2);
433
+ }
434
+ const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
435
+ if (
436
+ recordedTargetIdentity
437
+ && recordedTargetIdentity !== resolveTargetIdentity(slugArg)
438
+ ) process.exit(2);
439
+
440
+ const closed = closeSnapshot(snapshotFile);
441
+ if (!closed) { process.exit(2); }
442
+ process.stdout.write(`${closed}\n`);
183
443
  return;
184
444
  }
185
445
  case 'trend': {
@@ -188,7 +448,7 @@ function main(argv) {
188
448
  return;
189
449
  }
190
450
  default:
191
- process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
451
+ process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
192
452
  process.exit(1);
193
453
  }
194
454
  }