@geraldmaron/construct 1.0.4 → 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.
Files changed (90) hide show
  1. package/README.md +51 -42
  2. package/bin/construct +256 -4
  3. package/commands/understand/research.md +5 -3
  4. package/lib/auto-docs.mjs +2 -2
  5. package/lib/cli-commands.mjs +44 -0
  6. package/lib/comment-lint.mjs +7 -1
  7. package/lib/config/schema.mjs +3 -0
  8. package/lib/flavors/loader.mjs +136 -0
  9. package/lib/hooks/agent-tracker.mjs +22 -3
  10. package/lib/hooks/pre-push-gate.mjs +14 -1
  11. package/lib/hooks/session-optimize.mjs +3 -2
  12. package/lib/hooks/session-reflect.mjs +68 -0
  13. package/lib/init-unified.mjs +25 -2
  14. package/lib/intake/classify.mjs +61 -183
  15. package/lib/intake/prepare.mjs +7 -0
  16. package/lib/intake/tables/creative.mjs +94 -0
  17. package/lib/intake/tables/operations.mjs +85 -0
  18. package/lib/intake/tables/research.mjs +85 -0
  19. package/lib/intake/tables/rnd.mjs +175 -0
  20. package/lib/knowledge/research-store.mjs +109 -0
  21. package/lib/observation-store.mjs +19 -0
  22. package/lib/outcomes/aggregate.mjs +104 -0
  23. package/lib/outcomes/record.mjs +115 -0
  24. package/lib/parity.mjs +6 -9
  25. package/lib/profiles/lifecycle.mjs +388 -0
  26. package/lib/profiles/loader.mjs +123 -0
  27. package/lib/profiles/validate-custom.mjs +114 -0
  28. package/lib/reflect/extractor.mjs +193 -0
  29. package/lib/reflect.mjs +89 -2
  30. package/lib/sandbox.mjs +102 -0
  31. package/package.json +6 -1
  32. package/personas/construct.md +20 -20
  33. package/platforms/claude/settings.template.json +13 -0
  34. package/scripts/sync-agents.mjs +11 -0
  35. package/skills/roles/architect.ai-systems.md +4 -2
  36. package/skills/roles/architect.data.md +4 -2
  37. package/skills/roles/architect.enterprise.md +4 -2
  38. package/skills/roles/architect.integration.md +4 -2
  39. package/skills/roles/architect.md +7 -5
  40. package/skills/roles/architect.platform.md +4 -2
  41. package/skills/roles/data-analyst.experiment.md +4 -2
  42. package/skills/roles/data-analyst.md +9 -7
  43. package/skills/roles/data-analyst.product-intelligence.md +4 -2
  44. package/skills/roles/data-analyst.product.md +4 -2
  45. package/skills/roles/data-analyst.telemetry.md +4 -2
  46. package/skills/roles/data-engineer.pipeline.md +4 -2
  47. package/skills/roles/data-engineer.vector-retrieval.md +4 -2
  48. package/skills/roles/data-engineer.warehouse.md +4 -2
  49. package/skills/roles/debugger.md +7 -5
  50. package/skills/roles/designer.accessibility.md +4 -2
  51. package/skills/roles/designer.md +10 -8
  52. package/skills/roles/engineer.ai.md +4 -2
  53. package/skills/roles/engineer.data.md +5 -3
  54. package/skills/roles/engineer.md +14 -12
  55. package/skills/roles/engineer.platform.md +5 -3
  56. package/skills/roles/operator.docs.md +6 -4
  57. package/skills/roles/operator.md +6 -4
  58. package/skills/roles/operator.release.md +4 -2
  59. package/skills/roles/operator.sre.md +5 -3
  60. package/skills/roles/orchestrator.md +5 -3
  61. package/skills/roles/product-manager.ai-product.md +4 -2
  62. package/skills/roles/product-manager.business-strategy.md +4 -2
  63. package/skills/roles/product-manager.enterprise.md +4 -2
  64. package/skills/roles/product-manager.growth.md +4 -2
  65. package/skills/roles/product-manager.md +6 -4
  66. package/skills/roles/product-manager.platform.md +4 -2
  67. package/skills/roles/product-manager.product.md +4 -2
  68. package/skills/roles/qa.ai-eval.md +4 -2
  69. package/skills/roles/qa.api-contract.md +4 -2
  70. package/skills/roles/qa.data-pipeline.md +4 -2
  71. package/skills/roles/qa.md +7 -5
  72. package/skills/roles/qa.test-automation.md +5 -3
  73. package/skills/roles/qa.web-ui.md +4 -2
  74. package/skills/roles/researcher.explorer.md +4 -2
  75. package/skills/roles/researcher.md +11 -9
  76. package/skills/roles/researcher.ux.md +4 -2
  77. package/skills/roles/reviewer.devil-advocate.md +4 -2
  78. package/skills/roles/reviewer.evaluator.md +4 -2
  79. package/skills/roles/reviewer.md +14 -12
  80. package/skills/roles/reviewer.trace.md +4 -2
  81. package/skills/roles/security.ai.md +4 -2
  82. package/skills/roles/security.appsec.md +4 -2
  83. package/skills/roles/security.cloud.md +4 -2
  84. package/skills/roles/security.legal-compliance.md +4 -2
  85. package/skills/roles/security.md +7 -5
  86. package/skills/roles/security.privacy.md +4 -2
  87. package/skills/roles/security.supply-chain.md +4 -2
  88. package/templates/docs/persona-artifact.md +36 -0
  89. package/templates/docs/research-finding.md +26 -0
  90. package/templates/docs/skill-artifact.md +27 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * lib/profiles/loader.mjs — Curated and custom org-type profile loader.
3
+ *
4
+ * Reads JSON profiles from `<repo>/profiles/<id>.json` for the curated set
5
+ * (rnd, marketing, game-studio, internal-tools), and from `<cwd>/.cx/profile.json`
6
+ * for user-defined profiles when `custom: true`.
7
+ *
8
+ * Resolution order for the active profile:
9
+ * 1. `construct.config.json` -> `profile`
10
+ * 2. `<cwd>/.cx/profile.json` -> `id` (custom)
11
+ * 3. Default: 'rnd'
12
+ *
13
+ * All errors fall back to 'rnd' so a malformed profile never breaks the CLI.
14
+ * Validation surfaces in `npm run lint:profiles` and in B3's pre-push gate.
15
+ */
16
+
17
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
22
+ const REPO_ROOT = resolve(MODULE_DIR, '..', '..');
23
+ const PROFILES_DIR = join(REPO_ROOT, 'profiles');
24
+
25
+ export const DEFAULT_PROFILE_ID = 'rnd';
26
+
27
+ /**
28
+ * Read a profile by id from the curated catalog. Returns null if not found.
29
+ */
30
+ export function loadProfile(id) {
31
+ if (!id || typeof id !== 'string') return null;
32
+ const path = join(PROFILES_DIR, `${id}.json`);
33
+ if (!existsSync(path)) return null;
34
+ try {
35
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
36
+ return raw;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * List every curated profile id by scanning the profiles directory.
44
+ */
45
+ export function listProfiles() {
46
+ if (!existsSync(PROFILES_DIR)) return [];
47
+ return readdirSync(PROFILES_DIR)
48
+ .filter((f) => f.endsWith('.json'))
49
+ .map((f) => f.replace(/\.json$/, ''))
50
+ .sort();
51
+ }
52
+
53
+ /**
54
+ * Load a user-defined profile from `<cwd>/.cx/profile.json`. Returns null if
55
+ * the file is missing, malformed, or does not have `custom: true`.
56
+ */
57
+ export function loadCustomProfile(cwd) {
58
+ if (!cwd) return null;
59
+ const path = join(cwd, '.cx', 'profile.json');
60
+ if (!existsSync(path)) return null;
61
+ try {
62
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
63
+ if (raw && raw.custom === true) return raw;
64
+ return null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Resolve the active profile for a project. Always returns a profile object.
72
+ *
73
+ * Precedence (highest first):
74
+ * 1. Explicit `configProfileId` argument
75
+ * 2. `<cwd>/.cx/profile.json` with `custom: true`
76
+ * 3. `profile` field in `<cwd>/construct.config.json`
77
+ * 4. Default `rnd`
78
+ *
79
+ * Falls back to a minimal RND object on any miss so the CLI never breaks.
80
+ *
81
+ * @param {string} cwd - project root
82
+ * @param {string} [configProfileId] - explicit override, bypasses file lookups
83
+ */
84
+ export function resolveActiveProfile(cwd, configProfileId = null) {
85
+ if (configProfileId) {
86
+ const p = loadProfile(configProfileId);
87
+ if (p) return p;
88
+ }
89
+ const custom = loadCustomProfile(cwd);
90
+ if (custom) return custom;
91
+ const fromConfig = readProfileFromProjectConfig(cwd);
92
+ if (fromConfig) {
93
+ const p = loadProfile(fromConfig);
94
+ if (p) return p;
95
+ }
96
+ return loadProfile(DEFAULT_PROFILE_ID) ?? minimalRndFallback();
97
+ }
98
+
99
+ function readProfileFromProjectConfig(cwd) {
100
+ if (!cwd) return null;
101
+ const p = join(cwd, 'construct.config.json');
102
+ if (!existsSync(p)) return null;
103
+ try {
104
+ const raw = JSON.parse(readFileSync(p, 'utf8'));
105
+ return typeof raw?.profile === 'string' ? raw.profile : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // Last-resort fallback if the curated rnd profile file is missing or
112
+ // unreadable. Lets callers always rely on a non-null shape.
113
+ function minimalRndFallback() {
114
+ return {
115
+ id: 'rnd',
116
+ displayName: 'Software R&D',
117
+ roles: [],
118
+ intake: { types: [], stages: [] },
119
+ docTemplates: [],
120
+ hooks: { sessionReflect: 'on', sessionOptimize: 'on' },
121
+ rebrand: { intakeQueueLabel: 'R&D intake queue', signalNoun: 'signal' },
122
+ };
123
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * lib/profiles/validate-custom.mjs — Schema validator for user-defined profiles.
3
+ *
4
+ * Custom profiles live at `<project>/.cx/profile.json` with `custom: true`.
5
+ * They are validated on `construct sync` and via the pre-push gate. Rejected
6
+ * profiles never get loaded; resolveActiveProfile falls back to the default
7
+ * so a malformed escape-hatch file never breaks a project.
8
+ *
9
+ * Hard limits the validator enforces (kept in sync with schemas/profile.schema.json
10
+ * and scripts/lint-profiles.mjs):
11
+ * - max 12 stages
12
+ * - max 24 intake types
13
+ * - max 80 roles per profile
14
+ * - max 12 departments per profile
15
+ * - max 20 roles per department
16
+ * - classificationTable path must stay inside .cx/ (no repo escapes)
17
+ *
18
+ * Rationale for each cap lives in docs/concepts/persona-research.md.
19
+ */
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ const MAX_STAGES = 12;
24
+ const MAX_INTAKE_TYPES = 24;
25
+ const MAX_ROLES = 80;
26
+ const MAX_DEPARTMENTS = 12;
27
+ const MAX_ROLES_PER_DEPARTMENT = 20;
28
+
29
+ /**
30
+ * @returns {string[]} array of error strings; empty means valid.
31
+ */
32
+ export function validateCustomProfile(profile, { cwd } = {}) {
33
+ const errors = [];
34
+ if (!profile || typeof profile !== 'object') return ['profile is not an object'];
35
+ if (profile.custom !== true) errors.push('custom profiles must set custom: true');
36
+
37
+ if (!profile.id || !/^[a-z][a-z0-9-]{1,30}$/.test(profile.id)) {
38
+ errors.push('id must match ^[a-z][a-z0-9-]{1,30}$');
39
+ }
40
+ if (!profile.displayName || typeof profile.displayName !== 'string') {
41
+ errors.push('displayName is required');
42
+ }
43
+ if (!Array.isArray(profile.roles) || profile.roles.length === 0) {
44
+ errors.push('roles must be a non-empty array');
45
+ } else if (profile.roles.length > MAX_ROLES) {
46
+ errors.push(`roles exceeds max of ${MAX_ROLES}`);
47
+ }
48
+ if (!profile.intake || typeof profile.intake !== 'object') {
49
+ errors.push('intake is required');
50
+ } else {
51
+ if (!Array.isArray(profile.intake.types) || profile.intake.types.length === 0) {
52
+ errors.push('intake.types must be a non-empty array');
53
+ } else if (profile.intake.types.length > MAX_INTAKE_TYPES) {
54
+ errors.push(`intake.types exceeds max of ${MAX_INTAKE_TYPES}`);
55
+ }
56
+ if (!Array.isArray(profile.intake.stages) || profile.intake.stages.length === 0) {
57
+ errors.push('intake.stages must be a non-empty array');
58
+ } else if (profile.intake.stages.length > MAX_STAGES) {
59
+ errors.push(`intake.stages exceeds max of ${MAX_STAGES}`);
60
+ }
61
+ if (typeof profile.intake.classificationTable === 'string') {
62
+ const t = profile.intake.classificationTable;
63
+ if (path.isAbsolute(t)) {
64
+ errors.push('intake.classificationTable must be a relative path');
65
+ } else if (cwd && !t.startsWith('.cx/')) {
66
+ errors.push('intake.classificationTable must live under .cx/ for custom profiles');
67
+ }
68
+ }
69
+ }
70
+ if (profile.departments !== undefined) {
71
+ if (!Array.isArray(profile.departments)) {
72
+ errors.push('departments must be an array');
73
+ } else {
74
+ if (profile.departments.length > MAX_DEPARTMENTS) {
75
+ errors.push(`departments exceeds max of ${MAX_DEPARTMENTS}`);
76
+ }
77
+ for (const [i, dept] of profile.departments.entries()) {
78
+ if (!dept || typeof dept !== 'object') {
79
+ errors.push(`departments[${i}] must be an object`);
80
+ continue;
81
+ }
82
+ if (!dept.id || !/^[a-z][a-z0-9-]{1,40}$/.test(dept.id)) {
83
+ errors.push(`departments[${i}].id is missing or malformed`);
84
+ }
85
+ if (!dept.charter || dept.charter.length < 20) {
86
+ errors.push(`departments[${i}].charter must be at least 20 chars (mission statement, not a label)`);
87
+ }
88
+ if (!Array.isArray(dept.roles) || dept.roles.length === 0) {
89
+ errors.push(`departments[${i}].roles must be a non-empty array`);
90
+ } else if (dept.roles.length > MAX_ROLES_PER_DEPARTMENT) {
91
+ errors.push(`departments[${i}].roles exceeds max of ${MAX_ROLES_PER_DEPARTMENT}`);
92
+ }
93
+ }
94
+ }
95
+ }
96
+ return errors;
97
+ }
98
+
99
+ /**
100
+ * Read and validate `<cwd>/.cx/profile.json`. Returns:
101
+ * { status: 'absent' } if the file does not exist
102
+ * { status: 'invalid', errors: [...] } if validation failed
103
+ * { status: 'ok', profile } if the file is a valid custom profile
104
+ */
105
+ export function validateCustomProfileFile(cwd) {
106
+ const p = path.join(cwd, '.cx', 'profile.json');
107
+ if (!fs.existsSync(p)) return { status: 'absent' };
108
+ let raw;
109
+ try { raw = JSON.parse(fs.readFileSync(p, 'utf8')); }
110
+ catch (err) { return { status: 'invalid', errors: [`malformed JSON: ${err.message}`] }; }
111
+ const errors = validateCustomProfile(raw, { cwd });
112
+ if (errors.length > 0) return { status: 'invalid', errors };
113
+ return { status: 'ok', profile: raw };
114
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * lib/reflect/extractor.mjs — Deterministic transcript → observation extractor.
3
+ *
4
+ * Pure-function pass over a Claude Code transcript JSONL. Produces a compact
5
+ * structured summary suitable for `addObservation()` at session end. No LLM
6
+ * call — the transcript itself is the source of truth.
7
+ *
8
+ * Caller responsibility: read the transcript file and pass parsed lines.
9
+ * Output is capped at MAX_BYTES to honor the A1 hook's per-session budget.
10
+ */
11
+
12
+ // Hard cap on the summary content so a runaway session can't blow up the
13
+ // observation store. 5 KB matches the budget set in the A1 plan.
14
+ const MAX_BYTES = 5 * 1024;
15
+ const MAX_TAGS = 10;
16
+ const MAX_TOOL_TYPES = 8;
17
+ const MAX_FILES = 12;
18
+
19
+ /**
20
+ * Extract a session-summary observation from a parsed transcript.
21
+ *
22
+ * @param {object} args
23
+ * @param {object[]} args.entries — parsed transcript JSONL lines (objects)
24
+ * @param {string} args.cwd — session working directory
25
+ * @param {string} [args.sessionId] — session identifier
26
+ * @param {number} [args.durationMs] — wall-clock session duration
27
+ * @returns {object|null} observation payload, or null if transcript is unusable
28
+ */
29
+ export function extractSessionObservation({ entries, cwd, sessionId, durationMs }) {
30
+ if (!Array.isArray(entries) || entries.length === 0) return null;
31
+
32
+ const stats = collectStats(entries);
33
+ if (stats.assistantTurns === 0 && stats.userTurns === 0) return null;
34
+
35
+ const finalAssistant = stats.lastAssistantText;
36
+ const headline = deriveHeadline(finalAssistant, stats);
37
+
38
+ const lines = [];
39
+ lines.push(`Session: ${stats.userTurns} user turns, ${stats.assistantTurns} assistant turns`);
40
+ if (stats.toolCallCount > 0) {
41
+ const tools = Array.from(stats.toolTypes.entries())
42
+ .sort((a, b) => b[1] - a[1])
43
+ .slice(0, MAX_TOOL_TYPES)
44
+ .map(([name, n]) => `${name}×${n}`)
45
+ .join(', ');
46
+ lines.push(`Tools: ${stats.toolCallCount} calls (${tools})`);
47
+ }
48
+ if (stats.filesTouched.size > 0) {
49
+ const files = Array.from(stats.filesTouched).slice(0, MAX_FILES);
50
+ lines.push(`Files touched: ${files.join(', ')}${stats.filesTouched.size > MAX_FILES ? ', …' : ''}`);
51
+ }
52
+ if (durationMs && Number.isFinite(durationMs)) {
53
+ lines.push(`Duration: ${Math.round(durationMs / 1000)}s`);
54
+ }
55
+ if (finalAssistant) {
56
+ lines.push('', 'Final response excerpt:', clamp(finalAssistant, 800));
57
+ }
58
+
59
+ const content = clamp(lines.join('\n'), MAX_BYTES);
60
+
61
+ return {
62
+ role: stats.lastAgent || 'construct',
63
+ category: 'session-summary',
64
+ summary: headline,
65
+ content,
66
+ tags: buildTags(stats),
67
+ confidence: 0.7,
68
+ source: 'auto-reflect',
69
+ extras: {
70
+ sessionId: sessionId || null,
71
+ cwd,
72
+ durationMs: durationMs ?? null,
73
+ userTurns: stats.userTurns,
74
+ assistantTurns: stats.assistantTurns,
75
+ toolCallCount: stats.toolCallCount,
76
+ filesTouched: Array.from(stats.filesTouched).slice(0, MAX_FILES),
77
+ },
78
+ };
79
+ }
80
+
81
+ // Walk the transcript once and gather everything we need in one pass. Keeps
82
+ // the hook well under its latency budget even on long sessions.
83
+
84
+ function collectStats(entries) {
85
+ const toolTypes = new Map();
86
+ const filesTouched = new Set();
87
+ let assistantTurns = 0;
88
+ let userTurns = 0;
89
+ let toolCallCount = 0;
90
+ let lastAssistantText = '';
91
+ let lastAgent = null;
92
+
93
+ for (const entry of entries) {
94
+ if (!entry || typeof entry !== 'object') continue;
95
+ const type = entry.type;
96
+
97
+ if (type === 'user') {
98
+ userTurns += 1;
99
+ continue;
100
+ }
101
+
102
+ if (type === 'assistant') {
103
+ assistantTurns += 1;
104
+ const text = extractAssistantText(entry);
105
+ if (text) lastAssistantText = text;
106
+ const calls = extractToolCalls(entry);
107
+ for (const call of calls) {
108
+ toolCallCount += 1;
109
+ toolTypes.set(call.name, (toolTypes.get(call.name) || 0) + 1);
110
+ for (const f of call.files) filesTouched.add(f);
111
+ }
112
+ const agent = extractAgentName(entry);
113
+ if (agent) lastAgent = agent;
114
+ }
115
+ }
116
+
117
+ return { assistantTurns, userTurns, toolCallCount, toolTypes, filesTouched, lastAssistantText, lastAgent };
118
+ }
119
+
120
+ function extractAssistantText(entry) {
121
+ const message = entry.message ?? entry;
122
+ const content = message?.content;
123
+ if (typeof content === 'string') return content.trim();
124
+ if (Array.isArray(content)) {
125
+ const text = content
126
+ .filter((p) => p?.type === 'text' && typeof p.text === 'string')
127
+ .map((p) => p.text)
128
+ .join('\n')
129
+ .trim();
130
+ return text;
131
+ }
132
+ return '';
133
+ }
134
+
135
+ function extractToolCalls(entry) {
136
+ const message = entry.message ?? entry;
137
+ const content = message?.content;
138
+ if (!Array.isArray(content)) return [];
139
+ const calls = [];
140
+ for (const part of content) {
141
+ if (part?.type !== 'tool_use') continue;
142
+ const name = part.name || 'unknown';
143
+ const files = [];
144
+ const input = part.input || {};
145
+ if (typeof input.file_path === 'string') files.push(shortenPath(input.file_path));
146
+ if (typeof input.path === 'string') files.push(shortenPath(input.path));
147
+ calls.push({ name, files });
148
+ }
149
+ return calls;
150
+ }
151
+
152
+ function extractAgentName(entry) {
153
+ // Claude Code may stamp the agent name on subagent transcripts. Fall through
154
+ // to null if not present — caller defaults to 'construct'.
155
+ return entry?.agent || entry?.subagent_type || entry?.message?.agent || null;
156
+ }
157
+
158
+ function deriveHeadline(finalText, stats) {
159
+ // First non-empty line of the final assistant turn is usually a good lead.
160
+ // Fall back to a structural summary when nothing useful is available.
161
+ if (finalText) {
162
+ const firstLine = finalText.split('\n').map((l) => l.trim()).find((l) => l.length > 0);
163
+ if (firstLine) {
164
+ const clean = firstLine.replace(/^[#*\->]\s*/, '').replace(/[`*_]/g, '');
165
+ return `[session] ${clamp(clean, 200)}`;
166
+ }
167
+ }
168
+ return `[session] ${stats.assistantTurns} turn${stats.assistantTurns === 1 ? '' : 's'}, ${stats.toolCallCount} tool call${stats.toolCallCount === 1 ? '' : 's'}`;
169
+ }
170
+
171
+ function buildTags(stats) {
172
+ const tags = ['auto-reflect', 'session-summary'];
173
+ // Surface the most-used tool family so search can filter on "Bash-heavy
174
+ // session", etc. without parsing content.
175
+ const top = Array.from(stats.toolTypes.entries()).sort((a, b) => b[1] - a[1]).slice(0, 3);
176
+ for (const [name] of top) tags.push(`tool:${name.toLowerCase()}`);
177
+ if (stats.filesTouched.size > 0) tags.push('edits');
178
+ return tags.slice(0, MAX_TAGS);
179
+ }
180
+
181
+ function shortenPath(p) {
182
+ if (typeof p !== 'string') return '';
183
+ // Strip a repo-root prefix when present so observations stay portable.
184
+ const cwd = process.cwd();
185
+ if (p.startsWith(cwd + '/')) return p.slice(cwd.length + 1);
186
+ return p;
187
+ }
188
+
189
+ function clamp(str, max) {
190
+ if (!str) return '';
191
+ if (str.length <= max) return str;
192
+ return str.slice(0, max - 1) + '…';
193
+ }
package/lib/reflect.mjs CHANGED
@@ -5,11 +5,13 @@
5
5
  * knowledge base with actionable insights for continuous self-improvement.
6
6
  */
7
7
 
8
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
9
- import { join, resolve } from 'node:path';
8
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
9
+ import { join, resolve, basename } from 'node:path';
10
+ import { execSync } from 'node:child_process';
10
11
  import { addObservation } from './observation-store.mjs';
11
12
  import { loadConstructEnv } from './env-config.mjs';
12
13
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS, inferKnowledgeTarget } from './knowledge/layout.mjs';
14
+ import { extractSessionObservation } from './reflect/extractor.mjs';
13
15
 
14
16
  const HOME = process.env.HOME || process.env.USERPROFILE;
15
17
  const USER_ENV_PATH = join(HOME, '.construct', 'config.env');
@@ -125,6 +127,91 @@ export async function runReflectCli(args) {
125
127
  console.log(` Use 'construct ingest --target=${target} <file>' to manually add similar feedback in the future`);
126
128
  }
127
129
 
130
+ /**
131
+ * Auto-reflect from a Claude Code Stop hook payload.
132
+ *
133
+ * Reads the transcript JSONL, extracts a deterministic session summary, and
134
+ * persists it via the standard observation store. Contract: exit fast, fail
135
+ * silent, never throw — every error path returns null so the hook always
136
+ * exits 0.
137
+ *
138
+ * Returns the recorded observation id on success, null on no-op.
139
+ *
140
+ * @param {object} args
141
+ * @param {string} args.transcriptPath — path to the Stop hook's transcript_path
142
+ * @param {string} args.cwd — session cwd (the project root)
143
+ * @param {string} [args.sessionId]
144
+ * @param {number} [args.durationMs]
145
+ * @returns {Promise<string|null>}
146
+ */
147
+ export async function runReflectAuto({ transcriptPath, cwd, sessionId, durationMs }) {
148
+ if (!transcriptPath || !cwd) return null;
149
+ if (!existsSync(transcriptPath)) return null;
150
+
151
+ const entries = parseTranscript(transcriptPath);
152
+ if (entries.length === 0) return null;
153
+
154
+ const observation = extractSessionObservation({ entries, cwd, sessionId, durationMs });
155
+ if (!observation) return null;
156
+
157
+ // Skip trivial sessions: no tool calls AND no substantive final text. Avoids
158
+ // polluting the observation store with one-line acknowledgements.
159
+ const trivial =
160
+ observation.extras?.toolCallCount === 0 &&
161
+ (observation.content?.length || 0) < 120;
162
+ if (trivial) return null;
163
+
164
+ const project = safeProject(cwd);
165
+ const gitSha = safeGitSha(cwd);
166
+
167
+ // Session-id stays in tags so the index can filter by it without parsing.
168
+ // Structured metadata goes into the new extras field, not into content.
169
+ const sessionTag = sessionId ? `session:${sessionId.slice(0, 24)}` : null;
170
+ const tags = [...observation.tags];
171
+ if (sessionTag) tags.push(sessionTag);
172
+
173
+ const result = await addObservation(cwd, {
174
+ role: observation.role,
175
+ category: observation.category,
176
+ summary: observation.summary,
177
+ content: observation.content,
178
+ tags,
179
+ project,
180
+ gitSha,
181
+ confidence: observation.confidence,
182
+ source: observation.source,
183
+ extras: observation.extras ?? null,
184
+ });
185
+
186
+ return result?.id ?? null;
187
+ }
188
+
189
+ function safeProject(cwd) {
190
+ try { return basename(cwd) || null; } catch { return null; }
191
+ }
192
+
193
+ function safeGitSha(cwd) {
194
+ try {
195
+ return execSync('git rev-parse HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 200 })
196
+ .toString().trim().slice(0, 40) || null;
197
+ } catch { return null; }
198
+ }
199
+
200
+ // Transcripts are JSONL; one JSON object per line. Be permissive — a single
201
+ // malformed line shouldn't lose the rest of the session.
202
+
203
+ function parseTranscript(path) {
204
+ let raw = '';
205
+ try { raw = readFileSync(path, 'utf8'); } catch { return []; }
206
+ const out = [];
207
+ for (const line of raw.split('\n')) {
208
+ const trimmed = line.trim();
209
+ if (!trimmed) continue;
210
+ try { out.push(JSON.parse(trimmed)); } catch { /* skip malformed */ }
211
+ }
212
+ return out;
213
+ }
214
+
128
215
  /**
129
216
  * CLI entry point for direct execution.
130
217
  */
@@ -0,0 +1,102 @@
1
+ /**
2
+ * lib/sandbox.mjs — Lightweight isolated environment for QA validation.
3
+ *
4
+ * Spins up a fresh tmpdir with a minimal `.cx/` so a specialist (or operator)
5
+ * can run end-to-end checks against a candidate change without touching the
6
+ * working repo. Designed to be the cheapest possible alternative to a full
7
+ * Docker container: when Docker is unavailable, an isolated tmpdir is enough
8
+ * for almost every validation Construct needs.
9
+ *
10
+ * Layout created:
11
+ * <sandbox>/.cx/
12
+ * context.md stub
13
+ * observations/ empty
14
+ * intake/pending/ empty
15
+ * <sandbox>/.git/ empty repo init, so git-aware tools work
16
+ *
17
+ * Tear-down is opt-in via `construct sandbox prune` or by hand. Each sandbox
18
+ * lives under `~/.cx/sandboxes/<id>/` so they survive across CLI sessions.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ const ROOT = path.join(os.homedir(), '.cx', 'sandboxes');
26
+
27
+ function ensureRoot() {
28
+ fs.mkdirSync(ROOT, { recursive: true });
29
+ }
30
+
31
+ function newSandboxId() {
32
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
33
+ const rand = Math.random().toString(36).slice(2, 8);
34
+ return `${stamp}-${rand}`;
35
+ }
36
+
37
+ /**
38
+ * Create a new sandbox. Returns its absolute path.
39
+ *
40
+ * @param {object} [opts]
41
+ * @param {string} [opts.profile] - profile id to write into construct.config.json
42
+ * @param {string} [opts.seedContext] - text to drop into .cx/context.md
43
+ * @returns {{ id: string, path: string }}
44
+ */
45
+ export function createSandbox({ profile = null, seedContext = null } = {}) {
46
+ ensureRoot();
47
+ const id = newSandboxId();
48
+ const root = path.join(ROOT, id);
49
+ fs.mkdirSync(root, { recursive: true });
50
+ fs.mkdirSync(path.join(root, '.cx', 'observations'), { recursive: true });
51
+ fs.mkdirSync(path.join(root, '.cx', 'intake', 'pending'), { recursive: true });
52
+
53
+ const ctx = seedContext || `# Sandbox ${id}\n\nIsolated environment for QA validation. Created ${new Date().toISOString()}.\n`;
54
+ fs.writeFileSync(path.join(root, '.cx', 'context.md'), ctx);
55
+
56
+ const cfg = { version: 1, alias: 'Construct', deployment: { mode: 'solo' } };
57
+ if (profile) cfg.profile = profile;
58
+ fs.writeFileSync(path.join(root, 'construct.config.json'), JSON.stringify(cfg, null, 2) + '\n');
59
+
60
+ spawnSync('git', ['init', '--quiet'], { cwd: root, stdio: 'ignore' });
61
+
62
+ return { id, path: root };
63
+ }
64
+
65
+ /**
66
+ * List existing sandboxes, newest first.
67
+ */
68
+ export function listSandboxes() {
69
+ if (!fs.existsSync(ROOT)) return [];
70
+ const entries = fs.readdirSync(ROOT, { withFileTypes: true })
71
+ .filter((d) => d.isDirectory())
72
+ .map((d) => {
73
+ const full = path.join(ROOT, d.name);
74
+ const stat = fs.statSync(full);
75
+ return { id: d.name, path: full, createdAt: stat.birthtime.toISOString() };
76
+ });
77
+ return entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
78
+ }
79
+
80
+ /**
81
+ * Delete a sandbox by id. Returns true if removed, false if not found.
82
+ */
83
+ export function deleteSandbox(id) {
84
+ const full = path.join(ROOT, id);
85
+ if (!fs.existsSync(full)) return false;
86
+ fs.rmSync(full, { recursive: true, force: true });
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Prune sandboxes older than N days. Returns the count removed.
92
+ */
93
+ export function pruneSandboxes({ olderThanDays = 7 } = {}) {
94
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
95
+ let removed = 0;
96
+ for (const s of listSandboxes()) {
97
+ if (Date.parse(s.createdAt) < cutoff) {
98
+ if (deleteSandbox(s.id)) removed++;
99
+ }
100
+ }
101
+ return removed;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
6
6
  "bin": {
@@ -42,12 +42,17 @@
42
42
  "scripts": {
43
43
  "mcp": "node lib/mcp/server.mjs",
44
44
  "test": "node scripts/run-tests.mjs",
45
+ "test:functional": "node --test --test-timeout=30000 tests/functional/*.functional.test.mjs",
46
+ "test:unit": "node scripts/run-tests.mjs --exclude=tests/functional",
45
47
  "docs:init": "node lib/init-docs.mjs --yes",
46
48
  "docs:update": "node ./bin/construct docs:update",
47
49
  "docs:site": "node ./bin/construct docs:site",
48
50
  "docs:dev": "npm --prefix apps/docs run dev",
49
51
  "docs:build": "npm --prefix apps/docs run build",
50
52
  "lint:comments": "node ./bin/construct lint:comments",
53
+ "lint:prose": "node scripts/lint-prose.mjs",
54
+ "lint:profiles": "node scripts/lint-profiles.mjs",
55
+ "learning:status": "node scripts/learning-status.mjs",
51
56
  "lint:templates": "node scripts/lint-commits-pr.mjs",
52
57
  "eval:routing": "node scripts/eval/score-intent-classifier.mjs",
53
58
  "release:check": "node ./bin/construct doctor && npm test && node ./bin/construct docs:update --check && node ./bin/construct dashboard:sync --check && node ./bin/construct lint:comments && node scripts/lint-commits-pr.mjs",