quiver-cli 1.3.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.
- package/package.json +1 -1
- package/template/.agents/skills/agent-browser/SKILL.md +1 -0
- package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
- package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
- package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
- package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
- package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
- package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
- package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
- package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
- package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
- package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
- package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
- package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
- package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
- package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
- package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
- package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
- package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
- package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
- package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
- package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
- package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
- package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
- package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
- package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
- package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
- package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
- package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
- package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
- package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
- package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
- package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
- package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
- package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
- package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
- package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
- package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
- package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
- package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
- package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
- package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
- package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
- package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
- package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
- package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
- package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
- package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
- package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
- package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
- package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
- package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
- package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
- package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
- package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
- package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
- package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
- package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
- package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
- package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
- package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
- package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
- package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
- package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
- package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
- package/template/.agents/skills/hono/SKILL.md +17 -3
- package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
- package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
- package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
- package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
- package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
- package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
- package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
- package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
- package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
- package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
- package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
- package/template/.agents/skills/supabase/SKILL.md +5 -1
- package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
- package/template/.agents/skills/writing/humanizer/README.md +209 -0
- package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
- package/template/.agents/upstreams.json +21 -22
- 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
|
-
`
|
|
1047
|
+
`Mention it once, in this form: "A newer Impeccable (v${latestVersion}) is available. ` +
|
|
1020
1048
|
`Update now? It runs \`npx impeccable update\`." ` +
|
|
1021
|
-
`
|
|
1022
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
164
|
+
* Return snapshot files matching `suffix`, sorted oldest → newest.
|
|
109
165
|
*/
|
|
110
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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
|
}
|