@geraldmaron/construct 1.0.6 → 1.0.7

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 (123) hide show
  1. package/README.md +19 -17
  2. package/agents/contracts.json +617 -2
  3. package/agents/contracts.schema.json +83 -0
  4. package/agents/prompts/cx-accessibility.md +2 -0
  5. package/agents/prompts/cx-ai-engineer.md +2 -0
  6. package/agents/prompts/cx-architect.md +2 -0
  7. package/agents/prompts/cx-business-strategist.md +2 -0
  8. package/agents/prompts/cx-data-analyst.md +2 -0
  9. package/agents/prompts/cx-data-engineer.md +2 -0
  10. package/agents/prompts/cx-debugger.md +2 -0
  11. package/agents/prompts/cx-designer.md +2 -0
  12. package/agents/prompts/cx-devil-advocate.md +2 -0
  13. package/agents/prompts/cx-docs-keeper.md +2 -0
  14. package/agents/prompts/cx-engineer.md +2 -0
  15. package/agents/prompts/cx-evaluator.md +2 -0
  16. package/agents/prompts/cx-explorer.md +2 -0
  17. package/agents/prompts/cx-legal-compliance.md +2 -0
  18. package/agents/prompts/cx-operations.md +2 -0
  19. package/agents/prompts/cx-orchestrator.md +2 -0
  20. package/agents/prompts/cx-platform-engineer.md +2 -0
  21. package/agents/prompts/cx-product-manager.md +2 -0
  22. package/agents/prompts/cx-qa.md +2 -0
  23. package/agents/prompts/cx-rd-lead.md +2 -0
  24. package/agents/prompts/cx-release-manager.md +2 -0
  25. package/agents/prompts/cx-researcher.md +2 -0
  26. package/agents/prompts/cx-reviewer.md +2 -0
  27. package/agents/prompts/cx-security.md +2 -0
  28. package/agents/prompts/cx-sre.md +2 -0
  29. package/agents/prompts/cx-test-automation.md +2 -0
  30. package/agents/prompts/cx-trace-reviewer.md +2 -0
  31. package/agents/prompts/cx-ux-researcher.md +2 -0
  32. package/agents/registry.json +8 -3
  33. package/bin/construct +209 -32
  34. package/commands/build/feature.md +1 -1
  35. package/commands/build/fix.md +1 -1
  36. package/commands/design/access.md +1 -1
  37. package/commands/design/flow.md +1 -1
  38. package/commands/design/ui.md +1 -1
  39. package/commands/measure/experiment.md +1 -1
  40. package/commands/measure/metrics.md +1 -1
  41. package/commands/measure/results.md +1 -1
  42. package/commands/plan/api.md +1 -1
  43. package/commands/plan/challenge.md +1 -1
  44. package/commands/plan/decide.md +1 -1
  45. package/commands/plan/feature.md +1 -1
  46. package/commands/plan/requirements.md +1 -1
  47. package/commands/remember/context.md +1 -1
  48. package/commands/remember/handoff.md +1 -1
  49. package/commands/remember/runbook.md +1 -1
  50. package/commands/review/code.md +1 -1
  51. package/commands/review/quality.md +1 -1
  52. package/commands/review/security.md +1 -1
  53. package/commands/ship/ready.md +1 -1
  54. package/commands/ship/release.md +1 -1
  55. package/commands/ship/status.md +1 -1
  56. package/commands/understand/docs.md +1 -1
  57. package/commands/understand/this.md +1 -1
  58. package/commands/understand/why.md +1 -1
  59. package/commands/work/clean.md +1 -1
  60. package/commands/work/drive.md +1 -1
  61. package/commands/work/optimize-prompts.md +1 -1
  62. package/commands/work/parallel-review.md +1 -1
  63. package/lib/auto-docs.mjs +7 -7
  64. package/lib/boundary.mjs +126 -0
  65. package/lib/cache-strategy-google.js +26 -31
  66. package/lib/comment-lint.mjs +134 -0
  67. package/lib/contracts/validate.mjs +323 -0
  68. package/lib/daemons/contract.mjs +210 -0
  69. package/lib/docs-verify.mjs +59 -6
  70. package/lib/doctor/cli.mjs +16 -1
  71. package/lib/doctor/index.mjs +3 -1
  72. package/lib/doctor/watchers/consistency.mjs +310 -0
  73. package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
  74. package/lib/document-extract.mjs +211 -1
  75. package/lib/embed/cli.mjs +124 -3
  76. package/lib/embed/daemon.mjs +43 -4
  77. package/lib/embed/docs-lifecycle.mjs +1 -1
  78. package/lib/embed/inbox.mjs +2 -0
  79. package/lib/embed/scheduler.mjs +33 -5
  80. package/lib/evaluator-optimizer.mjs +2 -3
  81. package/lib/hooks/comment-lint.mjs +16 -0
  82. package/lib/hooks/mcp-audit.mjs +2 -1
  83. package/lib/hooks/proactive-activation.mjs +0 -14
  84. package/lib/hooks/rule-verifier.mjs +217 -0
  85. package/lib/hooks/session-optimize.mjs +2 -1
  86. package/lib/init-unified.mjs +55 -65
  87. package/lib/intake/classify.mjs +108 -24
  88. package/lib/intake/daemon.mjs +121 -0
  89. package/lib/intake/filesystem-queue.mjs +6 -1
  90. package/lib/intake/intake-config.mjs +2 -1
  91. package/lib/intake/prepare.mjs +0 -1
  92. package/lib/intake/session-prelude.mjs +7 -1
  93. package/lib/intake/traceability.mjs +90 -0
  94. package/lib/knowledge/research-store.mjs +2 -0
  95. package/lib/maintenance/cleanup.mjs +315 -0
  96. package/lib/mcp/memory-bridge.mjs +276 -0
  97. package/lib/mcp/server.mjs +2 -0
  98. package/lib/mcp/tools/workflow.mjs +25 -0
  99. package/lib/mcp-catalog.json +12 -8
  100. package/lib/mcp-platform-config.mjs +16 -8
  101. package/lib/migrations/index.mjs +106 -0
  102. package/lib/migrations/v1-baseline.mjs +33 -0
  103. package/lib/observation-store.mjs +9 -4
  104. package/lib/outcomes/record.mjs +2 -0
  105. package/lib/profiles/rebrand.mjs +46 -0
  106. package/lib/project-init-shared.mjs +12 -0
  107. package/lib/provider-capabilities.js +20 -7
  108. package/lib/providers/auth-manager.mjs +58 -17
  109. package/lib/reflect.mjs +49 -12
  110. package/lib/server/index.mjs +22 -28
  111. package/lib/session-store.mjs +6 -4
  112. package/lib/setup.mjs +14 -3
  113. package/lib/telemetry/client.mjs +5 -1
  114. package/lib/version.mjs +51 -0
  115. package/lib/worker/trace.mjs +5 -1
  116. package/package.json +4 -1
  117. package/personas/construct.md +3 -1
  118. package/rules/common/development-workflow.md +2 -1
  119. package/rules/common/no-fabrication.md +69 -0
  120. package/rules/common/review-before-change.md +58 -0
  121. package/scripts/sync-agents.mjs +45 -14
  122. package/templates/docs/construct_guide.md +6 -6
  123. package/templates/docs/skill-artifact.md +1 -1
package/lib/auto-docs.mjs CHANGED
@@ -158,10 +158,10 @@ function buildAgentsTable(rootDir) {
158
158
  const agents = registry.agents ?? registry;
159
159
  if (!Array.isArray(agents)) return '';
160
160
 
161
- const rows = ['| Agent | Tier | Purpose |', '|---|---|---|'];
161
+ const rows = ['| Specialist | Tier | Purpose |', '|---|---|---|'];
162
162
  for (const agent of agents.slice(0, 30)) {
163
- const name = agent.id ?? agent.name ?? 'n/a';
164
- const tier = agent.tier ?? agent.model_tier ?? 'n/a';
163
+ const name = agent.name ?? agent.id ?? 'n/a';
164
+ const tier = agent.modelTier ?? agent.tier ?? agent.model_tier ?? 'n/a';
165
165
  // Strip em-dashes from descriptions so the prose lint stays clean across
166
166
  // AUTO regen. Em-dashes are allowed in the registry's source descriptions
167
167
  // but never in the rendered markdown.
@@ -195,7 +195,7 @@ export async function regenerateDocs({ rootDir, check = false } = {}) {
195
195
  },
196
196
  },
197
197
  {
198
- file: path.join(rootDir, 'docs', 'concepts', 'architecture.md'),
198
+ file: path.join(rootDir, 'docs', 'concepts', 'architecture.mdx'),
199
199
  regions: {
200
200
  agents: buildAgentsTable(rootDir),
201
201
  },
@@ -396,13 +396,13 @@ function renderAgentsPage(rootDir) {
396
396
  if (!table) return null;
397
397
  return [
398
398
  '---',
399
- 'title: Agents',
400
- 'description: The 28 cx-* specialists plus the construct persona. Generated from agents/registry.json.',
399
+ 'title: Specialists',
400
+ 'description: The 28 specialists behind the construct persona. Generated from agents/registry.json.',
401
401
  '---',
402
402
  '',
403
403
  '> Generated from `agents/registry.json`. Re-run `construct docs:site` to refresh.',
404
404
  '',
405
- 'Construct ships one persona (`construct`) plus 28 specialists. Each specialist has a role, model tier, and prompt that defines its decision authority. Specialists are addressable directly when you want a focused conversation; for everyday work, address `@construct` and let it route.',
405
+ 'Construct ships one persona (`construct`) and 28 specialists behind it. You address `@construct` for all everyday work; it routes to specialists internally. Each specialist has a role, model tier, and prompt file that defines its decision authority.',
406
406
  '',
407
407
  table,
408
408
  '',
@@ -0,0 +1,126 @@
1
+ /**
2
+ * lib/boundary.mjs — Authenticated parent/child boundary registration.
3
+ *
4
+ * Verifies a parent Construct instance is reachable, validates an HMAC
5
+ * signature over `childInstanceId|nonce` when a shared secret is configured,
6
+ * and persists the binding to ~/.construct/boundary.json (mode 0600). A
7
+ * different parent rotates the registration only when explicitly allowed via
8
+ * CONSTRUCT_BOUNDARY_ALLOW_OVERRIDE=1; prior configs are archived alongside
9
+ * the active one. Exposed as a standalone module so the dashboard endpoint
10
+ * and the functional tests share the same logic.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto';
17
+ import { request as httpRequest } from 'node:http';
18
+ import { request as httpsRequest } from 'node:https';
19
+
20
+ export const BOUNDARY_VERSION = '1.0';
21
+
22
+ function boundaryDir(home = homedir()) {
23
+ return join(home, '.construct');
24
+ }
25
+
26
+ export function boundaryConfigPath(home = homedir()) {
27
+ return join(boundaryDir(home), 'boundary.json');
28
+ }
29
+
30
+ export async function registerBoundary({
31
+ parentInstance,
32
+ parentUrl,
33
+ childInstanceId,
34
+ nonce,
35
+ signature,
36
+ sharedSecret = null,
37
+ home = homedir(),
38
+ allowOverride = process.env.CONSTRUCT_BOUNDARY_ALLOW_OVERRIDE === '1',
39
+ probe = probeParent,
40
+ }) {
41
+ if (!parentInstance || !parentUrl) {
42
+ return { ok: false, status: 400, error: 'parentInstance and parentUrl are required' };
43
+ }
44
+ if (!childInstanceId) {
45
+ return { ok: false, status: 400, error: 'childInstanceId is required' };
46
+ }
47
+
48
+ if (sharedSecret) {
49
+ if (!nonce || !signature) {
50
+ return { ok: false, status: 401, error: 'nonce and signature are required when a shared secret is configured' };
51
+ }
52
+ const expected = createHmac('sha256', sharedSecret).update(`${childInstanceId}|${nonce}`).digest('hex');
53
+ const a = Buffer.from(expected, 'hex');
54
+ let b;
55
+ try { b = Buffer.from(String(signature), 'hex'); } catch { return { ok: false, status: 401, error: 'invalid signature encoding' }; }
56
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
57
+ return { ok: false, status: 401, error: 'signature mismatch' };
58
+ }
59
+ }
60
+
61
+ const reachable = await probe(parentUrl);
62
+ if (!reachable.ok) {
63
+ return { ok: false, status: 502, error: `parent unreachable: ${reachable.error}` };
64
+ }
65
+
66
+ const dir = boundaryDir(home);
67
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
68
+ const cfgPath = boundaryConfigPath(home);
69
+
70
+ let prior = null;
71
+ if (existsSync(cfgPath)) {
72
+ try { prior = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { prior = null; }
73
+ }
74
+
75
+ if (prior && prior.parentInstance && prior.parentInstance !== parentInstance) {
76
+ if (!allowOverride) {
77
+ return {
78
+ ok: false,
79
+ status: 409,
80
+ error: `child already bound to parent '${prior.parentInstance}'; set CONSTRUCT_BOUNDARY_ALLOW_OVERRIDE=1 to rotate`,
81
+ };
82
+ }
83
+ const archivePath = join(dir, `boundary.${Date.now()}.json`);
84
+ try { writeFileSync(archivePath, JSON.stringify(prior, null, 2)); } catch { /* archive is best effort */ }
85
+ }
86
+
87
+ const config = {
88
+ parentInstance,
89
+ parentUrl,
90
+ childInstanceId,
91
+ registeredAt: new Date().toISOString(),
92
+ rotatedFrom: prior?.parentInstance || null,
93
+ nonce: nonce || randomBytes(16).toString('hex'),
94
+ boundaryVersion: BOUNDARY_VERSION,
95
+ };
96
+
97
+ writeFileSync(cfgPath, JSON.stringify(config, null, 2), { mode: 0o600 });
98
+ return { ok: true, config, path: cfgPath };
99
+ }
100
+
101
+ export function probeParent(parentUrl) {
102
+ return new Promise((resolve) => {
103
+ let parsed;
104
+ try { parsed = new URL(parentUrl); } catch { return resolve({ ok: false, error: 'invalid URL' }); }
105
+ if (!/^https?:$/.test(parsed.protocol)) return resolve({ ok: false, error: 'unsupported protocol' });
106
+ const fn = parsed.protocol === 'https:' ? httpsRequest : httpRequest;
107
+ const req = fn({
108
+ method: 'HEAD',
109
+ protocol: parsed.protocol,
110
+ hostname: parsed.hostname,
111
+ port: parsed.port,
112
+ path: parsed.pathname || '/',
113
+ timeout: 3000,
114
+ }, (response) => {
115
+ resolve({ ok: response.statusCode < 500 });
116
+ response.resume();
117
+ });
118
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
119
+ req.on('error', (err) => resolve({ ok: false, error: err.message }));
120
+ req.end();
121
+ });
122
+ }
123
+
124
+ export function signBoundaryRequest({ childInstanceId, nonce, sharedSecret }) {
125
+ return createHmac('sha256', sharedSecret).update(`${childInstanceId}|${nonce}`).digest('hex');
126
+ }
@@ -1,17 +1,34 @@
1
1
  /**
2
- * lib/cache-strategy-google.js — Google Gemini cache strategy.
2
+ * lib/cache-strategy-google.js — Google Gemini cache strategy (agnostic skeleton).
3
3
  *
4
- * Google uses cachedContent API (resource-based caching, not annotations).
5
- * Cache TTL: 1h via cachedContent resource.
6
- * Requires separate API call to create/update cached content.
4
+ * Gemini uses a resource-based cachedContent API rather than per-message
5
+ * annotations. Construct does not own that integration: this module exposes
6
+ * the same surface as the other cache-strategy adapters and resolves cached
7
+ * resources via a pluggable resolver. The default resolver returns null
8
+ * (no cached resource), which is the safe agnostic default — callers will
9
+ * fall through to a fresh request. Provider plugins or operators can register
10
+ * a resolver via `setCachedContentResolver` to enable resource caching.
7
11
  */
8
12
  import { estimateTokens } from './token-engine.js';
9
13
 
10
14
  const GEMINI_CACHE_ENDPOINT = 'https://generativelanguage.googleapis.com/v1';
11
15
 
16
+ let cachedContentResolver = null;
17
+
18
+ /**
19
+ * Register a resolver responsible for producing a cachedContent resource name
20
+ * for a given system prompt. Contract:
21
+ * resolver({ systemText, caps, apiKey, modelId }) -> Promise<string|null>
22
+ * Returning null means no cached resource is available; the annotation is
23
+ * omitted and the request proceeds without a cached prefix.
24
+ */
25
+ export function setCachedContentResolver(resolver) {
26
+ cachedContentResolver = typeof resolver === 'function' ? resolver : null;
27
+ }
28
+
12
29
  /**
13
- * Annotate prompt structure for Google Gemini models.
14
- * Note: Gemini uses cachedContent API, not message annotations.
30
+ * Annotate prompt structure for Google Gemini models. Returns the shared
31
+ * annotation shape that every cache-strategy adapter emits.
15
32
  *
16
33
  * @param {object} promptStructure - { system, messages }
17
34
  * @param {object} caps - Provider capabilities
@@ -22,12 +39,10 @@ export async function annotate(promptStructure, caps, { apiKey, modelId } = {})
22
39
  const { system, messages } = promptStructure || {};
23
40
 
24
41
  let cachedContentName = null;
25
-
26
- if (apiKey && system) {
42
+ if (cachedContentResolver && apiKey && system) {
27
43
  try {
28
- cachedContentName = await createCachedContent(system, caps, apiKey, modelId);
29
- } catch (err) {
30
- }
44
+ cachedContentName = await cachedContentResolver({ systemText: system, caps, apiKey, modelId });
45
+ } catch { /* resolver failure is non-fatal — fall through with no annotation */ }
31
46
  }
32
47
 
33
48
  return {
@@ -49,26 +64,6 @@ export async function annotate(promptStructure, caps, { apiKey, modelId } = {})
49
64
  };
50
65
  }
51
66
 
52
- async function createCachedContent(systemText, caps, apiKey, modelId) {
53
- // POST to https://generativelanguage.googleapis.com/v1/cachedContents
54
- // Body: { model: 'gemini-1.5-pro', contents: [{ parts: [{ text: systemText }] }] }
55
- // Returns: { name: 'cachedContents/abc123' }
56
-
57
- const model = extractModelName(modelId);
58
- const url = `${GEMINI_CACHE_ENDPOINT}/cachedContents?key=${apiKey}`;
59
-
60
- // This is async but we don't want to block dispatch
61
- // In production, this should be pre-cached
62
- // For now, return null (defer to Phase C follow-up with live probing)
63
- return null;
64
- }
65
-
66
- function extractModelName(modelId) {
67
- const id = String(modelId || '').toLowerCase();
68
- const match = id.match(/google\/(gemini-[^\/]+)/);
69
- return match ? match[1] : 'gemini-1.5-pro';
70
- }
71
-
72
67
  export async function capabilities() {
73
68
  return {
74
69
  supportsAnnotations: false,
@@ -29,10 +29,29 @@ const MD_HEADER_GLOBS = [
29
29
  /^commands\//,
30
30
  ];
31
31
 
32
+ // Artifact paths receive an additional prose-lint pass for fabrication patterns
33
+ // (manufactured confidence, hand-wave percentages, mind-reading, speculation).
34
+ // Scope is deliberately narrow — READMEs, cookbook, concepts, and reference are
35
+ // unaffected. The patterns live in BANNED_ARTIFACT below; the severity (block
36
+ // vs warn) is controlled by CONSTRUCT_ARTIFACT_LINT_MODE.
37
+ const ARTIFACT_PATH_GLOBS = [
38
+ /^docs\/prd\//,
39
+ /^docs\/adr\//,
40
+ /^docs\/rfc\//,
41
+ /^docs\/research\//,
42
+ /^\.cx\/knowledge\//,
43
+ /^\.cx\/handoffs\//,
44
+ /^\.cx\/research\//,
45
+ ];
46
+
32
47
  function relPath(rootDir, absPath) {
33
48
  return path.relative(rootDir, absPath).replace(/\\/g, '/');
34
49
  }
35
50
 
51
+ function isArtifactPath(rel) {
52
+ return ARTIFACT_PATH_GLOBS.some((r) => r.test(rel));
53
+ }
54
+
36
55
  function requiresHeader(rel) {
37
56
  const ext = path.extname(rel);
38
57
  if (['.yaml', '.yml', '.json', '.jsonl', '.toml'].includes(ext)) return { required: false, type: null };
@@ -88,6 +107,13 @@ const BANNED = [
88
107
  // step markers — only flag in `//` comments. Ordered lists are
89
108
  // conventional inside JSDoc decision-tree and flow documentation.
90
109
  { pattern: /\/\/\s+\d+\.\s+\w/, label: 'step marker: use function names or block structure instead of "// 1. Step"' },
110
+ // deferred-implementation wording: signals incomplete work that should
111
+ // either ship or be removed before release.
112
+ { pattern: new RegExp(`${CMT}.*\\bphase\\s+[abc]\\s+follow-?up\\b`, 'i'), label: 'deferred implementation: "Phase X follow-up" — ship it or remove it' },
113
+ { pattern: new RegExp(`${CMT}.*\\bin a real implementation\\b`, 'i'), label: 'deferred implementation: "in a real implementation" — implement the contract honestly' },
114
+ { pattern: new RegExp(`${CMT}.*\\bwould go here\\b`, 'i'), label: 'deferred implementation: "would go here" — implement or delete the placeholder' },
115
+ { pattern: new RegExp(`${CMT}.*\\bcoming soon\\b`, 'i'), label: 'deferred implementation: "coming soon" — not a release-ready statement' },
116
+ { pattern: new RegExp(`${CMT}.*\\bnot yet supported\\b`, 'i'), label: 'deferred implementation: "not yet supported" — document the supported contract instead' },
91
117
  // markdown equivalents
92
118
  { pattern: /<!--.*\b(?:added for|added recently|just added|new:)\b.*-->/i, label: 'point-in-time in markdown comment' },
93
119
  { pattern: /<!--.*\b(?:used by|called from|only consumer)\b.*-->/i, label: 'caller reference in markdown comment' },
@@ -102,16 +128,111 @@ const BANNED = [
102
128
  const BANNED_CHECK_SKIP = [
103
129
  'lib/comment-lint.mjs',
104
130
  'rules/common/comments.md',
131
+ 'rules/common/no-fabrication.md',
105
132
  'tests/comment-lint.test.mjs',
106
133
  'commands/work/clean.md',
107
134
  'skills/utility/clean-code.md',
108
135
  ];
109
136
 
137
+ // Artifact-prose patterns. Scoped to ARTIFACT_PATH_GLOBS. Each entry can mark
138
+ // requireCitation: true — in that case the violation is suppressed when a
139
+ // citation (`[source: …]`, http URL, or footnote ref) appears in the same line
140
+ // or within 2 lines of context.
141
+ const BANNED_ARTIFACT = [
142
+ {
143
+ pattern: /\b(?:clearly|obviously|undoubtedly|definitely|certainly|surely)\b/i,
144
+ label: 'manufactured confidence: "clearly/obviously/undoubtedly/etc" smuggles certainty without evidence (no-fabrication §4)',
145
+ },
146
+ {
147
+ pattern: /\b\d{1,3}\s*%/,
148
+ label: 'unattributed percentage: quantitative claims require a [source: …] citation (no-fabrication §4)',
149
+ requireCitation: true,
150
+ },
151
+ {
152
+ pattern: /\b(?:users|customers|everyone)\s+(?:want|expect|prefer|agree|need)\b/i,
153
+ label: 'customer mind-reading: "users want / customers expect / everyone agrees" requires a [source: …] citation (no-fabrication §5)',
154
+ requireCitation: true,
155
+ },
156
+ {
157
+ pattern: /\b(?:will\s+(?:likely|probably)|should\s+improve|expected\s+to\s+(?:improve|reduce|increase|drop|rise))\b/i,
158
+ label: 'speculative projection: future-tense claims need a baseline + projection source (no-fabrication §3, §4)',
159
+ requireCitation: true,
160
+ },
161
+ ];
162
+
110
163
  function isBannedCheckSkipped(filePath) {
111
164
  if (!filePath) return false;
112
165
  return BANNED_CHECK_SKIP.some((suffix) => filePath.endsWith(suffix));
113
166
  }
114
167
 
168
+ // Track fenced code blocks ( ``` … ``` ) and YAML frontmatter ( --- … --- )
169
+ // so artifact-prose patterns inside code or metadata are not flagged.
170
+ function buildSkipMap(lines) {
171
+ const skip = new Array(lines.length).fill(false);
172
+ let inFence = false;
173
+ let inFrontmatter = false;
174
+ let frontmatterClosed = false;
175
+ for (let i = 0; i < lines.length; i++) {
176
+ const line = lines[i];
177
+ if (i === 0 && line.trim() === '---') {
178
+ inFrontmatter = true;
179
+ skip[i] = true;
180
+ continue;
181
+ }
182
+ if (inFrontmatter) {
183
+ skip[i] = true;
184
+ if (line.trim() === '---') {
185
+ inFrontmatter = false;
186
+ frontmatterClosed = true;
187
+ }
188
+ continue;
189
+ }
190
+ if (/^```/.test(line)) {
191
+ inFence = !inFence;
192
+ skip[i] = true;
193
+ continue;
194
+ }
195
+ if (inFence) {
196
+ skip[i] = true;
197
+ continue;
198
+ }
199
+ if (/^\s{0,3}#{1,6}\s/.test(line)) skip[i] = true;
200
+ // Markdown table rows (start and end with `|`) are typically targets,
201
+ // specs, or reference lookups, not narrative prose. Skip to keep the
202
+ // pattern bank focused on the prose surface where embellishment lives.
203
+ if (/^\s*\|.*\|\s*$/.test(line)) skip[i] = true;
204
+ }
205
+ void frontmatterClosed;
206
+ return skip;
207
+ }
208
+
209
+ function hasCitationNearby(lines, idx) {
210
+ const lo = Math.max(0, idx - 2);
211
+ const hi = Math.min(lines.length, idx + 3);
212
+ for (let i = lo; i < hi; i++) {
213
+ if (/\[source:|\bhttps?:\/\/|\[\^\d+\]/i.test(lines[i])) return true;
214
+ }
215
+ return false;
216
+ }
217
+
218
+ function checkArtifactBanned(content, rel) {
219
+ if (!isArtifactPath(rel)) return [];
220
+ const lines = content.split('\n');
221
+ const skip = buildSkipMap(lines);
222
+ const violations = [];
223
+ for (let i = 0; i < lines.length; i++) {
224
+ if (skip[i]) continue;
225
+ if (lines[i].includes('construct-lint-ignore')) continue;
226
+ for (const entry of BANNED_ARTIFACT) {
227
+ if (entry.pattern.test(lines[i])) {
228
+ if (entry.requireCitation && hasCitationNearby(lines, i)) continue;
229
+ violations.push({ line: i + 1, label: entry.label, kind: 'artifact' });
230
+ }
231
+ }
232
+ }
233
+ return violations;
234
+ }
235
+
115
236
  function checkBanned(content, filePath) {
116
237
  if (isBannedCheckSkipped(filePath)) return [];
117
238
 
@@ -180,6 +301,19 @@ export function lintFile(filePath, { rootDir = process.cwd(), fix = false } = {}
180
301
  const banned = checkBanned(content, filePath);
181
302
  for (const w of banned) warnings.push(w);
182
303
 
304
+ // Artifact-prose lint applies to markdown files under intake-fed artifact
305
+ // paths (PRD, ADR, RFC, research, knowledge, handoffs). Severity is governed
306
+ // by CONSTRUCT_ARTIFACT_LINT_MODE: 'block' (CLI / release-gate) routes hits
307
+ // to errors[]; 'warn' (PostToolUse hook default) routes to warnings[] with
308
+ // kind:'artifact' so the hook can let the edit through while still surfacing
309
+ // the finding.
310
+ const artifactHits = checkArtifactBanned(content, rel);
311
+ if (artifactHits.length > 0) {
312
+ const mode = process.env.CONSTRUCT_ARTIFACT_LINT_MODE || 'warn';
313
+ const bucket = mode === 'block' ? errors : warnings;
314
+ for (const v of artifactHits) bucket.push(v);
315
+ }
316
+
183
317
  return { path: rel, errors, warnings };
184
318
  }
185
319