@geraldmaron/construct 1.0.11 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +55 -18
  2. package/bin/construct +259 -11
  3. package/bin/construct-postinstall.mjs +23 -2
  4. package/lib/auto-docs.mjs +23 -22
  5. package/lib/cli-commands.mjs +2 -0
  6. package/lib/dashboard-static.mjs +7 -4
  7. package/lib/doc-stamp.mjs +16 -0
  8. package/lib/docs-verify.mjs +1 -8
  9. package/lib/doctor/watchers/bd-watch.mjs +6 -2
  10. package/lib/embed/daemon.mjs +20 -0
  11. package/lib/embed/docs-lifecycle.mjs +19 -0
  12. package/lib/embed/inbox.mjs +85 -2
  13. package/lib/embed/recommendation-store.mjs +29 -0
  14. package/lib/gates-audit.mjs +18 -12
  15. package/lib/hooks/_lib/input.mjs +52 -0
  16. package/lib/hooks/adaptive-lint.mjs +4 -0
  17. package/lib/hooks/agent-tracker.mjs +59 -15
  18. package/lib/hooks/audit-reads.mjs +83 -42
  19. package/lib/hooks/audit-trail.mjs +28 -18
  20. package/lib/hooks/bash-output-logger.mjs +8 -2
  21. package/lib/hooks/block-no-verify.mjs +4 -0
  22. package/lib/hooks/ci-status-check.mjs +4 -0
  23. package/lib/hooks/comment-lint.mjs +6 -4
  24. package/lib/hooks/config-protection.mjs +4 -0
  25. package/lib/hooks/context-watch.mjs +4 -0
  26. package/lib/hooks/context-window-recovery.mjs +4 -0
  27. package/lib/hooks/dep-audit.mjs +12 -5
  28. package/lib/hooks/doc-coupling-check.mjs +5 -1
  29. package/lib/hooks/edit-accumulator.mjs +25 -10
  30. package/lib/hooks/edit-error-recovery.mjs +4 -0
  31. package/lib/hooks/edit-guard.mjs +4 -0
  32. package/lib/hooks/guard-bash.mjs +4 -0
  33. package/lib/hooks/mcp-audit.mjs +4 -0
  34. package/lib/hooks/mcp-health-check.mjs +4 -0
  35. package/lib/hooks/model-fallback.mjs +7 -11
  36. package/lib/hooks/policy-engine.mjs +4 -0
  37. package/lib/hooks/post-merge-docs-check.mjs +4 -0
  38. package/lib/hooks/post-merge-tracking.mjs +82 -0
  39. package/lib/hooks/pre-compact.mjs +4 -0
  40. package/lib/hooks/pre-push-gate.mjs +84 -231
  41. package/lib/hooks/proactive-activation.mjs +5 -2
  42. package/lib/hooks/readme-age-check.mjs +4 -0
  43. package/lib/hooks/registry-sync.mjs +35 -20
  44. package/lib/hooks/rule-verifier.mjs +3 -0
  45. package/lib/hooks/scan-secrets.mjs +4 -0
  46. package/lib/hooks/session-optimize.mjs +4 -0
  47. package/lib/hooks/session-reflect.mjs +4 -0
  48. package/lib/hooks/session-start.mjs +48 -1
  49. package/lib/hooks/session-tracking-refresh.mjs +70 -0
  50. package/lib/hooks/stop-notify.mjs +13 -2
  51. package/lib/hooks/stop-typecheck.mjs +4 -0
  52. package/lib/hooks/test-watch.mjs +8 -4
  53. package/lib/init-unified.mjs +90 -23
  54. package/lib/intake/attribution.mjs +77 -0
  55. package/lib/intake/intake-config.mjs +3 -0
  56. package/lib/intake/manifest.mjs +107 -0
  57. package/lib/intake/poll-lock.mjs +136 -0
  58. package/lib/intake/prepare.mjs +42 -4
  59. package/lib/logging/rotate.mjs +394 -0
  60. package/lib/mcp/tools/project.mjs +2 -2
  61. package/lib/mcp/tools/telemetry.mjs +1 -1
  62. package/lib/opencode-config.mjs +10 -3
  63. package/lib/orchestration/routing-tables.mjs +176 -0
  64. package/lib/orchestration-policy.mjs +18 -106
  65. package/lib/parity.mjs +48 -7
  66. package/lib/profiles/lifecycle.mjs +19 -1
  67. package/lib/project-init-shared.mjs +11 -5
  68. package/lib/project-root.mjs +104 -0
  69. package/lib/roles/catalog.mjs +1 -1
  70. package/lib/roles/event-bus.mjs +29 -9
  71. package/lib/roles/router.mjs +8 -7
  72. package/lib/server/index.mjs +31 -9
  73. package/lib/server/static/index.html +1 -15
  74. package/lib/specialist-contracts-enforce.mjs +24 -10
  75. package/lib/status.mjs +1 -1
  76. package/lib/storage/backup.mjs +2 -2
  77. package/lib/sync/skill-frontmatter.mjs +71 -0
  78. package/lib/telemetry/intent-verifications.mjs +16 -3
  79. package/lib/telemetry/skill-calls.mjs +12 -3
  80. package/lib/tracking-surfaces.mjs +375 -0
  81. package/lib/worker/trace.mjs +19 -2
  82. package/package.json +6 -2
  83. package/platforms/claude/settings.template.json +28 -27
  84. package/scripts/sync-specialists.mjs +296 -94
  85. package/specialists/registry.json +158 -13
  86. package/lib/hooks/env-check.mjs +0 -83
  87. package/lib/hooks/read-tracker.mjs +0 -61
  88. package/lib/policy/unified-gates.mjs +0 -96
  89. package/lib/server/static/assets/index-ab25c707.js +0 -70
  90. package/lib/server/static/assets/index-f0c80a2b.css +0 -1
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 300
8
8
  * @maxBlockingScope SessionStart
9
+ *
10
+ * @lifecycle SessionStart
11
+ * @matcher *
12
+ * @exits 0 = pass
9
13
  */
10
14
  import { readFileSync, existsSync, statSync, writeFileSync } from 'fs';
11
15
  import { execSync } from 'child_process';
@@ -321,6 +325,49 @@ try {
321
325
  brokerStatusNote = `\n${buildBrokerStatusLine({ env: process.env })}\n`;
322
326
  } catch { /* best effort */ }
323
327
 
328
+ // Missing-env-vars notice. Reads .env.example from the project root,
329
+ // compares against .env and process.env, lists keys whose example value
330
+ // is a placeholder and whose live value is unset. Empty string when the
331
+ // project has no .env.example or all required keys are populated.
332
+
333
+ const envCheckNote = buildEnvCheckNote(cwd);
334
+
335
+ function buildEnvCheckNote(rootDir) {
336
+ const examplePath = join(rootDir, '.env.example');
337
+ if (!existsSync(examplePath)) return '';
338
+ const example = parseEnvFile(examplePath);
339
+ if (example.size === 0) return '';
340
+ const envFile = parseEnvFile(join(rootDir, '.env'));
341
+ const PLACEHOLDER = /^(YOUR_|<|__|\$\{|REPLACE|ADD_|INSERT_|xxx|TODO)/i;
342
+ const missing = [];
343
+ for (const [key, exampleVal] of example) {
344
+ const required = !exampleVal || PLACEHOLDER.test(exampleVal);
345
+ if (!required) continue;
346
+ const liveFromEnvFile = envFile.has(key) && envFile.get(key) !== '' && !PLACEHOLDER.test(envFile.get(key));
347
+ const liveFromProcessEnv = process.env[key] && !PLACEHOLDER.test(process.env[key]);
348
+ if (!liveFromEnvFile && !liveFromProcessEnv) missing.push(key);
349
+ }
350
+ if (missing.length === 0) return '';
351
+ const noun = missing.length === 1 ? 'variable' : 'variables';
352
+ const list = missing.map((k) => ` - ${k}`).join('\n');
353
+ return `\n## Environment check — ${missing.length} required ${noun} not set\n${list}\nAdd these to .env before running the app.\n`;
354
+ }
355
+
356
+ function parseEnvFile(p) {
357
+ const map = new Map();
358
+ if (!existsSync(p)) return map;
359
+ for (const line of readFileSync(p, 'utf8').split('\n')) {
360
+ const stripped = line.trim();
361
+ if (!stripped || stripped.startsWith('#')) continue;
362
+ const eq = stripped.indexOf('=');
363
+ if (eq === -1) continue;
364
+ const key = stripped.slice(0, eq).trim();
365
+ const val = stripped.slice(eq + 1).trim();
366
+ if (key) map.set(key, val);
367
+ }
368
+ return map;
369
+ }
370
+
324
371
  // Permission posture: surface gaps in ~/.claude/settings.json permissions.allow
325
372
  // so the agent can ask the user to close them instead of stumbling into the
326
373
  // classifier mid-task. Empty string when no gaps — keeps the banner small.
@@ -331,7 +378,7 @@ try {
331
378
  permissionPostureNote = buildPermissionPostureLine({ cwd });
332
379
  } catch { /* best effort */ }
333
380
 
334
- process.stdout.write(`${header}\n${body}${stateNote}${efficiencyNote}${observationsNote}${concurrencyNote}${skillScopeNote}${dropNote}${embedStatusNote}${sourcesNote}${selfKnowledgeNote}${rolesNote}${intakeReviewNote}${brokerStatusNote}${permissionPostureNote}\n${footer}${pendingNote}\n`);
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`);
335
382
  // Auto-bootstrap the policy-engine gate. session-start has just emitted
336
383
  // branch + recent commits + prior observations + context-state — that is
337
384
  // already grounding; mark the session bootstrapped so the PreToolUse rule
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lib/hooks/session-tracking-refresh.mjs — keep the project's tracking
4
+ * surfaces current at session end.
5
+ *
6
+ * Runs as a Stop hook in parallel with the other Stop-time hooks. Touches
7
+ * three surfaces:
8
+ *
9
+ * - `.cx/context.md` + `.cx/context.json` — refresh Active Work, Recent
10
+ * Decisions, and Architecture Notes from the session's observations,
11
+ * commits, and bead state changes.
12
+ * - `plan.md` — sync the bead-status table with current `bd show <id>`
13
+ * truth; if every referenced bead has closed and the plan has been
14
+ * idle for >1h, archive a copy to `.cx/handoffs/<date>-plan-landed.md`
15
+ * and reset `plan.md` to the standard template.
16
+ *
17
+ * Best-effort. Failures degrade silently. Wall-clock budgeted at 2000 ms
18
+ * total — bd shells out, and a plan with several bead refs needs one
19
+ * `bd show` per ref. Exits 0 on any error so the session close path is
20
+ * never blocked.
21
+ *
22
+ * Only runs inside Construct projects (presence of `.cx/`) — same gate as
23
+ * the other Stop hooks.
24
+ *
25
+ * @lifecycle Stop
26
+ * @matcher *
27
+ * @p95ms 2000
28
+ * @maxBlockingScope none (Stop, non-blocking)
29
+ * @exits 0 = pass
30
+ */
31
+
32
+ import { existsSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { readHookInput } from './_lib/input.mjs';
35
+ import { logHookFailure } from './_lib/log.mjs';
36
+
37
+ const HARD_BUDGET_MS = 2000;
38
+ const startedAt = Date.now();
39
+
40
+ const input = readHookInput();
41
+ const cwd = input?.cwd || process.cwd();
42
+
43
+ // Only run inside Construct projects.
44
+ if (!existsSync(join(cwd, '.cx'))) process.exit(0);
45
+
46
+ const deadline = setTimeout(() => process.exit(0), HARD_BUDGET_MS);
47
+ deadline.unref();
48
+
49
+ try {
50
+ // Order matters. archivePlanIfLanded reads plan.md's mtime to decide
51
+ // whether the plan has been idle long enough to retire. syncPlanFile
52
+ // mutates plan.md when bead statuses drift — which bumps mtime and
53
+ // makes the plan look "recently touched" to the archive check. Run
54
+ // archive first (true idle mtime), then sync the survivor.
55
+ const { archivePlanIfLanded, syncPlanFile, refreshContextMd } = await import('../tracking-surfaces.mjs');
56
+ await archivePlanIfLanded({ rootDir: cwd });
57
+ await syncPlanFile({ rootDir: cwd });
58
+ await refreshContextMd({ rootDir: cwd });
59
+ } catch (err) {
60
+ logHookFailure({ hook: 'session-tracking-refresh', err, phase: 'refresh' });
61
+ }
62
+
63
+ const elapsed = Date.now() - startedAt;
64
+ if (elapsed > HARD_BUDGET_MS) {
65
+ try {
66
+ process.stderr.write(`[session-tracking-refresh] over budget: ${elapsed}ms\n`);
67
+ } catch { /* stderr closed */ }
68
+ }
69
+
70
+ process.exit(0);
@@ -11,8 +11,14 @@
11
11
  *
12
12
  * @p95ms 500
13
13
  * @maxBlockingScope Stop
14
+ *
15
+ * @lifecycle Stop
16
+ * @matcher *
17
+ * @exits 0 = pass
14
18
  */
15
- import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'fs';
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
20
+ import { appendBounded } from '../logging/rotate.mjs';
21
+ import { resolveProjectScope } from '../project-root.mjs';
16
22
  import { execSync } from 'child_process';
17
23
  import { homedir } from 'os';
18
24
  import { join, dirname } from 'path';
@@ -228,7 +234,12 @@ try {
228
234
  if (agentName) entry.agent = agentName;
229
235
  if (taskKey) entry.task_key = taskKey;
230
236
 
231
- try { appendFileSync(costLogPath, JSON.stringify(entry) + '\n'); } catch { /* non-critical */ }
237
+ // Cross-project ledger keep at user scope but tag with projectId so a
238
+ // reader can attribute spend to a specific project.
239
+
240
+ const scope = resolveProjectScope();
241
+ if (scope?.projectId) entry.projectId = scope.projectId;
242
+ try { appendBounded('session-cost', costLogPath, JSON.stringify(entry) + '\n'); } catch { /* non-critical */ }
232
243
 
233
244
  totalCostUsd += priced.costUsd;
234
245
  turnsLogged += 1;
@@ -6,6 +6,10 @@
6
6
  *
7
7
  * @p95ms 2000
8
8
  * @maxBlockingScope Stop
9
+ *
10
+ * @lifecycle Stop
11
+ * @matcher *
12
+ * @exits 0 = pass
9
13
  */
10
14
  import { readFileSync, writeFileSync, existsSync } from 'fs';
11
15
  import { execSync } from 'child_process';
@@ -8,9 +8,13 @@
8
8
  *
9
9
  * @p95ms 30
10
10
  * @maxBlockingScope none (async, non-blocking)
11
+ *
12
+ * @lifecycle PostToolUse
13
+ * @matcher Bash
14
+ * @exits 0 = pass
11
15
  */
12
16
 
13
- import { readFileSync, existsSync, appendFileSync } from 'node:fs';
17
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
14
18
  import { homedir } from 'node:os';
15
19
  import { join } from 'node:path';
16
20
  import { logHookFailure } from './_lib/log.mjs';
@@ -56,13 +60,13 @@ try {
56
60
  }
57
61
  state[name] = { failedAt: now };
58
62
  }
59
- appendFileSync(flakeTrackerPath, '');
60
63
  const trimmed = {};
61
64
  for (const [k, v] of Object.entries(state)) {
62
65
  if ((v.failedAt || v.passedAt || 0) > now - 24 * 60 * 60 * 1000) trimmed[k] = v;
63
66
  }
64
- const writeFileSync = (await import('node:fs')).writeFileSync;
65
67
  writeFileSync(flakeTrackerPath, JSON.stringify(trimmed));
66
- } catch { /* best effort */ }
68
+ } catch (err) {
69
+ logHookFailure({ hook: 'test-watch', err, phase: 'flake-track' });
70
+ }
67
71
 
68
72
  process.exit(0);
@@ -812,12 +812,28 @@ async function main() {
812
812
  const { findProjectConfigPath, loadProjectConfig, writeProjectConfig, PROJECT_CONFIG_FILENAME } = await import('./config/project-config.mjs');
813
813
  const found = findProjectConfigPath(target);
814
814
  const cfgPath = found || path.join(target, PROJECT_CONFIG_FILENAME);
815
- const cfg = found ? (loadProjectConfig(target) || { version: 1 }) : { version: 1 };
815
+
816
+ // loadProjectConfig returns a wrapper { path, raw, config, source, errors }.
817
+ // Persist .raw (the on-disk JSON) so user customizations survive and
818
+ // defaults are not silently materialized into the file.
819
+
820
+ const loaded = found ? loadProjectConfig(target) : null;
821
+ const cfg = loaded?.raw ? { ...loaded.raw } : { version: 1 };
816
822
  cfg.profile = profileId;
817
823
  writeProjectConfig(cfgPath, cfg, { validate: true });
818
824
  if (!quiet) console.log(`Profile set to ${profileId}.`);
819
825
  }
820
-
826
+
827
+ // Resolve active profile to drive capability-gated scaffolding (intake
828
+ // archetype, attribution stamping). Falls back to rnd when no profile is
829
+ // configured — matches resolveActiveProfile semantics elsewhere.
830
+
831
+ const { resolveActiveProfile } = await import('./profiles/loader.mjs');
832
+ const activeProfile = resolveActiveProfile(target, profileId) ?? null;
833
+ const intakeCap = activeProfile?.capabilities?.intake ?? null;
834
+ const { gatherAttribution } = await import('./intake/attribution.mjs');
835
+ const attribution = intakeCap?.attribution ? gatherAttribution() : null;
836
+
821
837
  // Always create core Construct files
822
838
  writeStampedIfMissing({
823
839
  targetRoot: target,
@@ -826,8 +842,9 @@ async function main() {
826
842
  filePath: path.join(target, "AGENTS.md"),
827
843
  content: buildAgentsGuide(projectName),
828
844
  generator: "construct/init",
845
+ attribution,
829
846
  });
830
-
847
+
831
848
  writeStampedIfMissing({
832
849
  targetRoot: target,
833
850
  created,
@@ -835,17 +852,18 @@ async function main() {
835
852
  filePath: path.join(target, "plan.md"),
836
853
  content: buildPlanTemplate(),
837
854
  generator: "construct/init",
855
+ attribution,
838
856
  });
839
-
857
+
840
858
  writeStampedIfMissing({
841
859
  targetRoot: target,
842
860
  created,
843
861
  skipped,
844
862
  filePath: path.join(target, ".cx", "context.json"),
845
- content: buildContextJson(projectName),
863
+ content: buildContextJson(projectName, { attribution }),
846
864
  generator: "construct/init",
847
865
  });
848
-
866
+
849
867
  writeStampedIfMissing({
850
868
  targetRoot: target,
851
869
  created,
@@ -853,6 +871,7 @@ async function main() {
853
871
  filePath: path.join(target, ".cx", "context.md"),
854
872
  content: buildContextMarkdown(),
855
873
  generator: "construct/init",
874
+ attribution,
856
875
  });
857
876
 
858
877
  writeStampedIfMissing({
@@ -888,6 +907,56 @@ async function main() {
888
907
  });
889
908
  }
890
909
 
910
+ // Intake-archetype scaffolding. When the active profile declares
911
+ // capabilities.intake.inbox, scaffold a project-root inbox/ drop zone
912
+ // (with a .gitignore so raw drops never enter source) and seed the
913
+ // dedup manifest. The existing-structure detection already opted us
914
+ // out of clobbering a user's pipeline above.
915
+
916
+ if (intakeCap?.inbox && !inboxDecision.skip) {
917
+ const projectInbox = path.join(target, 'inbox');
918
+ if (!fs.existsSync(projectInbox)) {
919
+ fs.mkdirSync(projectInbox, { recursive: true });
920
+ created.push('inbox/');
921
+ }
922
+ const inboxGitignore = path.join(projectInbox, '.gitignore');
923
+ if (!fs.existsSync(inboxGitignore)) {
924
+ fs.writeFileSync(inboxGitignore, '*\n!.gitignore\n', 'utf8');
925
+ created.push('inbox/.gitignore');
926
+ }
927
+
928
+ if (intakeCap.dedup === 'sha256') {
929
+ const { saveManifest, loadManifest, MANIFEST_REL_PATH } = await import('./intake/manifest.mjs');
930
+ const manifestExists = fs.existsSync(path.join(target, MANIFEST_REL_PATH));
931
+ if (!manifestExists) {
932
+ saveManifest(target, loadManifest(target));
933
+ created.push(MANIFEST_REL_PATH);
934
+ }
935
+ }
936
+ }
937
+
938
+ // Ensure `.cx/` is in project .gitignore. .cx/ is runtime state — observations,
939
+ // sessions, vector index, and traces (with daily JSONL files that can exceed
940
+ // GitHub's 100 MB file-size limit). Idempotent: if `.cx/` (or a broader
941
+ // pattern like `.cx` / `*` / `**`) already matches, leave the file alone.
942
+
943
+ try {
944
+ const gitignorePath = path.join(target, '.gitignore');
945
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
946
+ const lines = existing.split('\n').map((l) => l.trim());
947
+ const alreadyIgnored = lines.some((l) =>
948
+ l === '.cx' || l === '.cx/' || l === '.cx/**' || l === '*' || l === '**'
949
+ );
950
+ if (!alreadyIgnored) {
951
+ const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
952
+ const block = `${prefix}\n# Construct runtime state — local-only, never source\n.cx/\n`;
953
+ fs.writeFileSync(gitignorePath, existing + block, 'utf8');
954
+ created.push(existing.length === 0 ? '.gitignore (added .cx/)' : '.gitignore (appended .cx/)');
955
+ }
956
+ } catch (err) {
957
+ console.warn(`⚠️ Could not update .gitignore: ${err.message}`);
958
+ }
959
+
891
960
  // Stage .construct/ launcher + sync .claude/ adapters so init produces the
892
961
  // same project shape as a fresh `npm install` of the package as a dep.
893
962
 
@@ -911,23 +980,12 @@ async function main() {
911
980
  created.push('.claude/ (agents + settings)');
912
981
  }
913
982
 
914
- // Also sync home-level adapters for all 6 host surfaces (Copilot, OpenCode,
915
- // Codex, VS Code, Cursor, plus Claude home) so the user's environment is
916
- // fully wired after init. Project-local Claude adapters were written above.
917
- const syncScript = path.join(ROOT_DIR, 'scripts', 'sync-specialists.mjs');
918
- if (fs.existsSync(syncScript)) {
919
- const { spawnSync } = await import('node:child_process');
920
- const homeResult = spawnSync(process.execPath, [syncScript], {
921
- cwd: target,
922
- stdio: 'pipe',
923
- env: { ...process.env, CONSTRUCT_PROJECT_ROOT: target },
924
- timeout: 120000,
925
- });
926
- if (homeResult.status !== 0) {
927
- const errMsg = homeResult.stderr.toString().substring(0, 200);
928
- console.warn(`⚠️ Home adapter sync had issues (exit ${homeResult.status}): ${errMsg}`);
929
- }
930
- }
983
+ // construct init writes project scope only. Global wiring (the `construct`
984
+ // front-door agent in `~/.claude/agents/`, `~/.codex/agents/`, etc.) is
985
+ // installed once by `construct sync --global` or the npm postinstall.
986
+ // Specialists, slash commands, and skills live with the repo per each
987
+ // host's documented best-practice scope.
988
+
931
989
  } catch (err) {
932
990
  console.warn(`⚠️ Adapter staging failed: ${err.message}`);
933
991
  }
@@ -987,6 +1045,15 @@ async function main() {
987
1045
  // pipeline. User can opt in later via `construct config intake.includeProjectInbox=true`.
988
1046
 
989
1047
  if (inboxDecision.skip) intakeConfig.includeProjectInbox = false;
1048
+
1049
+ // Archetype: flip includeArchetypeInbox on so resolveInboxDirs picks up
1050
+ // the project-root inbox/ drop zone alongside .cx/inbox/. Mirrors the
1051
+ // existing includeProjectInbox / includeDocsIntake toggles rather than
1052
+ // polluting parentDirs (which is reserved for user-explicit dirs).
1053
+
1054
+ if (intakeCap?.inbox) {
1055
+ intakeConfig.includeArchetypeInbox = true;
1056
+ }
990
1057
  const { saveIntakeConfig } = await import('./intake/intake-config.mjs');
991
1058
  try {
992
1059
  saveIntakeConfig(target, intakeConfig);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * lib/intake/attribution.mjs — provenance metadata for intake-archetype artifacts.
3
+ *
4
+ * Two-field attribution. Human identity comes from `git config user.name` +
5
+ * `user.email`; agent identity comes from CONSTRUCT_AGENT_ID (set by hosts
6
+ * like Claude Code, Codex, Copilot before they invoke the binary). Both are
7
+ * captured because either can be the proximate cause of a write and the
8
+ * audit reader needs to know which.
9
+ *
10
+ * Pure, side-effect free. No git/cli call when the env var
11
+ * CONSTRUCT_ATTRIBUTION_DISABLE=1 — useful for deterministic tests and for
12
+ * privacy-sensitive setups that prefer no identity capture.
13
+ */
14
+
15
+ import { spawnSync } from 'node:child_process';
16
+
17
+ function gitConfigValue(key, cwd) {
18
+ const result = spawnSync('git', ['config', '--get', key], {
19
+ cwd,
20
+ encoding: 'utf8',
21
+ timeout: 2000,
22
+ });
23
+ if (result.status !== 0) return null;
24
+ const value = (result.stdout || '').trim();
25
+ return value || null;
26
+ }
27
+
28
+ function humanIdentity(cwd) {
29
+ const name = gitConfigValue('user.name', cwd);
30
+ const email = gitConfigValue('user.email', cwd);
31
+ if (!name && !email) return null;
32
+ if (name && email) return `${name} <${email}>`;
33
+ return name || email;
34
+ }
35
+
36
+ function agentIdentity() {
37
+ return (
38
+ process.env.CONSTRUCT_AGENT_ID
39
+ || process.env.CLAUDE_AGENT_ID
40
+ || null
41
+ );
42
+ }
43
+
44
+ export function gatherAttribution({ now = new Date(), cwd } = {}) {
45
+ if (process.env.CONSTRUCT_ATTRIBUTION_DISABLE === '1') {
46
+ return {
47
+ createdBy: null,
48
+ createdByAgent: null,
49
+ createdAt: now.toISOString(),
50
+ };
51
+ }
52
+ return {
53
+ createdBy: humanIdentity(cwd),
54
+ createdByAgent: agentIdentity(),
55
+ createdAt: now.toISOString(),
56
+ };
57
+ }
58
+
59
+ export function stampAttribution(record, attribution = gatherAttribution()) {
60
+ if (!record || typeof record !== 'object') return record;
61
+ return {
62
+ ...record,
63
+ createdBy: attribution.createdBy,
64
+ createdByAgent: attribution.createdByAgent,
65
+ createdAt: attribution.createdAt,
66
+ };
67
+ }
68
+
69
+ export function touchAttribution(record, attribution = gatherAttribution()) {
70
+ if (!record || typeof record !== 'object') return record;
71
+ return {
72
+ ...record,
73
+ lastModifiedBy: attribution.createdBy,
74
+ lastModifiedByAgent: attribution.createdByAgent,
75
+ lastModifiedAt: attribution.createdAt,
76
+ };
77
+ }
@@ -44,6 +44,7 @@ export const DEFAULT_INTAKE_CONFIG = Object.freeze({
44
44
  maxDepth: INTAKE_DEFAULT_MAX_DEPTH,
45
45
  includeProjectInbox: true,
46
46
  includeDocsIntake: true,
47
+ includeArchetypeInbox: false,
47
48
  });
48
49
 
49
50
  export function intakeConfigPath(rootDir) {
@@ -98,6 +99,7 @@ export function loadIntakeConfig(rootDir, env = process.env) {
98
99
  maxDepth,
99
100
  includeProjectInbox: stored.includeProjectInbox !== false,
100
101
  includeDocsIntake: stored.includeDocsIntake !== false,
102
+ includeArchetypeInbox: stored.includeArchetypeInbox === true,
101
103
  };
102
104
  }
103
105
 
@@ -113,6 +115,7 @@ export function saveIntakeConfig(rootDir, patch = {}) {
113
115
  maxDepth: clampDepth(patch.maxDepth ?? current.maxDepth),
114
116
  includeProjectInbox: patch.includeProjectInbox !== undefined ? Boolean(patch.includeProjectInbox) : current.includeProjectInbox,
115
117
  includeDocsIntake: patch.includeDocsIntake !== undefined ? Boolean(patch.includeDocsIntake) : current.includeDocsIntake,
118
+ includeArchetypeInbox: patch.includeArchetypeInbox !== undefined ? Boolean(patch.includeArchetypeInbox) : current.includeArchetypeInbox,
116
119
  };
117
120
 
118
121
  const file = intakeConfigPath(rootDir);
@@ -0,0 +1,107 @@
1
+ /**
2
+ * lib/intake/manifest.mjs — SHA-256 dedup manifest for intake-archetype projects.
3
+ *
4
+ * Projects that scaffold the intake archetype (profile.capabilities.intake)
5
+ * drop raw source files into `inbox/`. The manifest records which source
6
+ * files have already been processed (by content hash, not path) so the
7
+ * intake runtime can refuse to reprocess an unchanged file and so a moved
8
+ * or renamed file still dedups correctly.
9
+ *
10
+ * Storage: <projectRoot>/.cx/intake/manifest.json
11
+ *
12
+ * Shape:
13
+ * {
14
+ * "version": 1,
15
+ * "files": {
16
+ * "<sha256>": {
17
+ * "sourcePath": "inbox/notes-2026-05-29.md",
18
+ * "processedAt": "2026-05-29T19:00:00.000Z",
19
+ * "intakeId": "intake-1779164832122-onxe",
20
+ * "createdBy": "Gerald Dagher <gerald@example.com>",
21
+ * "createdByAgent": "claude-opus-4-7"
22
+ * }
23
+ * }
24
+ * }
25
+ *
26
+ * Pure functions over the JSON. Callers own the rootDir; no implicit cwd.
27
+ * Atomic writes go through write-rename so a crash mid-write cannot leave
28
+ * the manifest half-written.
29
+ */
30
+
31
+ import { createHash } from 'node:crypto';
32
+ import {
33
+ existsSync,
34
+ mkdirSync,
35
+ readFileSync,
36
+ renameSync,
37
+ writeFileSync,
38
+ } from 'node:fs';
39
+ import { join } from 'node:path';
40
+
41
+ export const MANIFEST_REL_PATH = '.cx/intake/manifest.json';
42
+ export const MANIFEST_VERSION = 1;
43
+
44
+ function manifestPath(rootDir) {
45
+ return join(rootDir, MANIFEST_REL_PATH);
46
+ }
47
+
48
+ function emptyManifest() {
49
+ return { version: MANIFEST_VERSION, files: {} };
50
+ }
51
+
52
+ export function loadManifest(rootDir) {
53
+ const p = manifestPath(rootDir);
54
+ if (!existsSync(p)) return emptyManifest();
55
+ try {
56
+ const raw = JSON.parse(readFileSync(p, 'utf8'));
57
+ if (!raw || typeof raw !== 'object' || typeof raw.files !== 'object') {
58
+ return emptyManifest();
59
+ }
60
+ return raw;
61
+ } catch {
62
+ return emptyManifest();
63
+ }
64
+ }
65
+
66
+ export function saveManifest(rootDir, manifest) {
67
+ const p = manifestPath(rootDir);
68
+ const dir = join(rootDir, '.cx', 'intake');
69
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
70
+ const payload = JSON.stringify(manifest, null, 2) + '\n';
71
+ const tmp = `${p}.tmp`;
72
+ writeFileSync(tmp, payload, 'utf8');
73
+ renameSync(tmp, p);
74
+ }
75
+
76
+ export function sha256Of(buffer) {
77
+ return createHash('sha256').update(buffer).digest('hex');
78
+ }
79
+
80
+ export function hasFile(rootDir, sha) {
81
+ if (!sha) return false;
82
+ const manifest = loadManifest(rootDir);
83
+ return Boolean(manifest.files[sha]);
84
+ }
85
+
86
+ export function recordFile(rootDir, sha, entry) {
87
+ if (!sha) throw new Error('recordFile: sha is required');
88
+ const manifest = loadManifest(rootDir);
89
+ manifest.files[sha] = {
90
+ sourcePath: entry?.sourcePath ?? null,
91
+ processedAt: entry?.processedAt ?? new Date().toISOString(),
92
+ intakeId: entry?.intakeId ?? null,
93
+ createdBy: entry?.createdBy ?? null,
94
+ createdByAgent: entry?.createdByAgent ?? null,
95
+ };
96
+ saveManifest(rootDir, manifest);
97
+ return manifest.files[sha];
98
+ }
99
+
100
+ export function manifestStats(rootDir) {
101
+ const manifest = loadManifest(rootDir);
102
+ const entries = Object.values(manifest.files);
103
+ return {
104
+ total: entries.length,
105
+ version: manifest.version,
106
+ };
107
+ }