@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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/extractors/shared/drop-info.mjs — shared drop-info factory and envelope builder.
|
|
3
|
+
*
|
|
4
|
+
* Every extractor returns the same { text, structured, droppedInfo } envelope so
|
|
5
|
+
* callers can always read droppedInfo[] without format-specific branching. An empty
|
|
6
|
+
* droppedInfo array means nothing was silently dropped, not that nothing was structured.
|
|
7
|
+
*
|
|
8
|
+
* Valid `kind` values mirror the MIME world: 'attachment', 'inline-image', 'table',
|
|
9
|
+
* 'formula', 'comment', 'html-part', 'animation', 'speaker-notes', 'scanned-pdf'.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function makeDropInfo({ kind, count, reason, recoverable = false }) {
|
|
13
|
+
return { kind, count, reason, recoverable };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wrap extraction results in the universal envelope.
|
|
18
|
+
* Callers that produce no structured data pass `structured: null` (the default).
|
|
19
|
+
*/
|
|
20
|
+
export function makeEnvelope({ text = '', structured = null, droppedInfo = [] } = {}) {
|
|
21
|
+
return { text, structured, droppedInfo };
|
|
22
|
+
}
|
|
Binary file
|
package/lib/gates-audit.mjs
CHANGED
|
@@ -47,11 +47,17 @@ const GATE_DEFINITIONS = [
|
|
|
47
47
|
];
|
|
48
48
|
|
|
49
49
|
function parseCIJobs(rootDir) {
|
|
50
|
+
// Extracts both job-level names and step-level names. Consolidated jobs
|
|
51
|
+
// (e.g. the `lint suite` job carrying comment policy + docs drift + gates
|
|
52
|
+
// audit as steps) surface each step name as a gate signal. The audit's
|
|
53
|
+
// job-name comparison treats jobs and steps as a flat union so gate
|
|
54
|
+
// definitions stay decoupled from CI structure.
|
|
50
55
|
const path = join(rootDir, '.github', 'workflows', 'ci.yml');
|
|
51
56
|
if (!existsSync(path)) return [];
|
|
52
57
|
const yml = readFileSync(path, 'utf8');
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
const jobNames = [...yml.matchAll(/^ name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
|
|
59
|
+
const stepNames = [...yml.matchAll(/^ - name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
|
|
60
|
+
return [...jobNames, ...stepNames]
|
|
55
61
|
.map((n) => n.replace(/\$\{\{\s*matrix\.(\w+)\s*\}\}/g, (_, k) => `<${k}>`));
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Follows the RFC 9562 / git-interpret-trailers convention.
|
|
7
7
|
*
|
|
8
8
|
* Trailers added (when env vars are present):
|
|
9
|
-
* AI-Generator: construct/sync-
|
|
9
|
+
* AI-Generator: construct/sync-specialists
|
|
10
10
|
* AI-Model: claude-sonnet-4-6
|
|
11
11
|
* AI-Session: 019dbb90-5885-...
|
|
12
12
|
*
|
package/lib/headhunt.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { selectOption } from './tty-prompts.mjs';
|
|
|
16
16
|
|
|
17
17
|
const OVERLAY_DIRNAME = 'domain-overlays';
|
|
18
18
|
const PROMOTION_DIRNAME = 'promotion-requests';
|
|
19
|
-
const TEAMS_FILE = '
|
|
19
|
+
const TEAMS_FILE = 'specialists/teams.json';
|
|
20
20
|
|
|
21
21
|
function loadTeamTemplates(root) {
|
|
22
22
|
try {
|
|
@@ -342,7 +342,7 @@ export async function runHeadhunt({ args = [], cwd = process.cwd(), homeDir = os
|
|
|
342
342
|
if (!templateName) { listTeamTemplates(cwd); return; }
|
|
343
343
|
const root = findConstructRoot(cwd);
|
|
344
344
|
const teams = loadTeamTemplates(root);
|
|
345
|
-
if (!teams) throw new Error('
|
|
345
|
+
if (!teams) throw new Error('specialists/teams.json not found. Run from the Construct root.');
|
|
346
346
|
const tpl = teams.templates[templateName];
|
|
347
347
|
if (!tpl) throw new Error(`Unknown template: ${templateName}. Run 'construct headhunt template' to list available templates.`);
|
|
348
348
|
const objective = typeof flags.for === 'string' ? flags.for.trim() : rest.slice(1).join(' ').trim();
|
|
@@ -60,17 +60,12 @@ try {
|
|
|
60
60
|
const cxDir = join(home, '.cx');
|
|
61
61
|
mkdirSync(cxDir, { recursive: true });
|
|
62
62
|
|
|
63
|
-
// Update last-agent
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ts: new Date().toISOString(),
|
|
70
|
-
outcome,
|
|
71
|
-
description: description.slice(0, 200)
|
|
72
|
-
}),
|
|
73
|
-
);
|
|
63
|
+
// Update last-agent files for coordination (shared + per-agent)
|
|
64
|
+
const agentEntry = { agent: agentName, coordination: 'tracker-plus-plan', ts: new Date().toISOString(), outcome, description: description.slice(0, 200) };
|
|
65
|
+
writeFileSync(join(cxDir, 'last-agent.json'), JSON.stringify(agentEntry));
|
|
66
|
+
// Per-agent file prevents one agent dispatch from resetting another's fence window
|
|
67
|
+
const safeName = agentName.replace(/[^a-z0-9._-]/gi, '_');
|
|
68
|
+
writeFileSync(join(cxDir, `last-agent-${safeName}.json`), JSON.stringify(agentEntry));
|
|
74
69
|
|
|
75
70
|
// Append to agent log for telemetry
|
|
76
71
|
const agentLogFile = join(cxDir, 'agent-log.jsonl');
|
|
@@ -40,8 +40,24 @@ const rootDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
|
40
40
|
const result = lintFile(filePath, { rootDir });
|
|
41
41
|
if (!result.errors.length && !result.warnings.length) process.exit(0);
|
|
42
42
|
|
|
43
|
+
// Artifact-prose violations are advisory at write-time (no-fabrication policy
|
|
44
|
+
// enforces them at CI/release-gate time via CONSTRUCT_ARTIFACT_LINT_MODE=block).
|
|
45
|
+
// Surface them on stderr but do not block the edit.
|
|
46
|
+
const artifactOnly = (
|
|
47
|
+
result.errors.length === 0 &&
|
|
48
|
+
result.warnings.every((w) => w.kind === 'artifact')
|
|
49
|
+
);
|
|
50
|
+
|
|
43
51
|
const { output } = formatResults([result]);
|
|
44
52
|
process.stderr.write(output);
|
|
53
|
+
|
|
54
|
+
if (artifactOnly) {
|
|
55
|
+
process.stderr.write(
|
|
56
|
+
'\nArtifact-lint advisory (no-fabrication). The edit is not blocked, but these will fail the release gate. Fix before push.\n',
|
|
57
|
+
);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
process.stderr.write(
|
|
46
62
|
'\nComment policy blocked this edit. Remove the violations or set CONSTRUCT_SKIP_COMMENT_LINT=1.\n',
|
|
47
63
|
);
|
package/lib/hooks/guard-bash.mjs
CHANGED
|
@@ -95,41 +95,62 @@ for (const { pattern, reason } of WARN_PATTERNS) {
|
|
|
95
95
|
|
|
96
96
|
if (process.env.CONSTRUCT_ROLES !== 'off') {
|
|
97
97
|
try {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
const cxDir = join(homedir(), '.cx');
|
|
99
|
+
const id = String(process.env.CONSTRUCT_AGENT_ID || '').replace(/^cx-/, '');
|
|
100
|
+
let agentData = null;
|
|
101
|
+
|
|
102
|
+
// Prefer per-agent timestamp file (prevents cross-agent fence window reset)
|
|
103
|
+
if (id) {
|
|
104
|
+
const perAgentPath = join(cxDir, `last-agent-${id.replace(/[^a-z0-9._-]/gi, '_')}.json`);
|
|
105
|
+
if (existsSync(perAgentPath)) {
|
|
106
|
+
agentData = JSON.parse(readFileSync(perAgentPath, 'utf8'));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Fall back to shared last-agent.json
|
|
110
|
+
if (!agentData) {
|
|
111
|
+
const sharedPath = join(cxDir, 'last-agent.json');
|
|
112
|
+
if (existsSync(sharedPath)) {
|
|
113
|
+
agentData = JSON.parse(readFileSync(sharedPath, 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (agentData) {
|
|
118
|
+
const lastTs = agentData?.ts ? Date.parse(agentData.ts) : 0;
|
|
102
119
|
const fresh = lastTs && (Date.now() - lastTs) < 10 * 60 * 1000;
|
|
103
|
-
const
|
|
104
|
-
if (fresh &&
|
|
120
|
+
const personaId = id || String(agentData?.agent || '').replace(/^cx-/, '');
|
|
121
|
+
if (fresh && personaId) {
|
|
105
122
|
const { isOnboarded } = await import('../roles/manifest.mjs');
|
|
106
|
-
if (isOnboarded(
|
|
123
|
+
if (isOnboarded(personaId)) {
|
|
107
124
|
const { checkAction } = await import('../roles/fence.mjs');
|
|
108
125
|
const trimmed = command.trim();
|
|
109
126
|
let verdict;
|
|
110
127
|
if (/^git\s+commit\b/.test(trimmed)) {
|
|
111
|
-
verdict = checkAction({ personaId
|
|
128
|
+
verdict = checkAction({ personaId, action: 'commit', target: '' });
|
|
112
129
|
} else if (/^git\s+push\b/.test(trimmed)) {
|
|
113
|
-
verdict = checkAction({ personaId
|
|
130
|
+
verdict = checkAction({ personaId, action: 'push', target: '' });
|
|
114
131
|
} else {
|
|
115
|
-
verdict = checkAction({ personaId
|
|
132
|
+
verdict = checkAction({ personaId, action: 'bash', target: trimmed });
|
|
116
133
|
}
|
|
117
134
|
if (!verdict.allowed && verdict.reason === 'outside-fence') {
|
|
135
|
+
const allowedList = (() => {
|
|
136
|
+
try { const { loadManifest } = await import('../roles/manifest.mjs'); const m = loadManifest(personaId); return m?.fence?.allowedCommands?.join(', ') || 'see role-manifests.json'; } catch { return 'see role-manifests.json'; }
|
|
137
|
+
})();
|
|
118
138
|
process.stderr.write(
|
|
119
|
-
`[fence] cx-${
|
|
139
|
+
`[fence] cx-${personaId} cannot run this command — outside declared fence.\n` +
|
|
120
140
|
`Command: ${trimmed.slice(0, 200)}\n` +
|
|
121
|
-
`Allowed commands
|
|
141
|
+
`Allowed commands: ${allowedList}\n` +
|
|
142
|
+
`To bypass: CONSTRUCT_ROLES=off <command>\n`
|
|
122
143
|
);
|
|
123
144
|
process.exit(2);
|
|
124
145
|
}
|
|
125
146
|
if (!verdict.allowed && verdict.approval) {
|
|
126
147
|
process.stderr.write(
|
|
127
|
-
`[fence] cx-${
|
|
148
|
+
`[fence] cx-${personaId} running this requires user approval (${verdict.reason}).\n` +
|
|
128
149
|
`Command: ${trimmed.slice(0, 200)}\n`
|
|
129
150
|
);
|
|
130
151
|
const { recordApprovalRequest } = await import('../roles/approval-surface.mjs');
|
|
131
152
|
const actionLabel = /^git\s+commit\b/.test(trimmed) ? 'commit' : /^git\s+push\b/.test(trimmed) ? 'push' : 'bash';
|
|
132
|
-
await recordApprovalRequest({ personaId
|
|
153
|
+
await recordApprovalRequest({ personaId, action: actionLabel, target: trimmed, reason: verdict.reason || 'needs-approval' });
|
|
133
154
|
}
|
|
134
155
|
}
|
|
135
156
|
}
|
package/lib/hooks/mcp-audit.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* lib/hooks/mcp-audit.mjs — MCP audit hook — logs all MCP tool calls for observability and review.
|
|
4
4
|
*
|
|
5
5
|
* Runs as PostToolUse on MCP tool calls. Records tool name, input, and output summary to ~/.cx/mcp-audit.json for telemetry and security review.
|
|
6
|
+
* Also emits a gen_ai.client.tool.call span when OTel is configured (OTEL_EXPORTER_OTLP_ENDPOINT set).
|
|
6
7
|
*
|
|
7
8
|
* @p95ms 10
|
|
8
9
|
* @maxBlockingScope none (PostToolUse, non-blocking)
|
|
@@ -10,6 +11,8 @@
|
|
|
10
11
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
11
12
|
import { join } from 'path';
|
|
12
13
|
import { shouldCreateCx } from '../project-detection.mjs';
|
|
14
|
+
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
15
|
+
import { GenAiAttrs, withGenAiSpan } from '../telemetry/otel-tracer.mjs';
|
|
13
16
|
|
|
14
17
|
let input = {};
|
|
15
18
|
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
@@ -47,9 +50,19 @@ if (audit[mcpServer].length > 200) audit[mcpServer] = audit[mcpServer].slice(-20
|
|
|
47
50
|
|
|
48
51
|
try {
|
|
49
52
|
if (shouldCreateCxDir) {
|
|
50
|
-
|
|
53
|
+
ensureCxDir(cwd);
|
|
51
54
|
writeFileSync(auditPath, JSON.stringify(audit, null, 2));
|
|
52
55
|
}
|
|
53
56
|
} catch { /* best effort */ }
|
|
54
57
|
|
|
58
|
+
// Fire-and-forget OTel span for the tool call — non-blocking.
|
|
59
|
+
if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT && process.env.CONSTRUCT_OTEL !== 'off') {
|
|
60
|
+
withGenAiSpan('tool_call', {
|
|
61
|
+
[GenAiAttrs.TOOL_NAME]: mcpTool,
|
|
62
|
+
[GenAiAttrs.MCP_METHOD]: toolName,
|
|
63
|
+
[GenAiAttrs.MCP_TRANSPORT]: 'stdio',
|
|
64
|
+
'construct.mcp_server': mcpServer,
|
|
65
|
+
}, async (span) => span).catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
process.exit(0);
|
|
@@ -4,15 +4,87 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Runs before context compaction. Writes a structured summary of context state and pending tasks to preserve continuity across the compaction boundary.
|
|
6
6
|
*
|
|
7
|
+
* Manual sections in context.md (any heading not in AUTO_SECTION_NAMES) are
|
|
8
|
+
* detected, preserved verbatim, and re-appended after the auto-generated
|
|
9
|
+
* sections so that hand-written decisions, active-work notes, and architecture
|
|
10
|
+
* context survive every compaction cycle.
|
|
11
|
+
*
|
|
7
12
|
* @p95ms 100
|
|
8
13
|
* @maxBlockingScope PreCompact
|
|
9
14
|
*/
|
|
10
15
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
11
16
|
import { homedir } from 'os';
|
|
12
17
|
import { join } from 'path';
|
|
13
|
-
import { writeContextState } from '../context-state.mjs';
|
|
18
|
+
import { writeContextState, contextMarkdownPath, contextJsonPath } from '../context-state.mjs';
|
|
14
19
|
import { flushReadTrackerDeltas } from '../read-tracker-store.mjs';
|
|
15
20
|
|
|
21
|
+
// Section headings that are auto-generated by this hook. Any other heading in
|
|
22
|
+
// the existing context.md is treated as manually authored and preserved.
|
|
23
|
+
const AUTO_SECTION_NAMES = new Set([
|
|
24
|
+
'what was in progress',
|
|
25
|
+
'files changed this session',
|
|
26
|
+
'decisions captured',
|
|
27
|
+
'pending todos',
|
|
28
|
+
'session efficiency snapshot',
|
|
29
|
+
'open issues',
|
|
30
|
+
'session context',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a markdown string into `[{heading, level, body}]` sections.
|
|
35
|
+
* Returns an empty array for empty/missing input.
|
|
36
|
+
*/
|
|
37
|
+
function parseSections(md) {
|
|
38
|
+
if (!md) return [];
|
|
39
|
+
const lines = md.split('\n');
|
|
40
|
+
const sections = [];
|
|
41
|
+
let current = null;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const hm = line.match(/^(#{1,6})\s+(.+)$/);
|
|
44
|
+
if (hm) {
|
|
45
|
+
if (current) sections.push(current);
|
|
46
|
+
current = { heading: line, level: hm[1].length, title: hm[2].trim(), body: [] };
|
|
47
|
+
} else if (current) {
|
|
48
|
+
current.body.push(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (current) sections.push(current);
|
|
52
|
+
return sections;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract sections from an existing context.md that were NOT auto-generated.
|
|
57
|
+
* Returns a markdown string (may be empty) to append after the auto content.
|
|
58
|
+
*/
|
|
59
|
+
function extractManualSections(mdPath) {
|
|
60
|
+
if (!existsSync(mdPath)) return '';
|
|
61
|
+
let existing = '';
|
|
62
|
+
try { existing = readFileSync(mdPath, 'utf8'); } catch { return ''; }
|
|
63
|
+
const sections = parseSections(existing);
|
|
64
|
+
const manual = sections.filter((s) => !AUTO_SECTION_NAMES.has(s.title.toLowerCase()));
|
|
65
|
+
if (!manual.length) return '';
|
|
66
|
+
return manual
|
|
67
|
+
.map((s) => [s.heading, ...s.body].join('\n').trimEnd())
|
|
68
|
+
.join('\n\n') + '\n';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merge non-empty arrays from existing context.json into the new state to
|
|
73
|
+
* prevent the hook from discarding decisions/filesChanged captured manually.
|
|
74
|
+
*/
|
|
75
|
+
function mergeExistingJson(jsonPath, newState) {
|
|
76
|
+
if (!existsSync(jsonPath)) return newState;
|
|
77
|
+
let existing = {};
|
|
78
|
+
try { existing = JSON.parse(readFileSync(jsonPath, 'utf8')); } catch { return newState; }
|
|
79
|
+
const merged = { ...newState };
|
|
80
|
+
for (const key of ['decisions', 'filesChanged', 'pendingTodos']) {
|
|
81
|
+
if ((!merged[key] || !merged[key].length) && Array.isArray(existing[key]) && existing[key].length) {
|
|
82
|
+
merged[key] = existing[key];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return merged;
|
|
86
|
+
}
|
|
87
|
+
|
|
16
88
|
let input = {};
|
|
17
89
|
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
18
90
|
|
|
@@ -156,6 +228,15 @@ contextLines.push('## Open issues');
|
|
|
156
228
|
contextLines.push(warnFlags || 'None');
|
|
157
229
|
|
|
158
230
|
const content = contextLines.join('\n') + '\n';
|
|
231
|
+
|
|
232
|
+
const projectCxDir = join(cwd, '.cx');
|
|
233
|
+
try { mkdirSync(projectCxDir, { recursive: true }); } catch { /* exists */ }
|
|
234
|
+
|
|
235
|
+
// Preserve manually-written sections from the existing context.md.
|
|
236
|
+
const projectMdPath = contextMarkdownPath(cwd);
|
|
237
|
+
const manualSections = extractManualSections(projectMdPath);
|
|
238
|
+
const finalContent = content + (manualSections ? '\n' + manualSections : '');
|
|
239
|
+
|
|
159
240
|
const contextJson = {
|
|
160
241
|
source: 'pre-compact',
|
|
161
242
|
projectName,
|
|
@@ -167,9 +248,11 @@ const contextJson = {
|
|
|
167
248
|
warnFlags: warnFlags || 'None',
|
|
168
249
|
};
|
|
169
250
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
251
|
+
// Merge non-empty arrays from the existing JSON so manually recorded state
|
|
252
|
+
// is not silently dropped when the transcript scan finds nothing new.
|
|
253
|
+
const mergedJson = mergeExistingJson(contextJsonPath(cwd), contextJson);
|
|
254
|
+
|
|
255
|
+
try { writeContextState(cwd, { ...mergedJson, contextSummary: lastSummary }, { markdown: finalContent }); } catch { /* best effort */ }
|
|
173
256
|
if (decisions.length) {
|
|
174
257
|
const decisionsDir = join(projectCxDir, 'decisions');
|
|
175
258
|
const slug = `${date.replace(/[^0-9]/g, '')}-session-decisions`;
|
|
@@ -190,6 +273,10 @@ if (decisions.length) {
|
|
|
190
273
|
|
|
191
274
|
const globalCxDir = join(homedir(), '.cx');
|
|
192
275
|
try { mkdirSync(globalCxDir, { recursive: true }); } catch { /* exists */ }
|
|
193
|
-
|
|
276
|
+
const globalMdPath = contextMarkdownPath(homedir());
|
|
277
|
+
const globalManualSections = extractManualSections(globalMdPath);
|
|
278
|
+
const globalFinalContent = content + (globalManualSections ? '\n' + globalManualSections : '');
|
|
279
|
+
const globalMergedJson = mergeExistingJson(contextJsonPath(homedir()), contextJson);
|
|
280
|
+
try { writeContextState(homedir(), { ...globalMergedJson, contextSummary: lastSummary }, { markdown: globalFinalContent }); } catch { /* best effort */ }
|
|
194
281
|
|
|
195
282
|
process.exit(0);
|
|
@@ -198,8 +198,13 @@ function runNpmGate(projectDir) {
|
|
|
198
198
|
jobs.push({ label: 'audit', cmd: runner, args: auditArgs, timeout: 30_000 });
|
|
199
199
|
|
|
200
200
|
if (existsSync(join(projectDir, 'bin/construct'))) {
|
|
201
|
-
jobs.push({ label: 'evals',
|
|
202
|
-
jobs.push({ label: 'docs',
|
|
201
|
+
jobs.push({ label: 'evals', cmd: 'node', args: ['bin/construct', 'evals', 'retrieval'], timeout: 60_000 });
|
|
202
|
+
jobs.push({ label: 'docs', cmd: 'node', args: ['bin/construct', 'docs:verify'], timeout: 15_000 });
|
|
203
|
+
jobs.push({ label: 'docs drift', cmd: 'node', args: ['bin/construct', 'docs:update', '--check'], timeout: 15_000 });
|
|
204
|
+
jobs.push({ label: 'dashboard drift', cmd: 'node', args: ['bin/construct', 'dashboard:sync', '--check'], timeout: 15_000 });
|
|
205
|
+
jobs.push({ label: 'comment policy', cmd: 'node', args: ['bin/construct', 'lint:comments'], timeout: 30_000 });
|
|
206
|
+
jobs.push({ label: 'agents registry', cmd: 'node', args: ['bin/construct', 'lint:agents'], timeout: 15_000 });
|
|
207
|
+
jobs.push({ label: 'contracts schema', cmd: 'node', args: ['bin/construct', 'lint:contracts'], timeout: 15_000 });
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
if (existsSync(join(projectDir, 'scripts/lint-prose.mjs'))) {
|
|
@@ -274,15 +279,43 @@ function runJob(job) {
|
|
|
274
279
|
|
|
275
280
|
const results = await Promise.all(gate.jobs.map(runJob));
|
|
276
281
|
const failures = [];
|
|
282
|
+
|
|
283
|
+
// Auto-fix docs drift: if docs:update --check fails, run docs:update to regenerate
|
|
284
|
+
const docsDriftResult = results.find((r) => r.label === 'docs drift');
|
|
285
|
+
if (docsDriftResult && docsDriftResult.status !== 0) {
|
|
286
|
+
process.stderr.write('[pre-push-gate] Docs drift detected — auto-running docs:update...\n');
|
|
287
|
+
try {
|
|
288
|
+
const fix = spawnSync('node', ['bin/construct', 'docs:update'], {
|
|
289
|
+
cwd: gate.projectDir,
|
|
290
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
291
|
+
timeout: 30_000,
|
|
292
|
+
});
|
|
293
|
+
if (fix.status === 0) {
|
|
294
|
+
process.stderr.write('[pre-push-gate] docs:update succeeded — re-checking...\n');
|
|
295
|
+
const recheck = spawnSync('node', ['bin/construct', 'docs:update', '--check'], {
|
|
296
|
+
cwd: gate.projectDir,
|
|
297
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
298
|
+
timeout: 15_000,
|
|
299
|
+
});
|
|
300
|
+
if (recheck.status === 0) {
|
|
301
|
+
process.stderr.write('[pre-push-gate] Docs are now clean after auto-fix.\n');
|
|
302
|
+
results.splice(results.indexOf(docsDriftResult), 1); // remove from failure candidates
|
|
303
|
+
} else {
|
|
304
|
+
process.stderr.write(`[pre-push-gate] Auto-fix attempted but docs still drift — manual intervention needed.\n`);
|
|
305
|
+
docsDriftResult.stdout = (docsDriftResult.stdout || '') + '\n[auto-fix attempted but still dirty]';
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
process.stderr.write(`[pre-push-gate] docs:update auto-fix failed (exit ${fix.status}): ${fix.stderr.slice(0, 300)}\n`);
|
|
309
|
+
}
|
|
310
|
+
} catch (e) {
|
|
311
|
+
process.stderr.write(`[pre-push-gate] docs:update auto-fix threw: ${e.message}\n`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
277
315
|
for (const result of results) {
|
|
278
316
|
if (result.status === 0) continue;
|
|
279
317
|
const detail = (result.stderr || result.stdout || '').trim();
|
|
280
|
-
|
|
281
|
-
.map((l) => l.trim())
|
|
282
|
-
.filter((l) => l && !/^>/.test(l) && !/^\s*at /.test(l))
|
|
283
|
-
.slice(0, 3)
|
|
284
|
-
.join(' · ');
|
|
285
|
-
failures.push({ label: result.label, detail: firstError || `exited ${result.status}` });
|
|
318
|
+
failures.push({ label: result.label, detail: detail || `exited ${result.status}` });
|
|
286
319
|
}
|
|
287
320
|
|
|
288
321
|
if (failures.length === 0) { echo(); }
|
|
@@ -239,20 +239,6 @@ export function onScheduleCheck() {
|
|
|
239
239
|
return events;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
/**
|
|
243
|
-
* Get all pending activations for a specialist.
|
|
244
|
-
*/
|
|
245
|
-
export function getPendingActivations(specialist) {
|
|
246
|
-
// In a full implementation, this would query a pending queue
|
|
247
|
-
// For now, return recent activation history
|
|
248
|
-
const state = getActivationState(specialist);
|
|
249
|
-
return {
|
|
250
|
-
pending: 0,
|
|
251
|
-
recent: state.activations.length,
|
|
252
|
-
total: state.totalActivations,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
242
|
/**
|
|
257
243
|
* Clear activation state (for testing or manual reset).
|
|
258
244
|
*/
|
|
@@ -13,11 +13,11 @@ import { logHookFailure } from './_lib/log.mjs';
|
|
|
13
13
|
import { constructDir } from '../paths.mjs';
|
|
14
14
|
|
|
15
15
|
const filePath = process.env.TOOL_INPUT_FILE_PATH || '';
|
|
16
|
-
if (!filePath.endsWith(path.join('agents', 'registry.json')) && !filePath.endsWith('/
|
|
16
|
+
if (!filePath.endsWith(path.join('agents', 'registry.json')) && !filePath.endsWith('/specialists/registry.json')) process.exit(0);
|
|
17
17
|
|
|
18
18
|
const toolkitDir = constructDir();
|
|
19
19
|
try {
|
|
20
|
-
execSync('node scripts/sync-
|
|
20
|
+
execSync('node scripts/sync-specialists.mjs', {
|
|
21
21
|
cwd: toolkitDir,
|
|
22
22
|
stdio: 'pipe',
|
|
23
23
|
env: { ...process.env, CX_TOOLKIT_DIR: toolkitDir },
|