@geraldmaron/construct 1.2.0 → 1.2.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 (214) hide show
  1. package/README.md +8 -7
  2. package/bin/construct +206 -62
  3. package/commands/plan/decide.md +1 -1
  4. package/commands/remember/runbook.md +1 -1
  5. package/examples/README.md +1 -1
  6. package/examples/distribution/README.md +1 -1
  7. package/examples/distribution/sources/adr.md +2 -2
  8. package/examples/distribution/sources/deck-one-pager.md +1 -1
  9. package/examples/distribution/sources/prd-platform.md +1 -1
  10. package/examples/distribution/sources/research-brief.md +2 -2
  11. package/examples/distribution/sources/rfc-platform.md +1 -1
  12. package/examples/distribution/sources/runbook.md +2 -2
  13. package/examples/distribution/sources/strategy.md +1 -1
  14. package/lib/agent-instructions/inject.mjs +1 -1
  15. package/lib/artifact-manifest.mjs +150 -4
  16. package/lib/artifact-type-from-path.mjs +9 -5
  17. package/lib/artifact-workflow.mjs +252 -0
  18. package/lib/audit-specialists.mjs +1 -1
  19. package/lib/audit-trail.mjs +3 -3
  20. package/lib/auto-docs.mjs +13 -11
  21. package/lib/bootstrap/built-ins.mjs +3 -3
  22. package/lib/boundary.mjs +3 -2
  23. package/lib/brand-prose.mjs +1 -1
  24. package/lib/cache-governor.js +4 -4
  25. package/lib/certification/document-io-fixtures.mjs +1 -1
  26. package/lib/chat/config.mjs +2 -2
  27. package/lib/chat/desktop-binary.mjs +5 -3
  28. package/lib/chat/present.mjs +1 -0
  29. package/lib/cli-commands.mjs +23 -6
  30. package/lib/cli-service-inventory.mjs +42 -0
  31. package/lib/comment-lint.mjs +9 -5
  32. package/lib/config/alias.mjs +3 -2
  33. package/lib/config/intake-policy.mjs +139 -0
  34. package/lib/config/project-config.mjs +106 -7
  35. package/lib/config/schema.mjs +45 -6
  36. package/lib/config/source-targets.mjs +311 -0
  37. package/lib/config/xdg.mjs +63 -0
  38. package/lib/contracts/violation-log.mjs +2 -2
  39. package/lib/cost-ledger.mjs +4 -3
  40. package/lib/cost.mjs +3 -2
  41. package/lib/decisions/registry.mjs +3 -3
  42. package/lib/distill.mjs +2 -2
  43. package/lib/docs-verify.mjs +2 -0
  44. package/lib/doctor/audit.mjs +4 -3
  45. package/lib/doctor/cli.mjs +0 -1
  46. package/lib/doctor/index.mjs +4 -4
  47. package/lib/doctor/report.mjs +3 -3
  48. package/lib/doctor/source-checkout.mjs +16 -0
  49. package/lib/doctor/watchers/bd-watch.mjs +5 -5
  50. package/lib/doctor/watchers/cost.mjs +4 -4
  51. package/lib/doctor/watchers/disk.mjs +4 -3
  52. package/lib/doctor/watchers/service-health.mjs +5 -5
  53. package/lib/document-export.mjs +39 -17
  54. package/lib/document-extract.mjs +3 -3
  55. package/lib/document-ingest.mjs +13 -3
  56. package/lib/efficiency.mjs +2 -1
  57. package/lib/embed/approval-queue.mjs +3 -2
  58. package/lib/embed/auto-sources.mjs +44 -0
  59. package/lib/embed/cli.mjs +9 -6
  60. package/lib/embed/config.mjs +6 -5
  61. package/lib/embed/daemon.mjs +6 -5
  62. package/lib/embed/demand-fetch.mjs +50 -19
  63. package/lib/embed/inbox-live-watcher.mjs +1 -1
  64. package/lib/embed/inbox.mjs +52 -41
  65. package/lib/embed/intake-metrics.mjs +3 -2
  66. package/lib/embed/reconcile.mjs +1 -1
  67. package/lib/embed/semantic.mjs +3 -2
  68. package/lib/embed/supervision.mjs +4 -2
  69. package/lib/embed/worker.mjs +2 -1
  70. package/lib/embedded-contract/index.mjs +11 -0
  71. package/lib/engine/registry.mjs +3 -3
  72. package/lib/env-config.mjs +34 -12
  73. package/lib/export-branding.mjs +32 -0
  74. package/lib/features.mjs +4 -3
  75. package/lib/health-check.mjs +7 -6
  76. package/lib/hook-health.mjs +5 -4
  77. package/lib/hooks/_lib/log.mjs +4 -3
  78. package/lib/hooks/_lib/output-mode.mjs +2 -1
  79. package/lib/hooks/agent-tracker.mjs +3 -4
  80. package/lib/hooks/audit-reads.mjs +2 -2
  81. package/lib/hooks/audit-trail.mjs +2 -2
  82. package/lib/hooks/bash-output-logger.mjs +3 -3
  83. package/lib/hooks/brand-prose-lint.mjs +1 -1
  84. package/lib/hooks/ci-status-check.mjs +3 -2
  85. package/lib/hooks/context-watch.mjs +2 -2
  86. package/lib/hooks/context-window-recovery.mjs +5 -5
  87. package/lib/hooks/dep-audit.mjs +2 -2
  88. package/lib/hooks/doc-coupling-check.mjs +3 -2
  89. package/lib/hooks/edit-accumulator.mjs +3 -2
  90. package/lib/hooks/edit-guard.mjs +3 -3
  91. package/lib/hooks/guard-bash.mjs +2 -2
  92. package/lib/hooks/model-fallback.mjs +2 -1
  93. package/lib/hooks/policy-engine.mjs +4 -3
  94. package/lib/hooks/pre-compact.mjs +7 -7
  95. package/lib/hooks/readme-age-check.mjs +2 -2
  96. package/lib/hooks/session-optimize.mjs +4 -3
  97. package/lib/hooks/session-start.mjs +20 -16
  98. package/lib/hooks/stop-notify.mjs +9 -8
  99. package/lib/hooks/stop-typecheck.mjs +4 -3
  100. package/lib/hooks/test-watch.mjs +2 -2
  101. package/lib/host-disposition.mjs +1 -1
  102. package/lib/ingest/provider-extract.mjs +2 -1
  103. package/lib/init/detect-existing-structure.mjs +2 -2
  104. package/lib/init/doc-lanes.mjs +3 -10
  105. package/lib/init-docs.mjs +12 -54
  106. package/lib/init-unified.mjs +47 -92
  107. package/lib/init.mjs +7 -5
  108. package/lib/install/desktop-binary-download.mjs +5 -2
  109. package/lib/intake/constants.mjs +29 -0
  110. package/lib/intake/daemon.mjs +31 -8
  111. package/lib/intake/intake-config.mjs +32 -112
  112. package/lib/integrations/intake-integrations.mjs +8 -7
  113. package/lib/knowledge/rag.mjs +2 -2
  114. package/lib/knowledge/search.mjs +15 -15
  115. package/lib/maintenance/cleanup.mjs +14 -10
  116. package/lib/mcp/server.mjs +20 -2
  117. package/lib/mcp/tools/embedded-contract.mjs +14 -0
  118. package/lib/mcp/tools/project.mjs +2 -1
  119. package/lib/model-cheapest-provider.mjs +4 -3
  120. package/lib/model-pricing.mjs +3 -2
  121. package/lib/model-router.mjs +4 -3
  122. package/lib/models/catalog.mjs +2 -1
  123. package/lib/models/execution-capability-profile.mjs +2 -1
  124. package/lib/models/provider-poll.mjs +2 -1
  125. package/lib/ollama-manager.mjs +0 -1
  126. package/lib/op-log.mjs +2 -1
  127. package/lib/opencode-runtime-plugin.mjs +3 -2
  128. package/lib/oracle/artifact-gate.mjs +3 -0
  129. package/lib/oracle/cli.mjs +2 -1
  130. package/lib/oracle/execute.mjs +3 -2
  131. package/lib/oracle/index.mjs +2 -1
  132. package/lib/oracle/read-model.mjs +3 -2
  133. package/lib/performance/generate.mjs +4 -3
  134. package/lib/platforms/capabilities.mjs +1 -1
  135. package/lib/playwright-demo.mjs +2 -0
  136. package/lib/plugin-registry.mjs +2 -1
  137. package/lib/profiles/lifecycle.mjs +3 -3
  138. package/lib/project-profile.mjs +2 -1
  139. package/lib/project-root.mjs +9 -7
  140. package/lib/provider-capabilities.js +4 -3
  141. package/lib/providers/auth-manager.mjs +2 -1
  142. package/lib/providers/copilot-auth.mjs +3 -2
  143. package/lib/providers/creds.mjs +3 -2
  144. package/lib/providers/registry.mjs +3 -3
  145. package/lib/providers/secret-resolver.mjs +3 -2
  146. package/lib/read-tracker-store.mjs +2 -1
  147. package/lib/reconcile/mcp-entry-reconcile.mjs +2 -2
  148. package/lib/reflect.mjs +2 -1
  149. package/lib/registry/generate-docs.mjs +2 -2
  150. package/lib/roles/approval-surface.mjs +5 -4
  151. package/lib/roles/event-bus.mjs +0 -1
  152. package/lib/roles/gateway.mjs +6 -2
  153. package/lib/rules-delivery.mjs +1 -1
  154. package/lib/runtime-pressure.mjs +5 -3
  155. package/lib/sandbox.mjs +3 -2
  156. package/lib/scheduler/solo.mjs +6 -4
  157. package/lib/server/auth.mjs +4 -3
  158. package/lib/server/index.mjs +53 -26
  159. package/lib/server/insights.mjs +5 -4
  160. package/lib/server/rate-limit.mjs +5 -3
  161. package/lib/server/webhook.mjs +5 -3
  162. package/lib/service-manager.mjs +5 -3
  163. package/lib/setup.mjs +9 -8
  164. package/lib/status.mjs +6 -5
  165. package/lib/storage/embeddings-local.mjs +3 -2
  166. package/lib/storage/sync.mjs +2 -2
  167. package/lib/telemetry/client.mjs +0 -1
  168. package/lib/telemetry/intent-verifications.mjs +6 -6
  169. package/lib/telemetry/model-pricing-catalog.mjs +4 -3
  170. package/lib/telemetry/rule-calls.mjs +3 -3
  171. package/lib/telemetry/skill-calls.mjs +3 -3
  172. package/lib/template-registry.mjs +1 -0
  173. package/lib/templates/visual-requirements.mjs +1 -1
  174. package/lib/test-corpus-inventory.mjs +1 -1
  175. package/lib/uninstall/uninstall.mjs +12 -10
  176. package/package.json +3 -3
  177. package/platforms/claude/settings.template.json +43 -43
  178. package/rules/common/no-fabrication.md +1 -1
  179. package/rules/common/release-gates.md +1 -1
  180. package/rules/common/research.md +1 -1
  181. package/rules/common/review-before-change.md +1 -1
  182. package/scripts/sync-specialists.mjs +11 -8
  183. package/skills/docs/adr-workflow.md +3 -3
  184. package/skills/docs/init-docs.md +9 -9
  185. package/skills/docs/prd-workflow.md +5 -5
  186. package/skills/docs/product-intelligence-review.md +1 -1
  187. package/skills/docs/product-intelligence-workflow.md +2 -2
  188. package/skills/docs/product-signal-workflow.md +1 -1
  189. package/skills/docs/runbook-workflow.md +4 -4
  190. package/skills/exploration/unknown-codebase-onboarding.md +1 -1
  191. package/skills/operating/orchestration-reference.md +1 -1
  192. package/skills/routing.md +3 -3
  193. package/specialists/artifact-manifest.json +11 -2
  194. package/specialists/artifact-manifest.schema.json +31 -1
  195. package/specialists/prompts/cx-architect.md +1 -1
  196. package/specialists/prompts/cx-docs-keeper.md +1 -1
  197. package/specialists/prompts/cx-researcher.md +1 -1
  198. package/specialists/prompts/cx-sre.md +1 -1
  199. package/specialists/role-manifests.json +6 -6
  200. package/templates/distribution/construct-reference.docx +0 -0
  201. package/templates/docs/README.md +125 -0
  202. package/templates/docs/construct_guide.md +3 -4
  203. package/templates/docs/persona-artifact.md +1 -1
  204. package/templates/docs/prds/README.md +25 -0
  205. package/templates/docs/prds/templates/_template.md +206 -0
  206. package/templates/docs/prds/templates/meta-prd.template.md +177 -0
  207. package/templates/docs/prds/templates/prd-business.template.md +61 -0
  208. package/templates/docs/prds/templates/prd-platform.template.md +81 -0
  209. package/templates/docs/prds/templates/prfaq.template.md +38 -0
  210. package/templates/docs/rfcs/README.md +22 -0
  211. package/templates/docs/rfcs/templates/_template.md +58 -0
  212. package/templates/docs/rfcs/templates/rfc-platform.template.md +72 -0
  213. package/templates/homebrew/construct.rb +1 -1
  214. package/templates/workflows/new-feature.yml +8 -8
@@ -8,15 +8,23 @@
8
8
  * corresponding env-var value at load time. Pointers keep API keys in
9
9
  * `.env` where they belong; the JSON config never sees a secret literal.
10
10
  *
11
- * Precedence rule for consumers: env var if set > config.json > default.
12
- * `resolveSetting(config, jsonPath, env, envKey, default)` encodes it
13
- * once so call sites stay consistent.
11
+ * Two project tiers, most-specific wins: a gitignored
12
+ * `construct.config.local.json` sibling merges OVER the committed
13
+ * `construct.config.json`. List settings (sources.targets, intake
14
+ * additionalDirs, permission allowlists) merge + dedupe; scalars and objects
15
+ * override key-by-key.
16
+ *
17
+ * Precedence rule for consumers: env var if set > config (local ▷ committed)
18
+ * > default. `resolveSetting(config, jsonPath, env, envKey, default)` encodes
19
+ * the rule once so call sites stay consistent.
14
20
  */
15
21
  import fs from 'node:fs';
16
22
  import path from 'node:path';
17
23
  import { CONFIG_SCHEMA_VERSION, DEFAULT_PROJECT_CONFIG, validateProjectConfig, FIELD_RULES } from './schema.mjs';
24
+ import { validateSourceTargets } from './source-targets.mjs';
18
25
 
19
26
  export const PROJECT_CONFIG_FILENAME = 'construct.config.json';
27
+ export const PROJECT_LOCAL_CONFIG_FILENAME = 'construct.config.local.json';
20
28
 
21
29
  export function findProjectConfigPath(cwd = process.cwd()) {
22
30
  let dir = path.resolve(cwd);
@@ -61,6 +69,64 @@ function deepMerge(base, override) {
61
69
  return out;
62
70
  }
63
71
 
72
+ // Identity for list-tier dedupe: object entries collapse on their `id`
73
+ // (source targets, typed selectors) so a local override that re-declares an id
74
+ // replaces the committed entry rather than duplicating it; everything else
75
+ // dedupes on structural equality.
76
+
77
+ function listKey(item) {
78
+ if (item !== null && typeof item === 'object' && !Array.isArray(item) && typeof item.id === 'string') {
79
+ return `id:${item.id}`;
80
+ }
81
+ return `json:${JSON.stringify(item)}`;
82
+ }
83
+
84
+ function mergeList(base, override) {
85
+ const out = [];
86
+ const seen = new Map();
87
+ for (const item of [...(base || []), ...(override || [])]) {
88
+ const key = listKey(item);
89
+ if (seen.has(key)) {
90
+ out[seen.get(key)] = item;
91
+ } else {
92
+ seen.set(key, out.length);
93
+ out.push(item);
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ // Project-local tier merge (most-specific wins): lists merge + dedupe so a local
100
+ // override adds to committed lists (extra source targets, intake dirs, allowlist
101
+ // entries) instead of clobbering them; scalars and objects override key-by-key.
102
+
103
+ function mergeConfigTier(base, override) {
104
+ if (override === undefined) return base;
105
+ if (Array.isArray(base) && Array.isArray(override)) return mergeList(base, override);
106
+ if (override === null || typeof override !== 'object' || Array.isArray(override)) return override;
107
+ const out = Array.isArray(base) ? [...(base || [])] : { ...(base || {}) };
108
+ for (const [k, v] of Object.entries(override)) {
109
+ out[k] = mergeConfigTier(base?.[k], v);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ function loadLocalConfigOverlay(configPath) {
115
+ const localPath = path.join(path.dirname(configPath), PROJECT_LOCAL_CONFIG_FILENAME);
116
+ if (!fs.existsSync(localPath)) return { raw: null, path: null, errors: [] };
117
+ let raw;
118
+ try {
119
+ raw = JSON.parse(fs.readFileSync(localPath, 'utf8'));
120
+ } catch (err) {
121
+ return { raw: null, path: localPath, errors: [`failed to parse ${localPath}: ${err.message}`] };
122
+ }
123
+ const validation = validateProjectConfig(raw, { partial: true });
124
+ if (!validation.valid) {
125
+ return { raw: null, path: localPath, errors: (validation.errors ?? []).map((e) => `${localPath}: ${e}`) };
126
+ }
127
+ return { raw, path: localPath, errors: [] };
128
+ }
129
+
64
130
  export function loadProjectConfig(cwd = process.cwd(), env = process.env) {
65
131
  const configPath = findProjectConfigPath(cwd);
66
132
  if (!configPath) {
@@ -85,22 +151,51 @@ export function loadProjectConfig(cwd = process.cwd(), env = process.env) {
85
151
  };
86
152
  }
87
153
  const validation = validateProjectConfig(raw);
88
- if (!validation.valid) {
154
+ const targetErrors = raw?.sources?.targets !== undefined
155
+ ? validateSourceTargets(raw.sources.targets)
156
+ : [];
157
+ const allErrors = [...(validation.errors ?? []), ...targetErrors];
158
+ if (!validation.valid || targetErrors.length) {
89
159
  return {
90
160
  path: configPath,
91
161
  raw,
92
162
  config: structuredClone(DEFAULT_PROJECT_CONFIG),
93
163
  source: 'invalid',
94
- errors: validation.errors,
164
+ errors: allErrors,
95
165
  };
96
166
  }
167
+ const overlay = loadLocalConfigOverlay(configPath);
168
+ if (overlay.errors.length) {
169
+ return {
170
+ path: configPath,
171
+ raw,
172
+ config: structuredClone(DEFAULT_PROJECT_CONFIG),
173
+ source: 'invalid',
174
+ errors: overlay.errors,
175
+ };
176
+ }
177
+ if (overlay.raw?.sources?.targets !== undefined) {
178
+ const overlayTargetErrors = validateSourceTargets(overlay.raw.sources.targets);
179
+ if (overlayTargetErrors.length) {
180
+ return {
181
+ path: configPath,
182
+ raw,
183
+ config: structuredClone(DEFAULT_PROJECT_CONFIG),
184
+ source: 'invalid',
185
+ errors: overlayTargetErrors.map((e) => `${overlay.path}: ${e}`),
186
+ };
187
+ }
188
+ }
189
+
97
190
  const merged = deepMerge(structuredClone(DEFAULT_PROJECT_CONFIG), raw);
98
- const resolved = interpolateSecrets(merged, env);
191
+ const withLocal = overlay.raw ? mergeConfigTier(merged, overlay.raw) : merged;
192
+ const resolved = interpolateSecrets(withLocal, env);
99
193
  return {
100
194
  path: configPath,
101
195
  raw,
196
+ localPath: overlay.path,
102
197
  config: resolved,
103
- source: 'file',
198
+ source: overlay.raw ? 'file+local' : 'file',
104
199
  errors: [],
105
200
  };
106
201
  }
@@ -161,6 +256,10 @@ export function writeProjectConfig(filePath, config, options = {}) {
161
256
  let validation = { valid: true, errors: [] };
162
257
  if (validate) {
163
258
  validation = validateProjectConfig(config);
259
+ if (config?.sources?.targets !== undefined) {
260
+ validation.errors = [...(validation.errors ?? []), ...validateSourceTargets(config.sources.targets)];
261
+ validation.valid = validation.errors.length === 0;
262
+ }
164
263
  }
165
264
 
166
265
  // Combine all errors (handle case where validation.errors is undefined)
@@ -5,7 +5,8 @@
5
5
  * AJV/zod to keep Construct dependency-free at startup — the loader runs
6
6
  * before npm install completes in some bootstrap paths.
7
7
  *
8
- * Schema v1 covers: alias, deployment, providers, autoEmbed, ingest, telemetry.
8
+ * Schema v1 covers: alias, deployment, providers, sources, intakePolicy,
9
+ * autoEmbed, ingest, telemetry.
9
10
  * Tier model selection lives in `specialists/registry.json` only — this config
10
11
  * file is intentionally not a second edit surface for model assignments.
11
12
  * Later phases (6, 7b) extend it with `resources` and `costs` blocks
@@ -47,6 +48,17 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
47
48
  tenantId: null,
48
49
  }),
49
50
  providers: Object.freeze({}),
51
+ sources: Object.freeze({
52
+ targets: [],
53
+ }),
54
+ intakePolicy: Object.freeze({
55
+ maxDepth: 4,
56
+ additionalDirs: [],
57
+ }),
58
+ artifactWorkflow: Object.freeze({
59
+ defaults: Object.freeze({}),
60
+ types: Object.freeze({}),
61
+ }),
50
62
  profile: DEFAULT_PROFILE_ID,
51
63
  autoEmbed: false,
52
64
  ingest: Object.freeze({
@@ -121,6 +133,29 @@ export const FIELD_RULES = {
121
133
  },
122
134
  },
123
135
  providers: { type: 'object', required: false },
136
+ sources: {
137
+ type: 'object',
138
+ required: false,
139
+ fields: {
140
+ targets: { type: 'array' },
141
+ },
142
+ },
143
+ intakePolicy: {
144
+ type: 'object',
145
+ required: false,
146
+ fields: {
147
+ maxDepth: { type: 'number' },
148
+ additionalDirs: { type: 'array' },
149
+ },
150
+ },
151
+ artifactWorkflow: {
152
+ type: 'object',
153
+ required: false,
154
+ fields: {
155
+ defaults: { type: 'object', required: false },
156
+ types: { type: 'object', required: false },
157
+ },
158
+ },
124
159
  profile: { type: 'string', required: false, maxLength: 40 },
125
160
  autoEmbed: { type: 'boolean', required: false },
126
161
  ingest: {
@@ -220,10 +255,10 @@ function checkType(value, expected) {
220
255
  return typeof value === expected;
221
256
  }
222
257
 
223
- function validateField(value, rule, path) {
258
+ function validateField(value, rule, path, partial = false) {
224
259
  const errors = [];
225
260
  if (value === undefined) {
226
- if (rule.required) errors.push(`${path}: required field missing`);
261
+ if (rule.required && !partial) errors.push(`${path}: required field missing`);
227
262
  return errors;
228
263
  }
229
264
  if (!checkType(value, rule.type)) {
@@ -238,19 +273,23 @@ function validateField(value, rule, path) {
238
273
  }
239
274
  if (rule.fields && checkType(value, 'object')) {
240
275
  for (const [key, subRule] of Object.entries(rule.fields)) {
241
- errors.push(...validateField(value[key], subRule, `${path}.${key}`));
276
+ errors.push(...validateField(value[key], subRule, `${path}.${key}`, partial));
242
277
  }
243
278
  }
244
279
  return errors;
245
280
  }
246
281
 
247
- export function validateProjectConfig(raw) {
282
+ // `partial` validation skips the required-field check so a sparse override file
283
+ // (construct.config.local.json) validates, while still type- and enum-checking
284
+ // every key that is present.
285
+
286
+ export function validateProjectConfig(raw, { partial = false } = {}) {
248
287
  const errors = [];
249
288
  if (!checkType(raw, 'object')) {
250
289
  return { valid: false, errors: ['root: must be an object'] };
251
290
  }
252
291
  for (const [key, rule] of Object.entries(FIELD_RULES)) {
253
- errors.push(...validateField(raw[key], rule, key));
292
+ errors.push(...validateField(raw[key], rule, key, partial));
254
293
  }
255
294
  if (raw.version !== undefined && raw.version !== CONFIG_SCHEMA_VERSION) {
256
295
  errors.push(`version: expected ${CONFIG_SCHEMA_VERSION}, got ${raw.version}`);
@@ -0,0 +1,311 @@
1
+ /**
2
+ * lib/config/source-targets.mjs — typed integration source targets in construct.config.json.
3
+ *
4
+ * Validates GitHub, Jira, Linear, and Slack selector records, merges legacy env
5
+ * target lists additively, deduplicates by provider+selector signature, and
6
+ * converts resolved targets into embed provider source records for auto-discovery.
7
+ */
8
+
9
+ export const SOURCE_PROVIDERS = Object.freeze(['github', 'jira', 'linear', 'slack']);
10
+
11
+ export const SLACK_INTENTS = Object.freeze(['internal', 'risk', 'decision', 'external', 'how-to']);
12
+
13
+ const ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
14
+ const GITHUB_REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
15
+ const JIRA_PROJECT_RE = /^[A-Z][A-Z0-9]{1,9}$/;
16
+
17
+ export function validateSourceTarget(target, index = 0) {
18
+ const errors = [];
19
+ const at = `sources.targets[${index}]`;
20
+
21
+ if (!target || typeof target !== 'object' || Array.isArray(target)) {
22
+ return [`${at}: must be an object`];
23
+ }
24
+
25
+ if (typeof target.id !== 'string' || !ID_RE.test(target.id)) {
26
+ errors.push(`${at}.id: required stable id (letters, numbers, hyphens, underscores; max 64 chars)`);
27
+ }
28
+
29
+ if (!SOURCE_PROVIDERS.includes(target.provider)) {
30
+ errors.push(`${at}.provider: must be one of ${SOURCE_PROVIDERS.join(', ')}`);
31
+ }
32
+
33
+ if (!target.selector || typeof target.selector !== 'object' || Array.isArray(target.selector)) {
34
+ errors.push(`${at}.selector: required object`);
35
+ return errors;
36
+ }
37
+
38
+ const sel = target.selector;
39
+ switch (target.provider) {
40
+ case 'github':
41
+ if (typeof sel.repo !== 'string' || !GITHUB_REPO_RE.test(sel.repo.trim())) {
42
+ errors.push(`${at}.selector.repo: required owner/repo slug`);
43
+ }
44
+ break;
45
+ case 'jira':
46
+ if (typeof sel.project !== 'string' || !JIRA_PROJECT_RE.test(sel.project.trim().toUpperCase())) {
47
+ errors.push(`${at}.selector.project: required Jira project key (2–10 uppercase letters/digits)`);
48
+ }
49
+ break;
50
+ case 'linear':
51
+ if (typeof sel.team !== 'string' || !sel.team.trim()) {
52
+ errors.push(`${at}.selector.team: required Linear team key or name`);
53
+ }
54
+ break;
55
+ case 'slack': {
56
+ if (typeof sel.channel !== 'string' || !sel.channel.trim()) {
57
+ errors.push(`${at}.selector.channel: required Slack channel name or ID`);
58
+ }
59
+ if (sel.intent !== undefined && !SLACK_INTENTS.includes(sel.intent)) {
60
+ errors.push(`${at}.selector.intent: must be one of ${SLACK_INTENTS.join(', ')}`);
61
+ }
62
+ break;
63
+ }
64
+ default:
65
+ break;
66
+ }
67
+
68
+ return errors;
69
+ }
70
+
71
+ export function validateSourceTargets(targets) {
72
+ const errors = [];
73
+ if (targets === undefined) return errors;
74
+ if (!Array.isArray(targets)) {
75
+ return ['sources.targets: must be an array'];
76
+ }
77
+
78
+ const seenIds = new Set();
79
+ for (let i = 0; i < targets.length; i++) {
80
+ errors.push(...validateSourceTarget(targets[i], i));
81
+ const id = targets[i]?.id;
82
+ if (typeof id === 'string') {
83
+ const key = id.toLowerCase();
84
+ if (seenIds.has(key)) {
85
+ errors.push(`sources.targets[${i}].id: duplicate id "${id}"`);
86
+ }
87
+ seenIds.add(key);
88
+ }
89
+ }
90
+ return errors;
91
+ }
92
+
93
+ function parseCsv(value) {
94
+ return String(value ?? '')
95
+ .split(',')
96
+ .map((s) => s.trim())
97
+ .filter(Boolean);
98
+ }
99
+
100
+ function slugId(value) {
101
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
102
+ }
103
+
104
+ function parseSlackChannelEntry(raw) {
105
+ const trimmed = String(raw).trim().replace(/^#/, '');
106
+ const colon = trimmed.indexOf(':');
107
+ if (colon === -1) {
108
+ return { channel: trimmed, intent: 'internal' };
109
+ }
110
+ return { channel: trimmed.slice(0, colon), intent: trimmed.slice(colon + 1) || 'internal' };
111
+ }
112
+
113
+ export function legacyEnvSourceTargets(env = process.env) {
114
+ const targets = [];
115
+
116
+ for (const repo of parseCsv(env.GITHUB_REPOS ?? env.GITHUB_REPO)) {
117
+ targets.push({
118
+ id: `env-github-${slugId(repo)}`,
119
+ provider: 'github',
120
+ selector: { repo },
121
+ provenance: 'env:GITHUB_REPOS',
122
+ });
123
+ }
124
+
125
+ for (const project of parseCsv(env.JIRA_PROJECTS)) {
126
+ const key = project.toUpperCase();
127
+ targets.push({
128
+ id: `env-jira-${slugId(key)}`,
129
+ provider: 'jira',
130
+ selector: { project: key },
131
+ provenance: 'env:JIRA_PROJECTS',
132
+ });
133
+ }
134
+
135
+ for (const team of parseCsv(env.LINEAR_TEAMS)) {
136
+ targets.push({
137
+ id: `env-linear-${slugId(team)}`,
138
+ provider: 'linear',
139
+ selector: { team },
140
+ provenance: 'env:LINEAR_TEAMS',
141
+ });
142
+ }
143
+
144
+ for (const entry of parseCsv(env.SLACK_CHANNELS ?? env.SLACK_CHANNEL)) {
145
+ const { channel, intent } = parseSlackChannelEntry(entry);
146
+ targets.push({
147
+ id: `env-slack-${slugId(channel)}`,
148
+ provider: 'slack',
149
+ selector: { channel, intent: SLACK_INTENTS.includes(intent) ? intent : 'internal' },
150
+ provenance: 'env:SLACK_CHANNELS',
151
+ });
152
+ }
153
+
154
+ return targets;
155
+ }
156
+
157
+ export function targetSignature(target) {
158
+ const sel = target.selector ?? {};
159
+ switch (target.provider) {
160
+ case 'github':
161
+ return `github:repo:${sel.repo?.trim().toLowerCase()}`;
162
+ case 'jira':
163
+ return `jira:project:${String(sel.project ?? '').trim().toUpperCase()}`;
164
+ case 'linear':
165
+ return `linear:team:${String(sel.team ?? '').trim().toLowerCase()}`;
166
+ case 'slack':
167
+ return `slack:channel:${String(sel.channel ?? '').trim().toLowerCase()}:intent:${sel.intent ?? 'internal'}`;
168
+ default:
169
+ return `${target.provider}:${JSON.stringify(sel)}`;
170
+ }
171
+ }
172
+
173
+ export function mergeSourceTargets(configTargets = [], envTargets = []) {
174
+ const merged = [];
175
+ const seen = new Set();
176
+
177
+ for (const target of [...configTargets, ...envTargets]) {
178
+ const sig = targetSignature(target);
179
+ if (seen.has(sig)) continue;
180
+ seen.add(sig);
181
+ merged.push(target);
182
+ }
183
+ return merged;
184
+ }
185
+
186
+ export function normalizeConfigTarget(raw) {
187
+ const out = {
188
+ id: String(raw.id).trim(),
189
+ provider: raw.provider,
190
+ selector: { ...raw.selector },
191
+ provenance: raw.provenance ?? 'config',
192
+ };
193
+
194
+ if (out.provider === 'jira' && out.selector.project) {
195
+ out.selector.project = String(out.selector.project).trim().toUpperCase();
196
+ }
197
+ if (out.provider === 'github' && out.selector.repo) {
198
+ out.selector.repo = String(out.selector.repo).trim();
199
+ }
200
+ if (out.provider === 'linear' && out.selector.team) {
201
+ out.selector.team = String(out.selector.team).trim();
202
+ }
203
+ if (out.provider === 'slack' && out.selector.channel) {
204
+ out.selector.channel = String(out.selector.channel).trim().replace(/^#/, '');
205
+ if (!out.selector.intent) out.selector.intent = 'internal';
206
+ }
207
+ return out;
208
+ }
209
+
210
+ export function resolveEffectiveSourceTargetsFromConfig(config, env = process.env) {
211
+ const configTargets = (config?.sources?.targets ?? []).map(normalizeConfigTarget);
212
+ const envTargets = legacyEnvSourceTargets(env);
213
+ return mergeSourceTargets(configTargets, envTargets);
214
+ }
215
+
216
+ export function targetsToEmbedSources(targets) {
217
+ const sources = [];
218
+ const githubRepos = targets.filter((t) => t.provider === 'github').map((t) => t.selector.repo);
219
+ if (githubRepos.length) {
220
+ sources.push({
221
+ provider: 'github',
222
+ repos: githubRepos,
223
+ refs: ['prs', 'issues', 'commits'],
224
+ limit: 25,
225
+ });
226
+ sources.push({
227
+ provider: 'github',
228
+ repos: githubRepos,
229
+ refs: ['meta', 'readme', 'docs'],
230
+ limit: 25,
231
+ });
232
+ }
233
+
234
+ for (const target of targets.filter((t) => t.provider === 'jira')) {
235
+ sources.push({
236
+ provider: 'jira',
237
+ project: target.selector.project,
238
+ refs: ['issues'],
239
+ limit: 50,
240
+ });
241
+ }
242
+
243
+ for (const target of targets.filter((t) => t.provider === 'linear')) {
244
+ sources.push({
245
+ provider: 'linear',
246
+ team: target.selector.team,
247
+ refs: ['issues'],
248
+ limit: 50,
249
+ });
250
+ }
251
+
252
+ const slackByIntent = new Map();
253
+ for (const target of targets.filter((t) => t.provider === 'slack')) {
254
+ const intent = target.selector.intent ?? 'internal';
255
+ if (!slackByIntent.has(intent)) slackByIntent.set(intent, []);
256
+ slackByIntent.get(intent).push(target.selector.channel);
257
+ }
258
+ for (const [intent, channels] of slackByIntent) {
259
+ sources.push({
260
+ provider: 'slack',
261
+ channels,
262
+ intent,
263
+ refs: ['messages'],
264
+ oldest: 86400,
265
+ limit: 50,
266
+ });
267
+ }
268
+
269
+ return sources;
270
+ }
271
+
272
+ export function resolveKnownSourcesFromTargets(targets) {
273
+ const sources = [];
274
+
275
+ for (const target of targets) {
276
+ const sel = target.selector ?? {};
277
+ if (target.provider === 'github' && sel.repo) {
278
+ const repo = sel.repo;
279
+ const short = repo.split('/').pop();
280
+ sources.push({ id: repo.toLowerCase(), provider: 'github', ref: repo, display: repo, targetId: target.id });
281
+ sources.push({ id: short.toLowerCase(), provider: 'github', ref: repo, display: repo, targetId: target.id });
282
+ sources.push({ id: short.replace(/-/g, '').toLowerCase(), provider: 'github', ref: repo, display: repo, targetId: target.id });
283
+ const words = short.split('-');
284
+ if (words.length > 1) {
285
+ sources.push({ id: words[words.length - 1].toLowerCase(), provider: 'github', ref: repo, display: repo, targetId: target.id });
286
+ }
287
+ }
288
+ if (target.provider === 'jira' && sel.project) {
289
+ const key = sel.project;
290
+ sources.push({ id: key.toLowerCase(), provider: 'jira', ref: key, display: `Jira/${key}`, targetId: target.id });
291
+ }
292
+ if (target.provider === 'linear' && sel.team) {
293
+ const team = sel.team;
294
+ sources.push({ id: team.toLowerCase(), provider: 'linear', ref: team, display: `Linear/${team}`, targetId: target.id });
295
+ }
296
+ if (target.provider === 'slack' && sel.channel) {
297
+ const ch = sel.channel;
298
+ sources.push({ id: ch.toLowerCase(), provider: 'slack', ref: ch, display: `Slack/${ch}`, targetId: target.id });
299
+ }
300
+ }
301
+
302
+ const providers = new Set(targets.map((t) => t.provider));
303
+ if (providers.has('jira')) {
304
+ sources.push({ id: 'jira', provider: 'jira', ref: null, display: 'Jira' });
305
+ }
306
+ if (providers.has('linear')) {
307
+ sources.push({ id: 'linear', provider: 'linear', ref: null, display: 'Linear' });
308
+ }
309
+
310
+ return sources;
311
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * lib/config/xdg.mjs — single source of truth for Construct's user-scope base
3
+ * directories, following the XDG Base Directory specification.
4
+ *
5
+ * Keeps config (versionable settings), state (durable runtime data), and cache
6
+ * (regenerable transients) on separate roots so each can be backed up, synced,
7
+ * and pruned independently:
8
+ *
9
+ * configDir() → $XDG_CONFIG_HOME/construct (default ~/.config/construct)
10
+ * stateDir() → $XDG_STATE_HOME/construct (default ~/.local/state/construct)
11
+ * cacheDir() → $XDG_CACHE_HOME/construct (default ~/.cache/construct)
12
+ *
13
+ * Per the XDG spec, an env var is honored only when it is set AND absolute;
14
+ * a relative or empty value is ignored and the default applies. Every helper
15
+ * takes an optional homeDir for testability, mirroring the existing homeDir
16
+ * params across the codebase.
17
+ *
18
+ * Clean break: there is no legacy `~/.construct/*` read here. Existing installs
19
+ * re-run `construct install --scope=user` to repopulate at the XDG paths.
20
+ */
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+
24
+ // Leaf segment appended under each resolved base.
25
+
26
+ const APP = 'construct';
27
+
28
+ // XDG honors an env override only when it names an absolute path; anything
29
+ // relative or empty falls through to the spec default so a stray cwd-relative
30
+ // value can never relocate user state under the process working directory.
31
+
32
+ function resolveBase(envValue, fallbackSegments, homeDir) {
33
+ if (typeof envValue === 'string' && path.isAbsolute(envValue)) {
34
+ return envValue;
35
+ }
36
+ return path.join(homeDir, ...fallbackSegments);
37
+ }
38
+
39
+ export function configDir(homeDir = os.homedir(), env = process.env) {
40
+ const base = resolveBase(env.XDG_CONFIG_HOME, ['.config'], homeDir);
41
+ return path.join(base, APP);
42
+ }
43
+
44
+ export function stateDir(homeDir = os.homedir(), env = process.env) {
45
+ const base = resolveBase(env.XDG_STATE_HOME, ['.local', 'state'], homeDir);
46
+ return path.join(base, APP);
47
+ }
48
+
49
+ // The global doctor/telemetry/runtime axis (formerly rooted at ~/.cx) now
50
+ // resolves to the XDG state dir. CONSTRUCT_DOCTOR_ROOT, shared by the
51
+ // telemetry/doctor/oracle ecosystem for test isolation, still wins when set to
52
+ // a non-empty value; otherwise stateDir() supplies the durable default.
53
+
54
+ export function doctorRoot(homeDir = os.homedir(), env = process.env) {
55
+ const override = env.CONSTRUCT_DOCTOR_ROOT;
56
+ if (typeof override === 'string' && override.trim()) return override;
57
+ return stateDir(homeDir, env);
58
+ }
59
+
60
+ export function cacheDir(homeDir = os.homedir(), env = process.env) {
61
+ const base = resolveBase(env.XDG_CACHE_HOME, ['.cache'], homeDir);
62
+ return path.join(base, APP);
63
+ }
@@ -25,12 +25,12 @@
25
25
 
26
26
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
27
  import { join, dirname } from 'node:path';
28
- import { homedir } from 'node:os';
29
28
  import { createHash } from 'node:crypto';
30
29
  import { appendBounded, readLastLineAcrossSegments } from '../logging/rotate.mjs';
31
30
  import { resolveProjectScopedPath } from '../project-root.mjs';
31
+ import { doctorRoot } from '../config/xdg.mjs';
32
32
 
33
- const CX_DIR = join(homedir(), '.cx');
33
+ const CX_DIR = doctorRoot();
34
34
  const LAST_AGENT = join(CX_DIR, 'last-agent.json');
35
35
 
36
36
  // contract-violations are PROJECT-SCOPED: resolves to
@@ -5,7 +5,7 @@
5
5
  * before they blow the daily budget. Separate from lib/roles/ because both
6
6
  * the gateway and the cost watcher consume it.
7
7
  *
8
- * Storage: ~/.cx/cost-ledger.json — small JSON object keyed by `<dayKey>:<personaId>`.
8
+ * Storage: <doctorRoot>/cost-ledger.json — small JSON object keyed by `<dayKey>:<personaId>`.
9
9
  * Rotates: entries older than 30 days are pruned on each write.
10
10
  *
11
11
  * Budgets are env-driven defaults; explicit `CONSTRUCT_BUDGET_<PERSONA>` wins.
@@ -15,16 +15,17 @@
15
15
  */
16
16
 
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
- import { homedir } from 'node:os';
19
18
  import { join } from 'node:path';
20
19
  import { cwd as procCwd } from 'node:process';
21
20
 
21
+ import { doctorRoot } from './config/xdg.mjs';
22
+
22
23
  const PRUNE_DAYS = 30;
23
24
  const DEFAULT_PER_PERSONA = 10.0;
24
25
  const DEFAULT_TOTAL = 50.0;
25
26
 
26
27
  function rootDir() {
27
- return process.env.CONSTRUCT_DOCTOR_ROOT || join(homedir(), '.cx');
28
+ return doctorRoot();
28
29
  }
29
30
  function ledgerPath() {
30
31
  return join(rootDir(), 'cost-ledger.json');