@geraldmaron/construct 1.0.7 → 1.0.9
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 +20 -17
- package/bin/construct +1199 -69
- package/commands/work/optimize-prompts.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 +2 -2
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +5 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +65 -17
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/doctor/watchers/consistency.mjs +62 -12
- package/lib/document-extract.mjs +221 -28
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/inbox.mjs +48 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/env-config.mjs +9 -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/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +12 -0
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/registry-sync.mjs +2 -2
- 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 +18 -0
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +222 -55
- package/lib/intake/filesystem-queue.mjs +25 -5
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -0
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/mcp/server.mjs +9 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +1 -1
- package/lib/migrations/index.mjs +1 -1
- package/lib/model-registry.mjs +1 -1
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- 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/prompt-metadata.mjs +4 -4
- 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/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 +7 -1
- 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/{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/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/workflows/instantiate.mjs +320 -0
- package/package.json +13 -3
- package/personas/construct.md +2 -2
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/no-fabrication.md +1 -1
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +130 -58
- 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/{agents → specialists}/contracts.schema.json +1 -1
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-architect.md +1 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-debugger.md +1 -1
- package/{agents → specialists}/prompts/cx-designer.md +1 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
- package/{agents → specialists}/prompts/cx-engineer.md +2 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
- package/{agents → specialists}/prompts/cx-explorer.md +1 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
- package/{agents → specialists}/prompts/cx-operations.md +1 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-qa.md +1 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-researcher.md +1 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
- package/{agents → specialists}/prompts/cx-security.md +1 -1
- package/{agents → specialists}/prompts/cx-sre.md +2 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
- package/{agents → specialists}/registry.json +651 -423
- package/{agents → specialists}/role-manifests.json +8 -8
- 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 → specialists}/contracts.json +0 -0
- /package/{agents → specialists}/teams.json +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/extractors/calendar.mjs — RFC 5545 iCalendar (.ics) structured extraction.
|
|
3
|
+
*
|
|
4
|
+
* Parses VCALENDAR / VEVENT blocks without external dependencies. Handles
|
|
5
|
+
* multi-line property continuation (lines starting with whitespace), extracts
|
|
6
|
+
* SUMMARY, DTSTART, DTEND, ORGANIZER, ATTENDEE, DESCRIPTION, LOCATION, and
|
|
7
|
+
* preserves RRULE as a raw string for recurring events.
|
|
8
|
+
*
|
|
9
|
+
* Returns the universal { text, structured, droppedInfo } envelope where
|
|
10
|
+
* structured = { events: [{ summary, dtstart, dtend, organizer,
|
|
11
|
+
* attendees: [], description, location, rrule }] }.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { makeEnvelope, makeDropInfo } from './shared/drop-info.mjs';
|
|
15
|
+
|
|
16
|
+
// ─── RFC 5545 helpers ─────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
// Unfold continuation lines (RFC 5545 §3.1 — a line beginning with SPACE or TAB
|
|
19
|
+
// is a continuation of the previous property line).
|
|
20
|
+
function unfoldLines(raw) {
|
|
21
|
+
return raw.replace(/\r\n/g, '\n').replace(/\n[ \t]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Split a property line into its name, params object, and value.
|
|
25
|
+
// e.g. "DTSTART;TZID=America/New_York:20240101T090000" → name=DTSTART, params={TZID:...}, value=...
|
|
26
|
+
function parseProperty(line) {
|
|
27
|
+
const colonIdx = line.indexOf(':');
|
|
28
|
+
if (colonIdx === -1) return { name: line.toUpperCase(), params: {}, value: '' };
|
|
29
|
+
const nameAndParams = line.slice(0, colonIdx);
|
|
30
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
31
|
+
const parts = nameAndParams.split(';');
|
|
32
|
+
const name = (parts[0] || '').toUpperCase().trim();
|
|
33
|
+
const params = {};
|
|
34
|
+
for (const part of parts.slice(1)) {
|
|
35
|
+
const eqIdx = part.indexOf('=');
|
|
36
|
+
if (eqIdx === -1) continue;
|
|
37
|
+
params[part.slice(0, eqIdx).toUpperCase()] = part.slice(eqIdx + 1);
|
|
38
|
+
}
|
|
39
|
+
return { name, params, value };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract a human-readable name from an ORGANIZER or ATTENDEE value.
|
|
43
|
+
// Tries the CN param first, then strips mailto: prefix.
|
|
44
|
+
function extractName(value, params) {
|
|
45
|
+
if (params.CN) return params.CN.replace(/^["']|["']$/g, '');
|
|
46
|
+
return value.replace(/^mailto:/i, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Parse an iCal date/datetime string into an ISO string. Tolerant of both
|
|
50
|
+
// date-only (YYYYMMDD) and datetime (YYYYMMDDTHHMMSS[Z]) forms.
|
|
51
|
+
function parseIcalDate(value) {
|
|
52
|
+
if (!value) return null;
|
|
53
|
+
const v = value.replace('Z', '').trim();
|
|
54
|
+
if (v.length === 8) {
|
|
55
|
+
// Date-only: YYYYMMDD
|
|
56
|
+
return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
|
|
57
|
+
}
|
|
58
|
+
if (v.length >= 15 && v[8] === 'T') {
|
|
59
|
+
// Datetime: YYYYMMDDTHHMMSS
|
|
60
|
+
return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}T${v.slice(9, 11)}:${v.slice(11, 13)}:${v.slice(13, 15)}${value.endsWith('Z') ? 'Z' : ''}`;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── VEVENT block parser ──────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function parseVEvent(lines) {
|
|
68
|
+
const event = {
|
|
69
|
+
summary: null,
|
|
70
|
+
dtstart: null,
|
|
71
|
+
dtend: null,
|
|
72
|
+
organizer: null,
|
|
73
|
+
attendees: [],
|
|
74
|
+
description: null,
|
|
75
|
+
location: null,
|
|
76
|
+
rrule: null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (!line.includes(':')) continue;
|
|
81
|
+
const { name, params, value } = parseProperty(line);
|
|
82
|
+
switch (name) {
|
|
83
|
+
case 'SUMMARY': event.summary = value; break;
|
|
84
|
+
case 'DTSTART': event.dtstart = parseIcalDate(value); break;
|
|
85
|
+
case 'DTEND': event.dtend = parseIcalDate(value); break;
|
|
86
|
+
case 'DTDUE': event.dtend = event.dtend ?? parseIcalDate(value); break;
|
|
87
|
+
case 'ORGANIZER': event.organizer = extractName(value, params); break;
|
|
88
|
+
case 'ATTENDEE': event.attendees.push(extractName(value, params)); break;
|
|
89
|
+
case 'DESCRIPTION': event.description = value.replace(/\\n/g, '\n').replace(/\\,/g, ','); break;
|
|
90
|
+
case 'LOCATION': event.location = value; break;
|
|
91
|
+
case 'RRULE': event.rrule = value; break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return event;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── VCALENDAR parser ─────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function parseIcs(raw) {
|
|
100
|
+
const unfolded = unfoldLines(raw);
|
|
101
|
+
const lines = unfolded.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
102
|
+
|
|
103
|
+
if (!lines.some((l) => l === 'BEGIN:VCALENDAR')) {
|
|
104
|
+
return makeEnvelope({
|
|
105
|
+
text: raw.replace(/\r\n/g, '\n').trim(),
|
|
106
|
+
structured: null,
|
|
107
|
+
droppedInfo: [makeDropInfo({ kind: 'attachment', count: 1, reason: 'No BEGIN:VCALENDAR found; treating as plain text', recoverable: false })],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const events = [];
|
|
112
|
+
let inEvent = false;
|
|
113
|
+
let eventLines = [];
|
|
114
|
+
let droppedTodosVjournals = 0;
|
|
115
|
+
let inOther = false;
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (line === 'BEGIN:VEVENT') {
|
|
119
|
+
inEvent = true;
|
|
120
|
+
eventLines = [];
|
|
121
|
+
} else if (line === 'END:VEVENT') {
|
|
122
|
+
events.push(parseVEvent(eventLines));
|
|
123
|
+
inEvent = false;
|
|
124
|
+
} else if (line === 'BEGIN:VTODO' || line === 'BEGIN:VJOURNAL') {
|
|
125
|
+
inOther = true;
|
|
126
|
+
droppedTodosVjournals++;
|
|
127
|
+
} else if (line === 'END:VTODO' || line === 'END:VJOURNAL') {
|
|
128
|
+
inOther = false;
|
|
129
|
+
} else if (inEvent) {
|
|
130
|
+
eventLines.push(line);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const droppedInfo = [];
|
|
135
|
+
if (droppedTodosVjournals > 0) {
|
|
136
|
+
droppedInfo.push(makeDropInfo({
|
|
137
|
+
kind: 'attachment',
|
|
138
|
+
count: droppedTodosVjournals,
|
|
139
|
+
reason: 'VTODO and VJOURNAL components omitted (only VEVENT extracted)',
|
|
140
|
+
recoverable: false,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build text representation
|
|
145
|
+
const textParts = events.map((e) => {
|
|
146
|
+
const parts = [];
|
|
147
|
+
if (e.summary) parts.push(`Event: ${e.summary}`);
|
|
148
|
+
if (e.dtstart) parts.push(`Start: ${e.dtstart}`);
|
|
149
|
+
if (e.dtend) parts.push(`End: ${e.dtend}`);
|
|
150
|
+
if (e.organizer) parts.push(`Organizer: ${e.organizer}`);
|
|
151
|
+
if (e.attendees.length) parts.push(`Attendees: ${e.attendees.join(', ')}`);
|
|
152
|
+
if (e.location) parts.push(`Location: ${e.location}`);
|
|
153
|
+
if (e.description) parts.push(`\n${e.description}`);
|
|
154
|
+
return parts.join('\n');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return makeEnvelope({
|
|
158
|
+
text: textParts.join('\n\n').trim(),
|
|
159
|
+
structured: { events },
|
|
160
|
+
droppedInfo,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extract structured calendar data from a .ics file path.
|
|
168
|
+
* Returns the universal { text, structured, droppedInfo } envelope.
|
|
169
|
+
*/
|
|
170
|
+
export function extractCalendar(filePath) {
|
|
171
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
172
|
+
return parseIcs(raw);
|
|
173
|
+
}
|
|
@@ -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');
|
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)
|
|
@@ -11,6 +12,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
|
11
12
|
import { join } from 'path';
|
|
12
13
|
import { shouldCreateCx } from '../project-detection.mjs';
|
|
13
14
|
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
15
|
+
import { GenAiAttrs, withGenAiSpan } from '../telemetry/otel-tracer.mjs';
|
|
14
16
|
|
|
15
17
|
let input = {};
|
|
16
18
|
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
@@ -53,4 +55,14 @@ try {
|
|
|
53
55
|
}
|
|
54
56
|
} catch { /* best effort */ }
|
|
55
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
|
+
|
|
56
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(); }
|
|
@@ -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 },
|
|
@@ -161,7 +161,11 @@ try {
|
|
|
161
161
|
}
|
|
162
162
|
} catch { /* non-critical */ }
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
// Pricing readout is intentionally suppressed in user-facing notifications.
|
|
165
|
+
// The session-cost ledger is still written (lines below) so OTel + dashboard
|
|
166
|
+
// consumers can pick the data up when the observability surfaces are wired.
|
|
167
|
+
// Do not reintroduce a costNote variable here without a deliberate decision
|
|
168
|
+
// about whether to surface pricing to the user.
|
|
165
169
|
let totalCostUsd = 0;
|
|
166
170
|
let turnsLogged = 0;
|
|
167
171
|
try {
|
|
@@ -232,17 +236,14 @@ try {
|
|
|
232
236
|
|
|
233
237
|
saveTranscriptCheckpoints(checkpointPath, checkpoints);
|
|
234
238
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
costNote = `~$${totalCostUsd.toFixed(2)} ${label}`;
|
|
238
|
-
}
|
|
239
|
+
// totalCostUsd and turnsLogged stay as ledger-write accumulators only.
|
|
240
|
+
// Downstream telemetry consumers read the session-cost.jsonl ledger.
|
|
239
241
|
} catch { /* non-critical */ }
|
|
240
242
|
|
|
241
243
|
const parts = [];
|
|
242
244
|
if (fileCount > 0) parts.push(`${fileCount} file${fileCount !== 1 ? 's' : ''} updated`);
|
|
243
245
|
if (tsResult === 'pass') parts.push('TS OK');
|
|
244
246
|
else if (tsResult !== 'unchecked') parts.push(`TS: ${tsResult}`);
|
|
245
|
-
if (costNote) parts.push(costNote);
|
|
246
247
|
|
|
247
248
|
const hasWarnings = warnings.length > 0;
|
|
248
249
|
const title = hasWarnings ? 'Construct — Review needed' : 'Construct';
|
|
@@ -215,6 +215,10 @@ export function detectHostCapabilities() {
|
|
|
215
215
|
];
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
export function formatHostCapabilitiesJson(hosts = detectHostCapabilities()) {
|
|
219
|
+
return JSON.stringify(hosts, null, 2) + '\n';
|
|
220
|
+
}
|
|
221
|
+
|
|
218
222
|
export function printHostCapabilities(hosts = detectHostCapabilities()) {
|
|
219
223
|
console.log("Construct orchestration host capabilities:");
|
|
220
224
|
for (const host of hosts) {
|
|
@@ -229,5 +233,10 @@ export function printHostCapabilities(hosts = detectHostCapabilities()) {
|
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
232
|
-
|
|
236
|
+
const args = new Set(process.argv.slice(2));
|
|
237
|
+
if (args.has('--json')) {
|
|
238
|
+
process.stdout.write(formatHostCapabilitiesJson());
|
|
239
|
+
} else {
|
|
240
|
+
printHostCapabilities();
|
|
241
|
+
}
|
|
233
242
|
}
|