@vegastack/vegafactory 0.19.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +417 -0
  3. package/dist/dashboard-artifact.json +21240 -0
  4. package/dist/index.js +22752 -0
  5. package/dist/run-wrapper.js +377 -0
  6. package/package.json +46 -0
  7. package/skill/dev-architect/SKILL.md +106 -0
  8. package/skill/dev-architect/agents/openai.yaml +4 -0
  9. package/skill/dev-architect/references/ai-agents.md +96 -0
  10. package/skill/dev-architect/references/conventions.md +113 -0
  11. package/skill/dev-architect/references/data.md +89 -0
  12. package/skill/dev-architect/references/infra.md +99 -0
  13. package/skill/dev-architect/references/mobile.md +75 -0
  14. package/skill/dev-architect/references/pinned-facts.md +133 -0
  15. package/skill/dev-architect/references/principles.md +117 -0
  16. package/skill/dev-architect/references/security.md +90 -0
  17. package/skill/dev-architect/references/stack.md +38 -0
  18. package/skill/dev-architect/references/web.md +102 -0
  19. package/skill/dev-architect/refresh/REFRESH.md +31 -0
  20. package/skill/dev-architect/refresh/sources.json +239 -0
  21. package/skill/dev-chronicle/SKILL.md +52 -0
  22. package/skill/dev-chronicle/agents/openai.yaml +4 -0
  23. package/skill/dev-chronicle/references/conventions.md +113 -0
  24. package/skill/dev-chronicle/references/styles.md +75 -0
  25. package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
  26. package/skill/dev-chronicle/refresh/sources.json +6 -0
  27. package/skill/dev-debug/SKILL.md +43 -0
  28. package/skill/dev-debug/agents/openai.yaml +4 -0
  29. package/skill/dev-debug/references/conventions.md +113 -0
  30. package/skill/dev-debug/references/loop-ladder.md +20 -0
  31. package/skill/dev-debug/refresh/REFRESH.md +3 -0
  32. package/skill/dev-debug/refresh/sources.json +6 -0
  33. package/skill/dev-implement/SKILL.md +81 -0
  34. package/skill/dev-implement/agents/openai.yaml +4 -0
  35. package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
  36. package/skill/dev-implement/references/ask-route.md +80 -0
  37. package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
  38. package/skill/dev-implement/references/conventions.md +113 -0
  39. package/skill/dev-implement/references/ledger-and-resume.md +52 -0
  40. package/skill/dev-implement/references/parallel-children.md +35 -0
  41. package/skill/dev-implement/references/worktrees.md +63 -0
  42. package/skill/dev-implement/refresh/REFRESH.md +3 -0
  43. package/skill/dev-implement/refresh/sources.json +6 -0
  44. package/skill/dev-implement/scripts/children.mjs +479 -0
  45. package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
  46. package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
  47. package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
  48. package/skill/dev-implement/scripts/learning.mjs +41 -0
  49. package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
  50. package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
  51. package/skill/dev-implement/scripts/preflight.mjs +153 -0
  52. package/skill/dev-implement/scripts/questions.mjs +381 -0
  53. package/skill/dev-implement/scripts/reclaim.mjs +116 -0
  54. package/skill/dev-implement/scripts/recovery.mjs +213 -0
  55. package/skill/dev-implement/scripts/worktree.mjs +991 -0
  56. package/skill/dev-intake/SKILL.md +80 -0
  57. package/skill/dev-intake/agents/openai.yaml +4 -0
  58. package/skill/dev-intake/references/ask-route.md +80 -0
  59. package/skill/dev-intake/references/brief-template.md +89 -0
  60. package/skill/dev-intake/references/conventions.md +113 -0
  61. package/skill/dev-intake/refresh/REFRESH.md +3 -0
  62. package/skill/dev-intake/refresh/sources.json +6 -0
  63. package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
  64. package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
  65. package/skill/dev-intake/scripts/questions.mjs +381 -0
  66. package/skill/dev-plan/SKILL.md +54 -0
  67. package/skill/dev-plan/agents/openai.yaml +4 -0
  68. package/skill/dev-plan/references/ask-route.md +80 -0
  69. package/skill/dev-plan/references/conventions.md +113 -0
  70. package/skill/dev-plan/references/plan-format.md +82 -0
  71. package/skill/dev-plan/refresh/REFRESH.md +3 -0
  72. package/skill/dev-plan/refresh/sources.json +6 -0
  73. package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
  74. package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
  75. package/skill/dev-plan/scripts/questions.mjs +381 -0
  76. package/skill/dev-review/SKILL.md +76 -0
  77. package/skill/dev-review/agents/openai.yaml +4 -0
  78. package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
  79. package/skill/dev-review/references/conventions.md +113 -0
  80. package/skill/dev-review/references/cross-agent.md +39 -0
  81. package/skill/dev-review/references/dispatch-prompts.md +121 -0
  82. package/skill/dev-review/references/security-axis.md +45 -0
  83. package/skill/dev-review/refresh/REFRESH.md +5 -0
  84. package/skill/dev-review/refresh/sources.json +6 -0
  85. package/skill/dev-setup/SKILL.md +113 -0
  86. package/skill/dev-setup/agents/openai.yaml +4 -0
  87. package/skill/dev-setup/assets/agents-section.md.template +24 -0
  88. package/skill/dev-setup/assets/dev-profile.md.template +102 -0
  89. package/skill/dev-setup/assets/factory-board.yml.template +162 -0
  90. package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
  91. package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
  92. package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
  93. package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
  94. package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
  95. package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
  96. package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
  97. package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
  98. package/skill/dev-setup/references/ask-route.md +80 -0
  99. package/skill/dev-setup/references/conventions.md +113 -0
  100. package/skill/dev-setup/references/github-app.md +286 -0
  101. package/skill/dev-setup/references/harness-facts.md +169 -0
  102. package/skill/dev-setup/references/stack-playbooks.md +124 -0
  103. package/skill/dev-setup/refresh/REFRESH.md +13 -0
  104. package/skill/dev-setup/refresh/sources.json +602 -0
  105. package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
  106. package/skill/dev-setup/scripts/questions.mjs +381 -0
  107. package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
  108. package/skill/dev-ship/SKILL.md +56 -0
  109. package/skill/dev-ship/agents/openai.yaml +4 -0
  110. package/skill/dev-ship/references/conventions.md +113 -0
  111. package/skill/dev-ship/references/runbook.md +72 -0
  112. package/skill/dev-ship/refresh/REFRESH.md +3 -0
  113. package/skill/dev-ship/refresh/sources.json +6 -0
  114. package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
  115. package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
  116. package/skill/dev-status/SKILL.md +56 -0
  117. package/skill/dev-status/agents/openai.yaml +4 -0
  118. package/skill/dev-status/references/conventions.md +113 -0
  119. package/skill/dev-status/refresh/REFRESH.md +3 -0
  120. package/skill/dev-status/refresh/sources.json +6 -0
  121. package/skill/dev-status/scripts/effective-policy.mjs +694 -0
  122. package/skill/dev-status/scripts/status.mjs +311 -0
  123. package/skill/skill-maintainer/SKILL.md +82 -0
  124. package/skill/skill-maintainer/agents/openai.yaml +4 -0
  125. package/skill/skill-maintainer/references/release-ops.md +61 -0
  126. package/skill/skill-maintainer/references/standards.md +137 -0
  127. package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
  128. package/skill/skill-maintainer/refresh/sources.json +103 -0
  129. package/skill/skill-scan/SKILL.md +59 -0
  130. package/skill/skill-scan/agents/openai.yaml +4 -0
  131. package/skill/skill-scan/refresh/REFRESH.md +18 -0
  132. package/skill/skill-scan/refresh/sources.json +133 -0
  133. package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
  134. package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
  135. package/skill/skillify/SKILL.md +108 -0
  136. package/skill/skillify/agents/openai.yaml +4 -0
  137. package/skill/skillify/assets/templates/README.md.template +26 -0
  138. package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
  139. package/skill/skillify/assets/templates/SKILL.md.template +27 -0
  140. package/skill/skillify/assets/templates/evals.json.template +12 -0
  141. package/skill/skillify/assets/templates/openai.yaml.template +4 -0
  142. package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
  143. package/skill/skillify/assets/templates/sources.json.template +6 -0
  144. package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
  145. package/skill/skillify/references/authoring.md +103 -0
  146. package/skill/skillify/references/eval-playbook.md +112 -0
  147. package/skill/skillify/refresh/REFRESH.md +23 -0
  148. package/skill/skillify/refresh/sources.json +6 -0
  149. package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
  150. package/skill/skillify/scripts/trigger-check.mjs +209 -0
  151. package/skill/vegafactory-setup/SKILL.md +77 -0
  152. package/skill/vegafactory-setup/agents/openai.yaml +4 -0
  153. package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
  154. package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
  155. package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
  156. package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
  157. package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
  158. package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
  159. package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
  160. package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
  161. package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
  162. package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
  163. package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
  164. package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
  165. package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
  166. package/skill/vegafactory-setup/references/control-room.md +149 -0
  167. package/skill/vegafactory-setup/references/conventions.md +113 -0
  168. package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
  169. package/skill/vegafactory-setup/refresh/sources.json +6 -0
  170. package/skill-integrity.json +252 -0
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // Shared plumbing for the workflow guard scripts: gh invocation, vsk:v1 comment
3
+ // marker parsing, and the block/warn result contract (facts block, heuristics
4
+ // warn — exit 0 pass · 1 warn-only · 2 block; unverifiable state fails closed).
5
+ import { execFileSync } from 'node:child_process';
6
+
7
+ export class GhUnavailable extends Error {
8
+ constructor(message, httpStatus = null) {
9
+ super(message);
10
+ this.httpStatus = httpStatus; // parsed from gh's "HTTP <code>" stderr, when present
11
+ }
12
+ }
13
+
14
+ // Run gh with explicit args (never a shell) and parse JSON output. Any failure
15
+ // to reach GitHub is a GhUnavailable — callers treat it as "cannot verify",
16
+ // which blocks (fail closed), never as a pass. stdin is fed only when `input`
17
+ // is a string (a request body that must stay off argv); otherwise it is
18
+ // closed. VSK_GH is a TEST SEAM only (points unit tests at a stub binary);
19
+ // guards are enforcement infrastructure, so never set it in real runs.
20
+ export function ghJson(args, { gh = process.env.VSK_GH || 'gh', input } = {}) {
21
+ let out;
22
+ try {
23
+ // env is passed explicitly: Bun's execFileSync does not see process.env
24
+ // mutations made after startup unless told to, and the test seam relies on them.
25
+ out = execFileSync(gh, args, {
26
+ encoding: 'utf8',
27
+ env: process.env,
28
+ input,
29
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
30
+ });
31
+ } catch (error) {
32
+ const stderr = error.stderr?.toString().trim() || '';
33
+ const status = /HTTP (\d{3})/.exec(stderr)?.[1];
34
+ throw new GhUnavailable(`gh ${args.join(' ')} failed: ${stderr || error.message}`, status ? Number(status) : null);
35
+ }
36
+ try {
37
+ return JSON.parse(out);
38
+ } catch {
39
+ throw new GhUnavailable(`gh ${args.join(' ')} returned unparseable JSON`);
40
+ }
41
+ }
42
+
43
+ // Parse a `<!-- vsk:v1 key=value ... -->` marker from a comment/body's first
44
+ // marker line. Returns { keys } or null when no marker exists — and per
45
+ // conventions, no marker means the artifact does not exist (no fallback).
46
+ export function parseMarker(body) {
47
+ const match = /<!--\s*vsk:v1\s+([^>]*?)\s*-->/.exec(body ?? '');
48
+ if (!match) return null;
49
+ const keys = {};
50
+ for (const pair of match[1].split(/\s+/)) {
51
+ const eq = pair.indexOf('=');
52
+ if (eq > 0) keys[pair.slice(0, eq)] = pair.slice(eq + 1);
53
+ }
54
+ return { keys };
55
+ }
56
+
57
+ // Find the last comment carrying a marker of the given type (last wins: for
58
+ // edited-in-place singletons there is one; for repeated types the newest is
59
+ // the operative one).
60
+ export function findMarkerComment(comments, type) {
61
+ let found = null;
62
+ for (const comment of comments ?? []) {
63
+ const marker = parseMarker(comment.body);
64
+ if (marker && marker.keys.type === type) found = { comment, keys: marker.keys };
65
+ }
66
+ return found;
67
+ }
68
+
69
+ // Render the guard result and compute the exit code. blocks/warns are arrays of
70
+ // human sentences; the caller passes process.argv-derived json flag.
71
+ export function renderResult(name, { blocks = [], warns = [], ...receipt }, { json = false } = {}) {
72
+ const ok = blocks.length === 0;
73
+ const exitCode = blocks.length > 0 ? 2 : warns.length > 0 ? 1 : 0;
74
+ let text;
75
+ if (json) {
76
+ text = JSON.stringify({ ...receipt, guard: name, ok, blocks, warns }, null, 2);
77
+ } else {
78
+ const lines = [`${name}: ${ok ? (warns.length ? 'pass with warnings' : 'pass') : 'BLOCKED'}`];
79
+ for (const b of blocks) lines.push(` block: ${b}`);
80
+ for (const w of warns) lines.push(` warn: ${w}`);
81
+ text = lines.join('\n');
82
+ }
83
+ return { exitCode, text };
84
+ }
85
+
86
+ // Minimal flag parser shared by the guards: --key value and boolean --flags.
87
+ export function parseFlags(argv, booleans = ['json']) {
88
+ const flags = {};
89
+ for (let i = 0; i < argv.length; i += 1) {
90
+ const arg = argv[i];
91
+ if (!arg.startsWith('--')) continue;
92
+ const key = arg.slice(2);
93
+ if (booleans.includes(key)) {
94
+ flags[key] = true;
95
+ } else {
96
+ flags[key] = argv[i + 1];
97
+ i += 1;
98
+ }
99
+ }
100
+ return flags;
101
+ }
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ // dev-implement preflight guard: the deterministic facts that must hold before
3
+ // an agent may claim an issue. Facts block (exit 2 with reasons); nothing here
4
+ // warns — judgment checks stay in the skill prose.
5
+ //
6
+ // Exit codes: 0 pass · 1 pass-with-warnings · 2 blocked (reasons printed).
7
+ // Usage: node preflight.mjs --issue <n> [--repo owner/name] [--me <login>] [--dev-md <path>] --json
8
+ // --stage plan requires brief intent; implementation requires brief+plan.
9
+ // --consolidated-request <json> reads an exact pinned parent selection.
10
+ import { spawnSync } from 'node:child_process';
11
+ import { createHash } from 'node:crypto';
12
+ import { preparationTaskContracts } from './recovery.mjs';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { GhUnavailable, ghJson, parseFlags, renderResult } from './lib/gh.mjs';
18
+ import { evaluateApprovals, readApprovalSources, gatherConsolidatedApproval, parseStrictJson, readPages, scopeDigest } from './lib/approval.mjs';
19
+ const policyUrl = new URL('./effective-policy.mjs', import.meta.url);
20
+ const { resolveState, readWorkflowLabels, loadConfiguredPolicy, DEFAULT_LABELS } = await import(existsSync(policyUrl) ? policyUrl.href : new URL('../../dev-setup/scripts/effective-policy.mjs', import.meta.url).href);
21
+
22
+ export function evaluatePreflight({ issue, comments, devMd, me, expect = 'ready', stage = 'implement', sourceComments = [], configuredPolicy = null }) {
23
+ const blocks = [];
24
+ const warns = [];
25
+ const labels = (issue.labels ?? []).map((l) => l.name);
26
+
27
+ if (issue.state && issue.state !== 'open') blocks.push(`issue is ${issue.state} — only open issues are workable`);
28
+ let labelMap;
29
+ try { labelMap = configuredPolicy?.policy.values['workflow-labels'] ?? readWorkflowLabels(devMd ?? ''); }
30
+ catch (error) { blocks.push(error.message); }
31
+ if (configuredPolicy) blocks.push(...configuredPolicy.blocks);
32
+ const resolvedState = resolveState(labels, labelMap);
33
+ blocks.push(...resolvedState.blocks);
34
+ const expectedState = Object.entries(DEFAULT_LABELS).find(([key, name]) => key === expect || name === expect)?.[0];
35
+ if (!expectedState || resolvedState.state !== expectedState) blocks.push('issue state label is ' + (resolvedState.state ?? 'unresolved') + ', expected ' + expect);
36
+
37
+
38
+ const scope = ['research', 'quick-build', 'full-plan'].filter((s) => labels.includes(s));
39
+ if (scope.length !== 1) blocks.push(`issue needs exactly one scope label (research | quick-build | full-plan), found: ${scope.join(', ') || 'none'}`);
40
+ const operators = (/^operators:\s*([^#\n]+)/m.exec(devMd ?? '')?.[1] ?? '').split(',').map((name) => name.trim()).filter(Boolean);
41
+ const approval = evaluateApprovals({ repo: issue.repo, issue: issue.number, brief: issue, comments, operators,
42
+ sourceComments, requiredScope: stage === 'plan' || scope[0] === 'research' ? 'brief' : 'brief+plan' });
43
+ blocks.push(...approval.blocks);
44
+ if (scope[0] === 'research' && stage !== 'plan') blocks.push('research execution requires consolidated protocol, candidate and shared allowance admission');
45
+
46
+ // Resolved entries and explicitly later live requirements are retained as
47
+ // context. Unclassified assumptions still require reconciliation.
48
+ const assumptions = /^##\s+Assumptions\b[^\n]*\n([\s\S]*?)(?=^##\s|$(?![\s\S]))/m.exec(issue.body ?? '')?.[1];
49
+ if (assumptions?.split('\n').some(line => /^\s*-\s+/.test(line) && !/^\s*-\s+\[x\]/i.test(line) && !/\b(?:resolved|verified):/i.test(line))) {
50
+ blocks.push('unresolved execution-scope Assumptions require reconciliation before full execution');
51
+ }
52
+
53
+ const openBlockers = issue.blockedBy ?? [];
54
+ if (openBlockers.length > 0) blocks.push(`open blockers: ${openBlockers.map((b) => `#${b.number}`).join(', ')}`);
55
+
56
+ // Who the assignee is depends on the state (conventions' Labels table): `ready`
57
+ // is unassigned, `working` is the claimant, `for-operator` is the operator. So a
58
+ // foreign assignee blocks on the first two — someone else's claim — and is the
59
+ // expected shape on a corrections run, where the hand-back moved the assignee to
60
+ // the operator and the runner's gh login need not be that person. On `ready`,
61
+ // an assignee that is you is a fact worth naming, not a stop — a human may have
62
+ // picked it up by hand, and a block would strand a claimable issue.
63
+ const assigned = (issue.assignees ?? []).map((a) => a.login);
64
+ const others = assigned.filter((l) => l !== me);
65
+ const operatorPlanning = stage === 'plan' && expect === 'needs-plan' && others.every((login) => operators.includes(login));
66
+ if (others.length > 0 && expect !== 'for-operator' && !operatorPlanning) {
67
+ const why = expect === 'working'
68
+ ? 'a working issue belongs to its claimant'
69
+ : "a ready issue is unassigned by convention, so another assignee is someone else's claim";
70
+ blocks.push(`already assigned to ${others.join(', ')} — ${why}`);
71
+ }
72
+ if (expect === 'ready' && assigned.length > 0) {
73
+ warns.push(`a ready issue is unassigned by convention; this one is assigned to ${assigned.join(', ')} — confirm nobody else is mid-claim before taking it`);
74
+ }
75
+
76
+ const repoLine = /^repo:\s*(\S+)/m.exec(devMd ?? '');
77
+ if (!repoLine) {
78
+ warns.push('dev.md has no repo: line — the issue-repo match could not be verified');
79
+ } else if (issue.repo && repoLine[1] !== issue.repo) {
80
+ blocks.push(`issue repo ${issue.repo} does not match dev.md repo ${repoLine[1]}`);
81
+ }
82
+
83
+ return { state: resolvedState.state, labelMap, blocks, warns, bindings: approval.bindings, approvalIds: approval.approvalIds, approvalBindings: approval.approvalBindings };
84
+ }
85
+
86
+ // Both CLI and dispatcher use this owner reader and evaluator. The injected
87
+ // reader is transport only, never an approval verdict or policy override.
88
+ export async function gatherAndEvaluate(flags, { readJson = async (args) => ghJson(args), devMd: suppliedDevMd, configuredPolicy, preparationAdapter } = {}) {
89
+ const pages = (args) => readPages(readJson, args);
90
+ const repo = flags.repo || (await readJson(['repo', 'view', '--json', 'nameWithOwner'])).nameWithOwner;
91
+ if (flags['consolidated-request']) {
92
+ const request = parseStrictJson(readFileSync(flags['consolidated-request'], 'utf8'));
93
+ if (Object.keys(request).some((key) => !['parentRepo', 'parentIssue', 'approvalBinding', 'requested', 'admissionEvidence'].includes(key))) throw new Error('unknown consolidated request field');
94
+ const devMd = suppliedDevMd ?? readFileSync(flags['dev-md'] || '.vegastack/dev.md', 'utf8');
95
+ const policyRepo = /^repo:\s*(\S+)/m.exec(devMd)?.[1];
96
+ if (policyRepo !== repo || request.parentRepo !== repo || request.requested?.repo !== repo) throw new Error('consolidated request and current policy repository differ');
97
+ const operators = (/^operators:\s*([^#\n]+)/m.exec(devMd)?.[1] ?? '').split(',').map((name) => name.trim()).filter(Boolean);
98
+ return { ...(await gatherConsolidatedApproval({ ...request, operators, readJson, preparationAdapter: preparationAdapter ?? packagedPreparationAdapter({ readJson }) })), warns: [] };
99
+ }
100
+ const issueNumber = flags.issue;
101
+ const raw = await readJson(['api', 'repos/' + repo + '/issues/' + issueNumber]);
102
+ const comments = await pages(['api', 'repos/' + repo + '/issues/' + issueNumber + '/comments']);
103
+ // Missing dependency data cannot establish absence; do not treat a 404 as approval.
104
+ const blockedBy = (await pages(['api', 'repos/' + repo + '/issues/' + issueNumber + '/dependencies/blocked_by']))
105
+ .filter((entry) => entry.state !== 'closed');
106
+ const devMd = suppliedDevMd ?? readFileSync(flags['dev-md'] || '.vegastack/dev.md', 'utf8');
107
+ const sourceComments = await readApprovalSources(comments, readJson);
108
+ const me = flags.me || (await readJson(['api', 'user'])).login;
109
+ return evaluatePreflight({ issue: { ...raw, repo, blockedBy }, comments, devMd, me, sourceComments, configuredPolicy: configuredPolicy ?? loadConfiguredPolicy({ home: homedir(), repo, devMd }),
110
+ expect: flags.expect || 'ready', stage: flags.stage || 'implement' });
111
+ }
112
+
113
+ export function packagedPreparationAdapter({readJson, inspectAcceptedIntegration} = {}) {
114
+ return {
115
+ async readTaskPrerequisites(request) {
116
+ const {parent,plan,taskIds,approvalBinding}=request;
117
+ const source=await readJson(['api','repos/'+parent.repo+'/issues/comments/'+approvalBinding.commentId]);
118
+ if(source.id!==approvalBinding.commentId || createHash('sha256').update(source.body).digest('hex')!==approvalBinding.bodySha256 || source.issue_url!=='https://api.github.com/repos/'+parent.repo+'/issues/'+parent.issue) throw Error('canonical preparation source changed');
119
+ const comments=await readPages(readJson,['api','repos/'+plan.repo+'/issues/'+plan.issue+'/comments']);
120
+ const matching=comments.filter(row=>row.node_id===plan.artifactId);
121
+ if(matching.length!==1||scopeDigest(matching[0].body,'plan')!==plan.digest)throw Error('canonical preparation plan changed');
122
+ // canonicalScope is intentionally opaque to consumers. Its parser exposes
123
+ // exact files through the validated source task declarations below.
124
+ const selectedFiles=(body,ids)=>{
125
+ const starts=[...body.matchAll(/^-\s*\[[ x]\].*<!--\s*task-id:([1-9]\d*-T[1-9]\d*)\s*-->.*$/gim)];
126
+ return starts.flatMap((row,index)=>ids.includes(row[1])?[...body.slice(row.index,starts[index+1]?.index??body.length).split('\n').find(line=>/^\s*- Files\s/.test(line)).matchAll(/`([^`]+)`/g)].map(match=>match[1]):[]);
127
+ };
128
+ return {parent,plan,tasks:preparationTaskContracts(matching[0].body,taskIds,selectedFiles),approvalBinding};
129
+ },
130
+ async inspectAcceptedIntegration(contract) {
131
+ if(inspectAcceptedIntegration)return inspectAcceptedIntegration(contract);
132
+ const cli=fileURLToPath(new URL('../../../dist/index.js',import.meta.url));
133
+ if(!existsSync(cli))throw Error('installed preparation integration owner unavailable');
134
+ const result=spawnSync(process.execPath,[cli,'children','inspect-preparation','--json'],{input:JSON.stringify(contract),encoding:'utf8',timeout:30000,maxBuffer:1024*1024});
135
+ if(result.status!==0||result.error||result.signal)throw Error('accepted preparation integration unavailable');
136
+ return parseStrictJson(result.stdout);
137
+ },
138
+ };
139
+ }
140
+
141
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
142
+ if (invokedDirectly) {
143
+ const flags = parseFlags(process.argv.slice(2));
144
+ let outcome;
145
+ try {
146
+ outcome = await gatherAndEvaluate(flags);
147
+ } catch (error) {
148
+ outcome = { blocks: [error instanceof GhUnavailable ? `cannot verify: ${error.message}` : `preflight error: ${error.message}`], warns: [] };
149
+ }
150
+ const { exitCode, text } = renderResult('preflight', outcome, { json: Boolean(flags.json) });
151
+ console.log(text);
152
+ process.exit(exitCode);
153
+ }
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ // The ask round: render questions into a `questions` comment, parse whatever a
3
+ // teammate replies, re-ask only what is still open, and decide which route the
4
+ // round takes at all. Text in, text out — this script makes no `gh` call, no
5
+ // network call, and writes no file, so it needs no credentials and no dry run.
6
+ //
7
+ // The route, the comment shape and the reply grammar are documented once in
8
+ // dev-setup's references/ask-route.md; this file is the method behind them.
9
+ //
10
+ // node questions.mjs render --spec <file.json> [--rev <n>] [--json]
11
+ // node questions.mjs parse --comment <file> (--spec <file.json> | --round <file.md>) [--json]
12
+ // node questions.mjs re-ask (--spec <file.json> | --round <file.md>) --comment <file> --rev <n> [--json]
13
+ // node questions.mjs route --tool <name|none> --asker <login> --operator <login> [--json]
14
+ //
15
+ // Exit codes: 0 pass · 1 answers still open or malformed (nothing to re-ask, for
16
+ // `re-ask`) · 2 refusal or usage error.
17
+ import { lstatSync, readFileSync } from 'node:fs'
18
+ import { resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ // The comment marker, written once: the renderer emits it and the round reader
22
+ // finds it by the same string, so the two can never drift apart.
23
+ const MARKER_OPEN = '<!-- vsk:v1 type=questions rev='
24
+ const MARKER_CLOSE = ' -->'
25
+
26
+ // Options run a through h: eight is already more than a person will weigh, and a
27
+ // bounded set is what makes the reply grammar a single character.
28
+ const LETTERS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
29
+
30
+ // Every path this script reads goes through here. A symlink is refused rather
31
+ // than followed: the caller hands it a path from an issue comment's neighbourhood,
32
+ // and a guard that resolves links reads whatever the link points at.
33
+ export function readTextFile(path) {
34
+ const stats = lstatSync(path)
35
+ if (stats.isSymbolicLink()) throw new Error('refusing to read a symlink: ' + path)
36
+ if (!stats.isFile()) throw new Error('not a regular file: ' + path)
37
+ return readFileSync(path, 'utf8')
38
+ }
39
+
40
+ // The display number of a question: its `n` when the round is a re-ask carrying
41
+ // the original numbering, otherwise its position.
42
+ function numberOf(question, index) {
43
+ return typeof question.n === 'number' ? question.n : index + 1
44
+ }
45
+
46
+ // Text that would break out of the round it is rendered into. A round can be read
47
+ // back out of a comment anyone may have written, so this is checked on the way in
48
+ // as well as on the way out — rendered text is never allowed to carry a marker or
49
+ // close the block it sits in.
50
+ const UNSAFE_TEXT = ['<!--', '-->', '<questions>', '</questions>']
51
+
52
+ function checkText(label, value) {
53
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(label + ' is empty')
54
+ for (const fragment of UNSAFE_TEXT) {
55
+ if (value.includes(fragment)) throw new Error(label + ' carries markup that would break the round: ' + fragment)
56
+ }
57
+ }
58
+
59
+ // A spec that cannot render is a bug in the caller, not a question for the user,
60
+ // so every check throws with the question's own number in the message.
61
+ export function checkSpec(spec) {
62
+ const questions = spec && Array.isArray(spec.questions) ? spec.questions : null
63
+ if (!questions) throw new Error('spec has no questions array')
64
+ if (questions.length === 0) throw new Error('a round needs at least one question')
65
+ questions.forEach((question, index) => {
66
+ const n = numberOf(question, index)
67
+ const label = 'question ' + n
68
+ checkText(label, question.text)
69
+ const options = Array.isArray(question.options) ? question.options : []
70
+ if (options.length < 2) throw new Error(label + ' has fewer than two options')
71
+ const seen = new Set()
72
+ for (const option of options) {
73
+ checkText(label, option.text)
74
+ if (!LETTERS.includes(option.letter)) {
75
+ throw new Error(label + ' uses the letter "' + option.letter + '" — options run a through h')
76
+ }
77
+ if (seen.has(option.letter)) throw new Error(label + ' repeats the letter "' + option.letter + '"')
78
+ seen.add(option.letter)
79
+ if (option.recommended) {
80
+ if (typeof option.reason !== 'string' || option.reason.trim() === '') {
81
+ throw new Error(label + ' recommends "' + option.letter + '" without a reason')
82
+ }
83
+ checkText(label, option.reason)
84
+ }
85
+ }
86
+ const recommended = options.filter((option) => option.recommended)
87
+ if (recommended.length === 0) throw new Error(label + ' has no recommended option')
88
+ if (recommended.length > 1) throw new Error(label + ' has ' + recommended.length + ' recommended options')
89
+ })
90
+ return questions
91
+ }
92
+
93
+ // The rendered comment. The `<questions>` wrapper is what lets the parser and the
94
+ // model both find the round inside a comment that may carry prose around it.
95
+ export function renderQuestions(spec, options) {
96
+ const rev = options && options.rev ? Number(options.rev) : 1
97
+ if (!Number.isInteger(rev) || rev < 1) throw new Error('rev must be a positive integer')
98
+ const questions = checkSpec(spec)
99
+ const lines = []
100
+ lines.push(MARKER_OPEN + rev + MARKER_CLOSE)
101
+ lines.push('## Questions (v' + rev + ')')
102
+ lines.push('')
103
+ lines.push('<questions>')
104
+ questions.forEach((question, index) => {
105
+ if (index > 0) lines.push('')
106
+ lines.push('**Q' + numberOf(question, index) + '.** ' + question.text)
107
+ for (const option of question.options) {
108
+ let line = '- ' + option.letter + ') ' + option.text
109
+ if (option.recommended) line += ' (recommended — ' + option.reason + ')'
110
+ lines.push(line)
111
+ }
112
+ })
113
+ // The example in the reply line is the round's own first question and its first
114
+ // letter, so a re-ask that starts at Q3 tells the reader to write `3: a`.
115
+ const first = questions[0]
116
+ const example = numberOf(first, 0) + ': ' + first.options[0].letter
117
+ lines.push('')
118
+ lines.push('Reply with `' + example + '` per question, or `all recommended`.')
119
+ lines.push('</questions>')
120
+ lines.push('')
121
+ return lines.join('\n')
122
+ }
123
+
124
+ // An answer line: an optional bullet, the question number, `:` or `.`, the option
125
+ // word, and optional trailing prose — only after a dash. The dash is what makes
126
+ // `1: a — too slow` a note and `1: a is too slow, go with b` a sentence: without
127
+ // it the letter is not an answer, and a numbered line that is not an answer is
128
+ // reported rather than read as the letter it happens to open with. Lines with no
129
+ // number are prose — a reply is a comment written by a person, not a form.
130
+ const ANSWER_LINE = /^\s*(?:[-*]\s+)?(\d+)\s*[:.]\s*([A-Za-z]+)[.!]?\s*(?:(?:—|–|--|-)\s*(.*))?$/
131
+ const NUMBERED_LINE = /^\s*(?:[-*]\s+)?(\d+)\s*[:.]\s*(\S.*)$/
132
+ const ALL_RECOMMENDED = /^\s*(?:[-*]\s+)?all\s+recommended\s*[.!]?\s*$/i
133
+
134
+ // Reads a reply comment against the round it answers. Never throws on the reply
135
+ // itself: an unusable reply comes back as `malformed`, which is what a re-ask is
136
+ // built from. It throws only on a spec that could not have been rendered.
137
+ export function parseAnswers(replyText, spec) {
138
+ const questions = checkSpec(spec)
139
+ const numbers = questions.map((question, index) => numberOf(question, index))
140
+ const answers = {}
141
+ const malformed = []
142
+ let sawAnswerLine = false
143
+ let allRecommended = false
144
+
145
+ for (const line of String(replyText).split('\n')) {
146
+ if (ALL_RECOMMENDED.test(line)) {
147
+ allRecommended = true
148
+ sawAnswerLine = true
149
+ continue
150
+ }
151
+ const match = ANSWER_LINE.exec(line)
152
+ if (!match) {
153
+ const numbered = NUMBERED_LINE.exec(line)
154
+ if (numbered) {
155
+ sawAnswerLine = true
156
+ malformed.push('question ' + Number(numbered[1]) + ': "' + numbered[2].trim() + '" is not a bare answer — write "' + Number(numbered[1]) + ': a" or "' + Number(numbered[1]) + ': a — <note>"')
157
+ }
158
+ continue
159
+ }
160
+ sawAnswerLine = true
161
+ const n = Number(match[1])
162
+ const token = match[2].toLowerCase()
163
+ const trailing = (match[3] || '').trim()
164
+ const position = numbers.indexOf(n)
165
+ if (position === -1) {
166
+ malformed.push('question ' + n + ' is not in this round')
167
+ continue
168
+ }
169
+ if (Object.prototype.hasOwnProperty.call(answers, n)) {
170
+ malformed.push('question ' + n + ' answered twice — the first answer stands')
171
+ continue
172
+ }
173
+ const known = questions[position].options.some((option) => option.letter === token)
174
+ if (token !== 'other' && !known) {
175
+ malformed.push('question ' + n + ': "' + token + '" is not an option')
176
+ continue
177
+ }
178
+ answers[n] = { option: token, text: trailing === '' ? null : trailing }
179
+ }
180
+
181
+ if (allRecommended) {
182
+ questions.forEach((question, index) => {
183
+ const n = numbers[index]
184
+ if (Object.prototype.hasOwnProperty.call(answers, n)) return
185
+ const recommended = question.options.find((option) => option.recommended)
186
+ answers[n] = { option: recommended.letter, text: null }
187
+ })
188
+ }
189
+
190
+ const missing = numbers.filter((n) => !Object.prototype.hasOwnProperty.call(answers, n))
191
+ if (!sawAnswerLine) {
192
+ malformed.push('no answer line found — expected "<number>: <letter>" per question, or "all recommended"')
193
+ }
194
+ return { answers, missing, malformed }
195
+ }
196
+
197
+ // The next round: the questions a reply left open, each carrying the number it
198
+ // had in the original round, so a re-ask re-uses `**Q3.**` rather than renumbering
199
+ // and inviting an answer to a question nobody asked twice.
200
+ export function openQuestions(spec, parsed) {
201
+ const questions = checkSpec(spec)
202
+ const numbers = questions.map((question, index) => numberOf(question, index))
203
+ const open = new Set(parsed.missing)
204
+ // A malformed entry names its question; reopen it unless an earlier line in the
205
+ // same reply already answered it (a repeat is malformed, but the first stands).
206
+ for (const problem of parsed.malformed) {
207
+ const named = /^question (\d+)\b/.exec(problem)
208
+ if (!named) continue
209
+ const n = Number(named[1])
210
+ if (!numbers.includes(n)) continue
211
+ if (Object.prototype.hasOwnProperty.call(parsed.answers, n)) continue
212
+ open.add(n)
213
+ }
214
+ const kept = questions
215
+ .map((question, index) => ({ ...question, n: numbers[index] }))
216
+ .filter((question) => open.has(question.n))
217
+ return { ...spec, questions: kept }
218
+ }
219
+
220
+ // The inverse of `renderQuestions`: a posted `questions` comment read back into
221
+ // the spec it was rendered from. This is what makes the wrapper earn its place —
222
+ // the run that reads the reply is a different, later session with no round.json
223
+ // on disk, so the comment itself has to be the record of what was asked.
224
+ const ROUND_QUESTION = /^\*\*Q(\d+)\.\*\*\s+(.+)$/
225
+ const ROUND_OPTION = /^-\s+([a-z])\)\s+(.+)$/
226
+ const ROUND_RECOMMENDED = /^(.*?)\s+\(recommended — (.+)\)$/
227
+
228
+ export function parseRound(commentText) {
229
+ const text = String(commentText)
230
+ const opened = text.indexOf('<questions>')
231
+ const closed = text.indexOf('</questions>', opened + 1)
232
+ if (opened === -1 || closed === -1) throw new Error('no <questions> block in this comment')
233
+ const markerAt = text.indexOf(MARKER_OPEN)
234
+ const revDigits = markerAt === -1 ? null : /^(\d+)/.exec(text.slice(markerAt + MARKER_OPEN.length))
235
+ const rev = revDigits ? Number(revDigits[1]) : 1
236
+ const questions = []
237
+ for (const line of text.slice(opened + '<questions>'.length, closed).split('\n')) {
238
+ const asked = ROUND_QUESTION.exec(line)
239
+ if (asked) {
240
+ questions.push({ n: Number(asked[1]), text: asked[2].trim(), options: [] })
241
+ continue
242
+ }
243
+ const offered = ROUND_OPTION.exec(line)
244
+ if (!offered) continue
245
+ if (questions.length === 0) throw new Error('an option appears before any question')
246
+ const recommended = ROUND_RECOMMENDED.exec(offered[2].trim())
247
+ if (recommended) {
248
+ questions[questions.length - 1].options.push({
249
+ letter: offered[1],
250
+ text: recommended[1].trim(),
251
+ recommended: true,
252
+ reason: recommended[2].trim(),
253
+ })
254
+ } else {
255
+ questions[questions.length - 1].options.push({ letter: offered[1], text: offered[2].trim() })
256
+ }
257
+ }
258
+ const round = { rev, questions }
259
+ checkSpec(round)
260
+ return round
261
+ }
262
+
263
+ // Which surface the round goes to, in one precedence a caller cannot reorder:
264
+ // the environment the dispatcher sets, then whether this harness and run have a
265
+ // question tool at all, then whether the person being asked is the person who
266
+ // owns the issue. Anything unresolved routes to the issue, because a round in a
267
+ // comment is always readable and a round put to the wrong person is not.
268
+ export function decideRoute(input) {
269
+ const env = (input && input.env) || {}
270
+ const forced = typeof env.VSK_ASK_ROUTE === 'string' ? env.VSK_ASK_ROUTE.trim() : ''
271
+ if (forced !== '') {
272
+ if (forced !== 'issue' && forced !== 'tool') throw new Error('VSK_ASK_ROUTE must be issue or tool, not "' + forced + '"')
273
+ return { route: forced, reason: 'VSK_ASK_ROUTE=' + forced }
274
+ }
275
+ const tool = input && typeof input.tool === 'string' ? input.tool.trim() : ''
276
+ if (tool === '' || tool === 'none') return { route: 'issue', reason: 'no question tool in this harness or run' }
277
+ const asker = input && typeof input.asker === 'string' ? input.asker.trim() : ''
278
+ const operator = input && typeof input.operator === 'string' ? input.operator.trim() : ''
279
+ if (asker === '' || operator === '') return { route: 'issue', reason: 'the asker or the issue operator is unknown' }
280
+ if (asker !== operator) return { route: 'issue', reason: 'asker ' + asker + ' is not the issue operator ' + operator }
281
+ return { route: 'tool', reason: 'tool ' + tool + ' is available and the asker is the issue operator' }
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // CLI
286
+ // ---------------------------------------------------------------------------
287
+
288
+ const USAGE = [
289
+ 'usage:',
290
+ ' questions.mjs render --spec <file.json> [--rev <n>] [--json]',
291
+ ' questions.mjs parse --comment <file> (--spec <file.json> | --round <file.md>) [--json]',
292
+ ' questions.mjs re-ask (--spec <file.json> | --round <file.md>) --comment <file> --rev <n> [--json]',
293
+ ' questions.mjs route --tool <name|none> --asker <login> --operator <login> [--json]',
294
+ ].join('\n')
295
+
296
+ // A caller supplies the round it is answering either as the JSON it rendered from
297
+ // (--spec) or as the posted comment itself (--round), which is what a later,
298
+ // fresh session actually has.
299
+ function readSpec(path) {
300
+ return JSON.parse(readTextFile(path))
301
+ }
302
+
303
+ function loadRound(specPath, roundPath) {
304
+ if (specPath && roundPath) throw new Error('pass --spec or --round, not both')
305
+ if (specPath) return readSpec(specPath)
306
+ return parseRound(readTextFile(roundPath))
307
+ }
308
+
309
+ function report(json, payload, exitCode, humanLines) {
310
+ if (json) console.log(JSON.stringify(payload, null, 2))
311
+ else for (const line of humanLines) console.log(line)
312
+ process.exit(exitCode)
313
+ }
314
+
315
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
316
+ if (invokedDirectly) {
317
+ const argv = process.argv.slice(2)
318
+ const json = argv.includes('--json')
319
+ const get = (flag) => { const i = argv.indexOf(flag); return i === -1 ? undefined : argv[i + 1] }
320
+ const command = argv[0]
321
+ const refuse = (reason) => report(json, { command: command || null, ok: false, error: reason }, 2, ['questions: ' + reason])
322
+
323
+ if (command === 'render') {
324
+ const specPath = get('--spec')
325
+ if (!specPath) refuse(USAGE)
326
+ try {
327
+ const rev = get('--rev') ? Number(get('--rev')) : 1
328
+ const markdown = renderQuestions(readSpec(specPath), { rev })
329
+ report(json, { command: 'render', ok: true, rev, markdown }, 0, [markdown])
330
+ } catch (error) {
331
+ refuse(error.message)
332
+ }
333
+ } else if (command === 'parse') {
334
+ const specPath = get('--spec')
335
+ const roundPath = get('--round')
336
+ const commentPath = get('--comment')
337
+ if ((!specPath && !roundPath) || !commentPath) refuse(USAGE)
338
+ try {
339
+ const parsed = parseAnswers(readTextFile(commentPath), loadRound(specPath, roundPath))
340
+ const open = parsed.missing.length > 0 || parsed.malformed.length > 0
341
+ const human = ['questions: ' + Object.keys(parsed.answers).length + ' answered']
342
+ if (parsed.missing.length) human.push(' open: ' + parsed.missing.join(', '))
343
+ for (const problem of parsed.malformed) human.push(' malformed: ' + problem)
344
+ report(json, { command: 'parse', ok: !open, ...parsed }, open ? 1 : 0, human)
345
+ } catch (error) {
346
+ refuse(error.message)
347
+ }
348
+ } else if (command === 're-ask') {
349
+ const specPath = get('--spec')
350
+ const roundPath = get('--round')
351
+ const commentPath = get('--comment')
352
+ const revRaw = get('--rev')
353
+ if ((!specPath && !roundPath) || !commentPath || !revRaw) refuse(USAGE)
354
+ try {
355
+ const spec = loadRound(specPath, roundPath)
356
+ const parsed = parseAnswers(readTextFile(commentPath), spec)
357
+ const open = openQuestions(spec, parsed)
358
+ if (open.questions.length === 0) {
359
+ report(json, { command: 're-ask', ok: false, open: [], reason: 'nothing is open — every question is answered' }, 1, ['questions: nothing is open'])
360
+ }
361
+ const rev = Number(revRaw)
362
+ const markdown = renderQuestions(open, { rev })
363
+ report(json, { command: 're-ask', ok: true, rev, open: open.questions.map((q) => q.n), markdown }, 0, [markdown])
364
+ } catch (error) {
365
+ refuse(error.message)
366
+ }
367
+ } else if (command === 'route') {
368
+ const tool = get('--tool')
369
+ const asker = get('--asker')
370
+ const operator = get('--operator')
371
+ if (!tool || !asker || !operator) refuse(USAGE)
372
+ try {
373
+ const decision = decideRoute({ env: process.env, tool, asker, operator })
374
+ report(json, { command: 'route', ok: true, ...decision }, 0, ['questions: route ' + decision.route + ' — ' + decision.reason])
375
+ } catch (error) {
376
+ refuse(error.message)
377
+ }
378
+ } else {
379
+ refuse(USAGE)
380
+ }
381
+ }