@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.
- package/README.md +39 -34
- package/bin/construct +1403 -96
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +2 -2
- package/commands/work/parallel-review.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +8 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +139 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +371 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +360 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +420 -17
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +50 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/env-config.mjs +9 -3
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +14 -1
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +73 -65
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +309 -58
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +31 -6
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -1
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +11 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/model-registry.mjs +1 -1
- package/lib/observation-store.mjs +9 -4
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/reflect.mjs +49 -12
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +29 -29
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/client.mjs +5 -1
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +16 -3
- package/personas/construct.md +5 -3
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/specialists/contracts.json +1002 -0
- package/specialists/contracts.schema.json +83 -0
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-architect.md +3 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-debugger.md +3 -1
- package/{agents → specialists}/prompts/cx-designer.md +3 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
- package/{agents → specialists}/prompts/cx-engineer.md +4 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
- package/{agents → specialists}/prompts/cx-explorer.md +3 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
- package/{agents → specialists}/prompts/cx-operations.md +3 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-qa.md +3 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-researcher.md +3 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
- package/{agents → specialists}/prompts/cx-security.md +3 -1
- package/{agents → specialists}/prompts/cx-sre.md +4 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
- package/{agents → specialists}/registry.json +651 -418
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/docs/construct_guide.md +6 -6
- package/templates/docs/skill-artifact.md +1 -1
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- package/agents/contracts.json +0 -387
- /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
|
|
121
|
-
*
|
|
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
|
|
133
|
+
const adapterKey = resolveAdapterKey(modelId);
|
|
134
|
+
const loader = ADAPTERS[adapterKey] || ADAPTERS.generic;
|
|
129
135
|
|
|
136
|
+
let caps;
|
|
130
137
|
if (probe) {
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
fs.mkdirSync(
|
|
12
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
|
33
|
-
* extend the shape with extra methods
|
|
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
|
-
|
|
51
|
+
let summarySource = 'cli';
|
|
51
52
|
if (!summary) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
}
|