@graphit/cli 0.2.270 → 0.2.306

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 (37) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/bin/graphit +1 -1
  5. package/bin/graphit.ps1 +1 -1
  6. package/dist/commands/connector.js +4 -2
  7. package/dist/commands/connector.js.map +1 -1
  8. package/dist/commands/dashboard.js +24 -11
  9. package/dist/commands/dashboard.js.map +1 -1
  10. package/dist/commands/ds/refresh-history.js +2 -1
  11. package/dist/commands/ds/refresh-history.js.map +1 -1
  12. package/dist/commands/ds/ui-only.js +10 -4
  13. package/dist/commands/ds/ui-only.js.map +1 -1
  14. package/dist/commands/ds.js +8 -4
  15. package/dist/commands/ds.js.map +1 -1
  16. package/dist/commands/governance.js +2 -2
  17. package/dist/commands/governance.js.map +1 -1
  18. package/dist/commands/kb-create.js +8 -8
  19. package/dist/commands/kb-create.js.map +1 -1
  20. package/dist/commands/kb-delete.js +4 -1
  21. package/dist/commands/kb-delete.js.map +1 -1
  22. package/dist/commands/kb-read.js +22 -10
  23. package/dist/commands/kb-read.js.map +1 -1
  24. package/dist/commands/kb-shared.d.ts +3 -0
  25. package/dist/commands/kb-shared.js +12 -0
  26. package/dist/commands/kb-shared.js.map +1 -1
  27. package/dist/commands/kb-update.js +19 -10
  28. package/dist/commands/kb-update.js.map +1 -1
  29. package/dist/commands/query.js +2 -1
  30. package/dist/commands/query.js.map +1 -1
  31. package/package.json +4 -4
  32. package/scripts/commander-walk.mjs +154 -0
  33. package/scripts/generate-commands-doc.mjs +26 -95
  34. package/scripts/generate-tool-manifest.mjs +613 -0
  35. package/scripts/verb-policy-source.json +555 -0
  36. package/skills/graphit/SKILL.md +2 -2
  37. package/skills/graphit/VERSION.json +1 -1
@@ -0,0 +1,613 @@
1
+ #!/usr/bin/env node
2
+ // Generates the platform agent's tool surface from the CLI Commander tree.
3
+ //
4
+ // Project #275: the in-app agent's vocabulary IS the CLI's vocabulary. One
5
+ // Commander walk (commander-walk.mjs) feeds both the SKILL.md command table and
6
+ // the two artifacts written here, so the surfaces cannot drift.
7
+ //
8
+ // node scripts/generate-tool-manifest.mjs # write the artifacts
9
+ // node scripts/generate-tool-manifest.mjs --check # exit 1 if they are stale
10
+ // node scripts/generate-tool-manifest.mjs --stdout # print the manifest only
11
+ //
12
+ // Requires a prior `npm run build` (it imports the compiled registrars).
13
+ //
14
+ // Everything this file refuses to do is deliberate:
15
+ // - it never invents or rewrites a description. A thin one is enriched at the
16
+ // source in cli/src/commands/*.ts, where --help improves too.
17
+ // - it never emits `additionalProperties` (Gemini rejects schemas carrying it).
18
+ // - it never emits an org/user identity parameter. Tenancy is bound from the
19
+ // authenticated session at construction, never from a model-supplied value.
20
+
21
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { buildProgram, cliRoot, collectVerbs } from "./commander-walk.mjs";
24
+
25
+ const checkOnly = process.argv.includes("--check");
26
+ const toStdout = process.argv.includes("--stdout");
27
+
28
+ // Overridable so the drift tests can drive a mutated policy source without
29
+ // touching the committed one. Never set outside tests.
30
+ const policyPath =
31
+ process.env.GRAPHIT_VERB_POLICY_PATH ?? join(cliRoot, "scripts", "verb-policy-source.json");
32
+ const outDir = join(
33
+ cliRoot, "..", "back", "app", "services", "graphit_agent",
34
+ "platform_agent", "tools", "generated",
35
+ );
36
+ const manifestPath = join(outDir, "tool-manifest.json");
37
+ const policyOutPath = join(outDir, "verb-policy.json");
38
+
39
+ // ---------------------------------------------------------------- constraints
40
+
41
+ /** #178's tool window holds only while a noun stays comprehensible. `kb` sits at
42
+ * this ceiling today; the prescribed response when it fires is to split the
43
+ * noun by lifecycle (read vs authoring), never to reach for `oneOf`. */
44
+ const MAX_ACTIONS_PER_NOUN = 25;
45
+
46
+ /** Project #275 US-3.4: tool docs are always-on, so each is charged to every turn. */
47
+ const MAX_TOOL_DOC_CHARS = 2000;
48
+
49
+ const MUTATION_CLASSES = new Set([
50
+ "none", "kb", "canvas", "dashboard", "data_source", "governance",
51
+ ]);
52
+ const SURFACES = new Set(["both", "cli_only", "app_only"]);
53
+
54
+ /** Project #275 SEC-2: identity is bound from the session, never routed through
55
+ * a schema the model fills in. Matched against the derived parameter name. */
56
+ const TENANT_PARAM_DENYLIST = [
57
+ /^org(_?id)?$/, /^organization(_?id)?$/, /^user(_?id)?$/, /^tenant(_?id)?$/,
58
+ /^account_?id$/, /^org_role$/, /^actor(_?id)?$/, /^principal$/, /^impersonate/,
59
+ /^on_behalf_of/, /^as_user/,
60
+ ];
61
+
62
+ /** PE-DENY-001: internal issue/feature/project numbers never reach a prompt.
63
+ * `#NNN` has to stand alone - a hex colour like #4DB6AC is not a reference. */
64
+ const INTERNAL_REF = /(?:^|[^0-9A-Za-z_])#\d+(?![0-9A-Za-z_])/;
65
+
66
+ /** PE-DENY-002: a description teaches the shape of a secret, never a value.
67
+ * Deliberately narrow - long high-entropy tokens and the recognisable key
68
+ * prefixes - so a legitimate example like `--key <path>` stays legal. */
69
+ const CREDENTIAL_SHAPED =
70
+ /\b(?:sk|pk|ghp|gho|ghs|ghu|xox[abprs])[-_][A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/;
71
+
72
+ /** A value option whose placeholder is one of these is a number, not a string.
73
+ * Purely mechanical - no guessing from the description. */
74
+ const NUMERIC_PLACEHOLDERS = new Set(["n", "days", "seconds", "ms", "rows", "bytes"]);
75
+
76
+ /** Types an `app_params` entry may declare. Deliberately the scalar set the
77
+ * Commander walk itself produces: an in-app parameter is a per-surface
78
+ * mechanics difference, not a licence to invent a richer schema than the CLI's. */
79
+ const APP_PARAM_TYPES = new Set(["string", "integer", "boolean"]);
80
+
81
+ const errors = [];
82
+ const fail = (msg) => errors.push(msg);
83
+
84
+ /** Nothing is written while a single gate is unsatisfied. */
85
+ function exitOnErrors() {
86
+ if (!errors.length) return;
87
+ console.error("Tool manifest generation failed:");
88
+ for (const e of errors) console.error(` - ${e}`);
89
+ process.exit(1);
90
+ }
91
+
92
+ // ------------------------------------------------------------------- helpers
93
+
94
+ /** CLI flag or positional arg -> JSON Schema property name. Mechanical and
95
+ * reversible: lowercase, `-` becomes `_`, camelCase splits on the hump. */
96
+ function paramName(raw) {
97
+ return raw
98
+ .replace(/^--/, "")
99
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
100
+ .replace(/-/g, "_")
101
+ .toLowerCase();
102
+ }
103
+
104
+ function valuePlaceholder(flags) {
105
+ const m = flags.match(/[<[]([^>\]]+)[>\]]\s*$/);
106
+ return m ? m[1].replace(/\.\.\.$/, "") : null;
107
+ }
108
+
109
+ function optionSchema(opt) {
110
+ if (!opt.takesValue) return { type: "boolean" };
111
+ const placeholder = valuePlaceholder(opt.flags);
112
+ const numeric =
113
+ (placeholder && NUMERIC_PLACEHOLDERS.has(placeholder)) ||
114
+ (typeof opt.defaultValue === "string" && /^\d+$/.test(opt.defaultValue));
115
+ const scalar = { type: numeric ? "integer" : "string" };
116
+ // Commander marks `--flag <v...>` variadic, but a repeatable option built from
117
+ // a custom collector (`(val, prev) => [...prev, val]`) is not variadic - its
118
+ // array default is the only signal, and without this it ships as a scalar the
119
+ // model can only pass once.
120
+ const repeatable = opt.variadic || Array.isArray(opt.defaultValue);
121
+ return repeatable ? { type: "array", items: scalar } : scalar;
122
+ }
123
+
124
+ function argSchema(arg) {
125
+ return arg.variadic
126
+ ? { type: "array", items: { type: "string" } }
127
+ : { type: "string" };
128
+ }
129
+
130
+ /** First sentence of a description, for the action one-liners in the tool doc. */
131
+ function firstSentence(text) {
132
+ const clean = String(text ?? "").replace(/\s+/g, " ").trim();
133
+ const stop = clean.search(/\.(\s|$)/);
134
+ return (stop === -1 ? clean : clean.slice(0, stop)).trim();
135
+ }
136
+
137
+ /**
138
+ * True when a summary only restates the action name it would sit beside
139
+ * ("create metric" / "Create a new metric").
140
+ *
141
+ * The doc drops those, and only those. This is suppression of a tautology, not
142
+ * a rewrite: any summary carrying a word the action name does not already have
143
+ * survives verbatim ("Create a new relationship (JOIN between tables)" stays).
144
+ */
145
+ function restatesActionName(summary, action) {
146
+ const normalize = (s) =>
147
+ String(s)
148
+ .toLowerCase()
149
+ .replace(/\b(a|an|the|new)\b/g, "")
150
+ .replace(/[^a-z0-9]/g, "");
151
+ return normalize(summary) === normalize(action);
152
+ }
153
+
154
+ /** The one place emitted JSON is formatted. Determinism comes from the order
155
+ * keys are BUILT (walk order, then explicit sorts), not from re-sorting here. */
156
+ function formatJson(value) {
157
+ return `${JSON.stringify(value, null, 2)}\n`;
158
+ }
159
+
160
+ // ---------------------------------------------------------------------- walk
161
+
162
+ const program = await buildProgram();
163
+ const verbs = collectVerbs(program);
164
+
165
+ if (!existsSync(policyPath)) {
166
+ throw new Error(`verb-policy-source.json not found at ${policyPath}`);
167
+ }
168
+ const policySource = JSON.parse(readFileSync(policyPath, "utf-8"));
169
+ const declaredNouns = policySource.nouns ?? {};
170
+ const policyRows = policySource.verbs ?? {};
171
+ const identityExemptions = policySource.identity_param_exemptions ?? {};
172
+
173
+ /** A noun is the Commander group unless a verb row re-homes it (used when one
174
+ * group's actions do not fit a single tool - see the ceiling below). */
175
+ const nounOf = (verb) => policyRows[verb.command]?.noun || verb.group;
176
+
177
+ // -------------------------------------------------- completeness, both ways
178
+
179
+ const commanderKeys = new Set(verbs.map((v) => v.command));
180
+
181
+ for (const verb of verbs) {
182
+ const row = policyRows[verb.command];
183
+ if (!row) {
184
+ fail(
185
+ `no policy row for \`${verb.command}\` - add one to scripts/verb-policy-source.json. ` +
186
+ `A verb kept out of the app needs surface != "both" and a reason.`,
187
+ );
188
+ continue;
189
+ }
190
+ if (!SURFACES.has(row.surface)) {
191
+ fail(`\`${verb.command}\`: surface "${row.surface}" is not one of ${[...SURFACES].join(", ")}`);
192
+ }
193
+ if (!MUTATION_CLASSES.has(row.mutation_class)) {
194
+ fail(`\`${verb.command}\`: mutation_class "${row.mutation_class}" is not one of ${[...MUTATION_CLASSES].join(", ")}`);
195
+ }
196
+ for (const field of ["is_read_only", "requires_approval", "silent_retry_exempt"]) {
197
+ if (typeof row[field] !== "boolean") {
198
+ fail(`\`${verb.command}\`: ${field} must be a boolean`);
199
+ }
200
+ }
201
+ if (row.surface !== "both" && !String(row.reason ?? "").trim()) {
202
+ fail(`\`${verb.command}\`: surface is "${row.surface}" but no reason is given - absence is never silent`);
203
+ }
204
+ if (row.is_read_only && row.mutation_class !== "none") {
205
+ fail(`\`${verb.command}\`: is_read_only with mutation_class "${row.mutation_class}"`);
206
+ }
207
+ if (row.is_read_only && row.requires_approval) {
208
+ fail(`\`${verb.command}\`: a read-only verb must never prompt for approval`);
209
+ }
210
+
211
+ // `app_params` - a parameter the app has and the CLI does not. The policy
212
+ // source already decides what a verb MEANS in the app (approval, mutation
213
+ // class, whether it is there at all); this is the same decision applied to a
214
+ // verb's inputs, for the case where the two surfaces genuinely differ in
215
+ // mechanics rather than in capability. Every entry carries a written reason,
216
+ // for the reason absence does: an asymmetry nobody wrote down is one nobody
217
+ // reviewed.
218
+ const appParams = row.app_params ?? {};
219
+ if (Object.keys(appParams).length && row.surface !== "both") {
220
+ fail(
221
+ `\`${verb.command}\`: app_params is set but surface is "${row.surface}" - ` +
222
+ `a parameter for a surface this verb is not on`,
223
+ );
224
+ }
225
+ for (const [name, spec] of Object.entries(appParams)) {
226
+ if (!APP_PARAM_TYPES.has(spec?.type)) {
227
+ fail(
228
+ `\`${verb.command}\` app_params.${name}: type "${spec?.type}" is not one of ` +
229
+ `${[...APP_PARAM_TYPES].join(", ")}`,
230
+ );
231
+ }
232
+ if (!String(spec?.reason ?? "").trim()) {
233
+ fail(
234
+ `\`${verb.command}\` app_params.${name}: no reason given - a parameter the CLI ` +
235
+ `does not have is never silent`,
236
+ );
237
+ }
238
+ if (name !== paramName(name)) {
239
+ fail(
240
+ `\`${verb.command}\` app_params.${name}: name is already the property name here, ` +
241
+ `so write it as the walk would derive it ("${paramName(name)}")`,
242
+ );
243
+ }
244
+ }
245
+ }
246
+
247
+ for (const key of Object.keys(policyRows)) {
248
+ if (!commanderKeys.has(key)) {
249
+ fail(`policy row \`${key}\` names a verb the CLI no longer has - remove it or restore the command`);
250
+ }
251
+ }
252
+
253
+ // Noun coverage: every in-app verb resolves to a declared noun, and every
254
+ // declared noun has at least one in-app verb.
255
+ const inAppNouns = new Set(
256
+ verbs.filter((v) => policyRows[v.command]?.surface === "both").map(nounOf),
257
+ );
258
+ for (const noun of inAppNouns) {
259
+ if (!(noun in declaredNouns)) {
260
+ fail(`noun "${noun}" has in-app verbs but is not declared under "nouns"`);
261
+ }
262
+ }
263
+ for (const noun of Object.keys(declaredNouns)) {
264
+ if (!inAppNouns.has(noun)) {
265
+ fail(`noun "${noun}" is declared but has no verb with surface "both"`);
266
+ }
267
+ }
268
+
269
+ // A cli_only verb has nothing to be re-homed into - a `noun` there is a
270
+ // leftover from a split that would silently do nothing.
271
+ for (const [command, row] of Object.entries(policyRows)) {
272
+ if (row.noun && row.surface !== "both") {
273
+ fail(`policy row \`${command}\`: "noun" is set but surface is "${row.surface}"`);
274
+ }
275
+ }
276
+
277
+ // Identity-parameter exemptions are a checked-in, reviewed fixture: an entry
278
+ // only ever appears as a deliberate edit carrying a written justification.
279
+ for (const [command, params] of Object.entries(identityExemptions)) {
280
+ if (!commanderKeys.has(command)) {
281
+ fail(`identity_param_exemptions names \`${command}\`, which the CLI no longer has`);
282
+ continue;
283
+ }
284
+ for (const [param, reason] of Object.entries(params)) {
285
+ if (!String(reason ?? "").trim()) {
286
+ fail(`identity_param_exemptions \`${command}\`.${param} has no justification`);
287
+ }
288
+ }
289
+ }
290
+
291
+ exitOnErrors();
292
+
293
+ // ------------------------------------------------------------ build the tools
294
+
295
+ /** A single verb becomes one action: its name is the CLI path below the noun,
296
+ * verbatim ("create metric", "update-html"). A noun that is itself invocable
297
+ * (`query`, `status`) names its one action after itself. */
298
+ function actionName(verb) {
299
+ return verb.action || verb.group;
300
+ }
301
+
302
+ const tools = [];
303
+ const exclusions = [];
304
+
305
+ for (const noun of Object.keys(declaredNouns)) {
306
+ const nounVerbs = verbs.filter(
307
+ (v) => nounOf(v) === noun && policyRows[v.command].surface === "both",
308
+ );
309
+
310
+ const actions = [];
311
+ // name -> { schema, descriptions: Map<description, Set<action>>, forms: Map<action, form> }
312
+ //
313
+ // `forms` is per-action, not per-parameter: one property name can be a
314
+ // positional on one action and a flag on another (`name` is `<name>` on
315
+ // `update metric` and `--name` on `create metric`). Collapsing that to a
316
+ // single record told the adapter the wrong CLI form for 12 pairs.
317
+ const params = new Map();
318
+
319
+ const addParam = (name, schema, description, action, command, form) => {
320
+ if (
321
+ TENANT_PARAM_DENYLIST.some((re) => re.test(name)) &&
322
+ !identityExemptions[command]?.[name]
323
+ ) {
324
+ fail(
325
+ `\`${noun} ${action}\`: parameter "${name}" looks like caller identity. ` +
326
+ `Tenancy is bound from the authenticated session, never from a model-supplied argument. ` +
327
+ `If it genuinely filters within the caller's own org, add a justified entry to ` +
328
+ `identity_param_exemptions in verb-policy-source.json.`,
329
+ );
330
+ return;
331
+ }
332
+ if (!String(description ?? "").trim()) {
333
+ fail(
334
+ `\`${noun} ${action}\`: parameter "${name}" has no description. ` +
335
+ `Write one at the source in cli/src/commands/ - the generator will not invent it.`,
336
+ );
337
+ return;
338
+ }
339
+ if (!params.has(name)) {
340
+ params.set(name, { schema, descriptions: new Map(), forms: new Map() });
341
+ }
342
+ const entry = params.get(name);
343
+ if (JSON.stringify(entry.schema) !== JSON.stringify(schema)) {
344
+ fail(
345
+ `\`${noun}\`: parameter "${name}" is ${JSON.stringify(entry.schema)} for one action and ` +
346
+ `${JSON.stringify(schema)} for \`${action}\`. Reconcile the flag types at the source.`,
347
+ );
348
+ }
349
+ if (!entry.descriptions.has(description)) entry.descriptions.set(description, new Set());
350
+ entry.descriptions.get(description).add(action);
351
+ entry.forms.set(action, form);
352
+ };
353
+
354
+ for (const verb of nounVerbs) {
355
+ const action = actionName(verb);
356
+ const required = [];
357
+ const optional = [];
358
+
359
+ for (const arg of verb.args) {
360
+ const name = paramName(arg.name);
361
+ addParam(name, argSchema(arg), arg.description, action, verb.command, {
362
+ cli_token: arg.variadic ? `${arg.name}...` : arg.name,
363
+ positional: true,
364
+ required: arg.required,
365
+ variadic: arg.variadic,
366
+ });
367
+ (arg.required ? required : optional).push(name);
368
+ }
369
+ for (const opt of verb.options) {
370
+ const name = paramName(opt.long || opt.flags);
371
+ addParam(name, optionSchema(opt), opt.description, action, verb.command, {
372
+ cli_flag: opt.long || opt.flags,
373
+ positional: false,
374
+ // `.requiredOption()`. The CLI rejects the command without it, so the
375
+ // model must be told it is required or it will omit it.
376
+ required: opt.mandatory,
377
+ variadic: opt.variadic || Array.isArray(opt.defaultValue),
378
+ negated: opt.negated,
379
+ default: opt.defaultValue ?? null,
380
+ });
381
+ (opt.mandatory ? required : optional).push(name);
382
+ }
383
+ // After the CLI's own, so the doc reads `R id,content` rather than
384
+ // interleaving an in-app parameter into the command's argument order.
385
+ for (const [name, spec] of Object.entries(policyRows[verb.command].app_params ?? {})) {
386
+ if (required.includes(name) || optional.includes(name)) {
387
+ fail(
388
+ `\`${noun} ${action}\`: app_params declares "${name}", which this verb already ` +
389
+ `has as a flag or argument. An in-app parameter may not shadow a real one.`,
390
+ );
391
+ continue;
392
+ }
393
+ // Through `addParam` like every other parameter, so an in-app one is held
394
+ // to the same rules: the tenant denylist still refuses caller identity, a
395
+ // missing description still fails, and a type that disagrees with another
396
+ // action's parameter of the same name is still a conflict.
397
+ addParam(name, { type: spec.type }, spec.description, action, verb.command, {
398
+ app_only: true,
399
+ required: Boolean(spec.required),
400
+ });
401
+ (spec.required ? required : optional).push(name);
402
+ }
403
+
404
+ actions.push({
405
+ name: action,
406
+ command: verb.command,
407
+ summary: firstSentence(verb.description),
408
+ required: [...new Set(required)],
409
+ optional: [...new Set(optional)].filter((p) => !required.includes(p)),
410
+ });
411
+ }
412
+
413
+ if (actions.length > MAX_ACTIONS_PER_NOUN) {
414
+ fail(
415
+ `noun "${noun}" has ${actions.length} actions, over the ceiling of ${MAX_ACTIONS_PER_NOUN}. ` +
416
+ `Split it by lifecycle (read vs authoring) - do not reach for oneOf.`,
417
+ );
418
+ }
419
+
420
+ // Merge each parameter's descriptions. Identical across actions -> one line.
421
+ // Genuinely different -> the action names that own each meaning, prefixed.
422
+ // Concatenation only; no sentence is rewritten.
423
+ const properties = {
424
+ action: {
425
+ type: "string",
426
+ enum: actions.map((a) => a.name),
427
+ description: `Which ${noun} operation to run. Every other parameter belongs to exactly one action - the tool description lists which.`,
428
+ },
429
+ };
430
+ const paramIndex = {};
431
+ for (const [name, entry] of [...params.entries()].sort(([a], [b]) => a.localeCompare(b))) {
432
+ const variants = [...entry.descriptions.entries()];
433
+ const description =
434
+ variants.length === 1
435
+ ? variants[0][0]
436
+ : variants
437
+ .map(([desc, acts]) => `${[...acts].sort().join("/")}: ${desc}`)
438
+ .join(" | ");
439
+ properties[name] = { ...entry.schema, description };
440
+ // Sorted so a re-ordered walk cannot change the emitted bytes.
441
+ paramIndex[name] = {
442
+ actions: [...entry.forms.keys()].sort(),
443
+ forms: Object.fromEntries([...entry.forms.entries()].sort(([a], [b]) => a.localeCompare(b))),
444
+ };
445
+ }
446
+
447
+ // The always-on tool doc. `R` / `o` keep it inside the budget while still
448
+ // naming every action's own parameter subset (per-action, not one merged bag).
449
+ const docLines = [
450
+ `${declaredNouns[noun]}. Set \`action\`, then only that action's parameters (R = required, o = optional).`,
451
+ ];
452
+ for (const a of actions) {
453
+ const parts = [];
454
+ if (a.required.length) parts.push(`R ${a.required.join(",")}`);
455
+ if (a.optional.length) parts.push(`o ${a.optional.join(",")}`);
456
+ const summary = restatesActionName(a.summary, a.name) ? "" : ` ${a.summary}`;
457
+ docLines.push(`${a.name}:${summary}${parts.length ? ` [${parts.join("; ")}]` : ""}`);
458
+ }
459
+ const description = docLines.join("\n");
460
+
461
+ if (description.length > MAX_TOOL_DOC_CHARS) {
462
+ fail(
463
+ `noun "${noun}" tool doc is ${description.length} chars, over the ${MAX_TOOL_DOC_CHARS} ceiling. ` +
464
+ `Shorten the verbs' first sentences at the source, or split the noun by lifecycle.`,
465
+ );
466
+ }
467
+
468
+ tools.push({
469
+ name: noun,
470
+ summary: declaredNouns[noun],
471
+ description,
472
+ actions,
473
+ parameters: { type: "object", properties, required: ["action"] },
474
+ param_index: paramIndex,
475
+ });
476
+ }
477
+
478
+ for (const verb of verbs) {
479
+ const row = policyRows[verb.command];
480
+ if (row.surface === "both") continue;
481
+ exclusions.push({
482
+ command: verb.command,
483
+ group: verb.group,
484
+ action: actionName(verb),
485
+ surface: row.surface,
486
+ reason: row.reason,
487
+ });
488
+ }
489
+
490
+ // ------------------------------------------------------------- emitted policy
491
+
492
+ const emittedPolicy = {};
493
+ for (const verb of verbs) {
494
+ const row = policyRows[verb.command];
495
+ if (row.surface !== "both") continue;
496
+ emittedPolicy[`${nounOf(verb)}:${actionName(verb)}`] = {
497
+ tool: nounOf(verb),
498
+ action: actionName(verb),
499
+ command: verb.command,
500
+ surface: row.surface,
501
+ is_read_only: row.is_read_only,
502
+ mutation_class: row.mutation_class,
503
+ requires_approval: row.requires_approval,
504
+ silent_retry_exempt: row.silent_retry_exempt,
505
+ };
506
+ }
507
+
508
+ // ------------------------------------------------------------- hygiene sweeps
509
+
510
+ const GENERATED_WARNING =
511
+ "GENERATED FILE - do not hand-edit. Regenerate with: npm --prefix cli run gen:commands";
512
+
513
+ const manifest = {
514
+ $warning: GENERATED_WARNING,
515
+ $source: "cli Commander tree + cli/scripts/verb-policy-source.json",
516
+ $param_naming:
517
+ "Property name = the CLI long flag or positional argument, lowercased with '-' and camelCase humps turned into '_'. param_index carries the CLI token back.",
518
+ $app_params:
519
+ "A param_index form marked \"app_only\" has no CLI counterpart: it is declared in verb-policy-source.json with a written reason, for a verb whose two surfaces differ in mechanics rather than in capability.",
520
+ tools,
521
+ exclusions,
522
+ };
523
+ const policyArtifact = {
524
+ $warning: GENERATED_WARNING,
525
+ $source: "cli/scripts/verb-policy-source.json, validated against the Commander tree",
526
+ $key: "\"<tool>:<action>\" - the same pair the runtime keys mutation class, approval and retry on",
527
+ verbs: emittedPolicy,
528
+ };
529
+
530
+ // PE-DENY-001 / PE-DENY-002: no internal reference number or credential reaches a
531
+ // prompt. Sweeps only what the model actually sees - tool docs, summaries and
532
+ // the parameter schema. Exclusion reasons and param_index metadata are read by
533
+ // humans in review and never sent to a model.
534
+ const modelFacing = JSON.stringify(
535
+ tools.map((t) => ({
536
+ summary: t.summary,
537
+ description: t.description,
538
+ parameters: t.parameters,
539
+ actions: t.actions.map((a) => a.summary),
540
+ })),
541
+ );
542
+ const internalHit = modelFacing.match(INTERNAL_REF);
543
+ if (internalHit) {
544
+ const at = modelFacing.indexOf(internalHit[0]);
545
+ fail(
546
+ `emitted tool text carries an internal reference number (PE-DENY-001): ` +
547
+ `...${modelFacing.slice(Math.max(0, at - 60), at + 60)}...`,
548
+ );
549
+ }
550
+ const credentialHit = modelFacing.match(CREDENTIAL_SHAPED);
551
+ if (credentialHit) {
552
+ fail(
553
+ `emitted tool text looks like it carries a credential (PE-DENY-002): ` +
554
+ `"${credentialHit[0].slice(0, 40)}...". Descriptions teach shapes, never values.`,
555
+ );
556
+ }
557
+
558
+ // Schema shape. Both of these are invariants over what the emitters may produce,
559
+ // not gates that today's emitters can trip: nothing here builds an object-typed
560
+ // property or an additionalProperties key. They exist so that adding a param
561
+ // type later cannot silently ship a declaration Gemini rejects outright
562
+ // (additionalProperties) or fails on with MALFORMED_FUNCTION_CALL (untyped
563
+ // object). The reachable version of this check lives in the CLI test suite.
564
+ for (const tool of tools) {
565
+ if (JSON.stringify(tool.parameters).includes("additionalProperties")) {
566
+ fail(`\`${tool.name}\`: emitted schema contains additionalProperties`);
567
+ }
568
+ for (const [name, schema] of Object.entries(tool.parameters.properties)) {
569
+ const inner = schema.type === "array" ? schema.items : schema;
570
+ if (inner?.type === "object") {
571
+ fail(`\`${tool.name}\`: parameter "${name}" is an untyped object`);
572
+ }
573
+ }
574
+ }
575
+
576
+ exitOnErrors();
577
+
578
+ // --------------------------------------------------------------------- write
579
+
580
+ const nextManifest = formatJson(manifest);
581
+ const nextPolicy = formatJson(policyArtifact);
582
+
583
+ if (toStdout) {
584
+ // No process.exit() after this write: on a pipe, exiting before stdout
585
+ // drains truncates the output, and the determinism check compares whole files.
586
+ process.stdout.write(nextManifest);
587
+ } else {
588
+ if (!existsSync(outDir)) {
589
+ throw new Error(
590
+ `generated package not found at ${outDir} - the platform_agent tools package must exist before artifacts are written`,
591
+ );
592
+ }
593
+
594
+ const current = {
595
+ manifest: existsSync(manifestPath) ? readFileSync(manifestPath, "utf-8").replace(/\r\n/g, "\n") : "",
596
+ policy: existsSync(policyOutPath) ? readFileSync(policyOutPath, "utf-8").replace(/\r\n/g, "\n") : "",
597
+ };
598
+
599
+ if (current.manifest === nextManifest && current.policy === nextPolicy) {
600
+ console.error("Tool manifest and verb policy are in sync.");
601
+ } else if (checkOnly) {
602
+ console.error(
603
+ "Generated tool manifest / verb policy is stale - run: npm --prefix cli run gen:commands",
604
+ );
605
+ process.exit(1);
606
+ } else {
607
+ writeFileSync(manifestPath, nextManifest, "utf-8");
608
+ writeFileSync(policyOutPath, nextPolicy, "utf-8");
609
+ console.error(
610
+ `Wrote ${tools.length} tools (${Object.keys(emittedPolicy).length} actions) and ${exclusions.length} exclusions to ${outDir}`,
611
+ );
612
+ }
613
+ }