@geraldmaron/construct 1.0.6 → 1.0.8

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 (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lib/hooks/rule-verifier.mjs — Stop hook that audits the session for
4
+ * compliance with the construct rules the agent claimed to follow.
5
+ *
6
+ * Checks:
7
+ * - Each commit / push was preceded by an approval signal scoped to that
8
+ * branch + change.
9
+ * - Edits to protected files (CLAUDE.md table) had user confirmation.
10
+ * - Beads claims exist for any work that touched durable state.
11
+ *
12
+ * Approval detection is intent-based, not keyword-based. The verifier reads
13
+ * the conversational window before each consequential action and classifies
14
+ * the user's stance: APPROVED, REFUSED, INCONCLUSIVE. Direct affirmation,
15
+ * accepting a proposed plan that named the action, or "go ahead" all count
16
+ * as approval — no specific word is required.
17
+ *
18
+ * Output: { ok: 'pass' | 'fail' | 'inconclusive', findings[] } written to
19
+ * .cx/audit.jsonl. Inconclusive results surface as escalation questions to
20
+ * the user rather than silent passes or false-positive blocks.
21
+ *
22
+ * @p95ms 200
23
+ * @maxBlockingScope Stop
24
+ */
25
+
26
+ import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
28
+ import { logHookFailure } from './_lib/log.mjs';
29
+
30
+ const AUDIT_REL = '.cx/audit.jsonl';
31
+
32
+ function safeReadJsonLines(transcriptPath) {
33
+ try {
34
+ const raw = readFileSync(transcriptPath, 'utf8');
35
+ return raw.split('\n').map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
36
+ } catch { return []; }
37
+ }
38
+
39
+ function lastN(entries, count) {
40
+ return entries.slice(Math.max(0, entries.length - count));
41
+ }
42
+
43
+ /**
44
+ * Identify consequential actions in the transcript tail. Returns a list of
45
+ * { kind, branch, target, atIndex } items. Each one needs an approval signal
46
+ * within the window of conversation immediately preceding it.
47
+ *
48
+ * Detected actions:
49
+ * - 'commit' — git commit invocation
50
+ * - 'push' — git push invocation
51
+ * - 'protected' — edit to a CLAUDE.md-protected file
52
+ * - 'durable-state' — bd close / claim against durable state
53
+ */
54
+ export function findConsequentialActions(entries) {
55
+ const actions = [];
56
+ for (let i = 0; i < entries.length; i++) {
57
+ const e = entries[i];
58
+ const cmd = extractBashCommand(e);
59
+ if (cmd) {
60
+ if (/^git\s+commit\b/.test(cmd) && !/--dry-run/.test(cmd)) {
61
+ actions.push({ kind: 'commit', target: cmd.slice(0, 200), atIndex: i });
62
+ } else if (/^git\s+push\b/.test(cmd) && !/--dry-run/.test(cmd)) {
63
+ actions.push({ kind: 'push', target: cmd.slice(0, 200), atIndex: i });
64
+ } else if (/^gh\s+pr\s+(create|merge)\b/.test(cmd)) {
65
+ actions.push({ kind: 'pr-mutation', target: cmd.slice(0, 200), atIndex: i });
66
+ }
67
+ }
68
+ const editPath = extractEditPath(e);
69
+ if (editPath && /CLAUDE\.md|install\.sh|claude\/settings\.template\.json|agents\/registry\.json/.test(editPath)) {
70
+ actions.push({ kind: 'protected', target: editPath, atIndex: i });
71
+ }
72
+ }
73
+ return actions;
74
+ }
75
+
76
+ function extractBashCommand(entry) {
77
+ const toolUse = entry?.message?.content?.find?.((c) => c?.type === 'tool_use' && c?.name === 'Bash');
78
+ return toolUse?.input?.command || null;
79
+ }
80
+
81
+ function extractEditPath(entry) {
82
+ const toolUse = entry?.message?.content?.find?.((c) => c?.type === 'tool_use' && (c?.name === 'Edit' || c?.name === 'Write'));
83
+ return toolUse?.input?.file_path || null;
84
+ }
85
+
86
+ /**
87
+ * Classify a conversational window for approval intent. This is intentionally
88
+ * conservative — it does not require any keyword. If the window contains a
89
+ * user turn that directly addresses the action being taken (mentioning the
90
+ * branch, the file, or accepting a proposed plan), that counts as approval.
91
+ *
92
+ * The default classifier is deterministic: it scans for plausible approval
93
+ * tokens AND for the action target appearing earlier in the conversation.
94
+ * A pluggable classifier can replace this with an LLM-graded inference at
95
+ * runtime — register via setApprovalClassifier(fn).
96
+ *
97
+ * Returns: 'APPROVED' | 'REFUSED' | 'INCONCLUSIVE'.
98
+ */
99
+ let _approvalClassifier = defaultApprovalClassifier;
100
+
101
+ export function setApprovalClassifier(fn) {
102
+ _approvalClassifier = typeof fn === 'function' ? fn : defaultApprovalClassifier;
103
+ }
104
+
105
+ export function classifyApproval(window, action) {
106
+ return _approvalClassifier(window, action);
107
+ }
108
+
109
+ function defaultApprovalClassifier(window, action) {
110
+ if (!Array.isArray(window) || window.length === 0) return 'INCONCLUSIVE';
111
+
112
+ let assistantProposedAction = false;
113
+ let userResponded = false;
114
+ let userRefused = false;
115
+ let userAffirmed = false;
116
+
117
+ for (const entry of window) {
118
+ const role = entry?.message?.role || entry?.role;
119
+ const text = extractText(entry).toLowerCase();
120
+ if (!text) continue;
121
+
122
+ if (role === 'assistant') {
123
+ if (actionMentioned(text, action)) assistantProposedAction = true;
124
+ }
125
+ if (role === 'user') {
126
+ userResponded = true;
127
+ if (/\b(no|stop|don'?t|cancel|wait|abort|hold off|never mind)\b/.test(text)) userRefused = true;
128
+ if (/\b(yes|yep|yeah|go ahead|do it|proceed|ship it|approve|confirmed|sounds good|that works|lgtm|sgtm|ok|sure|please do|merge it|continue)\b/.test(text)) userAffirmed = true;
129
+ // Even without explicit affirmation words, accepting a named plan is approval.
130
+ if (assistantProposedAction && !userRefused && text.length > 0 && !/\?$/.test(text.trim())) {
131
+ userAffirmed = userAffirmed || /\b(thanks|great|perfect|sounds|works|good)\b/.test(text);
132
+ }
133
+ }
134
+ }
135
+
136
+ if (userRefused) return 'REFUSED';
137
+ if (userAffirmed && assistantProposedAction) return 'APPROVED';
138
+ if (userAffirmed) return 'APPROVED';
139
+ if (!userResponded) return 'INCONCLUSIVE';
140
+ return 'INCONCLUSIVE';
141
+ }
142
+
143
+ function extractText(entry) {
144
+ const msg = entry?.message;
145
+ if (!msg) return '';
146
+ if (typeof msg.content === 'string') return msg.content;
147
+ if (Array.isArray(msg.content)) {
148
+ return msg.content.map((c) => (typeof c === 'string' ? c : c?.text || '')).join(' ');
149
+ }
150
+ return '';
151
+ }
152
+
153
+ function actionMentioned(text, action) {
154
+ if (!text) return false;
155
+ if (action.kind === 'commit' || action.kind === 'push' || action.kind === 'pr-mutation') {
156
+ return /\b(commit|push|merge|ship|pr|pull request)\b/.test(text);
157
+ }
158
+ if (action.kind === 'protected') {
159
+ const fname = action.target.split('/').pop().toLowerCase();
160
+ return text.includes(fname.toLowerCase()) || text.includes(action.target.toLowerCase());
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * Verify a transcript against the rule set. Returns a structured audit
167
+ * result. Side-effect free; the runtime entry point below writes the result
168
+ * to .cx/audit.jsonl.
169
+ */
170
+ export function verifyTranscript(entries, { windowSize = 20 } = {}) {
171
+ const findings = [];
172
+ const actions = findConsequentialActions(entries);
173
+
174
+ for (const action of actions) {
175
+ const window = lastN(entries.slice(0, action.atIndex), windowSize);
176
+ const verdict = classifyApproval(window, action);
177
+ findings.push({
178
+ action: action.kind,
179
+ target: action.target,
180
+ verdict,
181
+ atIndex: action.atIndex,
182
+ });
183
+ }
184
+
185
+ const failed = findings.some((f) => f.verdict === 'REFUSED');
186
+ const inconclusive = findings.some((f) => f.verdict === 'INCONCLUSIVE');
187
+ let ok;
188
+ if (failed) ok = 'fail';
189
+ else if (inconclusive) ok = 'inconclusive';
190
+ else ok = 'pass';
191
+
192
+ return { ok, findings, actionCount: actions.length };
193
+ }
194
+
195
+ function writeAudit(cwd, payload) {
196
+ const path = join(cwd, AUDIT_REL);
197
+ try { mkdirSync(dirname(path), { recursive: true }); } catch { /* ignore */ }
198
+ try { appendFileSync(path, JSON.stringify(payload) + '\n'); }
199
+ catch (err) { logHookFailure({ hook: 'rule-verifier', err, phase: 'append-audit' }); }
200
+ }
201
+
202
+ if (import.meta.url === `file://${process.argv[1]}`) {
203
+ let input = {};
204
+ try { input = JSON.parse(readFileSync(0, 'utf8')); }
205
+ catch (err) { logHookFailure({ hook: 'rule-verifier', err, phase: 'parse-stdin' }); process.exit(0); }
206
+
207
+ const cwd = input?.cwd || process.cwd();
208
+ const transcriptPath = input?.transcript_path;
209
+ if (!transcriptPath || !existsSync(transcriptPath)) process.exit(0);
210
+
211
+ const entries = safeReadJsonLines(transcriptPath);
212
+ const result = verifyTranscript(entries);
213
+ writeAudit(cwd, { ts: new Date().toISOString(), hook: 'rule-verifier', ...result });
214
+
215
+ if (result.ok === 'fail' && process.env.CONSTRUCT_RULE_VERIFIER === 'block') process.exit(2);
216
+ process.exit(0);
217
+ }
@@ -237,7 +237,8 @@ function main() {
237
237
  console.log(` • ${agent.name}: avgScore=${agent.avgScore}, invocations=${agent.invocations}`);
238
238
  });
239
239
 
240
- // Run optimization for each agent (serial for now)
240
+ // Sequential optimization keeps prompt-tuning deterministic and avoids
241
+ // colliding writes to per-agent prompt files.
241
242
  let optimizedCount = 0;
242
243
  let failedCount = 0;
243
244
 
@@ -161,7 +161,11 @@ try {
161
161
  }
162
162
  } catch { /* non-critical */ }
163
163
 
164
- let costNote = '';
164
+ // Pricing readout is intentionally suppressed in user-facing notifications.
165
+ // The session-cost ledger is still written (lines below) so OTel + dashboard
166
+ // consumers can pick the data up when the observability surfaces are wired.
167
+ // Do not reintroduce a costNote variable here without a deliberate decision
168
+ // about whether to surface pricing to the user.
165
169
  let totalCostUsd = 0;
166
170
  let turnsLogged = 0;
167
171
  try {
@@ -232,17 +236,14 @@ try {
232
236
 
233
237
  saveTranscriptCheckpoints(checkpointPath, checkpoints);
234
238
 
235
- if (totalCostUsd > 0) {
236
- const label = turnsLogged > 1 ? `${turnsLogged} turns` : 'this response';
237
- costNote = `~$${totalCostUsd.toFixed(2)} ${label}`;
238
- }
239
+ // totalCostUsd and turnsLogged stay as ledger-write accumulators only.
240
+ // Downstream telemetry consumers read the session-cost.jsonl ledger.
239
241
  } catch { /* non-critical */ }
240
242
 
241
243
  const parts = [];
242
244
  if (fileCount > 0) parts.push(`${fileCount} file${fileCount !== 1 ? 's' : ''} updated`);
243
245
  if (tsResult === 'pass') parts.push('TS OK');
244
246
  else if (tsResult !== 'unchecked') parts.push(`TS: ${tsResult}`);
245
- if (costNote) parts.push(costNote);
246
247
 
247
248
  const hasWarnings = warnings.length > 0;
248
249
  const title = hasWarnings ? 'Construct — Review needed' : 'Construct';
@@ -215,6 +215,10 @@ export function detectHostCapabilities() {
215
215
  ];
216
216
  }
217
217
 
218
+ export function formatHostCapabilitiesJson(hosts = detectHostCapabilities()) {
219
+ return JSON.stringify(hosts, null, 2) + '\n';
220
+ }
221
+
218
222
  export function printHostCapabilities(hosts = detectHostCapabilities()) {
219
223
  console.log("Construct orchestration host capabilities:");
220
224
  for (const host of hosts) {
@@ -229,5 +233,10 @@ export function printHostCapabilities(hosts = detectHostCapabilities()) {
229
233
  }
230
234
 
231
235
  if (import.meta.url === `file://${process.argv[1]}`) {
232
- printHostCapabilities();
236
+ const args = new Set(process.argv.slice(2));
237
+ if (args.has('--json')) {
238
+ process.stdout.write(formatHostCapabilitiesJson());
239
+ } else {
240
+ printHostCapabilities();
241
+ }
233
242
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * lib/hygiene/scan.mjs — Lightweight doc hygiene candidate scanner.
3
+ *
4
+ * Selects up to N (default 25) documents for hygiene review each run using:
5
+ * 1. lifecycle/approved tag + last_verified_at older than 30 days
6
+ * 2. lifecycle/draft tag + last_verified_at older than 7 days
7
+ * 3. Any document without a last_verified_at field
8
+ *
9
+ * Sorted oldest-first. Results are beads-item candidates — actual creation
10
+ * is handled by the scheduler job handler.
11
+ *
12
+ * Wired into the `doc-hygiene-scan` scheduler job (lib/scheduler/index.mjs).
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const APPROVED_STALE_DAYS = 30;
19
+ const DRAFT_STALE_DAYS = 7;
20
+ const DEFAULT_BATCH = 25;
21
+
22
+ /**
23
+ * Read YAML frontmatter from a markdown file. Returns {} on failure.
24
+ */
25
+ function readFrontmatter(filePath) {
26
+ let text = '';
27
+ try { text = fs.readFileSync(filePath, 'utf8'); } catch { return {}; }
28
+ const m = text.match(/^---\n([\s\S]*?)\n---/);
29
+ if (!m) return {};
30
+ const fm = {};
31
+ for (const line of m[1].split('\n')) {
32
+ const kv = line.match(/^(\w[\w_-]*):\s*(.+)$/);
33
+ if (kv) fm[kv[1].replace(/-/g, '_')] = kv[2].trim().replace(/^["']|["']$/g, '');
34
+ }
35
+ return fm;
36
+ }
37
+
38
+ /**
39
+ * Walk a directory and return all .md / .mdx files.
40
+ */
41
+ function walkMarkdown(dir, results = []) {
42
+ let entries;
43
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
44
+ for (const e of entries) {
45
+ const p = path.join(dir, e.name);
46
+ if (e.isDirectory()) walkMarkdown(p, results);
47
+ else if (e.name.endsWith('.md') || e.name.endsWith('.mdx')) results.push(p);
48
+ }
49
+ return results;
50
+ }
51
+
52
+ /**
53
+ * Return the N oldest hygiene candidates from the given scopes.
54
+ *
55
+ * @param {object} opts
56
+ * @param {string} opts.cwd — project root
57
+ * @param {string[]} [opts.scopes] — directories to scan relative to cwd
58
+ * @param {number} [opts.limit] — max candidates (default 25)
59
+ * @returns {Array<{path, rel, verdict, lastVerifiedAt, ageDays, tags}>}
60
+ */
61
+ export function findHygieneCandidates({ cwd = process.cwd(), scopes = ['docs/prd', 'docs/adr', 'docs/rfc', '.cx/knowledge'], limit = DEFAULT_BATCH } = {}) {
62
+ const now = Date.now();
63
+ const candidates = [];
64
+
65
+ for (const scope of scopes) {
66
+ const dir = path.join(cwd, scope);
67
+ if (!fs.existsSync(dir)) continue;
68
+ for (const filePath of walkMarkdown(dir)) {
69
+ const fm = readFrontmatter(filePath);
70
+ // Handle both inline YAML arrays `[a, b]` and comma-separated strings.
71
+ const rawTags = fm.tags ? String(fm.tags).replace(/^\s*\[|\]\s*$/g, '') : '';
72
+ const tags = rawTags ? rawTags.split(',').map((t) => t.trim().replace(/^["']|["']$/g, '')) : [];
73
+ const lva = fm.last_verified_at || null;
74
+ const ageDays = lva ? Math.floor((now - new Date(lva).getTime()) / 86400000) : null;
75
+
76
+ const lifecycle = tags.find((t) => t.startsWith('lifecycle/')) || null;
77
+
78
+ let include = false;
79
+ let reason = '';
80
+
81
+ if (!lva) {
82
+ include = true;
83
+ reason = 'no last_verified_at';
84
+ } else if (lifecycle === 'lifecycle/approved' && ageDays > APPROVED_STALE_DAYS) {
85
+ include = true;
86
+ reason = `approved and stale (${ageDays}d > ${APPROVED_STALE_DAYS}d)`;
87
+ } else if (lifecycle === 'lifecycle/draft' && ageDays > DRAFT_STALE_DAYS) {
88
+ include = true;
89
+ reason = `draft and stale (${ageDays}d > ${DRAFT_STALE_DAYS}d)`;
90
+ }
91
+
92
+ if (include) {
93
+ candidates.push({
94
+ path: filePath,
95
+ rel: path.relative(cwd, filePath),
96
+ reason,
97
+ lastVerifiedAt: lva,
98
+ ageDays,
99
+ tags,
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ // Sort: no last_verified_at first (oldest-first by path), then by age descending.
106
+ candidates.sort((a, b) => {
107
+ if (!a.lastVerifiedAt && !b.lastVerifiedAt) return a.rel.localeCompare(b.rel);
108
+ if (!a.lastVerifiedAt) return -1;
109
+ if (!b.lastVerifiedAt) return 1;
110
+ return (b.ageDays || 0) - (a.ageDays || 0);
111
+ });
112
+
113
+ return candidates.slice(0, limit);
114
+ }
115
+
116
+ /**
117
+ * Stamp last_verified_at into a document's frontmatter.
118
+ * If no frontmatter exists, prepends one.
119
+ */
120
+ export function stampVerified(filePath, { date = new Date().toISOString().slice(0, 10) } = {}) {
121
+ let text = '';
122
+ try { text = fs.readFileSync(filePath, 'utf8'); } catch {
123
+ throw new Error(`stampVerified: cannot read ${filePath}`);
124
+ }
125
+
126
+ const fmMatch = text.match(/^(---\n)([\s\S]*?)(\n---)/);
127
+ if (fmMatch) {
128
+ const fmBody = fmMatch[2];
129
+ if (fmBody.includes('last_verified_at:')) {
130
+ // Update existing field.
131
+ const updated = fmBody.replace(/^last_verified_at:.+$/m, `last_verified_at: ${date}`);
132
+ fs.writeFileSync(filePath, `${fmMatch[1]}${updated}${fmMatch[3]}${text.slice(fmMatch[0].length)}`);
133
+ } else {
134
+ // Append field inside frontmatter.
135
+ fs.writeFileSync(filePath, `${fmMatch[1]}${fmBody}\nlast_verified_at: ${date}${fmMatch[3]}${text.slice(fmMatch[0].length)}`);
136
+ }
137
+ } else {
138
+ // No frontmatter — prepend one.
139
+ fs.writeFileSync(filePath, `---\nlast_verified_at: ${date}\n---\n${text}`);
140
+ }
141
+ }
@@ -615,87 +615,76 @@ function discoverProjectDirs(targetPath) {
615
615
  return dirs.sort();
616
616
  }
617
617
 
618
- const INTAKE_DIR_PRESETS = [
619
- { value: 'src', label: 'src/', reason: 'Source code most projects put code here' },
620
- { value: 'lib', label: 'lib/', reason: 'Library code' },
621
- { value: 'packages', label: 'packages/', reason: 'Monorepo packages' },
622
- { value: 'services', label: 'services/', reason: 'Microservices or backend services' },
623
- { value: 'apps', label: 'apps/', reason: 'Monorepo apps' },
624
- { value: 'docs', label: 'docs/', reason: 'Documentation source' },
625
- { value: 'tests', label: 'tests/', reason: 'Test files' },
626
- { value: 'spec', label: 'spec/', reason: 'Specifications' },
627
- { value: 'infra', label: 'infra/', reason: 'Infrastructure code (Terraform, etc.)' },
628
- { value: 'config', label: 'config/', reason: 'Configuration files' },
629
- { value: 'scripts', label: 'scripts/', reason: 'Build/ops scripts' },
630
- { value: 'tools', label: 'tools/', reason: 'Internal tooling' },
631
- ];
618
+ // Extra inbox-style directories the user can opt into. These are NOT
619
+ // source-code, doc, or test directories: they're places where unsorted
620
+ // signals tend to land (downloads, scratch notes, exported research). The
621
+ // built-in `.cx/inbox/` and `docs/intake/` are always watched and not
622
+ // listed here.
623
+ //
624
+ // Historically this list included `src/`, `docs/`, `tests/`, etc., and
625
+ // `init --yes` auto-enabled every one that existed in the project. That
626
+ // turned every code change, doc edit, and test file into a synthetic
627
+ // "intake signal" and polluted the queue with hundreds of false positives.
628
+ // Watching project artifact directories is opt-in only; an empty list is
629
+ // the correct default.
630
+ const INTAKE_DIR_PRESETS = [];
632
631
 
633
632
  async function askIntakeCollection(targetPath, skipInteractive) {
633
+ // Non-interactive default: empty parentDirs. The built-in zones
634
+ // (.cx/inbox/ and docs/intake/ when present) handle every well-defined
635
+ // signal path. Users who want to watch additional directories opt in
636
+ // explicitly via `construct intake config set --add-dir=<path>`.
634
637
  if (skipInteractive) {
635
- // Non-interactive: auto-detect and suggest based on what exists
636
- const existingDirs = discoverProjectDirs(targetPath);
637
- const selected = INTAKE_DIR_PRESETS
638
- .filter(preset => existingDirs.includes(preset.value))
639
- .map(p => p.value);
640
-
641
- if (selected.length === 0) return null;
642
-
643
- console.log('');
644
- console.log('═══════════════════════════════════════════════════════════');
645
- console.log(' INTAKE COLLECTION');
646
- console.log('═══════════════════════════════════════════════════════════');
647
- console.log('');
648
- console.log(`Auto-detected ${selected.length} directory(ies) to watch for context:`);
649
- for (const d of selected) console.log(` • ${d}/`);
650
- console.log('');
651
- console.log('The inbox watcher will scan these directories for new files');
652
- console.log('and route them through R&D triage automatically.');
653
- console.log('');
654
-
655
- return { parentDirs: selected, maxDepth: 4 };
638
+ return null;
656
639
  }
657
-
658
- // Interactive mode
640
+
641
+ // Interactive mode: no presets to surface — the user must name the
642
+ // directory they want watched. Skipped entirely when the preset list
643
+ // is empty (the current default after the auto-include regression).
644
+ if (INTAKE_DIR_PRESETS.length === 0) {
645
+ return null;
646
+ }
647
+
659
648
  const existingDirs = discoverProjectDirs(targetPath);
660
649
  const presetOptions = INTAKE_DIR_PRESETS.map(p => ({
661
650
  label: p.label,
662
651
  value: p.value,
663
- checked: existingDirs.includes(p.value),
652
+ checked: false,
664
653
  description: p.reason,
665
654
  }));
666
-
655
+
667
656
  console.log('');
668
657
  console.log('═══════════════════════════════════════════════════════════');
669
658
  console.log(' INTAKE COLLECTION');
670
659
  console.log('═══════════════════════════════════════════════════════════');
671
660
  console.log('');
672
- console.log('Intake watches directories for new files and routes them');
673
- console.log('through R&D triage. Select which directories to watch.');
674
- console.log('(directories already found in your project are pre-checked)');
661
+ console.log('Intake watches `.cx/inbox/` and `docs/intake/` by default.');
662
+ console.log('Add extra directories ONLY if signals (customer notes, exports,');
663
+ console.log('PDFs, etc.) regularly land there. Do not select code or finished');
664
+ console.log('artifact directories like src/, docs/, or tests/.');
675
665
  console.log('');
676
-
666
+
667
+ void existingDirs;
677
668
  const selected = await multiSelect({
678
- title: 'Directories to Watch',
679
- instructions: 'Press Enter to confirm your selection',
669
+ title: 'Additional Inbox Directories',
670
+ instructions: 'Press Enter to confirm your selection (or pick none)',
680
671
  options: presetOptions,
681
672
  });
682
-
673
+
683
674
  if (selected.length === 0) {
684
675
  console.log('');
685
- console.log('No directories selected. The inbox watcher will only scan');
686
- console.log('.cx/inbox/ and docs/intake/ (built-in zones).');
687
- console.log('');
688
- console.log('You can always add more later with:');
689
- console.log(' construct intake config set --add-dir=src');
676
+ console.log('No extra directories selected. The inbox watcher will scan');
677
+ console.log('.cx/inbox/ and docs/intake/ only. Add more later with:');
678
+ console.log(' construct intake config set --add-dir=<path>');
690
679
  console.log('');
691
680
  return null;
692
681
  }
693
-
682
+
694
683
  console.log('');
695
- console.log(`Watching ${selected.length} directory(ies):`);
684
+ console.log(`Watching ${selected.length} extra directory(ies):`);
696
685
  for (const d of selected) console.log(` • ${d}/`);
697
686
  console.log('');
698
-
687
+
699
688
  return { parentDirs: selected, maxDepth: 4 };
700
689
  }
701
690
 
@@ -902,6 +891,24 @@ async function main() {
902
891
  if (!claudePre && fs.existsSync(path.join(target, '.claude', 'settings.json'))) {
903
892
  created.push('.claude/ (agents + settings)');
904
893
  }
894
+
895
+ // Also sync home-level adapters for all 6 host surfaces (Copilot, OpenCode,
896
+ // Codex, VS Code, Cursor, plus Claude home) so the user's environment is
897
+ // fully wired after init. Project-local Claude adapters were written above.
898
+ const syncScript = path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
899
+ if (fs.existsSync(syncScript)) {
900
+ const { spawnSync } = await import('node:child_process');
901
+ const homeResult = spawnSync(process.execPath, [syncScript], {
902
+ cwd: target,
903
+ stdio: 'pipe',
904
+ env: { ...process.env, CONSTRUCT_PROJECT_ROOT: target },
905
+ timeout: 120000,
906
+ });
907
+ if (homeResult.status !== 0) {
908
+ const errMsg = homeResult.stderr.toString().substring(0, 200);
909
+ console.warn(`⚠️ Home adapter sync had issues (exit ${homeResult.status}): ${errMsg}`);
910
+ }
911
+ }
905
912
  } catch (err) {
906
913
  console.warn(`⚠️ Adapter staging failed: ${err.message}`);
907
914
  }
@@ -949,18 +956,19 @@ async function main() {
949
956
  console.log('');
950
957
  }
951
958
 
952
- // Intake collection — ask which directories to watch for context
959
+ // Intake collection — always write intake-config.json with safe defaults
960
+ // so the user can inspect what's watched and edit the file directly. Empty
961
+ // parentDirs is the correct default: the inbox watcher always scans
962
+ // .cx/inbox/ and docs/intake/ (when present); extra dirs are opt-in.
953
963
  console.log('[TRACE init:intake-ask]');
954
-
955
- const intakeConfig = await askIntakeCollection(target, skipInteractive);
956
- if (intakeConfig) {
957
- const { saveIntakeConfig } = await import('./intake/intake-config.mjs');
958
- try {
959
- saveIntakeConfig(target, intakeConfig);
960
- created.push('.cx/intake-config.json');
961
- } catch (err) {
962
- console.warn(`⚠️ Could not write intake config: ${err.message}`);
963
- }
964
+
965
+ const intakeConfig = (await askIntakeCollection(target, skipInteractive)) ?? { parentDirs: [], maxDepth: 4 };
966
+ const { saveIntakeConfig } = await import('./intake/intake-config.mjs');
967
+ try {
968
+ saveIntakeConfig(target, intakeConfig);
969
+ created.push('.cx/intake-config.json');
970
+ } catch (err) {
971
+ console.warn(`⚠️ Could not write intake config: ${err.message}`);
964
972
  }
965
973
 
966
974
  // Ask about documentation system
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Same work as `bin/construct-postinstall.mjs` but callable from both the
5
5
  * npm postinstall hook and `construct init`. Stages `.construct/{run.mjs,
6
- * bootstrap.sh, bootstrap.ps1, version}` and runs `sync-agents.mjs --project`
6
+ * bootstrap.sh, bootstrap.ps1, version}` and runs `sync-specialists.mjs --project`
7
7
  * to materialise `.claude/agents/` and `.claude/settings.json` from the
8
8
  * bundled registry. Idempotent — re-runs overwrite the launcher files but
9
9
  * leave existing `.construct/version` and project state untouched beyond
10
- * what `sync-agents.mjs --project` writes itself.
10
+ * what `sync-specialists.mjs --project` writes itself.
11
11
  */
12
12
 
13
13
  import { spawnSync } from 'node:child_process';
@@ -20,13 +20,13 @@ export function stageProjectAdapters({ projectRoot, packageRoot, pkgVersion, log
20
20
  const emit = typeof log === 'function' ? log : () => {};
21
21
 
22
22
  const templateDir = path.join(packageRoot, 'templates', 'distribution');
23
- const syncScript = path.join(packageRoot, 'scripts', 'sync-agents.mjs');
23
+ const syncScript = path.join(packageRoot, 'scripts', 'sync-specialists.mjs');
24
24
 
25
25
  ensureProjectLauncher({ projectRoot, templateDir, pkgVersion });
26
26
  emit(`staged .construct/ launcher in ${projectRoot}`);
27
27
 
28
28
  if (!existsSync(syncScript)) {
29
- emit(`sync-agents.mjs not found at ${syncScript}; skipping adapter sync`);
29
+ emit(`sync-specialists.mjs not found at ${syncScript}; skipping adapter sync`);
30
30
  return { staged: true, synced: false };
31
31
  }
32
32