canicode 0.11.0 → 0.11.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/dist/cli/index.js CHANGED
@@ -1377,7 +1377,9 @@ var EVENTS = {
1377
1377
  // cannot import `core/monitoring` directly, so the event fires through a
1378
1378
  // caller-supplied callback. Define the typed name here so a future consumer
1379
1379
  // has a single place to wire it up.
1380
- ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`
1380
+ ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`,
1381
+ /** CLI `canicode roundtrip-tally` completed successfully. */
1382
+ ROUNDTRIP_TALLY: `${EVENT_PREFIX}roundtrip_tally`
1381
1383
  };
1382
1384
 
1383
1385
  // src/core/monitoring/capture.ts
@@ -1529,6 +1531,14 @@ function printDocsSetup() {
1529
1531
  console.log(`
1530
1532
  CANICODE SETUP GUIDE
1531
1533
 
1534
+ Skills at a glance: canicode-gotchas = survey answers saved locally (memo-only).
1535
+ canicode-roundtrip = same flow plus writes to Figma via use_figma (canvas).
1536
+
1537
+ Token safety: Do NOT paste your Figma token into Claude, Cursor, or other
1538
+ agent chats \u2014 transcripts can retain it. Use:
1539
+ FIGMA_TOKEN=figd_\u2026 npx canicode init
1540
+ or run \`npx canicode init\` and enter the token only at the CLI prompt.
1541
+
1532
1542
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1533
1543
  1. CLI (REST API)
1534
1544
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
@@ -1556,6 +1566,8 @@ CANICODE SETUP GUIDE
1556
1566
  2. CLAUDE CODE SKILLS (requires FIGMA_TOKEN)
1557
1567
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1558
1568
 
1569
+ (Same token safety as above \u2014 env var or interactive prompt, not chat.)
1570
+
1559
1571
  Setup:
1560
1572
  canicode init --token figd_xxxxxxxxxxxxx
1561
1573
  (installs three skills into ./.claude/skills/ alongside the token)
@@ -1575,6 +1587,35 @@ CANICODE SETUP GUIDE
1575
1587
  /canicode-gotchas <url> Run a gotcha survey
1576
1588
  /canicode-roundtrip <url> Analyze, fix gotchas in Figma, re-analyze
1577
1589
 
1590
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1591
+ 3. CURSOR SKILLS (requires FIGMA_TOKEN)
1592
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1593
+
1594
+ (Same token safety as above \u2014 env var or interactive prompt, not chat.)
1595
+
1596
+ Setup:
1597
+ canicode init --token figd_xxxxxxxxxxxxx --cursor-skills
1598
+ (installs Cursor copies of the three skills into ./.cursor/skills/)
1599
+
1600
+ Installed skills:
1601
+ canicode Lightweight CLI wrapper
1602
+ canicode-gotchas Standalone gotcha survey
1603
+ canicode-roundtrip Full analyze -> gotcha -> apply roundtrip
1604
+
1605
+ Flags:
1606
+ --cursor-skills Install Cursor copies of all three skills into .cursor/skills/
1607
+ --no-skills Skip Claude Code skills (with --cursor-skills, still installs
1608
+ the Cursor bundle plus the shared gotchas answer file)
1609
+ --force Overwrite existing skill files without prompting
1610
+
1611
+ Use (in Cursor Agent chat):
1612
+ @canicode <figma-url>
1613
+ @canicode-gotchas <figma-url> Run a gotcha survey
1614
+ @canicode-roundtrip <figma-url> Analyze, fix gotchas in Figma, re-analyze
1615
+
1616
+ See also: docs/CUSTOMIZATION.md#cursor-mcp-canicode (Figma MCP required for roundtrip
1617
+ writes; analyze-only works without it).
1618
+
1578
1619
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1579
1620
  TOKEN PRIORITY
1580
1621
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
@@ -1590,6 +1631,7 @@ CANICODE SETUP GUIDE
1590
1631
  CI/CD, automation -> CLI + FIGMA_TOKEN env var
1591
1632
  Claude Code (full) -> canicode MCP server + FIGMA_TOKEN
1592
1633
  Claude Code (light) -> /canicode skill + FIGMA_TOKEN
1634
+ Cursor Agent -> canicode init --cursor-skills + Figma MCP
1593
1635
  In Figma -> Figma Plugin
1594
1636
  Browser -> Web App (GitHub Pages)
1595
1637
  Quick trial, offline -> CLI + JSON fixtures
@@ -3520,9 +3562,27 @@ defineRule({
3520
3562
  definition: missingPrototypeDef,
3521
3563
  check: missingPrototypeCheck
3522
3564
  });
3565
+ var AcknowledgmentIntentSchema = z.object({
3566
+ field: z.string(),
3567
+ value: z.unknown(),
3568
+ scope: z.enum(["instance", "definition"])
3569
+ });
3570
+ var AcknowledgmentSceneWriteOutcomeSchema = z.object({
3571
+ result: z.enum([
3572
+ "succeeded",
3573
+ "silent-ignored",
3574
+ "api-rejected",
3575
+ "user-declined-propagation",
3576
+ "unknown"
3577
+ ]),
3578
+ reason: z.string().optional()
3579
+ });
3523
3580
  var AcknowledgmentSchema = z.object({
3524
3581
  nodeId: z.string(),
3525
- ruleId: z.string()
3582
+ ruleId: z.string(),
3583
+ intent: AcknowledgmentIntentSchema.optional(),
3584
+ sceneWriteOutcome: AcknowledgmentSceneWriteOutcomeSchema.optional(),
3585
+ codegenDirective: z.string().optional()
3526
3586
  });
3527
3587
  var AcknowledgmentListSchema = z.array(AcknowledgmentSchema);
3528
3588
  function normalizeNodeId(id) {
@@ -4172,7 +4232,7 @@ function computeApplyContext(violation, instanceContext) {
4172
4232
  }
4173
4233
 
4174
4234
  // package.json
4175
- var version2 = "0.11.0";
4235
+ var version2 = "0.11.1";
4176
4236
 
4177
4237
  // src/core/engine/scoring.ts
4178
4238
  function computeTotalScorePerCategory(configs) {
@@ -5660,7 +5720,7 @@ var AnalyzeOptionsSchema = z.object({
5660
5720
  scope: z.enum(["page", "component"]).optional()
5661
5721
  });
5662
5722
  function registerAnalyze(cli2) {
5663
- cli2.command("analyze <input>", "Analyze a Figma file or JSON fixture").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--output <path>", "HTML report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").option("--json", "Output JSON results to stdout (same format as MCP)").option("--acknowledgments <path>", "(#371) Path to a JSON file containing [{ nodeId, ruleId }] pairs harvested from canicode-authored Figma annotations. Matching issues are flagged acknowledged and contribute half weight to density.").option("--scope <scope>", "(#404) Override analysis scope: `page` (screen/section \u2014 container bounds are required) or `component` (standalone reusable unit \u2014 root FILL is the design contract). Defaults to auto-detection from the root node type.").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
5723
+ cli2.command("analyze <input>", "Analyze a Figma file or JSON fixture").option("--preset <preset>", "Analysis preset (relaxed | dev-friendly | ai-ready | strict)").option("--output <path>", "HTML report output path").option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)").option("--api", "Load via Figma REST API (requires FIGMA_TOKEN)").option("--screenshot", "Include screenshot comparison in report (requires ANTHROPIC_API_KEY)").option("--config <path>", "Path to config JSON file (override rule scores/settings)").option("--no-open", "Don't open report in browser after analysis").option("--json", "Output JSON results to stdout (same format as MCP)").option("--acknowledgments <path>", "(#371 / ADR-019) Path to JSON acknowledgments from canicode Figma annotations (nodeId, ruleId; optional intent / sceneWriteOutcome / codegenDirective per #444). Matching issues are flagged acknowledged and contribute half weight to density.").option("--scope <scope>", "(#404) Override analysis scope: `page` (screen/section \u2014 container bounds are required) or `component` (standalone reusable unit \u2014 root FILL is the design contract). Defaults to auto-detection from the root node type.").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign").example(" canicode analyze https://www.figma.com/design/ABC123/MyDesign --api --token YOUR_TOKEN").example(" canicode analyze ./fixtures/my-design --output report.html").example(" canicode analyze ./fixtures/my-design --config ./my-config.json").action(async (input, rawOptions) => {
5664
5724
  const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
5665
5725
  if (!parseResult.success) {
5666
5726
  const msg = parseResult.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
@@ -6260,6 +6320,221 @@ ${msg}`);
6260
6320
  }
6261
6321
  });
6262
6322
  }
6323
+ var DetectionSchema = z.literal("rule-based");
6324
+ var OutputChannelSchema = z.enum(["score", "annotation"]);
6325
+ var PersistenceIntentSchema = z.enum(["transient", "durable"]);
6326
+ var RulePurposeSchema = z.enum(["violation", "info-collection"]);
6327
+
6328
+ // src/core/contracts/gotcha-survey.ts
6329
+ var GradeSchema = z.enum(["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"]);
6330
+ var InstanceContextSchema = z.object({
6331
+ parentInstanceNodeId: z.string(),
6332
+ sourceNodeId: z.string(),
6333
+ sourceComponentId: z.string().optional(),
6334
+ sourceComponentName: z.string().optional()
6335
+ });
6336
+ var RuleApplyStrategySchema = z.enum([
6337
+ "property-mod",
6338
+ "structural-mod",
6339
+ "annotation",
6340
+ "auto-fix"
6341
+ ]);
6342
+ var TargetPropertySchema = z.union([z.string(), z.array(z.string())]);
6343
+ var AnnotationPropertySchema = z.object({ type: z.string() });
6344
+ var GotchaDetectionSchema = DetectionSchema;
6345
+ var GotchaOutputChannelSchema = OutputChannelSchema.extract([
6346
+ "annotation"
6347
+ ]);
6348
+ var GotchaPersistenceIntentSchema = PersistenceIntentSchema.extract([
6349
+ "durable"
6350
+ ]);
6351
+ var GotchaSurveyQuestionSchema = z.object({
6352
+ nodeId: z.string(),
6353
+ nodeName: z.string(),
6354
+ ruleId: z.string(),
6355
+ detection: GotchaDetectionSchema,
6356
+ outputChannel: GotchaOutputChannelSchema,
6357
+ persistenceIntent: GotchaPersistenceIntentSchema,
6358
+ /**
6359
+ * #406: Classifies the triggering rule as `violation` (score-primary,
6360
+ * gotcha secondary) or `info-collection` (annotation-primary, score
6361
+ * minimal). Consumers use this to prioritize answers that collect durable
6362
+ * implementation context over answers that merely describe how to fix a
6363
+ * violation the rule will stop firing for.
6364
+ */
6365
+ purpose: RulePurposeSchema,
6366
+ severity: SeveritySchema,
6367
+ question: z.string(),
6368
+ hint: z.string(),
6369
+ example: z.string(),
6370
+ instanceContext: InstanceContextSchema.optional(),
6371
+ applyStrategy: RuleApplyStrategySchema,
6372
+ targetProperty: TargetPropertySchema.optional(),
6373
+ annotationProperties: z.array(AnnotationPropertySchema).optional(),
6374
+ suggestedName: z.string().optional(),
6375
+ isInstanceChild: z.boolean(),
6376
+ sourceChildId: z.string().optional(),
6377
+ // #356: when this question collapses N instance-child issues that share the
6378
+ // same `(sourceComponentId, sourceNodeId, ruleId)` tuple, `replicas` is the
6379
+ // total instance count (>=2) and `replicaNodeIds` lists every instance scene
6380
+ // node id OTHER than the kept `nodeId`. Apply step iterates
6381
+ // `[nodeId, ...replicaNodeIds]` so the same answer lands on every replica.
6382
+ // Single-instance questions omit both fields.
6383
+ replicas: z.number().int().min(2).optional(),
6384
+ replicaNodeIds: z.array(z.string()).optional()
6385
+ });
6386
+ var SurveyQuestionBatchSchema = z.object({
6387
+ ruleId: z.string(),
6388
+ /**
6389
+ * `true` when every member shares an answer-shape uniformly applicable to
6390
+ * all of them (e.g. one `min-width` value covers all FILL children).
6391
+ * The SKILL renders one shared prompt for `batchable: true` batches with
6392
+ * `questions.length >= 2`; everything else falls through to the
6393
+ * single-question template.
6394
+ */
6395
+ batchable: z.boolean(),
6396
+ questions: z.array(GotchaSurveyQuestionSchema),
6397
+ /**
6398
+ * Sum of `max(question.replicas, 1)` across `questions`. Counts the
6399
+ * actual Figma scene fan-out so the SKILL can render `N instances`
6400
+ * accurately even when one batch member already collapses multiple
6401
+ * replicas via the #356 source-component dedupe.
6402
+ */
6403
+ totalScenes: z.number().int().min(1)
6404
+ });
6405
+ var SurveyQuestionGroupSchema = z.object({
6406
+ /**
6407
+ * Shared `instanceContext` for the group, or `null` for the trailing
6408
+ * non-instance group. The SKILL emits the verbose "Instance note" header
6409
+ * once per non-null group instead of once per question (#370).
6410
+ */
6411
+ instanceContext: InstanceContextSchema.nullable(),
6412
+ batches: z.array(SurveyQuestionBatchSchema)
6413
+ });
6414
+ var GroupedSurveySchema = z.object({
6415
+ groups: z.array(SurveyQuestionGroupSchema)
6416
+ });
6417
+ var GotchaSurveySchema = z.object({
6418
+ designGrade: GradeSchema,
6419
+ isReadyForCodeGen: z.boolean(),
6420
+ questions: z.array(GotchaSurveyQuestionSchema),
6421
+ groupedQuestions: GroupedSurveySchema,
6422
+ /**
6423
+ * #384 — canonical identifier for this design across canicode runs.
6424
+ * Computed by `computeDesignKey(input)` (`<fileKey>#<nodeId>` for Figma
6425
+ * URLs, absolute path for fixtures). The `canicode-gotchas` SKILL reads
6426
+ * this directly when upserting the per-design section, so the SKILL.md
6427
+ * prose no longer parses URLs (per ADR-016).
6428
+ */
6429
+ designKey: z.string()
6430
+ });
6431
+ var AnswersMapSchema = z.record(
6432
+ z.string(),
6433
+ z.union([
6434
+ z.object({ answer: z.string() }),
6435
+ z.object({ skipped: z.literal(true) })
6436
+ ])
6437
+ );
6438
+ var RenderGotchaSectionInputSchema = z.object({
6439
+ questions: z.array(GotchaSurveyQuestionSchema),
6440
+ answers: AnswersMapSchema,
6441
+ designName: z.string(),
6442
+ figmaUrl: z.string(),
6443
+ designKey: z.string(),
6444
+ designGrade: z.string(),
6445
+ analyzedAt: z.string(),
6446
+ /** Local date for the section header (`YYYY-MM-DD`). */
6447
+ today: z.string()
6448
+ });
6449
+ function isSkippedAnswer(nodeId, answers) {
6450
+ const v = answers[nodeId];
6451
+ if (v === void 0) return true;
6452
+ if ("skipped" in v && v.skipped === true) return true;
6453
+ if ("answer" in v) return false;
6454
+ return true;
6455
+ }
6456
+ function skippedCountsByRule(skippedQs) {
6457
+ const m = /* @__PURE__ */ new Map();
6458
+ for (const q of skippedQs) {
6459
+ m.set(q.ruleId, (m.get(q.ruleId) ?? 0) + 1);
6460
+ }
6461
+ return m;
6462
+ }
6463
+ function renderSkippedCompact(skippedQs) {
6464
+ const n = skippedQs.length;
6465
+ const counts = skippedCountsByRule(skippedQs);
6466
+ const lines = [`#### Skipped (${n})`, ""];
6467
+ const sortedRules = [...counts.keys()].sort((a, b) => a.localeCompare(b));
6468
+ for (const ruleId of sortedRules) {
6469
+ const c = counts.get(ruleId) ?? 0;
6470
+ lines.push(`- \`${ruleId}\` \xD7 ${c}`);
6471
+ }
6472
+ lines.push("");
6473
+ return lines.join("\n");
6474
+ }
6475
+ function renderInstanceContextBullet(q) {
6476
+ const ic = q.instanceContext;
6477
+ if (!ic) return null;
6478
+ let componentPart = "";
6479
+ if (ic.sourceComponentName !== void 0 && ic.sourceComponentId !== void 0) {
6480
+ componentPart = `, component \`${ic.sourceComponentName}\` / \`${ic.sourceComponentId}\``;
6481
+ } else if (ic.sourceComponentName !== void 0) {
6482
+ componentPart = `, component \`${ic.sourceComponentName}\``;
6483
+ } else if (ic.sourceComponentId !== void 0) {
6484
+ componentPart = `, component \`${ic.sourceComponentId}\``;
6485
+ }
6486
+ return `- **Instance context**: parent instance \`${ic.parentInstanceNodeId}\`, source node \`${ic.sourceNodeId}\`${componentPart} \u2014 roundtrip apply uses this to write on the source definition when instance overrides fail.`;
6487
+ }
6488
+ function renderGotchaSection(raw) {
6489
+ const input = RenderGotchaSectionInputSchema.parse(raw);
6490
+ const header = [
6491
+ `## #{{SECTION_NUMBER}} \u2014 ${input.designName} \u2014 ${input.today}`,
6492
+ "",
6493
+ `- **Figma URL**: ${input.figmaUrl}`,
6494
+ `- **Design key**: ${input.designKey}`,
6495
+ `- **Grade**: ${input.designGrade}`,
6496
+ `- **Analyzed at**: ${input.analyzedAt}`,
6497
+ "",
6498
+ "### Gotchas",
6499
+ ""
6500
+ ].join("\n");
6501
+ const answered = [];
6502
+ const skippedList = [];
6503
+ for (const q of input.questions) {
6504
+ if (isSkippedAnswer(q.nodeId, input.answers)) skippedList.push(q);
6505
+ else answered.push(q);
6506
+ }
6507
+ const blocks = [];
6508
+ for (const q of answered) {
6509
+ const v = input.answers[q.nodeId];
6510
+ if (v === void 0 || !("answer" in v)) {
6511
+ throw new Error(
6512
+ `renderGotchaSection: expected answer for nodeId ${q.nodeId} (answered set)`
6513
+ );
6514
+ }
6515
+ const answerLine = v.answer;
6516
+ const lines = [
6517
+ `#### ${q.ruleId} \u2014 ${q.nodeName}`,
6518
+ "",
6519
+ `- **Severity**: ${q.severity}`,
6520
+ `- **Node ID**: ${q.nodeId}`
6521
+ ];
6522
+ const icBullet = renderInstanceContextBullet(q);
6523
+ if (icBullet !== null) {
6524
+ lines.push(icBullet);
6525
+ }
6526
+ lines.push(
6527
+ `- **Question**: ${q.question}`,
6528
+ `- **Answer**: ${answerLine}`,
6529
+ ""
6530
+ );
6531
+ blocks.push(lines.join("\n"));
6532
+ }
6533
+ if (skippedList.length > 0) {
6534
+ blocks.push(renderSkippedCompact(skippedList));
6535
+ }
6536
+ return `${header}${blocks.join("")}`.replace(/\s+$/, "") + "\n";
6537
+ }
6263
6538
  z.enum([
6264
6539
  "missing",
6265
6540
  "valid",
@@ -6376,10 +6651,26 @@ function ensureTrailingNewline(s) {
6376
6651
  }
6377
6652
 
6378
6653
  // src/cli/commands/upsert-gotcha-section.ts
6654
+ var AnswerSchema = z.union([
6655
+ z.object({ answer: z.string() }),
6656
+ z.object({ skipped: z.literal(true) })
6657
+ ]);
6658
+ var UpsertJsonPayloadSchema = z.object({
6659
+ survey: GotchaSurveySchema.pick({
6660
+ designKey: true,
6661
+ designGrade: true,
6662
+ questions: true
6663
+ }),
6664
+ answers: z.record(z.string(), AnswerSchema),
6665
+ designName: z.string(),
6666
+ figmaUrl: z.string(),
6667
+ analyzedAt: z.string(),
6668
+ today: z.string()
6669
+ });
6379
6670
  var UpsertOptionsSchema = z.object({
6380
6671
  file: z.string().min(1, "--file is required"),
6381
6672
  designKey: z.string().min(1, "--design-key is required"),
6382
- section: z.string().min(1, "--section is required (use '-' to read stdin)")
6673
+ input: z.string().min(1, "--input is required (use '--input=-' to read stdin)")
6383
6674
  });
6384
6675
  var USER_MESSAGES = {
6385
6676
  missing: "Gotchas SKILL.md not found at the given path. Run `canicode init` first, then re-invoke this skill.",
@@ -6392,8 +6683,38 @@ async function readStdin() {
6392
6683
  }
6393
6684
  return Buffer.concat(chunks).toString("utf-8");
6394
6685
  }
6686
+ function parseUpsertPayload(rawJson) {
6687
+ let parsed;
6688
+ try {
6689
+ parsed = JSON.parse(rawJson);
6690
+ } catch {
6691
+ throw new Error("Invalid JSON in --input");
6692
+ }
6693
+ const result = UpsertJsonPayloadSchema.safeParse(parsed);
6694
+ if (!result.success) {
6695
+ const msg = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
6696
+ throw new Error(`Invalid upsert payload: ${msg}`);
6697
+ }
6698
+ return result.data;
6699
+ }
6395
6700
  async function runUpsertGotchaSection(options) {
6396
- const sectionMarkdown = options.section === "-" ? await readStdin() : options.section;
6701
+ const rawJson = options.input === "-" ? await readStdin() : readFileSync(options.input, "utf-8");
6702
+ const payload = parseUpsertPayload(rawJson);
6703
+ if (payload.survey.designKey !== options.designKey) {
6704
+ throw new Error(
6705
+ `--design-key (${options.designKey}) does not match survey.designKey (${payload.survey.designKey})`
6706
+ );
6707
+ }
6708
+ const sectionMarkdown = renderGotchaSection({
6709
+ questions: payload.survey.questions,
6710
+ answers: payload.answers,
6711
+ designName: payload.designName,
6712
+ figmaUrl: payload.figmaUrl,
6713
+ designKey: payload.survey.designKey,
6714
+ designGrade: payload.survey.designGrade,
6715
+ analyzedAt: payload.analyzedAt,
6716
+ today: payload.today
6717
+ });
6397
6718
  const currentContent = existsSync(options.file) ? readFileSync(options.file, "utf-8") : null;
6398
6719
  const { state, newContent, plan } = renderUpsertedFile({
6399
6720
  currentContent,
@@ -6406,7 +6727,8 @@ async function runUpsertGotchaSection(options) {
6406
6727
  action: null,
6407
6728
  sectionNumber: null,
6408
6729
  wrote: false,
6409
- userMessage: USER_MESSAGES[state] ?? null
6730
+ userMessage: USER_MESSAGES[state] ?? null,
6731
+ designKey: payload.survey.designKey
6410
6732
  };
6411
6733
  }
6412
6734
  writeFileSync(options.file, newContent, "utf-8");
@@ -6415,7 +6737,8 @@ async function runUpsertGotchaSection(options) {
6415
6737
  action: plan?.action ?? null,
6416
6738
  sectionNumber: plan?.sectionNumber ?? null,
6417
6739
  wrote: true,
6418
- userMessage: null
6740
+ userMessage: null,
6741
+ designKey: payload.survey.designKey
6419
6742
  };
6420
6743
  }
6421
6744
  function registerUpsertGotchaSection(cli2) {
@@ -6426,8 +6749,8 @@ function registerUpsertGotchaSection(cli2) {
6426
6749
  "--design-key <key>",
6427
6750
  "Canonical design key from gotcha-survey's response"
6428
6751
  ).option(
6429
- "--section <markdown>",
6430
- "Already-rendered per-design section markdown. Use '-' to read from stdin."
6752
+ "--input <path>",
6753
+ "JSON payload path, or '--input=-' to read JSON from stdin (cac parses a bare '-' as a flag, so the '=' form is required)."
6431
6754
  ).action(async (rawOptions) => {
6432
6755
  const parseResult = UpsertOptionsSchema.safeParse(rawOptions);
6433
6756
  if (!parseResult.success) {
@@ -6894,14 +7217,16 @@ function formatNextSteps(opts) {
6894
7217
  "",
6895
7218
  " Next:",
6896
7219
  " 1. Restart Cursor or reload MCP (so skills + MCP tools load in a fresh session)",
6897
- " 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)"
7220
+ " 2. In Agent chat, @ canicode-roundtrip with your Figma URL (or @ canicode-gotchas for survey-only)",
7221
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode (project `.cursor/mcp.json`), then reload MCP; otherwise skills keep using `npx canicode \u2026` (#433)."
6898
7222
  ].join("\n");
6899
7223
  }
6900
7224
  return [
6901
7225
  "",
6902
7226
  " Next:",
6903
7227
  " 1. Restart Claude Code (the newly installed skills only load on a fresh session)",
6904
- " 2. Run /canicode-roundtrip <figma-url>"
7228
+ " 2. Run /canicode-roundtrip <figma-url>",
7229
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so `analyze` / `gotcha-survey` tools load \u2014 otherwise skills shell out to `npx canicode \u2026` (#433)."
6905
7230
  ].join("\n");
6906
7231
  }
6907
7232
  if (cursor) {
@@ -6910,7 +7235,8 @@ function formatNextSteps(opts) {
6910
7235
  " Next:",
6911
7236
  " 1. Add Figma MCP to .cursor/mcp.json (see https://github.com/let-sunny/canicode/blob/main/docs/CUSTOMIZATION.md#cursor-mcp-canicode and Figma MCP docs)",
6912
7237
  " 2. Restart Cursor so Figma tools (e.g. use_figma) load",
6913
- " 3. @ canicode-roundtrip with your Figma URL for full roundtrip"
7238
+ " 3. @ canicode-roundtrip with your Figma URL for full roundtrip",
7239
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: add `canicode-mcp` per the Customization guide (`#cursor-mcp-canicode`), then reload MCP; otherwise skills keep using `npx canicode \u2026` (#433)."
6914
7240
  ].join("\n");
6915
7241
  }
6916
7242
  return [
@@ -6919,20 +7245,29 @@ function formatNextSteps(opts) {
6919
7245
  " 1. Install Figma MCP:",
6920
7246
  " claude mcp add -s project -t http figma https://mcp.figma.com/mcp",
6921
7247
  " 2. Restart Claude Code (so the new skills + Figma MCP tools both load)",
6922
- " 3. Run /canicode-roundtrip <figma-url>"
7248
+ " 3. Run /canicode-roundtrip <figma-url>",
7249
+ " \u2022 Optional \u2014 faster canicode MCP than `npx`: `claude mcp add canicode -- npx --yes --package=canicode canicode-mcp`, then restart Claude Code so MCP tools load \u2014 otherwise skills shell out to `npx canicode \u2026` (#433)."
6923
7250
  ].join("\n");
6924
7251
  }
6925
7252
  var InitOptionsSchema = z.object({
6926
7253
  token: z.string().optional(),
6927
7254
  global: z.boolean().optional(),
6928
- // cac maps `--no-skills` to `skills: false` (mirrors `--no-telemetry`).
7255
+ // Declared positively as `--skills`; mri's built-in `--no-` prefix handling
7256
+ // still maps `--no-skills` to `skills: false`. Declaring the option
7257
+ // positively avoids cac's `(default: true)` artifact on negated flags.
6929
7258
  skills: z.boolean().optional(),
6930
7259
  /** Install `skills/cursor/*` into `.cursor/skills/` (canicode, gotchas, roundtrip — issue #407). */
6931
7260
  cursorSkills: z.boolean().optional(),
6932
7261
  force: z.boolean().optional()
6933
7262
  });
6934
7263
  function registerInit(cli2) {
6935
- cli2.command("init", "Set up canicode with Figma API token").option("--token <token>", "Save Figma API token and install Claude Code skills to .claude/skills/").option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--no-skills", "Skip skill installation (token only)").option("--cursor-skills", "Also install Cursor copies of canicode / canicode-gotchas / canicode-roundtrip under .cursor/skills/").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
7264
+ cli2.command(
7265
+ "init",
7266
+ "Set up canicode with Figma API token (never paste a token into agent chat \u2014 use FIGMA_TOKEN=\u2026 or the interactive prompt)"
7267
+ ).option(
7268
+ "--token <token>",
7269
+ "Save Figma API token (use env/CLI only \u2014 not agent chat) and install Claude Code skills to .claude/skills/"
7270
+ ).option("--global", "Install skills to ~/.claude/skills/ instead of ./.claude/skills/").option("--skills", "Install Claude Code skills into .claude/skills/ (default: on \u2014 pass --no-skills to opt out)").option("--cursor-skills", "Also install Cursor copies of canicode / canicode-gotchas / canicode-roundtrip under .cursor/skills/").option("--force", "Overwrite existing skill files without prompting (also for non-TTY/CI)").action(async (rawOptions) => {
6936
7271
  try {
6937
7272
  const parseResult = InitOptionsSchema.safeParse(rawOptions);
6938
7273
  if (!parseResult.success) {
@@ -7044,6 +7379,10 @@ ${msg}`);
7044
7379
  }
7045
7380
  console.log(`CANICODE SETUP
7046
7381
  `);
7382
+ console.log(
7383
+ ` Never paste your token into Claude/Cursor chat \u2014 use FIGMA_TOKEN=\u2026 npx canicode init or this prompt only.
7384
+ `
7385
+ );
7047
7386
  console.log(` canicode init --token YOUR_FIGMA_TOKEN`);
7048
7387
  console.log(` Get token: figma.com > Settings > Personal access tokens
7049
7388
  `);
@@ -7151,6 +7490,164 @@ function registerListRules(cli2) {
7151
7490
  }
7152
7491
  });
7153
7492
  }
7493
+ var StepFourReportSchema = z.object({
7494
+ /** ✅ — Strategy A property write (or A's auto-fix branch) succeeded. */
7495
+ resolved: z.number().int().min(0),
7496
+ /** 📝 — Strategy C wrote a Figma annotation (or A/B fell back to one). */
7497
+ annotated: z.number().int().min(0),
7498
+ /**
7499
+ * 🌐 — `applyWithInstanceFallback` propagated the write up to the
7500
+ * source COMPONENT definition (only counted when
7501
+ * `allowDefinitionWrite: true`).
7502
+ */
7503
+ definitionWritten: z.number().int().min(0),
7504
+ /**
7505
+ * ⏭️ — User said "skip", "n/a", or otherwise declined a per-question
7506
+ * confirmation (Strategy B opt-out, etc.).
7507
+ */
7508
+ skipped: z.number().int().min(0)
7509
+ });
7510
+ var ReanalyzeForTallySchema = z.object({
7511
+ /**
7512
+ * Total remaining issues from the re-analyze. Maps to the existing
7513
+ * `issueCount` (analyze JSON) / `questions.length` (gotcha-survey JSON)
7514
+ * field — both downstream channels populate it.
7515
+ */
7516
+ issueCount: z.number().int().min(0),
7517
+ /**
7518
+ * Issues the re-analyze flagged with `acknowledged: true` because they
7519
+ * matched a canicode-authored Figma annotation harvested in Step 5a.
7520
+ * From the analyze response's top-level `acknowledgedCount` (#371).
7521
+ */
7522
+ acknowledgedCount: z.number().int().min(0)
7523
+ });
7524
+ z.object({
7525
+ /** ✅ resolved (passthrough from `stepFourReport.resolved`). */
7526
+ X: z.number().int().min(0),
7527
+ /** 📝 annotated. */
7528
+ Y: z.number().int().min(0),
7529
+ /** 🌐 definition writes propagated. */
7530
+ Z: z.number().int().min(0),
7531
+ /** ⏭️ skipped. */
7532
+ W: z.number().int().min(0),
7533
+ /** `X + Y + Z + W` — questions the SKILL acted on in Step 4. */
7534
+ N: z.number().int().min(0),
7535
+ /** `reanalyzeResponse.issueCount` — total remaining after re-analyze. */
7536
+ V: z.number().int().min(0),
7537
+ /**
7538
+ * `reanalyzeResponse.acknowledgedCount` — the slice of `V` that carries
7539
+ * a canicode annotation (counted at half weight by the density score
7540
+ * per #371, but still surfaced as remaining).
7541
+ */
7542
+ V_ack: z.number().int().min(0),
7543
+ /** `V - V_ack` — issues with no annotation; the user's follow-up backlog. */
7544
+ V_open: z.number().int().min(0)
7545
+ });
7546
+
7547
+ // src/core/roundtrip/compute-roundtrip-tally.ts
7548
+ function computeRoundtripTally(args) {
7549
+ const { stepFourReport, reanalyzeResponse } = args;
7550
+ const { resolved, annotated, definitionWritten, skipped } = stepFourReport;
7551
+ const { issueCount, acknowledgedCount } = reanalyzeResponse;
7552
+ if (acknowledgedCount > issueCount) {
7553
+ throw new Error(
7554
+ `computeRoundtripTally: reanalyzeResponse.acknowledgedCount (${acknowledgedCount}) cannot exceed issueCount (${issueCount}). Acknowledged issues are a subset of remaining issues.`
7555
+ );
7556
+ }
7557
+ return {
7558
+ X: resolved,
7559
+ Y: annotated,
7560
+ Z: definitionWritten,
7561
+ W: skipped,
7562
+ N: resolved + annotated + definitionWritten + skipped,
7563
+ V: issueCount,
7564
+ V_ack: acknowledgedCount,
7565
+ V_open: issueCount - acknowledgedCount
7566
+ };
7567
+ }
7568
+
7569
+ // src/cli/commands/roundtrip-tally.ts
7570
+ var RoundtripTallyCliOptionsSchema = z.object({
7571
+ analyze: z.string().min(1),
7572
+ step4: z.string().min(1)
7573
+ });
7574
+ function parseJsonFile(label, path, raw) {
7575
+ try {
7576
+ return JSON.parse(raw);
7577
+ } catch (err) {
7578
+ throw new Error(
7579
+ `roundtrip-tally: ${label} is not valid JSON (${path})`,
7580
+ { cause: err }
7581
+ );
7582
+ }
7583
+ }
7584
+ async function readUtf8File(label, path) {
7585
+ try {
7586
+ return await readFile(path, "utf-8");
7587
+ } catch (err) {
7588
+ const detail = err instanceof Error ? err.message : String(err);
7589
+ throw new Error(`roundtrip-tally: cannot read ${label} (${path}): ${detail}`, {
7590
+ cause: err
7591
+ });
7592
+ }
7593
+ }
7594
+ async function computeRoundtripTallyFromSavedFiles(args) {
7595
+ const [analyzeRaw, step4Raw] = await Promise.all([
7596
+ readUtf8File("--analyze", args.analyzePath),
7597
+ readUtf8File("--step4", args.step4Path)
7598
+ ]);
7599
+ const analyzeParsed = parseJsonFile("--analyze", args.analyzePath, analyzeRaw);
7600
+ const step4Parsed = parseJsonFile("--step4", args.step4Path, step4Raw);
7601
+ const reanalyzeResult = ReanalyzeForTallySchema.safeParse(analyzeParsed);
7602
+ if (!reanalyzeResult.success) {
7603
+ const msg = reanalyzeResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
7604
+ throw new Error(
7605
+ `roundtrip-tally: --analyze must include issueCount and acknowledgedCount (${args.analyzePath}): ${msg}`
7606
+ );
7607
+ }
7608
+ const stepFourResult = StepFourReportSchema.safeParse(step4Parsed);
7609
+ if (!stepFourResult.success) {
7610
+ const msg = stepFourResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
7611
+ throw new Error(
7612
+ `roundtrip-tally: --step4 must match StepFourReport (${args.step4Path}): ${msg}`
7613
+ );
7614
+ }
7615
+ return computeRoundtripTally({
7616
+ stepFourReport: stepFourResult.data,
7617
+ reanalyzeResponse: reanalyzeResult.data
7618
+ });
7619
+ }
7620
+ function registerRoundtripTally(cli2) {
7621
+ cli2.command(
7622
+ "roundtrip-tally",
7623
+ "Print the Step 5 roundtrip tally from re-analyze JSON and Step 4 outcome counts"
7624
+ ).option("--analyze <path>", "Path to re-analyze JSON (`canicode analyze --json` output)").option("--step4 <path>", "Path to Step 4 structured counts (resolved / annotated / definitionWritten / skipped)").example(" canicode roundtrip-tally --analyze ./reanalyze.json --step4 ./step4-report.json").action(async (rawOptions) => {
7625
+ const parsed = RoundtripTallyCliOptionsSchema.safeParse(rawOptions);
7626
+ if (!parsed.success) {
7627
+ const msg = parsed.error.issues.map((i) => `--${i.path.join(".")}: ${i.message}`).join("\n");
7628
+ console.error(`
7629
+ roundtrip-tally requires --analyze and --step4:
7630
+ ${msg}`);
7631
+ process.exit(1);
7632
+ }
7633
+ try {
7634
+ const tally = await computeRoundtripTallyFromSavedFiles({
7635
+ analyzePath: parsed.data.analyze,
7636
+ step4Path: parsed.data.step4
7637
+ });
7638
+ console.log(JSON.stringify(tally, null, 2));
7639
+ trackEvent(EVENTS.ROUNDTRIP_TALLY);
7640
+ } catch (err) {
7641
+ if (err instanceof Error) {
7642
+ trackError(err, { command: "roundtrip-tally" });
7643
+ console.error(err.message);
7644
+ } else {
7645
+ console.error(err);
7646
+ }
7647
+ process.exit(1);
7648
+ }
7649
+ });
7650
+ }
7154
7651
 
7155
7652
  // src/cli/internal-commands.ts
7156
7653
  var INTERNAL_COMMANDS = [
@@ -8429,9 +8926,9 @@ function registerCalibrateEvaluate(cli2) {
8429
8926
  if (!existsSync(conversionPath)) {
8430
8927
  throw new Error(`Conversion file not found: ${conversionPath}`);
8431
8928
  }
8432
- const { readFile: readFile4 } = await import('fs/promises');
8433
- const analysisData = JSON.parse(await readFile4(analysisPath, "utf-8"));
8434
- const conversionData = JSON.parse(await readFile4(conversionPath, "utf-8"));
8929
+ const { readFile: readFile5 } = await import('fs/promises');
8930
+ const analysisData = JSON.parse(await readFile5(analysisPath, "utf-8"));
8931
+ const conversionData = JSON.parse(await readFile5(conversionPath, "utf-8"));
8435
8932
  let fixtureName;
8436
8933
  if (options.runDir) {
8437
8934
  const dirName = resolve(options.runDir).split(/[/\\]/).pop() ?? "";
@@ -10289,6 +10786,7 @@ registerVisualCompare(cli);
10289
10786
  registerInit(cli);
10290
10787
  registerConfig(cli);
10291
10788
  registerListRules(cli);
10789
+ registerRoundtripTally(cli);
10292
10790
  registerCalibrateAnalyze(cli);
10293
10791
  registerCalibrateEvaluate(cli);
10294
10792
  registerCalibrateGapReport(cli);
@@ -10312,6 +10810,7 @@ cli.help((sections) => {
10312
10810
  section.body = section.body.split("\n").filter((line) => !INTERNAL_COMMANDS.some((cmd) => line.includes(cmd))).join("\n");
10313
10811
  }
10314
10812
  }
10813
+ sections.splice(1, 0, { body: ` ${pkg2.description}` });
10315
10814
  sections.push(
10316
10815
  {
10317
10816
  title: "\nSetup",
@@ -10334,7 +10833,8 @@ cli.help((sections) => {
10334
10833
  ` $ canicode analyze "https://www.figma.com/design/..." --api`,
10335
10834
  ` $ canicode analyze "https://www.figma.com/design/..." --preset strict`,
10336
10835
  ` $ canicode analyze "https://www.figma.com/design/..." --config ./my-config.json`,
10337
- ` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`
10836
+ ` $ canicode gotcha-survey "https://www.figma.com/design/..." --json`,
10837
+ ` $ canicode roundtrip-tally --analyze ./reanalyze.json --step4 ./step4.json`
10338
10838
  ].join("\n")
10339
10839
  },
10340
10840
  {