@geraldmaron/construct 1.0.20 → 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 (124) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +194 -16
  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 +44 -16
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +22 -0
  11. package/lib/decisions/enforced-baseline.json +3 -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/document-ingest.mjs +13 -4
  16. package/lib/embed/daemon.mjs +1 -1
  17. package/lib/embed/inbox.mjs +25 -7
  18. package/lib/embedded-contract/triage.mjs +24 -2
  19. package/lib/embedded-contract/workflow-invoke.mjs +21 -0
  20. package/lib/features.mjs +11 -11
  21. package/lib/git-hooks-path.mjs +61 -0
  22. package/lib/home-namespace.mjs +60 -0
  23. package/lib/hooks/ci-status-check.mjs +62 -40
  24. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  25. package/lib/hooks/pre-push-gate.mjs +15 -6
  26. package/lib/hooks/stop-notify.mjs +32 -17
  27. package/lib/hooks/stop-typecheck.mjs +7 -2
  28. package/lib/host-capabilities.mjs +24 -8
  29. package/lib/host-disposition.mjs +76 -0
  30. package/lib/ingest/provider-extract.mjs +1 -1
  31. package/lib/ingest/strategy.mjs +35 -3
  32. package/lib/init-docs.mjs +1 -1
  33. package/lib/init-unified.mjs +320 -219
  34. package/lib/init-update.mjs +4 -84
  35. package/lib/init.mjs +9 -25
  36. package/lib/install/stage-project.mjs +8 -2
  37. package/lib/intent-classifier.mjs +1 -1
  38. package/lib/knowledge/search.mjs +52 -3
  39. package/lib/mcp/server.mjs +57 -14
  40. package/lib/mcp/tools/memory.mjs +2 -2
  41. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  42. package/lib/model-registry.mjs +40 -33
  43. package/lib/observation-store.mjs +6 -2
  44. package/lib/opencode-config.mjs +1 -1
  45. package/lib/orchestration/events.mjs +66 -0
  46. package/lib/orchestration/run-store-postgres.mjs +85 -0
  47. package/lib/orchestration/run-store-sqlite.mjs +122 -0
  48. package/lib/orchestration/runtime.mjs +188 -50
  49. package/lib/orchestration/store.mjs +102 -0
  50. package/lib/orchestration/worker.mjs +215 -0
  51. package/lib/orchestration-policy.mjs +27 -3
  52. package/lib/parity.mjs +80 -26
  53. package/lib/policy/unified-gates.mjs +96 -0
  54. package/lib/project-init-shared.mjs +0 -173
  55. package/lib/reconcile/adapter-prune.mjs +105 -0
  56. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  57. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  58. package/lib/reconcile/index.mjs +171 -0
  59. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  60. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  61. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  62. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  63. package/lib/reconcile/postgres-namespace.mjs +102 -0
  64. package/lib/runtime/uv-bootstrap.mjs +27 -3
  65. package/lib/schema-infer.mjs +16 -2
  66. package/lib/server/csrf.mjs +14 -2
  67. package/lib/server/index.mjs +95 -0
  68. package/lib/service-manager.mjs +39 -14
  69. package/lib/setup-prompts.mjs +2 -1
  70. package/lib/setup.mjs +165 -141
  71. package/lib/storage/backend.mjs +14 -0
  72. package/lib/storage/unified-storage.mjs +550 -0
  73. package/lib/template-registry.mjs +73 -0
  74. package/lib/term-format.mjs +75 -0
  75. package/lib/uninstall/uninstall.mjs +180 -7
  76. package/package.json +2 -2
  77. package/personas/construct.md +7 -8
  78. package/platforms/claude/settings.template.json +30 -4
  79. package/platforms/opencode/config.template.json +2 -2
  80. package/rules/common/neurodivergent-output.md +66 -0
  81. package/rules/common/tool-invisibility.md +37 -0
  82. package/scripts/sync-specialists.mjs +381 -95
  83. package/skills/operating/orchestration-reference.md +2 -16
  84. package/specialists/policy-inventory.json +14 -0
  85. package/specialists/prompts/cx-accessibility.md +2 -6
  86. package/specialists/prompts/cx-ai-engineer.md +0 -4
  87. package/specialists/prompts/cx-architect.md +3 -5
  88. package/specialists/prompts/cx-business-strategist.md +0 -5
  89. package/specialists/prompts/cx-data-analyst.md +0 -4
  90. package/specialists/prompts/cx-data-engineer.md +0 -4
  91. package/specialists/prompts/cx-debugger.md +2 -6
  92. package/specialists/prompts/cx-designer.md +0 -8
  93. package/specialists/prompts/cx-devil-advocate.md +2 -2
  94. package/specialists/prompts/cx-docs-keeper.md +0 -13
  95. package/specialists/prompts/cx-engineer.md +0 -13
  96. package/specialists/prompts/cx-evaluator.md +2 -2
  97. package/specialists/prompts/cx-explorer.md +4 -5
  98. package/specialists/prompts/cx-legal-compliance.md +4 -5
  99. package/specialists/prompts/cx-operations.md +0 -5
  100. package/specialists/prompts/cx-orchestrator.md +0 -4
  101. package/specialists/prompts/cx-platform-engineer.md +0 -8
  102. package/specialists/prompts/cx-product-manager.md +0 -8
  103. package/specialists/prompts/cx-qa.md +2 -11
  104. package/specialists/prompts/cx-rd-lead.md +0 -5
  105. package/specialists/prompts/cx-release-manager.md +0 -8
  106. package/specialists/prompts/cx-researcher.md +5 -29
  107. package/specialists/prompts/cx-reviewer.md +2 -6
  108. package/specialists/prompts/cx-security.md +2 -11
  109. package/specialists/prompts/cx-sre.md +0 -15
  110. package/specialists/prompts/cx-test-automation.md +0 -4
  111. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  112. package/specialists/prompts/cx-ux-researcher.md +0 -4
  113. package/specialists/registry.json +29 -29
  114. package/templates/distribution/run.mjs +36 -7
  115. package/templates/docs/accessibility-audit.md +56 -0
  116. package/templates/docs/architecture-review.md +59 -0
  117. package/templates/docs/code-review-report.md +46 -0
  118. package/templates/docs/construct_guide.md +14 -14
  119. package/templates/docs/debug-investigation.md +53 -0
  120. package/templates/docs/qa-report.md +48 -0
  121. package/templates/docs/security-audit-report.md +48 -0
  122. package/templates/docs/task-packet.md +49 -0
  123. package/templates/docs/verdict.md +40 -0
  124. package/lib/server/static/index.html +0 -1
@@ -14,7 +14,6 @@ import path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
 
16
16
  import {
17
- buildAgentsGuide,
18
17
  buildContextJson,
19
18
  buildContextMarkdown,
20
19
  buildPlanTemplate,
@@ -23,6 +22,7 @@ import {
23
22
  import { multiSelect } from './tty-prompts.mjs';
24
23
  import { execSync, spawnSync } from 'node:child_process';
25
24
  import { stageProjectAdapters } from './install/stage-project.mjs';
25
+ import { missingIgnorePatterns, isConstructPackageRepo } from './host-disposition.mjs';
26
26
 
27
27
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
28
28
  const ROOT_DIR = path.join(__dirname, "..");
@@ -53,6 +53,20 @@ const skipInteractive = !interactive;
53
53
  // templates/ already exist (issue #97).
54
54
  const forceScaffold = args.includes("--force");
55
55
 
56
+ // `bd init` is git-native and unconditionally commits its bootstrap; ADR-0027
57
+ // §3 requires init to leave the host repo's commit history untouched unless the
58
+ // user opts in. --commit-bootstrap keeps that commit; the default withholds it.
59
+ const commitBootstrap = args.includes("--commit-bootstrap");
60
+
61
+ // Adapter selection (construct-4xy6 / ADR-0027 §1). By default init writes
62
+ // adapters only for hosts detected on the machine; --with-<host> force-includes
63
+ // one, --all-hosts writes every adapter set. Copilot (`.github/`) is opt-in only
64
+ // — never written by detection — so init never touches a repo's CI directory
65
+ // without --with-copilot.
66
+ const HOST_FLAG_KEYS = ["claude", "codex", "opencode", "vscode", "cursor", "copilot"];
67
+ const withHostFlags = new Set(HOST_FLAG_KEYS.filter((k) => args.includes(`--with-${k}`)));
68
+ const allHosts = args.includes("--all-hosts");
69
+
56
70
  // Active profile selector. `--profile=<id>` writes the field into the
57
71
  // project's construct.config.json so resolveActiveProfile picks it up
58
72
  // on first run. Unknown ids are rejected with the available catalog.
@@ -322,61 +336,35 @@ function normalizeAnswer(value) {
322
336
 
323
337
 
324
338
 
325
- function buildDocsReadme(projectName) {
326
- return `# ${projectName} Documentation
327
-
328
- This directory contains the structured documentation system for ${projectName}.
329
-
330
- ## Lanes
331
-
332
- Each subdirectory represents a documentation lane with a specific purpose:
333
-
334
- - **adr/** – Architecture decision records
335
- - **briefs/** – Research, evidence, and one-pager documents
336
- - **changelogs/** – User-facing release notes
337
- - **intake/** – Raw source material awaiting processing
338
- - **memos/** – Decision memos and internal arguments
339
- - **meetings/** – Meeting notes and minutes
340
- - **notes/** – Working notes and lightweight context
341
- - **onboarding/** – Setup guides and first-day workflows
342
- - **postmortems/** – Blameless incident reports
343
- - **prds/** – Product and capability requirement documents
344
- - **rfcs/** – Architecture and implementation proposals
345
- - **runbooks/** – Operational procedures and diagnostics
346
-
347
- ## Usage
348
-
349
- ### Adding Documents
350
-
351
- Place new documents in the appropriate lane directory. Each lane has templates in its \`templates/\` subdirectory.
339
+ // Index only the lanes actually scaffolded — advertising lanes that have no
340
+ // directory sends readers to dead links. Project-framed: this is the team's doc
341
+ // surface, not Construct's tooling, so the body carries no `construct` commands.
352
342
 
353
- ### Consistency Rules
354
-
355
- - Use the templates as starting points
356
- - Keep documents focused on one lane's purpose
357
- - Update related documents when making changes
358
- - Link to beads issues in \`plan.md\`
359
-
360
- ### Quality Gates
343
+ function buildDocsReadme(projectName, selectedLanes = []) {
344
+ const laneLines = [...selectedLanes]
345
+ .sort()
346
+ .map((lane) => {
347
+ const meta = DOC_LANES[lane];
348
+ const label = meta?.title ?? lane;
349
+ const laneDir = meta?.dir ?? lane;
350
+ const desc = meta?.description ?? "Custom documentation lane.";
351
+ return `- [${label}](./${laneDir}/) — ${desc}`;
352
+ });
361
353
 
362
- Run \`construct docs:verify\` to check documentation quality.
354
+ return `# ${projectName} Documentation
363
355
 
364
- \`\`\`bash
365
- # Validate all documentation
366
- construct docs:verify
356
+ > The canonical home for this project's long-lived documents — decision records, briefs, notes, and runbooks.
367
357
 
368
- # Quick check (critical only)
369
- construct docs:verify --quick
358
+ ## Operating model
370
359
 
371
- # Attempt to fix issues
372
- construct docs:verify --fix
373
- \`\`\`
360
+ - Keep each document focused on one lane's purpose; start from the per-lane \`templates/\`.
361
+ - Link durable work to the project's tracker and \`plan.md\`, and update related documents in the same change.
362
+ - Prune a lane when it stops serving a real purpose rather than letting it collect stale templates.
374
363
 
375
- ## Maintenance
364
+ ## Lanes
376
365
 
377
- - Review documentation quarterly with \`construct init:update\`
378
- - Remove stale documents that no longer reflect reality
379
- - Update README.md when architecture changes`;
366
+ ${laneLines.join("\n")}
367
+ `;
380
368
  }
381
369
 
382
370
  function buildLaneReadme(laneKey) {
@@ -698,52 +686,28 @@ async function askIntakeCollection(targetPath, skipInteractive) {
698
686
  return { parentDirs: selected, maxDepth: 4 };
699
687
  }
700
688
 
689
+ // A greenfield README belongs to the project, not to Construct: a neutral
690
+ // skeleton the owner fills in. Construct's presence is one delineated pointer to
691
+ // AGENTS.md, never project-identity content or a tooling command reference —
692
+ // matching how a native agent stays out of the host's README (ADR-0027 §2).
693
+
701
694
  function buildProjectReadme(projectName) {
702
695
  return `# ${projectName}
703
696
 
704
- ## Getting Started
705
-
706
- This project uses [Construct](https://github.com/geraldmaron/construct) for agentic software development.
707
-
708
- ## Usage
709
-
710
- From inside OpenCode, Claude Code, or similar agent surfaces:
711
-
712
- \`\`\`text
713
- @construct build the feature and ship it when it's verified
714
- @construct fix the bug
715
- @construct review the changes before release
716
- \`\`\`
697
+ > Briefly describe what this project does and who it is for.
717
698
 
718
- Construct routes work across specialists, maintains project state, and ensures quality gates pass before shipping.
699
+ ## Getting started
719
700
 
720
- ## Project Structure
721
-
722
- - \`AGENTS.md\` — Operating contract for AI agents
723
- - \`plan.md\` — Current implementation plan
724
- - \`.cx/context.md\` — Session context and handoff state
725
- - \`docs/\` — Documentation system (if initialized with \`construct init --docs-preset\`)
701
+ Document how to install dependencies and run the project.
726
702
 
727
703
  ## Development
728
704
 
729
- \`\`\`bash
730
- # Initialize project with Construct
731
- construct init --docs-preset=lean
732
-
733
- # Check documentation quality
734
- construct docs:verify
705
+ Document how to build, test, and contribute.
735
706
 
736
- # Run tests
737
- npm test
738
- \`\`\`
707
+ ---
739
708
 
740
- ## CI Enforcement
741
-
742
- This project enforces documentation quality:
743
- - README.md must exist and be current
744
- - AGENTS.md must have required sections
745
- - plan.md must be updated weekly
746
- - .cx/context.md must track active work`;
709
+ <sub>Agent workflows for this repository are configured in [\`AGENTS.md\`](AGENTS.md), managed with [Construct](https://github.com/geraldmaron/construct).</sub>
710
+ `;
747
711
  }
748
712
 
749
713
  function preflight(target) {
@@ -776,11 +740,45 @@ function preflight(target) {
776
740
  return { clean };
777
741
  }
778
742
 
779
- function initializeBeadsTracker(target) {
743
+ function gitHeadSha(cwd) {
744
+ const r = spawnSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
745
+ return r.status === 0 ? r.stdout.trim() : null;
746
+ }
747
+
748
+ // Restore the host repo's commit pointer to where init found it, leaving every
749
+ // bd-written file in the working tree (uncommitted, unstaged) for the user to
750
+ // commit on their own terms. When the bootstrap commit was the repo's first,
751
+ // drop the branch ref so the repo returns to "no commits yet". bd's issue data
752
+ // lives in Dolt, not this git commit, so the tracker stays fully functional.
753
+
754
+ function undoBootstrapCommit(cwd, headBefore) {
755
+ const headAfter = gitHeadSha(cwd);
756
+ if (!headAfter || headAfter === headBefore) return false;
757
+ if (headBefore) {
758
+ spawnSync("git", ["reset", "--mixed", headBefore], { cwd, stdio: "ignore" });
759
+ } else {
760
+ spawnSync("git", ["update-ref", "-d", "HEAD"], { cwd, stdio: "ignore" });
761
+ spawnSync("git", ["reset"], { cwd, stdio: "ignore" });
762
+ }
763
+ return true;
764
+ }
765
+
766
+ function initializeBeadsTracker(target, { commitBootstrap = false } = {}) {
767
+ // bd init prints verbose, host-specific output (a doubled "Claude Code
768
+ // integration installed", a "Restart Claude Code" line that ignores the other
769
+ // synced hosts) that interleaves with Construct's own flow. Capture it and let
770
+ // the caller print one clean summary; the captured prefix names the issue ids.
771
+
772
+ // bd init is git-native and unconditionally commits its bootstrap (.beads/*,
773
+ // .gitignore, agent files) with no flag to suppress it. Capture HEAD first so
774
+ // the commit can be withheld unless --commit-bootstrap (ADR-0027 §3).
775
+
776
+ const headBefore = gitHeadSha(target);
777
+
780
778
  const result = spawnSync("bd", ["init"], {
781
779
  cwd: target,
782
780
  encoding: "utf8",
783
- stdio: "inherit",
781
+ stdio: ["ignore", "pipe", "pipe"],
784
782
  killSignal: "SIGTERM",
785
783
  timeout: 30_000,
786
784
  });
@@ -792,8 +790,42 @@ function initializeBeadsTracker(target) {
792
790
  throw result.error;
793
791
  }
794
792
  if (result.status !== 0) {
795
- throw new Error(`bd init exited with code ${result.status ?? "unknown"}`);
793
+ const tail = (result.stderr || result.stdout || "").trim().split("\n").slice(-2).join(" ");
794
+ throw new Error(`bd init exited with code ${result.status ?? "unknown"}${tail ? `: ${tail}` : ""}`);
796
795
  }
796
+ const withheldCommit = commitBootstrap ? false : undoBootstrapCommit(target, headBefore);
797
+ const prefixMatch = (result.stdout || "").match(/Issue prefix:\s*(\S+)/);
798
+ return { prefix: prefixMatch ? prefixMatch[1] : null, withheldCommit };
799
+ }
800
+
801
+ // Resolve the host adapter sets init should write (construct-4xy6). Returns null
802
+ // for "all hosts" (--all-hosts), otherwise the union of detected hosts and
803
+ // --with-<host> flags. Copilot is excluded from detection so `.github/` is only
804
+ // written on explicit --with-copilot; selecting VS Code pulls in claude because
805
+ // VS Code reads `.claude/agents` natively; an empty result falls back to claude.
806
+
807
+ async function resolveAdapterHosts() {
808
+ if (allHosts) return null;
809
+ const detected = new Set();
810
+ try {
811
+ const { detectHostCapabilities } = await import('./host-capabilities.mjs');
812
+ const nameToKey = {
813
+ 'Claude Code': 'claude',
814
+ 'OpenCode': 'opencode',
815
+ 'Codex': 'codex',
816
+ 'VS Code': 'vscode',
817
+ 'Cursor': 'cursor',
818
+ 'Copilot': 'copilot',
819
+ };
820
+ for (const cap of detectHostCapabilities()) {
821
+ if (cap.availability === 'installed' && nameToKey[cap.host]) detected.add(nameToKey[cap.host]);
822
+ }
823
+ } catch { /* detection is advisory; fall back to flags + baseline */ }
824
+ detected.delete('copilot');
825
+ const selected = new Set([...detected, ...withHostFlags]);
826
+ if (selected.has('vscode')) selected.add('claude');
827
+ if (selected.size === 0) selected.add('claude');
828
+ return HOST_FLAG_KEYS.filter((k) => selected.has(k));
797
829
  }
798
830
 
799
831
  async function main() {
@@ -813,27 +845,49 @@ async function main() {
813
845
  console.log(`Initializing Construct in ${path.relative(process.cwd(), target) || "."}`);
814
846
  }
815
847
 
816
- // Persist --profile=<id> into construct.config.json. Validate against the
817
- // curated catalog so a typo never silently falls back to rnd.
818
- if (profileId) {
819
- const { loadProfile, listProfiles } = await import('./profiles/loader.mjs');
820
- if (!loadProfile(profileId)) {
821
- console.error(`Unknown profile: ${profileId}. Available: ${listProfiles().join(', ')}`);
822
- process.exit(1);
823
- }
848
+ // Scaffold the central project config so the configuration surface is
849
+ // discoverable (ADR-0027 §1 / construct-e13x). Organized by design
850
+ // surfaces all major configurable blocks (deployment, telemetry,
851
+ // orchestration, etc.) so the user can discover and edit options easily.
852
+ // A fresh file is written on every init; skip-if-exists preserves user edits.
853
+
854
+ {
824
855
  const { findProjectConfigPath, loadProjectConfig, writeProjectConfig, PROJECT_CONFIG_FILENAME } = await import('./config/project-config.mjs');
856
+ const { DEFAULT_PROJECT_CONFIG } = await import('./config/schema.mjs');
857
+
858
+ if (profileId) {
859
+ const { loadProfile, listProfiles } = await import('./profiles/loader.mjs');
860
+ if (!loadProfile(profileId)) {
861
+ console.error(`Unknown profile: ${profileId}. Available: ${listProfiles().join(', ')}`);
862
+ process.exit(1);
863
+ }
864
+ }
825
865
  const found = findProjectConfigPath(target);
826
866
  const cfgPath = found || path.join(target, PROJECT_CONFIG_FILENAME);
827
867
 
828
- // loadProjectConfig returns a wrapper { path, raw, config, source, errors }.
829
- // Persist .raw (the on-disk JSON) so user customizations survive and
830
- // defaults are not silently materialized into the file.
831
-
832
- const loaded = found ? loadProjectConfig(target) : null;
833
- const cfg = loaded?.raw ? { ...loaded.raw } : { version: 1 };
834
- cfg.profile = profileId;
835
- writeProjectConfig(cfgPath, cfg, { validate: true });
836
- if (!quiet) console.log(`Profile set to ${profileId}.`);
868
+ // loadProjectConfig returns { path, raw, config, source }. Persist .raw (the
869
+ // on-disk JSON) so user customizations survive and defaults are not silently
870
+ // materialized into the file.
871
+
872
+ if (!found) {
873
+ const cfg = {
874
+ $schema: "./node_modules/@geraldmaron/construct/schemas/project-config.schema.json",
875
+ ...DEFAULT_PROJECT_CONFIG,
876
+ profile: profileId || 'rnd',
877
+ };
878
+ // Explicitly write the full default config object so all options are
879
+ // visible and discoverable to the user in the file. The $schema property
880
+ // provides inline documentation and validation in supported editors.
881
+ writeProjectConfig(cfgPath, cfg, { validate: true, silent: true });
882
+ created.push(PROJECT_CONFIG_FILENAME);
883
+ if (!quiet && profileId) console.log(`Profile set to ${profileId}.`);
884
+ } else if (profileId) {
885
+ const loaded = loadProjectConfig(target);
886
+ const cfg = loaded?.raw ? { ...loaded.raw } : { version: 1 };
887
+ cfg.profile = profileId;
888
+ writeProjectConfig(cfgPath, cfg, { validate: true, silent: true });
889
+ if (!quiet) console.log(`Profile set to ${profileId}.`);
890
+ }
837
891
  }
838
892
 
839
893
  // Resolve active profile to drive capability-gated scaffolding (intake
@@ -846,16 +900,11 @@ async function main() {
846
900
  const { gatherAttribution } = await import('./intake/attribution.mjs');
847
901
  const attribution = intakeCap?.attribution ? gatherAttribution() : null;
848
902
 
849
- // Always create core Construct files
850
- writeStampedIfMissing({
851
- targetRoot: target,
852
- created,
853
- skipped,
854
- filePath: path.join(target, "AGENTS.md"),
855
- content: buildAgentsGuide(projectName),
856
- generator: "construct/init",
857
- attribution,
858
- });
903
+ // AGENTS.md and CLAUDE.md are user-owned files (ADR-0027 §2). Construct does
904
+ // not author their bodies — bd init creates them with the project skeleton and
905
+ // the Beads block, and the injectIntoAgentFile pass below adds Construct's
906
+ // guidance as a fenced marker block only. Pre-writing a doctrine body here
907
+ // would land un-fenced Construct content that sync/doctor cannot reconcile.
859
908
 
860
909
  writeStampedIfMissing({
861
910
  targetRoot: target,
@@ -886,15 +935,10 @@ async function main() {
886
935
  attribution,
887
936
  });
888
937
 
889
- writeStampedIfMissing({
890
- targetRoot: target,
891
- created,
892
- skipped,
893
- filePath: path.join(target, ".cx", ".gitkeep"),
894
- content: "",
895
- generator: "construct/init",
896
- });
897
-
938
+ // `.cx/` already exists from the context writes above and is gitignored in full
939
+ // (ADR-0027), so a keep file there could never be tracked. No dead keep file is written.
940
+
941
+
898
942
  // Detect existing project content once; the result feeds three decisions
899
943
  // below: skip .cx/inbox/ on custom intake, skip lane scaffolding for lanes
900
944
  // already covered elsewhere, and skip per-lane templates/ when root
@@ -909,21 +953,19 @@ async function main() {
909
953
  console.log(`[init:intake] skipping .cx/inbox/ — ${inboxDecision.reason}. Run with --force to scaffold anyway.`);
910
954
  skipped.push('.cx/inbox/ (deferred to existing intake)');
911
955
  } else {
912
- writeStampedIfMissing({
913
- targetRoot: target,
914
- created,
915
- skipped,
916
- filePath: path.join(target, ".cx", "inbox", ".gitkeep"),
917
- content: "",
918
- generator: "construct/init",
919
- });
956
+ // `.cx/inbox/` is the local intake drop zone the watcher polls; under the gitignored
957
+ // `.cx/` tree the directory must exist on disk but needs no keep file.
958
+
959
+ fs.mkdirSync(path.join(target, ".cx", "inbox"), { recursive: true });
960
+ created.push(".cx/inbox/");
920
961
  }
921
962
 
922
963
  // Intake-archetype scaffolding. When the active profile declares
923
- // capabilities.intake.inbox, scaffold a project-root inbox/ drop zone
924
- // (with a .gitignore so raw drops never enter source) and seed the
925
- // dedup manifest. The existing-structure detection already opted us
926
- // out of clobbering a user's pipeline above.
964
+ // capabilities.intake.inbox, scaffold a project-root inbox/ drop zone and seed
965
+ // the dedup manifest. The whole inbox/ is ignored via host-disposition
966
+ // IGNORED_PATTERNS (ADR-0027 §1), so raw drops never enter source and no local
967
+ // keep-file is needed. Existing-structure detection already opted out of
968
+ // clobbering a user's pipeline above.
927
969
 
928
970
  if (intakeCap?.inbox && !inboxDecision.skip) {
929
971
  const projectInbox = path.join(target, 'inbox');
@@ -931,11 +973,6 @@ async function main() {
931
973
  fs.mkdirSync(projectInbox, { recursive: true });
932
974
  created.push('inbox/');
933
975
  }
934
- const inboxGitignore = path.join(projectInbox, '.gitignore');
935
- if (!fs.existsSync(inboxGitignore)) {
936
- fs.writeFileSync(inboxGitignore, '*\n!.gitignore\n', 'utf8');
937
- created.push('inbox/.gitignore');
938
- }
939
976
 
940
977
  if (intakeCap.dedup === 'sha256') {
941
978
  const { saveManifest, loadManifest, MANIFEST_REL_PATH } = await import('./intake/manifest.mjs');
@@ -947,104 +984,170 @@ async function main() {
947
984
  }
948
985
  }
949
986
 
950
- // Ensure `.cx/` is in project .gitignore. .cx/ is runtime state — observations,
951
- // sessions, vector index, and traces (with daily JSONL files that can exceed
952
- // GitHub's 100 MB file-size limit). Idempotent: if `.cx/` (or a broader
953
- // pattern like `.cx` / `*` / `**`) already matches, leave the file alone.
987
+ // Gitignore every Construct-generated artifact whose disposition is `ignored`
988
+ // (ADR-0027 §1): the six adapter dirs, the `.construct/` launcher, `.cx/`
989
+ // runtime state, and the generated config files all carry machine-specific
990
+ // absolute paths (MCP server paths, env-resolved tokens) and are recreated by
991
+ // `construct sync`, so they are never committed. Mirrors Construct's own repo
992
+ // .gitignore 1:1. Idempotent per-pattern: a pattern already matched exactly,
993
+ // by its bare/slashed form, or by a broader `*` / `**`, is left untouched.
954
994
 
955
- try {
956
- const gitignorePath = path.join(target, '.gitignore');
957
- const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
958
- const lines = existing.split('\n').map((l) => l.trim());
959
- const alreadyIgnored = lines.some((l) =>
960
- l === '.cx' || l === '.cx/' || l === '.cx/**' || l === '*' || l === '**'
961
- );
962
- if (!alreadyIgnored) {
963
- const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
964
- const block = `${prefix}\n# Construct runtime state — local-only, never source\n.cx/\n`;
965
- fs.writeFileSync(gitignorePath, existing + block, 'utf8');
966
- created.push(existing.length === 0 ? '.gitignore (added .cx/)' : '.gitignore (appended .cx/)');
995
+ const isToolRepo = isConstructPackageRepo(target);
996
+
997
+ if (!isToolRepo) {
998
+ try {
999
+ const gitignorePath = path.join(target, '.gitignore');
1000
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
1001
+ const missing = missingIgnorePatterns(existing);
1002
+ if (missing.length > 0) {
1003
+ const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
1004
+ const block = `${prefix}\n# Construct — generated adapters, launcher, and runtime state.\n# Machine-specific, recreated by \`construct sync\`; never source (ADR-0027).\n${missing.join('\n')}\n`;
1005
+ fs.writeFileSync(gitignorePath, existing + block, 'utf8');
1006
+ created.push(existing.length === 0 ? '.gitignore (Construct ignores)' : `.gitignore (+${missing.length} Construct ignore${missing.length === 1 ? '' : 's'})`);
1007
+ }
1008
+ } catch (err) {
1009
+ console.warn(`⚠️ Could not update .gitignore: ${err.message}`);
967
1010
  }
968
- } catch (err) {
969
- console.warn(`⚠️ Could not update .gitignore: ${err.message}`);
1011
+ } else if (verbose) {
1012
+ console.log('[init:conflation] inside Construct repository; skipping .gitignore mutation.');
970
1013
  }
971
1014
 
972
- // Stage .construct/ launcher + sync .claude/ adapters so init produces the
973
- // same project shape as a fresh `npm install` of the package as a dep.
1015
+ // Resolve which host adapter sets to write (construct-4xy6). Default: the
1016
+ // hosts detected on this machine, plus any --with-<host>. VS Code reads
1017
+ // .claude/agents natively, so selecting it pulls in claude. Copilot is opt-in
1018
+ // only — never written by detection. --all-hosts writes every set; an empty
1019
+ // selection falls back to the .claude/ baseline so a project is never bare.
974
1020
 
975
- try {
976
- let pkgVersion = '';
1021
+ const adapterHosts = await resolveAdapterHosts();
1022
+
1023
+ // Stage .construct/ launcher + sync the selected adapter sets so init produces
1024
+ // the same project shape as a fresh `npm install` of the package as a dep.
1025
+
1026
+ if (!isToolRepo) {
977
1027
  try {
978
- pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8')).version || '';
979
- } catch { /* fall through with empty version */ }
980
- const stagingPre = fs.existsSync(path.join(target, '.construct', 'run.mjs'));
981
- const claudePre = fs.existsSync(path.join(target, '.claude', 'settings.json'));
982
- stageProjectAdapters({
983
- projectRoot: target,
984
- packageRoot: ROOT_DIR,
985
- pkgVersion,
986
- log: (msg) => console.log(`[init:stage] ${msg}`),
987
- });
988
- if (!stagingPre && fs.existsSync(path.join(target, '.construct', 'run.mjs'))) {
989
- created.push('.construct/ (launcher staged)');
990
- }
991
- if (!claudePre && fs.existsSync(path.join(target, '.claude', 'settings.json'))) {
992
- created.push('.claude/ (agents + settings)');
993
- }
1028
+ let pkgVersion = '';
1029
+ try {
1030
+ pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8')).version || '';
1031
+ } catch { /* fall through with empty version */ }
1032
+ const stagingPre = fs.existsSync(path.join(target, '.construct', 'run.mjs'));
1033
+ const claudePre = fs.existsSync(path.join(target, '.claude', 'settings.json'));
1034
+ stageProjectAdapters({
1035
+ projectRoot: target,
1036
+ packageRoot: ROOT_DIR,
1037
+ pkgVersion,
1038
+ hosts: adapterHosts,
1039
+ log: (msg) => console.log(`[init:stage] ${msg}`),
1040
+ });
1041
+ if (!quiet && adapterHosts) {
1042
+ console.log(` Adapters: ${adapterHosts.join(', ')} (add more with --with-<host>, or --all-hosts).`);
1043
+ }
1044
+ if (!stagingPre && fs.existsSync(path.join(target, '.construct', 'run.mjs'))) {
1045
+ created.push('.construct/ (launcher staged)');
1046
+ }
1047
+ if (!claudePre && fs.existsSync(path.join(target, '.claude', 'settings.json'))) {
1048
+ created.push('.claude/ (agents + settings)');
1049
+ }
994
1050
 
995
- // construct init writes project scope only. Global wiring (the `construct`
996
- // front-door agent in `~/.claude/agents/`, `~/.codex/agents/`, etc.) is
997
- // installed once by `construct sync --global` or the npm postinstall.
998
- // Specialists, slash commands, and skills live with the repo per each
999
- // host's documented best-practice scope.
1051
+ // construct init writes project scope only. Global wiring (the `construct`
1052
+ // front-door agent in `~/.claude/agents/`, `~/.codex/agents/`, etc.) is
1053
+ // installed once by `construct sync --global` or the npm postinstall.
1054
+ // Specialists, slash commands, and skills live with the repo per each
1055
+ // host's documented best-practice scope.
1000
1056
 
1001
- } catch (err) {
1002
- console.warn(`⚠️ Adapter staging failed: ${err.message}`);
1057
+ } catch (err) {
1058
+ console.warn(`⚠️ Adapter staging failed: ${err.message}`);
1059
+ }
1060
+ } else if (verbose) {
1061
+ console.log('[init:conflation] inside Construct repository; skipping .construct/ launcher staging.');
1003
1062
  }
1004
1063
 
1005
- // Written once, never re-copied, so user edits survive subsequent inits
1006
- // and `npm install` rebuilds. Skip-when-exists is the contract.
1064
+ // Cache a host-agnostic project profile at .cx/project-profile.json (gitignored)
1065
+ // so per-host skill filtering has a signal to scope against. Owned by init
1066
+ // install must not read the cwd (ADR-0027 §3).
1067
+
1068
+ try {
1069
+ const { detectProjectProfile, writeProfile } = await import('./project-profile.mjs');
1070
+ const profile = detectProjectProfile(target);
1071
+ if (profile.tags.length > 0) {
1072
+ writeProfile(profile, target);
1073
+ created.push('.cx/project-profile.json');
1074
+ }
1075
+ } catch { /* project profiling is advisory; init proceeds */ }
1076
+
1077
+ // Tool orientation describes Construct, not the project, so it belongs in the
1078
+ // ignored .cx/ tree rather than the host repo root — never read as project
1079
+ // content, never committed (ADR-0027 §1). Written once; skip-when-exists keeps
1080
+ // user edits across subsequent inits and `npm install` rebuilds.
1007
1081
 
1008
1082
  const guideSrc = path.join(ROOT_DIR, 'templates', 'docs', 'construct_guide.md');
1009
- const guideDst = path.join(target, 'construct_guide.md');
1083
+ const guideDst = path.join(target, '.cx', 'construct_guide.md');
1010
1084
  if (fs.existsSync(guideSrc) && !fs.existsSync(guideDst)) {
1011
1085
  try {
1086
+ fs.mkdirSync(path.dirname(guideDst), { recursive: true });
1012
1087
  fs.copyFileSync(guideSrc, guideDst);
1013
- created.push('construct_guide.md');
1088
+ created.push('.cx/construct_guide.md');
1014
1089
  } catch (err) {
1015
- console.warn(`⚠️ Could not write construct_guide.md: ${err.message}`);
1090
+ console.warn(`⚠️ Could not write .cx/construct_guide.md: ${err.message}`);
1016
1091
  }
1017
1092
  } else if (fs.existsSync(guideDst)) {
1018
- skipped.push('construct_guide.md');
1093
+ skipped.push('.cx/construct_guide.md');
1019
1094
  }
1020
1095
 
1021
1096
  // Auto-init beads
1022
1097
  const beadsMeta = path.join(target, ".beads", "metadata.json");
1023
- if (!fs.existsSync(beadsMeta)) {
1024
- console.log('');
1025
- console.log('═══════════════════════════════════════════════════════════');
1026
- console.log(' TASK TRACKING (beads)');
1027
- console.log('═══════════════════════════════════════════════════════════');
1028
- console.log('');
1029
- console.log('Beads is a local-first issue tracker that keeps your work');
1030
- console.log('organized without depending on external services like Jira.');
1031
- console.log('');
1032
- console.log('Features:');
1033
- console.log(' • Local SQLite database (no cloud required)');
1034
- console.log(' • Issues sync to git (optional)');
1035
- console.log(' • Works offline');
1098
+ if (!fs.existsSync(beadsMeta) && !isToolRepo) {
1036
1099
  console.log('');
1037
- console.log("Initializing beads tracker...");
1100
+ console.log('Initializing the task tracker (beads — local-first, git-synced, works offline)…');
1038
1101
  fs.mkdirSync(path.join(target, ".beads"), { recursive: true });
1039
1102
  try {
1040
- initializeBeadsTracker(target);
1103
+ const beads = initializeBeadsTracker(target, { commitBootstrap });
1041
1104
  created.push(".beads/ (initialized)");
1105
+ const prefixNote = beads?.prefix ? ` — issues prefixed \`${beads.prefix}-\`` : "";
1106
+ console.log(` ✓ Task tracker ready${prefixNote}. Run \`bd quickstart\` to start.`);
1107
+ if (beads?.withheldCommit) {
1108
+ console.log(" Bootstrap files left uncommitted (commit them yourself, or re-run with --commit-bootstrap).");
1109
+ }
1042
1110
  } catch (e) {
1043
1111
  console.warn("⚠️ Beads init failed:", e.message);
1044
1112
  }
1045
1113
  console.log('');
1046
1114
  }
1047
-
1115
+
1116
+ // Wire the project's git hooks to .beads/hooks so Construct's pre-commit
1117
+ // secret-scan and policy gates activate. Owned by init, not install (ADR-0027
1118
+ // §3): install must never mutate the cwd repo. Idempotent — no-op when
1119
+ // .beads/hooks/pre-commit is absent or a non-default custom path is set.
1120
+
1121
+ try {
1122
+ const { ensureGitHooksPath } = await import('./git-hooks-path.mjs');
1123
+ const hooks = ensureGitHooksPath({ cwd: target });
1124
+ if (hooks.status === 'set') {
1125
+ created.push('git core.hooksPath → .beads/hooks');
1126
+ if (!quiet) console.log(` ✓ ${hooks.message}`);
1127
+ } else if (hooks.status === 'warning' && !quiet) {
1128
+ console.log(` ⚠️ ${hooks.message}`);
1129
+ }
1130
+ } catch { /* git-hooks wiring is advisory; init proceeds */ }
1131
+
1132
+ // Inject Construct's integration guidance into AGENTS.md and CLAUDE.md as a
1133
+ // versioned, hash-stamped marker block (ADR-0027 §2). Runs after bd init so
1134
+ // the block survives whatever bd wrote, preserves all content outside the
1135
+ // markers, and dedups against a sibling Beads Integration block. Idempotent:
1136
+ // same hash is a no-op, a version/hash bump replaces the block content only.
1137
+
1138
+ try {
1139
+ const { injectIntoAgentFile, CONSTRUCT_INTEGRATION_VERSION } = await import('./agent-instructions/inject.mjs');
1140
+ for (const name of ["AGENTS.md", "CLAUDE.md"]) {
1141
+ const res = injectIntoAgentFile(path.join(target, name), {
1142
+ version: CONSTRUCT_INTEGRATION_VERSION,
1143
+ header: `# ${projectName}\n`,
1144
+ });
1145
+ if (!res.existed) created.push(`${name} (Construct integration)`);
1146
+ }
1147
+ } catch (err) {
1148
+ console.warn(`⚠️ Could not inject agent-instruction block: ${err.message}`);
1149
+ }
1150
+
1048
1151
  // Intake collection — always write intake-config.json with safe defaults
1049
1152
  // so the user can inspect what's watched and edit the file directly. Empty
1050
1153
  // parentDirs is the correct default: the inbox watcher always scans
@@ -1106,7 +1209,7 @@ async function main() {
1106
1209
  if (lanesToScaffold.length > 0) {
1107
1210
  writeIfMissing(
1108
1211
  path.join(target, "docs", "README.md"),
1109
- buildDocsReadme(projectName)
1212
+ buildDocsReadme(projectName, lanesToScaffold)
1110
1213
  );
1111
1214
 
1112
1215
  for (const laneKey of lanesToScaffold) {
@@ -1244,22 +1347,20 @@ async function main() {
1244
1347
 
1245
1348
  if (!quiet) {
1246
1349
  console.log('\nServices:');
1350
+ let dashboardUrl = null;
1247
1351
  for (const svc of results) {
1248
1352
  if (svc.status === 'started' || svc.status === 'reused') {
1249
1353
  const status = svc.status === 'reused' ? ' (running)' : '';
1250
- const url = svc.url ? ` ${svc.url}` : '';
1251
- console.log(` ${svc.name}${status}${url}`);
1354
+ console.log(` ${svc.name}${status}${svc.url ? ` ${svc.url}` : ''}`);
1355
+ if (svc.name === 'Dashboard' && svc.url) dashboardUrl = svc.url;
1356
+ } else if (svc.status === 'failed') {
1357
+ console.log(` ${svc.name} — failed${svc.note ? ` (${svc.note})` : ''}`);
1252
1358
  }
1253
1359
  }
1254
-
1255
- // Show Telemetry credentials if running locally
1360
+
1256
1361
  const telemetrySvc = results.find(r => r.name === 'Telemetry' && r.url?.includes('localhost'));
1257
- if (telemetrySvc) {
1258
- console.log(`\nTelemetry: ${telemetrySvc.url}`);
1259
- }
1260
-
1261
- const dashboardPort = process.env.DASHBOARD_PORT || '4242';
1262
- console.log(`\nDashboard: http://127.0.0.1:${dashboardPort}`);
1362
+ if (telemetrySvc) console.log(`\nTelemetry: ${telemetrySvc.url}`);
1363
+ if (dashboardUrl) console.log(`\nDashboard: ${dashboardUrl} · stop services with \`construct stop\``);
1263
1364
  }
1264
1365
  } catch (error) {
1265
1366
  console.error(`Services could not be started: ${error.message}`);