@geraldmaron/construct 1.0.3 → 1.0.5
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 +51 -42
- package/agents/prompts/cx-ai-engineer.md +6 -26
- package/agents/prompts/cx-architect.md +1 -0
- package/agents/prompts/cx-business-strategist.md +2 -0
- package/agents/prompts/cx-data-analyst.md +6 -26
- package/agents/prompts/cx-docs-keeper.md +1 -31
- package/agents/prompts/cx-explorer.md +1 -0
- package/agents/prompts/cx-orchestrator.md +40 -112
- package/agents/prompts/cx-platform-engineer.md +2 -22
- package/agents/prompts/cx-product-manager.md +2 -1
- package/agents/prompts/cx-qa.md +0 -20
- package/agents/prompts/cx-rd-lead.md +2 -0
- package/agents/prompts/cx-researcher.md +77 -31
- package/agents/prompts/cx-security.md +11 -49
- package/agents/prompts/cx-sre.md +9 -43
- package/agents/prompts/cx-ux-researcher.md +1 -0
- package/agents/role-manifests.json +4 -4
- package/bin/construct +279 -4
- package/commands/understand/research.md +5 -3
- package/db/schema/004_recommendations.sql +46 -0
- package/db/schema/005_strategy.sql +21 -0
- package/lib/auto-docs.mjs +3 -4
- package/lib/beads-automation.mjs +16 -7
- package/lib/cli-commands.mjs +52 -2
- package/lib/comment-lint.mjs +7 -1
- package/lib/config/schema.mjs +3 -0
- package/lib/embed/conflict-detection.mjs +26 -9
- package/lib/embed/customer-profiles.mjs +37 -17
- package/lib/embed/daemon.mjs +10 -8
- package/lib/embed/recommendation-store.mjs +213 -14
- package/lib/embed/workspaces.mjs +53 -18
- package/lib/flavors/loader.mjs +136 -0
- package/lib/gates-audit.mjs +3 -3
- package/lib/health-check.mjs +1 -1
- package/lib/hooks/agent-tracker.mjs +22 -3
- package/lib/hooks/pre-compact.mjs +3 -0
- package/lib/hooks/pre-push-gate.mjs +14 -1
- package/lib/hooks/read-tracker.mjs +10 -101
- package/lib/hooks/session-optimize.mjs +3 -2
- package/lib/hooks/session-reflect.mjs +68 -0
- package/lib/host-capabilities.mjs +90 -1
- package/lib/init-unified.mjs +25 -2
- package/lib/init-update.mjs +246 -131
- package/lib/intake/classify.mjs +61 -183
- package/lib/intake/prepare.mjs +7 -0
- package/lib/intake/tables/creative.mjs +94 -0
- package/lib/intake/tables/operations.mjs +85 -0
- package/lib/intake/tables/research.mjs +85 -0
- package/lib/intake/tables/rnd.mjs +175 -0
- package/lib/intent-classifier.mjs +1 -0
- package/lib/knowledge/layout.mjs +10 -0
- package/lib/knowledge/research-store.mjs +109 -0
- package/lib/mcp/tools/telemetry.mjs +30 -78
- package/lib/model-router.mjs +61 -1
- package/lib/observation-store.mjs +19 -0
- package/lib/ollama-manager.mjs +1 -1
- package/lib/opencode-telemetry.mjs +4 -5
- package/lib/orchestration-policy.mjs +9 -0
- package/lib/outcomes/aggregate.mjs +104 -0
- package/lib/outcomes/record.mjs +115 -0
- package/lib/parity.mjs +121 -21
- package/lib/profiles/lifecycle.mjs +388 -0
- package/lib/profiles/loader.mjs +123 -0
- package/lib/profiles/validate-custom.mjs +114 -0
- package/lib/prompt-composer.js +106 -29
- package/lib/read-tracker-store.mjs +149 -0
- package/lib/reflect/extractor.mjs +193 -0
- package/lib/reflect.mjs +89 -2
- package/lib/sandbox.mjs +102 -0
- package/lib/server/index.mjs +76 -0
- package/lib/server/telemetry-login.mjs +17 -25
- package/lib/service-manager.mjs +30 -22
- package/lib/services/local-postgres.mjs +15 -0
- package/lib/services/telemetry-backend.mjs +1 -2
- package/lib/setup.mjs +8 -43
- package/lib/status.mjs +51 -5
- package/lib/storage/backend.mjs +12 -2
- package/lib/strategy-store.mjs +371 -0
- package/lib/telemetry/backends/local.mjs +6 -4
- package/lib/telemetry/client.mjs +185 -0
- package/lib/telemetry/ingest.mjs +13 -5
- package/lib/telemetry/team-rollup.mjs +9 -2
- package/lib/worker/trace.mjs +17 -27
- package/package.json +10 -2
- package/personas/construct.md +20 -20
- package/platforms/claude/settings.template.json +13 -0
- package/rules/common/research.md +44 -12
- package/scripts/sync-agents.mjs +11 -0
- package/skills/docs/backlog-proposal-workflow.md +2 -2
- package/skills/docs/customer-profile-workflow.md +1 -1
- package/skills/docs/evidence-ingest-workflow.md +5 -5
- package/skills/docs/prfaq-workflow.md +1 -1
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +3 -3
- package/skills/docs/product-signal-workflow.md +48 -18
- package/skills/docs/research-workflow.md +26 -14
- package/skills/docs/strategy-workflow.md +36 -0
- package/skills/roles/architect.ai-systems.md +4 -2
- package/skills/roles/architect.data.md +4 -2
- package/skills/roles/architect.enterprise.md +4 -2
- package/skills/roles/architect.integration.md +4 -2
- package/skills/roles/architect.md +7 -5
- package/skills/roles/architect.platform.md +4 -2
- package/skills/roles/data-analyst.experiment.md +4 -2
- package/skills/roles/data-analyst.md +9 -7
- package/skills/roles/data-analyst.product-intelligence.md +5 -3
- package/skills/roles/data-analyst.product.md +4 -2
- package/skills/roles/data-analyst.telemetry.md +4 -2
- package/skills/roles/data-engineer.pipeline.md +4 -2
- package/skills/roles/data-engineer.vector-retrieval.md +4 -2
- package/skills/roles/data-engineer.warehouse.md +4 -2
- package/skills/roles/debugger.md +7 -5
- package/skills/roles/designer.accessibility.md +4 -2
- package/skills/roles/designer.md +10 -8
- package/skills/roles/engineer.ai.md +4 -2
- package/skills/roles/engineer.data.md +5 -3
- package/skills/roles/engineer.md +14 -12
- package/skills/roles/engineer.platform.md +5 -3
- package/skills/roles/operator.docs.md +6 -4
- package/skills/roles/operator.md +6 -4
- package/skills/roles/operator.release.md +4 -2
- package/skills/roles/operator.sre.md +5 -3
- package/skills/roles/orchestrator.md +5 -3
- package/skills/roles/product-manager.ai-product.md +4 -2
- package/skills/roles/product-manager.business-strategy.md +4 -2
- package/skills/roles/product-manager.enterprise.md +4 -2
- package/skills/roles/product-manager.growth.md +4 -2
- package/skills/roles/product-manager.md +6 -4
- package/skills/roles/product-manager.platform.md +4 -2
- package/skills/roles/product-manager.product.md +4 -2
- package/skills/roles/qa.ai-eval.md +4 -2
- package/skills/roles/qa.api-contract.md +4 -2
- package/skills/roles/qa.data-pipeline.md +4 -2
- package/skills/roles/qa.md +7 -5
- package/skills/roles/qa.test-automation.md +5 -3
- package/skills/roles/qa.web-ui.md +4 -2
- package/skills/roles/researcher.explorer.md +4 -2
- package/skills/roles/researcher.md +35 -20
- package/skills/roles/researcher.ux.md +4 -2
- package/skills/roles/reviewer.devil-advocate.md +4 -2
- package/skills/roles/reviewer.evaluator.md +4 -2
- package/skills/roles/reviewer.md +14 -12
- package/skills/roles/reviewer.trace.md +4 -2
- package/skills/roles/security.ai.md +4 -2
- package/skills/roles/security.appsec.md +4 -2
- package/skills/roles/security.cloud.md +4 -2
- package/skills/roles/security.legal-compliance.md +4 -2
- package/skills/roles/security.md +7 -5
- package/skills/roles/security.privacy.md +4 -2
- package/skills/roles/security.supply-chain.md +4 -2
- package/skills/routing.md +8 -1
- package/templates/docs/persona-artifact.md +36 -0
- package/templates/docs/research-brief.md +63 -9
- package/templates/docs/research-finding.md +26 -0
- package/templates/docs/skill-artifact.md +27 -0
- package/templates/docs/strategy.md +36 -0
- package/templates/homebrew/construct.rb +6 -6
package/lib/embed/workspaces.mjs
CHANGED
|
@@ -2,41 +2,61 @@
|
|
|
2
2
|
* lib/embed/workspaces.mjs — Multi-PM workspace management.
|
|
3
3
|
*
|
|
4
4
|
* Workspaces isolate PM workstreams while enabling cross-workspace visibility.
|
|
5
|
-
* Each workspace has an owner (PM), assigned customers, and
|
|
5
|
+
* Each workspace has an owner (PM), assigned customers, product areas, and a type.
|
|
6
|
+
*
|
|
7
|
+
* Workspace types: 'product' | 'platform' | 'enterprise' | 'growth' | 'ai-product'
|
|
8
|
+
* Default type: 'product'
|
|
6
9
|
*
|
|
7
10
|
* Storage:
|
|
8
|
-
* ~/.cx/
|
|
9
|
-
* ~/.cx/
|
|
11
|
+
* ~/.cx/knowledge/internal/workspaces/<workspace-id>.json
|
|
12
|
+
* ~/.cx/knowledge/internal/workspaces/index.json — quick lookup
|
|
10
13
|
*
|
|
11
14
|
* Intake packets are routed to workspaces based on customer assignment
|
|
12
15
|
* or product area matching. Cross-workspace signals trigger notifications.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
|
|
16
19
|
import { join } from 'node:path';
|
|
17
20
|
import { randomUUID } from 'node:crypto';
|
|
18
|
-
import {
|
|
21
|
+
import { cxDir } from '../paths.mjs';
|
|
22
|
+
import { knowledgeInternalStore } from '../knowledge/layout.mjs';
|
|
23
|
+
|
|
24
|
+
function migrateLegacyDir(modernDir, legacyDir) {
|
|
25
|
+
if (existsSync(modernDir) || !existsSync(legacyDir)) return;
|
|
26
|
+
mkdirSync(join(modernDir, '..'), { recursive: true });
|
|
27
|
+
try { renameSync(legacyDir, modernDir); } catch { /* compatibility-only */ }
|
|
28
|
+
}
|
|
19
29
|
|
|
20
|
-
|
|
21
|
-
const
|
|
30
|
+
function workspacePaths({ migrate = false } = {}) {
|
|
31
|
+
const modernDir = join(cxDir(), knowledgeInternalStore('workspaces'));
|
|
32
|
+
const legacyDir = join(cxDir(), 'product-intel', 'workspaces');
|
|
33
|
+
if (migrate) migrateLegacyDir(modernDir, legacyDir);
|
|
34
|
+
const workspacesDir = existsSync(modernDir) || !existsSync(legacyDir) ? modernDir : legacyDir;
|
|
35
|
+
return {
|
|
36
|
+
workspacesDir,
|
|
37
|
+
indexFile: join(workspacesDir, 'index.json'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
22
40
|
|
|
23
41
|
/**
|
|
24
42
|
* Ensure workspaces directory exists.
|
|
25
43
|
*/
|
|
26
44
|
function ensureDir() {
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
const { workspacesDir } = workspacePaths({ migrate: true });
|
|
46
|
+
if (!existsSync(workspacesDir)) {
|
|
47
|
+
mkdirSync(workspacesDir, { recursive: true });
|
|
29
48
|
}
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
/**
|
|
33
52
|
* Read the index file.
|
|
34
|
-
* @returns {{ [key: string]: { id: string, name: string, owner: string, status: string } }}
|
|
53
|
+
* @returns {{ [key: string]: { id: string, name: string, owner: string, status: string, type: string } }}
|
|
35
54
|
*/
|
|
36
55
|
function readIndex() {
|
|
37
|
-
|
|
56
|
+
const { indexFile } = workspacePaths();
|
|
57
|
+
if (!existsSync(indexFile)) return {};
|
|
38
58
|
try {
|
|
39
|
-
return JSON.parse(readFileSync(
|
|
59
|
+
return JSON.parse(readFileSync(indexFile, 'utf8'));
|
|
40
60
|
} catch (err) {
|
|
41
61
|
process.stderr.write('[workspaces.mjs] readIndex: ' + (err?.message ?? String(err)) + '\n');
|
|
42
62
|
return {};
|
|
@@ -48,10 +68,13 @@ function readIndex() {
|
|
|
48
68
|
* @param {object} index
|
|
49
69
|
*/
|
|
50
70
|
function writeIndex(index) {
|
|
71
|
+
const { indexFile } = workspacePaths();
|
|
51
72
|
ensureDir();
|
|
52
|
-
writeFileSync(
|
|
73
|
+
writeFileSync(indexFile, JSON.stringify(index, null, 2) + '\n');
|
|
53
74
|
}
|
|
54
75
|
|
|
76
|
+
const VALID_WORKSPACE_TYPES = ['product', 'platform', 'enterprise', 'growth', 'ai-product'];
|
|
77
|
+
|
|
55
78
|
/**
|
|
56
79
|
* Create a new workspace.
|
|
57
80
|
*
|
|
@@ -61,15 +84,19 @@ function writeIndex(index) {
|
|
|
61
84
|
* @param {string} [opts.description] - Workspace description
|
|
62
85
|
* @param {string[]} [opts.productAreas] - Product areas this workspace covers
|
|
63
86
|
* @param {string[]} [opts.customerIds] - Customer IDs assigned to this workspace
|
|
87
|
+
* @param {string} [opts.type] - Workspace type: 'product' | 'platform' | 'enterprise' | 'growth' | 'ai-product' (default: 'product')
|
|
64
88
|
* @returns {{ id: string, workspace: Workspace }}
|
|
65
89
|
*/
|
|
66
|
-
export function createWorkspace({ name, owner, description, productAreas = [], customerIds = [] }) {
|
|
90
|
+
export function createWorkspace({ name, owner, description, productAreas = [], customerIds = [], type = 'product' }) {
|
|
67
91
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
68
92
|
throw new Error('name is required');
|
|
69
93
|
}
|
|
70
94
|
if (!owner || typeof owner !== 'string' || !owner.trim()) {
|
|
71
95
|
throw new Error('owner is required');
|
|
72
96
|
}
|
|
97
|
+
if (!VALID_WORKSPACE_TYPES.includes(type)) {
|
|
98
|
+
throw new Error(`type must be one of: ${VALID_WORKSPACE_TYPES.join(', ')}`);
|
|
99
|
+
}
|
|
73
100
|
|
|
74
101
|
ensureDir();
|
|
75
102
|
|
|
@@ -79,6 +106,7 @@ export function createWorkspace({ name, owner, description, productAreas = [], c
|
|
|
79
106
|
name: name.trim(),
|
|
80
107
|
owner,
|
|
81
108
|
description: description || '',
|
|
109
|
+
type,
|
|
82
110
|
productAreas,
|
|
83
111
|
customerIds,
|
|
84
112
|
status: 'active',
|
|
@@ -86,7 +114,7 @@ export function createWorkspace({ name, owner, description, productAreas = [], c
|
|
|
86
114
|
updatedAt: new Date().toISOString(),
|
|
87
115
|
};
|
|
88
116
|
|
|
89
|
-
const filePath = join(
|
|
117
|
+
const filePath = join(workspacePaths().workspacesDir, `${id}.json`);
|
|
90
118
|
writeFileSync(filePath, JSON.stringify(workspace, null, 2) + '\n');
|
|
91
119
|
|
|
92
120
|
// Update index
|
|
@@ -96,6 +124,7 @@ export function createWorkspace({ name, owner, description, productAreas = [], c
|
|
|
96
124
|
name: workspace.name,
|
|
97
125
|
owner: workspace.owner,
|
|
98
126
|
status: workspace.status,
|
|
127
|
+
type: workspace.type,
|
|
99
128
|
};
|
|
100
129
|
writeIndex(index);
|
|
101
130
|
|
|
@@ -110,7 +139,7 @@ export function createWorkspace({ name, owner, description, productAreas = [], c
|
|
|
110
139
|
export function getWorkspace(workspaceId) {
|
|
111
140
|
ensureDir();
|
|
112
141
|
|
|
113
|
-
const filePath = join(
|
|
142
|
+
const filePath = join(workspacePaths().workspacesDir, `${workspaceId}.json`);
|
|
114
143
|
if (!existsSync(filePath)) return null;
|
|
115
144
|
|
|
116
145
|
try {
|
|
@@ -156,6 +185,7 @@ export function listWorkspaces(opts = {}) {
|
|
|
156
185
|
* @param {string[]} [updates.productAreas]
|
|
157
186
|
* @param {string[]} [updates.customerIds]
|
|
158
187
|
* @param {string} [updates.status]
|
|
188
|
+
* @param {string} [updates.type] - 'product' | 'platform' | 'enterprise' | 'growth' | 'ai-product'
|
|
159
189
|
* @returns {{ success: boolean, updatedAt: string }}
|
|
160
190
|
*/
|
|
161
191
|
export function updateWorkspace(workspaceId, updates) {
|
|
@@ -164,13 +194,17 @@ export function updateWorkspace(workspaceId, updates) {
|
|
|
164
194
|
throw new Error(`Workspace not found: ${workspaceId}`);
|
|
165
195
|
}
|
|
166
196
|
|
|
197
|
+
if (updates.type !== undefined && !VALID_WORKSPACE_TYPES.includes(updates.type)) {
|
|
198
|
+
throw new Error(`type must be one of: ${VALID_WORKSPACE_TYPES.join(', ')}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
167
201
|
const updated = {
|
|
168
202
|
...workspace,
|
|
169
203
|
...updates,
|
|
170
204
|
updatedAt: new Date().toISOString(),
|
|
171
205
|
};
|
|
172
206
|
|
|
173
|
-
const filePath = join(
|
|
207
|
+
const filePath = join(workspacePaths().workspacesDir, `${workspaceId}.json`);
|
|
174
208
|
writeFileSync(filePath, JSON.stringify(updated, null, 2) + '\n');
|
|
175
209
|
|
|
176
210
|
// Update index
|
|
@@ -181,6 +215,7 @@ export function updateWorkspace(workspaceId, updates) {
|
|
|
181
215
|
name: updated.name,
|
|
182
216
|
owner: updated.owner,
|
|
183
217
|
status: updated.status,
|
|
218
|
+
type: updated.type ?? workspace.type ?? 'product',
|
|
184
219
|
};
|
|
185
220
|
writeIndex(index);
|
|
186
221
|
}
|
|
@@ -194,7 +229,7 @@ export function updateWorkspace(workspaceId, updates) {
|
|
|
194
229
|
* @returns {{ success: boolean }}
|
|
195
230
|
*/
|
|
196
231
|
export function deleteWorkspace(workspaceId) {
|
|
197
|
-
const filePath = join(
|
|
232
|
+
const filePath = join(workspacePaths().workspacesDir, `${workspaceId}.json`);
|
|
198
233
|
if (!existsSync(filePath)) {
|
|
199
234
|
throw new Error(`Workspace not found: ${workspaceId}`);
|
|
200
235
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/flavors/loader.mjs — Role flavor overlay loader and validator.
|
|
3
|
+
*
|
|
4
|
+
* Reads frontmatter from skills/roles/<role>.<flavor>.md files, exposes:
|
|
5
|
+
* - listFlavors(role?, profile?) — overlays that apply to role and profile
|
|
6
|
+
* - validateFlavor(path) — frontmatter conformance check against the schema
|
|
7
|
+
* - perRoleFlavorCount(profile) — flavor counts per role for cap enforcement
|
|
8
|
+
*
|
|
9
|
+
* The cap is enforced at sync time and in lint-prose's sibling lint-flavors.
|
|
10
|
+
* Six flavors per role per profile is the current ceiling; bumping requires
|
|
11
|
+
* an ADR. Existing roles like qa already have 6, so the cap is set tight.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const REPO_ROOT = path.resolve(MODULE_DIR, '..', '..');
|
|
19
|
+
const FLAVORS_DIR = path.join(REPO_ROOT, 'skills', 'roles');
|
|
20
|
+
|
|
21
|
+
export const FLAVOR_CAP_PER_ROLE_PER_PROFILE = 6;
|
|
22
|
+
|
|
23
|
+
// Files may start with an HTML comment block before the YAML frontmatter.
|
|
24
|
+
// The /m flag lets ^ match line-start so we find the YAML wherever it sits.
|
|
25
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/m;
|
|
26
|
+
|
|
27
|
+
function parseFrontmatter(content) {
|
|
28
|
+
const m = FRONTMATTER_RE.exec(content);
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
const out = {};
|
|
31
|
+
let currentArrayKey = null;
|
|
32
|
+
for (const rawLine of m[1].split('\n')) {
|
|
33
|
+
const line = rawLine.replace(/\r$/, '');
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
if (currentArrayKey && /^\s*-\s+/.test(line)) {
|
|
36
|
+
out[currentArrayKey].push(line.replace(/^\s*-\s+/, '').trim());
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
currentArrayKey = null;
|
|
40
|
+
const kv = /^([a-z_]+):\s*(.*)$/.exec(line);
|
|
41
|
+
if (!kv) continue;
|
|
42
|
+
const [, key, valueRaw] = kv;
|
|
43
|
+
const value = valueRaw.trim();
|
|
44
|
+
if (value === '') {
|
|
45
|
+
out[key] = [];
|
|
46
|
+
currentArrayKey = key;
|
|
47
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
48
|
+
const items = value.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
|
|
49
|
+
out[key] = items;
|
|
50
|
+
} else if (value === 'null') {
|
|
51
|
+
out[key] = null;
|
|
52
|
+
} else if (/^-?\d+$/.test(value)) {
|
|
53
|
+
out[key] = Number(value);
|
|
54
|
+
} else {
|
|
55
|
+
out[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read and parse every overlay in skills/roles/. Returns { path, frontmatter }
|
|
63
|
+
* tuples. Files without parseable frontmatter are skipped silently.
|
|
64
|
+
*/
|
|
65
|
+
export function listAllFlavors() {
|
|
66
|
+
if (!fs.existsSync(FLAVORS_DIR)) return [];
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const f of fs.readdirSync(FLAVORS_DIR)) {
|
|
69
|
+
if (!f.endsWith('.md') || f === 'README.md') continue;
|
|
70
|
+
const full = path.join(FLAVORS_DIR, f);
|
|
71
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
72
|
+
const fm = parseFrontmatter(content);
|
|
73
|
+
if (fm) out.push({ path: full, file: f, frontmatter: fm });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Return overlays that match the given role (any-flavor for that role) and
|
|
80
|
+
* apply to the given profile. Pass undefined for either to skip that filter.
|
|
81
|
+
*/
|
|
82
|
+
export function listFlavors({ role, profile } = {}) {
|
|
83
|
+
return listAllFlavors().filter(({ frontmatter }) => {
|
|
84
|
+
if (role) {
|
|
85
|
+
const roleId = frontmatter.role || '';
|
|
86
|
+
const baseRole = roleId.split('.')[0];
|
|
87
|
+
if (baseRole !== role && roleId !== role) return false;
|
|
88
|
+
}
|
|
89
|
+
if (profile) {
|
|
90
|
+
const profiles = frontmatter.profiles;
|
|
91
|
+
if (!Array.isArray(profiles) || profiles.length === 0) return true;
|
|
92
|
+
if (!profiles.includes(profile)) return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Count flavor overlays per base role for a given profile.
|
|
100
|
+
* Returns { [baseRole]: count }. Excludes the canonical base file (e.g.
|
|
101
|
+
* architect.md) so only true flavors count toward the cap.
|
|
102
|
+
*/
|
|
103
|
+
export function perRoleFlavorCount(profile = 'rnd') {
|
|
104
|
+
const counts = {};
|
|
105
|
+
for (const entry of listFlavors({ profile })) {
|
|
106
|
+
const roleId = entry.frontmatter.role || '';
|
|
107
|
+
const isFlavor = roleId.includes('.');
|
|
108
|
+
if (!isFlavor) continue;
|
|
109
|
+
const base = roleId.split('.')[0];
|
|
110
|
+
counts[base] = (counts[base] || 0) + 1;
|
|
111
|
+
}
|
|
112
|
+
return counts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate one overlay's frontmatter against the schema invariants. Returns
|
|
117
|
+
* an array of error strings; empty array means valid.
|
|
118
|
+
*/
|
|
119
|
+
export function validateFlavor(filePath) {
|
|
120
|
+
const errors = [];
|
|
121
|
+
if (!fs.existsSync(filePath)) return [`file not found: ${filePath}`];
|
|
122
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
123
|
+
const fm = parseFrontmatter(content);
|
|
124
|
+
if (!fm) return [`${filePath}: missing or unparseable frontmatter`];
|
|
125
|
+
|
|
126
|
+
if (!fm.role || !/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)?$/.test(fm.role)) {
|
|
127
|
+
errors.push(`${filePath}: invalid role "${fm.role}"`);
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(fm.applies_to) || fm.applies_to.length === 0) {
|
|
130
|
+
errors.push(`${filePath}: applies_to must be a non-empty array`);
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(fm.profiles) || fm.profiles.length === 0) {
|
|
133
|
+
errors.push(`${filePath}: profiles must be a non-empty array (run scripts/migrate-flavors.mjs)`);
|
|
134
|
+
}
|
|
135
|
+
return errors;
|
|
136
|
+
}
|
package/lib/gates-audit.mjs
CHANGED
|
@@ -42,7 +42,7 @@ const GATE_DEFINITIONS = [
|
|
|
42
42
|
{ ciJob: 'secret scanning', prePushLabel: null, preCommitCheck: 'ECC secret scan', critical: true, note: 'pre-commit ECC scan covers a subset of gitleaks rules' },
|
|
43
43
|
{ ciJob: 'postgres + pgvector integration', prePushLabel: null, preCommitCheck: null, critical: false, note: 'CI-only: requires Docker Postgres + pgvector container; not practical locally', localMirror: 'ci-only' },
|
|
44
44
|
{ ciJob: 'docs drift check', prePushLabel: 'docs', critical: true },
|
|
45
|
-
{ ciJob: 'comment policy', prePushLabel: null, preCommitCheck: 'Construct comment-lint
|
|
45
|
+
{ ciJob: 'comment policy', prePushLabel: null, preCommitCheck: 'Construct comment-lint', critical: true, note: 'pre-commit Construct policy section calls `lint:comments` across the full worktree' },
|
|
46
46
|
{ ciJob: 'template policy', prePushLabel: null, preCommitGhPrIntercept: true, critical: true, note: 'pre-push-gate intercepts `gh pr create` / `gh pr edit` and lints the body' },
|
|
47
47
|
];
|
|
48
48
|
|
|
@@ -68,8 +68,8 @@ function parsePreCommitChecks(rootDir) {
|
|
|
68
68
|
const src = readFileSync(path, 'utf8');
|
|
69
69
|
const checks = [];
|
|
70
70
|
if (src.includes('scan_added_lines')) checks.push('ECC secret scan');
|
|
71
|
-
if (src.includes('construct lint:comments
|
|
72
|
-
if (src.includes('construct docs:verify
|
|
71
|
+
if (src.includes('construct lint:comments')) checks.push('Construct comment-lint');
|
|
72
|
+
if (src.includes('construct docs:verify')) checks.push('Construct docs:verify');
|
|
73
73
|
if (src.includes('BEADS INTEGRATION')) checks.push('BEADS dispatcher');
|
|
74
74
|
return checks;
|
|
75
75
|
}
|
package/lib/health-check.mjs
CHANGED
|
@@ -67,7 +67,7 @@ export async function checkPrerequisites(options = {}) {
|
|
|
67
67
|
required: true,
|
|
68
68
|
installed: hasDocker,
|
|
69
69
|
version: dockerVersion,
|
|
70
|
-
why: 'Required for
|
|
70
|
+
why: 'Required for managed Postgres; trace capture works locally without Docker',
|
|
71
71
|
install: isWindows
|
|
72
72
|
? 'https://docs.docker.com/desktop/install/windows-install/'
|
|
73
73
|
: 'https://docs.docker.com/get-docker/',
|
|
@@ -119,7 +119,7 @@ try {
|
|
|
119
119
|
if (success !== null) {
|
|
120
120
|
const learningDir = join(cxDir, 'learning', agentName);
|
|
121
121
|
mkdirSync(learningDir, { recursive: true });
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
const learningFile = join(learningDir, `${Date.now()}-${success ? 'success' : 'failure'}.json`);
|
|
124
124
|
const learningEntry = {
|
|
125
125
|
agent: agentName,
|
|
@@ -127,13 +127,32 @@ try {
|
|
|
127
127
|
description,
|
|
128
128
|
outcome,
|
|
129
129
|
timestamp: new Date().toISOString(),
|
|
130
|
-
// Extract potential patterns from description
|
|
131
130
|
keywords: extractKeywords(description),
|
|
132
131
|
taskType: classifyTaskType(description)
|
|
133
132
|
};
|
|
134
|
-
|
|
133
|
+
|
|
135
134
|
writeFileSync(learningFile, JSON.stringify(learningEntry, null, 2) + '\n');
|
|
136
135
|
}
|
|
136
|
+
|
|
137
|
+
// A3 outcome capture: record the per-project outcome JSONL that feeds
|
|
138
|
+
// .cx/outcomes/_summary.json and the classifier's tiebreaker. Best-effort;
|
|
139
|
+
// any failure leaves the existing telemetry paths above untouched.
|
|
140
|
+
if (success !== null) {
|
|
141
|
+
try {
|
|
142
|
+
const { recordOutcome } = await import('../outcomes/record.mjs');
|
|
143
|
+
const { resolveActiveProfile } = await import('../profiles/loader.mjs');
|
|
144
|
+
const roleId = agentName.replace(/^cx-/, '');
|
|
145
|
+
const projectCwd = input?.cwd || process.cwd();
|
|
146
|
+
const activeProfile = resolveActiveProfile(projectCwd);
|
|
147
|
+
recordOutcome(projectCwd, {
|
|
148
|
+
role: roleId,
|
|
149
|
+
success,
|
|
150
|
+
notes: outcome,
|
|
151
|
+
source: 'agent-tracker',
|
|
152
|
+
profile: activeProfile?.id ?? null,
|
|
153
|
+
});
|
|
154
|
+
} catch { /* best effort */ }
|
|
155
|
+
}
|
|
137
156
|
}
|
|
138
157
|
|
|
139
158
|
} catch (error) {
|
|
@@ -11,10 +11,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
|
11
11
|
import { homedir } from 'os';
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
import { writeContextState } from '../context-state.mjs';
|
|
14
|
+
import { flushReadTrackerDeltas } from '../read-tracker-store.mjs';
|
|
14
15
|
|
|
15
16
|
let input = {};
|
|
16
17
|
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
17
18
|
|
|
19
|
+
try { flushReadTrackerDeltas({ env: process.env }); } catch { /* best effort */ }
|
|
20
|
+
|
|
18
21
|
const cwd = input?.cwd || process.cwd();
|
|
19
22
|
const transcriptPath = input?.transcript_path || '';
|
|
20
23
|
|
|
@@ -189,13 +189,26 @@ function runNpmGate(projectDir) {
|
|
|
189
189
|
const jobs = [];
|
|
190
190
|
if (hasTest || hasCi) jobs.push({ label: 'tests', cmd: runner, args: ['run', testScript], timeout: 90_000 });
|
|
191
191
|
if (hasBuild) jobs.push({ label: 'build', cmd: runner, args: ['run', 'build'], timeout: 120_000 });
|
|
192
|
-
|
|
192
|
+
// Use the project's package manager for audit so the lockfile format matches.
|
|
193
|
+
const auditArgs = runner === 'pnpm'
|
|
194
|
+
? ['audit', '--prod', '--audit-level=high']
|
|
195
|
+
: runner === 'yarn'
|
|
196
|
+
? ['npm-audit', '--groups', 'dependencies', '--level', 'high']
|
|
197
|
+
: ['audit', '--omit=dev', '--audit-level=high'];
|
|
198
|
+
jobs.push({ label: 'audit', cmd: runner, args: auditArgs, timeout: 30_000 });
|
|
193
199
|
|
|
194
200
|
if (existsSync(join(projectDir, 'bin/construct'))) {
|
|
195
201
|
jobs.push({ label: 'evals', cmd: 'node', args: ['bin/construct', 'evals', 'retrieval'], timeout: 60_000 });
|
|
196
202
|
jobs.push({ label: 'docs', cmd: 'node', args: ['bin/construct', 'docs:verify'], timeout: 15_000 });
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
if (existsSync(join(projectDir, 'scripts/lint-prose.mjs'))) {
|
|
206
|
+
jobs.push({ label: 'prose', cmd: 'node', args: ['scripts/lint-prose.mjs'], timeout: 15_000 });
|
|
207
|
+
}
|
|
208
|
+
if (existsSync(join(projectDir, 'scripts/lint-profiles.mjs'))) {
|
|
209
|
+
jobs.push({ label: 'profiles', cmd: 'node', args: ['scripts/lint-profiles.mjs', '--quiet'], timeout: 15_000 });
|
|
210
|
+
}
|
|
211
|
+
|
|
199
212
|
return { projectDir, jobs };
|
|
200
213
|
}
|
|
201
214
|
|
|
@@ -7,63 +7,14 @@
|
|
|
7
7
|
* @p95ms 10
|
|
8
8
|
* @maxBlockingScope none (PostToolUse, non-blocking)
|
|
9
9
|
*/
|
|
10
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
11
11
|
import { createHash } from 'crypto';
|
|
12
12
|
import { join, resolve } from 'path';
|
|
13
13
|
import { homedir } from 'os';
|
|
14
|
+
import { flushReadTrackerDeltas, recordReadDelta } from '../read-tracker-store.mjs';
|
|
14
15
|
|
|
15
16
|
const CX_DIR = join(homedir(), '.cx');
|
|
16
17
|
const HASH_STORE = join(CX_DIR, 'file-hashes.json');
|
|
17
|
-
const EFFICIENCY_STORE = join(CX_DIR, 'session-efficiency.json');
|
|
18
|
-
const WARN_FLAGS = join(CX_DIR, 'warn-flags.txt');
|
|
19
|
-
const SESSION_IDLE_RESET_MS = 2 * 60 * 60 * 1000;
|
|
20
|
-
const REPEATED_READ_WARNING_THRESHOLD = 5;
|
|
21
|
-
const REPEATED_READ_TIER2_THRESHOLD = 8;
|
|
22
|
-
const LARGE_READ_WARNING_THRESHOLD = 3;
|
|
23
|
-
const TOTAL_BYTES_WARNING_THRESHOLD = 750_000;
|
|
24
|
-
|
|
25
|
-
// Bound per-session `stats.files` so the read-tracking map cannot grow
|
|
26
|
-
// without limit across long sessions. The hook is a fresh node process per
|
|
27
|
-
// Read, so every invocation must read-mutate-write the full state — there
|
|
28
|
-
// is no in-memory accumulator that would survive between calls. The LRU cap
|
|
29
|
-
// drops the oldest entries by `lastReadAt`.
|
|
30
|
-
|
|
31
|
-
const FILES_LRU_CAP = 200;
|
|
32
|
-
|
|
33
|
-
function loadEfficiencyStats(nowIso) {
|
|
34
|
-
const fresh = {
|
|
35
|
-
sessionStartedAt: nowIso,
|
|
36
|
-
lastUpdatedAt: nowIso,
|
|
37
|
-
readCount: 0,
|
|
38
|
-
uniqueFileCount: 0,
|
|
39
|
-
repeatedReadCount: 0,
|
|
40
|
-
largeReadCount: 0,
|
|
41
|
-
totalBytesRead: 0,
|
|
42
|
-
warnings: {},
|
|
43
|
-
files: {},
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
const existing = JSON.parse(readFileSync(EFFICIENCY_STORE, 'utf8'));
|
|
48
|
-
const lastUpdated = new Date(existing.lastUpdatedAt || 0).getTime();
|
|
49
|
-
if (!lastUpdated || Date.now() - lastUpdated > SESSION_IDLE_RESET_MS) return fresh;
|
|
50
|
-
return { ...fresh, ...existing, warnings: existing.warnings || {}, files: existing.files || {} };
|
|
51
|
-
} catch {
|
|
52
|
-
return fresh;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function appendWarning(message) {
|
|
57
|
-
try { appendFileSync(WARN_FLAGS, `${message}\n`); } catch { /* best effort */ }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function topRepeatedPath(files) {
|
|
61
|
-
return Object.entries(files || {})
|
|
62
|
-
.map(([filePath, value]) => ({ filePath, count: Number(value?.count || 0) }))
|
|
63
|
-
.filter((entry) => entry.count > 1)
|
|
64
|
-
.sort((a, b) => b.count - a.count || a.filePath.localeCompare(b.filePath))[0];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
18
|
let input = {};
|
|
68
19
|
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
69
20
|
|
|
@@ -81,7 +32,6 @@ try {
|
|
|
81
32
|
const nowIso = new Date().toISOString();
|
|
82
33
|
const requestedLimit = Number(input?.tool_input?.limit || 0);
|
|
83
34
|
const effectiveLimit = requestedLimit > 0 ? requestedLimit : 2000;
|
|
84
|
-
const isLargeRead = effectiveLimit > 400;
|
|
85
35
|
|
|
86
36
|
mkdirSync(CX_DIR, { recursive: true });
|
|
87
37
|
let store = {};
|
|
@@ -95,58 +45,17 @@ try {
|
|
|
95
45
|
if (new Date(v.ts).getTime() < cutoff) delete store[k];
|
|
96
46
|
}
|
|
97
47
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
stats.totalBytesRead += content.length;
|
|
102
|
-
if (isLargeRead) stats.largeReadCount += 1;
|
|
103
|
-
if (existingFile) {
|
|
104
|
-
stats.repeatedReadCount += 1;
|
|
105
|
-
} else {
|
|
106
|
-
stats.uniqueFileCount += 1;
|
|
107
|
-
}
|
|
108
|
-
stats.files[absPath] = {
|
|
109
|
-
count: (existingFile?.count || 0) + 1,
|
|
48
|
+
writeFileSync(HASH_STORE, JSON.stringify(store, null, 2));
|
|
49
|
+
recordReadDelta({
|
|
50
|
+
path: absPath,
|
|
110
51
|
size: content.length,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
};
|
|
52
|
+
limit: effectiveLimit,
|
|
53
|
+
ts: nowIso,
|
|
54
|
+
}, process.env);
|
|
114
55
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const fileEntries = Object.entries(stats.files);
|
|
118
|
-
if (fileEntries.length > FILES_LRU_CAP) {
|
|
119
|
-
fileEntries.sort((a, b) => (a[1]?.lastReadAt || '').localeCompare(b[1]?.lastReadAt || ''));
|
|
120
|
-
const drop = fileEntries.length - FILES_LRU_CAP;
|
|
121
|
-
for (let i = 0; i < drop; i++) delete stats.files[fileEntries[i][0]];
|
|
56
|
+
if (process.env.CONSTRUCT_READ_TRACKER_FLUSH === '1') {
|
|
57
|
+
flushReadTrackerDeltas({ nowIso, env: process.env });
|
|
122
58
|
}
|
|
123
|
-
|
|
124
|
-
if (stats.repeatedReadCount >= REPEATED_READ_WARNING_THRESHOLD && !stats.warnings.repeatedReads) {
|
|
125
|
-
const top = topRepeatedPath(stats.files);
|
|
126
|
-
const topNote = top ? ` Top repeat: ${top.filePath} (${top.count}x).` : '';
|
|
127
|
-
appendWarning(`Efficiency: ${stats.repeatedReadCount} repeated reads this session.${topNote} Use rg or construct distill before re-reading more files.`);
|
|
128
|
-
stats.warnings.repeatedReads = nowIso;
|
|
129
|
-
|
|
130
|
-
if (stats.repeatedReadCount >= REPEATED_READ_TIER2_THRESHOLD && !stats.warnings.repeatedReadsTier2) {
|
|
131
|
-
appendWarning(`Efficiency: ${stats.repeatedReadCount} repeated reads this session — consider using construct distill or rg before re-reading entire files.`);
|
|
132
|
-
stats.warnings.repeatedReadsTier2 = nowIso;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (stats.largeReadCount >= LARGE_READ_WARNING_THRESHOLD && !stats.warnings.largeReads) {
|
|
137
|
-
appendWarning(`Efficiency: ${stats.largeReadCount} large reads this session — prefer rg/glob plus targeted reads under 400 lines.`);
|
|
138
|
-
stats.warnings.largeReads = nowIso;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (stats.totalBytesRead >= TOTAL_BYTES_WARNING_THRESHOLD && !stats.warnings.totalBytes) {
|
|
142
|
-
appendWarning(`Efficiency: ${Math.round(stats.totalBytesRead / 1024)} KB read this session — consider distill/query-focused retrieval or compact context before continuing.`);
|
|
143
|
-
stats.warnings.totalBytes = nowIso;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
stats.lastUpdatedAt = nowIso;
|
|
147
|
-
|
|
148
|
-
writeFileSync(HASH_STORE, JSON.stringify(store, null, 2));
|
|
149
|
-
writeFileSync(EFFICIENCY_STORE, JSON.stringify(stats, null, 2));
|
|
150
59
|
} catch { /* best effort */ }
|
|
151
60
|
|
|
152
61
|
process.exit(0);
|
|
@@ -128,8 +128,9 @@ function optimizeAgent(agentName) {
|
|
|
128
128
|
const startTime = Date.now();
|
|
129
129
|
|
|
130
130
|
try {
|
|
131
|
-
//
|
|
132
|
-
|
|
131
|
+
// Preview only. The new contract requires an explicit --apply from the
|
|
132
|
+
// operator; hook-driven runs must never modify prompt files.
|
|
133
|
+
const result = spawnSync(CONSTRUCT_BIN, ['optimize', agentName, `--days=${OPTIMIZATION_DAYS}`, '--dry-run'], {
|
|
133
134
|
cwd,
|
|
134
135
|
encoding: 'utf8',
|
|
135
136
|
timeout: 300000, // 5 minutes timeout for optimization
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lib/hooks/session-reflect.mjs — Session end auto-reflect hook.
|
|
4
|
+
*
|
|
5
|
+
* Runs as a Stop hook. Reads the transcript, extracts a deterministic session
|
|
6
|
+
* summary, and records it via the observation store so subsequent sessions
|
|
7
|
+
* can search what happened. Closes the manual-reflect gap that left learning
|
|
8
|
+
* to operator discipline.
|
|
9
|
+
*
|
|
10
|
+
* Hard 500ms wall-clock budget. Exits 0 on any failure — observation capture
|
|
11
|
+
* is best-effort by design.
|
|
12
|
+
*
|
|
13
|
+
* Opt-out: CONSTRUCT_REFLECT_AUTO=off
|
|
14
|
+
*
|
|
15
|
+
* @p95ms 300
|
|
16
|
+
* @maxBlockingScope Stop
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const HARD_BUDGET_MS = 500;
|
|
22
|
+
const startedAt = Date.now();
|
|
23
|
+
|
|
24
|
+
// Opt-out before any heavy work — the env check is cheaper than parsing input.
|
|
25
|
+
if (process.env.CONSTRUCT_REFLECT_AUTO === 'off') process.exit(0);
|
|
26
|
+
|
|
27
|
+
let input = {};
|
|
28
|
+
try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
|
|
29
|
+
|
|
30
|
+
const cwd = input?.cwd || process.cwd();
|
|
31
|
+
const projectName = cwd.split('/').pop() || 'project';
|
|
32
|
+
|
|
33
|
+
// Only run for Construct projects — same gate as session-optimize.mjs.
|
|
34
|
+
if (projectName !== 'construct' && !existsSync(join(cwd, '.cx'))) process.exit(0);
|
|
35
|
+
|
|
36
|
+
const transcriptPath =
|
|
37
|
+
input?.transcript_path ||
|
|
38
|
+
input?.transcriptPath ||
|
|
39
|
+
process.env.CLAUDE_TRANSCRIPT_PATH ||
|
|
40
|
+
null;
|
|
41
|
+
|
|
42
|
+
if (!transcriptPath) process.exit(0);
|
|
43
|
+
|
|
44
|
+
// Wall-clock budget guard. If the extractor blows past the cap (it shouldn't
|
|
45
|
+
// — it's deterministic and O(n) over transcript lines), the parent process
|
|
46
|
+
// exits without writing. The next session-stop tries again.
|
|
47
|
+
const deadline = setTimeout(() => process.exit(0), HARD_BUDGET_MS);
|
|
48
|
+
deadline.unref();
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const { runReflectAuto } = await import('../reflect.mjs');
|
|
52
|
+
const sessionId = input?.session_id || input?.sessionId || null;
|
|
53
|
+
const durationMs = input?.session_duration_ms || null;
|
|
54
|
+
await runReflectAuto({ transcriptPath, cwd, sessionId, durationMs });
|
|
55
|
+
} catch {
|
|
56
|
+
// Best-effort — observation capture must not affect session exit.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const elapsed = Date.now() - startedAt;
|
|
60
|
+
if (elapsed > HARD_BUDGET_MS) {
|
|
61
|
+
// Log only if budget exceeded — surfaces in construct doctor if it becomes
|
|
62
|
+
// a pattern. Otherwise silent.
|
|
63
|
+
try {
|
|
64
|
+
process.stderr.write(`[session-reflect] over budget: ${elapsed}ms\n`);
|
|
65
|
+
} catch { /* stderr closed — nothing to do */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.exit(0);
|