@geraldmaron/construct 1.0.21 → 1.0.23

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 (117) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +191 -13
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +25 -7
  17. package/lib/features.mjs +11 -11
  18. package/lib/git-hooks-path.mjs +61 -0
  19. package/lib/home-namespace.mjs +60 -0
  20. package/lib/hooks/ci-status-check.mjs +62 -40
  21. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  22. package/lib/hooks/pre-push-gate.mjs +15 -6
  23. package/lib/hooks/stop-notify.mjs +32 -17
  24. package/lib/hooks/stop-typecheck.mjs +7 -2
  25. package/lib/host-capabilities.mjs +24 -8
  26. package/lib/host-disposition.mjs +76 -0
  27. package/lib/ingest/provider-extract.mjs +1 -1
  28. package/lib/init-docs.mjs +1 -1
  29. package/lib/init-unified.mjs +320 -219
  30. package/lib/init-update.mjs +4 -84
  31. package/lib/init.mjs +9 -25
  32. package/lib/install/stage-project.mjs +8 -2
  33. package/lib/intent-classifier.mjs +1 -1
  34. package/lib/knowledge/search.mjs +52 -3
  35. package/lib/mcp/server.mjs +57 -14
  36. package/lib/mcp/tools/memory.mjs +2 -2
  37. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  38. package/lib/model-registry.mjs +40 -33
  39. package/lib/observation-store.mjs +6 -2
  40. package/lib/opencode-config.mjs +1 -1
  41. package/lib/orchestration/events.mjs +66 -0
  42. package/lib/orchestration/runtime.mjs +70 -9
  43. package/lib/orchestration/worker.mjs +69 -21
  44. package/lib/orchestration-policy.mjs +27 -3
  45. package/lib/parity.mjs +46 -24
  46. package/lib/policy/unified-gates.mjs +96 -0
  47. package/lib/project-init-shared.mjs +0 -173
  48. package/lib/reconcile/adapter-prune.mjs +105 -0
  49. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  50. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  51. package/lib/reconcile/index.mjs +171 -0
  52. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  53. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  54. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  55. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  56. package/lib/reconcile/postgres-namespace.mjs +102 -0
  57. package/lib/runtime/uv-bootstrap.mjs +27 -3
  58. package/lib/schema-infer.mjs +16 -2
  59. package/lib/server/csrf.mjs +14 -2
  60. package/lib/server/index.mjs +95 -0
  61. package/lib/service-manager.mjs +39 -14
  62. package/lib/setup-prompts.mjs +2 -1
  63. package/lib/setup.mjs +165 -141
  64. package/lib/storage/backend.mjs +14 -0
  65. package/lib/storage/unified-storage.mjs +550 -0
  66. package/lib/template-registry.mjs +73 -0
  67. package/lib/term-format.mjs +75 -0
  68. package/lib/uninstall/uninstall.mjs +180 -7
  69. package/package.json +2 -2
  70. package/personas/construct.md +7 -8
  71. package/platforms/claude/settings.template.json +30 -4
  72. package/platforms/opencode/config.template.json +2 -2
  73. package/rules/common/neurodivergent-output.md +66 -0
  74. package/rules/common/tool-invisibility.md +37 -0
  75. package/scripts/sync-specialists.mjs +381 -95
  76. package/skills/operating/orchestration-reference.md +2 -16
  77. package/specialists/policy-inventory.json +14 -0
  78. package/specialists/prompts/cx-accessibility.md +2 -6
  79. package/specialists/prompts/cx-ai-engineer.md +0 -4
  80. package/specialists/prompts/cx-architect.md +3 -5
  81. package/specialists/prompts/cx-business-strategist.md +0 -5
  82. package/specialists/prompts/cx-data-analyst.md +0 -4
  83. package/specialists/prompts/cx-data-engineer.md +0 -4
  84. package/specialists/prompts/cx-debugger.md +2 -6
  85. package/specialists/prompts/cx-designer.md +0 -8
  86. package/specialists/prompts/cx-devil-advocate.md +2 -2
  87. package/specialists/prompts/cx-docs-keeper.md +0 -13
  88. package/specialists/prompts/cx-engineer.md +0 -13
  89. package/specialists/prompts/cx-evaluator.md +2 -2
  90. package/specialists/prompts/cx-explorer.md +4 -5
  91. package/specialists/prompts/cx-legal-compliance.md +4 -5
  92. package/specialists/prompts/cx-operations.md +0 -5
  93. package/specialists/prompts/cx-orchestrator.md +0 -4
  94. package/specialists/prompts/cx-platform-engineer.md +0 -8
  95. package/specialists/prompts/cx-product-manager.md +0 -8
  96. package/specialists/prompts/cx-qa.md +2 -11
  97. package/specialists/prompts/cx-rd-lead.md +0 -5
  98. package/specialists/prompts/cx-release-manager.md +0 -8
  99. package/specialists/prompts/cx-researcher.md +5 -29
  100. package/specialists/prompts/cx-reviewer.md +2 -6
  101. package/specialists/prompts/cx-security.md +2 -11
  102. package/specialists/prompts/cx-sre.md +0 -15
  103. package/specialists/prompts/cx-test-automation.md +0 -4
  104. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  105. package/specialists/prompts/cx-ux-researcher.md +0 -4
  106. package/specialists/registry.json +29 -29
  107. package/templates/distribution/run.mjs +36 -7
  108. package/templates/docs/accessibility-audit.md +56 -0
  109. package/templates/docs/architecture-review.md +59 -0
  110. package/templates/docs/code-review-report.md +46 -0
  111. package/templates/docs/construct_guide.md +14 -14
  112. package/templates/docs/debug-investigation.md +53 -0
  113. package/templates/docs/qa-report.md +48 -0
  114. package/templates/docs/security-audit-report.md +48 -0
  115. package/templates/docs/task-packet.md +49 -0
  116. package/templates/docs/verdict.md +40 -0
  117. package/lib/server/static/index.html +0 -1
@@ -0,0 +1,94 @@
1
+ /**
2
+ * lib/agent-instructions/inject.mjs — non-destructive marker-block injection for
3
+ * agent-instruction files the user owns (AGENTS.md, CLAUDE.md) — ADR-0027 §2.
4
+ *
5
+ * Construct's integration guidance lives inside a versioned, hash-stamped block:
6
+ *
7
+ * <!-- BEGIN CONSTRUCT INTEGRATION v:N hash:XXXX -->
8
+ * ...pointers...
9
+ * <!-- END CONSTRUCT INTEGRATION -->
10
+ *
11
+ * Everything outside the markers is preserved byte-for-byte. Injection is
12
+ * idempotent: an existing block with the same version+hash is a no-op; a
13
+ * different hash replaces the block content only; no block appends one. The
14
+ * block dedups against a sibling Beads Integration block — when one is present
15
+ * the tracker line points at it instead of repeating `bd` commands.
16
+ *
17
+ * Mirrors the marker pattern beads-automation uses for its own block, lifted
18
+ * into a reusable primitive so init and sync share one append-or-replace path.
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { createHash } from 'node:crypto';
24
+
25
+ export const CONSTRUCT_INTEGRATION_VERSION = 1;
26
+
27
+ const BEGIN_PREFIX = '<!-- BEGIN CONSTRUCT INTEGRATION';
28
+ const END_MARKER = '<!-- END CONSTRUCT INTEGRATION -->';
29
+ const BLOCK_RE = /<!-- BEGIN CONSTRUCT INTEGRATION[^>]*-->[\s\S]*?<!-- END CONSTRUCT INTEGRATION -->/;
30
+ const BEADS_BLOCK_RE = /<!-- BEGIN BEADS INTEGRATION/;
31
+
32
+ function shortHash(body) {
33
+ return createHash('sha256').update(body).digest('hex').slice(0, 12);
34
+ }
35
+
36
+ // The managed block: concise pointers to the surfaces an agent needs to work
37
+ // with Construct (≤60 lines). When a Beads Integration block is already present
38
+ // the tracker line defers to it rather than duplicating `bd` commands.
39
+
40
+ export function buildConstructIntegrationBody({ hasBeadsBlock = false } = {}) {
41
+ const tracker = hasBeadsBlock
42
+ ? '- **Tracker**: see the Beads Integration block below for `bd` commands.'
43
+ : '- **Tracker**: use Beads (`bd`) for all task tracking — run `bd prime` for the workflow. Do not use ad-hoc TODO lists.';
44
+ return [
45
+ '## Construct integration',
46
+ '',
47
+ 'This project is managed by Construct. Address `@construct` in your editor and ask',
48
+ 'for the outcome, not the specialist — Construct routes to the right specialist chain.',
49
+ '',
50
+ '- **Durable state** lives in `.cx/` (context, knowledge, intake, traces) and Beads.',
51
+ ' If services are down, resume from `plan.md`, `.cx/context.md`, the latest',
52
+ ' `.cx/handoffs/` file, Beads, and git.',
53
+ '- **Signals**: drop a file into `.cx/inbox/`; `construct intake` classifies and routes it.',
54
+ '- **Specialists** (architect, reviewer, security, …) are dispatched by Construct — you',
55
+ ' do not call them directly.',
56
+ tracker,
57
+ '- **Single-writer rule**: one session owns a file\'s edits; others review or wait.',
58
+ '- Run `construct doctor` to check system health.',
59
+ ].join('\n');
60
+ }
61
+
62
+ // Inject or update the managed block in `content`. Returns the new content and
63
+ // the action taken (`unchanged` | `updated` | `created`).
64
+
65
+ export function injectConstructBlock(content, body, version = CONSTRUCT_INTEGRATION_VERSION) {
66
+ const hash = shortHash(body);
67
+ const block = `${BEGIN_PREFIX} v:${version} hash:${hash} -->\n${body}\n${END_MARKER}`;
68
+ const existing = content.match(BLOCK_RE);
69
+ if (existing) {
70
+ if (existing[0].includes(`v:${version} hash:${hash}`)) return { content, action: 'unchanged' };
71
+ return { content: content.replace(BLOCK_RE, block), action: 'updated' };
72
+ }
73
+ const sep = content.length === 0 ? '' : content.endsWith('\n') ? '\n' : '\n\n';
74
+ return { content: `${content}${sep}${block}\n`, action: 'created' };
75
+ }
76
+
77
+ // Apply the managed block to a single agent-instruction file. Creates the file
78
+ // (with `header`) when missing; otherwise injects/updates in place, preserving
79
+ // all surrounding content. Writes only when the content changes.
80
+
81
+ export function injectIntoAgentFile(filePath, { version = CONSTRUCT_INTEGRATION_VERSION, header = '' } = {}) {
82
+ const existed = fs.existsSync(filePath);
83
+ const current = existed ? fs.readFileSync(filePath, 'utf8') : '';
84
+ const hasBeadsBlock = BEADS_BLOCK_RE.test(current);
85
+ const body = buildConstructIntegrationBody({ hasBeadsBlock });
86
+ const base = !existed && header ? (header.endsWith('\n') ? header : `${header}\n`) : current;
87
+ const { content, action } = injectConstructBlock(base, body, version);
88
+ const changed = !existed || action !== 'unchanged';
89
+ if (changed) {
90
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
91
+ fs.writeFileSync(filePath, content, 'utf8');
92
+ }
93
+ return { action: existed ? action : 'created', existed, changed };
94
+ }
package/lib/auto-docs.mjs CHANGED
@@ -20,6 +20,14 @@ import { execSync } from 'node:child_process';
20
20
 
21
21
  import { CLI_COMMANDS_BY_CATEGORY, CATEGORY_ORDER } from './cli-commands.mjs';
22
22
 
23
+ // A raw `|` inside a markdown table cell is parsed as a column delimiter, so a
24
+ // description like `--scope=project|user|both` splits the row into phantom
25
+ // columns. Escape pipes (and flatten newlines) for any value emitted into a cell.
26
+
27
+ function tableCell(value) {
28
+ return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
29
+ }
30
+
23
31
  // --- region helpers ---
24
32
 
25
33
  function replaceRegion(content, regionName, newBody) {
@@ -53,7 +61,7 @@ function buildCommandsTable() {
53
61
  rows.push('| Command | What it does |');
54
62
  rows.push('|---|---|');
55
63
  for (const cmd of cmds) {
56
- rows.push(`| \`construct ${cmd.name}\` | ${cmd.description} |`);
64
+ rows.push(`| \`construct ${cmd.name}\` | ${tableCell(cmd.description)} |`);
57
65
  }
58
66
  rows.push('');
59
67
  }
@@ -335,7 +343,7 @@ function renderCommandPage(category, commands) {
335
343
  lines.push('| Command | What it does |');
336
344
  lines.push('|---|---|');
337
345
  for (const cmd of commands) {
338
- lines.push(`| \`construct ${cmd.name}\` | ${cmd.description} |`);
346
+ lines.push(`| \`construct ${cmd.name}\` | ${tableCell(cmd.description)} |`);
339
347
  }
340
348
  lines.push('');
341
349
  for (const cmd of commands) {
@@ -12,6 +12,8 @@
12
12
  * - internal: true → hidden from --help and --all; callable but not advertised
13
13
  */
14
14
 
15
+ import { resolveColors } from './term-format.mjs';
16
+
15
17
  export const CLI_COMMANDS = [
16
18
  // ── Core (shown by default) ───────────────────────────────────────────
17
19
  {
@@ -26,6 +28,14 @@ export const CLI_COMMANDS = [
26
28
  { flag: '--only=<a,b,c>', desc: 'Start only the named services (postgres, dashboard, telemetry, memory, opencode)' },
27
29
  ],
28
30
  },
31
+ {
32
+ name: 'dashboard',
33
+ emoji: '📊',
34
+ category: 'Core',
35
+ core: true,
36
+ description: 'Start the local dashboard/orchestration daemon (or --token to mint a dashboard token)',
37
+ usage: 'construct dashboard [--token]',
38
+ },
29
39
  {
30
40
  name: 'stop',
31
41
  emoji: '⏹',
@@ -50,12 +60,14 @@ export const CLI_COMMANDS = [
50
60
  emoji: '🛠️',
51
61
  category: 'Core',
52
62
  core: true,
53
- description: 'Machine setup (once per machine): Docker, cm/cass, config, embeddings',
54
- usage: 'construct install [--yes] [--no-docker] [--reconfigure]',
63
+ description: 'Machine setup (scoped per ADR-0029): --scope=project|user|both, default project',
64
+ usage: 'construct install [--scope=project|user|both] [--yes] [--no-docker] [--no-launch-agent] [--reconfigure]',
55
65
  options: [
56
- { flag: '--yes', desc: 'Apply defaults without prompts' },
57
- { flag: '--no-docker', desc: 'Skip Docker-based service setup (local Postgres)' },
58
- { flag: '--reconfigure', desc: 'Re-prompt for service consent, ignoring cached answers' },
66
+ { flag: '--scope=<s>', desc: 'project (default, no-op + guidance) | user (writes ~/.construct/, MCP, ~/.claude/* via consent) | both' },
67
+ { flag: '--yes', desc: 'Apply defaults without prompts (only meaningful with --scope=user|both)' },
68
+ { flag: '--no-docker', desc: 'Skip Docker-based service setup (local Postgres)' },
69
+ { flag: '--no-launch-agent', desc: 'Skip background macOS LaunchAgent registration' },
70
+ { flag: '--reconfigure', desc: 'Re-prompt for service consent, ignoring cached answers' },
59
71
  ],
60
72
  },
61
73
  {
@@ -68,6 +80,9 @@ export const CLI_COMMANDS = [
68
80
  options: [
69
81
  { flag: '--yes', desc: 'Accept all defaults (non-interactive)' },
70
82
  { flag: '--no-start', desc: 'Do not start services after init' },
83
+ { flag: '--commit-bootstrap', desc: 'Keep the beads bootstrap commit (default: leave files uncommitted)' },
84
+ { flag: '--with-<host>', desc: 'Force an adapter set (claude|codex|opencode|vscode|cursor|copilot); default writes detected hosts only' },
85
+ { flag: '--all-hosts', desc: 'Write every adapter set regardless of what is installed' },
71
86
  { flag: '--interactive, -i', desc: 'Enable interactive setup with project detection' },
72
87
  { flag: '--quiet, -q', desc: 'Minimal output' },
73
88
  { flag: '--verbose, -v', desc: 'Detailed output' },
@@ -236,6 +251,8 @@ export const CLI_COMMANDS = [
236
251
  { flag: '--fix-legacy-agents', desc: 'Sweep legacy cx-*.md agents at user scope and re-sync' },
237
252
  ],
238
253
  },
254
+
255
+ // ── Work (project workflows) ──────────────────────────────────────────
239
256
  {
240
257
  name: 'distill',
241
258
  emoji: '🔬',
@@ -252,6 +269,14 @@ export const CLI_COMMANDS = [
252
269
  description: 'Convert documents to indexed markdown',
253
270
  usage: 'construct ingest <file> [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--strict] [--legacy-extractor]',
254
271
  },
272
+ {
273
+ name: 'export',
274
+ emoji: '📤',
275
+ category: 'Work',
276
+ core: false,
277
+ description: 'Export markdown to PDF/DOCX/HTML via Pandoc + Typst (optional system binaries; ADR-0024)',
278
+ usage: 'construct export <markdown-file> --to=<pdf|docx|html> [--output=<path>]',
279
+ },
255
280
  {
256
281
  name: 'infer',
257
282
  emoji: '🧠',
@@ -339,13 +364,21 @@ export const CLI_COMMANDS = [
339
364
  emoji: '🎼',
340
365
  category: 'Models & Integrations',
341
366
  core: false,
342
- description: 'Construct-owned local orchestration runtime (Mode-A: single-process, filesystem-backed, no Docker)',
343
- usage: 'construct orchestrate <run|status> [options]',
367
+ description: 'Construct-owned local orchestration runtime, in-process or against the local daemon (--remote)',
368
+ usage: 'construct orchestrate <run|status> [options] [--remote]',
344
369
  subcommands: [
345
- { name: 'run "<request>" [--strategy S] [--host H] [--no-construct] [--no-execute] [--json]', desc: 'Plan and run a request through a Construct-owned specialist chain; returns host-adapter metadata' },
346
- { name: 'status [run-id] [--json]', desc: 'Inspect a run, or list recent runs' },
370
+ { name: 'run "<request>" [--strategy S] [--host H] [--worker-backend provider] [--no-construct] [--no-execute] [--json] [--remote]', desc: 'Plan and run a request through a Construct-owned specialist chain; --remote drives the local daemon over HTTP' },
371
+ { name: 'status [run-id] [--json] [--remote]', desc: 'Inspect a run, or list recent runs (locally or from the daemon)' },
347
372
  ],
348
373
  },
374
+ {
375
+ name: 'acp',
376
+ emoji: '🔗',
377
+ category: 'Models & Integrations',
378
+ core: false,
379
+ description: 'Run Construct as an Agent Client Protocol (ACP) server over stdio for Zed/JetBrains/VS Code ACP clients',
380
+ usage: 'construct acp',
381
+ },
349
382
  {
350
383
  name: 'mcp',
351
384
  emoji: '🔌',
@@ -965,17 +998,12 @@ export function getCommandSpec(name) {
965
998
  return CLI_COMMANDS.find((c) => c.name === name) || null;
966
999
  }
967
1000
 
968
- const ESC = String.fromCharCode(27);
969
-
970
1001
  // Render a per-command help block. Pulls usage, description, subcommands,
971
1002
  // and options from the CLI_COMMANDS entry so help stays consistent across
972
1003
  // every command. Internal commands fall back to a minimal block.
973
1004
 
974
1005
  export function formatCommandHelp(name, { colors = false } = {}) {
975
- const useColor = colors && process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
976
- const c = useColor
977
- ? { bold: `${ESC}[1m`, dim: `${ESC}[2m`, reset: `${ESC}[0m` }
978
- : { bold: '', dim: '', reset: '' };
1006
+ const c = resolveColors({ enabled: colors });
979
1007
  const spec = getCommandSpec(name);
980
1008
  if (!spec) {
981
1009
  return `Unknown command: ${name}\n\nRun 'construct --help' for available commands.\n`;
@@ -52,6 +52,38 @@ function isArtifactPath(rel) {
52
52
  return ARTIFACT_PATH_GLOBS.some((r) => r.test(rel));
53
53
  }
54
54
 
55
+ // Deliverable surfaces where a tool-identity leak (rules/common/tool-invisibility.md)
56
+ // matters: project docs and the durable .cx knowledge / research / handoff / strategy
57
+ // stores. Broader than the fabrication-scoped ARTIFACT_PATH_GLOBS because a strategy
58
+ // lands in docs/ root, not only docs/prd.
59
+
60
+ const DELIVERABLE_LEAK_GLOBS = [
61
+ /^docs\/.*\.md$/,
62
+ /^\.cx\/(?:research|knowledge|handoffs)\//,
63
+ /^\.cx\/strategy\.md$/,
64
+ ];
65
+
66
+ function isDeliverablePath(rel) {
67
+ return DELIVERABLE_LEAK_GLOBS.some((r) => r.test(rel));
68
+ }
69
+
70
+ // The Construct repo legitimately names Construct and its cx-* roles in its own docs —
71
+ // the tool-identity check is for consuming projects only. Identify self by package name,
72
+ // memoized per rootDir so a repo-wide lint reads package.json once, not once per file.
73
+
74
+ const selfRepoCache = new Map();
75
+
76
+ function isConstructSelfRepo(rootDir) {
77
+ if (selfRepoCache.has(rootDir)) return selfRepoCache.get(rootDir);
78
+ let isSelf = false;
79
+ try {
80
+ const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
81
+ isSelf = pkg.name === '@geraldmaron/construct';
82
+ } catch { isSelf = false; }
83
+ selfRepoCache.set(rootDir, isSelf);
84
+ return isSelf;
85
+ }
86
+
55
87
  function requiresHeader(rel) {
56
88
  const ext = path.extname(rel);
57
89
  if (['.yaml', '.yml', '.json', '.jsonl', '.toml'].includes(ext)) return { required: false, type: null };
@@ -284,6 +316,77 @@ function checkArtifactBanned(content, rel) {
284
316
  return violations;
285
317
  }
286
318
 
319
+ // Internal specialist role ids (cx-business-strategist, cx-product-manager, …) are
320
+ // implementation detail; in a consuming project's deliverable they read as a tool-identity
321
+ // leak. The set is anchored to the real role ids from specialists/registry.json — an
322
+ // open-ended /cx-[a-z-]+/ also matches unrelated npm packages (cx-oracle, cx-ray, cx-pro)
323
+ // and would fail the release gate on legitimate user content. Drift from the registry is
324
+ // caught by tests/tool-invisibility.test.mjs, not at runtime: this module sits on the hook
325
+ // path and must never throw on a missing or malformed registry file.
326
+
327
+ export const KNOWN_CX_ROLE_IDS = [
328
+ 'accessibility', 'ai-engineer', 'architect', 'business-strategist', 'data-analyst',
329
+ 'data-engineer', 'debugger', 'designer', 'devil-advocate', 'docs-keeper', 'engineer',
330
+ 'evaluator', 'explorer', 'legal-compliance', 'operations', 'orchestrator',
331
+ 'platform-engineer', 'product-manager', 'qa', 'rd-lead', 'release-manager', 'researcher',
332
+ 'reviewer', 'security', 'sre', 'test-automation', 'trace-reviewer', 'ux-researcher',
333
+ ];
334
+
335
+ const CX_ROLE_LEAK = new RegExp(
336
+ `\\bcx-(?:${[...KNOWN_CX_ROLE_IDS].sort((a, b) => b.length - a.length).join('|')})\\b`,
337
+ );
338
+
339
+ // Code fences (``` or ~~~) wrap non-prose content, so a role id inside one is not a leak.
340
+ // Only closed fences are skipped: an unclosed fence must not suppress scanning of the rest
341
+ // of the document, or the backstop would fail open on a leak. Returns the line indices that
342
+ // fall inside a properly terminated fence.
343
+
344
+ function closedFenceLines(lines) {
345
+ const inside = new Set();
346
+ let openChar = null;
347
+ let pending = [];
348
+ for (let i = 0; i < lines.length; i++) {
349
+ const marker = lines[i].match(/^(```+|~~~+)/);
350
+ if (openChar === null) {
351
+ if (marker) { openChar = marker[1][0]; pending = [i]; }
352
+ continue;
353
+ }
354
+ pending.push(i);
355
+ if (marker && marker[1][0] === openChar) {
356
+ for (const idx of pending) inside.add(idx);
357
+ openChar = null;
358
+ pending = [];
359
+ }
360
+ }
361
+ return inside;
362
+ }
363
+
364
+ function findToolIdentityLeaks(content) {
365
+ const lines = content.split('\n');
366
+ const fenced = closedFenceLines(lines);
367
+ const violations = [];
368
+ let inFrontmatter = false;
369
+ let inComment = false;
370
+ for (let i = 0; i < lines.length; i++) {
371
+ const line = lines[i];
372
+ if (i === 0 && line.trim() === '---') { inFrontmatter = true; continue; }
373
+ if (inFrontmatter) { if (line.trim() === '---') inFrontmatter = false; continue; }
374
+ if (fenced.has(i)) continue;
375
+ if (inComment) { if (line.includes('-->')) inComment = false; continue; }
376
+ if (line.includes('construct-lint-ignore')) continue;
377
+ const scan = line.replace(/<!--.*?-->/g, '');
378
+ if (line.includes('<!--') && !line.includes('-->')) inComment = true;
379
+ if (CX_ROLE_LEAK.test(scan)) {
380
+ violations.push({
381
+ line: i + 1,
382
+ label: 'tool-identity leak: internal cx-* specialist role id in a deliverable — the artifact is about the user\'s project, not Construct\'s roles (tool-invisibility §2)',
383
+ kind: 'artifact',
384
+ });
385
+ }
386
+ }
387
+ return violations;
388
+ }
389
+
287
390
  function checkBanned(content, filePath) {
288
391
  if (isBannedCheckSkipped(filePath)) return [];
289
392
 
@@ -366,6 +469,18 @@ export function lintFile(filePath, { rootDir = process.cwd(), fix = false } = {}
366
469
  for (const v of artifactHits) bucket.push(v);
367
470
  }
368
471
 
472
+ // Tool-identity leak (rules/common/tool-invisibility.md): consuming-project deliverables
473
+ // only — skipped on the Construct repo, where naming Construct and cx-* roles is correct.
474
+ // Same mode routing as the artifact lint (warn by default, block in the release gate).
475
+ if (!isConstructSelfRepo(rootDir) && isDeliverablePath(rel)) {
476
+ const leaks = findToolIdentityLeaks(content);
477
+ if (leaks.length > 0) {
478
+ const mode = process.env.CONSTRUCT_ARTIFACT_LINT_MODE || 'warn';
479
+ const bucket = mode === 'block' ? errors : warnings;
480
+ for (const v of leaks) bucket.push(v);
481
+ }
482
+ }
483
+
369
484
  return { path: rel, errors, warnings };
370
485
  }
371
486
 
@@ -5,7 +5,13 @@
5
5
  * Outputs to a platform-appropriate per-user completions directory.
6
6
  */
7
7
 
8
- import { CLI_COMMANDS } from './cli-commands.mjs';
8
+ import { CLI_COMMANDS as ALL_CLI_COMMANDS } from './cli-commands.mjs';
9
+
10
+ // Completions advertise only public commands. internal: true commands stay
11
+ // callable but never appear as tab candidates (the cli-commands.mjs visibility
12
+ // contract). Shadowing the imported list keeps every generator below public-only.
13
+
14
+ const CLI_COMMANDS = ALL_CLI_COMMANDS.filter((c) => !c.internal);
9
15
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
10
16
  import { join } from 'path';
11
17
  import { homedir } from 'os';
@@ -30,6 +30,7 @@ export const INGEST_ORCHESTRATIONS = ['prompt-only', 'orchestrated'];
30
30
 
31
31
  export const ORCHESTRATION_WORKER_BACKENDS = ['inline', 'provider'];
32
32
  export const ORCHESTRATION_STORES = ['filesystem', 'sqlite', 'postgres'];
33
+ export const CHAIN_OF_THOUGHT_MODES = ['hidden', 'surface', 'telemetry_only'];
33
34
 
34
35
  // SessionStart context routing. `auto` keeps the rich payload on stdout for
35
36
  // interactive sessions and suppresses it (to a debug log) for non-interactive /
@@ -56,6 +57,7 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
56
57
  orchestration: Object.freeze({
57
58
  workerBackend: 'inline',
58
59
  store: 'filesystem',
60
+ chainOfThought: 'hidden',
59
61
  }),
60
62
  telemetry: Object.freeze({
61
63
  enabled: true,
@@ -93,6 +95,7 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
93
95
  });
94
96
 
95
97
  export const FIELD_RULES = {
98
+ $schema: { type: 'string', required: false },
96
99
  version: { type: 'number', required: true },
97
100
  alias: { type: 'string', required: false, maxLength: 120 },
98
101
  deployment: {
@@ -123,6 +126,7 @@ export const FIELD_RULES = {
123
126
  fields: {
124
127
  workerBackend: { type: 'string', enum: ORCHESTRATION_WORKER_BACKENDS },
125
128
  store: { type: 'string', enum: ORCHESTRATION_STORES },
129
+ chainOfThought: { type: 'string', enum: CHAIN_OF_THOUGHT_MODES },
126
130
  },
127
131
  },
128
132
  telemetry: {
@@ -7,6 +7,8 @@
7
7
  "ADR-0019",
8
8
  "ADR-0020",
9
9
  "ADR-0021",
10
+ "ADR-0022",
11
+ "ADR-0023",
10
12
  "any-to-business-strategist",
11
13
  "any-to-explorer",
12
14
  "architect-to-data-engineer",
@@ -122,26 +122,23 @@ function checkReadmeBasicSections(readmePath) {
122
122
  }
123
123
  }
124
124
 
125
- function checkAgentsRequiredSections(agentsPath) {
125
+ // AGENTS.md is user-owned (ADR-0027 §2): a host project carries bd's skeleton
126
+ // plus Construct's fenced integration block, while the meta-repo carries its own
127
+ // hand-written contract. Neither has a fixed section list, so verify substance —
128
+ // a heading and a non-trivial body past any frontmatter — not a prescribed shape.
129
+
130
+ function checkAgentsPresent(agentsPath) {
126
131
  if (!fs.existsSync(agentsPath)) return false;
127
-
132
+
128
133
  try {
129
- const content = readFileSync(agentsPath, "utf8").toLowerCase();
130
- const requiredSections = [
131
- "operating hierarchy",
132
- "start-of-session rules",
133
- "maintenance rules",
134
- "end-of-session rules",
135
- "verification rules"
136
- ];
137
-
138
- const missingSections = requiredSections.filter(section => !content.includes(section));
139
-
140
- if (missingSections.length > 0) {
141
- warnings.push(`AGENTS.md missing sections: ${missingSections.join(", ")} in ${path.relative(cwd, agentsPath)}`);
134
+ const content = readFileSync(agentsPath, "utf8");
135
+ const body = content.replace(/^---[\s\S]*?\n---\s*/, "").trim();
136
+
137
+ if (!/^#\s/m.test(body) || body.length < 40) {
138
+ warnings.push(`AGENTS.md is empty or lacks a usable agent guide: ${path.relative(cwd, agentsPath)}`);
142
139
  return false;
143
140
  }
144
-
141
+
145
142
  return true;
146
143
  } catch (err) {
147
144
  warnings.push(`Cannot read AGENTS.md: ${path.relative(cwd, agentsPath)} (${err.message})`);
@@ -389,7 +386,7 @@ function main() {
389
386
  if (!quickMode) {
390
387
  // Detailed checks (warnings only)
391
388
  checkReadmeBasicSections(path.join(cwd, "README.md"));
392
- checkAgentsRequiredSections(path.join(cwd, "AGENTS.md"));
389
+ checkAgentsPresent(path.join(cwd, "AGENTS.md"));
393
390
  if (planExists) checkPlanLinkedToIssues(path.join(cwd, "plan.md"));
394
391
  if (cxExists && fs.existsSync(contextMdPath)) checkContextHasProgress(contextMdPath);
395
392
  checkDocsSystem(path.join(cwd, "docs"));
@@ -454,7 +451,7 @@ export {
454
451
  checkExists,
455
452
  checkFileAge,
456
453
  checkReadmeBasicSections,
457
- checkAgentsRequiredSections,
454
+ checkAgentsPresent,
458
455
  checkPlanLinkedToIssues,
459
456
  checkContextHasProgress,
460
457
  checkDocsSystem,
@@ -94,6 +94,13 @@ export async function runCli(args) {
94
94
  return 0;
95
95
  }
96
96
 
97
+ if (sub === 'bench:hooks') {
98
+ const { main } = await import('../../scripts/bench-hooks.mjs');
99
+ const report = await main();
100
+ const failed = report.results.filter((r) => r.status === 'fail');
101
+ return failed.length > 0 ? 1 : 0;
102
+ }
103
+
97
104
  if (sub === 'consistency') {
98
105
  const { runAllChecks } = await import('./watchers/consistency.mjs');
99
106
  const result = await runAllChecks();
@@ -109,6 +116,6 @@ export async function runCli(args) {
109
116
  }
110
117
 
111
118
  console.error(`Unknown doctor subcommand: ${sub}`);
112
- console.error('Usage: construct doctor [status|watch|stop|logs|tick|consistency]');
119
+ console.error('Usage: construct doctor [status|watch|stop|logs|tick|consistency|bench:hooks]');
113
120
  return 2;
114
121
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * lib/document-export.mjs — markdown → PDF / DOCX / HTML export via external
3
+ * binaries (Pandoc + Typst), per ADR-0024.
4
+ *
5
+ * Bound to system-binary tooling discovered at runtime, never bundled in core
6
+ * (ADR-0001 zero-npm-core; ADR-0014 optional-capability pattern). Both engines
7
+ * are spawned as separate processes — Pandoc isolates its GPLv2+ licence at
8
+ * the process boundary; Typst is the Apache-2.0 PDF engine driven through
9
+ * Pandoc's `--pdf-engine=typst` flag, so a single Pandoc invocation produces
10
+ * every target format the contract supports.
11
+ *
12
+ * detect(format) reports availability without spawning the engine for real.
13
+ * exportMarkdown({ inputPath, outputPath, format, env }) spawns the engine and
14
+ * returns a structured result. Both surface actionable "install X" guidance
15
+ * when a required binary is absent; neither throws on missing tooling.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ export const EXPORT_FORMATS = ['pdf', 'docx', 'html'];
23
+
24
+ const FORMAT_ENGINES = {
25
+ pdf: { engine: 'pandoc', pdfEngine: 'typst', extraBinaries: ['typst'] },
26
+ docx: { engine: 'pandoc', pdfEngine: null, extraBinaries: [] },
27
+ html: { engine: 'pandoc', pdfEngine: null, extraBinaries: [] },
28
+ };
29
+
30
+ function whichBin(name, env = process.env) {
31
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
32
+ const result = spawnSync(cmd, [name], { encoding: 'utf8', env });
33
+ if (result.status !== 0) return null;
34
+ const first = (result.stdout || '').trim().split('\n')[0];
35
+ return first || null;
36
+ }
37
+
38
+ function binVersion(name, args = ['--version'], env = process.env) {
39
+ const r = spawnSync(name, args, { encoding: 'utf8', env });
40
+ if (r.status !== 0) return null;
41
+ const line = (r.stdout || '').trim().split('\n')[0];
42
+ return line || null;
43
+ }
44
+
45
+ function installHint(name) {
46
+ if (name === 'pandoc') return 'Install pandoc to enable document export (e.g. `brew install pandoc` on macOS, `apt install pandoc` on Debian/Ubuntu, or https://pandoc.org/installing.html).';
47
+ if (name === 'typst') return 'Install typst to enable PDF export via Pandoc (`brew install typst` on macOS, https://github.com/typst/typst/releases for binaries).';
48
+ return `Install ${name} to enable this export format.`;
49
+ }
50
+
51
+ // detect returns the same structure regardless of format so callers can render
52
+ // availability uniformly. `present` is the single boolean a caller should gate
53
+ // on; `missing` enumerates the binaries to install when present is false.
54
+
55
+ export function detect(format, env = process.env) {
56
+ const config = FORMAT_ENGINES[format];
57
+ if (!config) return { ok: false, format, present: false, missing: [], message: `Unsupported format: ${format}. Supported: ${EXPORT_FORMATS.join(', ')}.` };
58
+ const required = [config.engine, ...(config.extraBinaries || [])];
59
+ const status = required.map((name) => ({ name, path: whichBin(name, env), version: null }));
60
+ for (const s of status) if (s.path) s.version = binVersion(s.name, ['--version'], env);
61
+ const missing = status.filter((s) => !s.path).map((s) => s.name);
62
+ return {
63
+ ok: true,
64
+ format,
65
+ present: missing.length === 0,
66
+ binaries: status,
67
+ missing,
68
+ message: missing.length === 0
69
+ ? `Ready: ${status.map((s) => `${s.name} (${s.version?.split(' ').slice(0, 2).join(' ') ?? 'unknown'})`).join(', ')}`
70
+ : missing.map(installHint).join(' '),
71
+ };
72
+ }
73
+
74
+ function defaultOutputPath(inputPath, format) {
75
+ const parsed = path.parse(inputPath);
76
+ return path.join(parsed.dir, `${parsed.name}.${format}`);
77
+ }
78
+
79
+ // exportMarkdown is the single contract callers depend on (CLI / MCP / SDK).
80
+ // Returns { ok, format, inputPath, outputPath, engine, missing?, message }.
81
+ // ok=false with a populated `missing` means a required binary is absent — the
82
+ // caller surfaces `message` (which carries the install hint) instead of
83
+ // retrying or crashing.
84
+
85
+ export function exportMarkdown({ inputPath, outputPath, format, env = process.env, spawnFn = spawnSync } = {}) {
86
+ if (!inputPath) return { ok: false, format, message: 'exportMarkdown: inputPath is required.' };
87
+ if (!EXPORT_FORMATS.includes(format)) return { ok: false, format, inputPath, message: `Unsupported format: ${format}. Supported: ${EXPORT_FORMATS.join(', ')}.` };
88
+ if (!fs.existsSync(inputPath)) return { ok: false, format, inputPath, message: `exportMarkdown: input does not exist: ${inputPath}` };
89
+
90
+ const detection = detect(format, env);
91
+ if (!detection.present) {
92
+ return { ok: false, format, inputPath, missing: detection.missing, message: detection.message };
93
+ }
94
+
95
+ const config = FORMAT_ENGINES[format];
96
+ const target = outputPath || defaultOutputPath(inputPath, format);
97
+ fs.mkdirSync(path.dirname(target), { recursive: true });
98
+
99
+ const args = ['-f', 'markdown', '-o', target, '--standalone'];
100
+ if (config.pdfEngine) args.push(`--pdf-engine=${config.pdfEngine}`);
101
+ args.push(inputPath);
102
+
103
+ const result = spawnFn(config.engine, args, { encoding: 'utf8', env });
104
+ if (result.status !== 0) {
105
+ return {
106
+ ok: false,
107
+ format,
108
+ inputPath,
109
+ outputPath: target,
110
+ engine: config.engine,
111
+ message: `Export failed (${config.engine} exit ${result.status}): ${(result.stderr || result.stdout || '').trim().slice(0, 400)}`,
112
+ };
113
+ }
114
+
115
+ return {
116
+ ok: true,
117
+ format,
118
+ inputPath,
119
+ outputPath: target,
120
+ engine: config.engine,
121
+ pdfEngine: config.pdfEngine,
122
+ message: `Wrote ${path.relative(process.cwd(), target)}`,
123
+ };
124
+ }
@@ -1077,7 +1077,7 @@ this.#scheduler.register(
1077
1077
  method: 'POST',
1078
1078
  headers: {
1079
1079
  Authorization: `Bearer ${orKey}`, 'content-type': 'application/json',
1080
- 'HTTP-Referer': 'https://github.com/construct',
1080
+ 'HTTP-Referer': 'https://github.com/geraldmaron/construct',
1081
1081
  },
1082
1082
  body: JSON.stringify({
1083
1083
  model: modelId.replace(/^openrouter\//, ''),