@geraldmaron/construct 1.0.18 → 1.0.20
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 -4
- package/bin/construct +105 -3
- package/db/schema/003_observation_reconciliation.sql +14 -0
- package/lib/bootstrap/resources.mjs +0 -1
- package/lib/cli-commands.mjs +57 -6
- package/lib/comment-lint.mjs +44 -0
- package/lib/config/schema.mjs +31 -1
- package/lib/contracts/validate.mjs +106 -0
- package/lib/decisions/enforced-baseline.json +25 -0
- package/lib/decisions/golden.mjs +87 -0
- package/lib/decisions/precedence.mjs +46 -0
- package/lib/decisions/registry.mjs +469 -0
- package/lib/deployment/parity-contract.mjs +148 -0
- package/lib/document-ingest.mjs +71 -6
- package/lib/embed/cli.mjs +11 -0
- package/lib/embed/conflict-detection.mjs +4 -4
- package/lib/embed/customer-profiles.mjs +1 -1
- package/lib/embed/reconcile.mjs +60 -0
- package/lib/embedded-contract/capability.mjs +3 -3
- package/lib/embedded-contract/contract-version.mjs +1 -1
- package/lib/embedded-contract/execution.mjs +184 -0
- package/lib/embedded-contract/index.mjs +16 -0
- package/lib/embedded-contract/ingest.mjs +61 -7
- package/lib/gates-audit.mjs +2 -2
- package/lib/hooks/_lib/output-mode.mjs +101 -0
- package/lib/hooks/config-protection.mjs +22 -3
- package/lib/hooks/guard-bash.mjs +1 -1
- package/lib/hooks/session-start.mjs +13 -1
- package/lib/ingest/provider-extract.mjs +200 -0
- package/lib/ingest/strategy.mjs +95 -0
- package/lib/init-docs.mjs +1 -0
- package/lib/mcp/server.mjs +72 -4
- package/lib/mcp/tools/embedded-contract.mjs +17 -1
- package/lib/mode-commands.mjs +6 -8
- package/lib/observation-store.mjs +16 -2
- package/lib/opencode-telemetry.mjs +1 -1
- package/lib/orchestration/run-store.mjs +82 -0
- package/lib/orchestration/runtime.mjs +240 -0
- package/lib/roles/cli.mjs +10 -2
- package/lib/roles/gateway.mjs +50 -1
- package/lib/scheduler/index.mjs +31 -0
- package/lib/server/index.mjs +13 -3
- package/lib/server/static/index.html +1 -1
- package/lib/setup.mjs +6 -0
- package/lib/storage/hybrid-query.mjs +49 -38
- package/lib/storage/rrf.mjs +42 -0
- package/lib/storage/vector-client.mjs +18 -3
- package/lib/telemetry/backends/local.mjs +1 -1
- package/lib/telemetry/skill-calls.mjs +1 -1
- package/lib/templates/visual-requirements.mjs +84 -0
- package/package.json +10 -1
- package/rules/common/comments.md +3 -0
- package/rules/common/no-fabrication.md +3 -0
- package/rules/common/precedence.md +19 -0
- package/rules/common/research-sources.md +32 -0
- package/rules/common/research.md +59 -2
- package/skills/roles/data-engineer.pipeline.md +13 -1
- package/skills/roles/debugger.md +9 -0
- package/skills/roles/designer.accessibility.md +13 -3
- package/skills/roles/designer.md +10 -0
- package/skills/roles/engineer.platform.md +8 -0
- package/skills/roles/operator.md +10 -1
- package/skills/roles/operator.release.md +8 -0
- package/skills/roles/operator.sre.md +10 -1
- package/skills/roles/orchestrator.md +9 -2
- package/skills/roles/product-manager.business-strategy.md +10 -1
- package/skills/roles/researcher.explorer.md +12 -1
- package/skills/roles/researcher.ux.md +13 -1
- package/skills/roles/reviewer.devil-advocate.md +14 -2
- package/skills/roles/reviewer.evaluator.md +17 -4
- package/skills/roles/reviewer.trace.md +12 -1
- package/skills/roles/security.legal-compliance.md +8 -0
- package/skills/roles/security.md +11 -0
- package/specialists/contracts.json +18 -0
- package/specialists/prompts/cx-researcher.md +4 -2
- package/templates/docs/backlog-proposal.md +1 -1
- package/templates/docs/customer-profile.md +1 -1
- package/templates/docs/evidence-brief.md +5 -1
- package/templates/docs/incident-report.md +37 -21
- package/templates/docs/prfaq.md +2 -2
- package/templates/docs/product-intelligence-report.md +1 -1
- package/templates/docs/research-brief.md +8 -6
- package/templates/docs/research-finding.md +32 -7
- package/templates/docs/rfc.md +13 -1
- package/templates/docs/runbook.md +20 -1
- package/templates/docs/signal-brief.md +4 -1
- package/templates/docs/skill-artifact.md +27 -7
- package/templates/docs/strategy.md +23 -2
- package/lib/bootstrap/lazy-install.mjs +0 -161
- package/lib/embed/jobs/vector-sync.mjs +0 -198
- package/lib/knowledge/postgres-search.mjs +0 -132
- package/lib/services/pattern-promotion-service.mjs +0 -167
- package/lib/storage/unified-storage.mjs +0 -550
package/lib/mcp/server.mjs
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
12
12
|
import { resolve, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { realpathSync } from 'node:fs';
|
|
15
15
|
import { loadToolkitEnv } from '../toolkit-env.mjs';
|
|
16
16
|
import { loadConstructEnv } from '../env-config.mjs';
|
|
17
|
+
import { getInstalledVersion } from '../version.mjs';
|
|
18
|
+
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
17
19
|
import { withGenAiSpan, GenAiAttrs, extractTraceContext, injectTraceContext } from '../telemetry/otel-tracer.mjs';
|
|
18
20
|
|
|
19
21
|
// Apply config.env values to process.env, letting config.env win over shell env
|
|
@@ -56,7 +58,7 @@ import {
|
|
|
56
58
|
profileCreate, profileArchive, sandboxList, learningStatus,
|
|
57
59
|
knowledgeGraphAsk,
|
|
58
60
|
} from './tools/profile.mjs';
|
|
59
|
-
import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe } from './tools/embedded-contract.mjs';
|
|
61
|
+
import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe, executionResolve } from './tools/embedded-contract.mjs';
|
|
60
62
|
|
|
61
63
|
const DEFAULT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
62
64
|
const ROOT_DIR = resolve(process.env.CX_TOOLKIT_DIR || DEFAULT_ROOT_DIR);
|
|
@@ -64,11 +66,58 @@ loadToolkitEnv(ROOT_DIR);
|
|
|
64
66
|
|
|
65
67
|
const opts = { ROOT_DIR };
|
|
66
68
|
|
|
69
|
+
// Identity an MCP host can render meaningfully: the real installed version (not a
|
|
70
|
+
// hardcoded stub), a one-line description of what the server offers (shown in the
|
|
71
|
+
// host's server-detail view), and a `construct://status` resource for live state.
|
|
72
|
+
// Without these the host shows only "<name> · OK" with an empty detail panel.
|
|
73
|
+
|
|
74
|
+
const VERSION = getInstalledVersion()?.version || 'unknown';
|
|
75
|
+
const DEPLOYMENT_MODE = getDeploymentMode(process.env, { cwd: ROOT_DIR });
|
|
76
|
+
const INSTRUCTIONS = [
|
|
77
|
+
`Construct MCP — agent orchestration for this workspace (v${VERSION}, ${DEPLOYMENT_MODE} mode).`,
|
|
78
|
+
'Tools cover: project context and diff review, skills/templates/teams, document ingestion and vector storage,',
|
|
79
|
+
'workflow orchestration and contracts, telemetry and scoring, and cross-session memory.',
|
|
80
|
+
'Read the `construct://status` resource for live state, or run `construct status` for full project health.',
|
|
81
|
+
'Start the dashboard with `construct dev`.',
|
|
82
|
+
].join(' ');
|
|
83
|
+
|
|
67
84
|
const server = new Server(
|
|
68
|
-
{ name: 'construct-mcp', version:
|
|
69
|
-
{ capabilities: { tools: {} } }
|
|
85
|
+
{ name: 'construct-mcp', version: VERSION },
|
|
86
|
+
{ capabilities: { tools: {}, resources: {} }, instructions: INSTRUCTIONS },
|
|
70
87
|
);
|
|
71
88
|
|
|
89
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
90
|
+
resources: [
|
|
91
|
+
{
|
|
92
|
+
uri: 'construct://status',
|
|
93
|
+
name: 'Construct status',
|
|
94
|
+
description: 'Live toolkit status: version, deployment mode, MCP broker, and how to get full project health.',
|
|
95
|
+
mimeType: 'application/json',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
101
|
+
if (req.params?.uri !== 'construct://status') {
|
|
102
|
+
throw new Error(`Unknown resource: ${req.params?.uri}`);
|
|
103
|
+
}
|
|
104
|
+
const payload = {
|
|
105
|
+
name: 'Construct',
|
|
106
|
+
version: VERSION,
|
|
107
|
+
deploymentMode: DEPLOYMENT_MODE,
|
|
108
|
+
mcpBroker: process.env.CONSTRUCT_MCP_BROKER === 'on' ? 'on' : 'off',
|
|
109
|
+
capabilities: [
|
|
110
|
+
'project-context', 'skills', 'templates', 'teams', 'document-ingestion',
|
|
111
|
+
'vector-storage', 'workflow', 'telemetry', 'memory',
|
|
112
|
+
],
|
|
113
|
+
fullHealth: 'run `construct status`',
|
|
114
|
+
dashboard: 'run `construct dev`, then open the printed URL',
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
contents: [{ uri: req.params.uri, mimeType: 'application/json', text: JSON.stringify(payload, null, 2) }],
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
72
121
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
73
122
|
tools: [
|
|
74
123
|
{
|
|
@@ -1109,6 +1158,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1109
1158
|
},
|
|
1110
1159
|
},
|
|
1111
1160
|
},
|
|
1161
|
+
{
|
|
1162
|
+
name: 'construct_execution_resolve',
|
|
1163
|
+
description: 'Resolve the execution-capability contract for an embedded workflow BEFORE/at workflow start: returns executionMode (construct-orchestrated | construct-prompt-only | host-direct | same-family-fallback), constructCapabilitiesActive (subset of personas/skills/workflow-routing/prompt-envelope), degraded + machine-readable degradationReason, requestedStrategy vs effectiveStrategy, and the resolved provider/model. Descriptive, not enforced: reports what Construct planned and can resolve a model for, never an observation that the host ran personas (see the semantics field). Read-only and secret-free.',
|
|
1164
|
+
inputSchema: {
|
|
1165
|
+
type: 'object',
|
|
1166
|
+
properties: {
|
|
1167
|
+
workflow_type: { type: 'string', description: 'Workflow whose orchestration plan is weighed (e.g. evidence-ingest, architecture-review). Absent ⇒ generic orchestration availability.' },
|
|
1168
|
+
requested_strategy: { type: 'string', enum: ['orchestrated', 'prompt-only', 'auto'], description: 'Desired execution strategy (default auto).' },
|
|
1169
|
+
use_construct: { type: 'boolean', description: 'false ⇒ host-direct (host runs without Construct capabilities). Default true.' },
|
|
1170
|
+
host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
|
|
1171
|
+
host_model: { type: 'string', description: 'Model the host is currently using, for model resolution.' },
|
|
1172
|
+
host_provider: { type: 'string', description: 'Provider family the host uses, when no host_model is given.' },
|
|
1173
|
+
requested_tier: { type: 'string', enum: ['reasoning', 'standard', 'fast'], description: 'Desired model tier; overrides the workflow-type hint.' },
|
|
1174
|
+
capabilities: { type: 'array', items: { type: 'string' }, description: 'Optional required capabilities; unverifiable ones are returned as warnings.' },
|
|
1175
|
+
allow_cross_provider_fallback: { type: 'boolean', description: 'Permit model fallback outside the host provider family (default false).' },
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1112
1179
|
],
|
|
1113
1180
|
}));
|
|
1114
1181
|
|
|
@@ -1190,6 +1257,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1190
1257
|
else if (name === 'triage_recommend') result = await triageRecommend(args);
|
|
1191
1258
|
else if (name === 'workflow_invoke') result = await workflowInvoke(args);
|
|
1192
1259
|
else if (name === 'capability_describe') result = capabilityDescribe(args);
|
|
1260
|
+
else if (name === 'construct_execution_resolve') result = executionResolve(args);
|
|
1193
1261
|
else result = { error: `Unknown tool: ${name}` };
|
|
1194
1262
|
} catch (err) {
|
|
1195
1263
|
result = { error: err.message ?? String(err) };
|
|
@@ -12,11 +12,12 @@ import { resolveEmbeddedModel as resolveModelCore } from '../../embedded-contrac
|
|
|
12
12
|
import { recommendPlan as recommendPlanCore } from '../../embedded-contract/triage.mjs';
|
|
13
13
|
import { invokeWorkflow as invokeWorkflowCore } from '../../embedded-contract/workflow-invoke.mjs';
|
|
14
14
|
import { buildCapabilityContract } from '../../embedded-contract/capability.mjs';
|
|
15
|
+
import { resolveExecution as resolveExecutionCore } from '../../embedded-contract/execution.mjs';
|
|
15
16
|
import { resolveInput } from '../../embedded-contract/ingest.mjs';
|
|
16
17
|
|
|
17
18
|
async function ingestArgs(args) {
|
|
18
19
|
if (args.input || !args.file_path) return { input: args.input, ingestion: undefined };
|
|
19
|
-
const resolved = await resolveInput({ filePath: args.file_path });
|
|
20
|
+
const resolved = await resolveInput({ filePath: args.file_path, strategy: args.ingest_strategy });
|
|
20
21
|
return { input: resolved.text, ingestion: resolved.error ? { ...(resolved.ingestion || {}), error: resolved.error } : resolved.ingestion };
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -75,3 +76,18 @@ export async function workflowInvoke(args = {}) {
|
|
|
75
76
|
export function capabilityDescribe(args = {}) {
|
|
76
77
|
return wrapContractResult(buildCapabilityContract({ rootDir: args.root_dir }), { surface: 'mcp' });
|
|
77
78
|
}
|
|
79
|
+
|
|
80
|
+
export function executionResolve(args = {}) {
|
|
81
|
+
const request = {
|
|
82
|
+
workflowType: args.workflow_type,
|
|
83
|
+
requestedStrategy: args.requested_strategy,
|
|
84
|
+
useConstruct: args.use_construct !== false,
|
|
85
|
+
host: args.host,
|
|
86
|
+
hostModel: args.host_model,
|
|
87
|
+
hostProvider: args.host_provider,
|
|
88
|
+
requestedTier: args.requested_tier,
|
|
89
|
+
capabilities: csv(args.capabilities),
|
|
90
|
+
allowCrossProviderFallback: Boolean(args.allow_cross_provider_fallback),
|
|
91
|
+
};
|
|
92
|
+
return wrapContractResult(resolveExecutionCore(request), { surface: 'mcp' });
|
|
93
|
+
}
|
package/lib/mode-commands.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// Standardized command contracts across deployment modes
|
|
10
10
|
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { basename, join } from 'node:path';
|
|
12
14
|
|
|
13
15
|
export const MODE_COMMANDS = {
|
|
14
16
|
// Intake queue operations
|
|
@@ -65,19 +67,15 @@ function getDeploymentMode() {
|
|
|
65
67
|
function parseIntakeFiles(stdout) {
|
|
66
68
|
return stdout.split('\n')
|
|
67
69
|
.filter(Boolean)
|
|
68
|
-
.map(f => ({ id:
|
|
70
|
+
.map(f => ({ id: basename(f, '.json'), source: 'file' }));
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
function readIntakeFile(id, options) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
const filePath = path.join(options.cwd || process.cwd(), '.cx/intake/pending', `${id}.json`);
|
|
75
|
-
|
|
76
|
-
if (!fs.existsSync(filePath)) {
|
|
74
|
+
const filePath = join(options.cwd || process.cwd(), '.cx/intake/pending', `${id}.json`);
|
|
75
|
+
if (!existsSync(filePath)) {
|
|
77
76
|
return { error: `Intake item ${id} not found` };
|
|
78
77
|
}
|
|
79
|
-
|
|
80
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
78
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
async function queryPostgresIntake(options) {
|
|
@@ -25,6 +25,18 @@ const OBS_DIR = '.cx/observations';
|
|
|
25
25
|
const INDEX_FILE = 'index.json';
|
|
26
26
|
const VECTORS_FILE = 'vectors.json';
|
|
27
27
|
const MAX_INDEX = 1000;
|
|
28
|
+
|
|
29
|
+
// The exact text fed to the embedder, and its content hash. addObservation and
|
|
30
|
+
// the reconciliation pass MUST derive both the same way, so the (content_hash,
|
|
31
|
+
// model) fingerprint they compare is computed identically.
|
|
32
|
+
|
|
33
|
+
export function observationSearchText(obs) {
|
|
34
|
+
return [obs.summary, obs.content, ...(obs.tags || [])].filter(Boolean).join(' ');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function observationContentHash(text) {
|
|
38
|
+
return crypto.createHash('sha256').update(String(text || '')).digest('hex');
|
|
39
|
+
}
|
|
28
40
|
const MAX_SUMMARY = 500;
|
|
29
41
|
const MAX_CONTENT = 2000;
|
|
30
42
|
const MAX_TAGS = 10;
|
|
@@ -214,11 +226,13 @@ export async function addObservation(rootDir, {
|
|
|
214
226
|
try {
|
|
215
227
|
const client = new VectorClient();
|
|
216
228
|
if (await client.isHealthy() && await client.isPgvectorEnabled()) {
|
|
217
|
-
const searchText =
|
|
218
|
-
const { embedding } = await embedTextEngine(searchText);
|
|
229
|
+
const searchText = observationSearchText(record);
|
|
230
|
+
const { embedding, model } = await embedTextEngine(searchText);
|
|
219
231
|
await client.storeObservation({
|
|
220
232
|
...record,
|
|
221
233
|
embedding,
|
|
234
|
+
contentHash: observationContentHash(searchText),
|
|
235
|
+
model,
|
|
222
236
|
});
|
|
223
237
|
await client.close();
|
|
224
238
|
return record;
|
|
@@ -29,7 +29,7 @@ export function getIngestClient(env = process.env) {
|
|
|
29
29
|
rootDir: env.CX_TOOLKIT_DIR,
|
|
30
30
|
onError: (err) => {
|
|
31
31
|
if (env.CONSTRUCT_TRACE_DEBUG === "1") {
|
|
32
|
-
|
|
32
|
+
|
|
33
33
|
console.error("[construct-telemetry]", err?.message || err);
|
|
34
34
|
}
|
|
35
35
|
},
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/run-store.mjs — filesystem-backed persistence for the local
|
|
3
|
+
* orchestration runtime (Mode-A, zero-dependency).
|
|
4
|
+
*
|
|
5
|
+
* Orchestration runs and their task lifecycle live as JSON under the project's
|
|
6
|
+
* `.cx/runtime/orchestration/runs/`, so a run survives editor/host restarts and
|
|
7
|
+
* is inspectable without a daemon, a database, or Docker. Writes are atomic
|
|
8
|
+
* (temp file then rename) so a crash mid-write cannot leave a half-written run;
|
|
9
|
+
* reads tolerate a corrupt file by skipping it rather than throwing. This is the
|
|
10
|
+
* Mode-A backend; Mode-B/C (SQLite/Postgres) would implement the same load/save
|
|
11
|
+
* surface without changing callers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const RUNTIME_REL = join('.cx', 'runtime', 'orchestration');
|
|
18
|
+
|
|
19
|
+
let writeCounter = 0;
|
|
20
|
+
|
|
21
|
+
function projectRoot(cwd) {
|
|
22
|
+
return isAbsolute(cwd) ? cwd : resolve(cwd);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runtimeDir(cwd = process.cwd()) {
|
|
26
|
+
return join(projectRoot(cwd), RUNTIME_REL);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runsDir(cwd) {
|
|
30
|
+
return join(runtimeDir(cwd), 'runs');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Atomic JSON write: a same-directory temp file then rename, which is atomic on
|
|
34
|
+
// POSIX filesystems. A per-process counter keeps concurrent writers from
|
|
35
|
+
// colliding on the temp name before the rename lands.
|
|
36
|
+
|
|
37
|
+
function atomicWriteJson(filePath, value) {
|
|
38
|
+
writeCounter = (writeCounter + 1) % 100000;
|
|
39
|
+
const tmp = `${filePath}.${process.pid}.${writeCounter}.tmp`;
|
|
40
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
41
|
+
renameSync(tmp, filePath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveRun(cwd, run) {
|
|
45
|
+
const dir = runsDir(cwd);
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
atomicWriteJson(join(dir, `${run.runId}.json`), run);
|
|
48
|
+
return run;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadRun(cwd, runId) {
|
|
52
|
+
if (!runId) return null;
|
|
53
|
+
const file = join(runsDir(cwd), `${runId}.json`);
|
|
54
|
+
if (!existsSync(file)) return null;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listRuns(cwd, { limit = 20 } = {}) {
|
|
63
|
+
const dir = runsDir(cwd);
|
|
64
|
+
if (!existsSync(dir)) return [];
|
|
65
|
+
const runs = [];
|
|
66
|
+
for (const name of readdirSync(dir)) {
|
|
67
|
+
if (!name.endsWith('.json')) continue;
|
|
68
|
+
try {
|
|
69
|
+
runs.push(JSON.parse(readFileSync(join(dir, name), 'utf8')));
|
|
70
|
+
} catch {
|
|
71
|
+
/* a corrupt run file is skipped, not fatal to listing */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
runs.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
75
|
+
return runs.slice(0, limit).map((run) => ({
|
|
76
|
+
runId: run.runId,
|
|
77
|
+
status: run.status,
|
|
78
|
+
executionMode: run.execution?.executionMode || null,
|
|
79
|
+
createdAt: run.createdAt,
|
|
80
|
+
request: run.request?.summary || null,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/runtime.mjs — Construct-owned local orchestration runtime (Mode-A).
|
|
3
|
+
*
|
|
4
|
+
* The product goal: hosts are front doors, Construct is the system. A
|
|
5
|
+
* host that lacks native multi-agent execution should still reach equivalent
|
|
6
|
+
* Construct outcomes through a Construct-owned runtime rather than depending on
|
|
7
|
+
* whichever editor exposes the strongest teammate mechanics. This module is the
|
|
8
|
+
* Mode-A (zero-dependency, single-process, filesystem-backed) slice of that
|
|
9
|
+
* runtime: it intakes a request, plans/decomposes it into a sequenced specialist
|
|
10
|
+
* chain (reusing the orchestration-policy planner), resolves the execution-
|
|
11
|
+
* capability contract (reusing resolveExecution), persists a durable run + task
|
|
12
|
+
* lifecycle (run-store), and emits lifecycle traces.
|
|
13
|
+
*
|
|
14
|
+
* Honesty boundary (ADR-0020, extending ADR-0019): the Mode-A worker backend is
|
|
15
|
+
* `inline` — it OWNS planning, sequencing, handoff state, persistence, and
|
|
16
|
+
* observability, and PREPARES each specialist task (role, reason, handoff
|
|
17
|
+
* contract) for a downstream executor. It does NOT itself perform specialist LLM
|
|
18
|
+
* reasoning; a provider-backed worker backend is a separate, later backend. The
|
|
19
|
+
* run reports `workerBackend` and per-task `executor` so a host can never mistake
|
|
20
|
+
* a prepared task for executed specialist output. When the execution contract
|
|
21
|
+
* resolves to prompt-only or host-direct, Construct owns no specialist task
|
|
22
|
+
* sequence and the run records that explicitly rather than implying orchestration.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { randomBytes } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
import { routeRequest } from '../orchestration-policy.mjs';
|
|
28
|
+
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
29
|
+
import { detectHostCapabilities } from '../host-capabilities.mjs';
|
|
30
|
+
import { newTraceId, newSpanId, emitTraceEvent } from '../worker/trace.mjs';
|
|
31
|
+
import { saveRun, loadRun, listRuns } from './run-store.mjs';
|
|
32
|
+
|
|
33
|
+
export const WORKER_BACKENDS = ['inline'];
|
|
34
|
+
export const DEFAULT_WORKER_BACKEND = 'inline';
|
|
35
|
+
|
|
36
|
+
const RUNTIME_SEMANTICS = 'Mode-A inline backend owns planning, sequencing, handoff state, persistence, and observability, and prepares each specialist task for a downstream executor. It does not perform specialist LLM reasoning itself; tasks reaching status "prepared" are ready for a worker backend or host to execute.';
|
|
37
|
+
|
|
38
|
+
function newRunId() {
|
|
39
|
+
return `run-${randomBytes(6).toString('hex')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function truncate(text, max) {
|
|
43
|
+
const s = String(text || '');
|
|
44
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// hostRole reports how the calling host orchestrates, so an adapter can decide
|
|
48
|
+
// whether to delegate to Construct's runtime (its reason for being). A direct
|
|
49
|
+
// CLI call has no host; an unrecognized host name is reported as unknown rather
|
|
50
|
+
// than guessed.
|
|
51
|
+
|
|
52
|
+
function resolveHostRole(hostName) {
|
|
53
|
+
if (!hostName) return 'cli-direct';
|
|
54
|
+
const match = detectHostCapabilities().find((h) => h.host.toLowerCase() === String(hostName).toLowerCase());
|
|
55
|
+
return match ? match.orchestration : 'unknown';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pickExecution(data) {
|
|
59
|
+
return {
|
|
60
|
+
executionMode: data.executionMode,
|
|
61
|
+
effectiveStrategy: data.effectiveStrategy,
|
|
62
|
+
requestedStrategy: data.requestedStrategy,
|
|
63
|
+
constructCapabilitiesActive: data.constructCapabilitiesActive,
|
|
64
|
+
degraded: data.degraded,
|
|
65
|
+
degradationReason: data.degradationReason,
|
|
66
|
+
selectedProvider: data.selectedProvider,
|
|
67
|
+
selectedModel: data.selectedModel,
|
|
68
|
+
resolutionSource: data.resolutionSource,
|
|
69
|
+
orchestrationPlanned: data.orchestrationPlanned,
|
|
70
|
+
orchestrationAvailable: data.orchestrationAvailable,
|
|
71
|
+
deploymentMode: data.deploymentMode,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildTasks(route) {
|
|
76
|
+
const reasons = route.dispatchReasons || {};
|
|
77
|
+
const handoffByProducer = new Map();
|
|
78
|
+
for (const edge of route.contractChain || []) {
|
|
79
|
+
const producer = edge.contract?.producer;
|
|
80
|
+
if (producer && !handoffByProducer.has(producer)) handoffByProducer.set(producer, edge.contract.id);
|
|
81
|
+
}
|
|
82
|
+
return (route.specialists || []).map((role, i) => ({
|
|
83
|
+
id: `t${i + 1}`,
|
|
84
|
+
seq: i,
|
|
85
|
+
role,
|
|
86
|
+
reason: reasons[role] || null,
|
|
87
|
+
handoffContract: handoffByProducer.get(role.replace(/^cx-/, '')) || null,
|
|
88
|
+
status: 'queued',
|
|
89
|
+
executor: null,
|
|
90
|
+
startedAt: null,
|
|
91
|
+
finishedAt: null,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Plan a run: resolve the execution contract, decompose into a specialist chain,
|
|
97
|
+
* and persist a durable run record (status `planned`). Pure of model calls.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} request
|
|
100
|
+
* @param {object} [opts] { env, cwd }
|
|
101
|
+
* @returns {object} the persisted run
|
|
102
|
+
*/
|
|
103
|
+
export function planRun(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
104
|
+
const {
|
|
105
|
+
request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
|
|
106
|
+
host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
|
|
107
|
+
} = request;
|
|
108
|
+
|
|
109
|
+
const execData = resolveExecution(
|
|
110
|
+
{ workflowType, requestedStrategy, useConstruct, host, hostModel, hostProvider },
|
|
111
|
+
{ env, cwd },
|
|
112
|
+
);
|
|
113
|
+
const route = routeRequest({ request: text, fileCount, moduleCount });
|
|
114
|
+
|
|
115
|
+
const traceId = newTraceId();
|
|
116
|
+
const runId = newRunId();
|
|
117
|
+
const now = new Date().toISOString();
|
|
118
|
+
|
|
119
|
+
// Construct owns a specialist task sequence only when the contract resolves to
|
|
120
|
+
// orchestrated execution; prompt-only and host-direct own none, and the run
|
|
121
|
+
// records that rather than implying orchestration that did not happen.
|
|
122
|
+
const orchestrates = execData.effectiveStrategy === 'orchestrated';
|
|
123
|
+
const tasks = orchestrates ? buildTasks(route) : [];
|
|
124
|
+
|
|
125
|
+
const run = {
|
|
126
|
+
runId,
|
|
127
|
+
traceId,
|
|
128
|
+
createdAt: now,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
request: { summary: truncate(text, 200), workflowType, requestedStrategy },
|
|
131
|
+
hostRole: resolveHostRole(host),
|
|
132
|
+
workerBackend: DEFAULT_WORKER_BACKEND,
|
|
133
|
+
execution: pickExecution(execData),
|
|
134
|
+
plan: {
|
|
135
|
+
intent: route.intent,
|
|
136
|
+
track: route.track,
|
|
137
|
+
specialists: route.specialists || [],
|
|
138
|
+
dispatchPlan: route.dispatchPlan,
|
|
139
|
+
contractChain: (route.contractChain || []).map((e) => ({ id: e.contract?.id, producer: e.contract?.producer, consumer: e.contract?.consumer, stage: e.stage })),
|
|
140
|
+
},
|
|
141
|
+
tasks,
|
|
142
|
+
status: 'planned',
|
|
143
|
+
warnings: execData.warnings || [],
|
|
144
|
+
semantics: RUNTIME_SEMANTICS,
|
|
145
|
+
executionSemantics: execData.semantics,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
saveRun(cwd, run);
|
|
149
|
+
emitTraceEvent({
|
|
150
|
+
rootDir: cwd, env, traceId, spanId: newSpanId(), eventType: 'task_graph.created',
|
|
151
|
+
metadata: { runId, executionMode: run.execution.executionMode, specialists: run.plan.specialists, workerBackend: run.workerBackend },
|
|
152
|
+
});
|
|
153
|
+
return run;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Execute a planned run through the inline (Mode-A) worker backend: advance each
|
|
158
|
+
* task queued → running → prepared, persisting after each transition so the run
|
|
159
|
+
* is resumable and observable. The inline backend prepares tasks; it does not
|
|
160
|
+
* perform specialist LLM reasoning.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} cwd
|
|
163
|
+
* @param {string} runId
|
|
164
|
+
* @param {object} [opts] { env }
|
|
165
|
+
* @returns {object} the completed run
|
|
166
|
+
*/
|
|
167
|
+
export function executeRun(cwd, runId, { env = process.env } = {}) {
|
|
168
|
+
const run = loadRun(cwd, runId);
|
|
169
|
+
if (!run) {
|
|
170
|
+
const err = new Error(`Orchestration run not found: ${runId}`);
|
|
171
|
+
err.code = 'RUN_NOT_FOUND';
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
run.status = 'running';
|
|
176
|
+
run.updatedAt = new Date().toISOString();
|
|
177
|
+
saveRun(cwd, run);
|
|
178
|
+
|
|
179
|
+
for (const task of run.tasks) {
|
|
180
|
+
task.status = 'running';
|
|
181
|
+
task.startedAt = new Date().toISOString();
|
|
182
|
+
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId } });
|
|
183
|
+
|
|
184
|
+
task.executor = 'inline:prepared';
|
|
185
|
+
task.status = 'prepared';
|
|
186
|
+
task.finishedAt = new Date().toISOString();
|
|
187
|
+
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: { runId, status: 'prepared' } });
|
|
188
|
+
|
|
189
|
+
run.updatedAt = new Date().toISOString();
|
|
190
|
+
saveRun(cwd, run);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
run.status = 'completed';
|
|
194
|
+
run.updatedAt = new Date().toISOString();
|
|
195
|
+
saveRun(cwd, run);
|
|
196
|
+
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
|
|
197
|
+
return run;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Plan and execute in one call (the common `orchestrate run` path).
|
|
202
|
+
*/
|
|
203
|
+
export function runOrchestration(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
204
|
+
const planned = planRun(request, { env, cwd });
|
|
205
|
+
return executeRun(cwd, planned.runId, { env });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The structured metadata a host adapter consumes for runtime-backed integration.
|
|
210
|
+
*/
|
|
211
|
+
export function hostAdapterMetadata(run) {
|
|
212
|
+
const e = run.execution || {};
|
|
213
|
+
return {
|
|
214
|
+
runId: run.runId,
|
|
215
|
+
traceId: run.traceId,
|
|
216
|
+
status: run.status,
|
|
217
|
+
requestedStrategy: e.requestedStrategy,
|
|
218
|
+
effectiveStrategy: e.effectiveStrategy,
|
|
219
|
+
executionMode: e.executionMode,
|
|
220
|
+
constructCapabilitiesActive: e.constructCapabilitiesActive,
|
|
221
|
+
workerBackend: run.workerBackend,
|
|
222
|
+
hostRole: run.hostRole,
|
|
223
|
+
degraded: e.degraded,
|
|
224
|
+
degradationReason: e.degradationReason,
|
|
225
|
+
selectedProvider: e.selectedProvider,
|
|
226
|
+
selectedModel: e.selectedModel,
|
|
227
|
+
tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor })),
|
|
228
|
+
warnings: run.warnings || [],
|
|
229
|
+
semantics: run.semantics,
|
|
230
|
+
executionSemantics: run.executionSemantics,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getRun(cwd, runId) {
|
|
235
|
+
return loadRun(cwd, runId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function getRuns(cwd, opts) {
|
|
239
|
+
return listRuns(cwd, opts);
|
|
240
|
+
}
|
package/lib/roles/cli.mjs
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* latest — show the most recent unresolved invocation (full brief)
|
|
7
7
|
* show <fingerprint> — show one invocation by fingerprint
|
|
8
8
|
* status — show onboarded personas + their event types
|
|
9
|
+
* resolve <fp> — mark one invocation resolved
|
|
10
|
+
* prune — compact the queue: drop resolved + TTL-expired entries
|
|
9
11
|
* reset — clear the pending queue (manual recovery)
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { listOnboardedPersonas, loadManifest } from './manifest.mjs';
|
|
13
|
-
import { listPending, markResolved, resetPending } from './gateway.mjs';
|
|
15
|
+
import { listPending, markResolved, resetPending, prunePending } from './gateway.mjs';
|
|
14
16
|
import { recent as recentEvents } from './event-bus.mjs';
|
|
15
17
|
|
|
16
18
|
function fmtTime(ts) {
|
|
@@ -106,6 +108,12 @@ export async function runCli(args) {
|
|
|
106
108
|
return ok ? 0 : 1;
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
if (sub === 'prune') {
|
|
112
|
+
const { removed, resolved, expired, fixtures, kept } = prunePending();
|
|
113
|
+
console.log(`Pruned ${removed} entr${removed === 1 ? 'y' : 'ies'} (${resolved} resolved, ${expired} expired, ${fixtures} fixture) · ${kept} remaining.`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
109
117
|
if (sub === 'reset') {
|
|
110
118
|
resetPending();
|
|
111
119
|
console.log('Pending queue cleared.');
|
|
@@ -113,6 +121,6 @@ export async function runCli(args) {
|
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
console.error(`Unknown role subcommand: ${sub}`);
|
|
116
|
-
console.error('Usage: construct role [list|latest|show <fp>|status|resolve <fp>|reset]');
|
|
124
|
+
console.error('Usage: construct role [list|latest|show <fp>|status|resolve <fp>|prune|reset]');
|
|
117
125
|
return 2;
|
|
118
126
|
}
|
package/lib/roles/gateway.mjs
CHANGED
|
@@ -30,6 +30,7 @@ const DEFAULTS = {
|
|
|
30
30
|
thresholdWindowMs: 10 * 60 * 1000,
|
|
31
31
|
cooldownMs: 30 * 60 * 1000,
|
|
32
32
|
rateCeilingPerHour: 3,
|
|
33
|
+
pendingTtlMs: 14 * 24 * 60 * 60 * 1000,
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
function ensureDir() {
|
|
@@ -226,10 +227,58 @@ export async function recordAndMaybeInvoke(eventType, payload = {}) {
|
|
|
226
227
|
return { recorded: true, ...r };
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
// A surfaced role invocation is a prompt to act, not a durable record (bd owns
|
|
231
|
+
// the durable issue). Unresolved entries older than the TTL are stale noise —
|
|
232
|
+
// e.g. pre-fixture-guard escalations that would otherwise surface at every
|
|
233
|
+
// session-start forever — so they drop out of listings and never expire bd work.
|
|
234
|
+
|
|
235
|
+
function isExpired(entry, now) {
|
|
236
|
+
return typeof entry.ts === 'number' && (now - entry.ts) > DEFAULTS.pendingTtlMs;
|
|
237
|
+
}
|
|
238
|
+
|
|
229
239
|
export function listPending({ unresolved = true } = {}) {
|
|
230
240
|
const entries = readPending();
|
|
231
241
|
if (!unresolved) return entries;
|
|
232
|
-
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
return entries.filter((e) => !e.resolvedAt && !isExpired(e, now));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// An entry that names an OS-tmp path is a pre-guard test-fixture escalation
|
|
247
|
+
// (shouldEscalate now blocks these at the source). Sweep them on prune so the
|
|
248
|
+
// historical noise — e.g. the cx-secrets fixtures — clears immediately rather
|
|
249
|
+
// than waiting out the TTL.
|
|
250
|
+
|
|
251
|
+
function referencesFixture(entry) {
|
|
252
|
+
for (const field of [entry.cwd, entry.project, entry.summary]) {
|
|
253
|
+
if (typeof field !== 'string') continue;
|
|
254
|
+
for (const token of field.split(/\s+/)) {
|
|
255
|
+
if (isTestFixturePath(token.replace(/[)\].,:;]+$/, ''))) return true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Compact the queue on disk: drop resolved, TTL-expired, and fixture-path
|
|
262
|
+
// entries. Idempotent; returns counts so the CLI and session-start can report
|
|
263
|
+
// what was reclaimed.
|
|
264
|
+
|
|
265
|
+
export function prunePending({ now = Date.now() } = {}) {
|
|
266
|
+
const entries = readPending();
|
|
267
|
+
const kept = [];
|
|
268
|
+
let resolved = 0;
|
|
269
|
+
let expired = 0;
|
|
270
|
+
let fixtures = 0;
|
|
271
|
+
for (const e of entries) {
|
|
272
|
+
if (e.resolvedAt) { resolved += 1; continue; }
|
|
273
|
+
if (isExpired(e, now)) { expired += 1; continue; }
|
|
274
|
+
if (referencesFixture(e)) { fixtures += 1; continue; }
|
|
275
|
+
kept.push(e);
|
|
276
|
+
}
|
|
277
|
+
const removed = resolved + expired + fixtures;
|
|
278
|
+
if (removed > 0) {
|
|
279
|
+
writeFileSync(pendingPath(), kept.length ? kept.map((e) => JSON.stringify(e)).join('\n') + '\n' : '');
|
|
280
|
+
}
|
|
281
|
+
return { removed, resolved, expired, fixtures, kept: kept.length };
|
|
233
282
|
}
|
|
234
283
|
|
|
235
284
|
// Drain events that accumulated outside a Claude session. Walks recent
|