@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
package/lib/role-preload.mjs
CHANGED
|
@@ -34,11 +34,16 @@ function stripTopHeading(body) {
|
|
|
34
34
|
export function readRoleFile(root, name, opts = {}) {
|
|
35
35
|
const p = path.join(root, "skills", "roles", `${name}.md`);
|
|
36
36
|
if (!fs.existsSync(p)) return null;
|
|
37
|
+
const t0 = Date.now();
|
|
37
38
|
const body = stripTopHeading(stripFrontmatter(fs.readFileSync(p, "utf8"))).trim();
|
|
38
39
|
logSkillCall({
|
|
39
40
|
skillId: `roles/${name}`,
|
|
40
41
|
source: opts.source || 'role-preload',
|
|
41
42
|
callerContext: opts.callerContext,
|
|
43
|
+
latencyMs: Date.now() - t0,
|
|
44
|
+
agentId: opts.agentId,
|
|
45
|
+
sessionId: opts.sessionId,
|
|
46
|
+
tokensReturned: Math.ceil(body.length / 4),
|
|
42
47
|
});
|
|
43
48
|
return body;
|
|
44
49
|
}
|
package/lib/roles/catalog.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/roles/catalog.mjs — role listing and display helpers.
|
|
3
3
|
*
|
|
4
|
-
* Reads
|
|
4
|
+
* Reads specialists/registry.json to produce a list of available roles with
|
|
5
5
|
* optional department grouping and consolidated-role views. Consumed by the
|
|
6
6
|
* `roles:list` CLI command.
|
|
7
7
|
*/
|
package/lib/roles/manifest.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/roles/manifest.mjs — role manifest loader.
|
|
3
3
|
*
|
|
4
|
-
* Reads
|
|
4
|
+
* Reads specialists/role-manifests.json. A persona is "onboarded" when its
|
|
5
5
|
* `events` array is non-empty. Empty entries reserve the slot for future
|
|
6
6
|
* wiring without code change.
|
|
7
7
|
*/
|
|
@@ -11,7 +11,7 @@ import { dirname, join } from 'node:path';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
|
|
13
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const MANIFEST_PATH = join(__dirname, '..', '..', '
|
|
14
|
+
const MANIFEST_PATH = join(__dirname, '..', '..', 'specialists', 'role-manifests.json');
|
|
15
15
|
|
|
16
16
|
let cached = null;
|
|
17
17
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/scheduler/index.mjs — deployment-topology-aware background job scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Three modes:
|
|
5
|
+
* solo — native platform triggers (launchd/systemd/Task Scheduler) via lib/scheduler/solo.mjs
|
|
6
|
+
* team/enterprise — Postgres advisory lock runner via lib/scheduler/postgres.mjs
|
|
7
|
+
* one-shot — run handler synchronously and exit (for CI)
|
|
8
|
+
*
|
|
9
|
+
* Jobs registered here: 'tag-candidate-mining', 'skill-usage-rollup', 'doc-hygiene-scan'
|
|
10
|
+
*
|
|
11
|
+
* Deployment mode is determined by getDeploymentMode(env), which inspects env vars.
|
|
12
|
+
* Registration is in-memory: no persistence across process restarts beyond the
|
|
13
|
+
* native trigger files written by solo.mjs or the DB rows in 009_scheduler.sql.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// In-memory job registry
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const jobRegistry = new Map();
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Public API
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Registers a job definition in memory. Overwrites any prior registration
|
|
28
|
+
* with the same id.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* id {string} - Unique job identifier
|
|
32
|
+
* schedule {string} - Cron expression or interval string
|
|
33
|
+
* mode {string} - 'solo' | 'team' | 'enterprise' | 'one-shot'
|
|
34
|
+
* handler {Function} - Async function ({ cwd, env }) => result
|
|
35
|
+
* leaderLockKey {string} - Postgres advisory lock key (team/enterprise only)
|
|
36
|
+
*/
|
|
37
|
+
export function registerJob({ id, schedule, mode, handler, leaderLockKey = null }) {
|
|
38
|
+
jobRegistry.set(id, { id, schedule, mode, handler, leaderLockKey, registeredAt: new Date().toISOString() });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runs the handler for the given job id synchronously in the current process.
|
|
43
|
+
* Returns the handler's result. Throws if the job id is not registered.
|
|
44
|
+
*/
|
|
45
|
+
export async function runJobOnce(id, { cwd = process.cwd(), env = process.env } = {}) {
|
|
46
|
+
const job = jobRegistry.get(id);
|
|
47
|
+
if (!job) throw new Error(`job not registered: ${id}`);
|
|
48
|
+
return job.handler({ cwd, env });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns an array of all registered job definitions (handler excluded for safety).
|
|
53
|
+
*/
|
|
54
|
+
export function listJobs() {
|
|
55
|
+
return Array.from(jobRegistry.values()).map(({ id, schedule, mode, leaderLockKey, registeredAt }) => ({
|
|
56
|
+
id, schedule, mode, leaderLockKey, registeredAt,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Derives the deployment mode from environment variables.
|
|
62
|
+
* Returns 'enterprise' | 'team' | 'solo'.
|
|
63
|
+
*
|
|
64
|
+
* CONSTRUCT_DEPLOYMENT=enterprise or CONSTRUCT_ENTERPRISE=1 => enterprise
|
|
65
|
+
* CONSTRUCT_DEPLOYMENT=team or CONSTRUCT_TEAM=1 => team
|
|
66
|
+
* Otherwise => solo
|
|
67
|
+
*/
|
|
68
|
+
export function getDeploymentMode(env = process.env) {
|
|
69
|
+
const explicit = env.CONSTRUCT_DEPLOYMENT;
|
|
70
|
+
if (explicit === 'enterprise' || env.CONSTRUCT_ENTERPRISE === '1') return 'enterprise';
|
|
71
|
+
if (explicit === 'team' || env.CONSTRUCT_TEAM === '1') return 'team';
|
|
72
|
+
if (explicit === 'solo') return 'solo';
|
|
73
|
+
return 'solo';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Built-in job registrations
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
registerJob({
|
|
81
|
+
id: 'tag-candidate-mining',
|
|
82
|
+
schedule: '0 3 * * *',
|
|
83
|
+
mode: 'solo',
|
|
84
|
+
handler: async ({ cwd, env }) => {
|
|
85
|
+
const { default: run } = await import('../../scripts/tag-candidate-mining.mjs');
|
|
86
|
+
return run({ cwd, env });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
registerJob({
|
|
91
|
+
id: 'skill-usage-rollup',
|
|
92
|
+
schedule: '0 4 * * 1',
|
|
93
|
+
mode: 'solo',
|
|
94
|
+
handler: async () => ({ status: 'noop', reason: 'not yet implemented' }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
registerJob({
|
|
98
|
+
id: 'doc-hygiene-scan',
|
|
99
|
+
schedule: '0 2 * * *',
|
|
100
|
+
mode: 'solo',
|
|
101
|
+
handler: async ({ cwd, env }) => {
|
|
102
|
+
const { findHygieneCandidates } = await import('../hygiene/scan.mjs');
|
|
103
|
+
const mode = getDeploymentMode(env);
|
|
104
|
+
const limit = mode === 'solo' ? 25 : 50;
|
|
105
|
+
const candidates = findHygieneCandidates({ cwd, limit });
|
|
106
|
+
return {
|
|
107
|
+
status: 'ok',
|
|
108
|
+
candidates: candidates.length,
|
|
109
|
+
items: candidates.map((c) => ({ rel: c.rel, reason: c.reason, ageDays: c.ageDays })),
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/scheduler/solo.mjs — native platform trigger management for solo deployment mode.
|
|
3
|
+
*
|
|
4
|
+
* Detects the current platform (darwin/linux/win32) and writes the appropriate
|
|
5
|
+
* trigger file so the OS scheduler runs the job without a long-lived Construct
|
|
6
|
+
* daemon.
|
|
7
|
+
*
|
|
8
|
+
* darwin — launchd plist at ~/Library/LaunchAgents/com.construct.job.<id>.plist
|
|
9
|
+
* linux — systemd timer unit at ~/.config/systemd/user/construct-<id>.timer
|
|
10
|
+
* (plus a matching .service unit at the same path)
|
|
11
|
+
* win32 — no-op with a descriptive message (Task Scheduler XML generation
|
|
12
|
+
* is deferred; Windows support is tracked separately)
|
|
13
|
+
*
|
|
14
|
+
* The schedule parameter is a cron expression (5-field).
|
|
15
|
+
* launchd uses StartCalendarInterval derived from the cron fields.
|
|
16
|
+
* systemd uses OnCalendar= translated from the cron expression.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Cron parsing helpers — minimal support for standard 5-field cron
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function parseCron(schedule) {
|
|
28
|
+
const parts = schedule.trim().split(/\s+/);
|
|
29
|
+
if (parts.length !== 5) throw new Error(`unsupported cron expression (need 5 fields): ${schedule}`);
|
|
30
|
+
const [minute, hour, dom, month, dow] = parts;
|
|
31
|
+
return { minute, hour, dom, month, dow };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cronToLaunchdCalendar(schedule) {
|
|
35
|
+
const { minute, hour, dom, month, dow } = parseCron(schedule);
|
|
36
|
+
const items = [];
|
|
37
|
+
if (minute !== '*') items.push(` <key>Minute</key><integer>${minute}</integer>`);
|
|
38
|
+
if (hour !== '*') items.push(` <key>Hour</key><integer>${hour}</integer>`);
|
|
39
|
+
if (dom !== '*') items.push(` <key>Day</key><integer>${dom}</integer>`);
|
|
40
|
+
if (month !== '*') items.push(` <key>Month</key><integer>${month}</integer>`);
|
|
41
|
+
if (dow !== '*') items.push(` <key>Weekday</key><integer>${dow}</integer>`);
|
|
42
|
+
return items.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cronToSystemdOnCalendar(schedule) {
|
|
46
|
+
const { minute, hour, dom, month, dow } = parseCron(schedule);
|
|
47
|
+
const dayOfWeek = dow === '*' ? '*' : dow;
|
|
48
|
+
const d = dom === '*' ? '*' : dom;
|
|
49
|
+
const m = month === '*' ? '*' : `*-${month}`;
|
|
50
|
+
const time = `${hour === '*' ? '*' : hour.padStart(2,'0')}:${minute === '*' ? '*' : minute.padStart(2,'0')}:00`;
|
|
51
|
+
return `${dayOfWeek} ${m}-${d} ${time}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Platform-specific trigger writers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function writeLaunchdPlist(id, schedule, cwd) {
|
|
59
|
+
const label = `com.construct.job.${id}`;
|
|
60
|
+
const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
61
|
+
const plistPath = path.join(plistDir, `${label}.plist`);
|
|
62
|
+
const constructBin = path.join(cwd, 'bin', 'construct');
|
|
63
|
+
const calendarEntries = cronToLaunchdCalendar(schedule);
|
|
64
|
+
|
|
65
|
+
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
66
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
67
|
+
<plist version="1.0">
|
|
68
|
+
<dict>
|
|
69
|
+
<key>Label</key>
|
|
70
|
+
<string>${label}</string>
|
|
71
|
+
<key>ProgramArguments</key>
|
|
72
|
+
<array>
|
|
73
|
+
<string>${constructBin}</string>
|
|
74
|
+
<string>scheduler</string>
|
|
75
|
+
<string>run</string>
|
|
76
|
+
<string>${id}</string>
|
|
77
|
+
</array>
|
|
78
|
+
<key>WorkingDirectory</key>
|
|
79
|
+
<string>${cwd}</string>
|
|
80
|
+
<key>StartCalendarInterval</key>
|
|
81
|
+
<dict>
|
|
82
|
+
${calendarEntries}
|
|
83
|
+
</dict>
|
|
84
|
+
<key>StandardOutPath</key>
|
|
85
|
+
<string>${path.join(os.homedir(), '.cx', 'scheduler', 'logs', `${id}.stdout.log`)}</string>
|
|
86
|
+
<key>StandardErrorPath</key>
|
|
87
|
+
<string>${path.join(os.homedir(), '.cx', 'scheduler', 'logs', `${id}.stderr.log`)}</string>
|
|
88
|
+
<key>RunAtLoad</key>
|
|
89
|
+
<false/>
|
|
90
|
+
</dict>
|
|
91
|
+
</plist>
|
|
92
|
+
`;
|
|
93
|
+
fs.mkdirSync(plistDir, { recursive: true });
|
|
94
|
+
fs.writeFileSync(plistPath, content, 'utf8');
|
|
95
|
+
return plistPath;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeSystemdUnits(id, schedule, cwd) {
|
|
99
|
+
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
100
|
+
const timerPath = path.join(unitDir, `construct-${id}.timer`);
|
|
101
|
+
const servicePath = path.join(unitDir, `construct-${id}.service`);
|
|
102
|
+
const constructBin = path.join(cwd, 'bin', 'construct');
|
|
103
|
+
const onCalendar = cronToSystemdOnCalendar(schedule);
|
|
104
|
+
|
|
105
|
+
const timerContent = `[Unit]
|
|
106
|
+
Description=Construct job scheduler: ${id}
|
|
107
|
+
|
|
108
|
+
[Timer]
|
|
109
|
+
OnCalendar=${onCalendar}
|
|
110
|
+
Persistent=true
|
|
111
|
+
|
|
112
|
+
[Install]
|
|
113
|
+
WantedBy=timers.target
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
const serviceContent = `[Unit]
|
|
117
|
+
Description=Construct job: ${id}
|
|
118
|
+
|
|
119
|
+
[Service]
|
|
120
|
+
Type=oneshot
|
|
121
|
+
WorkingDirectory=${cwd}
|
|
122
|
+
ExecStart=${constructBin} scheduler run ${id}
|
|
123
|
+
StandardOutput=append:${path.join(os.homedir(), '.cx', 'scheduler', 'logs', `${id}.stdout.log`)}
|
|
124
|
+
StandardError=append:${path.join(os.homedir(), '.cx', 'scheduler', 'logs', `${id}.stderr.log`)}
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
128
|
+
fs.writeFileSync(timerPath, timerContent, 'utf8');
|
|
129
|
+
fs.writeFileSync(servicePath, serviceContent, 'utf8');
|
|
130
|
+
return timerPath;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Public API
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Detects the current platform and writes the appropriate native trigger file.
|
|
139
|
+
* Returns { platform, triggerPath, installed: boolean }.
|
|
140
|
+
*
|
|
141
|
+
* On win32, installed is false and triggerPath is null (deferred support).
|
|
142
|
+
*/
|
|
143
|
+
export function registerNativeTrigger({ id, schedule, cwd = process.cwd() }) {
|
|
144
|
+
const platform = process.platform;
|
|
145
|
+
|
|
146
|
+
if (platform === 'darwin') {
|
|
147
|
+
const triggerPath = writeLaunchdPlist(id, schedule, cwd);
|
|
148
|
+
return { platform, triggerPath, installed: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (platform === 'linux') {
|
|
152
|
+
const triggerPath = writeSystemdUnits(id, schedule, cwd);
|
|
153
|
+
return { platform, triggerPath, installed: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { platform, triggerPath: null, installed: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Removes the native trigger file(s) for the given job id.
|
|
161
|
+
* Silently ignores missing files.
|
|
162
|
+
*/
|
|
163
|
+
export function removeNativeTrigger({ id }) {
|
|
164
|
+
const platform = process.platform;
|
|
165
|
+
|
|
166
|
+
if (platform === 'darwin') {
|
|
167
|
+
const label = `com.construct.job.${id}`;
|
|
168
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
169
|
+
if (fs.existsSync(plistPath)) fs.unlinkSync(plistPath);
|
|
170
|
+
return { platform, removed: plistPath };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (platform === 'linux') {
|
|
174
|
+
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
175
|
+
const timerPath = path.join(unitDir, `construct-${id}.timer`);
|
|
176
|
+
const servicePath = path.join(unitDir, `construct-${id}.service`);
|
|
177
|
+
if (fs.existsSync(timerPath)) fs.unlinkSync(timerPath);
|
|
178
|
+
if (fs.existsSync(servicePath)) fs.unlinkSync(servicePath);
|
|
179
|
+
return { platform, removed: timerPath };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { platform, removed: null };
|
|
183
|
+
}
|
package/lib/server/index.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Runs on port 4242 (overridable via PORT env var), bound to 127.0.0.1.
|
|
9
9
|
*/
|
|
10
10
|
import { createServer } from 'http';
|
|
11
|
+
import { registerBoundary } from '../boundary.mjs';
|
|
11
12
|
import { readFileSync, writeFileSync, statSync, watch, existsSync, readdirSync, mkdirSync, renameSync, chmodSync, appendFileSync } from 'fs';
|
|
12
13
|
import { spawnSync } from 'child_process';
|
|
13
14
|
import { join, extname, relative, normalize, dirname } from 'path';
|
|
@@ -692,6 +693,12 @@ const server = createServer(async (req, res) => {
|
|
|
692
693
|
return;
|
|
693
694
|
}
|
|
694
695
|
|
|
696
|
+
if (url.pathname === '/api/services/langfuse/login' && req.method === 'GET') {
|
|
697
|
+
const { handleLangfuseLogin } = await import('./langfuse-login.mjs');
|
|
698
|
+
await handleLangfuseLogin(req, res);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
695
702
|
if (url.pathname === '/api/status') {
|
|
696
703
|
try {
|
|
697
704
|
const status = await buildStatus();
|
|
@@ -1115,44 +1122,36 @@ const server = createServer(async (req, res) => {
|
|
|
1115
1122
|
const chunks = [];
|
|
1116
1123
|
await new Promise((resolve, reject) => { req.on('data', c => chunks.push(c)); req.on('end', resolve); req.on('error', reject); });
|
|
1117
1124
|
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
1118
|
-
|
|
1119
|
-
const { parentInstance, parentUrl, childInstanceId } = body;
|
|
1120
|
-
|
|
1125
|
+
|
|
1126
|
+
const { parentInstance, parentUrl, childInstanceId, nonce, signature } = body;
|
|
1127
|
+
|
|
1121
1128
|
if (!parentInstance || !parentUrl) {
|
|
1122
1129
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1123
1130
|
res.end(JSON.stringify({ error: 'parentInstance and parentUrl are required' }));
|
|
1124
1131
|
return;
|
|
1125
1132
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
// - Validate the parent is a legitimate Construct instance
|
|
1129
|
-
// - Store the parent registration in a boundary configuration
|
|
1130
|
-
// - Set up communication channels between parent and child
|
|
1131
|
-
// - Configure isolation boundaries
|
|
1132
|
-
|
|
1133
|
-
// For now, just acknowledge the registration
|
|
1134
|
-
const configDir = join(HOME, '.construct');
|
|
1135
|
-
if (!existsSync(configDir)) {
|
|
1136
|
-
mkdirSync(configDir, { recursive: true });
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
const boundaryConfig = {
|
|
1133
|
+
|
|
1134
|
+
const result = await registerBoundary({
|
|
1140
1135
|
parentInstance,
|
|
1141
1136
|
parentUrl,
|
|
1142
|
-
childInstanceId: childInstanceId || env.CONSTRUCT_INSTANCE_ID || 'default',
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1137
|
+
childInstanceId: childInstanceId || process.env.CONSTRUCT_INSTANCE_ID || 'default',
|
|
1138
|
+
nonce,
|
|
1139
|
+
signature,
|
|
1140
|
+
sharedSecret: process.env.CONSTRUCT_BOUNDARY_SECRET || null,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
if (!result.ok) {
|
|
1144
|
+
res.writeHead(result.status || 400, { 'Content-Type': 'application/json' });
|
|
1145
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1150
1149
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1151
|
-
res.end(JSON.stringify({
|
|
1150
|
+
res.end(JSON.stringify({
|
|
1152
1151
|
success: true,
|
|
1153
1152
|
message: 'Boundary registration accepted',
|
|
1154
|
-
boundaryConfig,
|
|
1155
|
-
boundaryConfigPath
|
|
1153
|
+
boundaryConfig: result.config,
|
|
1154
|
+
boundaryConfigPath: result.path,
|
|
1156
1155
|
}));
|
|
1157
1156
|
} catch (err) {
|
|
1158
1157
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -2181,7 +2180,7 @@ watchFiles();
|
|
|
2181
2180
|
// to keep the knowledge base current without manual intervention.
|
|
2182
2181
|
// Override interval via CX_EMBED_INTERVAL_MS (default: 30 minutes).
|
|
2183
2182
|
function runEmbedSync() {
|
|
2184
|
-
const syncScript = join(ROOT_DIR, 'scripts', 'sync-
|
|
2183
|
+
const syncScript = join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
|
|
2185
2184
|
if (!existsSync(syncScript)) return;
|
|
2186
2185
|
try {
|
|
2187
2186
|
spawnSync(process.execPath, [syncScript], {
|
|
@@ -2243,3 +2242,4 @@ process.on('SIGINT', () => shutdown('SIGINT'));
|
|
|
2243
2242
|
onEmbedNotification((event) => {
|
|
2244
2243
|
notifyClients({ type: 'toast', ...event });
|
|
2245
2244
|
});
|
|
2245
|
+
|
package/lib/server/insights.mjs
CHANGED
|
@@ -47,9 +47,11 @@ async function fetchTelemetrySummary(env, { limit = 100 } = {}) {
|
|
|
47
47
|
if (!res.ok) return { state: 'unreachable', message: `Telemetry HTTP ${res.status}` };
|
|
48
48
|
const json = await res.json();
|
|
49
49
|
const traces = Array.isArray(json.data) ? json.data : [];
|
|
50
|
-
|
|
50
|
+
// Cost figures are intentionally absent from the user-facing summary.
|
|
51
|
+
// The underlying telemetry trace data still carries totalCost; that
|
|
52
|
+
// surfaces via the OTel + dashboard wiring planned in Workstream J.
|
|
53
|
+
let observations = 0; const byModel = new Map(); let withError = 0; let withInput = 0; let withOutput = 0;
|
|
51
54
|
for (const trace of traces) {
|
|
52
|
-
totalCost += Number(trace?.totalCost) || 0;
|
|
53
55
|
observations += Number(trace?.observationCount) || 0;
|
|
54
56
|
if (trace?.input != null) withInput += 1;
|
|
55
57
|
if (trace?.output != null) withOutput += 1;
|
|
@@ -59,16 +61,16 @@ async function fetchTelemetrySummary(env, { limit = 100 } = {}) {
|
|
|
59
61
|
trace?.output?.model || trace?.output?.costModel || trace?.input?.metadata?.model ||
|
|
60
62
|
(Array.isArray(trace?.observations) ? trace.observations.find((o) => o?.model)?.model : null) || null;
|
|
61
63
|
if (!model) continue;
|
|
62
|
-
const prior = byModel.get(model) || { traces: 0
|
|
63
|
-
prior.traces += 1;
|
|
64
|
+
const prior = byModel.get(model) || { traces: 0 };
|
|
65
|
+
prior.traces += 1;
|
|
64
66
|
byModel.set(model, prior);
|
|
65
67
|
}
|
|
66
68
|
return {
|
|
67
69
|
state: traces.length === 0 ? 'empty' : 'ok',
|
|
68
70
|
baseUrl, sampleSize: traces.length,
|
|
69
|
-
|
|
70
|
-
topModels: [...byModel.entries()].map(([model, v]) => ({ model, traces: v.traces
|
|
71
|
-
.sort((a, b) => b.
|
|
71
|
+
observations, withInput, withOutput, withError,
|
|
72
|
+
topModels: [...byModel.entries()].map(([model, v]) => ({ model, traces: v.traces }))
|
|
73
|
+
.sort((a, b) => b.traces - a.traces).slice(0, 5),
|
|
72
74
|
};
|
|
73
75
|
} catch (err) {
|
|
74
76
|
clearTimeout(timer);
|
|
@@ -727,11 +729,13 @@ export async function buildInsights({ env = process.env, cwd = process.cwd(), ro
|
|
|
727
729
|
} catch (err) {
|
|
728
730
|
handoffs = { state: 'unreachable', message: err.message };
|
|
729
731
|
}
|
|
730
|
-
|
|
732
|
+
// Cost-ledger and telemetry-cost-trend summaries are intentionally omitted
|
|
733
|
+
// from the dashboard payload. Underlying ledger writes + telemetry traces
|
|
734
|
+
// continue; the consumer surface lands with the OTel + dashboard wiring
|
|
735
|
+
// (plan Workstream J).
|
|
736
|
+
const [telemetry, telemetryLatency, intake, beads, recommendations, integrations] = await Promise.all([
|
|
731
737
|
fetchTelemetrySummary(env),
|
|
732
|
-
summarizeTelemetryCostTrend(env),
|
|
733
738
|
summarizeTelemetryLatency(env),
|
|
734
|
-
summarizeCostLedger(env),
|
|
735
739
|
Promise.resolve(summarizeIntakeQueue(env, cwd)),
|
|
736
740
|
Promise.resolve(summarizeBeads(rootDir)),
|
|
737
741
|
summarizeRecommendations(),
|
|
@@ -747,9 +751,7 @@ export async function buildInsights({ env = process.env, cwd = process.cwd(), ro
|
|
|
747
751
|
} catch { sessionUsage = null; }
|
|
748
752
|
return {
|
|
749
753
|
telemetry,
|
|
750
|
-
telemetryTrend,
|
|
751
754
|
telemetryLatency,
|
|
752
|
-
cost,
|
|
753
755
|
intake,
|
|
754
756
|
recommendations,
|
|
755
757
|
integrations,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/server/langfuse-login.mjs — Magic-link bridge for local Langfuse.
|
|
3
|
+
*
|
|
4
|
+
* `construct up` seeds Langfuse with deterministic admin creds via the
|
|
5
|
+
* LANGFUSE_INIT_* env vars in langfuse/docker-compose.yml. This bridge turns
|
|
6
|
+
* the "Open Langfuse" link in the dashboard into a one-click sign-in: the
|
|
7
|
+
* server fetches a CSRF token from local Langfuse, then returns an HTML
|
|
8
|
+
* auto-submit form that POSTs the seeded credentials to NextAuth's callback.
|
|
9
|
+
* The browser receives Langfuse's session cookie on localhost:3000 directly,
|
|
10
|
+
* sidestepping cross-origin restrictions.
|
|
11
|
+
*
|
|
12
|
+
* The seeded creds are NOT secret — they are deterministic local defaults
|
|
13
|
+
* declared in the public compose file. Their only job is to make local
|
|
14
|
+
* Langfuse zero-touch.
|
|
15
|
+
*/
|
|
16
|
+
import { LANGFUSE_LOCAL } from '../service-manager.mjs';
|
|
17
|
+
|
|
18
|
+
const HTML_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
19
|
+
|
|
20
|
+
function escapeHtml(value) {
|
|
21
|
+
return String(value).replace(/[&<>"']/g, (c) => ({
|
|
22
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
23
|
+
})[c]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function handleLangfuseLogin(req, res, { baseUrl = LANGFUSE_LOCAL.baseUrl, email = LANGFUSE_LOCAL.email, pwd = LANGFUSE_LOCAL.pwd } = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const csrfResponse = await fetch(`${baseUrl}/api/auth/csrf`, { headers: { Accept: 'application/json' } });
|
|
29
|
+
if (!csrfResponse.ok) {
|
|
30
|
+
res.writeHead(502, HTML_HEADERS);
|
|
31
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse at ${escapeHtml(baseUrl)}. Status: ${csrfResponse.status}. Try \`construct up\` first.</pre>`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const { csrfToken } = await csrfResponse.json();
|
|
35
|
+
|
|
36
|
+
const callbackUrl = `${baseUrl}/api/auth/callback/credentials`;
|
|
37
|
+
const redirectAfter = `${baseUrl}/`;
|
|
38
|
+
|
|
39
|
+
res.writeHead(200, HTML_HEADERS);
|
|
40
|
+
res.end(`<!doctype html>
|
|
41
|
+
<html><head><meta charset="utf-8"><title>Signing in to Langfuse…</title>
|
|
42
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;color:#666;background:#fff}</style>
|
|
43
|
+
</head><body>
|
|
44
|
+
<p>Signing you in to Langfuse…</p>
|
|
45
|
+
<form id="lf" method="POST" action="${escapeHtml(callbackUrl)}">
|
|
46
|
+
<input type="hidden" name="csrfToken" value="${escapeHtml(csrfToken)}">
|
|
47
|
+
<input type="hidden" name="email" value="${escapeHtml(email)}">
|
|
48
|
+
<input type="hidden" name="password" value="${escapeHtml(pwd)}">
|
|
49
|
+
<input type="hidden" name="callbackUrl" value="${escapeHtml(redirectAfter)}">
|
|
50
|
+
<input type="hidden" name="json" value="false">
|
|
51
|
+
</form>
|
|
52
|
+
<script>document.getElementById('lf').submit();</script>
|
|
53
|
+
</body></html>`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
res.writeHead(502, HTML_HEADERS);
|
|
56
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Langfuse unreachable</title><pre>Could not reach local Langfuse: ${escapeHtml(err?.message || 'unknown error')}. Try \`construct up\` first.</pre>`);
|
|
57
|
+
}
|
|
58
|
+
}
|