pagegraph 0.11.0 → 0.12.0

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 (65) hide show
  1. package/README.md +140 -137
  2. package/dist/audit.d.ts +1 -1
  3. package/dist/audit.js +1 -1
  4. package/dist/build-only/audit.d.ts +1 -0
  5. package/dist/build-only/audit.js +6 -0
  6. package/dist/build-only/audit.js.map +1 -0
  7. package/dist/build-only/config.d.ts +1 -0
  8. package/dist/build-only/config.js +6 -0
  9. package/dist/build-only/config.js.map +1 -0
  10. package/dist/build-only/tanstack-start.d.ts +1 -0
  11. package/dist/build-only/tanstack-start.js +6 -0
  12. package/dist/build-only/tanstack-start.js.map +1 -0
  13. package/dist/build-only/vite.d.ts +1 -0
  14. package/dist/build-only/vite.js +6 -0
  15. package/dist/build-only/vite.js.map +1 -0
  16. package/dist/{checks-nk8ZRRIA.js → checks-uZk5Em9A.js} +3 -141
  17. package/dist/checks-uZk5Em9A.js.map +1 -0
  18. package/dist/cli.js +393 -287
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config.d.ts +7 -66
  21. package/dist/config.js +3 -122
  22. package/dist/config.js.map +1 -1
  23. package/dist/{content-DVL2iyGU.d.ts → content-B1cPtL8b.d.ts} +4 -128
  24. package/dist/{content-CB_kHmJ3.js → content-kcEnQdfi.js} +3 -2
  25. package/dist/{content-CB_kHmJ3.js.map → content-kcEnQdfi.js.map} +1 -1
  26. package/dist/declare-D5o7hwmv.js +9 -0
  27. package/dist/declare-D5o7hwmv.js.map +1 -0
  28. package/dist/declare-DVgdEdOL.d.ts +12127 -0
  29. package/dist/graph-Bcsourlv.js +162 -0
  30. package/dist/graph-Bcsourlv.js.map +1 -0
  31. package/dist/graph-BxCY90vq.d.ts +104 -0
  32. package/dist/graph-CBIY71w6.d.ts +57 -0
  33. package/dist/index.d.ts +37 -73
  34. package/dist/index.js +23 -134
  35. package/dist/index.js.map +1 -1
  36. package/dist/message-DT_CjUdt.js +12 -0
  37. package/dist/message-DT_CjUdt.js.map +1 -0
  38. package/dist/page-heads-6y-CARRQ.js +25 -0
  39. package/dist/page-heads-6y-CARRQ.js.map +1 -0
  40. package/dist/page-heads-DNkjMUEN.d.ts +21 -0
  41. package/dist/projections-D0oOFKiY.js +142 -0
  42. package/dist/projections-D0oOFKiY.js.map +1 -0
  43. package/dist/projections-DeEFe7XT.d.ts +78 -0
  44. package/dist/react.d.ts +26 -12051
  45. package/dist/react.js +12 -3
  46. package/dist/react.js.map +1 -1
  47. package/dist/route-config-35ZEzkNC.js +308 -0
  48. package/dist/route-config-35ZEzkNC.js.map +1 -0
  49. package/dist/route-config-wneg6ktn.d.ts +29 -0
  50. package/dist/tanstack-start/index.d.ts +40 -0
  51. package/dist/tanstack-start/index.js +368 -0
  52. package/dist/tanstack-start/index.js.map +1 -0
  53. package/dist/tanstack-start/server.d.ts +14 -0
  54. package/dist/tanstack-start/server.js +53 -0
  55. package/dist/tanstack-start/server.js.map +1 -0
  56. package/dist/vite-graph-loader-01p1WxEq.js +104 -0
  57. package/dist/vite-graph-loader-01p1WxEq.js.map +1 -0
  58. package/dist/vite-graph-loader-Btgjz5RH.d.ts +56 -0
  59. package/dist/vite.d.ts +2 -28
  60. package/dist/vite.js +1 -307
  61. package/dist/wire-B4RQMc4e.js +26 -0
  62. package/dist/wire-B4RQMc4e.js.map +1 -0
  63. package/package.json +27 -5
  64. package/dist/checks-nk8ZRRIA.js.map +0 -1
  65. package/dist/vite.js.map +0 -1
package/dist/cli.js CHANGED
@@ -35946,7 +35946,7 @@ const runWith = (command, config) => {
35946
35946
  };
35947
35947
  //#endregion
35948
35948
  //#region package.json
35949
- var version = "0.11.0";
35949
+ var version = "0.12.0";
35950
35950
  //#endregion
35951
35951
  //#region src/core/links.ts
35952
35952
  const TOKEN = /<(\/?)(nav|footer|header)\b[^>]*>|<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
@@ -38254,7 +38254,7 @@ const isWorkflowConfig = (value) => {
38254
38254
  * an undefined `origin` would otherwise surface as "undefined/pricing" in a
38255
38255
  * rendered sitemap rather than as an error here.
38256
38256
  */
38257
- const isSeoCliConfig = (value) => isObject$1(value) && isFunction(value["loadGraph"]) && isString(value["origin"]) && isStringArray(value["disallow"]) && (value["contentSignal"] === void 0 || isString(value["contentSignal"])) && (value["directives"] === void 0 || isStringArray(value["directives"])) && (value["transform"] === void 0 || isFunction(value["transform"])) && (value["coverage"] === void 0 || isCoverageRules(value["coverage"])) && (value["content"] === void 0 || isContentPolicy(value["content"])) && (value["freshness"] === void 0 || isFreshnessPolicy(value["freshness"])) && (value["workflows"] === void 0 || isWorkflowConfig(value["workflows"]));
38257
+ const isSeoCliConfig = (value) => isObject$1(value) && isFunction(value["loadGraph"]) && isString(value["origin"]) && (value["disallow"] === void 0 || isStringArray(value["disallow"])) && (value["contentSignal"] === void 0 || isString(value["contentSignal"])) && (value["directives"] === void 0 || isStringArray(value["directives"])) && (value["transform"] === void 0 || isFunction(value["transform"])) && (value["coverage"] === void 0 || isCoverageRules(value["coverage"])) && (value["content"] === void 0 || isContentPolicy(value["content"])) && (value["freshness"] === void 0 || isFreshnessPolicy(value["freshness"])) && (value["workflows"] === void 0 || isWorkflowConfig(value["workflows"]));
38258
38258
  /** Import and validate one config path. */
38259
38259
  const loadConfigFile = (configPath) => gen(function* () {
38260
38260
  yield* logDebug(`Loading SEO config from ${configPath}`);
@@ -38262,7 +38262,7 @@ const loadConfigFile = (configPath) => gen(function* () {
38262
38262
  try: () => import(pathToFileURL(configPath).href),
38263
38263
  catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$2(cause)}` })
38264
38264
  });
38265
- if (!isSeoCliConfig(module.default)) return yield* new SeoCliError({ message: `${configPath} must default-export defineSeoConfig({ origin, disallow, loadGraph }).` });
38265
+ if (!isSeoCliConfig(module.default)) return yield* new SeoCliError({ message: `${configPath} must default-export defineSeoConfig({ origin, loadGraph }).` });
38266
38266
  return module.default;
38267
38267
  });
38268
38268
  /**
@@ -38272,7 +38272,7 @@ const loadConfigFile = (configPath) => gen(function* () {
38272
38272
  const loadSeoConfig = gen(function* () {
38273
38273
  const cwd = process.cwd();
38274
38274
  const configPath = findConfigFile(cwd);
38275
- if (configPath === void 0) return yield* new SeoCliError({ message: `No ${CONFIG_FILENAMES[0]} in ${cwd} or any parent directory. Create one that exports \`defineSeoConfig({ origin, disallow, loadGraph })\` from "pagegraph/config".` });
38275
+ if (configPath === void 0) return yield* new SeoCliError({ message: `No ${CONFIG_FILENAMES[0]} in ${cwd} or any parent directory. Create one that exports \`defineSeoConfig({ origin, loadGraph })\` from "pagegraph/config".` });
38276
38276
  return yield* loadConfigFile(configPath);
38277
38277
  });
38278
38278
  /** Load the config together with the project root that owns it. */
@@ -38302,15 +38302,17 @@ const loadSeoConfigOptional = gen(function* () {
38302
38302
  * {@link viteGraphLoader}, an in-process Vite server) is released when the
38303
38303
  * surrounding `Effect.scoped` exits, on success or failure.
38304
38304
  */
38305
- const acquireGraph = (config) => gen(function* () {
38305
+ const acquireLoadedGraph = (config) => gen(function* () {
38306
38306
  yield* logDebug("Loading the SEO graph…");
38307
38307
  const loaded = yield* acquireRelease(tryPromise({
38308
38308
  try: () => config.loadGraph(),
38309
38309
  catch: (cause) => new SeoCliError({ message: messageOf$2(cause) })
38310
38310
  }), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$2(defect)}`))));
38311
38311
  yield* logDebug(`Loaded SEO graph: ${loaded.graph.nodes.size} nodes, ${loaded.graph.edges.length} edges.`);
38312
- return loaded.graph;
38312
+ return loaded;
38313
38313
  });
38314
+ /** {@link acquireLoadedGraph}, for commands that need only the graph. */
38315
+ const acquireGraph = (config) => map$5(acquireLoadedGraph(config), (loaded) => loaded.graph);
38314
38316
  //#endregion
38315
38317
  //#region src/cli/render.ts
38316
38318
  /** Count of edges pointing *at* each node — 0 means nothing links to it. */
@@ -40581,11 +40583,22 @@ const loadWorkflowOpenCode = async () => {
40581
40583
  };
40582
40584
  //#endregion
40583
40585
  //#region src/workflows/opencode.ts
40584
- var MissingExecutorEvidenceError = class extends Error {
40586
+ var WorkflowHostError = class extends Error {
40587
+ sessionId;
40588
+ transcript;
40589
+ constructor(message, sessionId, transcript, cause) {
40590
+ super(message, { cause });
40591
+ this.sessionId = sessionId;
40592
+ this.transcript = transcript;
40593
+ this.name = "WorkflowHostError";
40594
+ }
40595
+ };
40596
+ var MissingExecutorEvidenceError = class extends WorkflowHostError {
40585
40597
  result;
40586
40598
  constructor(result) {
40587
- super(`The SEO agent returned without the required Executor evidence (searches: ${result.executor.searches.length}, calls: ${result.executor.calls.length}).`);
40599
+ super(`The SEO agent returned without the required Executor evidence (searches: ${result.executor.searches.length}, calls: ${result.executor.calls.length}).`, result.sessionId, result.transcript, void 0);
40588
40600
  this.result = result;
40601
+ this.name = "MissingExecutorEvidenceError";
40589
40602
  }
40590
40603
  };
40591
40604
  const requireExecutorEvidence = async (result, recover) => {
@@ -40653,15 +40666,18 @@ const parseWorkflowState = (transcript) => {
40653
40666
  return isSome(text) && text.value.text.includes("{") ? [text.value.text] : [];
40654
40667
  });
40655
40668
  });
40669
+ let parseCause;
40656
40670
  for (const text of candidates.reverse()) {
40657
40671
  const start = text.indexOf("{");
40658
40672
  const end = text.lastIndexOf("}");
40659
40673
  if (start === -1 || end <= start) continue;
40660
40674
  try {
40661
40675
  return JSON.parse(text.slice(start, end + 1));
40662
- } catch {}
40676
+ } catch (cause) {
40677
+ parseCause = cause;
40678
+ }
40663
40679
  }
40664
- throw new Error("The SEO agent did not return a valid workflow JSON object.");
40680
+ throw new Error("The SEO agent did not return a valid workflow JSON object.", { cause: parseCause });
40665
40681
  };
40666
40682
  const parseWorkflowStateWithRepair = async (transcript, repair) => {
40667
40683
  try {
@@ -40669,12 +40685,16 @@ const parseWorkflowStateWithRepair = async (transcript, repair) => {
40669
40685
  state: parseWorkflowState(transcript),
40670
40686
  transcript
40671
40687
  };
40672
- } catch {
40673
- const repaired = await repair();
40674
- return {
40675
- state: parseWorkflowState(repaired),
40676
- transcript: repaired
40677
- };
40688
+ } catch (firstError) {
40689
+ try {
40690
+ const repaired = await repair();
40691
+ return {
40692
+ state: parseWorkflowState(repaired),
40693
+ transcript: repaired
40694
+ };
40695
+ } catch (repairError) {
40696
+ throw new AggregateError([firstError, repairError], "The SEO agent did not return valid workflow JSON after one repair.", { cause: repairError });
40697
+ }
40678
40698
  }
40679
40699
  };
40680
40700
  const STATE_REPAIR_PROMPT = [
@@ -40925,70 +40945,93 @@ const acquireWorkflowHost = async (options) => {
40925
40945
  const location = { directory: options.root };
40926
40946
  if (!await waitForActiveExecutorPlugin({ list: async () => (await openCodeOperation("GET /api/plugin (plugin.list)", () => host.plugin.list({ location }))).data })) throw new Error(`No active Executor plugin was discovered in ${configDirectory}; agentic SEO workflows require Executor.`);
40927
40947
  const cursors = /* @__PURE__ */ new Map();
40948
+ const transcripts = /* @__PURE__ */ new Map();
40928
40949
  const complete = async (sessionId, prompt, workflowOptions) => {
40929
- assertWorkflowSkillsAvailable(configDirectory, workflowOptions.skills);
40930
- if (workflowOptions.permissions !== void 0) await host.sessions.update({
40931
- sessionID: sessionId,
40932
- permissions: [...workflowOptions.permissions]
40933
- });
40934
- const configuredTimeoutMs = options.timeoutMs ?? options.config.timeoutMs ?? 18e4;
40935
- const deadline = Date.now() + configuredTimeoutMs;
40936
- const remaining = () => Math.max(0, deadline - Date.now());
40937
- const activity = {
40938
- modelSteps: 0,
40939
- completedTools: 0,
40940
- toolNames: /* @__PURE__ */ new Map()
40941
- };
40942
- let cursor = await runWorkflowTurn(host, sessionId, prompt, {
40943
- timeoutMs: remaining(),
40944
- configuredTimeoutMs,
40945
- stage: "prompt admission",
40946
- after: cursors.get(sessionId),
40947
- skills: workflowOptions.skills,
40948
- activity
40949
- });
40950
- cursors.set(sessionId, cursor);
40951
- let transcript = await host.sessions.export({
40952
- sessionID: sessionId,
40953
- sanitize: false
40954
- });
40955
- const parsed = await parseWorkflowStateWithRepair(transcript, async () => {
40956
- const priorPermissions = [...workflowOptions.permissions ?? []];
40957
- await host.sessions.update({
40950
+ let transcript = transcripts.get(sessionId) ?? { messages: [] };
40951
+ try {
40952
+ assertWorkflowSkillsAvailable(configDirectory, workflowOptions.skills);
40953
+ if (workflowOptions.permissions !== void 0) await host.sessions.update({
40958
40954
  sessionID: sessionId,
40959
- permissions: [{
40960
- action: "*",
40961
- resource: "*",
40962
- effect: "deny"
40963
- }]
40955
+ permissions: [...workflowOptions.permissions]
40964
40956
  });
40965
- try {
40966
- cursor = await runWorkflowTurn(host, sessionId, STATE_REPAIR_PROMPT, {
40967
- timeoutMs: remaining(),
40968
- configuredTimeoutMs,
40969
- stage: "workflow state repair",
40970
- after: cursor,
40971
- activity
40957
+ const configuredTimeoutMs = options.timeoutMs ?? options.config.timeoutMs ?? 18e4;
40958
+ const deadline = Date.now() + configuredTimeoutMs;
40959
+ const remaining = () => Math.max(0, deadline - Date.now());
40960
+ const activity = {
40961
+ modelSteps: 0,
40962
+ completedTools: 0,
40963
+ toolNames: /* @__PURE__ */ new Map()
40964
+ };
40965
+ let cursor = await runWorkflowTurn(host, sessionId, prompt, {
40966
+ timeoutMs: remaining(),
40967
+ configuredTimeoutMs,
40968
+ stage: "prompt admission",
40969
+ after: cursors.get(sessionId),
40970
+ skills: workflowOptions.skills,
40971
+ activity
40972
+ });
40973
+ cursors.set(sessionId, cursor);
40974
+ transcript = await host.sessions.export({
40975
+ sessionID: sessionId,
40976
+ sanitize: false
40977
+ });
40978
+ transcripts.set(sessionId, transcript);
40979
+ const parsed = await parseWorkflowStateWithRepair(transcript, async () => {
40980
+ const priorPermissions = [...workflowOptions.permissions ?? []];
40981
+ await host.sessions.update({
40982
+ sessionID: sessionId,
40983
+ permissions: [{
40984
+ action: "*",
40985
+ resource: "*",
40986
+ effect: "deny"
40987
+ }]
40972
40988
  });
40973
- cursors.set(sessionId, cursor);
40989
+ let repairFailed = false;
40990
+ try {
40991
+ cursor = await runWorkflowTurn(host, sessionId, STATE_REPAIR_PROMPT, {
40992
+ timeoutMs: remaining(),
40993
+ configuredTimeoutMs,
40994
+ stage: "workflow state repair",
40995
+ after: cursor,
40996
+ activity
40997
+ });
40998
+ cursors.set(sessionId, cursor);
40999
+ transcript = await host.sessions.export({
41000
+ sessionID: sessionId,
41001
+ sanitize: false
41002
+ });
41003
+ transcripts.set(sessionId, transcript);
41004
+ return transcript;
41005
+ } catch (cause) {
41006
+ repairFailed = true;
41007
+ throw cause;
41008
+ } finally {
41009
+ try {
41010
+ await host.sessions.update({
41011
+ sessionID: sessionId,
41012
+ permissions: priorPermissions
41013
+ });
41014
+ } catch (cause) {
41015
+ if (!repairFailed) throw cause;
41016
+ }
41017
+ }
41018
+ });
41019
+ return {
41020
+ state: parsed.state,
41021
+ sessionId,
41022
+ transcript: parsed.transcript,
41023
+ executor: collectExecutorEvidence(parsed.transcript)
41024
+ };
41025
+ } catch (cause) {
41026
+ try {
40974
41027
  transcript = await host.sessions.export({
40975
41028
  sessionID: sessionId,
40976
41029
  sanitize: false
40977
- });
40978
- return transcript;
40979
- } finally {
40980
- await host.sessions.update({
40981
- sessionID: sessionId,
40982
- permissions: priorPermissions
40983
- });
40984
- }
40985
- });
40986
- return {
40987
- state: parsed.state,
40988
- sessionId,
40989
- transcript: parsed.transcript,
40990
- executor: collectExecutorEvidence(parsed.transcript)
40991
- };
41030
+ }, { signal: AbortSignal.timeout(5e3) });
41031
+ } catch {}
41032
+ transcripts.set(sessionId, transcript);
41033
+ throw new WorkflowHostError(openCodeErrorMessage(cause), sessionId, transcript, cause);
41034
+ }
40992
41035
  };
40993
41036
  return {
40994
41037
  model,
@@ -41010,7 +41053,9 @@ const acquireWorkflowHost = async (options) => {
41010
41053
  close: () => host.close()
41011
41054
  };
41012
41055
  } catch (cause) {
41013
- await host.close();
41056
+ try {
41057
+ await host.close();
41058
+ } catch {}
41014
41059
  throw cause;
41015
41060
  }
41016
41061
  };
@@ -44817,13 +44862,33 @@ const writeResearchCheckpoint = (root, runsDirectory, checkpoint) => {
44817
44862
  writeFileSync(path, `${JSON.stringify(checkpoint, null, 2)}\n`, "utf8");
44818
44863
  return path;
44819
44864
  };
44820
- const writeResearchFailure = (root, runsDirectory, id, details) => {
44865
+ const writeWorkflowFailure = (root, runsDirectory, id, details) => {
44821
44866
  const directory = resolve(root, runsDirectory, id);
44822
44867
  mkdirSync(directory, { recursive: true });
44823
- const path = resolve(directory, "research-failure.json");
44868
+ const path = resolve(directory, "failure.json");
44824
44869
  writeFileSync(path, `${JSON.stringify(details, null, 2)}\n`, "utf8");
44825
44870
  return path;
44826
44871
  };
44872
+ const serializeCause = (value, top = true, seen = /* @__PURE__ */ new Set()) => {
44873
+ if (seen.has(value)) return {
44874
+ name: "CircularCause",
44875
+ message: "Circular error cause"
44876
+ };
44877
+ if (value !== null && typeof value === "object") {
44878
+ seen.add(value);
44879
+ return {
44880
+ message: value instanceof Error ? value.message : "message" in value ? String(value.message) : JSON.stringify(value),
44881
+ name: value instanceof Error ? value.name : "name" in value ? String(value.name) : "Error",
44882
+ ...top && value instanceof Error ? { stack: value.stack } : {},
44883
+ ..."cause" in value ? { cause: serializeCause(value.cause, false, seen) } : {},
44884
+ ...value instanceof AggregateError ? { errors: value.errors.map((error) => serializeCause(error, false, new Set(seen))) } : {}
44885
+ };
44886
+ }
44887
+ return {
44888
+ name: "Error",
44889
+ message: String(value)
44890
+ };
44891
+ };
44827
44892
  //#endregion
44828
44893
  //#region node_modules/effect/dist/ai/Decision.js
44829
44894
  /**
@@ -45151,8 +45216,8 @@ const runDecisions = (options) => gen(function* () {
45151
45216
  //#endregion
45152
45217
  //#region src/workflows/specs/types.ts
45153
45218
  const defineWorkflow = (definition) => {
45154
- const decodeState = decodeUnknownSync(definition.stateSchema);
45155
- const decodeInput = decodeUnknownSync(definition.family.input);
45219
+ const decodeState = decodeUnknownSync(definition.stateSchema, { errors: "all" });
45220
+ const decodeInput = decodeUnknownSync(definition.family.input, { errors: "all" });
45156
45221
  const inputsFrom = (state) => definition.decisionInputs(decodeState(state));
45157
45222
  const decodedInputs = (inputs) => inputs.map((input) => decodeInput(input));
45158
45223
  const decodeDecisionMetadata = decodeUnknownSync(Struct({
@@ -46243,6 +46308,11 @@ const defaultDecide = async (inputs, spec) => {
46243
46308
  });
46244
46309
  }).pipe(provide(layer$14)));
46245
46310
  };
46311
+ const dropNulls = (value) => {
46312
+ if (Array.isArray(value)) return value.filter((item) => item !== null).map(dropNulls);
46313
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== null).map(([key, item]) => [key, dropNulls(item)]));
46314
+ return value;
46315
+ };
46246
46316
  const capState = (state, limit) => {
46247
46317
  if (state === null || typeof state !== "object" || Array.isArray(state)) return state;
46248
46318
  const record = state;
@@ -46291,236 +46361,270 @@ const runWorkflow = async (input, dependencies = {}) => {
46291
46361
  const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
46292
46362
  const started = now();
46293
46363
  const id = createRunId(started, spec.id);
46294
- const gitAtStart = inspectGit(input.root);
46295
- const mutation = createWorkflowMutationPolicy({
46296
- root: input.root,
46297
- dirtyAtStart: gitAtStart.dirty,
46298
- dryRun: spec.mutatesFiles ? input.options.dryRun : true,
46299
- allowDirty: input.options.allowDirty
46300
- });
46301
- if (spec.mutatesFiles) mutation.assertStartAllowed();
46302
- const evidence = collectWorkflowEvidence(input.graph, input.options, input.root, spec.id, workflows.context);
46303
- const suggestionReport = input.options.suggestions === void 0 ? void 0 : (() => {
46304
- if (spec.id !== "improve.links") throw new Error("--suggestions is only valid for improve links");
46305
- const report = decodeLinksSuggestionReport(JSON.parse(readFileSync(resolve(input.root, input.options.suggestions), "utf8")), new URL(input.config.origin).origin);
46306
- const selectedSources = selectGraph(input.graph, input.options).nodes;
46307
- for (const candidate of report.candidates) {
46308
- if (!input.graph.nodes.has(candidate.source) || !input.graph.nodes.has(candidate.destination)) throw new Error(`Suggestion references a path absent from seo.config.ts: ${candidate.source} → ${candidate.destination}`);
46309
- if (!selectedSources.has(candidate.source)) throw new Error(`Suggestion source is outside workflow page/kind/limit targets: ${candidate.source}`);
46310
- }
46311
- return report;
46312
- })();
46313
- const suppliedEvidence = suggestionReport === void 0 ? evidence : {
46314
- ...evidence,
46315
- suggestions: suggestionReport
46316
- };
46317
- const host = await (dependencies.acquireHost ?? acquireWorkflowHost)({
46318
- root: input.root,
46319
- config: workflows.opencode,
46320
- model: input.model ?? workflows.opencode.models?.[spec.id]
46321
- });
46364
+ const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
46365
+ let stage = "acquire";
46366
+ let sessionId;
46367
+ let transcript = null;
46368
+ const retainResult = (result) => {
46369
+ sessionId = result.sessionId;
46370
+ transcript = result.transcript;
46371
+ };
46322
46372
  try {
46323
- const researchPermissions = repositoryMutationPermissionRules(input.root);
46324
- const actionPermissions = spec.mutatesFiles && !input.options.dryRun ? mutation.sessionPermissions : researchPermissions;
46325
- let researched;
46373
+ const gitAtStart = inspectGit(input.root);
46374
+ const mutation = createWorkflowMutationPolicy({
46375
+ root: input.root,
46376
+ dirtyAtStart: gitAtStart.dirty,
46377
+ dryRun: spec.mutatesFiles ? input.options.dryRun : true,
46378
+ allowDirty: input.options.allowDirty
46379
+ });
46380
+ if (spec.mutatesFiles) mutation.assertStartAllowed();
46381
+ const evidence = collectWorkflowEvidence(input.graph, input.options, input.root, spec.id, workflows.context);
46382
+ const suggestionReport = input.options.suggestions === void 0 ? void 0 : (() => {
46383
+ if (spec.id !== "improve.links") throw new Error("--suggestions is only valid for improve links");
46384
+ const report = decodeLinksSuggestionReport(JSON.parse(readFileSync(resolve(input.root, input.options.suggestions), "utf8")), new URL(input.config.origin).origin);
46385
+ const selectedSources = selectGraph(input.graph, input.options).nodes;
46386
+ for (const candidate of report.candidates) {
46387
+ if (!input.graph.nodes.has(candidate.source) || !input.graph.nodes.has(candidate.destination)) throw new Error(`Suggestion references a path absent from seo.config.ts: ${candidate.source} → ${candidate.destination}`);
46388
+ if (!selectedSources.has(candidate.source)) throw new Error(`Suggestion source is outside workflow page/kind/limit targets: ${candidate.source}`);
46389
+ }
46390
+ return report;
46391
+ })();
46392
+ const suppliedEvidence = suggestionReport === void 0 ? evidence : {
46393
+ ...evidence,
46394
+ suggestions: suggestionReport
46395
+ };
46396
+ const host = await (dependencies.acquireHost ?? acquireWorkflowHost)({
46397
+ root: input.root,
46398
+ config: workflows.opencode,
46399
+ model: input.model ?? workflows.opencode.models?.[spec.id]
46400
+ });
46401
+ let turnFailed = false;
46326
46402
  try {
46327
- researched = await host.research(researchPrompt(spec, suppliedEvidence, input.options), {
46403
+ const researchPermissions = repositoryMutationPermissionRules(input.root);
46404
+ const actionPermissions = spec.mutatesFiles && !input.options.dryRun ? mutation.sessionPermissions : researchPermissions;
46405
+ stage = "research";
46406
+ let researched = await host.research(researchPrompt(spec, suppliedEvidence, input.options), {
46328
46407
  skills: spec.skills,
46329
46408
  permissions: researchPermissions
46330
46409
  });
46331
- } catch (cause) {
46332
- if (cause instanceof MissingExecutorEvidenceError) {
46333
- const path = writeResearchFailure(input.root, input.out ?? workflows.runsDirectory ?? ".pagegraph/runs", id, {
46334
- message: cause.message,
46335
- ...cause.result
46336
- });
46337
- throw new Error(`${cause.message}\nResearch failure: ${path}`, { cause });
46338
- }
46339
- throw cause;
46340
- }
46341
- const assertResearchReadOnly = () => {
46342
- const researchFiles = changedFiles(gitAtStart, inspectGit(input.root));
46343
- if (researchFiles.length > 0) throw new Error(`${spec.id} changed repository files during its read-only research turn: ${researchFiles.join(", ")}`);
46344
- };
46345
- assertResearchReadOnly();
46346
- const decodeResearchState = (value) => {
46347
- const decoded = spec.decodeState(capState(value, input.options.limit));
46348
- for (const item of spec.decisionInputs(decoded)) {
46349
- const error = spec.validateDecisionInput(item);
46350
- if (error !== void 0) throw new Error(error);
46351
- }
46352
- return decoded;
46353
- };
46354
- let state;
46355
- try {
46356
- state = decodeResearchState(researched.state);
46357
- } catch (firstError) {
46358
- const original = researched;
46359
- let repaired;
46410
+ retainResult(researched);
46411
+ const assertResearchReadOnly = () => {
46412
+ const researchFiles = changedFiles(gitAtStart, inspectGit(input.root));
46413
+ if (researchFiles.length > 0) throw new Error(`${spec.id} changed repository files during its read-only research turn: ${researchFiles.join(", ")}`);
46414
+ };
46415
+ assertResearchReadOnly();
46416
+ const decodeResearchState = (value) => {
46417
+ const decoded = spec.decodeState(capState(dropNulls(value), input.options.limit));
46418
+ const issues = [];
46419
+ for (const [index, item] of spec.decisionInputs(decoded).entries()) try {
46420
+ const error = spec.validateDecisionInput(item);
46421
+ if (error !== void 0) issues.push(`decisionInputs[${index}]: ${error}`);
46422
+ } catch (cause) {
46423
+ issues.push(`decisionInputs[${index}]: ${cause instanceof Error ? cause.message : String(cause)}`);
46424
+ }
46425
+ if (issues.length > 0) throw new Error(issues.join("\n"));
46426
+ return decoded;
46427
+ };
46428
+ let state;
46360
46429
  try {
46361
- repaired = await host.continue(researched.sessionId, [
46362
- `The previous ${spec.id} research JSON did not match the required state schema: ${firstError instanceof Error ? firstError.message : String(firstError)}.`,
46363
- `Return only one corrected JSON object matching this schema: ${JSON.stringify(spec.stateJsonSchema)}.`,
46364
- "Use only evidence already collected. Do not call tools, edit files, or add commentary."
46365
- ].join(" "), {
46366
- skills: spec.skills,
46367
- permissions: researchPermissions
46368
- });
46369
- assertResearchReadOnly();
46370
- state = decodeResearchState(repaired.state);
46371
- researched = repaired;
46372
- } catch (repairError) {
46373
- const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
46374
- const path = writeResearchFailure(input.root, runsDirectory, id, {
46375
- kind: "pagegraph-workflow-research-failure",
46376
- workflow: spec.id,
46377
- original,
46378
- repaired,
46379
- firstError: String(firstError),
46380
- repairError: String(repairError)
46381
- });
46382
- throw new Error(`${spec.id} research state failed schema validation after one read-only repair. Research failure artifact: ${path}`, { cause: repairError });
46383
- }
46384
- }
46385
- const decisionInputs = spec.decisionInputs(state);
46386
- if (suggestionReport !== void 0) {
46387
- const valid = new Set(suggestionReport.candidates.map((item) => `${item.source}\u0000${item.destination}\u0000${item.anchor}\u0000${item.sentence}`));
46388
- for (const item of decisionInputs) {
46389
- const link = item;
46390
- if (!valid.has(`${link.from}\u0000${link.to}\u0000${link.anchor}\u0000${link.context}`)) throw new Error("Research proposed a link absent from the supplied suggestions");
46391
- }
46392
- }
46393
- for (const item of decisionInputs) {
46394
- const error = spec.validateDecisionInput(item);
46395
- if (error !== void 0) throw new Error(error);
46396
- }
46397
- const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
46398
- const checkpoint = {
46399
- kind: "pagegraph-workflow-research-checkpoint",
46400
- schemaVersion: 1,
46401
- id,
46402
- workflow: spec.id,
46403
- startedAt: started.toISOString(),
46404
- checkpointedAt: now().toISOString(),
46405
- project: {
46406
- root: input.root,
46407
- head: gitAtStart.head,
46408
- dirtyAtStart: gitAtStart.dirty,
46409
- filesAtStart: gitAtStart.files
46410
- },
46411
- options: input.options,
46412
- evidence: {
46413
- ...suppliedEvidence,
46414
- executor: researched.executor
46415
- },
46416
- state,
46417
- decisionInputs,
46418
- opencode: {
46419
- agent: "seo",
46420
- sessionId: researched.sessionId,
46421
- model: host.model,
46422
- transcript: researched.transcript
46423
- }
46424
- };
46425
- const checkpointPath = writeResearchCheckpoint(input.root, runsDirectory, checkpoint);
46426
- const gitAtCheckpoint = inspectGit(input.root);
46427
- try {
46428
- const decisionReport = await (dependencies.decide ?? defaultDecide)(decisionInputs, spec);
46429
- const accepted = spec.id === "improve.links" ? decisionReport.resolved.filter((record) => record.verdict === "add" || record.verdict === "update") : void 0;
46430
- const acceptedIndexes = /* @__PURE__ */ new Set();
46431
- if (accepted !== void 0) for (const record of accepted) {
46432
- const match = /^workflow-links:(\d+)$/.exec(record.decisionId);
46433
- const index = match === null ? -1 : Number(match[1]);
46434
- const item = decisionInputs[index];
46435
- if (!item || record.inputRef !== `${item.from} → ${item.to}`) throw new Error(`Decision does not match a researched link: ${record.decisionId}`);
46436
- acceptedIndexes.add(index);
46437
- }
46438
- const actionItems = accepted === void 0 ? void 0 : state.items.filter((_, index) => acceptedIndexes.has(index));
46439
- if (actionItems !== void 0 && actionItems.length > 0 && suggestionReport !== void 0) {
46440
- const readSentences = dependencies.readSuggestionSentences ?? readCurrentSuggestionSentences;
46441
- const current = /* @__PURE__ */ new Map();
46442
- for (const item of actionItems) {
46443
- const candidate = suggestionReport.candidates.find((entry) => entry.source === item.from && entry.destination === item.to && entry.anchor === item.anchor && entry.sentence === item.context);
46444
- if (!candidate) throw new Error("Accepted link is absent from the supplied suggestions");
46445
- for (const path of [candidate.source, candidate.destination]) if (!current.has(path)) current.set(path, await readSentences(suggestionReport.origin, path, input.options.allowPrivate === true, suggestionReport.maxBodyBytes));
46446
- if (!current.get(candidate.source).includes(candidate.sentence) || !current.get(candidate.destination).includes(candidate.targetSentence)) throw new Error(`Suggestion is stale; served copy changed for ${candidate.source} → ${candidate.destination}. Regenerate links candidates --site.`);
46430
+ state = decodeResearchState(researched.state);
46431
+ } catch (firstError) {
46432
+ stage = "repair";
46433
+ try {
46434
+ const repaired = await host.continue(researched.sessionId, [
46435
+ `The previous ${spec.id} research JSON did not match the required state schema: ${firstError instanceof Error ? firstError.message : String(firstError)}.`,
46436
+ `Return only one corrected JSON object matching this schema: ${JSON.stringify(spec.stateJsonSchema)}.`,
46437
+ "Use only evidence already collected. Do not call tools, edit files, or add commentary."
46438
+ ].join(" "), {
46439
+ skills: spec.skills,
46440
+ permissions: researchPermissions
46441
+ });
46442
+ retainResult(repaired);
46443
+ assertResearchReadOnly();
46444
+ state = decodeResearchState(repaired.state);
46445
+ researched = repaired;
46446
+ } catch (repairError) {
46447
+ throw new Error(`${spec.id} research state failed schema validation after one read-only repair.`, { cause: new AggregateError([firstError, repairError], "Research decode and repair failed", { cause: repairError }) });
46447
46448
  }
46448
46449
  }
46449
- const actionState = actionItems === void 0 ? state : {
46450
- ...state,
46451
- items: actionItems
46452
- };
46453
- const actionDecisions = accepted === void 0 ? decisionReport : {
46454
- ...decisionReport,
46455
- resolved: accepted,
46456
- review: [],
46457
- counts: {
46458
- inputs: accepted.length,
46459
- resolved: accepted.length,
46460
- review: 0
46461
- },
46462
- verdicts: {
46463
- add: accepted.filter((record) => record.verdict === "add").length,
46464
- update: accepted.filter((record) => record.verdict === "update").length
46450
+ const decisionInputs = spec.decisionInputs(state);
46451
+ if (suggestionReport !== void 0) {
46452
+ const valid = new Set(suggestionReport.candidates.map((item) => `${item.source}\u0000${item.destination}\u0000${item.anchor}\u0000${item.sentence}`));
46453
+ for (const item of decisionInputs) {
46454
+ const link = item;
46455
+ if (!valid.has(`${link.from}\u0000${link.to}\u0000${link.anchor}\u0000${link.context}`)) throw new Error("Research proposed a link absent from the supplied suggestions");
46465
46456
  }
46466
- };
46467
- const mayAct = spec.mutatesFiles && (actionItems === void 0 || actionItems.length > 0);
46468
- const acted = mayAct ? await host.continue(researched.sessionId, actionPrompt(spec, actionState, actionDecisions, mutation), {
46469
- skills: spec.skills,
46470
- permissions: actionPermissions
46471
- }) : researched;
46472
- const gitAfter = inspectGit(input.root);
46473
- const files = changedFiles(gitAtCheckpoint, gitAfter);
46474
- if ((!spec.mutatesFiles || input.options.dryRun) && files.length > 0) throw new Error(`${spec.id} changed repository files while running in read-only mode: ${files.join(", ")}`);
46475
- const run = {
46476
- kind: "pagegraph-workflow-run",
46457
+ }
46458
+ const checkpoint = {
46459
+ kind: "pagegraph-workflow-research-checkpoint",
46477
46460
  schemaVersion: 1,
46478
46461
  id,
46479
46462
  workflow: spec.id,
46480
46463
  startedAt: started.toISOString(),
46481
- finishedAt: now().toISOString(),
46464
+ checkpointedAt: now().toISOString(),
46482
46465
  project: {
46483
46466
  root: input.root,
46484
46467
  head: gitAtStart.head,
46485
- dirtyAtStart: gitAtStart.dirty
46468
+ dirtyAtStart: gitAtStart.dirty,
46469
+ filesAtStart: gitAtStart.files
46486
46470
  },
46487
46471
  options: input.options,
46488
- model: host.model,
46489
- targets: {
46490
- pages: evidence.graph.nodes.map((node) => node.path),
46491
- queries: input.options.queries,
46492
- kinds: input.options.kinds
46493
- },
46494
46472
  evidence: {
46495
46473
  ...suppliedEvidence,
46496
- executor: acted.executor
46474
+ executor: researched.executor
46497
46475
  },
46498
- decisions: [decisionArtifact(spec, decisionInputs, decisionReport)],
46499
- changes: {
46500
- files,
46501
- providerCalls: acted.executor.calls.map((call) => call.tool)
46502
- },
46503
- result: mayAct ? acted.state : accepted !== void 0 ? {
46504
- summary: "No link suggestions accepted.",
46505
- outcome: "no-change",
46506
- files: []
46507
- } : state,
46476
+ state,
46477
+ decisionInputs,
46508
46478
  opencode: {
46509
46479
  agent: "seo",
46510
- sessionId: acted.sessionId,
46511
- transcript: acted.transcript
46480
+ sessionId: researched.sessionId,
46481
+ model: host.model,
46482
+ transcript: researched.transcript
46512
46483
  }
46513
46484
  };
46514
- return {
46515
- run,
46516
- directory: writeRunBundle(input.root, runsDirectory, run)
46517
- };
46485
+ const checkpointPath = writeResearchCheckpoint(input.root, runsDirectory, checkpoint);
46486
+ const gitAtCheckpoint = inspectGit(input.root);
46487
+ stage = "decide";
46488
+ try {
46489
+ const decisionReport = await (dependencies.decide ?? defaultDecide)(decisionInputs, spec);
46490
+ const accepted = spec.id === "improve.links" ? decisionReport.resolved.filter((record) => record.verdict === "add" || record.verdict === "update") : void 0;
46491
+ const acceptedIndexes = /* @__PURE__ */ new Set();
46492
+ if (accepted !== void 0) for (const record of accepted) {
46493
+ const match = /^workflow-links:(\d+)$/.exec(record.decisionId);
46494
+ const index = match === null ? -1 : Number(match[1]);
46495
+ const item = decisionInputs[index];
46496
+ if (!item || record.inputRef !== `${item.from} → ${item.to}`) throw new Error(`Decision does not match a researched link: ${record.decisionId}`);
46497
+ acceptedIndexes.add(index);
46498
+ }
46499
+ const actionItems = accepted === void 0 ? void 0 : state.items.filter((_, index) => acceptedIndexes.has(index));
46500
+ if (actionItems !== void 0 && actionItems.length > 0 && suggestionReport !== void 0) {
46501
+ const readSentences = dependencies.readSuggestionSentences ?? readCurrentSuggestionSentences;
46502
+ const current = /* @__PURE__ */ new Map();
46503
+ for (const item of actionItems) {
46504
+ const candidate = suggestionReport.candidates.find((entry) => entry.source === item.from && entry.destination === item.to && entry.anchor === item.anchor && entry.sentence === item.context);
46505
+ if (!candidate) throw new Error("Accepted link is absent from the supplied suggestions");
46506
+ for (const path of [candidate.source, candidate.destination]) if (!current.has(path)) current.set(path, await readSentences(suggestionReport.origin, path, input.options.allowPrivate === true, suggestionReport.maxBodyBytes));
46507
+ if (!current.get(candidate.source).includes(candidate.sentence) || !current.get(candidate.destination).includes(candidate.targetSentence)) throw new Error(`Suggestion is stale; served copy changed for ${candidate.source} → ${candidate.destination}. Regenerate links candidates --site.`);
46508
+ }
46509
+ }
46510
+ const actionState = actionItems === void 0 ? state : {
46511
+ ...state,
46512
+ items: actionItems
46513
+ };
46514
+ const actionDecisions = accepted === void 0 ? decisionReport : {
46515
+ ...decisionReport,
46516
+ resolved: accepted,
46517
+ review: [],
46518
+ counts: {
46519
+ inputs: accepted.length,
46520
+ resolved: accepted.length,
46521
+ review: 0
46522
+ },
46523
+ verdicts: {
46524
+ add: accepted.filter((record) => record.verdict === "add").length,
46525
+ update: accepted.filter((record) => record.verdict === "update").length
46526
+ }
46527
+ };
46528
+ const mayAct = spec.mutatesFiles && (actionItems === void 0 || actionItems.length > 0);
46529
+ if (mayAct) stage = "action";
46530
+ const acted = mayAct ? await host.continue(researched.sessionId, actionPrompt(spec, actionState, actionDecisions, mutation), {
46531
+ skills: spec.skills,
46532
+ permissions: actionPermissions
46533
+ }) : researched;
46534
+ retainResult(acted);
46535
+ const gitAfter = inspectGit(input.root);
46536
+ const files = changedFiles(gitAtCheckpoint, gitAfter);
46537
+ if ((!spec.mutatesFiles || input.options.dryRun) && files.length > 0) throw new Error(`${spec.id} changed repository files while running in read-only mode: ${files.join(", ")}`);
46538
+ const run = {
46539
+ kind: "pagegraph-workflow-run",
46540
+ schemaVersion: 1,
46541
+ id,
46542
+ workflow: spec.id,
46543
+ startedAt: started.toISOString(),
46544
+ finishedAt: now().toISOString(),
46545
+ project: {
46546
+ root: input.root,
46547
+ head: gitAtStart.head,
46548
+ dirtyAtStart: gitAtStart.dirty
46549
+ },
46550
+ options: input.options,
46551
+ model: host.model,
46552
+ targets: {
46553
+ pages: evidence.graph.nodes.map((node) => node.path),
46554
+ queries: input.options.queries,
46555
+ kinds: input.options.kinds
46556
+ },
46557
+ evidence: {
46558
+ ...suppliedEvidence,
46559
+ executor: acted.executor
46560
+ },
46561
+ decisions: [decisionArtifact(spec, decisionInputs, decisionReport)],
46562
+ changes: {
46563
+ files,
46564
+ providerCalls: acted.executor.calls.map((call) => call.tool)
46565
+ },
46566
+ result: mayAct ? acted.state : accepted !== void 0 ? {
46567
+ summary: "No link suggestions accepted.",
46568
+ outcome: "no-change",
46569
+ files: []
46570
+ } : state,
46571
+ opencode: {
46572
+ agent: "seo",
46573
+ sessionId: acted.sessionId,
46574
+ transcript: acted.transcript
46575
+ }
46576
+ };
46577
+ return {
46578
+ run,
46579
+ directory: writeRunBundle(input.root, runsDirectory, run)
46580
+ };
46581
+ } catch (cause) {
46582
+ const message = cause instanceof Error ? cause.message : String(cause);
46583
+ throw new Error(`${message}\nResearch checkpoint: ${checkpointPath}`, { cause });
46584
+ }
46518
46585
  } catch (cause) {
46519
- const message = cause instanceof Error ? cause.message : String(cause);
46520
- throw new Error(`${message}\nResearch checkpoint: ${checkpointPath}`, { cause });
46586
+ turnFailed = true;
46587
+ throw cause;
46588
+ } finally {
46589
+ try {
46590
+ await host.close();
46591
+ } catch (cause) {
46592
+ if (!turnFailed) throw cause;
46593
+ }
46521
46594
  }
46522
- } finally {
46523
- await host.close();
46595
+ } catch (cause) {
46596
+ const seen = /* @__PURE__ */ new Set();
46597
+ let current = cause;
46598
+ while (current instanceof Error && !seen.has(current)) {
46599
+ seen.add(current);
46600
+ if (current instanceof WorkflowHostError) {
46601
+ sessionId = current.sessionId;
46602
+ transcript = current.transcript;
46603
+ break;
46604
+ }
46605
+ current = current.cause;
46606
+ }
46607
+ const message = cause instanceof Error ? cause.message : String(cause);
46608
+ let path;
46609
+ try {
46610
+ path = writeWorkflowFailure(input.root, runsDirectory, id, {
46611
+ kind: "pagegraph-workflow-failure",
46612
+ schemaVersion: 1,
46613
+ id,
46614
+ workflow: spec.id,
46615
+ stage,
46616
+ sessionId,
46617
+ transcript,
46618
+ cause: serializeCause(cause)
46619
+ });
46620
+ } catch (writeError) {
46621
+ if (cause instanceof Error) {
46622
+ cause.message += `\nCould not write failure artifact: ${writeError instanceof Error ? writeError.message : String(writeError)}`;
46623
+ throw cause;
46624
+ }
46625
+ throw new Error(`${message}\nCould not write failure artifact: ${String(writeError)}`, { cause });
46626
+ }
46627
+ throw new Error(`${message}\nFailure artifact: ${path}\nNext: ${stage === "acquire" ? "inspect that file; check the OpenCode preset and Executor plugin configuration" : stage === "decide" ? "inspect that file and research.json; check the decision provider configuration and retry" : "inspect the transcript in that file; rerun with --model <id> or raise workflows.opencode.timeoutMs"}`, { cause });
46524
46628
  }
46525
46629
  };
46526
46630
  //#endregion
@@ -47557,13 +47661,15 @@ const robotsCommand = make$9("robots", {
47557
47661
  description: "Disallow-all with no Sitemap line — the preview posture"
47558
47662
  }]), withHandler(fnUntraced(function* ({ origin, indexable }) {
47559
47663
  const config = yield* loadSeoConfig;
47560
- const graph = yield* scoped(acquireGraph(config));
47561
- yield* printText(renderRobots(graph, {
47664
+ const loaded = yield* scoped(acquireLoadedGraph(config));
47665
+ if ((config.disallow !== void 0 || config.contentSignal !== void 0 || config.directives !== void 0) && loaded.robots !== void 0) return yield* new SeoCliError({ message: "The graph loader supplies the robots policy; remove disallow, contentSignal, and directives from seo.config.ts." });
47666
+ const policy = loaded.robots ?? config;
47667
+ yield* printText(renderRobots(loaded.graph, {
47562
47668
  origin: originOf(origin, config.origin),
47563
47669
  indexable,
47564
- disallow: config.disallow,
47565
- contentSignal: config.contentSignal,
47566
- directives: config.directives,
47670
+ disallow: policy.disallow ?? [],
47671
+ contentSignal: policy.contentSignal,
47672
+ directives: policy.directives,
47567
47673
  transform: config.transform
47568
47674
  }));
47569
47675
  })));
@@ -47606,7 +47712,7 @@ const staleCommand = make$9("stale", {
47606
47712
  })));
47607
47713
  //#endregion
47608
47714
  //#region src/cli/commands/skills.ts
47609
- const skills = { core: "---\nname: core\ndescription: Use when adding Pagegraph to a TanStack Start app or changing its SEO wiring — staticData.seo declarations, seoHead in route head(), typed related links, content collections, the seoRouteConfig coverage gate, seo.config.ts, sitemap and robots routes — or when a `pagegraph check`, coverage, or rendered-head failure needs diagnosing.\n---\n\n# Pagegraph and TanStack Start\n\nEach route declares its SEO policy once in `staticData.seo`; Pagegraph derives the sitemap, robots, breadcrumbs, JSON-LD, related links, and `pagegraph check` from those declarations. TanStack Start still owns routing and the document shell. This skill ships inside the Pagegraph CLI that printed it, so it matches that version. Run the app's installed binary (`bunx pagegraph` from the app root) so the guidance and the types agree.\n\nBefore editing, read the app's `package.json`, `src/routes/`, `src/routeTree.gen.ts`, any existing `seo.config.ts`, and `vite.config.ts`. Adapt the paths below to the app's layout.\n\n## Wire it up\n\n**1. Bind site identity and typed paths once.** Route files never repeat the origin or brand.\n\n```ts\n// src/lib/seo.ts\nimport { createSeo } from \"pagegraph/react\";\nimport type { FileRouteTypes } from \"../routeTree.gen\";\n\ndeclare module \"pagegraph\" {\n interface Register {\n paths: FileRouteTypes[\"fullPaths\"];\n kinds: \"page\" | \"article\" | \"hub\";\n }\n}\n\nexport const { seoHead } = createSeo({\n origin: \"https://example.com\",\n site: {\n name: \"Example\",\n logo: \"/logo.png\",\n publisherLogo: \"/publisher.png\",\n defaultImage: \"/og.png\",\n defaultAuthor: { name: \"Example Team\" },\n },\n organization: {\n description: \"What the company does.\",\n sameAs: [],\n contactPoint: { contactType: \"support\", email: \"support@example.com\" },\n },\n website: { searchPath: \"/search?q={search_term_string}\" },\n});\n```\n\nThe `Register` augmentation is what types `staticData.seo` on TanStack routes and checks `related` and `redirectTo` against real paths, so a renamed route becomes a compile error. `kinds` is the app's own taxonomy; nothing branches on it.\n\n**2. Declare policy on the route, content in `head()`.**\n\n```tsx\n// src/routes/(marketing)/pricing.tsx\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { seoHead } from \"../../lib/seo\";\n\nexport const Route = createFileRoute(\"/(marketing)/pricing\")({\n staticData: {\n seo: {\n kind: \"page\",\n crumb: \"Pricing\",\n sitemap: { priority: 0.9, changeFrequency: \"monthly\" },\n related: [\"/features\"],\n link: { title: \"Pricing\", description: \"Simple volume pricing.\" },\n },\n },\n head: (ctx) =>\n seoHead(ctx, {\n title: \"Pricing — Example\",\n description: \"Simple volume pricing.\",\n }),\n component: PricingPage,\n});\n```\n\n- `staticData.seo` is static crawl policy and graph edges: `kind`, `crumb`, `sitemap` (or `false`), `robots`, `related`, `link`, `redirectTo`.\n- `seoHead(ctx, instance)` takes the full `title` (no suffix is added) and `description`, and optionally `article`, `faqs`, `service`, `itemList`, `jsonLd`, `canonicalPath`, and `breadcrumbs`. It returns `meta` and a canonical `links` entry for TanStack to render; the document shell must keep `<HeadContent />` and `<Scripts />`.\n- `link` is how other pages render a card for this route. Add it to every route that appears in someone's `related`.\n- Declare `related` only for links the page actually renders in its body.\n- `crumb` accepts `(match) => string` for dynamic segments, such as a title from `loaderData`.\n\n**3. Export one graph loader.** The CLI, sitemap, and robots all read it.\n\n```ts\n// src/lib/seo/graph.ts\nimport { buildSeoGraph } from \"pagegraph\";\nimport { routeTree } from \"../../routeTree.gen\";\nimport { posts } from \"../content/posts\"; // the app's own content source\n\nexport const loadSeoGraph = async () =>\n buildSeoGraph({\n routeTree,\n collections: [\n {\n route: \"/blog/$slug\",\n source: \"blog\",\n instances: posts.map((post) => ({\n path: `/blog/${post.slug}`,\n title: post.title,\n description: post.description,\n publishedAt: post.date,\n })),\n },\n ],\n });\n```\n\nA param route such as `/blog/$slug` never enters the sitemap itself; list its pages as a collection. Instances inherit the param route's `kind` and `sitemap`, so declare those on `/blog/$slug`. Omit `collections` when every public page is a static route.\n\n**4. Point the CLI at the loader** with `seo.config.ts` at the app root:\n\n```ts\n// seo.config.ts\nimport { defineSeoConfig, viteGraphLoader } from \"pagegraph/config\";\n\nexport default defineSeoConfig({\n origin: \"https://example.com\",\n disallow: [\"/dashboard\", \"/api\"],\n loadGraph: viteGraphLoader({\n root: import.meta.dirname,\n entry: \"/src/lib/seo/graph.ts\",\n exportName: \"loadSeoGraph\",\n }),\n});\n```\n\n`entry` is root-relative and evaluated in a headless Vite server, so aliases and content plugins resolve as they do in the app. Optional fields: `contentSignal`, `coverage` (`[{ path, minInbound }]`, contextual-link minimums enforced by `check`), `directives`, and `transform`.\n\n**5. Serve the projections.** In Start server routes, return `renderSitemap(graph, { origin, indexable })` and `renderRobots(graph, { origin, indexable, disallow, contentSignal })` from `pagegraph`. On preview deployments, pass `indexable: false` so robots.txt disallows everything; the sitemap body does not change.\n\n**6. Optionally gate coverage at build time.**\n\n```ts\n// vite.config.ts\nimport { fileURLToPath } from \"node:url\";\nimport { tanstackStart } from \"@tanstack/react-start/plugin/vite\";\nimport { defineConfig } from \"vite\";\nimport { seoRouteConfig } from \"pagegraph/vite\";\n\nexport default defineConfig({\n plugins: [\n tanstackStart(),\n // Runs its own route parse; it does not replace the Start plugin.\n seoRouteConfig({\n outputPath: fileURLToPath(new URL(\"./src/lib/route-config.ts\", import.meta.url)),\n publicGroups: [\"(marketing)\"],\n enforceCoverageIn: [\"(marketing)\"],\n alwaysDisallow: [\"/dashboard\", \"/api\"],\n }),\n ],\n});\n```\n\nThis plugin fails dev startup and `vite build` when a page leaf in an enforced group has neither `staticData` nor `head`. It writes `robotsExclusions` and `reservedSegments` to `outputPath`. Feed `robotsExclusions` into `seo.config.ts` `disallow` and `renderRobots` instead of repeating the list.\n\n## Rules that bite\n\n- `robots` values must be lowercase (`\"noindex, follow\"`). The sitemap projection matches `noindex` literally, so `check` rejects mixed case.\n- A route with `robots` containing `noindex` or with `redirectTo` must not declare a positive `sitemap`. Use `sitemap: false` or omit it.\n- A `related` target route without `link` fails `check`. Collection instances are exempt because their card uses the instance `title` and `description`.\n- Coverage enforcement sees only route files inside the named group folders. `route.tsx` layouts, `.ts` server routes, and ungrouped routes are not checked. List ungrouped public routes in `extraPublicRoutes`.\n- `seoRouteConfig` throws if the directory of `outputPath` does not exist. Point it outside `src/routes` so the route watcher does not loop.\n- The graph knows route declarations and collection instances, not `head()` output. Editorial title and description checks cover instances only. A route's rendered title, headings, and JSON-LD are verified only by inspecting HTML (`check --site`).\n- A FAQPage question or answer, or an Offer price, that is not in the server-rendered text fails `check --site`. Accordions must render closed panels into the HTML (for example `hidden=\"until-found\"`), not mount them on open.\n- Dates (`publishedAt`, `modifiedAt`, a route's `modifiedAt`) must be ISO 8601 (`2026-09-29`, or a date-time with an offset); they become the sitemap's `<lastmod>`, and `check` rejects any other form.\n- Keep indexable pages server-rendered or prerendered. An `ssr: false` route's head is not in the served HTML.\n\n## Verify\n\n| Changed | Run | Proves |\n| --- | --- | --- |\n| Declarations, collections, `seo.config.ts` | `pagegraph check` | No structural violations (exit 1 otherwise); editorial findings are reported but do not fail |\n| Headings, JSON-LD, robots meta, canonicals, page copy | `pagegraph check --site <url>` against a running server | Every declared page's rendered HTML matches its declaration and its structured data |\n| `modifiedAt` dates, `freshness` policy | `pagegraph stale` | The refresh queue and which pages are undated |\n| Graph shape | `pagegraph graph`, `pagegraph inspect /path` | Nodes, edges, and a page's sitemap status |\n| Sitemap or robots wiring | `pagegraph sitemap`, `pagegraph robots` | The projections from the declared graph |\n| `head()`, `seoHead` input, document shell | `pagegraph inspect <full-url> --live` against a running server | The served `<head>` and JSON-LD |\n| Rendered links vs `related` | `pagegraph links verify <url>` (add `--assert-coverage` for `coverage` rules) | What a crawler receives, not what was declared |\n| Coverage gate | `vite build` | Every enforced page leaf declares SEO |\n\nA passing `check` without `--site` says nothing about served HTML. Verify head and content changes with `check --site`, `inspect --live`, or a rendered-HTML test. Add `--json` when a script consumes the output. Stdout carries the data and stderr carries status. Check the exit code as well as the output.\n" };
47715
+ const skills = { core: "---\nname: core\ndescription: Use when adding Pagegraph to a TanStack Start app or changing its SEO wiring — the pagegraph() Vite plugin, staticData.seo declarations and seo.head, title templates, typed related links, content collections, the coverage gate, seo.config.ts, sitemap and robots routes — or when a `pagegraph check`, coverage, or rendered-head failure needs diagnosing.\n---\n\n# Pagegraph and TanStack Start\n\nEach route declares its SEO policy once in `staticData.seo`; Pagegraph derives the sitemap, robots, breadcrumbs, JSON-LD, related links, and `pagegraph check` from those declarations. TanStack Start still owns routing and the document shell. This skill ships inside the Pagegraph CLI that printed it, so it matches that version. Run the app's installed binary (`bunx pagegraph` from the app root) so the guidance and the types agree.\n\nBefore editing, read the app's `package.json`, `src/routes/`, `src/routeTree.gen.ts`, any existing `seo.config.ts`, and `vite.config.ts`. Adapt the paths below to the app's layout.\n\n## Wire it up\n\n**1. Bind site identity and typed paths once.** Route files never repeat the origin or brand.\n\n```ts\n// src/lib/seo.ts\nimport { createSeo } from \"pagegraph/react\";\nimport type { FileRouteTypes } from \"../routeTree.gen\";\n\ndeclare module \"pagegraph\" {\n interface Register {\n paths: FileRouteTypes[\"fullPaths\"];\n kinds: \"page\" | \"article\" | \"hub\";\n }\n}\n\nconst site = createSeo({\n origin: \"https://example.com\",\n site: {\n name: \"Example\",\n logo: \"/logo.png\",\n publisherLogo: \"/publisher.png\",\n defaultImage: \"/og.png\",\n defaultAuthor: { name: \"Example Team\" },\n },\n organization: {\n description: \"What the company does.\",\n sameAs: [],\n contactPoint: { contactType: \"support\", email: \"support@example.com\" },\n },\n website: { searchPath: \"/search?q={search_term_string}\" },\n});\n\nexport const seo = site; // seo.head for static routes\nexport const { seoHead } = site;\n```\n\nThe `Register` augmentation is what types `staticData.seo` on TanStack routes and checks `related` and `redirectTo` against real paths, so a renamed route becomes a compile error. `kinds` is the app's own taxonomy; nothing branches on it.\n\n**2. Declare each static page once.** `staticData.seo` holds the crawl policy and the head together; `seo.head` renders it.\n\n```tsx\n// src/routes/(marketing)/pricing.tsx\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { seo } from \"../../lib/seo\";\n\nexport const Route = createFileRoute(\"/(marketing)/pricing\")({\n staticData: {\n seo: {\n kind: \"page\",\n crumb: \"Pricing\",\n sitemap: { priority: 0.9, changeFrequency: \"monthly\" },\n related: [\"/features\"],\n link: { title: \"Pricing\", description: \"Simple volume pricing.\" },\n head: { title: \"Pricing — Example\", description: \"Simple volume pricing.\", faqs: pricingFaqs },\n },\n },\n head: seo.head,\n component: PricingPage,\n});\n```\n\n- Policy fields: `kind`, `crumb`, `sitemap` (or `false`), `robots`, `related`, `link`, `redirectTo`, `modifiedAt`, `titleTemplate`.\n- `head` is the full `title` and `description`, plus optional `faqs`, `article`, `service`, `itemList`, `jsonLd`, `ogTitle`, `canonicalPath`, and `breadcrumbs`. The title, description, and FAQs land in `staticData.seo.head`, which the graph, `pageHeads`, and `check` read — the page and the graph cannot disagree.\n- Write `staticData` as an object literal and `head: seo.head`: the coverage gate reads both keys statically, and a helper call or an arrow typed with `AnyRouteMatch` in route options would make TanStack infer `any` search and loader types for the route tree.\n- `link` is how other pages render a card for this route. Add it to every route that appears in someone's `related`.\n- Declare `related` only for links the page actually renders in its body.\n- `crumb` accepts `(match) => string` for dynamic segments, such as a title from `loaderData`.\n\n**3. Dynamic routes keep `head()` and declare a title template.** A param route renders from `loaderData`, so it calls `seoHead`; its `titleTemplate` (`%s` is the page title) is applied by `seoHead` and by the graph to the route's collection instances, so the suffix lives in one place.\n\n```tsx\nexport const Route = createFileRoute(\"/(marketing)/blog/$slug\")({\n staticData: {\n seo: { kind: \"article\", titleTemplate: \"%s | Example Blog\", sitemap: { priority: 0.7, changeFrequency: \"weekly\" } },\n },\n head: (ctx) => (ctx.loaderData ? seoHead(ctx, { title: ctx.loaderData.title, description: ctx.loaderData.description }) : {}),\n});\n```\n\n**4. List content pages as collections.** A param route such as `/blog/$slug` never enters the sitemap itself; its pages come from a collections module. `contentCollection` accepts any pages with a `url` (Fumadocs `loader().getPages()` directly) and turns each entry's `related` into edges. Instances inherit the param route's `kind`, `sitemap`, and `titleTemplate`.\n\n```ts\n// src/lib/seo/collections.ts\nimport { contentCollection } from \"pagegraph\";\nimport { blogSource } from \"../blog-source\";\n\nexport const collections = () => [\n contentCollection({\n route: \"/blog/$slug\",\n source: \"blog\",\n pages: blogSource.getPages(),\n entry: (page) => ({\n title: page.data.title,\n description: page.data.description,\n publishedAt: page.data.date,\n related: page.data.related,\n }),\n }),\n];\n```\n\n**5. Register the plugin once.** It builds the graph inside the app's Vite pipeline (aliases, `define`s, and content plugins apply; no stubs) and ships it to the server runtime.\n\n```ts\n// vite.config.ts\nimport { fileURLToPath } from \"node:url\";\nimport { tanstackStart } from \"@tanstack/react-start/plugin/vite\";\nimport { pagegraph } from \"pagegraph/tanstack-start\";\nimport { defineConfig } from \"vite\";\n\nexport default defineConfig({\n plugins: [\n tanstackStart(),\n pagegraph({\n origin: \"https://example.com\",\n indexable: process.env.DEPLOY_ENV === \"production\",\n robots: { contentSignal: \"search=yes, ai-input=yes, ai-train=yes\" },\n collections: \"src/lib/seo/collections.ts\",\n routeConfig: {\n outputPath: fileURLToPath(new URL(\"./src/lib/route-config.ts\", import.meta.url)),\n publicGroups: [\"(marketing)\"],\n enforceCoverageIn: [\"(marketing)\"],\n alwaysDisallow: [\"/dashboard\", \"/api\"],\n },\n }),\n ],\n});\n```\n\n- The graph evaluates every route file that declares `staticData` in a Node environment named `pagegraph`, never `routeTree.gen.ts`. A route that imports a server-runtime-only module (`cloudflare:workers`) fails with its file name; if it is an app surface rather than a public page, add it to `exclude` (route-file globs relative to `src/routes`).\n- `routeConfig` is the coverage gate: dev and `vite build` fail when a page leaf in an enforced group has neither `staticData` nor `head`. Its `robotsExclusions` join `robots.disallow`.\n- `indexable: false` (previews, local) serves a disallow-all robots.txt with no Sitemap line.\n\n**6. Serve robots.txt and sitemap.xml.** They are routes, because their output depends on the deployment.\n\n```ts\n// src/routes/robots[.]txt.ts — sitemap[.]xml.ts is the same with sitemapXml\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { robotsTxt } from \"pagegraph/tanstack-start/server\";\n\nexport const Route = createFileRoute(\"/robots.txt\")({ server: { handlers: { GET: robotsTxt } } });\n```\n\n`pagegraph/tanstack-start/server` also exports `seoGraph()` and `seoSite()` for other server code. Never import `pagegraph/tanstack-start`, `pagegraph/config`, `pagegraph/vite`, or `pagegraph/audit` from code a Worker or browser bundle reaches: under those runtimes they resolve to a stub that throws at import.\n\n**7. Point the CLI at the same graph** with `seo.config.ts` at the app root:\n\n```ts\n// seo.config.ts\nimport { defineSeoConfig } from \"pagegraph/config\";\nimport { tanstackStartGraph } from \"pagegraph/tanstack-start\";\n\nexport default defineSeoConfig({\n origin: \"https://example.com\",\n loadGraph: tanstackStartGraph({ root: import.meta.dirname }),\n});\n```\n\n`tanstackStartGraph` evaluates the app's Vite config the way the dev server does (command `serve`) in `production` mode, and returns the plugin's graph and robots policy. Optional fields: `coverage` (`[{ path, minInbound }]`, contextual-link minimums enforced by `check`), `content`, `freshness`, and `workflows`. Build-time head consumers use `loadPageHeads(tanstackStartGraph({ root }), { exclude })` from `pagegraph/config`.\n\n## Rules that bite\n\n- `robots` values must be lowercase (`\"noindex, follow\"`). The sitemap projection matches `noindex` literally, so `check` rejects mixed case.\n- A route with `robots` containing `noindex` or with `redirectTo` must not declare a positive `sitemap`. Use `sitemap: false` or omit it.\n- A `related` target route without `link` fails `check`. Collection instances are exempt because their card uses the instance `title` and `description`.\n- Coverage enforcement sees only route files inside the named group folders. `route.tsx` layouts, `.ts` server routes, and ungrouped routes are not checked. List ungrouped public routes in `extraPublicRoutes`.\n- `routeConfig.outputPath` must be in an existing directory outside `src/routes`, so the route watcher does not loop.\n- The graph knows declared heads (`staticData.seo.head`) and collection instances, not what a dynamic `head()` computes at request time. A route's rendered headings and JSON-LD are verified only by inspecting HTML (`check --site`).\n- A FAQPage question or answer, or an Offer price, that is not in the server-rendered text fails `check --site`. Accordions must render closed panels into the HTML (for example `hidden=\"until-found\"`), not mount them on open.\n- Dates (`publishedAt`, `modifiedAt`, a route's `modifiedAt`) must be ISO 8601 (`2026-09-29`, or a date-time with an offset); they become the sitemap's `<lastmod>`, and `check` rejects any other form.\n- Keep indexable pages server-rendered or prerendered. An `ssr: false` route's head is not in the served HTML.\n\n## Verify\n\n| Changed | Run | Proves |\n| --- | --- | --- |\n| Declarations, collections, the plugin, `seo.config.ts` | `pagegraph check` | No structural violations (exit 1 otherwise); editorial findings are reported but do not fail |\n| Headings, JSON-LD, robots meta, canonicals, page copy | `pagegraph check --site <url>` against a running server | Every declared page's rendered HTML matches its declaration and its structured data |\n| `modifiedAt` dates, `freshness` policy | `pagegraph stale` | The refresh queue and which pages are undated |\n| Graph shape | `pagegraph graph`, `pagegraph inspect /path` | Nodes, edges, and a page's sitemap status |\n| Sitemap or robots wiring | `pagegraph sitemap`, `pagegraph robots` | The projections from the declared graph |\n| Declared heads, `seoHead` input, document shell | `pagegraph inspect <full-url> --live` against a running server | The served `<head>` and JSON-LD |\n| Rendered links vs `related` | `pagegraph links verify <url>` (add `--assert-coverage` for `coverage` rules) | What a crawler receives, not what was declared |\n| Coverage gate | `vite build` | Every enforced page leaf declares SEO |\n\nA passing `check` without `--site` says nothing about served HTML. Verify head and content changes with `check --site`, `inspect --live`, or a rendered-HTML test. Add `--json` when a script consumes the output. Stdout carries the data and stderr carries status. Check the exit code as well as the output.\n" };
47610
47716
  const nameArgument = String$1("name").pipe(withDescription("Bundled skill name from `pagegraph skills list`"));
47611
47717
  const listCommand = make$9("list", { json: jsonFlag }).pipe(withDescription$1("List skills embedded in this Pagegraph CLI"), withExamples([{
47612
47718
  command: "pagegraph skills list",