@agent-native/core 0.80.1 → 0.80.4

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 (59) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +65 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/action.ts +11 -0
  5. package/corpus/core/src/agent/production-agent.ts +3 -0
  6. package/corpus/core/src/cli/plan-local.ts +445 -108
  7. package/corpus/core/src/cli/skills.ts +14 -5
  8. package/corpus/core/src/mcp/build-server.ts +1 -1
  9. package/corpus/core/src/mcp/embed-app.ts +35 -1
  10. package/corpus/core/src/server/action-routes.ts +14 -0
  11. package/corpus/core/src/vite/client.ts +15 -1
  12. package/corpus/templates/clips/app/components/player/timestamped-comment-button.tsx +162 -0
  13. package/corpus/templates/clips/app/i18n/ar-SA.ts +4 -0
  14. package/corpus/templates/clips/app/i18n/de-DE.ts +4 -0
  15. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  16. package/corpus/templates/clips/app/i18n/es-ES.ts +4 -0
  17. package/corpus/templates/clips/app/i18n/fr-FR.ts +4 -0
  18. package/corpus/templates/clips/app/i18n/hi-IN.ts +4 -0
  19. package/corpus/templates/clips/app/i18n/ja-JP.ts +4 -0
  20. package/corpus/templates/clips/app/i18n/ko-KR.ts +4 -0
  21. package/corpus/templates/clips/app/i18n/pt-BR.ts +4 -0
  22. package/corpus/templates/clips/app/i18n/zh-CN.ts +4 -0
  23. package/corpus/templates/clips/app/i18n/zh-TW.ts +4 -0
  24. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +42 -18
  25. package/corpus/templates/plan/.agents/skills/visual-plan/references/local-files.md +14 -5
  26. package/corpus/templates/plan/.agents/skills/visual-recap/references/local-files.md +14 -5
  27. package/corpus/templates/plan/actions/validate-local-plan-source.ts +118 -0
  28. package/corpus/templates/plan/server/lib/plan-connector-catalog.ts +1 -0
  29. package/corpus/templates/plan/server/lib/public-action-paths.ts +1 -0
  30. package/dist/action.d.ts +8 -0
  31. package/dist/action.d.ts.map +1 -1
  32. package/dist/action.js +3 -0
  33. package/dist/action.js.map +1 -1
  34. package/dist/agent/production-agent.d.ts +3 -0
  35. package/dist/agent/production-agent.d.ts.map +1 -1
  36. package/dist/agent/production-agent.js.map +1 -1
  37. package/dist/cli/plan-local.d.ts +30 -0
  38. package/dist/cli/plan-local.d.ts.map +1 -1
  39. package/dist/cli/plan-local.js +281 -56
  40. package/dist/cli/plan-local.js.map +1 -1
  41. package/dist/cli/skills.d.ts +1 -1
  42. package/dist/cli/skills.d.ts.map +1 -1
  43. package/dist/cli/skills.js +14 -5
  44. package/dist/cli/skills.js.map +1 -1
  45. package/dist/collab/routes.d.ts +1 -1
  46. package/dist/mcp/build-server.js +1 -1
  47. package/dist/mcp/build-server.js.map +1 -1
  48. package/dist/mcp/embed-app.d.ts.map +1 -1
  49. package/dist/mcp/embed-app.js +35 -1
  50. package/dist/mcp/embed-app.js.map +1 -1
  51. package/dist/resources/handlers.d.ts +1 -1
  52. package/dist/server/action-routes.d.ts.map +1 -1
  53. package/dist/server/action-routes.js +14 -0
  54. package/dist/server/action-routes.js.map +1 -1
  55. package/dist/server/transcribe-voice.d.ts +1 -1
  56. package/dist/vite/client.d.ts.map +1 -1
  57. package/dist/vite/client.js +12 -1
  58. package/dist/vite/client.js.map +1 -1
  59. package/package.json +1 -1
@@ -141,6 +141,22 @@ export type LocalPlanBridgeServer = {
141
141
  result: LocalPlanServeResult;
142
142
  };
143
143
 
144
+ export type RendererValidationIssue = {
145
+ path: string;
146
+ message: string;
147
+ };
148
+
149
+ export type RendererValidation = {
150
+ /** Whether the renderer validate endpoint was reachable and answered. */
151
+ ran: boolean;
152
+ endpoint: string;
153
+ /** Renderer verdict — present only when `ran` is true. */
154
+ valid?: boolean;
155
+ issues?: RendererValidationIssue[];
156
+ /** Transport/endpoint error when `ran` is false (unreachable, 404, old deploy). */
157
+ error?: string;
158
+ };
159
+
144
160
  export type LocalPlanVerifyResult = {
145
161
  ok: boolean;
146
162
  dir: string;
@@ -165,6 +181,7 @@ export type LocalPlanVerifyResult = {
165
181
  mdxFiles?: string[];
166
182
  error?: string;
167
183
  };
184
+ validation: RendererValidation;
168
185
  warnings: string[];
169
186
  };
170
187
 
@@ -1023,16 +1040,21 @@ function lintColumnsBlocks(
1023
1040
  const start = match.index;
1024
1041
  const openingEnd = findJsxOpeningTagEnd(scanSource, start);
1025
1042
  if (openingEnd < 0) continue;
1026
- const opening = scanSource.slice(start, openingEnd + 1);
1027
- if (/\bcolumns\s*=/.test(opening)) {
1028
- addValidationIssue(
1029
- issues,
1030
- file,
1031
- source,
1032
- start,
1033
- 'Columns must use <Column> children, not a columns= prop. Use <Columns><Column label="Before">...</Column><Column label="After">...</Column></Columns>.',
1034
- );
1035
- }
1043
+ const opening = source.slice(start, openingEnd + 1);
1044
+ const columnsAttr = readJsxAttribute(opening, "columns");
1045
+ if (!columnsAttr) continue;
1046
+ const entries =
1047
+ columnsAttr.kind === "expression"
1048
+ ? extractTopLevelObjectLiterals(columnsAttr.value)
1049
+ : null;
1050
+ if (entries && entries.length > 0) continue;
1051
+ addValidationIssue(
1052
+ issues,
1053
+ file,
1054
+ source,
1055
+ start,
1056
+ 'Columns must use <Column> children or a columns={[{ id, label, blocks }]} array, not a bare columns= prop. Use <Columns><Column label="Before">...</Column><Column label="After">...</Column></Columns>.',
1057
+ );
1036
1058
  }
1037
1059
  }
1038
1060
 
@@ -1318,6 +1340,120 @@ function hasRequiredEnumLiteral(
1318
1340
  return allowed.includes(trimmed.slice(1, -1).trim());
1319
1341
  }
1320
1342
 
1343
+ // Shared per-item checks so the top-level `<Checklist items={[…]} />` tag and
1344
+ // blocks authored as JSON inside a container's `tabs={[…]}` / `columns={[…]}`
1345
+ // array enforce the SAME required fields. `listBase` is the absolute offset of
1346
+ // the items array in the original source so line numbers point near the item.
1347
+ function checkChecklistItemList(
1348
+ items: Array<{ source: string; start: number }>,
1349
+ listBase: number,
1350
+ file: string,
1351
+ source: string,
1352
+ issues: LocalPlanValidationIssue[],
1353
+ ) {
1354
+ items.forEach((item, index) => {
1355
+ const base = listBase + item.start;
1356
+ if (!hasRequiredStaticId(item.source)) {
1357
+ addValidationIssue(
1358
+ issues,
1359
+ file,
1360
+ source,
1361
+ base,
1362
+ `Checklist items[${index}].id is required by the Plan renderer schema; add a stable string id.`,
1363
+ );
1364
+ }
1365
+ if (!hasRequiredStaticString(item.source, "label")) {
1366
+ addValidationIssue(
1367
+ issues,
1368
+ file,
1369
+ source,
1370
+ base,
1371
+ `Checklist items[${index}].label is required by the Plan renderer schema; add a non-empty string label.`,
1372
+ );
1373
+ }
1374
+ });
1375
+ }
1376
+
1377
+ function checkQuestionObjectList(
1378
+ questionObjects: Array<{ source: string; start: number }>,
1379
+ listBase: number,
1380
+ tag: string,
1381
+ file: string,
1382
+ source: string,
1383
+ issues: LocalPlanValidationIssue[],
1384
+ ) {
1385
+ questionObjects.forEach((question, questionIndex) => {
1386
+ const questionBase = listBase + question.start;
1387
+ if (!hasRequiredStaticId(question.source)) {
1388
+ addValidationIssue(
1389
+ issues,
1390
+ file,
1391
+ source,
1392
+ questionBase,
1393
+ `${tag} questions[${questionIndex}].id is required by the Plan renderer schema; add a stable string id.`,
1394
+ );
1395
+ }
1396
+ if (!hasRequiredStaticString(question.source, "title")) {
1397
+ addValidationIssue(
1398
+ issues,
1399
+ file,
1400
+ source,
1401
+ questionBase,
1402
+ `${tag} questions[${questionIndex}].title is required by the Plan renderer schema; add a non-empty string title.`,
1403
+ );
1404
+ }
1405
+ if (
1406
+ !hasRequiredEnumLiteral(question.source, "mode", [
1407
+ "single",
1408
+ "multi",
1409
+ "freeform",
1410
+ ])
1411
+ ) {
1412
+ addValidationIssue(
1413
+ issues,
1414
+ file,
1415
+ source,
1416
+ questionBase,
1417
+ `${tag} questions[${questionIndex}].mode is required by the Plan renderer schema; use "single", "multi", or "freeform".`,
1418
+ );
1419
+ }
1420
+ const options = readTopLevelObjectProperty(question.source, "options");
1421
+ if (!options) return;
1422
+ const optionObjects = extractTopLevelObjectLiterals(options.value);
1423
+ if (!optionObjects) {
1424
+ addValidationIssue(
1425
+ issues,
1426
+ file,
1427
+ source,
1428
+ questionBase + options.valueStart,
1429
+ `${tag} questions[${questionIndex}].options must be an inline array of object literals.`,
1430
+ );
1431
+ return;
1432
+ }
1433
+ optionObjects.forEach((option, optionIndex) => {
1434
+ const optionBase = questionBase + options.valueStart + option.start;
1435
+ if (!hasRequiredStaticId(option.source)) {
1436
+ addValidationIssue(
1437
+ issues,
1438
+ file,
1439
+ source,
1440
+ optionBase,
1441
+ `${tag} questions[${questionIndex}].options[${optionIndex}].id is required by the Plan renderer schema; add a stable string id.`,
1442
+ );
1443
+ }
1444
+ if (!hasRequiredStaticString(option.source, "label")) {
1445
+ addValidationIssue(
1446
+ issues,
1447
+ file,
1448
+ source,
1449
+ optionBase,
1450
+ `${tag} questions[${questionIndex}].options[${optionIndex}].label is required by the Plan renderer schema; add a non-empty string label.`,
1451
+ );
1452
+ }
1453
+ });
1454
+ });
1455
+ }
1456
+
1321
1457
  function lintChecklistShape(
1322
1458
  file: string,
1323
1459
  source: string,
@@ -1354,27 +1490,7 @@ function lintChecklistShape(
1354
1490
  );
1355
1491
  continue;
1356
1492
  }
1357
- objects.forEach((item, index) => {
1358
- const base = start + items.start + item.start;
1359
- if (!hasRequiredStaticId(item.source)) {
1360
- addValidationIssue(
1361
- issues,
1362
- file,
1363
- source,
1364
- base,
1365
- `Checklist items[${index}].id is required by the Plan renderer schema; add a stable string id.`,
1366
- );
1367
- }
1368
- if (!hasRequiredStaticString(item.source, "label")) {
1369
- addValidationIssue(
1370
- issues,
1371
- file,
1372
- source,
1373
- base,
1374
- `Checklist items[${index}].label is required by the Plan renderer schema; add a non-empty string label.`,
1375
- );
1376
- }
1377
- });
1493
+ checkChecklistItemList(objects, start + items.start, file, source, issues);
1378
1494
  }
1379
1495
  }
1380
1496
 
@@ -1424,81 +1540,171 @@ function lintQuestionFormShape(
1424
1540
  );
1425
1541
  continue;
1426
1542
  }
1427
- questionObjects.forEach((question, questionIndex) => {
1428
- const questionBase = start + questions.start + question.start;
1429
- if (!hasRequiredStaticId(question.source)) {
1430
- addValidationIssue(
1431
- issues,
1432
- file,
1433
- source,
1434
- questionBase,
1435
- `${tag} questions[${questionIndex}].id is required by the Plan renderer schema; add a stable string id.`,
1436
- );
1437
- }
1438
- if (!hasRequiredStaticString(question.source, "title")) {
1439
- addValidationIssue(
1440
- issues,
1441
- file,
1442
- source,
1443
- questionBase,
1444
- `${tag} questions[${questionIndex}].title is required by the Plan renderer schema; add a non-empty string title.`,
1445
- );
1446
- }
1447
- if (
1448
- !hasRequiredEnumLiteral(question.source, "mode", [
1449
- "single",
1450
- "multi",
1451
- "freeform",
1452
- ])
1453
- ) {
1454
- addValidationIssue(
1455
- issues,
1543
+ checkQuestionObjectList(
1544
+ questionObjects,
1545
+ start + questions.start,
1546
+ tag,
1547
+ file,
1548
+ source,
1549
+ issues,
1550
+ );
1551
+ }
1552
+ }
1553
+
1554
+ function staticStringLiteralValue(
1555
+ value: string | undefined,
1556
+ ): string | undefined {
1557
+ if (value === undefined) return undefined;
1558
+ const trimmed = value.trim();
1559
+ const quote = trimmed[0];
1560
+ if (quote !== '"' && quote !== "'" && quote !== "`") return undefined;
1561
+ if (!trimmed.endsWith(quote) || trimmed.length < 2) return undefined;
1562
+ if (quote === "`" && /\$\{/.test(trimmed)) return undefined;
1563
+ return trimmed.slice(1, -1);
1564
+ }
1565
+
1566
+ // Container blocks authored with a JSON attribute array (`<TabsBlock tabs={[…]}
1567
+ // />`) carry their children as nested block objects, not top-level JSX tags, so
1568
+ // the tag-based linters above never inspect them — the exact "false green" the
1569
+ // renderer catches because it validates the whole parsed block tree. Walk those
1570
+ // nested `blocks: [...]` arrays and enforce the same per-block required fields,
1571
+ // recursing through nested containers. This is still a subset of the renderer
1572
+ // schema (which is why `verify` is the authority), but it closes the common
1573
+ // nested checklist / question-form / missing-id case offline.
1574
+ const NESTED_CONTAINER_TAGS: ReadonlyArray<{ tag: string; attr: string }> = [
1575
+ { tag: "TabsBlock", attr: "tabs" },
1576
+ { tag: "Tabs", attr: "tabs" },
1577
+ { tag: "Columns", attr: "columns" },
1578
+ ];
1579
+
1580
+ function lintNestedContainerEntry(
1581
+ entrySource: string,
1582
+ entryBase: number,
1583
+ file: string,
1584
+ source: string,
1585
+ issues: LocalPlanValidationIssue[],
1586
+ ) {
1587
+ const blocksProp = readTopLevelObjectProperty(entrySource, "blocks");
1588
+ if (!blocksProp) return;
1589
+ const blockObjects = extractTopLevelObjectLiterals(blocksProp.value);
1590
+ if (!blockObjects) return;
1591
+ const blocksBase = entryBase + blocksProp.valueStart;
1592
+ for (const block of blockObjects) {
1593
+ lintNestedBlock(
1594
+ block.source,
1595
+ blocksBase + block.start,
1596
+ file,
1597
+ source,
1598
+ issues,
1599
+ );
1600
+ }
1601
+ }
1602
+
1603
+ function lintNestedBlock(
1604
+ blockSource: string,
1605
+ blockBase: number,
1606
+ file: string,
1607
+ source: string,
1608
+ issues: LocalPlanValidationIssue[],
1609
+ ) {
1610
+ const typeProp = readTopLevelObjectProperty(blockSource, "type");
1611
+ const type = staticStringLiteralValue(typeProp?.value);
1612
+ if (!hasRequiredStaticId(blockSource)) {
1613
+ addValidationIssue(
1614
+ issues,
1615
+ file,
1616
+ source,
1617
+ blockBase,
1618
+ `Nested ${type ?? "block"} is missing a required string \`id\`; the Plan renderer schema validates every nested block.`,
1619
+ );
1620
+ }
1621
+ const dataProp = readTopLevelObjectProperty(blockSource, "data");
1622
+ if (!dataProp) return;
1623
+ const dataBase = blockBase + dataProp.valueStart;
1624
+
1625
+ if (type === "checklist") {
1626
+ const itemsProp = readTopLevelObjectProperty(dataProp.value, "items");
1627
+ if (!itemsProp) return;
1628
+ const items = extractTopLevelObjectLiterals(itemsProp.value);
1629
+ if (items) {
1630
+ checkChecklistItemList(
1631
+ items,
1632
+ dataBase + itemsProp.valueStart,
1633
+ file,
1634
+ source,
1635
+ issues,
1636
+ );
1637
+ }
1638
+ return;
1639
+ }
1640
+ // visual-questions shares question-form's schema; lint both.
1641
+ if (type === "question-form" || type === "visual-questions") {
1642
+ const questionsProp = readTopLevelObjectProperty(
1643
+ dataProp.value,
1644
+ "questions",
1645
+ );
1646
+ if (!questionsProp) return;
1647
+ const questionObjects = extractTopLevelObjectLiterals(questionsProp.value);
1648
+ if (questionObjects) {
1649
+ checkQuestionObjectList(
1650
+ questionObjects,
1651
+ dataBase + questionsProp.valueStart,
1652
+ type,
1653
+ file,
1654
+ source,
1655
+ issues,
1656
+ );
1657
+ }
1658
+ return;
1659
+ }
1660
+ if (type === "tabs" || type === "columns") {
1661
+ const innerAttr = type === "tabs" ? "tabs" : "columns";
1662
+ const innerProp = readTopLevelObjectProperty(dataProp.value, innerAttr);
1663
+ if (!innerProp) return;
1664
+ const innerEntries = extractTopLevelObjectLiterals(innerProp.value);
1665
+ if (!innerEntries) return;
1666
+ const innerBase = dataBase + innerProp.valueStart;
1667
+ for (const entry of innerEntries) {
1668
+ lintNestedContainerEntry(
1669
+ entry.source,
1670
+ innerBase + entry.start,
1671
+ file,
1672
+ source,
1673
+ issues,
1674
+ );
1675
+ }
1676
+ }
1677
+ }
1678
+
1679
+ function lintNestedContainerBlocks(
1680
+ file: string,
1681
+ source: string,
1682
+ issues: LocalPlanValidationIssue[],
1683
+ ) {
1684
+ const scanSource = maskCodeRegions(source);
1685
+ for (const { tag, attr } of NESTED_CONTAINER_TAGS) {
1686
+ const re = new RegExp(`<${tag}\\b`, "g");
1687
+ let match: RegExpExecArray | null;
1688
+ while ((match = re.exec(scanSource))) {
1689
+ const start = match.index;
1690
+ const openingEnd = findJsxOpeningTagEnd(scanSource, start);
1691
+ if (openingEnd < 0) continue;
1692
+ const opening = source.slice(start, openingEnd + 1);
1693
+ const attrValue = readJsxAttribute(opening, attr);
1694
+ if (!attrValue || attrValue.kind !== "expression") continue;
1695
+ const entries = extractTopLevelObjectLiterals(attrValue.value);
1696
+ if (!entries) continue;
1697
+ const entriesBase = start + attrValue.start;
1698
+ for (const entry of entries) {
1699
+ lintNestedContainerEntry(
1700
+ entry.source,
1701
+ entriesBase + entry.start,
1456
1702
  file,
1457
1703
  source,
1458
- questionBase,
1459
- `${tag} questions[${questionIndex}].mode is required by the Plan renderer schema; use "single", "multi", or "freeform".`,
1460
- );
1461
- }
1462
- const options = readTopLevelObjectProperty(question.source, "options");
1463
- if (!options) return;
1464
- const optionObjects = extractTopLevelObjectLiterals(options.value);
1465
- if (!optionObjects) {
1466
- addValidationIssue(
1467
1704
  issues,
1468
- file,
1469
- source,
1470
- start + questions.start + question.start + options.valueStart,
1471
- `${tag} questions[${questionIndex}].options must be an inline array of object literals.`,
1472
1705
  );
1473
- return;
1474
1706
  }
1475
- optionObjects.forEach((option, optionIndex) => {
1476
- const optionBase =
1477
- start +
1478
- questions.start +
1479
- question.start +
1480
- options.valueStart +
1481
- option.start;
1482
- if (!hasRequiredStaticId(option.source)) {
1483
- addValidationIssue(
1484
- issues,
1485
- file,
1486
- source,
1487
- optionBase,
1488
- `${tag} questions[${questionIndex}].options[${optionIndex}].id is required by the Plan renderer schema; add a stable string id.`,
1489
- );
1490
- }
1491
- if (!hasRequiredStaticString(option.source, "label")) {
1492
- addValidationIssue(
1493
- issues,
1494
- file,
1495
- source,
1496
- optionBase,
1497
- `${tag} questions[${questionIndex}].options[${optionIndex}].label is required by the Plan renderer schema; add a non-empty string label.`,
1498
- );
1499
- }
1500
- });
1501
- });
1707
+ }
1502
1708
  }
1503
1709
  }
1504
1710
 
@@ -1523,6 +1729,7 @@ export function validateLocalPlanFiles(
1523
1729
  lintColumnsBlocks(entry.file, source, issues);
1524
1730
  lintChecklistShape(entry.file, entry.source, issues);
1525
1731
  lintQuestionFormShape(entry.file, entry.source, issues);
1732
+ lintNestedContainerBlocks(entry.file, entry.source, issues);
1526
1733
  }
1527
1734
  return issues;
1528
1735
  }
@@ -1703,10 +1910,13 @@ function buildLocalPlanBridgePayload(input: {
1703
1910
  kind?: LocalPlanKind;
1704
1911
  title?: string;
1705
1912
  brief?: string;
1913
+ // `verify` brings the bridge up only to exercise transport + the authoritative
1914
+ // renderer check, so it must not be short-circuited by the weaker offline lint.
1915
+ skipSourceValidation?: boolean;
1706
1916
  }): LocalPlanBridgePayload {
1707
1917
  const dir = path.resolve(input.dir);
1708
1918
  const files = readLocalPlanFiles(dir);
1709
- assertLocalPlanFilesValid(files);
1919
+ if (!input.skipSourceValidation) assertLocalPlanFilesValid(files);
1710
1920
  const parsed = stripFrontmatter(files.planMdx);
1711
1921
  const kind = input.kind || normalizeKind(parsed.frontmatter.kind);
1712
1922
  const title =
@@ -1792,6 +2002,7 @@ export async function startLocalPlanBridge(input: {
1792
2002
  open?: boolean;
1793
2003
  urlFile?: string | false;
1794
2004
  openUrl?: (url: string) => OpenLocalUrlResult;
2005
+ skipSourceValidation?: boolean;
1795
2006
  }): Promise<LocalPlanBridgeServer> {
1796
2007
  const dir = path.resolve(input.dir);
1797
2008
  const initialPayload = buildLocalPlanBridgePayload({
@@ -1799,6 +2010,7 @@ export async function startLocalPlanBridge(input: {
1799
2010
  kind: input.kind,
1800
2011
  title: input.title,
1801
2012
  brief: input.brief,
2013
+ skipSourceValidation: input.skipSourceValidation,
1802
2014
  });
1803
2015
  const token = input.token || crypto.randomBytes(24).toString("base64url");
1804
2016
  const host = input.host || "127.0.0.1";
@@ -1832,6 +2044,7 @@ export async function startLocalPlanBridge(input: {
1832
2044
  kind: input.kind,
1833
2045
  title: input.title,
1834
2046
  brief: input.brief,
2047
+ skipSourceValidation: input.skipSourceValidation,
1835
2048
  }),
1836
2049
  );
1837
2050
  } catch (error) {
@@ -1918,6 +2131,107 @@ function localPlanBridgeWarnings(input: {
1918
2131
  return warnings;
1919
2132
  }
1920
2133
 
2134
+ const VALIDATE_LOCAL_PLAN_ACTION = "validate-local-plan-source";
2135
+
2136
+ /**
2137
+ * Ask the Plan app to validate the folder against its real renderer schema
2138
+ * (`parsePlanMdxFolder` + `planContentSchema`) via the public, no-DB
2139
+ * `validate-local-plan-source` action. This is what makes `verify`
2140
+ * authoritative: the hand-rolled `check` lint can disagree with the renderer,
2141
+ * but this runs the renderer's own parser. Degrades gracefully (`ran: false`)
2142
+ * when the endpoint is unreachable or a Plan app predates the action, so an
2143
+ * old deploy never turns into a hard failure.
2144
+ */
2145
+ export async function fetchRendererValidation(input: {
2146
+ appUrl: string;
2147
+ files: LocalPlanFiles;
2148
+ fetchFn?: typeof fetch;
2149
+ }): Promise<RendererValidation> {
2150
+ const fetchFn = input.fetchFn ?? fetch;
2151
+ const endpoint = planActionEndpoint(input.appUrl, VALIDATE_LOCAL_PLAN_ACTION);
2152
+ try {
2153
+ const response = await fetchActionWithTimeout(
2154
+ endpoint,
2155
+ {
2156
+ method: "POST",
2157
+ headers: {
2158
+ accept: "application/json",
2159
+ "content-type": "application/json",
2160
+ },
2161
+ body: JSON.stringify({ mdx: localPlanMdxFolder(input.files) }),
2162
+ },
2163
+ fetchFn,
2164
+ );
2165
+ if (!response.ok) {
2166
+ const detail = await response.text().catch(() => "");
2167
+ return {
2168
+ ran: false,
2169
+ endpoint,
2170
+ error: `${VALIDATE_LOCAL_PLAN_ACTION} failed ${response.status} ${
2171
+ response.statusText
2172
+ }: ${sanitizeHttpDetail(detail, 300)}`,
2173
+ };
2174
+ }
2175
+ const json = (await response.json().catch(() => null)) as
2176
+ | (Record<string, unknown> & { result?: Record<string, unknown> })
2177
+ | null;
2178
+ const body =
2179
+ json && isRecord(json.result) ? json.result : (json ?? undefined);
2180
+ if (!body || typeof body.valid !== "boolean") {
2181
+ return {
2182
+ ran: false,
2183
+ endpoint,
2184
+ error: `${VALIDATE_LOCAL_PLAN_ACTION} returned an unexpected payload.`,
2185
+ };
2186
+ }
2187
+ const issues = Array.isArray(body.issues)
2188
+ ? body.issues.filter(isRecord).map((issue) => ({
2189
+ path: typeof issue.path === "string" ? issue.path : "",
2190
+ message:
2191
+ typeof issue.message === "string" ? issue.message : "Invalid value",
2192
+ }))
2193
+ : [];
2194
+ return { ran: true, endpoint, valid: body.valid, issues };
2195
+ } catch (err) {
2196
+ return {
2197
+ ran: false,
2198
+ endpoint,
2199
+ error: err instanceof Error ? err.message : String(err),
2200
+ };
2201
+ }
2202
+ }
2203
+
2204
+ function rendererValidationWarnings(
2205
+ validation: RendererValidation,
2206
+ offlineIssues: LocalPlanValidationIssue[],
2207
+ ): string[] {
2208
+ if (!validation.ran) {
2209
+ const base = `Plan content was NOT validated against the renderer schema (${
2210
+ validation.error ?? "validate endpoint unavailable"
2211
+ }). Fell back to the offline lint. Run against a Plan app that exposes ${VALIDATE_LOCAL_PLAN_ACTION} (e.g. --app-url http://localhost:8096) for authoritative validation.`;
2212
+ if (offlineIssues.length === 0) return [base];
2213
+ const preview = offlineIssues
2214
+ .slice(0, 8)
2215
+ .map((issue) => `${issue.file}:${issue.line} ${issue.message}`)
2216
+ .join("; ");
2217
+ const overflow =
2218
+ offlineIssues.length > 8 ? ` (+${offlineIssues.length - 8} more)` : "";
2219
+ return [base, `Offline lint rejected this plan: ${preview}${overflow}`];
2220
+ }
2221
+ if (validation.valid) return [];
2222
+ const issues = validation.issues ?? [];
2223
+ const preview = issues
2224
+ .slice(0, 8)
2225
+ .map((issue) =>
2226
+ issue.path ? `${issue.path}: ${issue.message}` : issue.message,
2227
+ )
2228
+ .join("; ");
2229
+ const overflow = issues.length > 8 ? ` (+${issues.length - 8} more)` : "";
2230
+ return [
2231
+ `Renderer schema rejected this plan — it will not render. ${preview}${overflow}`,
2232
+ ];
2233
+ }
2234
+
1921
2235
  export async function verifyLocalPlanBridge(input: {
1922
2236
  dir: string;
1923
2237
  kind?: LocalPlanKind;
@@ -1931,6 +2245,7 @@ export async function verifyLocalPlanBridge(input: {
1931
2245
  fetchFn?: typeof fetch;
1932
2246
  }): Promise<LocalPlanVerifyResult> {
1933
2247
  const fetchFn = input.fetchFn ?? fetch;
2248
+ const files = readLocalPlanFiles(input.dir);
1934
2249
  const bridge = await startLocalPlanBridge({
1935
2250
  dir: input.dir,
1936
2251
  kind: input.kind,
@@ -1941,6 +2256,9 @@ export async function verifyLocalPlanBridge(input: {
1941
2256
  port: input.port,
1942
2257
  token: input.token,
1943
2258
  urlFile: input.urlFile,
2259
+ // Don't let the weaker offline lint abort the bridge before the authoritative
2260
+ // renderer check runs — verify reports the renderer's verdict, not the lint's.
2261
+ skipSourceValidation: true,
1944
2262
  });
1945
2263
 
1946
2264
  try {
@@ -1972,8 +2290,21 @@ export async function verifyLocalPlanBridge(input: {
1972
2290
  preflight.status === 204 &&
1973
2291
  preflight.headers.get("access-control-allow-origin") === "*" &&
1974
2292
  preflight.headers.get("access-control-allow-private-network") === "true";
2293
+ const validation = await fetchRendererValidation({
2294
+ appUrl: bridge.result.appUrl,
2295
+ files,
2296
+ fetchFn,
2297
+ });
2298
+ // The renderer's verdict is authoritative and gates `ok`. When it could not
2299
+ // run (old/unreachable Plan app), fall back to the offline lint as the
2300
+ // content signal so verify still catches the common cases offline instead
2301
+ // of silently passing unvalidated content.
2302
+ const offlineIssues = validation.ran ? [] : validateLocalPlanFiles(files);
2303
+ const contentOk = validation.ran
2304
+ ? validation.valid === true
2305
+ : offlineIssues.length === 0;
1975
2306
  return {
1976
- ok: bridgeOk && preflightOk,
2307
+ ok: bridgeOk && preflightOk && contentOk,
1977
2308
  dir: bridge.result.dir,
1978
2309
  url: bridge.result.url,
1979
2310
  ...(bridge.result.urlFile ? { urlFile: bridge.result.urlFile } : {}),
@@ -2000,10 +2331,14 @@ export async function verifyLocalPlanBridge(input: {
2000
2331
  ...(mdxFiles ? { mdxFiles } : {}),
2001
2332
  ...(payload?.error ? { error: payload.error } : {}),
2002
2333
  },
2003
- warnings: localPlanBridgeWarnings({
2004
- appUrl: bridge.result.appUrl,
2005
- bridgeUrl: bridge.result.bridgeUrl,
2006
- }),
2334
+ validation,
2335
+ warnings: [
2336
+ ...localPlanBridgeWarnings({
2337
+ appUrl: bridge.result.appUrl,
2338
+ bridgeUrl: bridge.result.bridgeUrl,
2339
+ }),
2340
+ ...rendererValidationWarnings(validation, offlineIssues),
2341
+ ],
2007
2342
  };
2008
2343
  } finally {
2009
2344
  await new Promise<void>((resolve) => bridge.server.close(() => resolve()));
@@ -2090,7 +2425,9 @@ function runCheck(args: Record<string, string | boolean>): void {
2090
2425
  const result = {
2091
2426
  ok: true,
2092
2427
  noDb: true,
2093
- validation: "passed",
2428
+ validation: "lint-passed",
2429
+ validationScope: "offline-lint",
2430
+ note: "Quick offline lint only — NOT the full Plan renderer schema. A green here does not guarantee the plan renders. Run `plan local verify` against a Plan app for authoritative validation before handing off.",
2094
2431
  dir: files.dir,
2095
2432
  title: parsed.frontmatter.title || firstHeading(parsed.body),
2096
2433
  kind: normalizeKind(parsed.frontmatter.kind),