@geraldmaron/construct 1.0.20 → 1.0.23
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 +23 -5
- package/bin/construct +194 -16
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +44 -16
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +22 -0
- package/lib/decisions/enforced-baseline.json +3 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/document-ingest.mjs +13 -4
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +25 -7
- package/lib/embedded-contract/triage.mjs +24 -2
- package/lib/embedded-contract/workflow-invoke.mjs +21 -0
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/ingest/strategy.mjs +35 -3
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +6 -2
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/run-store-postgres.mjs +85 -0
- package/lib/orchestration/run-store-sqlite.mjs +122 -0
- package/lib/orchestration/runtime.mjs +188 -50
- package/lib/orchestration/store.mjs +102 -0
- package/lib/orchestration/worker.mjs +215 -0
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +80 -26
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +171 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/reconcile/postgres-namespace.mjs +102 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +39 -14
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +165 -141
- package/lib/storage/backend.mjs +14 -0
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/package.json +2 -2
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -4
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +381 -95
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +2 -11
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +29 -29
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +14 -14
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/lib/server/static/index.html +0 -1
package/lib/document-ingest.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { syncFileStateToSql } from './storage/sync.mjs';
|
|
|
14
14
|
import { stampFrontmatter } from './doc-stamp.mjs';
|
|
15
15
|
import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
|
|
16
16
|
import { loadProjectConfig } from './config/project-config.mjs';
|
|
17
|
-
import { resolveIngestStrategy, INGEST_STRATEGIES } from './ingest/strategy.mjs';
|
|
17
|
+
import { resolveIngestStrategy, INGEST_STRATEGIES, INGEST_ORCHESTRATION_STRATEGIES } from './ingest/strategy.mjs';
|
|
18
18
|
import { extractViaProvider } from './ingest/provider-extract.mjs';
|
|
19
19
|
|
|
20
20
|
const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
|
|
@@ -167,6 +167,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
167
167
|
strict = false,
|
|
168
168
|
highFidelity = true,
|
|
169
169
|
strategy = null,
|
|
170
|
+
orchestration = null,
|
|
170
171
|
env = process.env,
|
|
171
172
|
} = {}) {
|
|
172
173
|
if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
|
|
@@ -182,7 +183,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
const { config } = loadProjectConfig(cwd, env);
|
|
185
|
-
const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy });
|
|
186
|
+
const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy, orchestrationOverride: orchestration, cwd });
|
|
186
187
|
|
|
187
188
|
const results = [];
|
|
188
189
|
let totalDrops = 0;
|
|
@@ -266,9 +267,11 @@ export async function ingestDocuments(inputPaths, {
|
|
|
266
267
|
ingestion: {
|
|
267
268
|
strategy: resolvedStrategy.strategy,
|
|
268
269
|
fallback: resolvedStrategy.fallback,
|
|
270
|
+
orchestration: resolvedStrategy.orchestration,
|
|
269
271
|
model: resolvedStrategy.model,
|
|
270
272
|
provider: resolvedStrategy.provider,
|
|
271
273
|
fallbackApplied,
|
|
274
|
+
execution: resolvedStrategy.execution,
|
|
272
275
|
},
|
|
273
276
|
};
|
|
274
277
|
}
|
|
@@ -282,6 +285,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
282
285
|
let strict = false;
|
|
283
286
|
let highFidelity = true;
|
|
284
287
|
let strategy = null;
|
|
288
|
+
let orchestration = null;
|
|
285
289
|
|
|
286
290
|
for (const arg of argv) {
|
|
287
291
|
if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
|
|
@@ -289,6 +293,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
289
293
|
else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
|
|
290
294
|
else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
|
|
291
295
|
else if (arg === '--strategy') strategy = '';
|
|
296
|
+
else if (arg.startsWith('--orchestration=')) orchestration = arg.split('=').slice(1).join('=');
|
|
292
297
|
else if (arg === '--sync') sync = true;
|
|
293
298
|
else if (arg === '--strict') strict = true;
|
|
294
299
|
else if (arg === '--legacy-extractor') highFidelity = false;
|
|
@@ -299,8 +304,9 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
299
304
|
if (inputs.length === 0) {
|
|
300
305
|
throw new Error(
|
|
301
306
|
`Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
|
|
302
|
-
`[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--sync] [--strict] [--legacy-extractor]\n` +
|
|
307
|
+
`[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--sync] [--strict] [--legacy-extractor]\n` +
|
|
303
308
|
` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
|
|
309
|
+
` --orchestration: prompt-only (deterministic extraction) or orchestrated (engage the specialist chain)\n` +
|
|
304
310
|
` --strict: exit non-zero if any extraction drops occur\n` +
|
|
305
311
|
` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
|
|
306
312
|
` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
|
|
@@ -309,6 +315,9 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
309
315
|
if (strategy !== null && strategy !== '' && !INGEST_STRATEGIES.includes(strategy)) {
|
|
310
316
|
throw new Error(`Unsupported strategy: ${strategy}. Valid strategies: ${INGEST_STRATEGIES.join(', ')}`);
|
|
311
317
|
}
|
|
318
|
+
if (orchestration !== null && !INGEST_ORCHESTRATION_STRATEGIES.includes(orchestration)) {
|
|
319
|
+
throw new Error(`Unsupported orchestration: ${orchestration}. Valid values: ${INGEST_ORCHESTRATION_STRATEGIES.join(', ')}`);
|
|
320
|
+
}
|
|
312
321
|
const knowledgeTargets = KNOWLEDGE_SUBDIRS.map((s) => `knowledge/${s}`);
|
|
313
322
|
if (!['sibling', ...knowledgeTargets].includes(target)) {
|
|
314
323
|
throw new Error(
|
|
@@ -316,5 +325,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
316
325
|
);
|
|
317
326
|
}
|
|
318
327
|
|
|
319
|
-
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, env });
|
|
328
|
+
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, orchestration: orchestration || null, env });
|
|
320
329
|
}
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -1077,7 +1077,7 @@ this.#scheduler.register(
|
|
|
1077
1077
|
method: 'POST',
|
|
1078
1078
|
headers: {
|
|
1079
1079
|
Authorization: `Bearer ${orKey}`, 'content-type': 'application/json',
|
|
1080
|
-
'HTTP-Referer': 'https://github.com/construct',
|
|
1080
|
+
'HTTP-Referer': 'https://github.com/geraldmaron/construct',
|
|
1081
1081
|
},
|
|
1082
1082
|
body: JSON.stringify({
|
|
1083
1083
|
model: modelId.replace(/^openrouter\//, ''),
|
package/lib/embed/inbox.mjs
CHANGED
|
@@ -296,17 +296,22 @@ export class InboxWatcher {
|
|
|
296
296
|
#rootDir;
|
|
297
297
|
#env;
|
|
298
298
|
#cwd;
|
|
299
|
+
#prepareIntakeFn;
|
|
299
300
|
|
|
300
301
|
/**
|
|
301
302
|
* @param {object} opts
|
|
302
303
|
* @param {string} opts.rootDir - Root dir for observation store + state file
|
|
303
304
|
* @param {object} [opts.env] - Env override (default: process.env)
|
|
304
305
|
* @param {string} [opts.cwd] - Working dir for ingest output (default: rootDir)
|
|
306
|
+
* @param {Function} [opts.prepareIntakeFn] - Override for prepareIntakeForIngestedFile
|
|
307
|
+
* (test seam): receives the same args; returns null/throws to simulate a failed
|
|
308
|
+
* packet creation so tests can assert the manifest-gate behavior (construct-k4bg).
|
|
305
309
|
*/
|
|
306
|
-
constructor({ rootDir, env = process.env, cwd } = {}) {
|
|
310
|
+
constructor({ rootDir, env = process.env, cwd, prepareIntakeFn } = {}) {
|
|
307
311
|
this.#rootDir = rootDir;
|
|
308
312
|
this.#env = env;
|
|
309
313
|
this.#cwd = cwd ?? rootDir;
|
|
314
|
+
this.#prepareIntakeFn = prepareIntakeFn ?? prepareIntakeForIngestedFile;
|
|
310
315
|
}
|
|
311
316
|
|
|
312
317
|
/**
|
|
@@ -423,13 +428,15 @@ export class InboxWatcher {
|
|
|
423
428
|
extractedText,
|
|
424
429
|
});
|
|
425
430
|
|
|
426
|
-
// Queue an intake packet so the agent can triage the new
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
431
|
+
// Queue an intake packet so the agent can triage the new signal
|
|
432
|
+
// against the existing corpus. Failure here is non-fatal — ingest
|
|
433
|
+
// already succeeded; the queue is a best-effort handoff, not the
|
|
434
|
+
// ingestion's source of truth.
|
|
435
|
+
|
|
430
436
|
let intakeId = null;
|
|
437
|
+
let intakeFailed = false;
|
|
431
438
|
try {
|
|
432
|
-
const intakeResult = await
|
|
439
|
+
const intakeResult = await this.#prepareIntakeFn({
|
|
433
440
|
rootDir: this.#rootDir,
|
|
434
441
|
ingestedFile: {
|
|
435
442
|
sourcePath: candidate.path,
|
|
@@ -441,7 +448,9 @@ export class InboxWatcher {
|
|
|
441
448
|
env: this.#env,
|
|
442
449
|
});
|
|
443
450
|
intakeId = intakeResult?.id || null;
|
|
451
|
+
if (!intakeId) intakeFailed = true;
|
|
444
452
|
} catch (err) {
|
|
453
|
+
intakeFailed = true;
|
|
445
454
|
if (this.#env.CONSTRUCT_DEBUG_INTAKE === '1') {
|
|
446
455
|
process.stderr.write(`[inbox] intake queue write failed for ${candidate.path}: ${err.message}\n`);
|
|
447
456
|
}
|
|
@@ -449,7 +458,14 @@ export class InboxWatcher {
|
|
|
449
458
|
|
|
450
459
|
state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), outputPath: fileResult.outputPath };
|
|
451
460
|
|
|
452
|
-
|
|
461
|
+
// The dedup manifest entry is the signal that `construct intake
|
|
462
|
+
// process` uses to skip already-handled files. Recording it when
|
|
463
|
+
// packet creation failed strands the file: explicit `intake process`
|
|
464
|
+
// reports "skipped (unchanged)" while `intake list` is empty, with
|
|
465
|
+
// no path back. Record only on a successful intake id so a failed
|
|
466
|
+
// auto-ingest stays retriable via the explicit path (construct-k4bg).
|
|
467
|
+
|
|
468
|
+
if (manifestEnabled && candidateSha && intakeId && !intakeFailed) {
|
|
453
469
|
try {
|
|
454
470
|
recordFile(this.#rootDir, candidateSha, {
|
|
455
471
|
sourcePath: candidate.path,
|
|
@@ -462,6 +478,8 @@ export class InboxWatcher {
|
|
|
462
478
|
process.stderr.write(`[inbox] manifest record failed for ${candidate.path}: ${err.message}\n`);
|
|
463
479
|
}
|
|
464
480
|
}
|
|
481
|
+
} else if (manifestEnabled && intakeFailed) {
|
|
482
|
+
process.stderr.write(`[inbox] ${candidate.path}: ingest succeeded but intake-packet creation failed; rerun \`construct intake process\` to retry.\n`);
|
|
465
483
|
}
|
|
466
484
|
|
|
467
485
|
processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId, dedupHash: candidateSha });
|
|
@@ -16,6 +16,7 @@ import { classifyRdIntake } from '../intake/classify.mjs';
|
|
|
16
16
|
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
17
17
|
import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
|
|
18
18
|
import { workflowTypeForIntake } from './workflow-defs.mjs';
|
|
19
|
+
import { resolveExecution } from './execution.mjs';
|
|
19
20
|
|
|
20
21
|
const CONFIDENCE_FLOOR = 0.6;
|
|
21
22
|
|
|
@@ -46,7 +47,7 @@ function buildRiskFactors(triage) {
|
|
|
46
47
|
* @returns {object}
|
|
47
48
|
*/
|
|
48
49
|
export function recommendPlan(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
49
|
-
const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null } = request;
|
|
50
|
+
const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null, host, hostModel, hostProvider, constructStrategy = 'auto' } = request;
|
|
50
51
|
const warnings = [];
|
|
51
52
|
|
|
52
53
|
// Surface extraction provenance and flag low-yield/truncated inputs so a
|
|
@@ -109,6 +110,26 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
|
|
|
109
110
|
if (canExecute) nextStepOptions.push({ action: 'invoke-workflow', description: 'Invoke the recommended role chain as an embedded workflow.' });
|
|
110
111
|
if (!canExecute) nextStepOptions.push({ action: 'clarify', description: 'Request more detail; classification confidence is low or unknown.' });
|
|
111
112
|
|
|
113
|
+
const suggestedWorkflowType = workflowTypeForIntake(triage.intakeType);
|
|
114
|
+
|
|
115
|
+
// Execution preview: forecast the executionMode an invocation of the suggested
|
|
116
|
+
// workflow would resolve to, but only when the host supplies context — absent
|
|
117
|
+
// it, forcing a model resolution on every triage call would be wasteful and
|
|
118
|
+
// the preview would be uninformative. null keeps the field parity-stable.
|
|
119
|
+
let execution = null;
|
|
120
|
+
if (suggestedWorkflowType && (hostModel || hostProvider || (constructStrategy && constructStrategy !== 'auto'))) {
|
|
121
|
+
const e = resolveExecution({ workflowType: suggestedWorkflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider }, { env, cwd });
|
|
122
|
+
execution = {
|
|
123
|
+
executionMode: e.executionMode,
|
|
124
|
+
effectiveStrategy: e.effectiveStrategy,
|
|
125
|
+
requestedStrategy: e.requestedStrategy,
|
|
126
|
+
constructCapabilitiesActive: e.constructCapabilitiesActive,
|
|
127
|
+
degraded: e.degraded,
|
|
128
|
+
degradationReason: e.degradationReason,
|
|
129
|
+
semantics: e.semantics,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
return {
|
|
113
134
|
classification: { intakeType: triage.intakeType, rdStage: triage.rdStage },
|
|
114
135
|
ingestion,
|
|
@@ -117,7 +138,8 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
|
|
|
117
138
|
primaryOwner,
|
|
118
139
|
recommendedAction: triage.recommendedAction,
|
|
119
140
|
recommendedChain: chain,
|
|
120
|
-
suggestedWorkflowType
|
|
141
|
+
suggestedWorkflowType,
|
|
142
|
+
execution,
|
|
121
143
|
roleRationale: rationale,
|
|
122
144
|
suggestedSkills,
|
|
123
145
|
evidenceRequirements,
|
|
@@ -26,6 +26,7 @@ import { recordApprovalRequest } from '../roles/approval-surface.mjs';
|
|
|
26
26
|
import { getWorkflowDef, WORKFLOW_TYPES } from './workflow-defs.mjs';
|
|
27
27
|
import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
|
|
28
28
|
import { resolveEmbeddedModel } from './model-resolve.mjs';
|
|
29
|
+
import { resolveExecution } from './execution.mjs';
|
|
29
30
|
import { resolveWriteGate, newTraceId, DEFAULT_APPROVAL_MODE } from './audit.mjs';
|
|
30
31
|
|
|
31
32
|
const VALID_STRATEGIES = ['auto', 'explicit', 'constrained'];
|
|
@@ -91,6 +92,7 @@ function errorResult({ workflowId, traceId, approvalMode, code, message, warning
|
|
|
91
92
|
* @param {string} [request.host]
|
|
92
93
|
* @param {string} [request.hostModel]
|
|
93
94
|
* @param {string} [request.hostProvider]
|
|
95
|
+
* @param {string} [request.constructStrategy] orchestrated | prompt-only | auto (execution mode)
|
|
94
96
|
* @param {object} [opts] { env, cwd }
|
|
95
97
|
* @returns {Promise<object>}
|
|
96
98
|
*/
|
|
@@ -98,6 +100,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
98
100
|
const {
|
|
99
101
|
workflowType, context = {}, roleStrategy = 'auto', requestedRoles,
|
|
100
102
|
approvalMode, trace = true, host, hostModel, hostProvider, ingestion = null,
|
|
103
|
+
constructStrategy = 'auto',
|
|
101
104
|
} = request;
|
|
102
105
|
const warnings = [];
|
|
103
106
|
if (ingestion) {
|
|
@@ -129,6 +132,23 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
129
132
|
);
|
|
130
133
|
for (const w of modelWarnings) warnings.push(`model-resolution: ${w}`);
|
|
131
134
|
|
|
135
|
+
// The execution-capability contract reports the PLANNED executionMode for this
|
|
136
|
+
// run (descriptive, not enforced — ADR-0019). Construct returns a plan; the
|
|
137
|
+
// host runtime executes it, so this never claims observed specialist execution.
|
|
138
|
+
const executionData = resolveExecution(
|
|
139
|
+
{ workflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider, requestedTier: def.tier },
|
|
140
|
+
{ env, cwd },
|
|
141
|
+
);
|
|
142
|
+
const execution = {
|
|
143
|
+
executionMode: executionData.executionMode,
|
|
144
|
+
effectiveStrategy: executionData.effectiveStrategy,
|
|
145
|
+
requestedStrategy: executionData.requestedStrategy,
|
|
146
|
+
constructCapabilitiesActive: executionData.constructCapabilitiesActive,
|
|
147
|
+
degraded: executionData.degraded,
|
|
148
|
+
degradationReason: executionData.degradationReason,
|
|
149
|
+
semantics: executionData.semantics,
|
|
150
|
+
};
|
|
151
|
+
|
|
132
152
|
const primaryOwner = selectedRoles[0];
|
|
133
153
|
const facts = contractFacts(primaryOwner);
|
|
134
154
|
const skillsApplied = skillsForChain(selectedRoles, map);
|
|
@@ -204,6 +224,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
|
|
|
204
224
|
roleRationale: rationale,
|
|
205
225
|
skillsApplied,
|
|
206
226
|
modelResolution,
|
|
227
|
+
execution,
|
|
207
228
|
outputs,
|
|
208
229
|
recommendations,
|
|
209
230
|
evidence: { requirements: facts.evidenceRequirements, satisfied, missing: missingEvidence, traceId },
|
package/lib/features.mjs
CHANGED
|
@@ -63,27 +63,27 @@ function readOpenCodeConfig(overrides = {}) {
|
|
|
63
63
|
return null;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function
|
|
67
|
-
if (overrides.
|
|
66
|
+
function getVSCodeUserMcpPaths(overrides = {}) {
|
|
67
|
+
if (overrides.vscodeMcpPaths) return overrides.vscodeMcpPaths.filter(existsSync);
|
|
68
68
|
const home = getHomeDir(overrides);
|
|
69
69
|
const { platform } = process;
|
|
70
70
|
if (platform === 'darwin') {
|
|
71
71
|
return [
|
|
72
|
-
join(home, 'Library', 'Application Support', 'Code', 'User', '
|
|
73
|
-
join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', '
|
|
72
|
+
join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
|
|
73
|
+
join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json'),
|
|
74
74
|
].filter(existsSync);
|
|
75
75
|
}
|
|
76
76
|
if (platform === 'linux') {
|
|
77
77
|
return [
|
|
78
|
-
join(home, '.config', 'Code', 'User', '
|
|
79
|
-
join(home, '.config', 'Code - Insiders', 'User', '
|
|
78
|
+
join(home, '.config', 'Code', 'User', 'mcp.json'),
|
|
79
|
+
join(home, '.config', 'Code - Insiders', 'User', 'mcp.json'),
|
|
80
80
|
].filter(existsSync);
|
|
81
81
|
}
|
|
82
82
|
if (platform === 'win32') {
|
|
83
83
|
const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
|
|
84
84
|
return [
|
|
85
|
-
join(appData, 'Code', 'User', '
|
|
86
|
-
join(appData, 'Code - Insiders', 'User', '
|
|
85
|
+
join(appData, 'Code', 'User', 'mcp.json'),
|
|
86
|
+
join(appData, 'Code - Insiders', 'User', 'mcp.json'),
|
|
87
87
|
].filter(existsSync);
|
|
88
88
|
}
|
|
89
89
|
return [];
|
|
@@ -230,9 +230,9 @@ function getMcpPlatforms(mcp, overrides = {}) {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
// --- VS Code / Copilot ---
|
|
233
|
-
for (const p of
|
|
234
|
-
const
|
|
235
|
-
if (configHasAny(
|
|
233
|
+
for (const p of getVSCodeUserMcpPaths(overrides)) {
|
|
234
|
+
const config = readJSON(p);
|
|
235
|
+
if (configHasAny(config?.servers, matchIds)) {
|
|
236
236
|
const label = p.includes('Insiders') ? 'VS Code Insiders' : 'VS Code';
|
|
237
237
|
if (!platforms.includes(label)) platforms.push(label);
|
|
238
238
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/git-hooks-path.mjs — wire a git repo's `core.hooksPath` to `.beads/hooks`
|
|
3
|
+
* so Construct's pre-commit secret-scan, policy gates, and the beads dispatcher
|
|
4
|
+
* activate.
|
|
5
|
+
*
|
|
6
|
+
* Project-scoped: it mutates the git config of the directory passed in, so it
|
|
7
|
+
* is owned by `construct init` (project scaffolding), never `construct install`
|
|
8
|
+
* (machine setup must not touch the cwd repo — ADR-0027 §3). The git default
|
|
9
|
+
* (`.git/hooks`, set or unset) is treated as "no active choice" and overwritten;
|
|
10
|
+
* a non-default custom hooksPath is left alone with a warning.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
export function ensureGitHooksPath({ cwd = process.cwd() } = {}) {
|
|
18
|
+
const hooksDir = path.join(cwd, '.beads', 'hooks');
|
|
19
|
+
if (!fs.existsSync(path.join(hooksDir, 'pre-commit'))) {
|
|
20
|
+
return { status: 'skipped', reason: 'no .beads/hooks/pre-commit in this directory' };
|
|
21
|
+
}
|
|
22
|
+
const inGit = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'pipe', encoding: 'utf8' });
|
|
23
|
+
if (inGit.status !== 0) {
|
|
24
|
+
return { status: 'skipped', reason: 'not a git working tree' };
|
|
25
|
+
}
|
|
26
|
+
const current = spawnSync('git', ['config', '--get', 'core.hooksPath'], { cwd, stdio: 'pipe', encoding: 'utf8' });
|
|
27
|
+
const currentValue = current.status === 0 ? (current.stdout || '').trim() : '';
|
|
28
|
+
const desired = '.beads/hooks';
|
|
29
|
+
if (currentValue === desired) {
|
|
30
|
+
return { status: 'ok', message: 'core.hooksPath already wired to .beads/hooks' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Treat the git default (`.git/hooks` or an absolute path to it) as
|
|
34
|
+
// equivalent to unset. A user who has accepted the default has not made an
|
|
35
|
+
// active choice that justifies "leave alone" semantics; without this branch
|
|
36
|
+
// an install that happens to land while `.git/hooks` is already in play
|
|
37
|
+
// leaves policy gates inactive permanently.
|
|
38
|
+
|
|
39
|
+
const isGitDefault =
|
|
40
|
+
!currentValue ||
|
|
41
|
+
currentValue === '.git/hooks' ||
|
|
42
|
+
currentValue === path.join(cwd, '.git', 'hooks') ||
|
|
43
|
+
currentValue.replace(/\/+$/, '').endsWith(`${path.sep}.git${path.sep}hooks`);
|
|
44
|
+
if (!isGitDefault) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'warning',
|
|
47
|
+
message: `core.hooksPath is set to '${currentValue}'. Leaving alone. Set to '.beads/hooks' to activate Construct policy gates.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const result = spawnSync('git', ['config', 'core.hooksPath', desired], { cwd, stdio: 'pipe', encoding: 'utf8' });
|
|
51
|
+
if (result.status !== 0) {
|
|
52
|
+
return {
|
|
53
|
+
status: 'error',
|
|
54
|
+
message: `failed to set core.hooksPath: ${(result.stderr || '').trim() || 'unknown error'}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
status: 'set',
|
|
59
|
+
message: 'core.hooksPath set to .beads/hooks (activates pre-commit secret-scan + Construct policy gates + beads dispatcher)',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/home-namespace.mjs — per-home derivation of local-service identifiers so
|
|
3
|
+
* isolated HOMEs (test sandboxes, multiple users on one machine, parallel CI)
|
|
4
|
+
* never collide on the same Postgres container/port or memory port (construct-lb7b).
|
|
5
|
+
*
|
|
6
|
+
* The canonical `construct-postgres`:54329 container and the memory :8765 port are
|
|
7
|
+
* singular per machine; two HOMEs sharing them clobber each other's data and
|
|
8
|
+
* fight over the port. Each identifier here is derived from a stable hash of the
|
|
9
|
+
* resolved home directory, so every HOME gets its own deterministic namespace.
|
|
10
|
+
*
|
|
11
|
+
* Explicit env overrides win (CONSTRUCT_PG_PORT, MEMORY_PORT, CONSTRUCT_PG_CONTAINER),
|
|
12
|
+
* so a pinned setup keeps its values. The LaunchAgent label stays singular — it is
|
|
13
|
+
* a machine-level guard, not a per-home resource.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { homeDir } from './paths.mjs';
|
|
18
|
+
|
|
19
|
+
const PG_PORT_BASE = 54329;
|
|
20
|
+
const MEMORY_PORT_BASE = 8765;
|
|
21
|
+
const PORT_SPAN = 2000;
|
|
22
|
+
|
|
23
|
+
function digest(home) {
|
|
24
|
+
return createHash('sha256').update(home).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function homeNamespaceSuffix(home = homeDir()) {
|
|
28
|
+
return digest(home).slice(0, 8);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Distinct hash slices for the two ports so they do not move in lockstep across
|
|
32
|
+
// homes (which would re-create collisions one base apart).
|
|
33
|
+
|
|
34
|
+
function offsetFromSlice(home, start) {
|
|
35
|
+
return parseInt(digest(home).slice(start, start + 8), 16) % PORT_SPAN;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function postgresPort(env = process.env, home = homeDir()) {
|
|
39
|
+
const override = env.CONSTRUCT_PG_PORT;
|
|
40
|
+
if (override && /^\d+$/.test(override)) return Number(override);
|
|
41
|
+
return PG_PORT_BASE + offsetFromSlice(home, 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function memoryPort(env = process.env, home = homeDir()) {
|
|
45
|
+
const override = env.MEMORY_PORT;
|
|
46
|
+
if (override && /^\d+$/.test(override)) return Number(override);
|
|
47
|
+
return MEMORY_PORT_BASE + offsetFromSlice(home, 8);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function postgresContainerName(env = process.env, home = homeDir()) {
|
|
51
|
+
return env.CONSTRUCT_PG_CONTAINER || `construct-postgres-${homeNamespaceSuffix(home)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The legacy singular identifiers prior versions wrote. The postgres-namespace
|
|
55
|
+
// reconciliation (lib/reconcile/) uses these to detect state that predates the
|
|
56
|
+
// per-home derivation.
|
|
57
|
+
|
|
58
|
+
export const LEGACY_PG_CONTAINER = 'construct-postgres';
|
|
59
|
+
export const LEGACY_PG_PORT = 54329;
|
|
60
|
+
export const LEGACY_MEMORY_PORT = 8765;
|
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* lib/hooks/ci-status-check.mjs — UserPromptSubmit hook: inject remote CI status into agent context.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* agent
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Reads a 60s on-disk cache of the last `gh run list` result for the current
|
|
6
|
+
* branch and, when the cached conclusion is `failure`, emits a stderr line the
|
|
7
|
+
* agent sees in its next observation. The synchronous fast path does no
|
|
8
|
+
* subprocess spawning beyond a 50ms `git branch` probe — every cache miss is
|
|
9
|
+
* served by detaching a background refresher (stale-while-revalidate), so the
|
|
10
|
+
* UserPromptSubmit critical path stays under its 200ms budget regardless of
|
|
11
|
+
* `gh` / network latency. Without this hook, an agent has no way to know
|
|
12
|
+
* remote CI is red without explicitly running gh.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
14
|
+
* The background refresher is `scripts/refresh-ci-status.mjs` invoked with
|
|
15
|
+
* `--branch=<name> --cwd=<dir> --cache=<path>`; it inherits no stdio and
|
|
16
|
+
* detaches so a slow `gh` invocation can't keep the hook process alive.
|
|
12
17
|
* Skipped silently if gh is not installed, not authenticated, or the dir is
|
|
13
18
|
* not a git repo.
|
|
14
19
|
*
|
|
15
|
-
* @p95ms
|
|
20
|
+
* @p95ms 200
|
|
16
21
|
* @maxBlockingScope none (UserPromptSubmit, non-blocking)
|
|
17
22
|
*
|
|
18
23
|
* @lifecycle UserPromptSubmit
|
|
@@ -20,13 +25,16 @@
|
|
|
20
25
|
* @exits 0 = pass
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync
|
|
24
|
-
import { execSync } from 'node:child_process';
|
|
28
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
29
|
+
import { execSync, spawn } from 'node:child_process';
|
|
25
30
|
import { homedir } from 'node:os';
|
|
26
|
-
import { join, dirname } from 'node:path';
|
|
31
|
+
import { join, dirname, resolve } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
27
33
|
|
|
28
34
|
const CACHE_PATH = join(homedir(), '.cx', 'ci-status-cache.json');
|
|
29
35
|
const CACHE_TTL_MS = 60_000;
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const REFRESHER = resolve(HERE, '..', '..', 'scripts', 'refresh-ci-status.mjs');
|
|
30
38
|
|
|
31
39
|
let cwd = process.cwd();
|
|
32
40
|
try {
|
|
@@ -34,12 +42,20 @@ try {
|
|
|
34
42
|
if (input?.cwd) cwd = input.cwd;
|
|
35
43
|
} catch {}
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
// 50ms hard timeout on `git branch --show-current`: bounded enough that even
|
|
46
|
+
// a hung git invocation cannot blow the 200ms hook budget.
|
|
47
|
+
|
|
48
|
+
function safeGitBranch() {
|
|
49
|
+
try {
|
|
50
|
+
return execSync('git branch --show-current', {
|
|
51
|
+
cwd,
|
|
52
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
53
|
+
timeout: 50,
|
|
54
|
+
}).toString().trim();
|
|
55
|
+
} catch { return null; }
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
const branch =
|
|
58
|
+
const branch = safeGitBranch();
|
|
43
59
|
if (!branch) process.exit(0);
|
|
44
60
|
|
|
45
61
|
const cacheKey = `${cwd}::${branch}`;
|
|
@@ -48,39 +64,45 @@ try { cache = JSON.parse(readFileSync(CACHE_PATH, 'utf8')); } catch {}
|
|
|
48
64
|
const cached = cache[cacheKey];
|
|
49
65
|
const now = Date.now();
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
// Stale-while-revalidate: serve any cached value (even stale) on the
|
|
68
|
+
// critical path, and detach a background refresher when the cache is stale
|
|
69
|
+
// or absent. The first prompt against a never-cached branch sees nothing;
|
|
70
|
+
// the second prompt (after the detached refresher has written) sees the
|
|
71
|
+
// real status. That tradeoff buys a flat ~50ms p95.
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
runUrl = cached.runUrl;
|
|
59
|
-
} else {
|
|
60
|
-
const json = safeExec(
|
|
61
|
-
`gh run list --branch=${JSON.stringify(branch)} --limit=1 --json conclusion,databaseId,url`,
|
|
62
|
-
{ timeout: 4000 },
|
|
63
|
-
);
|
|
64
|
-
if (!json) process.exit(0);
|
|
73
|
+
const isStale = !cached || (now - cached.fetchedAt) >= CACHE_TTL_MS;
|
|
74
|
+
|
|
75
|
+
if (isStale && existsSync(REFRESHER)) {
|
|
65
76
|
try {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
const child = spawn(process.execPath, [
|
|
78
|
+
REFRESHER,
|
|
79
|
+
`--branch=${branch}`,
|
|
80
|
+
`--cwd=${cwd}`,
|
|
81
|
+
`--cache=${CACHE_PATH}`,
|
|
82
|
+
], {
|
|
83
|
+
cwd,
|
|
84
|
+
detached: true,
|
|
85
|
+
stdio: 'ignore',
|
|
86
|
+
});
|
|
87
|
+
child.unref();
|
|
77
88
|
} catch {}
|
|
78
89
|
}
|
|
79
90
|
|
|
80
|
-
if (conclusion === 'failure') {
|
|
91
|
+
if (cached?.conclusion === 'failure') {
|
|
81
92
|
process.stderr.write(
|
|
82
|
-
`[ci] Last CI run on branch '${branch}' FAILED (run ${runId}).\n ${runUrl || ''}\n Investigate with: gh run view ${runId} --log-failed\n Do not stop the session until the failure is fixed or explicitly acknowledged (CONSTRUCT_STOP_OK_RED_CI=1).\n`,
|
|
93
|
+
`[ci] Last CI run on branch '${branch}' FAILED (run ${cached.runId}).\n ${cached.runUrl || ''}\n Investigate with: gh run view ${cached.runId} --log-failed\n Do not stop the session until the failure is fixed or explicitly acknowledged (CONSTRUCT_STOP_OK_RED_CI=1).\n`,
|
|
83
94
|
);
|
|
84
95
|
}
|
|
85
96
|
|
|
97
|
+
// First-prompt edge case: no cache exists yet AND we just kicked off the
|
|
98
|
+
// refresher. We still exit 0 (silent). On the next prompt, the cache will
|
|
99
|
+
// be populated and any failure surfaces.
|
|
100
|
+
|
|
101
|
+
if (!cached) {
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(dirname(CACHE_PATH), { recursive: true });
|
|
104
|
+
if (!existsSync(CACHE_PATH)) writeFileSync(CACHE_PATH, '{}');
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
process.exit(0);
|