@geraldmaron/construct 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +39 -34
  2. package/bin/construct +1403 -96
  3. package/commands/build/feature.md +1 -1
  4. package/commands/build/fix.md +1 -1
  5. package/commands/design/access.md +1 -1
  6. package/commands/design/flow.md +1 -1
  7. package/commands/design/ui.md +1 -1
  8. package/commands/measure/experiment.md +1 -1
  9. package/commands/measure/metrics.md +1 -1
  10. package/commands/measure/results.md +1 -1
  11. package/commands/plan/api.md +1 -1
  12. package/commands/plan/challenge.md +1 -1
  13. package/commands/plan/decide.md +1 -1
  14. package/commands/plan/feature.md +1 -1
  15. package/commands/plan/requirements.md +1 -1
  16. package/commands/remember/context.md +1 -1
  17. package/commands/remember/handoff.md +1 -1
  18. package/commands/remember/runbook.md +1 -1
  19. package/commands/review/code.md +1 -1
  20. package/commands/review/quality.md +1 -1
  21. package/commands/review/security.md +1 -1
  22. package/commands/ship/ready.md +1 -1
  23. package/commands/ship/release.md +1 -1
  24. package/commands/ship/status.md +1 -1
  25. package/commands/understand/docs.md +1 -1
  26. package/commands/understand/this.md +1 -1
  27. package/commands/understand/why.md +1 -1
  28. package/commands/work/clean.md +1 -1
  29. package/commands/work/drive.md +1 -1
  30. package/commands/work/optimize-prompts.md +2 -2
  31. package/commands/work/parallel-review.md +1 -1
  32. package/db/schema/007_tags.sql +30 -0
  33. package/db/schema/008_skill_usage.sql +24 -0
  34. package/db/schema/009_scheduler.sql +14 -0
  35. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  36. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  37. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  38. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  39. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  40. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  41. package/examples/seed-observations/decisions.md +1 -1
  42. package/lib/audit-skills.mjs +3 -3
  43. package/lib/auto-docs.mjs +8 -8
  44. package/lib/boundary.mjs +126 -0
  45. package/lib/cache-strategy-google.js +26 -31
  46. package/lib/cli-commands.mjs +276 -122
  47. package/lib/comment-lint.mjs +139 -1
  48. package/lib/completions.mjs +1 -1
  49. package/lib/config/schema.mjs +15 -1
  50. package/lib/contracts/validate.mjs +371 -0
  51. package/lib/daemons/contract.mjs +210 -0
  52. package/lib/distill.mjs +22 -4
  53. package/lib/doc-stamp.mjs +48 -0
  54. package/lib/docs-verify.mjs +59 -6
  55. package/lib/doctor/cli.mjs +16 -1
  56. package/lib/doctor/index.mjs +3 -1
  57. package/lib/doctor/watchers/consistency.mjs +360 -0
  58. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  59. package/lib/document-extract.mjs +420 -17
  60. package/lib/document-ingest.mjs +2 -0
  61. package/lib/embed/cli.mjs +124 -3
  62. package/lib/embed/daemon.mjs +43 -4
  63. package/lib/embed/docs-lifecycle.mjs +1 -1
  64. package/lib/embed/inbox.mjs +50 -3
  65. package/lib/embed/role-framing.mjs +3 -3
  66. package/lib/embed/scheduler.mjs +33 -5
  67. package/lib/env-config.mjs +9 -3
  68. package/lib/evaluator-optimizer.mjs +2 -3
  69. package/lib/extractors/calendar.mjs +173 -0
  70. package/lib/extractors/shared/drop-info.mjs +22 -0
  71. package/lib/extractors/transcript.mjs +0 -0
  72. package/lib/gates-audit.mjs +8 -2
  73. package/lib/git-hooks/prepare-commit-msg +1 -1
  74. package/lib/headhunt.mjs +2 -2
  75. package/lib/hooks/agent-tracker.mjs +6 -11
  76. package/lib/hooks/comment-lint.mjs +16 -0
  77. package/lib/hooks/guard-bash.mjs +35 -14
  78. package/lib/hooks/mcp-audit.mjs +14 -1
  79. package/lib/hooks/pre-compact.mjs +92 -5
  80. package/lib/hooks/pre-push-gate.mjs +41 -8
  81. package/lib/hooks/proactive-activation.mjs +0 -14
  82. package/lib/hooks/registry-sync.mjs +2 -2
  83. package/lib/hooks/rule-verifier.mjs +217 -0
  84. package/lib/hooks/session-optimize.mjs +2 -1
  85. package/lib/hooks/stop-notify.mjs +7 -6
  86. package/lib/host-capabilities.mjs +10 -1
  87. package/lib/hygiene/scan.mjs +141 -0
  88. package/lib/init-unified.mjs +73 -65
  89. package/lib/install/stage-project.mjs +4 -4
  90. package/lib/intake/classify.mjs +309 -58
  91. package/lib/intake/daemon.mjs +121 -0
  92. package/lib/intake/filesystem-queue.mjs +31 -6
  93. package/lib/intake/intake-config.mjs +2 -1
  94. package/lib/intake/postgres-queue.mjs +45 -3
  95. package/lib/intake/prepare.mjs +3 -1
  96. package/lib/intake/quarantine.mjs +205 -0
  97. package/lib/intake/session-prelude.mjs +7 -1
  98. package/lib/intake/traceability.mjs +90 -0
  99. package/lib/knowledge/postgres-search.mjs +132 -0
  100. package/lib/knowledge/research-store.mjs +2 -0
  101. package/lib/knowledge/search.mjs +14 -4
  102. package/lib/maintenance/cleanup.mjs +315 -0
  103. package/lib/mcp/memory-bridge.mjs +276 -0
  104. package/lib/mcp/server.mjs +11 -3
  105. package/lib/mcp/tools/skills.mjs +32 -11
  106. package/lib/mcp/tools/workflow.mjs +25 -0
  107. package/lib/mcp-catalog.json +12 -8
  108. package/lib/mcp-platform-config.mjs +16 -8
  109. package/lib/migrations/index.mjs +106 -0
  110. package/lib/migrations/v1-baseline.mjs +33 -0
  111. package/lib/model-registry.mjs +1 -1
  112. package/lib/observation-store.mjs +9 -4
  113. package/lib/opencode-runtime-plugin.mjs +1 -1
  114. package/lib/orchestration-policy.mjs +4 -4
  115. package/lib/outcomes/record.mjs +2 -0
  116. package/lib/overrides/resolver.mjs +3 -3
  117. package/lib/parity.mjs +4 -4
  118. package/lib/policy/engine.mjs +2 -2
  119. package/lib/profiles/lifecycle.mjs +1 -1
  120. package/lib/profiles/rebrand.mjs +46 -0
  121. package/lib/project-init-shared.mjs +12 -0
  122. package/lib/prompt-metadata.mjs +4 -4
  123. package/lib/provider-capabilities.js +20 -7
  124. package/lib/providers/auth-manager.mjs +58 -17
  125. package/lib/providers/circuit-breaker.mjs +14 -0
  126. package/lib/providers/contract.mjs +67 -3
  127. package/lib/providers/creds.mjs +219 -0
  128. package/lib/providers/scaffold.mjs +107 -0
  129. package/lib/reflect.mjs +49 -12
  130. package/lib/role-preload.mjs +5 -0
  131. package/lib/roles/catalog.mjs +1 -1
  132. package/lib/roles/manifest.mjs +2 -2
  133. package/lib/scheduler/index.mjs +112 -0
  134. package/lib/scheduler/solo.mjs +183 -0
  135. package/lib/server/index.mjs +29 -29
  136. package/lib/server/insights.mjs +14 -12
  137. package/lib/server/langfuse-login.mjs +58 -0
  138. package/lib/server/static/assets/index-ab25c707.js +1 -1
  139. package/lib/session-store.mjs +6 -4
  140. package/lib/setup.mjs +14 -3
  141. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  142. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  143. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  144. package/lib/{agents → specialists}/schema.mjs +6 -6
  145. package/lib/status.mjs +13 -11
  146. package/lib/storage/backup.mjs +2 -2
  147. package/lib/tags/lifecycle.mjs +278 -0
  148. package/lib/tags/vocabulary.mjs +140 -0
  149. package/lib/telemetry/client.mjs +5 -1
  150. package/lib/telemetry/otel-tracer.mjs +184 -0
  151. package/lib/telemetry/skill-calls.mjs +73 -12
  152. package/lib/uninstall/uninstall.mjs +1 -1
  153. package/lib/update.mjs +1 -1
  154. package/lib/validator.mjs +57 -56
  155. package/lib/version.mjs +51 -0
  156. package/lib/worker/trace.mjs +5 -1
  157. package/lib/workflows/instantiate.mjs +320 -0
  158. package/package.json +16 -3
  159. package/personas/construct.md +5 -3
  160. package/platforms/claude/CLAUDE.md +1 -1
  161. package/rules/common/development-workflow.md +2 -1
  162. package/rules/common/no-fabrication.md +69 -0
  163. package/rules/common/review-before-change.md +58 -0
  164. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
  165. package/skills/ai/prompt-optimizer.md +3 -3
  166. package/skills/exploration/dependency-graph-reading.md +98 -0
  167. package/skills/exploration/tracer-bullet-method.md +71 -0
  168. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  169. package/skills/operating/change-management.md +91 -0
  170. package/skills/operating/incident-response.md +75 -0
  171. package/skills/operating/oncall-rotation.md +95 -0
  172. package/skills/operating/orchestration-reference.md +2 -2
  173. package/skills/strategy/competitive-landscape.md +75 -0
  174. package/skills/strategy/market-research-methods.md +87 -0
  175. package/skills/strategy/narrative-arc.md +77 -0
  176. package/skills/strategy/pricing-positioning.md +94 -0
  177. package/specialists/contracts.json +1002 -0
  178. package/specialists/contracts.schema.json +83 -0
  179. package/specialists/policy-inventory.json +160 -0
  180. package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
  181. package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
  182. package/{agents → specialists}/prompts/cx-architect.md +3 -1
  183. package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
  184. package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
  185. package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
  186. package/{agents → specialists}/prompts/cx-debugger.md +3 -1
  187. package/{agents → specialists}/prompts/cx-designer.md +3 -1
  188. package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
  189. package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
  190. package/{agents → specialists}/prompts/cx-engineer.md +4 -2
  191. package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
  192. package/{agents → specialists}/prompts/cx-explorer.md +3 -1
  193. package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
  194. package/{agents → specialists}/prompts/cx-operations.md +3 -1
  195. package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
  196. package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
  197. package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
  198. package/{agents → specialists}/prompts/cx-qa.md +3 -1
  199. package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
  200. package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
  201. package/{agents → specialists}/prompts/cx-researcher.md +3 -1
  202. package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
  203. package/{agents → specialists}/prompts/cx-security.md +3 -1
  204. package/{agents → specialists}/prompts/cx-sre.md +4 -2
  205. package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
  206. package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
  207. package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
  208. package/{agents → specialists}/registry.json +651 -418
  209. package/{agents → specialists}/role-manifests.json +8 -8
  210. package/templates/docs/construct_guide.md +6 -6
  211. package/templates/docs/skill-artifact.md +1 -1
  212. package/templates/provider-scaffold/health.test.mjs +30 -0
  213. package/templates/provider-scaffold/index.mjs +48 -0
  214. package/templates/workflows/cross-team-handoff.yml +85 -0
  215. package/templates/workflows/engineering-onboarding.yml +77 -0
  216. package/templates/workflows/new-feature.yml +53 -0
  217. package/agents/contracts.json +0 -387
  218. /package/{agents → specialists}/teams.json +0 -0
@@ -117,23 +117,36 @@ export function resolveProviderCapabilitiesSync(modelId) {
117
117
  }
118
118
 
119
119
  /**
120
- * Probe provider for live capabilities (async).
121
- * Results are cached for 24h.
120
+ * Probe provider for capabilities and cache the result for 24h.
121
+ *
122
+ * Static path (probe=false): resolves capabilities from the adapter's static
123
+ * declaration. Live path (probe=true): dispatches to the adapter's optional
124
+ * `probe(modelId)` export — adapters that don't implement it fall back to the
125
+ * static declaration. Construct itself stays provider-agnostic; per-vendor
126
+ * probing is the adapter's responsibility.
122
127
  *
123
128
  * @param {string} modelId
124
129
  * @param {object} opts - { probe: boolean }
125
130
  * @returns {Promise<object>}
126
131
  */
127
132
  export async function probeProviderCapabilities(modelId, { probe = false } = {}) {
128
- const caps = await resolveProviderCapabilities(modelId);
133
+ const adapterKey = resolveAdapterKey(modelId);
134
+ const loader = ADAPTERS[adapterKey] || ADAPTERS.generic;
129
135
 
136
+ let caps;
130
137
  if (probe) {
131
- // TODO: implement live probing (API call) in Phase A follow-up
132
- // For now, just return the static capabilities
138
+ try {
139
+ const mod = await loader();
140
+ caps = typeof mod.probe === 'function'
141
+ ? await mod.probe(modelId)
142
+ : mod.capabilities(modelId);
143
+ } catch {
144
+ caps = await resolveProviderCapabilities(modelId);
145
+ }
146
+ } else {
147
+ caps = await resolveProviderCapabilities(modelId);
133
148
  }
134
149
 
135
- // Cache by adapter key
136
- const adapterKey = resolveAdapterKey(modelId);
137
150
  const cache = getCache();
138
151
  cache[adapterKey] = caps;
139
152
  writeCapabilityCache(cache);
@@ -1,15 +1,23 @@
1
- // lib/providers/auth-manager.mjs
2
- // OAuth refresh and token lifecycle management
1
+ /**
2
+ * lib/providers/auth-manager.mjs Token lifecycle and refresh dispatch.
3
+ *
4
+ * Provider-agnostic store and contract for credential rotation. The actual
5
+ * refresh flow (OAuth, JWT, vendor-specific) lives in a registered adapter;
6
+ * dispatch goes to the adapter's `refresh(state)` and the result is persisted
7
+ * back to the auth store. Adapters lacking a refresh implementation leave the
8
+ * operator on a reauthenticate-manually path — the safe agnostic default.
9
+ */
3
10
 
4
11
  import fs from 'node:fs';
5
12
  import path from 'node:path';
6
13
  import { homedir } from 'node:os';
7
14
 
8
- const AUTH_DIR = path.join(homedir(), '.construct', 'auth');
9
-
10
- try {
11
- fs.mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
12
- } catch { /* ignore */ }
15
+ function authDir() {
16
+ const home = process.env.HOME || homedir();
17
+ const dir = path.join(home, '.construct', 'auth');
18
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* ignore */ }
19
+ return dir;
20
+ }
13
21
 
14
22
  const PROVIDER_CONFIGS = {
15
23
  github: {
@@ -28,9 +36,20 @@ const PROVIDER_CONFIGS = {
28
36
  },
29
37
  };
30
38
 
39
+ const refreshAdapters = new Map();
40
+
41
+ /**
42
+ * Register a refresh adapter for a provider. Contract:
43
+ * adapter(state) -> Promise<{ success, token, refreshToken?, expiresAt? }>
44
+ * If no adapter is registered, refresh returns a manual-reauth instruction.
45
+ */
46
+ export function registerRefreshAdapter(provider, adapter) {
47
+ if (typeof adapter === 'function') refreshAdapters.set(provider, adapter);
48
+ }
49
+
31
50
  export function loadAuthState(provider) {
32
51
  try {
33
- const file = path.join(AUTH_DIR, `${provider}.json`);
52
+ const file = path.join(authDir(), `${provider}.json`);
34
53
  if (fs.existsSync(file)) {
35
54
  return JSON.parse(fs.readFileSync(file, 'utf8'));
36
55
  }
@@ -40,7 +59,7 @@ export function loadAuthState(provider) {
40
59
 
41
60
  export function saveAuthState(provider, state) {
42
61
  try {
43
- const file = path.join(AUTH_DIR, `${provider}.json`);
62
+ const file = path.join(authDir(), `${provider}.json`);
44
63
  fs.writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
45
64
  return true;
46
65
  } catch {
@@ -111,16 +130,38 @@ async function refreshToken(provider) {
111
130
  if (!config?.refreshable) {
112
131
  return { success: false, error: 'Provider does not support refresh' };
113
132
  }
114
-
133
+
115
134
  const state = loadAuthState(provider);
116
135
  if (!state?.refreshToken) {
117
136
  return { success: false, error: 'No refresh token available' };
118
137
  }
119
-
120
- // Provider-specific refresh logic would go here
121
- // For now, just indicate refresh is needed
122
- return {
123
- success: false,
124
- error: 'Token refresh required - reauthenticate manually',
125
- };
138
+
139
+ const adapter = refreshAdapters.get(provider);
140
+ if (!adapter) {
141
+ return {
142
+ success: false,
143
+ error: 'Token refresh required - reauthenticate manually',
144
+ };
145
+ }
146
+
147
+ try {
148
+ const result = await adapter(state);
149
+ if (result?.success && result.token) {
150
+ const next = {
151
+ ...state,
152
+ token: result.token,
153
+ refreshToken: result.refreshToken || state.refreshToken,
154
+ expiresAt: result.expiresAt || null,
155
+ rotatedAt: new Date().toISOString(),
156
+ };
157
+ saveAuthState(provider, next);
158
+ return { success: true, expiresAt: next.expiresAt };
159
+ }
160
+ return {
161
+ success: false,
162
+ error: result?.error || 'Refresh adapter returned no token',
163
+ };
164
+ } catch (err) {
165
+ return { success: false, error: `Refresh adapter threw: ${err.message}` };
166
+ }
126
167
  }
@@ -104,6 +104,20 @@ export function getBreaker(key, options) {
104
104
  return REGISTRY.get(key);
105
105
  }
106
106
 
107
+ export function getState(providerName) {
108
+ const b = REGISTRY.get(providerName);
109
+ if (!b) return null;
110
+ return {
111
+ state: b.state.toUpperCase(),
112
+ failures: b.failureCount,
113
+ lastFailure: b.openedAt ? new Date(b.openedAt) : null,
114
+ };
115
+ }
116
+
117
+ export function reset(providerName) {
118
+ REGISTRY.get(providerName)?.reset();
119
+ }
120
+
107
121
  export function describeBreakers() {
108
122
  return [...REGISTRY.values()].map((b) => ({
109
123
  key: b.key,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A provider integrates Construct with an external system that holds work
5
5
  * artefacts: GitHub repos, Jira projects, Confluence spaces, Slack channels,
6
- * Salesforce orgs, etc. Providers are stateless adapters durable state
6
+ * Salesforce orgs, etc. Providers are stateless adapters; durable state
7
7
  * (cached search results, last-seen markers, observations distilled from
8
8
  * fetched items) lives in core stores, not in the provider.
9
9
  *
@@ -29,11 +29,75 @@
29
29
  *
30
30
  * `assertProviderContract(provider)` runs at registry load time and refuses
31
31
  * to accept providers that miss required fields or declare capabilities
32
- * they don't implement. The check is deliberately loose providers can
33
- * extend the shape with extra methods but it gates the load-bearing
32
+ * they don't implement. The check is deliberately loose; providers can
33
+ * extend the shape with extra methods; but it gates the load-bearing
34
34
  * fields so a typo in a plugin can't take down the registry.
35
+ *
36
+ * Scope validation: `ALLOWLIST_SCHEMA` declares the allowlist fields
37
+ * supported per provider. `validateAllowlist(providerName, target, config)`
38
+ * checks whether `target` (a repo name, channel ID, project key, etc.) is
39
+ * permitted by the caller's config. When no allowlist fields are set the
40
+ * call is allowed through; when any allowlist field is set the target must
41
+ * satisfy it.
42
+ *
43
+ * Glob matching for `*Glob` fields follows minimatch-style single-segment
44
+ * patterns where `*` matches any sequence of non-slash characters.
35
45
  */
36
46
 
47
+ export const ALLOWLIST_SCHEMA = Object.freeze({
48
+ github: { fields: ['org', 'repoAllowlist', 'repoAllowGlob'] },
49
+ 'atlassian-jira': { fields: ['instance', 'projectAllowlist'] },
50
+ 'atlassian-confluence': { fields: ['instance', 'spaceAllowlist'] },
51
+ slack: { fields: ['workspace', 'channelAllowlist'] },
52
+ salesforce: { fields: ['instance', 'objectAllowlist'] },
53
+ });
54
+
55
+ function globMatch(pattern, value) {
56
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
57
+ return new RegExp(`^${escaped}$`).test(value);
58
+ }
59
+
60
+ export function validateAllowlist(providerName, target, config = {}) {
61
+ const schema = ALLOWLIST_SCHEMA[providerName];
62
+
63
+ // Unknown providers pass through; no schema means no restriction.
64
+ if (!schema) return { allowed: true, reason: 'no allowlist schema for provider' };
65
+
66
+ const listFields = schema.fields.filter((f) => f.endsWith('Allowlist'));
67
+ const globFields = schema.fields.filter((f) => f.endsWith('AllowGlob'));
68
+
69
+ const hasListConstraint = listFields.some((f) => Array.isArray(config[f]) && config[f].length > 0);
70
+ const hasGlobConstraint = globFields.some((f) => typeof config[f] === 'string' && config[f].length > 0);
71
+
72
+ // No allowlist configured: permit everything.
73
+ if (!hasListConstraint && !hasGlobConstraint) {
74
+ return { allowed: true, reason: 'no allowlist configured' };
75
+ }
76
+
77
+ // Check explicit list membership.
78
+ for (const field of listFields) {
79
+ if (Array.isArray(config[field]) && config[field].length > 0) {
80
+ if (config[field].includes(target)) {
81
+ return { allowed: true, reason: `matched ${field}` };
82
+ }
83
+ }
84
+ }
85
+
86
+ // Check glob patterns.
87
+ for (const field of globFields) {
88
+ if (typeof config[field] === 'string' && config[field].length > 0) {
89
+ if (globMatch(config[field], target)) {
90
+ return { allowed: true, reason: `matched ${field} pattern '${config[field]}'` };
91
+ }
92
+ }
93
+ }
94
+
95
+ return {
96
+ allowed: false,
97
+ reason: `'${target}' is not in the configured allowlist for ${providerName}`,
98
+ };
99
+ }
100
+
37
101
  export const CAPABILITIES = Object.freeze([
38
102
  'read',
39
103
  'search',
@@ -0,0 +1,219 @@
1
+ /**
2
+ * lib/providers/creds.mjs — credential management for provider integrations.
3
+ *
4
+ * Reads and writes provider credentials stored in `~/.construct/config.env`.
5
+ * Each provider block is delimited by comment markers so the file can be
6
+ * parsed without a full ini/dotenv library and can also be sourced by a shell.
7
+ *
8
+ * Block format (example for `github`):
9
+ *
10
+ * # CONSTRUCT_CREDS_GITHUB
11
+ * CONSTRUCT_CREDS_GITHUB_KEY=ghp_xxx
12
+ * CONSTRUCT_CREDS_GITHUB_ACCOUNT=myorg
13
+ * CONSTRUCT_CREDS_GITHUB_ROTATED_AT=2026-05-27
14
+ * # END_CONSTRUCT_CREDS_GITHUB
15
+ *
16
+ * Security: `writeCreds` sets the file mode to 0600 after every write.
17
+ * `checkCredsFileMode` reports whether the current mode is within policy.
18
+ *
19
+ * All functions are synchronous except those that need the filesystem and
20
+ * are documented as async. Currently all ops are sync; the async signature
21
+ * is reserved for future keychain-backend support.
22
+ */
23
+
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+
28
+ const CONSTRUCT_DIR = path.join(os.homedir(), '.construct');
29
+ const CONFIG_FILE = 'config.env';
30
+
31
+ export function credsFilePath() {
32
+ return path.join(CONSTRUCT_DIR, CONFIG_FILE);
33
+ }
34
+
35
+ function providerKey(provider) {
36
+ return provider.toUpperCase().replace(/-/g, '_');
37
+ }
38
+
39
+ function blockStart(key) {
40
+ return `# CONSTRUCT_CREDS_${key}`;
41
+ }
42
+
43
+ function blockEnd(key) {
44
+ return `# END_CONSTRUCT_CREDS_${key}`;
45
+ }
46
+
47
+ function readRaw() {
48
+ const fp = credsFilePath();
49
+ try {
50
+ return fs.readFileSync(fp, 'utf8');
51
+ } catch (err) {
52
+ if (err.code === 'ENOENT') return '';
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ function writeRaw(content) {
58
+ const fp = credsFilePath();
59
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
60
+ fs.writeFileSync(fp, content, { encoding: 'utf8', mode: 0o600 });
61
+
62
+ // Re-apply mode after write; some platforms reset on open.
63
+ fs.chmodSync(fp, 0o600);
64
+ }
65
+
66
+ function extractBlocks(raw) {
67
+ const blocks = {};
68
+ const lines = raw.split('\n');
69
+ let current = null;
70
+ let currentLines = [];
71
+
72
+ for (const line of lines) {
73
+ const startMatch = line.match(/^# CONSTRUCT_CREDS_([A-Z0-9_]+)$/);
74
+ const endMatch = line.match(/^# END_CONSTRUCT_CREDS_([A-Z0-9_]+)$/);
75
+
76
+ if (startMatch) {
77
+ current = startMatch[1];
78
+ currentLines = [line];
79
+ continue;
80
+ }
81
+ if (endMatch && current === endMatch[1]) {
82
+ currentLines.push(line);
83
+ blocks[current] = currentLines.join('\n');
84
+ current = null;
85
+ currentLines = [];
86
+ continue;
87
+ }
88
+ if (current) {
89
+ currentLines.push(line);
90
+ }
91
+ }
92
+
93
+ return blocks;
94
+ }
95
+
96
+ function parseBlock(blockText) {
97
+ const result = {};
98
+ for (const line of blockText.split('\n')) {
99
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
100
+ if (!m) continue;
101
+ result[m[1]] = m[2];
102
+ }
103
+ return result;
104
+ }
105
+
106
+ function buildBlock(key, { account, apiKey, rotatedAt }) {
107
+ const prefix = `CONSTRUCT_CREDS_${key}`;
108
+ const lines = [blockStart(key)];
109
+ if (apiKey !== undefined) lines.push(`${prefix}_KEY=${apiKey}`);
110
+ if (account !== undefined) lines.push(`${prefix}_ACCOUNT=${account}`);
111
+ if (rotatedAt !== undefined) lines.push(`${prefix}_ROTATED_AT=${rotatedAt}`);
112
+ lines.push(blockEnd(key));
113
+ return lines.join('\n');
114
+ }
115
+
116
+ function nextRotationDue(rotatedAtStr) {
117
+ if (!rotatedAtStr) return null;
118
+ const d = new Date(rotatedAtStr);
119
+ if (isNaN(d.getTime())) return null;
120
+ d.setDate(d.getDate() + 90);
121
+ return d.toISOString().slice(0, 10);
122
+ }
123
+
124
+ export function readCreds() {
125
+ const raw = readRaw();
126
+ const blocks = extractBlocks(raw);
127
+ const result = {};
128
+
129
+ for (const [upperKey, blockText] of Object.entries(blocks)) {
130
+ const prefix = `CONSTRUCT_CREDS_${upperKey}`;
131
+ const fields = parseBlock(blockText);
132
+ const rotatedAt = fields[`${prefix}_ROTATED_AT`] || null;
133
+ result[upperKey.toLowerCase().replace(/_/g, '-')] = {
134
+ account: fields[`${prefix}_ACCOUNT`] || null,
135
+ key: fields[`${prefix}_KEY`] || null,
136
+ rotatedAt,
137
+ nextRotationDue: nextRotationDue(rotatedAt),
138
+ };
139
+ }
140
+
141
+ return result;
142
+ }
143
+
144
+ export function writeCreds(provider, { key, account } = {}) {
145
+ const pKey = providerKey(provider);
146
+ const raw = readRaw();
147
+ const blocks = extractBlocks(raw);
148
+
149
+ const existing = blocks[pKey] ? parseBlock(blocks[pKey]) : {};
150
+ const prefix = `CONSTRUCT_CREDS_${pKey}`;
151
+
152
+ const rotatedAt = key !== undefined
153
+ ? new Date().toISOString().slice(0, 10)
154
+ : (existing[`${prefix}_ROTATED_AT`] || null);
155
+
156
+ const mergedAccount = account !== undefined ? account : (existing[`${prefix}_ACCOUNT`] || undefined);
157
+ const mergedKey = key !== undefined ? key : (existing[`${prefix}_KEY`] || undefined);
158
+
159
+ const newBlock = buildBlock(pKey, { account: mergedAccount, apiKey: mergedKey, rotatedAt });
160
+
161
+ let newRaw;
162
+ if (blocks[pKey]) {
163
+ const startIdx = raw.indexOf(blockStart(pKey));
164
+ const endIdx = raw.indexOf(blockEnd(pKey)) + blockEnd(pKey).length;
165
+ newRaw = raw.slice(0, startIdx) + newBlock + raw.slice(endIdx);
166
+ } else {
167
+ const trimmed = raw.trimEnd();
168
+ newRaw = trimmed ? `${trimmed}\n\n${newBlock}\n` : `${newBlock}\n`;
169
+ }
170
+
171
+ writeRaw(newRaw);
172
+ }
173
+
174
+ export function deleteCreds(provider) {
175
+ const pKey = providerKey(provider);
176
+ const raw = readRaw();
177
+ const start = blockStart(pKey);
178
+ const end = blockEnd(pKey);
179
+
180
+ const startIdx = raw.indexOf(start);
181
+ if (startIdx === -1) return;
182
+
183
+ const endIdx = raw.indexOf(end, startIdx);
184
+ if (endIdx === -1) return;
185
+
186
+ const before = raw.slice(0, startIdx).trimEnd();
187
+ const after = raw.slice(endIdx + end.length).trimStart();
188
+
189
+ const newRaw = before && after
190
+ ? `${before}\n\n${after}`
191
+ : before
192
+ ? `${before}\n`
193
+ : after || '';
194
+
195
+ writeRaw(newRaw);
196
+ }
197
+
198
+ export function checkCredsFileMode() {
199
+ const fp = credsFilePath();
200
+ try {
201
+ const stat = fs.statSync(fp);
202
+ const mode = stat.mode & 0o777;
203
+ const modeStr = '0' + mode.toString(8);
204
+ return { ok: mode <= 0o600, mode: modeStr };
205
+ } catch (err) {
206
+ if (err.code === 'ENOENT') return { ok: true, mode: 'absent' };
207
+ throw err;
208
+ }
209
+ }
210
+
211
+ export function listCreds() {
212
+ const all = readCreds();
213
+ return Object.entries(all).map(([provider, data]) => ({
214
+ provider,
215
+ account: data.account,
216
+ rotatedAt: data.rotatedAt,
217
+ nextRotationDue: data.nextRotationDue,
218
+ }));
219
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * lib/providers/scaffold.mjs — provider scaffold generator.
3
+ *
4
+ * Reads the template files from `templates/provider-scaffold/`, substitutes
5
+ * the %%PROVIDER_NAME%% and %%CAPABILITIES%% placeholders, and writes the
6
+ * result into `lib/providers/<name>/`. Returns the list of files created so
7
+ * the CLI can report them to the user.
8
+ *
9
+ * Supported capabilities: read, search, write, watch, webhook.
10
+ * The `read` and `search` methods are always included in the generated
11
+ * index.mjs; additional capabilities listed in `options.capabilities` add
12
+ * stub implementations for write, watch, and webhook.
13
+ *
14
+ * `scaffoldProvider` is idempotent with respect to the directory: it will
15
+ * not overwrite an existing file unless `options.force = true`. This prevents
16
+ * accidental loss of work when a developer reruns the command.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const TEMPLATES_DIR = path.resolve(__dirname, '..', '..', 'templates', 'provider-scaffold');
25
+
26
+ const ALL_CAPABILITIES = ['read', 'search', 'write', 'watch', 'webhook'];
27
+
28
+ function capabilityStub(cap) {
29
+ const stubs = {
30
+ write: `
31
+ async write(payload, config = {}) {
32
+ // Replace with a real write operation against the remote system. // construct-lint-ignore
33
+ throw new Error('not implemented');
34
+ },`,
35
+ watch: `
36
+ async watch(config = {}, callback) {
37
+ // Replace with a real subscription; call callback(item) on new events. // construct-lint-ignore
38
+ throw new Error('not implemented');
39
+ },`,
40
+ webhook: `
41
+ async webhook(config = {}, request) {
42
+ // Replace with real webhook signature verification and dispatch. // construct-lint-ignore
43
+ return { ok: false, error: 'not implemented' };
44
+ },`,
45
+ };
46
+ return stubs[cap] || '';
47
+ }
48
+
49
+ function renderIndex(name, capabilities) {
50
+ const capList = capabilities.filter((c) => ALL_CAPABILITIES.includes(c));
51
+ const capString = capList.join(', ');
52
+
53
+ const extraStubs = capList
54
+ .filter((c) => !['read', 'search'].includes(c))
55
+ .map(capabilityStub)
56
+ .join('\n');
57
+
58
+ const template = fs.readFileSync(path.join(TEMPLATES_DIR, 'index.mjs'), 'utf8');
59
+ let rendered = template
60
+ .replace(/%%PROVIDER_NAME%%/g, name)
61
+ .replace(/%%CAPABILITIES%%/g, capString);
62
+
63
+ if (extraStubs) {
64
+ rendered = rendered.replace(
65
+ /(\s+async search\(query, config = \{\}\) \{[\s\S]*?throw new Error\('not implemented'\);\n\s+\},)/,
66
+ `$1\n${extraStubs}`,
67
+ );
68
+ }
69
+
70
+ return rendered;
71
+ }
72
+
73
+ function renderTest(name) {
74
+ const template = fs.readFileSync(path.join(TEMPLATES_DIR, 'health.test.mjs'), 'utf8');
75
+ return template.replace(/%%PROVIDER_NAME%%/g, name);
76
+ }
77
+
78
+ export async function scaffoldProvider(name, options = {}) {
79
+ const {
80
+ capabilities = ['read', 'search'],
81
+ cwd = process.cwd(),
82
+ force = false,
83
+ } = options;
84
+
85
+ if (!name || typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
86
+ throw new Error(`scaffoldProvider: name must be a lowercase alphanumeric-and-hyphen string, got '${name}'`);
87
+ }
88
+
89
+ const providerDir = path.join(cwd, 'lib', 'providers', name);
90
+ fs.mkdirSync(providerDir, { recursive: true });
91
+
92
+ const files = [];
93
+
94
+ const indexPath = path.join(providerDir, 'index.mjs');
95
+ if (force || !fs.existsSync(indexPath)) {
96
+ fs.writeFileSync(indexPath, renderIndex(name, capabilities), 'utf8');
97
+ files.push(indexPath);
98
+ }
99
+
100
+ const testPath = path.join(providerDir, 'health.test.mjs');
101
+ if (force || !fs.existsSync(testPath)) {
102
+ fs.writeFileSync(testPath, renderTest(name), 'utf8');
103
+ files.push(testPath);
104
+ }
105
+
106
+ return { providerDir, files };
107
+ }
package/lib/reflect.mjs CHANGED
@@ -8,10 +8,11 @@
8
8
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
9
9
  import { join, resolve, basename } from 'node:path';
10
10
  import { execSync } from 'node:child_process';
11
- import { addObservation } from './observation-store.mjs';
11
+ import { addObservation, listObservations } from './observation-store.mjs';
12
12
  import { loadConstructEnv } from './env-config.mjs';
13
13
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS, inferKnowledgeTarget } from './knowledge/layout.mjs';
14
14
  import { extractSessionObservation } from './reflect/extractor.mjs';
15
+ import { readContextState, contextSummaryLine } from './context-state.mjs';
15
16
 
16
17
  const HOME = process.env.HOME || process.env.USERPROFILE;
17
18
  const USER_ENV_PATH = join(HOME, '.construct', 'config.env');
@@ -47,12 +48,19 @@ export async function runReflectCli(args) {
47
48
  target = `knowledge/${target}`;
48
49
  }
49
50
 
50
- // If no summary provided, try to get it from stdin or prompt
51
+ let summarySource = 'cli';
51
52
  if (!summary) {
52
- // For now, we'll require explicit summary - in future could read from chat context
53
- console.error('Error: --summary=<text> is required to capture improvement feedback');
54
- console.error('Example: construct reflect --target=internal --summary="Improve Slack channel intent parsing to handle edge cases"');
55
- process.exit(1);
53
+ const derived = deriveSummaryFromContext(process.cwd());
54
+ if (derived) {
55
+ summary = derived;
56
+ summarySource = 'auto-derived';
57
+ console.error(`Note: --summary not provided; using auto-derived summary from .cx/context.md and recent observations.`);
58
+ console.error(`Captured summary: "${summary.slice(0, 180)}${summary.length > 180 ? '…' : ''}"`);
59
+ } else {
60
+ console.error('Error: --summary=<text> is required (no .cx/context.md or recent session summaries to derive from)');
61
+ console.error('Example: construct reflect --target=internal --summary="Improve Slack channel intent parsing to handle edge cases"');
62
+ process.exit(1);
63
+ }
56
64
  }
57
65
 
58
66
  // Resolve the knowledge directory for this target
@@ -110,15 +118,14 @@ export async function runReflectCli(args) {
110
118
  // Write the file
111
119
  writeFileSync(filePath, content);
112
120
 
113
- // Record an observation about this feedback
114
- const observationSummary = `[reflect] Improvement feedback captured: ${summary.slice(0, 100)}${summary.length > 100 ? '...' : ''}`;
121
+ const observationSummary = `[reflect] Improvement feedback captured (${summarySource}): ${summary.slice(0, 100)}${summary.length > 100 ? '...' : ''}`;
115
122
  addObservation(process.cwd(), {
116
123
  role: 'construct',
117
- category: 'insight', // Feedback is typically an insight unless it's about a problem
124
+ category: 'insight',
118
125
  summary: observationSummary,
119
- content: `Feedback captured via construct reflect:\n\n${summary}\n\nStored as: ${filePath}\nTarget: ${knowledgeSubdir}/`,
120
- tags: ['reflect', 'improvement-feedback', `knowledge:${knowledgeSubdir}`],
121
- confidence: 0.9,
126
+ content: `Feedback captured via construct reflect (${summarySource}):\n\n${summary}\n\nStored as: ${filePath}\nTarget: ${knowledgeSubdir}/`,
127
+ tags: ['reflect', 'improvement-feedback', `knowledge:${knowledgeSubdir}`, `source:${summarySource}`],
128
+ confidence: summarySource === 'auto-derived' ? 0.65 : 0.9,
122
129
  source: 'reflect-command',
123
130
  });
124
131
 
@@ -186,6 +193,36 @@ export async function runReflectAuto({ transcriptPath, cwd, sessionId, durationM
186
193
  return result?.id ?? null;
187
194
  }
188
195
 
196
+ /**
197
+ * Derive a one-paragraph reflect summary from durable session state — the
198
+ * context.md digest first, then the most recent session-summary observation.
199
+ * Returns null when neither source yields useful content.
200
+ */
201
+ export function deriveSummaryFromContext(cwd) {
202
+ const parts = [];
203
+
204
+ try {
205
+ const state = readContextState(cwd);
206
+ const line = contextSummaryLine(state);
207
+ if (line && line.trim().length > 20) parts.push(line.trim());
208
+ } catch { /* best effort */ }
209
+
210
+ try {
211
+ const recent = listObservations(cwd, { limit: 5 }) || [];
212
+ const sessionSummary = recent.find((o) => Array.isArray(o.tags) && o.tags.includes('session-summary'));
213
+ if (sessionSummary?.summary) {
214
+ const text = String(sessionSummary.summary).replace(/^\[[^\]]+\]\s*/, '').trim();
215
+ if (text.length > 20 && !parts.some((p) => p.includes(text.slice(0, 40)))) {
216
+ parts.push(text);
217
+ }
218
+ }
219
+ } catch { /* best effort */ }
220
+
221
+ if (parts.length === 0) return null;
222
+ const joined = parts.join(' — ');
223
+ return joined.length > 600 ? joined.slice(0, 580) + '…' : joined;
224
+ }
225
+
189
226
  function safeProject(cwd) {
190
227
  try { return basename(cwd) || null; } catch { return null; }
191
228
  }