@geraldmaron/construct 1.4.0 → 1.4.2

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 (65) hide show
  1. package/bin/construct +2 -1
  2. package/bin/construct-postinstall.mjs +27 -2
  3. package/config/tag-vocabulary.json +264 -0
  4. package/lib/config/schema.mjs +1 -1
  5. package/lib/doctor/diagnosis.mjs +20 -0
  6. package/lib/doctor/index.mjs +5 -0
  7. package/lib/embed/worker.mjs +5 -0
  8. package/lib/env-config.mjs +10 -2
  9. package/lib/export-validate.mjs +34 -2
  10. package/lib/hooks/guard-bash.mjs +3 -1
  11. package/lib/host/readiness.mjs +109 -0
  12. package/lib/host-disposition.mjs +10 -1
  13. package/lib/install/stage-project.mjs +41 -4
  14. package/lib/intake/prepare.mjs +2 -0
  15. package/lib/mcp/destructive-approval.mjs +57 -0
  16. package/lib/mcp/server.mjs +209 -35
  17. package/lib/mcp/tool-rate-limit.mjs +47 -0
  18. package/lib/mcp/tool-safety.mjs +94 -0
  19. package/lib/mcp/tool-surface-parity.mjs +60 -0
  20. package/lib/mcp/tools/orchestration-run.mjs +45 -7
  21. package/lib/mcp/tools/project.mjs +25 -8
  22. package/lib/mcp/tools/storage.mjs +9 -1
  23. package/lib/mcp/tools/web-search-governance.mjs +96 -0
  24. package/lib/mcp/tools/web-search.mjs +6 -44
  25. package/lib/mcp-platform-config.mjs +27 -18
  26. package/lib/oracle/daemon-entry.mjs +6 -0
  27. package/lib/orchestration/runtime.mjs +81 -42
  28. package/lib/orchestration/web-capability.mjs +59 -0
  29. package/lib/orchestration/worker.mjs +263 -19
  30. package/lib/orchestration-policy.mjs +36 -0
  31. package/lib/output-quality.mjs +61 -2
  32. package/lib/path-policy.mjs +56 -0
  33. package/lib/providers/secret-audit-wiring.mjs +35 -18
  34. package/lib/providers/secret-resolver.mjs +28 -13
  35. package/lib/registry/catalog.mjs +6 -0
  36. package/lib/registry/loader.mjs +7 -1
  37. package/lib/registry/validate.mjs +3 -3
  38. package/lib/sandbox.mjs +1 -1
  39. package/lib/service-manager.mjs +59 -9
  40. package/lib/storage/admin.mjs +7 -2
  41. package/package.json +6 -1
  42. package/registry/agent-manifest.json +117 -0
  43. package/registry/capabilities.json +1880 -0
  44. package/schemas/brand-voice.schema.json +24 -0
  45. package/schemas/capability-registry.schema.json +72 -0
  46. package/schemas/certification-run.schema.json +130 -0
  47. package/schemas/demo-recording.schema.json +46 -0
  48. package/schemas/eval-dataset.schema.json +79 -0
  49. package/schemas/execution-capability-profile.schema.json +46 -0
  50. package/schemas/execution-policy.schema.json +114 -0
  51. package/schemas/improvement-proposal.schema.json +65 -0
  52. package/schemas/mcp-tool-output.schema.json +61 -0
  53. package/schemas/platform-capabilities.schema.json +83 -0
  54. package/schemas/project-config.schema.json +227 -0
  55. package/schemas/project-demo.schema.json +60 -0
  56. package/schemas/provider-behavior-matrix.schema.json +91 -0
  57. package/schemas/scope.schema.json +197 -0
  58. package/schemas/specialist-trace.schema.json +107 -0
  59. package/schemas/team.schema.json +99 -0
  60. package/schemas/unified-registry.schema.json +548 -0
  61. package/scripts/sync-specialists.mjs +135 -12
  62. package/specialists/org/specialists/cx-researcher.json +1 -0
  63. package/specialists/prompts/cx-researcher.md +9 -8
  64. package/vendor/pandoc-ext/README.md +3 -0
  65. package/vendor/pandoc-ext/diagram.lua +687 -0
package/bin/construct CHANGED
@@ -514,7 +514,8 @@ async function cmdDoctor() {
514
514
  const wantsFixMigrateState = wantsFixAll || args.includes('--fix-migrate-state');
515
515
 
516
516
  const checks = [];
517
- const add = (label, pass, optional = false) => checks.push({ label, pass, optional });
517
+ const { DOCTOR_STATES } = await import('../lib/doctor/diagnosis.mjs');
518
+ const add = (label, pass, optional = false, failureState = pass ? DOCTOR_STATES.healthy : null) => checks.push({ label, pass, optional, failureState });
518
519
  const { isConstructSourceCheckout } = await import('../lib/doctor/source-checkout.mjs');
519
520
  const isSourceCheckout = isConstructSourceCheckout(ROOT_DIR);
520
521
  add('specialists/org exists', fs.existsSync(path.join(ROOT_DIR, 'specialists', 'org')));
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { spawnSync } from 'node:child_process';
22
- import { existsSync, statSync, readFileSync } from 'node:fs';
22
+ import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
25
 
@@ -109,13 +109,19 @@ if (consumerPkg.name === '@geraldmaron/construct') {
109
109
  process.exit(0);
110
110
  }
111
111
 
112
+ // Track every project mutation for the itemized receipt written at the end.
113
+ // npm has no uninstall lifecycle hook so the manifest is the only machine-readable
114
+ // record of what the hook touched and how to revert it (CX-AUDIT-PACKAGE-004).
115
+ const mutations = [];
116
+
112
117
  try {
113
- stageProjectAdapters({
118
+ const stageResult = stageProjectAdapters({
114
119
  projectRoot: initCwd,
115
120
  packageRoot: PKG_ROOT,
116
121
  pkgVersion: PKG_VERSION,
117
122
  log,
118
123
  });
124
+ mutations.push({ path: '.construct', type: 'stage', synced: stageResult.synced });
119
125
 
120
126
  // ADR-0027: Ensure .gitignore covers the newly staged adapters (construct-f6l6).
121
127
  // Idempotent: missingIgnorePatterns returns only patterns not already present.
@@ -129,8 +135,27 @@ try {
129
135
  const block = `${prefix}\n${HEADER}\n${SUBHEADER}\n${missing.join('\n')}\n`;
130
136
  const { appendFileSync } = await import('node:fs');
131
137
  appendFileSync(giPath, block, 'utf8');
138
+ mutations.push({ path: '.gitignore', type: 'append', patterns: missing });
132
139
  log(`appended ${missing.length} Construct ignore pattern(s) to .gitignore`);
133
140
  }
141
+
142
+ // Write the itemized mutation receipt so consumers can audit and revert what the
143
+ // hook touched. Write is best-effort: a manifest failure must never break an install.
144
+ try {
145
+ mkdirSync(path.join(initCwd, '.construct'), { recursive: true });
146
+ const manifest = {
147
+ pkgVersion: PKG_VERSION,
148
+ ts: new Date().toISOString(),
149
+ files: mutations.map((m) => m.path),
150
+ mutations,
151
+ recovery: 'npx construct init --repair',
152
+ };
153
+ writeFileSync(
154
+ path.join(initCwd, '.construct', 'install-manifest.json'),
155
+ JSON.stringify(manifest, null, 2) + '\n',
156
+ );
157
+ log('wrote .construct/install-manifest.json');
158
+ } catch { /* manifest write must not fail the install */ }
134
159
  } catch (err) {
135
160
  fail(`Adapter staging failed: ${err.message}`, 'The package is installed; run `npx construct init` in this project to complete setup.');
136
161
  // Intentionally exit 0: staging is best-effort completion, and a non-zero exit
@@ -0,0 +1,264 @@
1
+ {
2
+ "version": 1,
3
+ "facets": {
4
+ "intake-type": { "exclusive": true, "auto_threshold": 0.80 },
5
+ "lifecycle": { "exclusive": true, "auto_threshold": 0.90 },
6
+ "priority": { "exclusive": true, "auto_threshold": 0.90 },
7
+ "signal-strength": { "exclusive": false, "auto_threshold": 0.95 },
8
+ "reversibility": { "exclusive": true, "auto_threshold": 0.85 },
9
+ "doctype": { "exclusive": true, "auto_threshold": 0.90 }
10
+ },
11
+ "tags": [
12
+ {
13
+ "id": "intake/bug",
14
+ "facet": "intake-type",
15
+ "label": "Bug",
16
+ "synonyms": ["defect", "regression", "error"],
17
+ "promote_to_graph": false,
18
+ "status": "active"
19
+ },
20
+ {
21
+ "id": "intake/user-signal",
22
+ "facet": "intake-type",
23
+ "label": "User Signal",
24
+ "synonyms": ["feedback", "user-feedback", "customer-signal"],
25
+ "promote_to_graph": false,
26
+ "status": "active"
27
+ },
28
+ {
29
+ "id": "intake/experiment",
30
+ "facet": "intake-type",
31
+ "label": "Experiment",
32
+ "synonyms": ["ab-test", "test", "trial"],
33
+ "promote_to_graph": false,
34
+ "status": "active"
35
+ },
36
+ {
37
+ "id": "intake/architecture",
38
+ "facet": "intake-type",
39
+ "label": "Architecture",
40
+ "synonyms": ["design", "system-design", "arch"],
41
+ "promote_to_graph": false,
42
+ "status": "active"
43
+ },
44
+ {
45
+ "id": "intake/incident",
46
+ "facet": "intake-type",
47
+ "label": "Incident",
48
+ "synonyms": ["outage", "postmortem", "production-issue"],
49
+ "promote_to_graph": false,
50
+ "status": "active"
51
+ },
52
+ {
53
+ "id": "intake/security",
54
+ "facet": "intake-type",
55
+ "label": "Security",
56
+ "synonyms": ["vulnerability", "cve", "threat"],
57
+ "promote_to_graph": false,
58
+ "status": "active"
59
+ },
60
+ {
61
+ "id": "intake/requirement",
62
+ "facet": "intake-type",
63
+ "label": "Requirement",
64
+ "synonyms": ["spec", "specification", "acceptance-criteria"],
65
+ "promote_to_graph": false,
66
+ "status": "active"
67
+ },
68
+ {
69
+ "id": "intake/research",
70
+ "facet": "intake-type",
71
+ "label": "Research",
72
+ "synonyms": ["study", "literature-review"],
73
+ "promote_to_graph": false,
74
+ "status": "active"
75
+ },
76
+ {
77
+ "id": "intake/ops",
78
+ "facet": "intake-type",
79
+ "label": "Ops",
80
+ "synonyms": ["operational", "runbook", "infra"],
81
+ "promote_to_graph": false,
82
+ "status": "active"
83
+ },
84
+ {
85
+ "id": "intake/eval-finding",
86
+ "facet": "intake-type",
87
+ "label": "Eval Finding",
88
+ "synonyms": ["evaluation", "eval", "benchmark-result"],
89
+ "promote_to_graph": false,
90
+ "status": "active"
91
+ },
92
+ {
93
+ "id": "intake/launch-asset",
94
+ "facet": "intake-type",
95
+ "label": "Launch Asset",
96
+ "synonyms": ["launch", "go-to-market", "release-material"],
97
+ "promote_to_graph": false,
98
+ "status": "active"
99
+ },
100
+ {
101
+ "id": "intake/legal-compliance",
102
+ "facet": "intake-type",
103
+ "label": "Legal Compliance",
104
+ "synonyms": ["legal", "compliance", "regulatory"],
105
+ "promote_to_graph": false,
106
+ "status": "active"
107
+ },
108
+ {
109
+ "id": "lifecycle/draft",
110
+ "facet": "lifecycle",
111
+ "label": "Draft",
112
+ "synonyms": ["wip", "in-progress"],
113
+ "promote_to_graph": false,
114
+ "status": "active"
115
+ },
116
+ {
117
+ "id": "lifecycle/approved",
118
+ "facet": "lifecycle",
119
+ "label": "Approved",
120
+ "synonyms": ["accepted", "ratified"],
121
+ "promote_to_graph": false,
122
+ "status": "active"
123
+ },
124
+ {
125
+ "id": "lifecycle/deprecated",
126
+ "facet": "lifecycle",
127
+ "label": "Deprecated",
128
+ "synonyms": ["sunset", "retiring"],
129
+ "promote_to_graph": false,
130
+ "status": "active"
131
+ },
132
+ {
133
+ "id": "lifecycle/superseded",
134
+ "facet": "lifecycle",
135
+ "label": "Superseded",
136
+ "synonyms": ["replaced", "obsolete"],
137
+ "promote_to_graph": false,
138
+ "status": "active"
139
+ },
140
+ {
141
+ "id": "lifecycle/archived",
142
+ "facet": "lifecycle",
143
+ "label": "Archived",
144
+ "synonyms": ["closed", "done"],
145
+ "promote_to_graph": false,
146
+ "status": "active"
147
+ },
148
+ {
149
+ "id": "priority/p0",
150
+ "facet": "priority",
151
+ "label": "P0 - Critical",
152
+ "synonyms": ["critical", "blocker", "sev0"],
153
+ "promote_to_graph": true,
154
+ "status": "active"
155
+ },
156
+ {
157
+ "id": "priority/p1",
158
+ "facet": "priority",
159
+ "label": "P1 - High",
160
+ "synonyms": ["high", "urgent", "sev1"],
161
+ "promote_to_graph": true,
162
+ "status": "active"
163
+ },
164
+ {
165
+ "id": "priority/p2",
166
+ "facet": "priority",
167
+ "label": "P2 - Medium",
168
+ "synonyms": ["medium", "normal", "sev2"],
169
+ "promote_to_graph": false,
170
+ "status": "active"
171
+ },
172
+ {
173
+ "id": "priority/p3",
174
+ "facet": "priority",
175
+ "label": "P3 - Low",
176
+ "synonyms": ["low", "nice-to-have", "sev3"],
177
+ "promote_to_graph": false,
178
+ "status": "active"
179
+ },
180
+ {
181
+ "id": "signal/high-customer-interest",
182
+ "facet": "signal-strength",
183
+ "label": "High Customer Interest",
184
+ "synonyms": ["customer-demand", "high-demand", "top-request"],
185
+ "promote_to_graph": true,
186
+ "anchor_entities": ["customer-feedback"],
187
+ "expected_cardinality": 100,
188
+ "cardinality_window_days": 90,
189
+ "status": "active"
190
+ },
191
+ {
192
+ "id": "signal/internal-alignment",
193
+ "facet": "signal-strength",
194
+ "label": "Internal Alignment",
195
+ "synonyms": ["aligned", "consensus", "cross-team"],
196
+ "promote_to_graph": false,
197
+ "status": "active"
198
+ },
199
+ {
200
+ "id": "signal/blocked",
201
+ "facet": "signal-strength",
202
+ "label": "Blocked",
203
+ "synonyms": ["dependency", "waiting", "stalled"],
204
+ "promote_to_graph": false,
205
+ "status": "active"
206
+ },
207
+ {
208
+ "id": "reversibility/irreversible",
209
+ "facet": "reversibility",
210
+ "label": "Irreversible",
211
+ "synonyms": ["one-way-door", "permanent"],
212
+ "promote_to_graph": true,
213
+ "status": "active"
214
+ },
215
+ {
216
+ "id": "reversibility/reversible",
217
+ "facet": "reversibility",
218
+ "label": "Reversible",
219
+ "synonyms": ["two-way-door", "rollback"],
220
+ "promote_to_graph": false,
221
+ "status": "active"
222
+ },
223
+ {
224
+ "id": "reversibility/quasi-reversible",
225
+ "facet": "reversibility",
226
+ "label": "Quasi-Reversible",
227
+ "synonyms": ["partially-reversible", "conditional-rollback"],
228
+ "promote_to_graph": false,
229
+ "status": "active"
230
+ },
231
+ {
232
+ "id": "doctype/prd",
233
+ "facet": "doctype",
234
+ "label": "PRD",
235
+ "synonyms": ["product-requirements", "product-spec"],
236
+ "promote_to_graph": false,
237
+ "status": "active"
238
+ },
239
+ {
240
+ "id": "doctype/adr",
241
+ "facet": "doctype",
242
+ "label": "ADR",
243
+ "synonyms": ["architecture-decision", "decision-record"],
244
+ "promote_to_graph": false,
245
+ "status": "active"
246
+ },
247
+ {
248
+ "id": "doctype/rfc",
249
+ "facet": "doctype",
250
+ "label": "RFC",
251
+ "synonyms": ["request-for-comments", "proposal"],
252
+ "promote_to_graph": false,
253
+ "status": "active"
254
+ },
255
+ {
256
+ "id": "doctype/postmortem",
257
+ "facet": "doctype",
258
+ "label": "Postmortem",
259
+ "synonyms": ["incident-review", "retrospective"],
260
+ "promote_to_graph": false,
261
+ "status": "active"
262
+ }
263
+ ]
264
+ }
@@ -156,7 +156,7 @@ export const FIELD_RULES = {
156
156
  types: { type: 'object', required: false },
157
157
  },
158
158
  },
159
- scope: { type: 'string', required: false, maxLength: 40 },
159
+ scope: { type: 'string', required: false, maxLength: 40, enum: ['rnd', 'operations', 'creative', 'research'] },
160
160
  autoEmbed: { type: 'boolean', required: false },
161
161
  ingest: {
162
162
  type: 'object',
@@ -0,0 +1,20 @@
1
+ /**
2
+ * lib/doctor/diagnosis.mjs — typed failure-state taxonomy for the doctor command.
3
+ *
4
+ * Each constant names a distinct class of host / package / MCP / runtime / secrets
5
+ * failure so doctor findings carry an actionable code rather than a bare boolean.
6
+ * Consumers (bin/construct cmdDoctor, lib/host/readiness.mjs, tests) import these
7
+ * keys to ensure the full taxonomy is covered and no failure collapses to a generic
8
+ * "unhealthy" verdict.
9
+ */
10
+
11
+ export const DOCTOR_STATES = {
12
+ 'missing-config': 'A required configuration file or directory is absent.',
13
+ 'stale-path': 'A registered path (toolkit, entrypoint, server) is outdated or points nowhere.',
14
+ 'disabled': 'A feature or gate is explicitly disabled in config.',
15
+ 'server-start-failure': 'The MCP or dashboard server failed to start or did not respond within the timeout.',
16
+ 'secret-leak': 'A credential or secret is exposed in a tracked file or insecure location.',
17
+ 'entrypoint-missing': 'A binary or script the runtime expects (construct-mcp, construct) is not found on PATH.',
18
+ 'healthy': 'The check passed with no issues detected.',
19
+ 'degraded': 'The check passed with warnings; functionality may be reduced.',
20
+ };
@@ -15,6 +15,7 @@ import { writeFileSync, existsSync, readFileSync, unlinkSync, mkdirSync } from '
15
15
  import { join } from 'node:path';
16
16
 
17
17
  import { record } from './audit.mjs';
18
+ import { enableSecretAuditTrail } from '../providers/secret-audit-wiring.mjs';
18
19
  import * as disk from './watchers/disk.mjs';
19
20
  import * as cost from './watchers/cost.mjs';
20
21
  import * as processPressure from './watchers/process-pressure.mjs';
@@ -140,5 +141,9 @@ const isMain = (() => {
140
141
  try { return import.meta.url === `file://${process.argv[1]}`; } catch { return false; }
141
142
  })();
142
143
  if (isMain) {
144
+ // A long-lived daemon in its own process: wire the audit sink at the entry so any
145
+ // credential resolution a watcher performs is recorded on the shared trail rather
146
+ // than escaping it, matching the CLI entrypoint's own wiring.
147
+ enableSecretAuditTrail();
143
148
  await start();
144
149
  }
@@ -9,6 +9,7 @@
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { prepareConstructEnv } from '../runtime-env.mjs';
12
+ import { enableSecretAuditTrail } from '../providers/secret-audit-wiring.mjs';
12
13
  import { EmbedDaemon } from './daemon.mjs';
13
14
 
14
15
  function parseArgv(argv) {
@@ -18,6 +19,10 @@ function parseArgv(argv) {
18
19
  return null;
19
20
  }
20
21
 
22
+ // Snapshot ticks resolve provider credentials in the daemon's own process; wiring
23
+ // the sink here records those op reads on the same trail as the CLI entrypoint.
24
+ enableSecretAuditTrail();
25
+
21
26
  const configPath = parseArgv(process.argv.slice(2));
22
27
  const env = prepareConstructEnv();
23
28
 
@@ -52,7 +52,15 @@ export function writeEnvValues(filePath, values = {}) {
52
52
  .filter(([, value]) => value !== undefined && value !== null && value !== '')
53
53
  .map(([key, value]) => `${key}=${value}`)
54
54
  .join('\n');
55
- fs.writeFileSync(filePath, `${content}\n`, 'utf8');
55
+ fs.writeFileSync(filePath, `${content}\n`, { encoding: 'utf8', mode: 0o600 });
56
+
57
+ // config.env can hold plaintext credentials. writeFileSync keeps an existing inode's
58
+ // mode, so the mode option does not tighten a file an earlier write (or a caller that
59
+ // skipped its own chmod) left 0644; re-apply 0600 on every write so the credential
60
+ // file is never group/world readable.
61
+ if (process.platform !== 'win32') {
62
+ try { fs.chmodSync(filePath, 0o600); } catch { /* exotic filesystem */ }
63
+ }
56
64
  }
57
65
 
58
66
  function cleanEnvValue(value) {
@@ -103,7 +111,7 @@ function isOpReference(value) {
103
111
  return Boolean(extractOpRef(value));
104
112
  }
105
113
 
106
- function preferInjectedCredential(envValue, fileValue) {
114
+ export function preferInjectedCredential(envValue, fileValue) {
107
115
  return Boolean(
108
116
  envValue
109
117
  && !isOpReference(envValue)
@@ -86,21 +86,53 @@ export function contentRoundtrip({ exportPath, format, sourceMarkdown, env = pro
86
86
  }
87
87
 
88
88
  // Local image and link targets must resolve on disk; remote URLs, anchors, and mailto are out of
89
- // scope for an on-disk integrity check.
89
+ // scope for an on-disk integrity check. Three reference forms in source are covered: ![]() syntax,
90
+ // raw inline <img src> HTML, and reference-style definitions ([id]: ./path). The produced HTML
91
+ // file's own <img src> references are also scanned, catching assets referenced only in the export.
90
92
 
91
- export function referenceIntegrity(sourceMarkdown, baseDir) {
93
+ export function referenceIntegrity(sourceMarkdown, baseDir, exportPath = null) {
92
94
  const body = sourceMarkdown.replace(/```[\s\S]*?```/g, '');
93
95
  const missingImages = [];
94
96
  const brokenLinks = [];
97
+
98
+ // Standard Markdown image syntax: ![alt](path)
95
99
  for (const match of body.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
96
100
  const ref = match[1].split(/\s+/)[0];
97
101
  if (/^https?:|^data:/.test(ref)) continue;
98
102
  if (!fs.existsSync(path.resolve(baseDir, ref))) missingImages.push(ref);
99
103
  }
104
+
105
+ // Raw inline HTML <img src> in source markdown
106
+ for (const match of body.matchAll(/<img[^>]+src\s*=\s*["']([^"']+)["']/gi)) {
107
+ const ref = match[1];
108
+ if (/^https?:|^data:|^#/.test(ref)) continue;
109
+ if (!fs.existsSync(path.resolve(baseDir, ref))) missingImages.push(ref);
110
+ }
111
+
112
+ // Reference-style image/link definitions: [id]: ./path
113
+ for (const match of body.matchAll(/^\[[^\]]+\]:\s+(\S+)/gm)) {
114
+ const ref = match[1];
115
+ if (/^https?:|^#|^mailto:/.test(ref)) continue;
116
+ if (!fs.existsSync(path.resolve(baseDir, ref))) missingImages.push(ref);
117
+ }
118
+
119
+ // Standard Markdown link syntax: [text](path) (non-image)
100
120
  for (const match of body.matchAll(/(?<!!)\[[^\]]*\]\(([^)]+)\)/g)) {
101
121
  const ref = match[1].split(/\s+/)[0];
102
122
  if (/^https?:|^#|^mailto:/.test(ref)) continue;
103
123
  if (!fs.existsSync(path.resolve(baseDir, ref))) brokenLinks.push(ref);
104
124
  }
125
+
126
+ // Produced HTML file: scan internal <img src> references that may not appear in source markdown
127
+ if (exportPath && exportPath.endsWith('.html') && fs.existsSync(exportPath)) {
128
+ let html = '';
129
+ try { html = fs.readFileSync(exportPath, 'utf8'); } catch { /* skip */ }
130
+ for (const match of html.matchAll(/<img[^>]+src\s*=\s*["']([^"']+)["']/gi)) {
131
+ const ref = match[1];
132
+ if (/^https?:|^data:|^#/.test(ref)) continue;
133
+ if (!fs.existsSync(path.resolve(baseDir, ref))) missingImages.push(ref);
134
+ }
135
+ }
136
+
105
137
  return { ok: missingImages.length === 0 && brokenLinks.length === 0, missingImages, brokenLinks };
106
138
  }
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * Runs as PreToolUse on Bash. Scans the command against a blocklist of destructive patterns (rm -rf, force push to main, etc.) and exits 2 to block matches.
6
6
  *
7
- * @p95ms 5
7
+ * Budget covers the role-fence path below — doctorRoot lookup, last-agent file reads, and the lazy manifest/fence/approval imports — which the original 5ms target predated; the nightly bench (variance-heavy CI lane) measured ~17-19ms marginal, so the budget reflects that real cost with the ×2 gate tolerance still catching a true regression.
8
+ *
9
+ * @p95ms 15
8
10
  * @maxBlockingScope PreToolUse
9
11
  *
10
12
  * @lifecycle PreToolUse
@@ -0,0 +1,109 @@
1
+ /**
2
+ * lib/host/readiness.mjs — host-config readiness classifier for VS Code / Copilot.
3
+ *
4
+ * Distinguishes the discrete states a host-config can be in — from missing config
5
+ * through various partial-setup failure modes to healthy — rather than collapsing
6
+ * readiness to a binary file-presence check. Each state has an associated next-step
7
+ * hint surfaced by `construct doctor` to guide the developer toward a working setup.
8
+ *
9
+ * classifyHostReadiness({ host, settingsPath, mcpPath, root }) returns a reasonCode
10
+ * from HOST_READINESS_REASONS. Resolution order: missing_config → stale_path →
11
+ * jsonc_unpatched → wrong_key → disabled → healthy (runtime states untrusted /
12
+ * server_start_failure / missing_tool / sandbox_disabled require a live host session
13
+ * and are returned when the caller supplies runtimeState evidence).
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ export const HOST_READINESS_REASONS = Object.freeze([
20
+ 'missing_config',
21
+ 'stale_path',
22
+ 'jsonc_unpatched',
23
+ 'wrong_key',
24
+ 'untrusted',
25
+ 'disabled',
26
+ 'server_start_failure',
27
+ 'missing_tool',
28
+ 'sandbox_disabled',
29
+ 'healthy',
30
+ ]);
31
+
32
+ export const HOST_READINESS_NEXT_STEPS = Object.freeze({
33
+ missing_config: 'Run `construct sync` to generate host config files.',
34
+ stale_path: 'Run `construct sync` to refresh the MCP server path.',
35
+ jsonc_unpatched: 'Run `construct sync` to merge managed settings into your settings.json.',
36
+ wrong_key: 'Run `construct sync` to correct the MCP autostart setting key.',
37
+ untrusted: 'Open VS Code, find the MCP server notification, and grant trust.',
38
+ disabled: 'Set `chat.mcp.autoStart: "always"` in your VS Code settings.',
39
+ server_start_failure: 'Check the MCP server log: `construct mcp:logs`.',
40
+ missing_tool: 'Run `construct sync` and restart VS Code.',
41
+ sandbox_disabled: 'Enable the MCP sandbox in VS Code extension settings.',
42
+ healthy: null,
43
+ });
44
+
45
+ const MANAGED_MCP_KEY_PATTERN = /\/lib\/mcp\/[a-z0-9-]+\.mjs$/i;
46
+ const AUTOSTART_KEY = 'chat.mcp.autoStart';
47
+ const AUTOSTART_KEY_WRONG_CASE_PATTERN = /^chat\.mcp\.autostart$/i;
48
+
49
+ function tryReadJson(filePath) {
50
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
51
+ catch { return null; }
52
+ }
53
+
54
+ function isJsoncFile(filePath) {
55
+ try {
56
+ const text = fs.readFileSync(filePath, 'utf8');
57
+ JSON.parse(text);
58
+ return false;
59
+ } catch {
60
+ return true;
61
+ }
62
+ }
63
+
64
+ export function classifyHostReadiness({
65
+ host = 'vscode',
66
+ settingsPath = null,
67
+ mcpPath = null,
68
+ root = null,
69
+ runtimeState = null,
70
+ } = {}) {
71
+ if (mcpPath && !fs.existsSync(mcpPath)) return 'missing_config';
72
+
73
+ if (mcpPath && root) {
74
+ const mcp = tryReadJson(mcpPath);
75
+ if (mcp) {
76
+ const servers = mcp.servers ?? {};
77
+ const hasStale = Object.values(servers).some((s) => {
78
+ const args = Array.isArray(s?.args) ? s.args : [];
79
+ return args.some((arg) => {
80
+ if (typeof arg !== 'string') return false;
81
+ const normalArg = arg.replace(/\\/g, '/');
82
+ const normalRoot = root.replace(/\\/g, '/');
83
+ return MANAGED_MCP_KEY_PATTERN.test(normalArg) && !normalArg.startsWith(`${normalRoot}/`);
84
+ });
85
+ });
86
+ if (hasStale) return 'stale_path';
87
+ }
88
+ }
89
+
90
+ if (settingsPath) {
91
+ if (fs.existsSync(settingsPath)) {
92
+ if (isJsoncFile(settingsPath)) return 'jsonc_unpatched';
93
+ const settings = tryReadJson(settingsPath);
94
+ if (settings) {
95
+ const hasWrongCase = Object.keys(settings).some(
96
+ (k) => k !== AUTOSTART_KEY && AUTOSTART_KEY_WRONG_CASE_PATTERN.test(k),
97
+ );
98
+ if (hasWrongCase) return 'wrong_key';
99
+ if (settings[AUTOSTART_KEY] === 'never' || settings[AUTOSTART_KEY] === false) {
100
+ return 'disabled';
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ if (runtimeState) return runtimeState;
107
+
108
+ return 'healthy';
109
+ }
@@ -41,13 +41,22 @@ export const IGNORED_PATTERNS = [
41
41
  '.construct/',
42
42
  '.mcp.json',
43
43
  '.github/prompts/',
44
- '.github/copilot-instructions.md',
44
+ '.github/agents/',
45
45
  'config.env',
46
46
  'embed.yaml',
47
47
  'plan.md',
48
48
  'inbox/',
49
49
  ];
50
50
 
51
+ // User-owned files Construct mutates only via replaceManagedBlock (ADR-0027 §2).
52
+ // These are durable repo source, not ignored cache — they must NOT appear in
53
+ // IGNORED_PATTERNS, but they still need an explicit disposition so consumers
54
+ // know not to gitignore or delete them on uninstall.
55
+
56
+ export const MANAGED_PATTERNS = [
57
+ '.github/copilot-instructions.md',
58
+ ];
59
+
51
60
  // A pattern counts as already-ignored when an existing line matches it exactly,
52
61
  // matches its bare/slashed form, or is a broader `*` / `**` catch-all.
53
62