@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.
- package/README.md +8 -7
- package/bin/construct +206 -62
- package/commands/plan/decide.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/examples/README.md +1 -1
- package/examples/distribution/README.md +1 -1
- package/examples/distribution/sources/adr.md +2 -2
- package/examples/distribution/sources/deck-one-pager.md +1 -1
- package/examples/distribution/sources/prd-platform.md +1 -1
- package/examples/distribution/sources/research-brief.md +2 -2
- package/examples/distribution/sources/rfc-platform.md +1 -1
- package/examples/distribution/sources/runbook.md +2 -2
- package/examples/distribution/sources/strategy.md +1 -1
- package/lib/agent-instructions/inject.mjs +1 -1
- package/lib/artifact-manifest.mjs +150 -4
- package/lib/artifact-type-from-path.mjs +9 -5
- package/lib/artifact-workflow.mjs +252 -0
- package/lib/audit-specialists.mjs +1 -1
- package/lib/audit-trail.mjs +3 -3
- package/lib/auto-docs.mjs +13 -11
- package/lib/bootstrap/built-ins.mjs +3 -3
- package/lib/boundary.mjs +3 -2
- package/lib/brand-prose.mjs +1 -1
- package/lib/cache-governor.js +4 -4
- package/lib/certification/document-io-fixtures.mjs +1 -1
- package/lib/chat/config.mjs +2 -2
- package/lib/chat/desktop-binary.mjs +5 -3
- package/lib/chat/present.mjs +1 -0
- package/lib/cli-commands.mjs +23 -6
- package/lib/cli-service-inventory.mjs +42 -0
- package/lib/comment-lint.mjs +9 -5
- package/lib/config/alias.mjs +3 -2
- package/lib/config/intake-policy.mjs +139 -0
- package/lib/config/project-config.mjs +106 -7
- package/lib/config/schema.mjs +45 -6
- package/lib/config/source-targets.mjs +311 -0
- package/lib/config/xdg.mjs +63 -0
- package/lib/contracts/violation-log.mjs +2 -2
- package/lib/cost-ledger.mjs +4 -3
- package/lib/cost.mjs +3 -2
- package/lib/decisions/registry.mjs +3 -3
- package/lib/distill.mjs +2 -2
- package/lib/docs-verify.mjs +2 -0
- package/lib/doctor/audit.mjs +4 -3
- package/lib/doctor/cli.mjs +0 -1
- package/lib/doctor/index.mjs +4 -4
- package/lib/doctor/report.mjs +3 -3
- package/lib/doctor/source-checkout.mjs +16 -0
- package/lib/doctor/watchers/bd-watch.mjs +5 -5
- package/lib/doctor/watchers/cost.mjs +4 -4
- package/lib/doctor/watchers/disk.mjs +4 -3
- package/lib/doctor/watchers/service-health.mjs +5 -5
- package/lib/document-export.mjs +39 -17
- package/lib/document-extract.mjs +3 -3
- package/lib/document-ingest.mjs +13 -3
- package/lib/efficiency.mjs +2 -1
- package/lib/embed/approval-queue.mjs +3 -2
- package/lib/embed/auto-sources.mjs +44 -0
- package/lib/embed/cli.mjs +9 -6
- package/lib/embed/config.mjs +6 -5
- package/lib/embed/daemon.mjs +6 -5
- package/lib/embed/demand-fetch.mjs +50 -19
- package/lib/embed/inbox-live-watcher.mjs +1 -1
- package/lib/embed/inbox.mjs +52 -41
- package/lib/embed/intake-metrics.mjs +3 -2
- package/lib/embed/reconcile.mjs +1 -1
- package/lib/embed/semantic.mjs +3 -2
- package/lib/embed/supervision.mjs +4 -2
- package/lib/embed/worker.mjs +2 -1
- package/lib/embedded-contract/index.mjs +11 -0
- package/lib/engine/registry.mjs +3 -3
- package/lib/env-config.mjs +34 -12
- package/lib/export-branding.mjs +32 -0
- package/lib/features.mjs +4 -3
- package/lib/health-check.mjs +7 -6
- package/lib/hook-health.mjs +5 -4
- package/lib/hooks/_lib/log.mjs +4 -3
- package/lib/hooks/_lib/output-mode.mjs +2 -1
- package/lib/hooks/agent-tracker.mjs +3 -4
- package/lib/hooks/audit-reads.mjs +2 -2
- package/lib/hooks/audit-trail.mjs +2 -2
- package/lib/hooks/bash-output-logger.mjs +3 -3
- package/lib/hooks/brand-prose-lint.mjs +1 -1
- package/lib/hooks/ci-status-check.mjs +3 -2
- package/lib/hooks/context-watch.mjs +2 -2
- package/lib/hooks/context-window-recovery.mjs +5 -5
- package/lib/hooks/dep-audit.mjs +2 -2
- package/lib/hooks/doc-coupling-check.mjs +3 -2
- package/lib/hooks/edit-accumulator.mjs +3 -2
- package/lib/hooks/edit-guard.mjs +3 -3
- package/lib/hooks/guard-bash.mjs +2 -2
- package/lib/hooks/model-fallback.mjs +2 -1
- package/lib/hooks/policy-engine.mjs +4 -3
- package/lib/hooks/pre-compact.mjs +7 -7
- package/lib/hooks/readme-age-check.mjs +2 -2
- package/lib/hooks/session-optimize.mjs +4 -3
- package/lib/hooks/session-start.mjs +20 -16
- package/lib/hooks/stop-notify.mjs +9 -8
- package/lib/hooks/stop-typecheck.mjs +4 -3
- package/lib/hooks/test-watch.mjs +2 -2
- package/lib/host-disposition.mjs +1 -1
- package/lib/ingest/provider-extract.mjs +2 -1
- package/lib/init/detect-existing-structure.mjs +2 -2
- package/lib/init/doc-lanes.mjs +3 -10
- package/lib/init-docs.mjs +12 -54
- package/lib/init-unified.mjs +47 -92
- package/lib/init.mjs +7 -5
- package/lib/install/desktop-binary-download.mjs +5 -2
- package/lib/intake/constants.mjs +29 -0
- package/lib/intake/daemon.mjs +31 -8
- package/lib/intake/intake-config.mjs +32 -112
- package/lib/integrations/intake-integrations.mjs +8 -7
- package/lib/knowledge/rag.mjs +2 -2
- package/lib/knowledge/search.mjs +15 -15
- package/lib/maintenance/cleanup.mjs +14 -10
- package/lib/mcp/server.mjs +20 -2
- package/lib/mcp/tools/embedded-contract.mjs +14 -0
- package/lib/mcp/tools/project.mjs +2 -1
- package/lib/model-cheapest-provider.mjs +4 -3
- package/lib/model-pricing.mjs +3 -2
- package/lib/model-router.mjs +4 -3
- package/lib/models/catalog.mjs +2 -1
- package/lib/models/execution-capability-profile.mjs +2 -1
- package/lib/models/provider-poll.mjs +2 -1
- package/lib/ollama-manager.mjs +0 -1
- package/lib/op-log.mjs +2 -1
- package/lib/opencode-runtime-plugin.mjs +3 -2
- package/lib/oracle/artifact-gate.mjs +3 -0
- package/lib/oracle/cli.mjs +2 -1
- package/lib/oracle/execute.mjs +3 -2
- package/lib/oracle/index.mjs +2 -1
- package/lib/oracle/read-model.mjs +3 -2
- package/lib/performance/generate.mjs +4 -3
- package/lib/platforms/capabilities.mjs +1 -1
- package/lib/playwright-demo.mjs +2 -0
- package/lib/plugin-registry.mjs +2 -1
- package/lib/profiles/lifecycle.mjs +3 -3
- package/lib/project-profile.mjs +2 -1
- package/lib/project-root.mjs +9 -7
- package/lib/provider-capabilities.js +4 -3
- package/lib/providers/auth-manager.mjs +2 -1
- package/lib/providers/copilot-auth.mjs +3 -2
- package/lib/providers/creds.mjs +3 -2
- package/lib/providers/registry.mjs +3 -3
- package/lib/providers/secret-resolver.mjs +3 -2
- package/lib/read-tracker-store.mjs +2 -1
- package/lib/reconcile/mcp-entry-reconcile.mjs +2 -2
- package/lib/reflect.mjs +2 -1
- package/lib/registry/generate-docs.mjs +2 -2
- package/lib/roles/approval-surface.mjs +5 -4
- package/lib/roles/event-bus.mjs +0 -1
- package/lib/roles/gateway.mjs +6 -2
- package/lib/rules-delivery.mjs +1 -1
- package/lib/runtime-pressure.mjs +5 -3
- package/lib/sandbox.mjs +3 -2
- package/lib/scheduler/solo.mjs +6 -4
- package/lib/server/auth.mjs +4 -3
- package/lib/server/index.mjs +53 -26
- package/lib/server/insights.mjs +5 -4
- package/lib/server/rate-limit.mjs +5 -3
- package/lib/server/webhook.mjs +5 -3
- package/lib/service-manager.mjs +5 -3
- package/lib/setup.mjs +9 -8
- package/lib/status.mjs +6 -5
- package/lib/storage/embeddings-local.mjs +3 -2
- package/lib/storage/sync.mjs +2 -2
- package/lib/telemetry/client.mjs +0 -1
- package/lib/telemetry/intent-verifications.mjs +6 -6
- package/lib/telemetry/model-pricing-catalog.mjs +4 -3
- package/lib/telemetry/rule-calls.mjs +3 -3
- package/lib/telemetry/skill-calls.mjs +3 -3
- package/lib/template-registry.mjs +1 -0
- package/lib/templates/visual-requirements.mjs +1 -1
- package/lib/test-corpus-inventory.mjs +1 -1
- package/lib/uninstall/uninstall.mjs +12 -10
- package/package.json +3 -3
- package/platforms/claude/settings.template.json +43 -43
- package/rules/common/no-fabrication.md +1 -1
- package/rules/common/release-gates.md +1 -1
- package/rules/common/research.md +1 -1
- package/rules/common/review-before-change.md +1 -1
- package/scripts/sync-specialists.mjs +11 -8
- package/skills/docs/adr-workflow.md +3 -3
- package/skills/docs/init-docs.md +9 -9
- package/skills/docs/prd-workflow.md +5 -5
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +2 -2
- package/skills/docs/product-signal-workflow.md +1 -1
- package/skills/docs/runbook-workflow.md +4 -4
- package/skills/exploration/unknown-codebase-onboarding.md +1 -1
- package/skills/operating/orchestration-reference.md +1 -1
- package/skills/routing.md +3 -3
- package/specialists/artifact-manifest.json +11 -2
- package/specialists/artifact-manifest.schema.json +31 -1
- package/specialists/prompts/cx-architect.md +1 -1
- package/specialists/prompts/cx-docs-keeper.md +1 -1
- package/specialists/prompts/cx-researcher.md +1 -1
- package/specialists/prompts/cx-sre.md +1 -1
- package/specialists/role-manifests.json +6 -6
- package/templates/distribution/construct-reference.docx +0 -0
- package/templates/docs/README.md +125 -0
- package/templates/docs/construct_guide.md +3 -4
- package/templates/docs/persona-artifact.md +1 -1
- package/templates/docs/prds/README.md +25 -0
- package/templates/docs/prds/templates/_template.md +206 -0
- package/templates/docs/prds/templates/meta-prd.template.md +177 -0
- package/templates/docs/prds/templates/prd-business.template.md +61 -0
- package/templates/docs/prds/templates/prd-platform.template.md +81 -0
- package/templates/docs/prds/templates/prfaq.template.md +38 -0
- package/templates/docs/rfcs/README.md +22 -0
- package/templates/docs/rfcs/templates/_template.md +58 -0
- package/templates/docs/rfcs/templates/rfc-platform.template.md +72 -0
- package/templates/homebrew/construct.rb +1 -1
- 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
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
|
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)
|
package/lib/config/schema.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 =
|
|
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
|
package/lib/cost-ledger.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
28
|
+
return doctorRoot();
|
|
28
29
|
}
|
|
29
30
|
function ledgerPath() {
|
|
30
31
|
return join(rootDir(), 'cost-ledger.json');
|