@geraldmaron/construct 1.0.3 → 1.0.4

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 (75) hide show
  1. package/README.md +4 -4
  2. package/agents/prompts/cx-ai-engineer.md +6 -26
  3. package/agents/prompts/cx-architect.md +1 -0
  4. package/agents/prompts/cx-business-strategist.md +2 -0
  5. package/agents/prompts/cx-data-analyst.md +6 -26
  6. package/agents/prompts/cx-docs-keeper.md +1 -31
  7. package/agents/prompts/cx-explorer.md +1 -0
  8. package/agents/prompts/cx-orchestrator.md +40 -112
  9. package/agents/prompts/cx-platform-engineer.md +2 -22
  10. package/agents/prompts/cx-product-manager.md +2 -1
  11. package/agents/prompts/cx-qa.md +0 -20
  12. package/agents/prompts/cx-rd-lead.md +2 -0
  13. package/agents/prompts/cx-researcher.md +77 -31
  14. package/agents/prompts/cx-security.md +11 -49
  15. package/agents/prompts/cx-sre.md +9 -43
  16. package/agents/prompts/cx-ux-researcher.md +1 -0
  17. package/agents/role-manifests.json +4 -4
  18. package/bin/construct +23 -0
  19. package/db/schema/004_recommendations.sql +46 -0
  20. package/db/schema/005_strategy.sql +21 -0
  21. package/lib/auto-docs.mjs +1 -2
  22. package/lib/beads-automation.mjs +16 -7
  23. package/lib/cli-commands.mjs +8 -2
  24. package/lib/embed/conflict-detection.mjs +26 -9
  25. package/lib/embed/customer-profiles.mjs +37 -17
  26. package/lib/embed/daemon.mjs +10 -8
  27. package/lib/embed/recommendation-store.mjs +213 -14
  28. package/lib/embed/workspaces.mjs +53 -18
  29. package/lib/gates-audit.mjs +3 -3
  30. package/lib/health-check.mjs +1 -1
  31. package/lib/hooks/pre-compact.mjs +3 -0
  32. package/lib/hooks/read-tracker.mjs +10 -101
  33. package/lib/host-capabilities.mjs +90 -1
  34. package/lib/init-update.mjs +246 -131
  35. package/lib/intent-classifier.mjs +1 -0
  36. package/lib/knowledge/layout.mjs +10 -0
  37. package/lib/mcp/tools/telemetry.mjs +30 -78
  38. package/lib/model-router.mjs +61 -1
  39. package/lib/ollama-manager.mjs +1 -1
  40. package/lib/opencode-telemetry.mjs +4 -5
  41. package/lib/orchestration-policy.mjs +9 -0
  42. package/lib/parity.mjs +124 -21
  43. package/lib/prompt-composer.js +106 -29
  44. package/lib/read-tracker-store.mjs +149 -0
  45. package/lib/server/index.mjs +76 -0
  46. package/lib/server/telemetry-login.mjs +17 -25
  47. package/lib/service-manager.mjs +30 -22
  48. package/lib/services/local-postgres.mjs +15 -0
  49. package/lib/services/telemetry-backend.mjs +1 -2
  50. package/lib/setup.mjs +8 -43
  51. package/lib/status.mjs +51 -5
  52. package/lib/storage/backend.mjs +12 -2
  53. package/lib/strategy-store.mjs +371 -0
  54. package/lib/telemetry/backends/local.mjs +6 -4
  55. package/lib/telemetry/client.mjs +185 -0
  56. package/lib/telemetry/ingest.mjs +13 -5
  57. package/lib/telemetry/team-rollup.mjs +9 -2
  58. package/lib/worker/trace.mjs +17 -27
  59. package/package.json +5 -2
  60. package/rules/common/research.md +44 -12
  61. package/skills/docs/backlog-proposal-workflow.md +2 -2
  62. package/skills/docs/customer-profile-workflow.md +1 -1
  63. package/skills/docs/evidence-ingest-workflow.md +5 -5
  64. package/skills/docs/prfaq-workflow.md +1 -1
  65. package/skills/docs/product-intelligence-review.md +1 -1
  66. package/skills/docs/product-intelligence-workflow.md +3 -3
  67. package/skills/docs/product-signal-workflow.md +48 -18
  68. package/skills/docs/research-workflow.md +26 -14
  69. package/skills/docs/strategy-workflow.md +36 -0
  70. package/skills/roles/data-analyst.product-intelligence.md +1 -1
  71. package/skills/roles/researcher.md +28 -15
  72. package/skills/routing.md +8 -1
  73. package/templates/docs/research-brief.md +63 -9
  74. package/templates/docs/strategy.md +36 -0
  75. package/templates/homebrew/construct.rb +6 -6
@@ -7,10 +7,14 @@
7
7
  * dismissal, automatic suppression after 7 days, and re-surfacing when
8
8
  * new signals arrive.
9
9
  *
10
- * Storage:
10
+ * Storage (solo mode):
11
11
  * ~/.cx/intake/recommendations.jsonl — append-only log
12
12
  * ~/.cx/intake/recommendations-index.json — fast lookup by id
13
13
  *
14
+ * Storage (team/enterprise mode — DATABASE_URL configured):
15
+ * construct_recommendations table in Postgres (see db/schema/004_recommendations.sql)
16
+ * JSONL store is also written as a local backup.
17
+ *
14
18
  * Prioritization formula:
15
19
  * score = (signalCount * 2) + (customerImpact * 3) + (recencyBonus) + (strategicBonus)
16
20
  * P0: score >= 10, P1: >= 7, P2: >= 4, P3: < 4
@@ -19,29 +23,38 @@
19
23
  import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
20
24
  import { join } from 'node:path';
21
25
  import { randomUUID } from 'node:crypto';
22
- import { homedir } from 'node:os';
23
-
24
- const STORE_DIR = join(homedir(), '.cx', 'intake');
25
- const LOG_FILE = join(STORE_DIR, 'recommendations.jsonl');
26
- const INDEX_FILE = join(STORE_DIR, 'recommendations-index.json');
26
+ import { cxDir } from '../paths.mjs';
27
+ import { hasSqlStore } from '../storage/sql-store.mjs';
28
+ import { createSqlClient } from '../storage/backend.mjs';
27
29
 
28
30
  const DEFAULT_SUPPRESS_DAYS = 7;
29
31
  const SUPERSEDE_WITHIN_HOURS = 72;
30
32
 
33
+ function recommendationPaths() {
34
+ const storeDir = join(cxDir(), 'intake');
35
+ return {
36
+ storeDir,
37
+ logFile: join(storeDir, 'recommendations.jsonl'),
38
+ indexFile: join(storeDir, 'recommendations-index.json'),
39
+ };
40
+ }
41
+
31
42
  /**
32
43
  * Ensure store directory exists.
33
44
  */
34
45
  function ensureDir() {
35
- if (!existsSync(STORE_DIR)) mkdirSync(STORE_DIR, { recursive: true });
46
+ const { storeDir } = recommendationPaths();
47
+ if (!existsSync(storeDir)) mkdirSync(storeDir, { recursive: true });
36
48
  }
37
49
 
38
50
  /**
39
51
  * Read the index (fast lookup).
40
52
  */
41
53
  function readIndex() {
42
- if (!existsSync(INDEX_FILE)) return {};
54
+ const { indexFile } = recommendationPaths();
55
+ if (!existsSync(indexFile)) return {};
43
56
  try {
44
- return JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
57
+ return JSON.parse(readFileSync(indexFile, 'utf8'));
45
58
  } catch {
46
59
  return {};
47
60
  }
@@ -51,8 +64,9 @@ function readIndex() {
51
64
  * Write the index.
52
65
  */
53
66
  function writeIndex(index) {
67
+ const { indexFile } = recommendationPaths();
54
68
  ensureDir();
55
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
69
+ writeFileSync(indexFile, JSON.stringify(index, null, 2) + '\n');
56
70
  }
57
71
 
58
72
  /**
@@ -86,9 +100,11 @@ function dedupKey(type, title) {
86
100
  * @param {number} [opts.strategicBonus=0] - 0-3: strategic alignment
87
101
  * @param {string[]} [opts.sourceSignalIds] - IDs of intake signals that triggered this
88
102
  * @param {string} [opts.lane] - Docs lane this belongs to
103
+ * @param {string} [opts.project] - Project name (defaults to CX_PROJECT or 'default')
104
+ * @param {object} [opts.env] - Environment (defaults to process.env)
89
105
  * @returns {{ id: string, dedupKey: string, priority: string, score: number }}
90
106
  */
91
- export function createRecommendation({ type, title, reason, signalCount = 1, customerImpact = 0, recencyBonus = 0, strategicBonus = 0, sourceSignalIds = [], lane }) {
107
+ export function createRecommendation({ type, title, reason, signalCount = 1, customerImpact = 0, recencyBonus = 0, strategicBonus = 0, sourceSignalIds = [], lane, project, env }) {
92
108
  if (!type || !title) throw new Error('type and title are required');
93
109
 
94
110
  ensureDir();
@@ -114,7 +130,10 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
114
130
  updated.priority = priorityTier(score);
115
131
  index[key] = updated;
116
132
  writeIndex(index);
117
- return { id: existing.id, dedupKey: key, priority: updated.priority, score, existing: true, updatedAt: updated.lastSeen };
133
+
134
+ const result = { id: existing.id, dedupKey: key, priority: updated.priority, score, existing: true, updatedAt: updated.lastSeen };
135
+ createRecommendationPgBestEffort(updated, project, env);
136
+ return result;
118
137
  }
119
138
  // Existing was dismissed — create new instance with new signals
120
139
  // (superseded recommendations can be revived with fresh signals)
@@ -145,12 +164,13 @@ export function createRecommendation({ type, title, reason, signalCount = 1, cus
145
164
  };
146
165
 
147
166
  // Append to log
148
- appendFileSync(LOG_FILE, JSON.stringify(rec) + '\n', 'utf8');
167
+ appendFileSync(recommendationPaths().logFile, JSON.stringify(rec) + '\n', 'utf8');
149
168
 
150
169
  // Update index
151
170
  index[key] = rec;
152
171
  writeIndex(index);
153
172
 
173
+ createRecommendationPgBestEffort(rec, project, env);
154
174
  return { id, dedupKey: key, priority: rec.priority, score, existing: false };
155
175
  }
156
176
 
@@ -216,13 +236,28 @@ export function supersedeRecommendation(dedupKey, supersedingId) {
216
236
  /**
217
237
  * List active (non-dismissed, non-superseded) recommendations.
218
238
  *
239
+ * In team/enterprise mode (DATABASE_URL configured), reads from Postgres when
240
+ * the project has rows. Falls back to JSONL index.
241
+ *
219
242
  * @param {object} [opts]
220
243
  * @param {string} [opts.type] - Filter by type
221
244
  * @param {string} [opts.priority] - Filter by priority (P0, P1, etc.)
222
245
  * @param {number} [opts.limit=20]
246
+ * @param {string} [opts.project] - Project name (defaults to CX_PROJECT or 'default')
247
+ * @param {object} [opts.env] - Environment (defaults to process.env)
223
248
  * @returns {Array<object>}
224
249
  */
225
- export function listActiveRecommendations({ type, priority, limit = 20 } = {}) {
250
+ export function listActiveRecommendations({ type, priority, limit = 20, project, env } = {}) {
251
+ if (hasSqlStore(env)) {
252
+ return listActiveRecommendationsPgWithFallback({ type, priority, limit, project, env });
253
+ }
254
+ return listActiveRecommendationsFile({ type, priority, limit });
255
+ }
256
+
257
+ /**
258
+ * File-based active recommendations list.
259
+ */
260
+ function listActiveRecommendationsFile({ type, priority, limit = 20 } = {}) {
226
261
  const index = readIndex();
227
262
  const now = new Date();
228
263
  let results = Object.values(index).filter(rec => {
@@ -246,6 +281,27 @@ export function listActiveRecommendations({ type, priority, limit = 20 } = {}) {
246
281
  .slice(0, limit);
247
282
  }
248
283
 
284
+ /**
285
+ * Attempt Postgres read, fall back to file synchronously.
286
+ * Returns a Promise so callers using await get Postgres data; callers not
287
+ * using await get the file-based results via the sync fallback.
288
+ */
289
+ function listActiveRecommendationsPgWithFallback({ type, priority, limit, project, env }) {
290
+ const resolvedProject = project || (env || process.env).CX_PROJECT || 'default';
291
+ const client = createSqlClient(env);
292
+
293
+ return listActiveRecommendationsPg(resolvedProject, { type, priority, limit }, client)
294
+ .then(rows => {
295
+ client.end({ timeout: 5 }).catch(() => {});
296
+ if (rows.length > 0) return rows;
297
+ return listActiveRecommendationsFile({ type, priority, limit });
298
+ })
299
+ .catch(() => {
300
+ client.end({ timeout: 5 }).catch(() => {});
301
+ return listActiveRecommendationsFile({ type, priority, limit });
302
+ });
303
+ }
304
+
249
305
  /**
250
306
  * Auto-suppress recommendations that have been active for DEFAULT_SUPPRESS_DAYS
251
307
  * without new signals, or that were superseded more than SUPERSEDE_WITHIN_HOURS ago.
@@ -376,3 +432,146 @@ export function isRecommendationActive(type, title) {
376
432
  if (rec.dismissedAt || rec.supersededAt) return { active: false, existing: rec };
377
433
  return { active: true, existing: rec };
378
434
  }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Postgres backend — team/enterprise mode only
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Upsert a recommendation into construct_recommendations.
442
+ * On conflict (project, dedup_key) update all mutable fields.
443
+ *
444
+ * @param {object} rec - Recommendation object (camelCase)
445
+ * @param {string} project
446
+ * @param {object} client - postgres.js SQL client
447
+ */
448
+ export async function createRecommendationPg(rec, project, client) {
449
+ await client`
450
+ insert into construct_recommendations (
451
+ id, project, dedup_key, type, title, reason, lane,
452
+ signal_count, total_signal_count,
453
+ customer_impact, recency_bonus, strategic_bonus,
454
+ score, priority, source_signal_ids,
455
+ first_seen, last_seen,
456
+ dismissed_at, dismiss_reason,
457
+ superseded_at, superseded_by_id,
458
+ suppressed_until, suppress_reason,
459
+ updated_at
460
+ ) values (
461
+ ${rec.id}, ${project}, ${rec.dedupKey || dedupKey(rec.type, rec.title)}, ${rec.type}, ${rec.title},
462
+ ${rec.reason ?? null}, ${rec.lane ?? null},
463
+ ${rec.signalCount ?? 1}, ${rec.totalSignalCount ?? 1},
464
+ ${rec.customerImpact ?? 0}, ${rec.recencyBonus ?? 0}, ${rec.strategicBonus ?? 0},
465
+ ${rec.score ?? 0}, ${rec.priority ?? 'P3'},
466
+ ${JSON.stringify(rec.sourceSignalIds ?? [])}::jsonb,
467
+ ${rec.firstSeen ?? new Date().toISOString()}, ${rec.lastSeen ?? new Date().toISOString()},
468
+ ${rec.dismissedAt ?? null}, ${rec.dismissReason ?? null},
469
+ ${rec.supersededAt ?? null}, ${rec.supersededById ?? null},
470
+ ${rec.suppressedUntil ?? null}, ${rec.suppressReason ?? null},
471
+ now()
472
+ )
473
+ on conflict (project, dedup_key) do update set
474
+ signal_count = excluded.signal_count,
475
+ total_signal_count = excluded.total_signal_count,
476
+ customer_impact = excluded.customer_impact,
477
+ recency_bonus = excluded.recency_bonus,
478
+ strategic_bonus = excluded.strategic_bonus,
479
+ score = excluded.score,
480
+ priority = excluded.priority,
481
+ source_signal_ids = excluded.source_signal_ids,
482
+ last_seen = excluded.last_seen,
483
+ dismissed_at = excluded.dismissed_at,
484
+ dismiss_reason = excluded.dismiss_reason,
485
+ superseded_at = excluded.superseded_at,
486
+ superseded_by_id = excluded.superseded_by_id,
487
+ suppressed_until = excluded.suppressed_until,
488
+ suppress_reason = excluded.suppress_reason,
489
+ updated_at = now()
490
+ `;
491
+ }
492
+
493
+ /**
494
+ * Query active recommendations from Postgres for a project.
495
+ *
496
+ * @param {string} project
497
+ * @param {object} [opts]
498
+ * @param {string} [opts.type]
499
+ * @param {string} [opts.priority]
500
+ * @param {number} [opts.limit=20]
501
+ * @param {object} client - postgres.js SQL client
502
+ * @returns {Promise<Array<object>>}
503
+ */
504
+ export async function listActiveRecommendationsPg(project, { type, priority, limit = 20 } = {}, client) {
505
+ const rows = await client`
506
+ select *
507
+ from construct_recommendations
508
+ where project = ${project}
509
+ and dismissed_at is null
510
+ and superseded_at is null
511
+ and (suppressed_until is null or suppressed_until <= now())
512
+ ${type ? client`and type = ${type}` : client``}
513
+ ${priority ? client`and priority = ${priority}` : client``}
514
+ order by score desc
515
+ limit ${limit}
516
+ `;
517
+
518
+ return rows.map(row => ({
519
+ id: row.id,
520
+ type: row.type,
521
+ title: row.title,
522
+ reason: row.reason,
523
+ lane: row.lane,
524
+ signalCount: row.signal_count,
525
+ totalSignalCount: row.total_signal_count,
526
+ customerImpact: row.customer_impact,
527
+ recencyBonus: row.recency_bonus,
528
+ strategicBonus: row.strategic_bonus,
529
+ score: row.score,
530
+ priority: row.priority,
531
+ sourceSignalIds: row.source_signal_ids ?? [],
532
+ firstSeen: row.first_seen instanceof Date ? row.first_seen.toISOString() : row.first_seen,
533
+ lastSeen: row.last_seen instanceof Date ? row.last_seen.toISOString() : row.last_seen,
534
+ dismissedAt: row.dismissed_at,
535
+ supersededAt: row.superseded_at,
536
+ suppressedUntil: row.suppressed_until,
537
+ }));
538
+ }
539
+
540
+ /**
541
+ * Dismiss a recommendation in Postgres.
542
+ *
543
+ * @param {string} dedupKeyValue
544
+ * @param {string} project
545
+ * @param {object} [opts]
546
+ * @param {string} [opts.reason]
547
+ * @param {number} [opts.suppressDays]
548
+ * @param {object} client - postgres.js SQL client
549
+ */
550
+ export async function dismissRecommendationPg(dedupKeyValue, project, { reason = 'manually dismissed', suppressDays } = {}, client) {
551
+ const suppressedUntil = suppressDays
552
+ ? new Date(Date.now() + suppressDays * 24 * 60 * 60 * 1000).toISOString()
553
+ : null;
554
+
555
+ await client`
556
+ update construct_recommendations
557
+ set
558
+ dismissed_at = now(),
559
+ dismiss_reason = ${reason},
560
+ suppressed_until = ${suppressedUntil},
561
+ updated_at = now()
562
+ where project = ${project}
563
+ and dedup_key = ${dedupKeyValue}
564
+ `;
565
+ }
566
+
567
+ /**
568
+ * Fire-and-forget Postgres upsert — never throws, never blocks the caller.
569
+ */
570
+ function createRecommendationPgBestEffort(rec, project, env) {
571
+ if (!hasSqlStore(env)) return;
572
+ const resolvedProject = project || (env || process.env).CX_PROJECT || 'default';
573
+ const client = createSqlClient(env);
574
+ createRecommendationPg(rec, resolvedProject, client)
575
+ .catch(() => {})
576
+ .finally(() => client.end({ timeout: 5 }).catch(() => {}));
577
+ }
@@ -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 product areas.
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/product-intel/workspaces/<workspace-id>.json
9
- * ~/.cx/product-intel/workspaces/index.json — quick lookup
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 { homedir } from 'node:os';
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
- const WORKSPACES_DIR = join(homedir(), '.cx', 'product-intel', 'workspaces');
21
- const INDEX_FILE = join(WORKSPACES_DIR, 'index.json');
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
- if (!existsSync(WORKSPACES_DIR)) {
28
- mkdirSync(WORKSPACES_DIR, { recursive: true });
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
- if (!existsSync(INDEX_FILE)) return {};
56
+ const { indexFile } = workspacePaths();
57
+ if (!existsSync(indexFile)) return {};
38
58
  try {
39
- return JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
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(INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
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(WORKSPACES_DIR, `${id}.json`);
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(WORKSPACES_DIR, `${workspaceId}.json`);
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(WORKSPACES_DIR, `${workspaceId}.json`);
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(WORKSPACES_DIR, `${workspaceId}.json`);
232
+ const filePath = join(workspacePaths().workspacesDir, `${workspaceId}.json`);
198
233
  if (!existsSync(filePath)) {
199
234
  throw new Error(`Workspace not found: ${workspaceId}`);
200
235
  }
@@ -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 (staged)', critical: true, note: 'pre-commit Construct policy section calls `lint:comments --staged`' },
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 --staged')) checks.push('Construct comment-lint (staged)');
72
- if (src.includes('construct docs:verify --staged')) checks.push('Construct docs:verify (staged)');
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
  }
@@ -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 telemetry backend (trace observability) and managed Postgres',
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/',
@@ -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