@geraldmaron/construct 1.0.18 → 1.0.20

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 (93) hide show
  1. package/README.md +8 -4
  2. package/bin/construct +105 -3
  3. package/db/schema/003_observation_reconciliation.sql +14 -0
  4. package/lib/bootstrap/resources.mjs +0 -1
  5. package/lib/cli-commands.mjs +57 -6
  6. package/lib/comment-lint.mjs +44 -0
  7. package/lib/config/schema.mjs +31 -1
  8. package/lib/contracts/validate.mjs +106 -0
  9. package/lib/decisions/enforced-baseline.json +25 -0
  10. package/lib/decisions/golden.mjs +87 -0
  11. package/lib/decisions/precedence.mjs +46 -0
  12. package/lib/decisions/registry.mjs +469 -0
  13. package/lib/deployment/parity-contract.mjs +148 -0
  14. package/lib/document-ingest.mjs +71 -6
  15. package/lib/embed/cli.mjs +11 -0
  16. package/lib/embed/conflict-detection.mjs +4 -4
  17. package/lib/embed/customer-profiles.mjs +1 -1
  18. package/lib/embed/reconcile.mjs +60 -0
  19. package/lib/embedded-contract/capability.mjs +3 -3
  20. package/lib/embedded-contract/contract-version.mjs +1 -1
  21. package/lib/embedded-contract/execution.mjs +184 -0
  22. package/lib/embedded-contract/index.mjs +16 -0
  23. package/lib/embedded-contract/ingest.mjs +61 -7
  24. package/lib/gates-audit.mjs +2 -2
  25. package/lib/hooks/_lib/output-mode.mjs +101 -0
  26. package/lib/hooks/config-protection.mjs +22 -3
  27. package/lib/hooks/guard-bash.mjs +1 -1
  28. package/lib/hooks/session-start.mjs +13 -1
  29. package/lib/ingest/provider-extract.mjs +200 -0
  30. package/lib/ingest/strategy.mjs +95 -0
  31. package/lib/init-docs.mjs +1 -0
  32. package/lib/mcp/server.mjs +72 -4
  33. package/lib/mcp/tools/embedded-contract.mjs +17 -1
  34. package/lib/mode-commands.mjs +6 -8
  35. package/lib/observation-store.mjs +16 -2
  36. package/lib/opencode-telemetry.mjs +1 -1
  37. package/lib/orchestration/run-store.mjs +82 -0
  38. package/lib/orchestration/runtime.mjs +240 -0
  39. package/lib/roles/cli.mjs +10 -2
  40. package/lib/roles/gateway.mjs +50 -1
  41. package/lib/scheduler/index.mjs +31 -0
  42. package/lib/server/index.mjs +13 -3
  43. package/lib/server/static/index.html +1 -1
  44. package/lib/setup.mjs +6 -0
  45. package/lib/storage/hybrid-query.mjs +49 -38
  46. package/lib/storage/rrf.mjs +42 -0
  47. package/lib/storage/vector-client.mjs +18 -3
  48. package/lib/telemetry/backends/local.mjs +1 -1
  49. package/lib/telemetry/skill-calls.mjs +1 -1
  50. package/lib/templates/visual-requirements.mjs +84 -0
  51. package/package.json +10 -1
  52. package/rules/common/comments.md +3 -0
  53. package/rules/common/no-fabrication.md +3 -0
  54. package/rules/common/precedence.md +19 -0
  55. package/rules/common/research-sources.md +32 -0
  56. package/rules/common/research.md +59 -2
  57. package/skills/roles/data-engineer.pipeline.md +13 -1
  58. package/skills/roles/debugger.md +9 -0
  59. package/skills/roles/designer.accessibility.md +13 -3
  60. package/skills/roles/designer.md +10 -0
  61. package/skills/roles/engineer.platform.md +8 -0
  62. package/skills/roles/operator.md +10 -1
  63. package/skills/roles/operator.release.md +8 -0
  64. package/skills/roles/operator.sre.md +10 -1
  65. package/skills/roles/orchestrator.md +9 -2
  66. package/skills/roles/product-manager.business-strategy.md +10 -1
  67. package/skills/roles/researcher.explorer.md +12 -1
  68. package/skills/roles/researcher.ux.md +13 -1
  69. package/skills/roles/reviewer.devil-advocate.md +14 -2
  70. package/skills/roles/reviewer.evaluator.md +17 -4
  71. package/skills/roles/reviewer.trace.md +12 -1
  72. package/skills/roles/security.legal-compliance.md +8 -0
  73. package/skills/roles/security.md +11 -0
  74. package/specialists/contracts.json +18 -0
  75. package/specialists/prompts/cx-researcher.md +4 -2
  76. package/templates/docs/backlog-proposal.md +1 -1
  77. package/templates/docs/customer-profile.md +1 -1
  78. package/templates/docs/evidence-brief.md +5 -1
  79. package/templates/docs/incident-report.md +37 -21
  80. package/templates/docs/prfaq.md +2 -2
  81. package/templates/docs/product-intelligence-report.md +1 -1
  82. package/templates/docs/research-brief.md +8 -6
  83. package/templates/docs/research-finding.md +32 -7
  84. package/templates/docs/rfc.md +13 -1
  85. package/templates/docs/runbook.md +20 -1
  86. package/templates/docs/signal-brief.md +4 -1
  87. package/templates/docs/skill-artifact.md +27 -7
  88. package/templates/docs/strategy.md +23 -2
  89. package/lib/bootstrap/lazy-install.mjs +0 -161
  90. package/lib/embed/jobs/vector-sync.mjs +0 -198
  91. package/lib/knowledge/postgres-search.mjs +0 -132
  92. package/lib/services/pattern-promotion-service.mjs +0 -167
  93. package/lib/storage/unified-storage.mjs +0 -550
@@ -10,13 +10,18 @@
10
10
  * error or a clearly-flagged best-effort fallback — never silent gibberish.
11
11
  *
12
12
  * Surfaces (CLI/MCP/SDK) own I/O and call this; the contract cores stay pure and
13
- * receive the resolved text plus an `ingestion` metadata block to echo back.
13
+ * receive the resolved text plus an `ingestion` metadata block to echo back. The
14
+ * `ingestion` block records the resolved strategy (adapter | provider) and the
15
+ * provider/model when one was selected, so the extraction choice is never opaque.
14
16
  */
15
17
 
16
18
  import { readFileSync } from 'node:fs';
17
19
  import { extname } from 'node:path';
18
20
 
19
21
  import { extractDocumentTextAsync, isExtractableDocumentPath } from '../document-extract.mjs';
22
+ import { loadProjectConfig } from '../config/project-config.mjs';
23
+ import { resolveIngestStrategy } from '../ingest/strategy.mjs';
24
+ import { extractViaProvider } from '../ingest/provider-extract.mjs';
20
25
 
21
26
  /**
22
27
  * Resolve contract input from inline text or a file path.
@@ -25,9 +30,14 @@ import { extractDocumentTextAsync, isExtractableDocumentPath } from '../document
25
30
  * @param {string} [opts.input] Inline text (returned as-is when provided).
26
31
  * @param {string} [opts.filePath] Path to extract when no inline text is given.
27
32
  * @param {number} [opts.maxChars]
33
+ * @param {object} [opts.config] Loaded project config (for strategy resolution).
34
+ * @param {Record<string,string>} [opts.env]
35
+ * @param {string} [opts.strategy] Explicit strategy override.
36
+ * @param {string} [opts.cwd] Working directory for locating project config.
37
+ * @param {Function} [opts.fetchImpl] Injectable fetch for provider extraction (tests).
28
38
  * @returns {Promise<{text:string, ingestion:(object|null), error:(object|null)}>}
29
39
  */
30
- export async function resolveInput({ input = '', filePath = '', maxChars = null } = {}) {
40
+ export async function resolveInput({ input = '', filePath = '', maxChars = null, config = null, env = process.env, strategy = null, cwd = process.cwd(), fetchImpl = undefined } = {}) {
31
41
  if (input && input.trim()) {
32
42
  return { text: input, ingestion: null, error: null };
33
43
  }
@@ -35,6 +45,48 @@ export async function resolveInput({ input = '', filePath = '', maxChars = null
35
45
  return { text: '', ingestion: null, error: null };
36
46
  }
37
47
 
48
+ const projectConfig = config || loadProjectConfig(cwd, env).config;
49
+ const resolved = resolveIngestStrategy({ config: projectConfig, env, override: strategy });
50
+ const strategyMeta = { strategy: resolved.strategy, fallback: resolved.fallback, model: resolved.model, provider: resolved.provider };
51
+
52
+ // Provider strategy runs a concrete provider-backed extraction. On failure
53
+ // the fallback policy decides: `adapter` routes to the local extractor and
54
+ // records the fallback; any other policy surfaces the structured provider
55
+ // error rather than silently masking the strategy mismatch.
56
+
57
+ let providerFallback = null;
58
+ if (resolved.strategy === 'provider') {
59
+ try {
60
+ const extracted = await extractViaProvider({ filePath, model: resolved.model, provider: resolved.provider, maxChars, env, ...(fetchImpl ? { fetchImpl } : {}) });
61
+ return {
62
+ text: extracted.text || '',
63
+ ingestion: {
64
+ sourcePath: filePath,
65
+ extractionMethod: extracted.extractionMethod,
66
+ characters: extracted.characters,
67
+ truncated: extracted.truncated,
68
+ droppedInfo: extracted.droppedInfo || [],
69
+ ...strategyMeta,
70
+ fallbackApplied: null,
71
+ },
72
+ error: null,
73
+ };
74
+ } catch (providerErr) {
75
+ if (resolved.fallback !== 'adapter') {
76
+ return {
77
+ text: '',
78
+ ingestion: { sourcePath: filePath, extractionMethod: 'provider-failed', droppedInfo: [], ...strategyMeta },
79
+ error: {
80
+ code: providerErr.code || 'PROVIDER_EXTRACTION_FAILED',
81
+ reason: providerErr.message,
82
+ remediation: providerErr.remediation || 'Set ingest.fallback to "adapter" to use the local extractor, or select the adapter strategy.',
83
+ },
84
+ };
85
+ }
86
+ providerFallback = { from: 'provider', to: 'adapter', reason: providerErr.message, code: providerErr.code || 'PROVIDER_EXTRACTION_FAILED' };
87
+ }
88
+ }
89
+
38
90
  if (isExtractableDocumentPath(filePath)) {
39
91
  try {
40
92
  const extracted = await extractDocumentTextAsync(filePath, { maxChars });
@@ -46,17 +98,19 @@ export async function resolveInput({ input = '', filePath = '', maxChars = null
46
98
  characters: extracted.characters,
47
99
  truncated: extracted.truncated,
48
100
  droppedInfo: extracted.droppedInfo || [],
101
+ ...strategyMeta,
102
+ fallbackApplied: providerFallback,
49
103
  },
50
104
  error: null,
51
105
  };
52
106
  } catch (err) {
53
107
  if (err && err.code === 'ASR_REQUIRED') {
54
- return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'asr-required', droppedInfo: [] }, error: { code: 'ASR_REQUIRED', reason: err.message, remediation: 'Install whisper-cli (brew install whisper-cpp) or transcribe the file before sending.' } };
108
+ return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'asr-required', droppedInfo: [], ...strategyMeta }, error: { code: 'ASR_REQUIRED', reason: err.message, remediation: 'Install whisper-cli (brew install whisper-cpp) or transcribe the file before sending.' } };
55
109
  }
56
110
  if (err && /WHISPER_BINARY_MISSING/.test(String(err.message))) {
57
- return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'asr-required', droppedInfo: [] }, error: { code: 'ASR_REQUIRED', reason: err.message, remediation: 'Install whisper-cli (brew install whisper-cpp) to transcribe audio/video.' } };
111
+ return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'asr-required', droppedInfo: [], ...strategyMeta }, error: { code: 'ASR_REQUIRED', reason: err.message, remediation: 'Install whisper-cli (brew install whisper-cpp) to transcribe audio/video.' } };
58
112
  }
59
- return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'failed', droppedInfo: [] }, error: { code: 'EXTRACTION_FAILED', reason: err.message, remediation: 'Verify the file is readable and of a supported type.' } };
113
+ return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'failed', droppedInfo: [], ...strategyMeta }, error: { code: 'EXTRACTION_FAILED', reason: err.message, remediation: 'Verify the file is readable and of a supported type.' } };
60
114
  }
61
115
  }
62
116
 
@@ -68,10 +122,10 @@ export async function resolveInput({ input = '', filePath = '', maxChars = null
68
122
  const text = readFileSync(filePath, 'utf8');
69
123
  return {
70
124
  text,
71
- ingestion: { sourcePath: filePath, extractionMethod: 'raw-utf8', droppedInfo: [], note: `No structured extractor for ${extname(filePath) || 'this type'}; read as plain text.` },
125
+ ingestion: { sourcePath: filePath, extractionMethod: 'raw-utf8', droppedInfo: [], note: `No structured extractor for ${extname(filePath) || 'this type'}; read as plain text.`, ...strategyMeta, fallbackApplied: providerFallback },
72
126
  error: null,
73
127
  };
74
128
  } catch (err) {
75
- return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'failed', droppedInfo: [] }, error: { code: 'FILE_UNREADABLE', reason: err.message, remediation: 'Check the path and permissions.' } };
129
+ return { text: '', ingestion: { sourcePath: filePath, extractionMethod: 'failed', droppedInfo: [], ...strategyMeta }, error: { code: 'FILE_UNREADABLE', reason: err.message, remediation: 'Check the path and permissions.' } };
76
130
  }
77
131
  }
@@ -76,8 +76,8 @@ function parseCIJobs(rootDir) {
76
76
  const path = join(dir, file);
77
77
  if (!existsSync(path)) continue;
78
78
  const yml = readFileSync(path, 'utf8');
79
- const jobNames = [...yml.matchAll(/^ name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
80
- const stepNames = [...yml.matchAll(/^ - name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
79
+ const jobNames = [...yml.matchAll(/^ {4}name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
80
+ const stepNames = [...yml.matchAll(/^ {6}- name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
81
81
  names.push(...jobNames, ...stepNames);
82
82
  }
83
83
  return names.map((n) => n.replace(/\$\{\{\s*matrix\.(\w+)\s*\}\}/g, (_, k) => `<${k}>`));
@@ -0,0 +1,101 @@
1
+ /**
2
+ * lib/hooks/_lib/output-mode.mjs — capability-aware SessionStart output routing.
3
+ *
4
+ * A SessionStart hook's stdout is injected into the model's context. In a
5
+ * non-interactive / SDK / `claude -p` one-shot, that verbose context pollutes a
6
+ * caller's machine-oriented stdout and can spill startup payloads into editors
7
+ * and logs. This resolver picks the channel the payload should use so stdout
8
+ * stays reserved for the caller's output contract while diagnostics remain
9
+ * recoverable.
10
+ *
11
+ * Modes (`CONSTRUCT_HOOK_OUTPUT_MODE` env or `hooks.outputMode` config, env >
12
+ * config > default): `stdout` injects as context (interactive default),
13
+ * `stderr` writes to stderr, `silent` writes only to a debug log, and `auto`
14
+ * resolves to stdout when interactive and silent when not.
15
+ *
16
+ * Honest limitation: Claude Code exposes no reliable in-hook signal for
17
+ * interactive vs print/SDK mode (`CLAUDECODE=1` is set in both; `isTTY` is false
18
+ * even interactively because hooks run without a controlling terminal). So
19
+ * `auto` detects non-interactive only from signals that ARE reliable — `CI`,
20
+ * `NODE_ENV=test`, and an explicit `CONSTRUCT_NONINTERACTIVE` flag — and SDK /
21
+ * host adapters set `CONSTRUCT_HOOK_OUTPUT_MODE` explicitly. `auto` never
22
+ * suppresses on an ambiguous signal, so real interactive sessions keep their
23
+ * rich startup context.
24
+ */
25
+
26
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+
30
+ import { resolveSetting, loadProjectConfig } from '../../config/project-config.mjs';
31
+ import { HOOK_OUTPUT_MODES } from '../../config/schema.mjs';
32
+
33
+ export { HOOK_OUTPUT_MODES };
34
+
35
+ function truthyFlag(value) {
36
+ if (value === undefined || value === null) return false;
37
+ const v = String(value).trim().toLowerCase();
38
+ return v !== '' && v !== '0' && v !== 'false' && v !== 'no';
39
+ }
40
+
41
+ /**
42
+ * Whether the invocation is non-interactive, using only reliable signals.
43
+ * @param {Record<string,string>} [env]
44
+ * @returns {boolean}
45
+ */
46
+ export function isNonInteractive(env = process.env) {
47
+ if (env.CI === 'true') return true;
48
+ if (env.NODE_ENV === 'test') return true;
49
+ if (truthyFlag(env.CONSTRUCT_NONINTERACTIVE)) return true;
50
+ return false;
51
+ }
52
+
53
+ function normalizeMode(value) {
54
+ const m = typeof value === 'string' ? value.trim().toLowerCase() : '';
55
+ return HOOK_OUTPUT_MODES.includes(m) ? m : null;
56
+ }
57
+
58
+ /**
59
+ * Resolve the concrete output channel for the SessionStart payload.
60
+ *
61
+ * @param {object} [opts]
62
+ * @param {string} [opts.cwd]
63
+ * @param {Record<string,string>} [opts.env]
64
+ * @param {object} [opts.config] loaded project config; read from cwd when omitted
65
+ * @returns {{mode:('stdout'|'stderr'|'silent'), requested:string, source:string, nonInteractive:boolean}}
66
+ */
67
+ export function resolveHookOutputMode({ cwd = process.cwd(), env = process.env, config } = {}) {
68
+ let cfg = config;
69
+ if (cfg === undefined) {
70
+ try { cfg = loadProjectConfig(cwd, env).config; } catch { cfg = null; }
71
+ }
72
+ const setting = resolveSetting({ config: cfg, jsonPath: 'hooks.outputMode', env, envKey: 'CONSTRUCT_HOOK_OUTPUT_MODE', defaultValue: 'auto' });
73
+ const requested = normalizeMode(setting.value) || 'auto';
74
+ const nonInteractive = isNonInteractive(env);
75
+ const mode = requested !== 'auto' ? requested : (nonInteractive ? 'silent' : 'stdout');
76
+ return { mode, requested, source: setting.source, nonInteractive };
77
+ }
78
+
79
+ /**
80
+ * Route the SessionStart payload to the resolved channel. `silent` preserves the
81
+ * payload in a debug log so diagnostics stay available without touching the
82
+ * caller's stdout/stderr. Returns the channel actually used.
83
+ *
84
+ * @param {object} opts
85
+ * @param {string} opts.payload
86
+ * @param {string} opts.mode one of stdout|stderr|silent
87
+ * @param {string} [opts.homeDir]
88
+ * @param {{write:Function}} [opts.stdout]
89
+ * @param {{write:Function}} [opts.stderr]
90
+ * @returns {string} the channel used
91
+ */
92
+ export function writeHookContext({ payload, mode, homeDir = homedir(), stdout = process.stdout, stderr = process.stderr } = {}) {
93
+ if (mode === 'stdout') { stdout.write(payload); return 'stdout'; }
94
+ if (mode === 'stderr') { stderr.write(payload); return 'stderr'; }
95
+ try {
96
+ const dir = join(homeDir, '.cx');
97
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
98
+ writeFileSync(join(dir, 'session-start-last.log'), payload);
99
+ } catch { /* best effort — never block the session on a debug-log write */ }
100
+ return 'silent';
101
+ }
@@ -3,8 +3,10 @@
3
3
  * lib/hooks/config-protection.mjs — protects code-quality config from being weakened.
4
4
  *
5
5
  * Runs as PreToolUse on Edit|Write|MultiEdit. Blocks edits to eslint /
6
- * prettier / tsconfig / biome / stylelint configs. Those rules are a contract
7
- * with the codebase; weakening them is almost always a bug fix in disguise.
6
+ * prettier / tsconfig / biome / stylelint configs but only once the config
7
+ * is an established contract, i.e. tracked in git. A config that is not yet
8
+ * tracked is being introduced or authored: first-time setup and pre-commit
9
+ * iteration are allowed; weakening a committed config stays blocked.
8
10
  *
9
11
  * Meta-system files (hooks, registry, settings template, install/setup
10
12
  * entrypoints) are audited but NOT blocked. Editing them is normal work on
@@ -22,6 +24,8 @@
22
24
  * @exits 0 = pass | 2 = block tool call
23
25
  */
24
26
  import { readFileSync } from 'fs';
27
+ import { execFileSync } from 'child_process';
28
+ import { dirname } from 'path';
25
29
  import { logHookFailure } from './_lib/log.mjs';
26
30
 
27
31
  const filePath = process.env.TOOL_INPUT_FILE_PATH || (() => {
@@ -56,6 +60,21 @@ const META_FILES = [
56
60
  /(?:^|\/)tsconfig(\.[^/]+)?\.json$/i,
57
61
  ];
58
62
 
63
+ // A config is a contract worth protecting only once it is committed. `git
64
+ // ls-files --error-unmatch` exits 0 for a tracked path and non-zero otherwise,
65
+ // so an untracked (newly introduced) config is allowed through.
66
+
67
+ function isTrackedInGit(p) {
68
+ try {
69
+ execFileSync('git', ['ls-files', '--error-unmatch', p], {
70
+ cwd: dirname(p), stdio: 'ignore', timeout: 1000,
71
+ });
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
59
78
  async function emitEvent(type, category, summary) {
60
79
  try {
61
80
  const { emitRoleEvent } = await import('../roles/hook-emit.mjs');
@@ -69,7 +88,7 @@ async function emitEvent(type, category, summary) {
69
88
  }
70
89
 
71
90
  const base = filePath.split('/').pop();
72
- if (PROTECTED.some(r => r.test(base))) {
91
+ if (PROTECTED.some(r => r.test(base)) && isTrackedInGit(filePath)) {
73
92
  process.stderr.write(
74
93
  `[config-protection] The code quality rules are protected. Fix the code to meet the existing standards — don't weaken the rules.\nFile: ${filePath}\n`
75
94
  );
@@ -136,7 +136,7 @@ if (process.env.CONSTRUCT_ROLES !== 'off') {
136
136
  verdict = checkAction({ personaId, action: 'bash', target: trimmed });
137
137
  }
138
138
  if (!verdict.allowed && verdict.reason === 'outside-fence') {
139
- const allowedList = (() => {
139
+ const allowedList = await (async () => {
140
140
  try { const { loadManifest } = await import('../roles/manifest.mjs'); const m = loadManifest(personaId); return m?.fence?.allowedCommands?.join(', ') || 'see role-manifests.json'; } catch { return 'see role-manifests.json'; }
141
141
  })();
142
142
  process.stderr.write(
@@ -23,6 +23,7 @@ import { createSession, lastSession, buildResumeContext } from '../session-store
23
23
  import { listObservations, searchObservations } from '../observation-store.mjs';
24
24
  import { countEntities } from '../entity-store.mjs';
25
25
  import { logHookFailure } from './_lib/log.mjs';
26
+ import { resolveHookOutputMode, writeHookContext } from './_lib/output-mode.mjs';
26
27
 
27
28
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
28
29
  const CONSTRUCT_BIN = resolve(MODULE_DIR, '..', '..', 'bin', 'construct');
@@ -378,7 +379,18 @@ try {
378
379
  permissionPostureNote = buildPermissionPostureLine({ cwd });
379
380
  } catch { /* best effort */ }
380
381
 
381
- process.stdout.write(`${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${rolesNote}${intakeReviewNote}${brokerStatusNote}${permissionPostureNote}${envCheckNote}\n${footer}${pendingNote}\n`);
382
+ // SessionStart context is injected via stdout. In non-interactive / SDK runs
383
+ // that pollutes the caller's output contract, so the resolved output mode routes
384
+ // the payload to stdout (interactive), stderr, or a debug log (silent).
385
+
386
+ const payload = `${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${rolesNote}${intakeReviewNote}${brokerStatusNote}${permissionPostureNote}${envCheckNote}\n${footer}${pendingNote}\n`;
387
+ try {
388
+ const { mode } = resolveHookOutputMode({ cwd, env: process.env });
389
+ writeHookContext({ payload, mode });
390
+ } catch (err) {
391
+ logHookFailure({ hook: 'session-start', err, phase: 'emit' });
392
+ process.stdout.write(payload);
393
+ }
382
394
  // Auto-bootstrap the policy-engine gate. session-start has just emitted
383
395
  // branch + recent commits + prior observations + context-state — that is
384
396
  // already grounding; mark the session bootstrapped so the PreToolUse rule
@@ -0,0 +1,200 @@
1
+ /**
2
+ * lib/ingest/provider-extract.mjs — concrete provider-backed document extraction.
3
+ *
4
+ * The `provider` ingest strategy routes document understanding through the
5
+ * configured provider/model instead of the local binary adapters. This module
6
+ * is that route: it reads a file, builds a provider-native request, and returns
7
+ * extracted text in the same shape `extractDocumentTextAsync` produces so every
8
+ * caller treats adapter and provider results uniformly.
9
+ *
10
+ * Capability is honest, never silently degraded. Images and PDFs are sent as
11
+ * multimodal blocks (Anthropic `image`/`document`, OpenRouter `image_url`);
12
+ * text-class files are sent inline for faithful extraction/normalization;
13
+ * audio/video and Office/zip documents are NOT chat-extractable and raise a
14
+ * specific `PROVIDER_MEDIA_UNSUPPORTED` so the strategy's fallback policy — not
15
+ * a hidden code path — decides whether the local adapter handles them. Provider
16
+ * selection follows the resolved model (Anthropic when the provider/model is
17
+ * Claude-family, OpenRouter otherwise); a missing key surfaces as a structured
18
+ * `PROVIDER_KEY_MISSING` rather than an opaque HTTP failure.
19
+ *
20
+ * `fetchImpl` and `env` are injectable so the provider path is exercised end to
21
+ * end against a mock provider without a live key.
22
+ */
23
+
24
+ import { readFileSync, existsSync } from 'node:fs';
25
+ import { extname, join } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+
28
+ import {
29
+ AUDIO_VIDEO_EXTS,
30
+ UTF8_TEXT_EXTS,
31
+ TRANSCRIPT_EXTS,
32
+ CALENDAR_EXTS,
33
+ } from '../document-extract.mjs';
34
+
35
+ export const PROVIDER_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
36
+
37
+ const IMAGE_MIME = {
38
+ '.png': 'image/png',
39
+ '.jpg': 'image/jpeg',
40
+ '.jpeg': 'image/jpeg',
41
+ '.gif': 'image/gif',
42
+ '.webp': 'image/webp',
43
+ };
44
+
45
+ const DEFAULT_PROMPT = 'Extract the complete textual content of this document faithfully and verbatim. Preserve headings, lists, and tables as Markdown. Do not summarize, interpret, or add commentary — output only the extracted content.';
46
+
47
+ const MAX_OUTPUT_TOKENS = 4096;
48
+
49
+ function providerError(code, reason, remediation) {
50
+ const err = new Error(reason);
51
+ err.code = code;
52
+ err.remediation = remediation;
53
+ return err;
54
+ }
55
+
56
+ function classifyMedia(filePath) {
57
+ const ext = extname(filePath).toLowerCase();
58
+ if (PROVIDER_IMAGE_EXTS.has(ext)) return 'image';
59
+ if (ext === '.pdf') return 'pdf';
60
+ if (AUDIO_VIDEO_EXTS.has(ext)) return 'audio-video';
61
+ if (UTF8_TEXT_EXTS.has(ext) || TRANSCRIPT_EXTS.has(ext) || CALENDAR_EXTS.has(ext)) return 'text';
62
+ return 'binary-doc';
63
+ }
64
+
65
+ function isAnthropic(provider, model) {
66
+ return /anthropic|claude/i.test(provider || '') || /claude/i.test(model || '');
67
+ }
68
+
69
+ // Key resolution mirrors the daemon's cheap sources (env, then the two dotenv
70
+ // files) without the shell-rc/1Password walk, which belongs to long-running
71
+ // processes rather than a per-file extraction call. Ambient dotenv discovery
72
+ // only augments the real process env; a caller that injects an explicit env
73
+ // object (embedded callers, tests) is authoritative and hermetic.
74
+
75
+ function resolveKey(varName, env, allowAmbient) {
76
+ if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
77
+ if (!allowAmbient) return null;
78
+ for (const file of [join(homedir(), '.construct', 'config.env'), join(homedir(), '.env')]) {
79
+ try {
80
+ if (!existsSync(file)) continue;
81
+ const m = readFileSync(file, 'utf8').match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
82
+ if (m && m[1]) return m[1].trim();
83
+ } catch { /* unreadable dotenv is not authoritative */ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ async function bodyExcerpt(res) {
89
+ try {
90
+ const text = await res.text();
91
+ return text.slice(0, 200);
92
+ } catch {
93
+ return '';
94
+ }
95
+ }
96
+
97
+ async function callAnthropic({ model, apiKey, content, fetchImpl }) {
98
+ const res = await fetchImpl('https://api.anthropic.com/v1/messages', {
99
+ method: 'POST',
100
+ headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
101
+ body: JSON.stringify({
102
+ model: model.replace(/^anthropic\//, ''),
103
+ max_tokens: MAX_OUTPUT_TOKENS,
104
+ messages: [{ role: 'user', content }],
105
+ }),
106
+ });
107
+ if (!res.ok) {
108
+ throw providerError('PROVIDER_EXTRACTION_FAILED', `Anthropic extraction failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and ANTHROPIC_API_KEY, or set ingest.fallback to "adapter".');
109
+ }
110
+ const data = await res.json();
111
+ return (data.content || []).filter((b) => b && b.type === 'text').map((b) => b.text).join('');
112
+ }
113
+
114
+ async function callOpenRouter({ model, apiKey, content, fetchImpl }) {
115
+ const res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
116
+ method: 'POST',
117
+ headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/construct' },
118
+ body: JSON.stringify({
119
+ model: model.replace(/^openrouter\//, ''),
120
+ max_tokens: MAX_OUTPUT_TOKENS,
121
+ messages: [{ role: 'user', content }],
122
+ }),
123
+ });
124
+ if (!res.ok) {
125
+ throw providerError('PROVIDER_EXTRACTION_FAILED', `OpenRouter extraction failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and OPENROUTER_API_KEY, or set ingest.fallback to "adapter".');
126
+ }
127
+ const data = await res.json();
128
+ return data.choices?.[0]?.message?.content || '';
129
+ }
130
+
131
+ /**
132
+ * Extract a document's text through the configured provider/model.
133
+ *
134
+ * @param {object} opts
135
+ * @param {string} opts.filePath
136
+ * @param {string} opts.model resolved provider model id
137
+ * @param {string} [opts.provider] resolved provider id (selects the API)
138
+ * @param {number} [opts.maxChars] truncate returned text to this length
139
+ * @param {Record<string,string>} [opts.env]
140
+ * @param {Function} [opts.fetchImpl] injectable fetch (for tests)
141
+ * @param {string} [opts.prompt]
142
+ * @returns {Promise<{text:string, extractionMethod:string, characters:number, truncated:boolean, droppedInfo:Array}>}
143
+ */
144
+ export async function extractViaProvider({ filePath, model, provider = null, maxChars = null, env = process.env, fetchImpl = globalThis.fetch, prompt = DEFAULT_PROMPT } = {}) {
145
+ if (!filePath) throw providerError('PROVIDER_NO_INPUT', 'No file path supplied for provider extraction.', 'Pass a readable file path.');
146
+ if (!model) throw providerError('PROVIDER_MODEL_UNRESOLVED', 'Provider strategy selected but no provider model resolved.', 'Configure the model tier registry so the ingest tier resolves a model, or set ingest.fallback to "adapter".');
147
+ if (typeof fetchImpl !== 'function') throw providerError('PROVIDER_NO_FETCH', 'No fetch implementation available for provider extraction.', 'Run on a runtime with global fetch (Node 18+) or inject fetchImpl.');
148
+
149
+ const media = classifyMedia(filePath);
150
+ if (media === 'audio-video') {
151
+ throw providerError('PROVIDER_MEDIA_UNSUPPORTED', `Provider chat extraction does not transcribe audio/video (${extname(filePath)}).`, 'Use the adapter strategy (whisper ASR) for audio/video, or set ingest.fallback to "adapter".');
152
+ }
153
+ if (media === 'binary-doc') {
154
+ throw providerError('PROVIDER_MEDIA_UNSUPPORTED', `Provider chat extraction does not accept ${extname(filePath) || 'this type'} directly.`, 'Use the adapter strategy for Office/zip documents, or set ingest.fallback to "adapter".');
155
+ }
156
+
157
+ const anthropic = isAnthropic(provider, model);
158
+ const keyVar = anthropic ? 'ANTHROPIC_API_KEY' : 'OPENROUTER_API_KEY';
159
+ const apiKey = resolveKey(keyVar, env, env === process.env);
160
+ if (!apiKey) {
161
+ throw providerError('PROVIDER_KEY_MISSING', `No API key for ${anthropic ? 'Anthropic' : 'OpenRouter'} provider extraction.`, `Set ${keyVar}, or set ingest.fallback to "adapter".`);
162
+ }
163
+
164
+ let text;
165
+ if (media === 'text') {
166
+ const raw = readFileSync(filePath, 'utf8');
167
+ const promptText = `${prompt}\n\n---\n${raw}`;
168
+ text = anthropic
169
+ ? await callAnthropic({ model, apiKey, fetchImpl, content: [{ type: 'text', text: promptText }] })
170
+ : await callOpenRouter({ model, apiKey, fetchImpl, content: promptText });
171
+ } else {
172
+ const base64 = readFileSync(filePath).toString('base64');
173
+ if (anthropic) {
174
+ const mediaBlock = media === 'image'
175
+ ? { type: 'image', source: { type: 'base64', media_type: IMAGE_MIME[extname(filePath).toLowerCase()], data: base64 } }
176
+ : { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: base64 } };
177
+ text = await callAnthropic({ model, apiKey, fetchImpl, content: [mediaBlock, { type: 'text', text: prompt }] });
178
+ } else if (media === 'pdf') {
179
+ throw providerError('PROVIDER_MEDIA_UNSUPPORTED', 'PDF provider extraction requires an Anthropic-family model in this build.', 'Select an Anthropic ingest model, or set ingest.fallback to "adapter".');
180
+ } else {
181
+ const dataUrl = `data:${IMAGE_MIME[extname(filePath).toLowerCase()]};base64,${base64}`;
182
+ text = await callOpenRouter({ model, apiKey, fetchImpl, content: [{ type: 'text', text: prompt }, { type: 'image_url', image_url: { url: dataUrl } }] });
183
+ }
184
+ }
185
+
186
+ text = text || '';
187
+ let truncated = false;
188
+ if (maxChars && text.length > maxChars) {
189
+ text = text.slice(0, maxChars);
190
+ truncated = true;
191
+ }
192
+
193
+ return {
194
+ text,
195
+ extractionMethod: `provider:${anthropic ? 'anthropic' : 'openrouter'}:${model}`,
196
+ characters: text.length,
197
+ truncated,
198
+ droppedInfo: [],
199
+ };
200
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * lib/ingest/strategy.mjs — resolve the ingest extraction strategy and provider model.
3
+ *
4
+ * Construct's ingest path historically extracted documents through local binary
5
+ * adapters (docling/whisper/pdftotext) without surfacing that choice. This module
6
+ * makes the choice explicit and configurable:
7
+ * - `adapter` (default): the local-extractor pipeline, byte-for-byte unchanged.
8
+ * - `provider`: route document understanding through the configured provider/model.
9
+ * A `fallback` policy (`none`/`provider`/`adapter`) governs what happens on
10
+ * primary-path failure; the default `none` never silently masks a strategy
11
+ * mismatch. Resolution precedence matches the project-config convention:
12
+ * env > config > default. Provider/model identification reuses the embedded model
13
+ * resolution contract and the tier registry so no second selection surface exists.
14
+ */
15
+
16
+ import { resolveSetting } from '../config/project-config.mjs';
17
+ import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
18
+
19
+ export const INGEST_STRATEGIES = ['adapter', 'provider'];
20
+ export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
21
+
22
+ export const DEFAULT_INGEST_STRATEGY = 'adapter';
23
+ export const DEFAULT_INGEST_FALLBACK = 'none';
24
+
25
+ function coerceEnum(value, allowed) {
26
+ if (typeof value !== 'string') return null;
27
+ const lowered = value.trim().toLowerCase();
28
+ return allowed.includes(lowered) ? lowered : null;
29
+ }
30
+
31
+ // The strategy step a document-understanding model serves under is `fast`; ingest
32
+ // is an extraction/understanding pass, not a reasoning workflow. The embedded
33
+ // resolution contract maps this tier to a concrete provider/model.
34
+
35
+ const INGEST_WORKFLOW_TYPE = 'evidence-ingest';
36
+
37
+ function resolveProviderModel({ env, registryPath }) {
38
+ const resolved = resolveEmbeddedModel({ workflowType: INGEST_WORKFLOW_TYPE }, { env, registryPath });
39
+ return {
40
+ model: resolved.selectedModel || null,
41
+ provider: resolved.selectedProvider || null,
42
+ resolutionSource: resolved.resolutionSource || null,
43
+ error: resolved.error || null,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Resolve the effective ingest strategy, fallback policy, and provider model.
49
+ *
50
+ * Precedence for both strategy and fallback is env > config > explicit override
51
+ * > default, where an explicit `override` (a CLI flag) wins over env/config when
52
+ * provided. The returned `model`/`provider` are non-null only when the resolved
53
+ * strategy is `provider` or when `fallback` could route to a provider.
54
+ *
55
+ * @param {object} opts
56
+ * @param {object} [opts.config] loaded project config object
57
+ * @param {Record<string,string>} [opts.env]
58
+ * @param {string} [opts.override] explicit strategy override (CLI flag)
59
+ * @param {string} [opts.registryPath]
60
+ * @returns {{strategy:string, fallback:string, model:(string|null), provider:(string|null), modelResolution:(object|null)}}
61
+ */
62
+ export function resolveIngestStrategy({ config = null, env = process.env, override = null, registryPath = null } = {}) {
63
+ const overrideStrategy = coerceEnum(override, INGEST_STRATEGIES);
64
+
65
+ const fromSettings = resolveSetting({
66
+ config,
67
+ jsonPath: 'ingest.strategy',
68
+ env,
69
+ envKey: 'CONSTRUCT_INGEST_STRATEGY',
70
+ defaultValue: DEFAULT_INGEST_STRATEGY,
71
+ });
72
+ const strategy = overrideStrategy
73
+ || coerceEnum(fromSettings.value, INGEST_STRATEGIES)
74
+ || DEFAULT_INGEST_STRATEGY;
75
+
76
+ const fallbackSetting = resolveSetting({
77
+ config,
78
+ jsonPath: 'ingest.fallback',
79
+ env,
80
+ envKey: 'CONSTRUCT_INGEST_FALLBACK',
81
+ defaultValue: DEFAULT_INGEST_FALLBACK,
82
+ });
83
+ const fallback = coerceEnum(fallbackSetting.value, INGEST_FALLBACKS) || DEFAULT_INGEST_FALLBACK;
84
+
85
+ const needsProviderModel = strategy === 'provider' || fallback === 'provider';
86
+ const modelResolution = needsProviderModel ? resolveProviderModel({ env, registryPath }) : null;
87
+
88
+ return {
89
+ strategy,
90
+ fallback,
91
+ model: modelResolution?.model || null,
92
+ provider: modelResolution?.provider || null,
93
+ modelResolution,
94
+ };
95
+ }
package/lib/init-docs.mjs CHANGED
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
 
26
26
  import { suggestDocsLaneForFile } from './docs-routing.mjs';
27
27
  import { stampFrontmatter } from "./doc-stamp.mjs";
28
+ import { multiSelect, selectOption } from './tty-prompts.mjs';
28
29
  import readline from "node:readline";
29
30
 
30
31
  const __dirname = fileURLToPath(new URL(".", import.meta.url));