@geraldmaron/construct 1.0.10 → 1.0.11

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  ---
8
8
 
9
- > Heads up. Construct is an open source project I started. I am not a developer. This is a side project. There may be bugs, there may be defects, but I'm building it to learn in public. If you'd like to contribute, please do.
9
+ > Heads up. I'm not a developer. Construct is a side project I'm vibe-coding to learn in public. There will be bugs, rough edges, and things that change without warning. The code is open source, the issues queue is real, and contributions are welcome. If you need production-grade tooling today, this isn't it yet.
10
10
 
11
11
  Construct sits on top of Claude Code, OpenCode, Codex, Cursor, and Copilot. You talk to one persona called `construct`. Behind it is a team of specialists shaped by your **org profile**: software R&D by default, with curated profiles for operations, creative, and research orgs, plus a schema-validated escape hatch for custom profiles. Each profile organizes its specialists by department (Product, Engineering, Operations, etc.) and carries its own intake taxonomy, doc templates, and role set. Sessions survive boundary changes via durable state in `.cx/`, beads, and a local vector index. Solo by default. Can deploy centrally for teams that want shared memory, telemetry, queues, and policy.
12
12
 
package/lib/auto-docs.mjs CHANGED
@@ -69,7 +69,7 @@ function buildCoreDocsContract() {
69
69
  '| `.cx/context.md` | Human-readable resumable project context | Active work, decisions, architecture assumptions, or open questions change |',
70
70
  '| `.cx/context.json` | Machine-readable resumable context | Context state needs to stay in sync with `.cx/context.md` |',
71
71
  '| `docs/README.md` | Docs index and maintenance contract | Core docs set or maintenance expectations change |',
72
- '| `docs/concepts/architecture.md` | Canonical architecture and invariants | Runtime shape, contracts, boundaries, or major dependencies change |',
72
+ '| `docs/concepts/architecture.mdx` | Canonical architecture and invariants | Runtime shape, contracts, boundaries, or major dependencies change |',
73
73
  '',
74
74
  '`plan.md` is a local working document. `construct init` creates it for the active session, but it is gitignored and not committed; durable work belongs in the tracker (Beads or external).',
75
75
  '',
@@ -177,7 +177,7 @@ function buildAgentsTable(rootDir) {
177
177
  // --- public API ---
178
178
 
179
179
  /**
180
- * Regenerate all AUTO regions in README.md, docs/README.md, and docs/concepts/architecture.md.
180
+ * Regenerate all AUTO regions in README.md, docs/README.md, and docs/concepts/architecture.mdx.
181
181
  * Returns { changed: string[], checked: boolean }.
182
182
  * With check:true writes nothing and sets changed to files that would differ.
183
183
  */
@@ -28,22 +28,31 @@ const DEFAULT_ROOT_DIR = join(MODULE_DIR, '..');
28
28
 
29
29
  // Authoritative gate mapping. Each entry declares a CI job name and its
30
30
  // expected local mirror. `critical: true` means this gate MUST be in CI and
31
- // SHOULD be in branch-protection required checks; absence is a hard gap.
31
+ // SHOULD have a local mirror; absence is a hard gap. Branch-protection
32
+ // enforcement is checked via `requireMergeVia`: if set, the audit looks for
33
+ // that aggregator context in required checks instead of the gate's own job
34
+ // name. The `ci-required` aggregator job (ci.yml) wraps every conditional
35
+ // job and reports the combined success/skipped/failure status, which lets
36
+ // path-skipped jobs satisfy branch protection on doc-only PRs.
37
+ //
32
38
  // `localMirror: 'ci-only'` flags gates that legitimately have no local
33
39
  // equivalent (e.g., dockerized integration tests).
34
40
 
41
+ const MERGE_AGGREGATOR = 'ci-required';
42
+
35
43
  const GATE_DEFINITIONS = [
36
- { ciJob: 'test (ubuntu-latest / node 22)', prePushLabel: 'tests', critical: true },
37
- { ciJob: 'test (macos-latest / node 22)', prePushLabel: 'tests', critical: true },
38
- { ciJob: 'test (ubuntu-latest / node 20)', prePushLabel: 'tests', critical: true },
39
- { ciJob: 'test (macos-latest / node 20)', prePushLabel: 'tests', critical: true },
40
- { ciJob: 'retrieval evals', prePushLabel: 'evals', critical: true },
41
- { ciJob: 'dependency CVE audit', prePushLabel: 'audit', critical: true },
44
+ { ciJob: 'test (ubuntu-latest / node 22)', prePushLabel: 'tests', critical: true, requireMergeVia: MERGE_AGGREGATOR },
45
+ { ciJob: 'test (macos-latest / node 22)', prePushLabel: 'tests', critical: true, requireMergeVia: MERGE_AGGREGATOR },
46
+ { ciJob: 'test (ubuntu-latest / node 20)', prePushLabel: 'tests', critical: true, requireMergeVia: MERGE_AGGREGATOR },
47
+ { ciJob: 'test (macos-latest / node 20)', prePushLabel: 'tests', critical: true, requireMergeVia: MERGE_AGGREGATOR },
48
+ { ciJob: 'retrieval evals', prePushLabel: 'evals', critical: true, requireMergeVia: MERGE_AGGREGATOR },
49
+ { ciJob: 'dependency CVE audit', prePushLabel: 'audit', critical: true, requireMergeVia: MERGE_AGGREGATOR },
42
50
  { ciJob: 'secret scanning', prePushLabel: null, preCommitCheck: 'ECC secret scan', critical: true, note: 'pre-commit ECC scan covers a subset of gitleaks rules' },
43
- { ciJob: 'postgres + pgvector integration', prePushLabel: null, preCommitCheck: null, critical: false, note: 'CI-only: requires Docker Postgres + pgvector container; not practical locally', localMirror: 'ci-only' },
44
- { ciJob: 'docs drift check', prePushLabel: 'docs', critical: true },
45
- { ciJob: 'comment policy', prePushLabel: null, preCommitCheck: 'Construct comment-lint', critical: true, note: 'pre-commit Construct policy section calls `lint:comments` across the full worktree' },
46
- { ciJob: 'template policy', prePushLabel: null, preCommitGhPrIntercept: true, critical: true, note: 'pre-push-gate intercepts `gh pr create` / `gh pr edit` and lints the body' },
51
+ { ciJob: 'postgres + pgvector integration', prePushLabel: null, preCommitCheck: null, critical: false, note: 'CI-only: requires Docker Postgres + pgvector container; not practical locally', localMirror: 'ci-only', requireMergeVia: MERGE_AGGREGATOR },
52
+ { ciJob: 'docs drift check', prePushLabel: 'docs', critical: true, requireMergeVia: MERGE_AGGREGATOR },
53
+ { ciJob: 'comment policy', prePushLabel: null, preCommitCheck: 'Construct comment-lint', critical: true, note: 'pre-commit Construct policy section calls `lint:comments` across the full worktree', requireMergeVia: MERGE_AGGREGATOR },
54
+ { ciJob: 'template policy', prePushLabel: null, preCommitGhPrIntercept: true, critical: true, note: 'pre-push-gate intercepts `gh pr create` / `gh pr edit` and lints the body', requireMergeVia: MERGE_AGGREGATOR },
55
+ { ciJob: MERGE_AGGREGATOR, prePushLabel: null, preCommitCheck: null, critical: true, note: 'aggregator: succeeds iff every wrapped conditional job in ci.yml ended in success or skipped', localMirror: 'ci-only' },
47
56
  ];
48
57
 
49
58
  function parseCIJobs(rootDir) {
@@ -51,14 +60,21 @@ function parseCIJobs(rootDir) {
51
60
  // (e.g. the `lint suite` job carrying comment policy + docs drift + gates
52
61
  // audit as steps) surface each step name as a gate signal. The audit's
53
62
  // job-name comparison treats jobs and steps as a flat union so gate
54
- // definitions stay decoupled from CI structure.
55
- const path = join(rootDir, '.github', 'workflows', 'ci.yml');
56
- if (!existsSync(path)) return [];
57
- const yml = readFileSync(path, 'utf8');
58
- const jobNames = [...yml.matchAll(/^ name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
59
- const stepNames = [...yml.matchAll(/^ - name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
60
- return [...jobNames, ...stepNames]
61
- .map((n) => n.replace(/\$\{\{\s*matrix\.(\w+)\s*\}\}/g, (_, k) => `<${k}>`));
63
+ // definitions stay decoupled from CI structure. Reads ci.yml plus any
64
+ // sibling workflow files that emit required-to-merge contexts (currently
65
+ // pr-review.yml for the `review` job).
66
+ const dir = join(rootDir, '.github', 'workflows');
67
+ const files = ['ci.yml', 'pr-review.yml'];
68
+ const names = [];
69
+ for (const file of files) {
70
+ const path = join(dir, file);
71
+ if (!existsSync(path)) continue;
72
+ const yml = readFileSync(path, 'utf8');
73
+ const jobNames = [...yml.matchAll(/^ name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
74
+ const stepNames = [...yml.matchAll(/^ - name:\s*(.+?)\s*$/gm)].map((m) => m[1].trim());
75
+ names.push(...jobNames, ...stepNames);
76
+ }
77
+ return names.map((n) => n.replace(/\$\{\{\s*matrix\.(\w+)\s*\}\}/g, (_, k) => `<${k}>`));
62
78
  }
63
79
 
64
80
  function parsePrePushJobs(rootDir) {
@@ -126,9 +142,15 @@ function identifyGaps(state) {
126
142
  }
127
143
 
128
144
  if (def.critical && protectionVisible) {
129
- const required = branchProtection.requiredContexts.includes(def.ciJob);
145
+ const mergeContext = def.requireMergeVia || def.ciJob;
146
+ const required = branchProtection.requiredContexts.includes(mergeContext);
130
147
  if (!required) {
131
- gaps.push({ kind: 'not-required-to-merge', gate: def.ciJob, critical: true });
148
+ gaps.push({
149
+ kind: 'not-required-to-merge',
150
+ gate: def.ciJob,
151
+ via: mergeContext === def.ciJob ? undefined : mergeContext,
152
+ critical: true,
153
+ });
132
154
  }
133
155
  }
134
156
 
@@ -231,7 +253,8 @@ export function formatReport(report) {
231
253
  const marker = g.critical ? '✗' : '⚠';
232
254
  const detail = g.note ? ` — ${g.note}` : '';
233
255
  const extra = g.kind === 'hooks-unwired' ? ` (expected ${g.expected}, actual ${g.actual})` : '';
234
- lines.push(` ${marker} ${g.kind}: ${g.gate || ''}${extra}${detail}`);
256
+ const via = g.via ? ` (via ${g.via})` : '';
257
+ lines.push(` ${marker} ${g.kind}: ${g.gate || ''}${via}${extra}${detail}`);
235
258
  }
236
259
  }
237
260
  lines.push('');
@@ -0,0 +1,265 @@
1
+ /**
2
+ * lib/init/detect-existing-structure.mjs — Inspect a project for content
3
+ * directories, custom intake surfaces, and root template files so init/sync
4
+ * can defer to what's already there instead of scaffolding parallel trees.
5
+ *
6
+ * Returns a deterministic shape:
7
+ * {
8
+ * existingLanes: { <laneKey>: [{ path, markdownCount }, ...] },
9
+ * customIntake: { ingestScript: string|null, intakePaths: string[] },
10
+ * rootTemplates: { dir: string|null, files: string[] }
11
+ * }
12
+ *
13
+ * Heuristics intentionally err on the side of "skip" — a directory is treated
14
+ * as a real lane only when it carries at least one markdown file. An empty
15
+ * docs/meetings/ that an earlier init scaffolded does NOT register; that lets
16
+ * re-running init be a no-op rather than a contradictory "skip what we just
17
+ * created" message.
18
+ *
19
+ * Issue #97 motivates this: `construct init` on an existing project with
20
+ * `internal/meetings/`, a custom `ingest` script, and root-level `templates/`
21
+ * created `docs/meetings/`, `.cx/inbox/`, and per-lane `templates/` folders
22
+ * that conflicted with the existing workflow.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+
28
+ // Matches the LANE_ALIASES table in lib/init-docs.mjs / lib/init-unified.mjs.
29
+ // Keep in sync if those grow new entries. The detector only needs alias →
30
+ // lane mapping for directory-name matching; the full lane metadata lives in
31
+ // the init modules that consume the result.
32
+
33
+ export const LANE_DIR_ALIASES = {
34
+ adr: 'adrs',
35
+ adrs: 'adrs',
36
+ brief: 'briefs',
37
+ briefs: 'briefs',
38
+ changelog: 'changelogs',
39
+ changelogs: 'changelogs',
40
+ release: 'changelogs',
41
+ releases: 'changelogs',
42
+ intake: 'intake',
43
+ inbox: 'intake',
44
+ memo: 'memos',
45
+ memos: 'memos',
46
+ meeting: 'meetings',
47
+ meetings: 'meetings',
48
+ minutes: 'meetings',
49
+ retro: 'meetings',
50
+ retros: 'meetings',
51
+ note: 'notes',
52
+ notes: 'notes',
53
+ onboard: 'onboarding',
54
+ onboarding: 'onboarding',
55
+ postmortem: 'postmortems',
56
+ postmortems: 'postmortems',
57
+ incident: 'postmortems',
58
+ incidents: 'postmortems',
59
+ prd: 'prds',
60
+ prds: 'prds',
61
+ rfc: 'rfcs',
62
+ rfcs: 'rfcs',
63
+ runbook: 'runbooks',
64
+ runbooks: 'runbooks',
65
+ };
66
+
67
+ // Skip these top-level directories when scanning. Generated, vendored, or
68
+ // already-Construct-managed trees never carry user content lanes.
69
+
70
+ const SCAN_SKIP_DIRS = new Set([
71
+ '.git',
72
+ '.cx',
73
+ '.beads',
74
+ '.construct',
75
+ '.claude',
76
+ '.codex',
77
+ '.cursor',
78
+ '.vscode',
79
+ '.github',
80
+ '.husky',
81
+ 'node_modules',
82
+ 'dist',
83
+ 'build',
84
+ 'coverage',
85
+ 'target',
86
+ '.next',
87
+ '.cache',
88
+ '.pnpm-store',
89
+ '.venv',
90
+ 'venv',
91
+ '__pycache__',
92
+ ]);
93
+
94
+ const MAX_SCAN_DEPTH = 3;
95
+
96
+ // Common custom intake-script names. Project owners who hand-roll an ingest
97
+ // path overwhelmingly name it one of these. Each is checked at repo root.
98
+
99
+ const INTAKE_SCRIPT_CANDIDATES = ['ingest', 'ingest.sh', 'ingest.mjs', 'ingest.js', 'ingest.py'];
100
+
101
+ // Common custom raw-intake directory shapes seen in real projects (issue #97
102
+ // repro had data/customers/notes/raw/). Glob-free literal paths so detection
103
+ // stays cheap and predictable.
104
+
105
+ const INTAKE_PATH_CANDIDATES = [
106
+ 'data/customers/notes/raw',
107
+ 'data/intake',
108
+ 'data/raw',
109
+ 'ingestion',
110
+ 'intake-pipeline',
111
+ 'raw',
112
+ ];
113
+
114
+ function countMarkdownFiles(dir) {
115
+ let count = 0;
116
+ try {
117
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
118
+ for (const entry of entries) {
119
+ if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.mdx'))) count++;
120
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
121
+ count += countMarkdownFiles(path.join(dir, entry.name));
122
+ }
123
+ if (count >= 100) break;
124
+ }
125
+ } catch { /* unreadable dir is treated as empty */ }
126
+ return count;
127
+ }
128
+
129
+ function walkLaneDirs(rootDir, currentDir, depth, accumulator) {
130
+ if (depth > MAX_SCAN_DEPTH) return;
131
+ let entries = [];
132
+ try { entries = fs.readdirSync(currentDir, { withFileTypes: true }); }
133
+ catch { return; }
134
+ for (const entry of entries) {
135
+ if (!entry.isDirectory()) continue;
136
+ if (SCAN_SKIP_DIRS.has(entry.name)) continue;
137
+ const absPath = path.join(currentDir, entry.name);
138
+ const relPath = path.relative(rootDir, absPath);
139
+ const lower = entry.name.toLowerCase();
140
+ const laneKey = LANE_DIR_ALIASES[lower];
141
+ if (laneKey) {
142
+ // The newly-scaffolded docs/<lane>/ tree is what init writes, so it
143
+ // doesn't count as "existing" content. Other locations do.
144
+ const isOwnDocsTree = relPath === path.join('docs', LANE_DIR_ALIASES[lower]) || relPath === path.join('docs', lower);
145
+ if (!isOwnDocsTree) {
146
+ const markdownCount = countMarkdownFiles(absPath);
147
+ if (markdownCount > 0) {
148
+ if (!accumulator[laneKey]) accumulator[laneKey] = [];
149
+ accumulator[laneKey].push({ path: relPath, markdownCount });
150
+ }
151
+ }
152
+ }
153
+ walkLaneDirs(rootDir, absPath, depth + 1, accumulator);
154
+ }
155
+ }
156
+
157
+ function detectIntakeScript(rootDir) {
158
+ for (const candidate of INTAKE_SCRIPT_CANDIDATES) {
159
+ const candidatePath = path.join(rootDir, candidate);
160
+ if (!fs.existsSync(candidatePath)) continue;
161
+ try {
162
+ const stat = fs.statSync(candidatePath);
163
+ if (stat.isFile()) return candidate;
164
+ } catch { /* race with deletion — treat as absent */ }
165
+ }
166
+ return null;
167
+ }
168
+
169
+ function detectIntakePaths(rootDir) {
170
+ const found = [];
171
+ for (const rel of INTAKE_PATH_CANDIDATES) {
172
+ const abs = path.join(rootDir, rel);
173
+ if (fs.existsSync(abs)) found.push(rel);
174
+ }
175
+ return found;
176
+ }
177
+
178
+ function detectRootTemplates(rootDir) {
179
+ const templatesDir = path.join(rootDir, 'templates');
180
+ if (!fs.existsSync(templatesDir)) return { dir: null, files: [] };
181
+ try {
182
+ const entries = fs.readdirSync(templatesDir, { withFileTypes: true });
183
+ const files = entries
184
+ .filter((e) => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')))
185
+ .map((e) => e.name);
186
+ return { dir: 'templates', files };
187
+ } catch {
188
+ return { dir: null, files: [] };
189
+ }
190
+ }
191
+
192
+ export function detectExistingContent(rootDir) {
193
+ const existingLanes = {};
194
+ walkLaneDirs(rootDir, rootDir, 0, existingLanes);
195
+ return {
196
+ existingLanes,
197
+ customIntake: {
198
+ ingestScript: detectIntakeScript(rootDir),
199
+ intakePaths: detectIntakePaths(rootDir),
200
+ },
201
+ rootTemplates: detectRootTemplates(rootDir),
202
+ };
203
+ }
204
+
205
+ // Convenience helper: pretty-print the detection result for init's
206
+ // "Skipped (deferred to existing project structure)" summary block.
207
+
208
+ export function formatDeferralSummary(detection) {
209
+ const lines = [];
210
+ for (const [lane, matches] of Object.entries(detection.existingLanes)) {
211
+ const top = matches[0];
212
+ const extra = matches.length > 1 ? ` (+${matches.length - 1} more)` : '';
213
+ lines.push(` • lane "${lane}": found existing ${top.path}/ (${top.markdownCount} md file${top.markdownCount === 1 ? '' : 's'})${extra}`);
214
+ }
215
+ if (detection.customIntake.ingestScript) {
216
+ lines.push(` • intake: custom script ./${detection.customIntake.ingestScript} detected`);
217
+ }
218
+ if (detection.customIntake.intakePaths.length) {
219
+ lines.push(` • intake: custom path${detection.customIntake.intakePaths.length === 1 ? '' : 's'} ${detection.customIntake.intakePaths.join(', ')} detected`);
220
+ }
221
+ if (detection.rootTemplates.dir && detection.rootTemplates.files.length) {
222
+ lines.push(` • templates: root ./${detection.rootTemplates.dir}/ has ${detection.rootTemplates.files.length} template file${detection.rootTemplates.files.length === 1 ? '' : 's'}`);
223
+ }
224
+ return lines.join('\n');
225
+ }
226
+
227
+ // Maps a lane key to the root-template file base name init would otherwise
228
+ // copy into docs/<lane>/templates/. Used so callers can ask "is the root
229
+ // templates/ already covering this lane?" without re-deriving the mapping.
230
+
231
+ export function rootTemplateCoversLane(detection, laneKey) {
232
+ if (!detection.rootTemplates.dir) return false;
233
+ const wanted = new Set([`${laneKey}.md`, `${laneKey.replace(/s$/, '')}.md`, `_template.md`, `template.md`]);
234
+ return detection.rootTemplates.files.some((f) => wanted.has(f.toLowerCase()));
235
+ }
236
+
237
+ // Single decision function so both init entry points (init-unified.mjs and
238
+ // init-docs.mjs) skip the same lanes for the same reasons. force=true makes
239
+ // every decision a pass-through so power users can scaffold over an existing
240
+ // layout when they really want a parallel docs/ tree.
241
+
242
+ export function shouldScaffoldLane(laneKey, detection, { force = false } = {}) {
243
+ if (force) return { skip: false };
244
+ const matches = detection.existingLanes?.[laneKey];
245
+ if (matches && matches.length > 0) {
246
+ const top = matches[0];
247
+ return {
248
+ skip: true,
249
+ reason: `existing ${top.path}/ has ${top.markdownCount} markdown file${top.markdownCount === 1 ? '' : 's'}`,
250
+ };
251
+ }
252
+ return { skip: false };
253
+ }
254
+
255
+ export function shouldSkipProjectInbox(detection, { force = false } = {}) {
256
+ if (force) return { skip: false };
257
+ const { ingestScript, intakePaths } = detection.customIntake || {};
258
+ if (ingestScript) {
259
+ return { skip: true, reason: `custom intake script ./${ingestScript} detected` };
260
+ }
261
+ if (intakePaths && intakePaths.length > 0) {
262
+ return { skip: true, reason: `custom intake path${intakePaths.length === 1 ? '' : 's'} ${intakePaths.join(', ')} detected` };
263
+ }
264
+ return { skip: false };
265
+ }
package/lib/init-docs.mjs CHANGED
@@ -38,6 +38,9 @@ const extrasArg = args.find((arg) => arg.startsWith("--extras="));
38
38
  const withArchitectureFlag = args.includes("--with-architecture");
39
39
  const suggestOrg = args.includes("--suggest-org");
40
40
  const organize = args.includes("--organize");
41
+ // --force bypasses the existing-content detector so lanes already covered by
42
+ // the project (e.g. internal/meetings/) still get a parallel docs/<lane>/.
43
+ const forceScaffold = args.includes("--force");
41
44
  const targetArg = args.find((arg) => !arg.startsWith("--"));
42
45
  const target = path.resolve(targetArg ?? process.cwd());
43
46
 
@@ -750,17 +753,57 @@ async function main() {
750
753
 
751
754
  process.stdout.write(`\nConstruct init-docs → ${target}\n\n`);
752
755
 
753
- writeIfMissing(path.join(docsDir, "README.md"), buildDocsReadme(projectName, allLaneKeys));
754
- if (withArchitecture) {
755
- writeIfMissing(path.join(docsDir, "architecture.md"), buildArchitectureDoc(projectName, allLaneKeys));
756
- }
757
- if (selectedLanes.includes('intake')) {
758
- writeIfMissing(path.join(target, '.cx', 'inbox', '.gitkeep'), '');
756
+ // Inspect existing project content once and filter out lanes the project
757
+ // already covers (issue #97). --force bypasses every check.
758
+
759
+ const { detectExistingContent, shouldScaffoldLane, shouldSkipProjectInbox, formatDeferralSummary } =
760
+ await import('./init/detect-existing-structure.mjs');
761
+ const detection = detectExistingContent(target);
762
+
763
+ const deferredLanes = [];
764
+ const lanesToScaffold = [];
765
+ for (const laneKey of selectedLanes) {
766
+ const decision = shouldScaffoldLane(laneKey, detection, { force: forceScaffold });
767
+ if (decision.skip) {
768
+ deferredLanes.push({ laneKey, reason: decision.reason });
769
+ } else {
770
+ lanesToScaffold.push(laneKey);
771
+ }
772
+ }
773
+ const allScaffoldedLaneKeys = sortLaneKeys([...lanesToScaffold, ...selectedCustomLanes]);
774
+
775
+ if (lanesToScaffold.length > 0 || selectedCustomLanes.length > 0) {
776
+ writeIfMissing(path.join(docsDir, "README.md"), buildDocsReadme(projectName, allScaffoldedLaneKeys));
777
+ }
778
+ if (withArchitecture) {
779
+ writeIfMissing(path.join(docsDir, "architecture.md"), buildArchitectureDoc(projectName, allScaffoldedLaneKeys));
780
+ }
781
+ if (lanesToScaffold.includes('intake')) {
782
+ const inboxDecision = shouldSkipProjectInbox(detection, { force: forceScaffold });
783
+ if (inboxDecision.skip) {
784
+ process.stdout.write(`[init:docs] skipping .cx/inbox/ — ${inboxDecision.reason}. Run with --force to scaffold anyway.\n`);
785
+ skipped.push('.cx/inbox/ (deferred to existing intake)');
786
+ } else {
787
+ writeIfMissing(path.join(target, '.cx', 'inbox', '.gitkeep'), '');
788
+ }
789
+ }
790
+
791
+ for (const { laneKey, reason } of deferredLanes) {
792
+ process.stdout.write(`[init:docs] skipping docs/${DOC_LANES[laneKey].dir}/ — ${reason}. Run with --force to scaffold anyway.\n`);
793
+ skipped.push(`docs/${DOC_LANES[laneKey].dir}/ (deferred to existing project structure)`);
759
794
  }
760
795
 
761
- for (const laneKey of selectedLanes) copyLaneTemplates(laneKey);
796
+ for (const laneKey of lanesToScaffold) copyLaneTemplates(laneKey);
762
797
  for (const laneKey of selectedCustomLanes) createCustomLane(laneKey);
763
798
 
799
+ if (!forceScaffold) {
800
+ const summary = formatDeferralSummary(detection);
801
+ if (summary) {
802
+ process.stdout.write('\nDeferred to existing project structure (use --force to scaffold anyway):\n');
803
+ process.stdout.write(`${summary}\n`);
804
+ }
805
+ }
806
+
764
807
  // Handle suggestion and organization of existing markdown files
765
808
  if (suggestOrg || organize) {
766
809
  if (organize && !skipInteractive) {
@@ -48,6 +48,10 @@ const verbose = args.includes("--verbose") || args.includes("-v");
48
48
  const interactive = args.includes("--interactive") || args.includes("-i");
49
49
  const quiet = args.includes("--quiet") || args.includes("-q");
50
50
  const skipInteractive = !interactive;
51
+ // --force bypasses the existing-content detector so init writes its full
52
+ // scaffold even when populated lane dirs / a custom intake script / a root
53
+ // templates/ already exist (issue #97).
54
+ const forceScaffold = args.includes("--force");
51
55
 
52
56
  // Active profile selector. `--profile=<id>` writes the field into the
53
57
  // project's construct.config.json so resolveActiveProfile picks it up
@@ -860,14 +864,29 @@ async function main() {
860
864
  generator: "construct/init",
861
865
  });
862
866
 
863
- writeStampedIfMissing({
864
- targetRoot: target,
865
- created,
866
- skipped,
867
- filePath: path.join(target, ".cx", "inbox", ".gitkeep"),
868
- content: "",
869
- generator: "construct/init",
870
- });
867
+ // Detect existing project content once; the result feeds three decisions
868
+ // below: skip .cx/inbox/ on custom intake, skip lane scaffolding for lanes
869
+ // already covered elsewhere, and skip per-lane templates/ when root
870
+ // templates/ already has them. --force bypasses every check.
871
+
872
+ const { detectExistingContent, shouldSkipProjectInbox, shouldScaffoldLane, formatDeferralSummary } =
873
+ await import('./init/detect-existing-structure.mjs');
874
+ const detection = detectExistingContent(target);
875
+ const inboxDecision = shouldSkipProjectInbox(detection, { force: forceScaffold });
876
+
877
+ if (inboxDecision.skip) {
878
+ console.log(`[init:intake] skipping .cx/inbox/ — ${inboxDecision.reason}. Run with --force to scaffold anyway.`);
879
+ skipped.push('.cx/inbox/ (deferred to existing intake)');
880
+ } else {
881
+ writeStampedIfMissing({
882
+ targetRoot: target,
883
+ created,
884
+ skipped,
885
+ filePath: path.join(target, ".cx", "inbox", ".gitkeep"),
886
+ content: "",
887
+ generator: "construct/init",
888
+ });
889
+ }
871
890
 
872
891
  // Stage .construct/ launcher + sync .claude/ adapters so init produces the
873
892
  // same project shape as a fresh `npm install` of the package as a dep.
@@ -963,6 +982,11 @@ async function main() {
963
982
  console.log('[TRACE init:intake-ask]');
964
983
 
965
984
  const intakeConfig = (await askIntakeCollection(target, skipInteractive)) ?? { parentDirs: [], maxDepth: 4 };
985
+ // When a custom intake surface was detected, default includeProjectInbox
986
+ // to false so the watcher does not double-claim with the user's existing
987
+ // pipeline. User can opt in later via `construct config intake.includeProjectInbox=true`.
988
+
989
+ if (inboxDecision.skip) intakeConfig.includeProjectInbox = false;
966
990
  const { saveIntakeConfig } = await import('./intake/intake-config.mjs');
967
991
  try {
968
992
  saveIntakeConfig(target, intakeConfig);
@@ -986,17 +1010,36 @@ async function main() {
986
1010
 
987
1011
  // Create documentation system if lanes specified
988
1012
  if (lanes.length > 0) {
989
- // Create docs/README.md
990
- writeIfMissing(
991
- path.join(target, "docs", "README.md"),
992
- buildDocsReadme(projectName)
993
- );
994
-
995
- // Create selected lanes
1013
+ // Filter out lanes that the project already covers elsewhere
1014
+ // (issue #97: don't create docs/meetings/ when internal/meetings/
1015
+ // has 12 markdown files). --force bypasses the filter.
1016
+
1017
+ const deferredLanes = [];
1018
+ const lanesToScaffold = [];
996
1019
  for (const laneKey of lanes) {
997
- copyLaneTemplates(laneKey);
1020
+ const decision = shouldScaffoldLane(laneKey, detection, { force: forceScaffold });
1021
+ if (decision.skip) {
1022
+ deferredLanes.push({ laneKey, reason: decision.reason });
1023
+ } else {
1024
+ lanesToScaffold.push(laneKey);
1025
+ }
998
1026
  }
999
-
1027
+ for (const { laneKey, reason } of deferredLanes) {
1028
+ console.log(`[init:docs] skipping docs/${DOC_LANES[laneKey].dir}/ — ${reason}. Run with --force to scaffold anyway.`);
1029
+ skipped.push(`docs/${DOC_LANES[laneKey].dir}/ (deferred to existing project structure)`);
1030
+ }
1031
+
1032
+ if (lanesToScaffold.length > 0) {
1033
+ writeIfMissing(
1034
+ path.join(target, "docs", "README.md"),
1035
+ buildDocsReadme(projectName)
1036
+ );
1037
+
1038
+ for (const laneKey of lanesToScaffold) {
1039
+ copyLaneTemplates(laneKey);
1040
+ }
1041
+ }
1042
+
1000
1043
  // Create architecture.md if requested
1001
1044
  if (withArchitecture) {
1002
1045
  writeIfMissing(
@@ -1005,6 +1048,19 @@ async function main() {
1005
1048
  );
1006
1049
  }
1007
1050
  }
1051
+
1052
+ // End-of-init summary block for what got deferred to existing project
1053
+ // structure. Mirrors the "Created:" section so users see WHY their docs/
1054
+ // tree is leaner than the default scaffold.
1055
+
1056
+ if (!forceScaffold) {
1057
+ const summary = formatDeferralSummary(detection);
1058
+ if (summary && !quiet) {
1059
+ console.log('');
1060
+ console.log('Deferred to existing project structure (use --force to scaffold anyway):');
1061
+ console.log(summary);
1062
+ }
1063
+ }
1008
1064
 
1009
1065
  // Output results (respect quiet mode)
1010
1066
  if (!quiet) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "type": "module",
5
5
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
6
6
  "bin": {