@geraldmaron/construct 1.0.10 โ 1.0.12
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 +48 -17
- package/lib/auto-docs.mjs +15 -18
- package/lib/cli-commands.mjs +2 -0
- package/lib/gates-audit.mjs +45 -22
- package/lib/hooks/registry-sync.mjs +1 -1
- package/lib/init/detect-existing-structure.mjs +265 -0
- package/lib/init-docs.mjs +50 -7
- package/lib/init-unified.mjs +73 -17
- package/lib/sync/skill-frontmatter.mjs +71 -0
- package/package.json +1 -1
- package/scripts/sync-specialists.mjs +29 -21
package/README.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
**One AI interface. A team of specialists behind it. Hard gates. Runs locally, or deploys for teams.**
|
|
4
4
|
|
|
5
|
-
๐ **[Read the docs โ](https://geraldmaron.github.io/construct/)** ยท ๐ **[5-minute quickstart โ](https://geraldmaron.github.io/construct/
|
|
5
|
+
๐ **[Read the docs โ](https://geraldmaron.github.io/construct/)** ยท ๐ **[5-minute quickstart โ](https://geraldmaron.github.io/construct/start)** ยท ๐ฆ `npm install -g @geraldmaron/construct`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
> Heads up.
|
|
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
|
|
|
13
|
-
`construct profile show|list|set <id>` to switch. See [Profile lifecycle](https://geraldmaron.github.io/construct/
|
|
13
|
+
`construct profile show|list|set <id>` to switch. See [Profile lifecycle](https://geraldmaron.github.io/construct/concepts/profile-lifecycle) for how new profiles are built (it's a research process, not a JSON exercise).
|
|
14
14
|
|
|
15
15
|
The team and enterprise modes exist because I wanted to learn what shipping a real multi-tenant tool would look like. The project is still open source, the code is still public, and the bar is still "does this help me learn." Run it solo if that's all you need.
|
|
16
16
|
|
|
@@ -39,20 +39,33 @@ Open your editor and talk to `@construct`. A walkthrough lives in `construct_gui
|
|
|
39
39
|
|
|
40
40
|
No Node? Try `brew install geraldmaron/construct/construct`. Cloning a project that already uses Construct? `npx -y @geraldmaron/construct init` wires it up.
|
|
41
41
|
|
|
42
|
-
[Five minute walkthrough](https://geraldmaron.github.io/construct/
|
|
42
|
+
[Five minute walkthrough](https://geraldmaron.github.io/construct/start).
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Most days, the loop is:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
construct status # confirm services and editor adapters are healthy
|
|
50
|
+
construct sync # refresh host adapters after registry, prompt, or config changes
|
|
51
|
+
construct intake list # review new signals, if your project uses the inbox
|
|
52
|
+
construct doctor # diagnose install, service, MCP, and adapter drift
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
In your editor, start with `@construct`. Ask for the outcome, not the specialist. Construct routes to the right specialist chain, keeps durable state in `.cx/` and Beads, and blocks risky mutations until the configured gates pass.
|
|
43
56
|
|
|
44
57
|
## What you can do
|
|
45
58
|
|
|
46
59
|
| If you want to... | Read |
|
|
47
60
|
|---|---|
|
|
48
|
-
| Install and run a first task | [Start](https://geraldmaron.github.io/construct/
|
|
49
|
-
| Understand how it works | [Architecture](https://geraldmaron.github.io/construct/
|
|
50
|
-
| Pick a deployment mode | [Deployment model](https://geraldmaron.github.io/construct/
|
|
51
|
-
| Drop a signal and triage it | [Intake and triage](https://geraldmaron.github.io/construct/
|
|
52
|
-
| Add a custom specialist | [Add a custom
|
|
53
|
-
| Fix a blocked commit or red CI | [Fix a policy violation](https://geraldmaron.github.io/construct/
|
|
54
|
-
| Plug in your own LLM | [Plug in your own LLM](https://geraldmaron.github.io/construct/
|
|
55
|
-
| Look up a CLI command | [CLI reference](https://geraldmaron.github.io/construct/
|
|
61
|
+
| Install and run a first task | [Start](https://geraldmaron.github.io/construct/start) |
|
|
62
|
+
| Understand how it works | [Architecture](https://geraldmaron.github.io/construct/concepts/architecture) |
|
|
63
|
+
| Pick a deployment mode | [Deployment model](https://geraldmaron.github.io/construct/concepts/deployment-model) |
|
|
64
|
+
| Drop a signal and triage it | [Intake and triage](https://geraldmaron.github.io/construct/concepts/intake-and-triage) |
|
|
65
|
+
| Add a custom specialist | [Add a custom specialist](https://geraldmaron.github.io/construct/cookbook/add-a-custom-agent) |
|
|
66
|
+
| Fix a blocked commit or red CI | [Fix a policy violation](https://geraldmaron.github.io/construct/cookbook/fix-a-policy-violation) |
|
|
67
|
+
| Plug in your own LLM | [Plug in your own LLM](https://geraldmaron.github.io/construct/cookbook/plug-in-your-own-llm) |
|
|
68
|
+
| Look up a CLI command | [CLI reference](https://geraldmaron.github.io/construct/reference/cli) |
|
|
56
69
|
|
|
57
70
|
Works with Anthropic, OpenRouter, Ollama, and other OpenAI-compatible providers.
|
|
58
71
|
|
|
@@ -64,21 +77,21 @@ Three modes. `solo` is the default and runs everything locally. Filesystem queue
|
|
|
64
77
|
|
|
65
78
|
`enterprise` adds tenant isolation, RBAC and ABAC scaffolding, isolated worker containers, signed MCP allowlists, and mandatory audit.
|
|
66
79
|
|
|
67
|
-
Pick or change modes with `construct config mode [solo|team|enterprise]`. [Deployment model](https://geraldmaron.github.io/construct/
|
|
80
|
+
Pick or change modes with `construct config mode [solo|team|enterprise]`. [Deployment model](https://geraldmaron.github.io/construct/concepts/deployment-model).
|
|
68
81
|
|
|
69
82
|
## Intake
|
|
70
83
|
|
|
71
84
|
Anything dropped into `.cx/inbox/` (a bug report, a customer comment, a competitor PDF, a postmortem draft) is classified by the active profile's intake taxonomy. The default `rnd` profile uses bug, user-signal, experiment, architecture, incident, security, requirement, research, ops, eval-finding, launch-asset, legal-compliance. The `operations` profile uses request, incident, ops, security, docs. The `creative` profile uses brief, content-request, asset, experiment, report. The `research` profile uses question, study, synthesis, report.
|
|
72
85
|
|
|
73
|
-
Each signal gets a primary owner and a recommended handoff chain. Inspect with `construct intake list` and `construct intake show <id>`. Generate a task graph with `construct graph from-intake <id>`. The classifier runs in the daemon and is deterministic. The agent in your editor does the actual analysis. [Intake and triage](https://geraldmaron.github.io/construct/
|
|
86
|
+
Each signal gets a primary owner and a recommended handoff chain. Inspect with `construct intake list` and `construct intake show <id>`. Generate a task graph with `construct graph from-intake <id>`. The classifier runs in the daemon and is deterministic. The agent in your editor does the actual analysis. [Intake and triage](https://geraldmaron.github.io/construct/concepts/intake-and-triage).
|
|
74
87
|
|
|
75
88
|
## Hard gates
|
|
76
89
|
|
|
77
|
-
Every code mutation runs through enforcement. No secrets committed, tests green, docs current, comments lint-clean, CI passes. Gates live in three places: write-time, commit-time, CI safety net. They can only be bypassed with explicit env vars so every exception leaves an audit trail. [Gates and enforcement](https://geraldmaron.github.io/construct/
|
|
90
|
+
Every code mutation runs through enforcement. No secrets committed, tests green, docs current, comments lint-clean, CI passes. Gates live in three places: write-time, commit-time, CI safety net. They can only be bypassed with explicit env vars so every exception leaves an audit trail. [Gates and enforcement](https://geraldmaron.github.io/construct/concepts/gates-and-enforcement).
|
|
78
91
|
|
|
79
92
|
## Learning loops
|
|
80
93
|
|
|
81
|
-
Construct gets smarter on its own. Every session ends with an automatic capture: tools used, files touched, what the final reply said. That goes into `.cx/observations/` and is searchable from the next session. See [`docs/concepts/learning-loops.
|
|
94
|
+
Construct gets smarter on its own. Every session ends with an automatic capture: tools used, files touched, what the final reply said. That goes into `.cx/observations/` and is searchable from the next session. See [`docs/concepts/learning-loops.mdx`](./docs/concepts/learning-loops.mdx) for what's wired, what's coming, and how to turn pieces off.
|
|
82
95
|
|
|
83
96
|
## Core commands
|
|
84
97
|
|
|
@@ -124,6 +137,16 @@ Construct gets smarter on its own. Every session ends with an automatic capture:
|
|
|
124
137
|
| `construct workflow` | Instantiate workflow templates (PRD-to-review chains, onboarding, handoffs) |
|
|
125
138
|
| `construct workspace` | Manage PM workspaces for multi-PM signal routing |
|
|
126
139
|
|
|
140
|
+
### Models & Integrations
|
|
141
|
+
|
|
142
|
+
| Command | What it does |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `construct claude:allow` | Manage Claude Code `permissions.allow` from the outside (auto-classifier blocks the agent from editing it) |
|
|
145
|
+
| `construct hosts` | Show host support for Construct orchestration |
|
|
146
|
+
| `construct mcp` | Manage MCP integrations |
|
|
147
|
+
| `construct models` | Show or update model tier assignments |
|
|
148
|
+
| `construct plugin` | Manage external Construct plugin manifests |
|
|
149
|
+
|
|
127
150
|
### Integrations
|
|
128
151
|
|
|
129
152
|
| Command | What it does |
|
|
@@ -145,6 +168,14 @@ Construct gets smarter on its own. Every session ends with an automatic capture:
|
|
|
145
168
|
| `construct telemetry` | Query telemetry traces and latency data |
|
|
146
169
|
| `construct telemetry-backfill` | Backfill sparse traces with observations (trace backend) |
|
|
147
170
|
|
|
171
|
+
### Diagnostics
|
|
172
|
+
|
|
173
|
+
| Command | What it does |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `construct audit` | Audit Construct internals and review the mutation trail |
|
|
176
|
+
| `construct cleanup` | Release dev-agent memory pressure by cleaning stale helper and bridge processes |
|
|
177
|
+
| `construct doc` | Verify or inspect auditability stamps on Construct-generated markdown files |
|
|
178
|
+
|
|
148
179
|
### Advanced
|
|
149
180
|
|
|
150
181
|
| Command | What it does |
|
|
@@ -176,7 +207,7 @@ Construct gets smarter on its own. Every session ends with an automatic capture:
|
|
|
176
207
|
|
|
177
208
|
- [`CONTRIBUTING.md`](./CONTRIBUTING.md). Branch workflow, gates, review expectations.
|
|
178
209
|
- [`CHANGELOG.md`](./CHANGELOG.md). Release history.
|
|
179
|
-
- [`docs/concepts/architecture.
|
|
210
|
+
- [`docs/concepts/architecture.mdx`](./docs/concepts/architecture.mdx). Canonical architecture.
|
|
180
211
|
- [`AGENTS.md`](./AGENTS.md). Agent operating contract.
|
|
181
212
|
|
|
182
213
|
## Project structure
|
package/lib/auto-docs.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/auto-docs.mjs โ regenerate managed regions in markdown docs and
|
|
2
|
+
* lib/auto-docs.mjs โ regenerate managed regions in markdown docs and generated docs-site references.
|
|
3
3
|
*
|
|
4
4
|
* Managed regions are HTML comment markers in the form:
|
|
5
5
|
* <!-- AUTO:region-name -->
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* Running regenerateDocs() is idempotent. With check:true it returns whether
|
|
10
10
|
* anything would change without writing files โ the mode CI uses to detect drift.
|
|
11
11
|
*
|
|
12
|
-
* buildSite()
|
|
13
|
-
*
|
|
12
|
+
* buildSite() is retained for compatibility; the public site now renders docs/
|
|
13
|
+
* directly through Fumadocs and buildFumadocsReference() emits generated pages.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import fs from 'node:fs';
|
|
@@ -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.
|
|
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
|
'',
|
|
@@ -93,7 +93,7 @@ const DIR_DESCRIPTIONS = {
|
|
|
93
93
|
opencode: 'OpenCode integration config',
|
|
94
94
|
personas: 'Persona prompt definitions',
|
|
95
95
|
rules: 'Coding and quality standards',
|
|
96
|
-
site: '
|
|
96
|
+
site: 'Legacy docs-site output',
|
|
97
97
|
skills: 'Reusable domain knowledge files',
|
|
98
98
|
tests: 'Test suite',
|
|
99
99
|
};
|
|
@@ -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.
|
|
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
|
*/
|
|
@@ -237,12 +237,12 @@ export async function regenerateDocs({ rootDir, check = false } = {}) {
|
|
|
237
237
|
* Check CLI command coverage against how-to links in docs/README.md.
|
|
238
238
|
*
|
|
239
239
|
* Returns an object with:
|
|
240
|
-
* - covered: string[] commands that have a linked
|
|
241
|
-
* - uncovered: string[] commands with no
|
|
240
|
+
* - covered: string[] commands that have a linked guide
|
|
241
|
+
* - uncovered: string[] commands with no linked guide in docs/README.md
|
|
242
242
|
* - total: number
|
|
243
243
|
*
|
|
244
|
-
* A command is considered covered if its name appears in
|
|
245
|
-
*
|
|
244
|
+
* A command is considered covered if its name appears in docs/README.md or in
|
|
245
|
+
* a linked cookbook/how-to guide.
|
|
246
246
|
*/
|
|
247
247
|
export function checkDocsCoverage({ rootDir } = {}) {
|
|
248
248
|
rootDir = rootDir ?? process.cwd();
|
|
@@ -251,11 +251,10 @@ export function checkDocsCoverage({ rootDir } = {}) {
|
|
|
251
251
|
// Extract all href targets from markdown links in docs/README.md
|
|
252
252
|
const linkTargets = [...docsReadme.matchAll(/\[.*?\]\((.*?)\)/g)].map(m => m[1]);
|
|
253
253
|
|
|
254
|
-
// Build a combined corpus: docs/README.md + every linked
|
|
255
|
-
const howToDir = path.join(rootDir, 'docs', 'how-to');
|
|
254
|
+
// Build a combined corpus: docs/README.md + every linked guide file
|
|
256
255
|
let corpus = docsReadme;
|
|
257
256
|
for (const target of linkTargets) {
|
|
258
|
-
if (!target.startsWith('./how-to/')) continue;
|
|
257
|
+
if (!target.startsWith('./how-to/') && !target.startsWith('./cookbook/')) continue;
|
|
259
258
|
const filePath = path.join(rootDir, 'docs', target.replace(/^\.\//, ''));
|
|
260
259
|
const content = readFile(filePath);
|
|
261
260
|
if (content) corpus += '\n' + content;
|
|
@@ -307,15 +306,13 @@ export function checkDocsCoverage({ rootDir } = {}) {
|
|
|
307
306
|
// Categories surfaced in the Fumadocs reference. Internal commands (e.g.,
|
|
308
307
|
// `construct hook <name>`) are not user-facing reference material.
|
|
309
308
|
const REFERENCE_CATEGORIES = [
|
|
310
|
-
'
|
|
311
|
-
'Agents & Sync',
|
|
309
|
+
'Core',
|
|
312
310
|
'Work',
|
|
313
|
-
'Embed',
|
|
314
311
|
'Models & Integrations',
|
|
315
|
-
'
|
|
312
|
+
'Integrations',
|
|
316
313
|
'Observability',
|
|
317
|
-
'Docs',
|
|
318
314
|
'Diagnostics',
|
|
315
|
+
'Advanced',
|
|
319
316
|
];
|
|
320
317
|
|
|
321
318
|
function slugify(category) {
|
package/lib/cli-commands.mjs
CHANGED
package/lib/gates-audit.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
145
|
+
const mergeContext = def.requireMergeVia || def.ciJob;
|
|
146
|
+
const required = branchProtection.requiredContexts.includes(mergeContext);
|
|
130
147
|
if (!required) {
|
|
131
|
-
gaps.push({
|
|
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
|
-
|
|
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('');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* lib/hooks/registry-sync.mjs โ Registry sync hook โ reminds to run construct sync after registry changes.
|
|
4
4
|
*
|
|
5
|
-
* Runs as PostToolUse after edits to
|
|
5
|
+
* Runs as PostToolUse after edits to specialists/registry.json. Checks if the file was modified and emits a reminder to run construct sync to regenerate platform adapters.
|
|
6
6
|
*
|
|
7
7
|
* @p95ms 12000
|
|
8
8
|
* @maxBlockingScope none (PostToolUse, non-blocking)
|
|
@@ -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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
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) {
|
package/lib/init-unified.mjs
CHANGED
|
@@ -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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
//
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/sync/skill-frontmatter.mjs โ Build Anthropic Agent Skills frontmatter
|
|
3
|
+
* for SKILL.md files emitted by sync-specialists.
|
|
4
|
+
*
|
|
5
|
+
* Anthropic Skills require YAML frontmatter with at minimum `name` and
|
|
6
|
+
* `description`. The description gates skill selection โ without it, the
|
|
7
|
+
* loader silently skips the file (the user-reported bug: 141 files dropped).
|
|
8
|
+
* Source Construct skills carry an HTML comment header with the description;
|
|
9
|
+
* extractSkillDescription pulls it out.
|
|
10
|
+
*
|
|
11
|
+
* Also exports a conservative stripLeadingFrontmatter helper so we never
|
|
12
|
+
* emit double-frontmatter when the source body already has its own block.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const HTML_COMMENT_RE = /^<!--([\s\S]*?)-->/;
|
|
16
|
+
// Matches the source skill comment header shape: `skills/path/foo.md (Title)
|
|
17
|
+
// Use when X.\n...rest of comment...`. Group 1 captures the optional title,
|
|
18
|
+
// group 2 captures all subsequent prose inside the comment. No /m flag โ `$`
|
|
19
|
+
// matches end of input, not end of line, so multi-line "Use when..." prose
|
|
20
|
+
// is captured in full and firstSentence() picks the first terminator.
|
|
21
|
+
|
|
22
|
+
const SOURCE_HEADER_RE = /^skills\/[\w./-]+\s*(?:\(([^)]+)\))?\s*([\s\S]*?)$/;
|
|
23
|
+
|
|
24
|
+
// Anthropic Skills cap descriptions at 1024 chars; this cap is tighter so
|
|
25
|
+
// the YAML stays readable in `construct skills list` output too.
|
|
26
|
+
const DESCRIPTION_MAX = 240;
|
|
27
|
+
|
|
28
|
+
export function buildSkillFrontmatter(name, sourceContent) {
|
|
29
|
+
const skillName = String(name).replace(/\//g, '.');
|
|
30
|
+
const description = extractSkillDescription(sourceContent) || `Construct skill: ${skillName}`;
|
|
31
|
+
const safeDescription = description.replace(/\n+/g, ' ').slice(0, DESCRIPTION_MAX).replace(/"/g, "'");
|
|
32
|
+
return `---\nname: ${skillName}\ndescription: "${safeDescription}"\n---\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extractSkillDescription(content) {
|
|
36
|
+
if (!content) return null;
|
|
37
|
+
const commentMatch = content.match(HTML_COMMENT_RE);
|
|
38
|
+
if (commentMatch) {
|
|
39
|
+
const inner = commentMatch[1].trim();
|
|
40
|
+
const headerMatch = inner.match(SOURCE_HEADER_RE);
|
|
41
|
+
if (headerMatch) {
|
|
42
|
+
const prose = (headerMatch[2] || '').trim();
|
|
43
|
+
if (prose) return firstSentence(prose);
|
|
44
|
+
}
|
|
45
|
+
const lines = inner.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
46
|
+
if (lines.length >= 2) return firstSentence(lines.slice(1).join(' '));
|
|
47
|
+
}
|
|
48
|
+
const body = content.replace(HTML_COMMENT_RE, '').trim();
|
|
49
|
+
const paragraphs = body.split(/\n\s*\n/);
|
|
50
|
+
for (const p of paragraphs) {
|
|
51
|
+
const trimmed = p.trim();
|
|
52
|
+
if (trimmed.startsWith('#')) continue;
|
|
53
|
+
if (trimmed) return firstSentence(trimmed);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function firstSentence(text) {
|
|
59
|
+
const match = text.match(/^[\s\S]*?[.!?](?:\s|$)/);
|
|
60
|
+
return (match ? match[0] : text).trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function stripLeadingFrontmatter(content) {
|
|
64
|
+
if (!content) return content;
|
|
65
|
+
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return content;
|
|
66
|
+
const closeIdx = content.indexOf('\n---', 4);
|
|
67
|
+
if (closeIdx === -1) return content;
|
|
68
|
+
const afterClose = content.indexOf('\n', closeIdx + 1);
|
|
69
|
+
if (afterClose === -1) return '';
|
|
70
|
+
return content.slice(afterClose + 1);
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -41,6 +41,7 @@ import { inlineRoleAntiPatterns, PROMPT_WORD_CAP } from "../lib/role-preload.mjs
|
|
|
41
41
|
import { resolveActiveProfile } from "../lib/profiles/loader.mjs";
|
|
42
42
|
import { resolveTiersForPrimary } from "../lib/model-router.mjs";
|
|
43
43
|
import { stampFrontmatter } from "../lib/doc-stamp.mjs";
|
|
44
|
+
import { buildSkillFrontmatter, stripLeadingFrontmatter } from "../lib/sync/skill-frontmatter.mjs";
|
|
44
45
|
|
|
45
46
|
const home = os.homedir();
|
|
46
47
|
const root = path.resolve(import.meta.dirname, "..");
|
|
@@ -180,9 +181,18 @@ function releaseLock() {
|
|
|
180
181
|
*/
|
|
181
182
|
const _stagedPairs = []; // [{ staging, real, content }]
|
|
182
183
|
|
|
183
|
-
function writeFile(file, content) {
|
|
184
|
+
function writeFile(file, content, { stamp = true } = {}) {
|
|
184
185
|
mkdirp(path.dirname(file));
|
|
185
|
-
|
|
186
|
+
// Doc-stamp wraps content with cx_doc_id + body_hash YAML frontmatter for
|
|
187
|
+
// tamper detection. That's right for content artifacts (research findings,
|
|
188
|
+
// knowledge files), wrong for host-platform adapter files that have their
|
|
189
|
+
// own frontmatter contract (Claude Code agents, Copilot prompts, Anthropic
|
|
190
|
+
// Agent Skills) or are user-managed (CLAUDE.md, copilot-instructions.md).
|
|
191
|
+
// Stamping those produces double-frontmatter that breaks the host loader.
|
|
192
|
+
// Callers writing those files pass { stamp: false }.
|
|
193
|
+
|
|
194
|
+
const shouldStamp = stamp && file.endsWith('.md');
|
|
195
|
+
const stamped = shouldStamp ? stampFrontmatter(content, { generator: 'construct/sync-specialists' }) : content;
|
|
186
196
|
|
|
187
197
|
if (DRY_RUN) {
|
|
188
198
|
// Stage in memory only โ compare against current on-disk content.
|
|
@@ -641,7 +651,7 @@ function syncClaude(entries, targetDir = null) {
|
|
|
641
651
|
for (const entry of entries) {
|
|
642
652
|
const name = adapterName(entry);
|
|
643
653
|
const md = claudeAgentMarkdown(entry, entries);
|
|
644
|
-
writeFile(path.join(claudeAgentsDir, `${name}.md`), md);
|
|
654
|
+
writeFile(path.join(claudeAgentsDir, `${name}.md`), md, { stamp: false });
|
|
645
655
|
}
|
|
646
656
|
removeStaleAdapters(claudeAgentsDir, ".md", entries);
|
|
647
657
|
|
|
@@ -663,7 +673,8 @@ ${personaList}
|
|
|
663
673
|
## Internal Specialists
|
|
664
674
|
|
|
665
675
|
${specialistList || "(all specialists are internal โ routed through Construct)"}`;
|
|
666
|
-
|
|
676
|
+
// User-managed file with our managed-block carved out โ never doc-stamp.
|
|
677
|
+
writeFile(claudeMdPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd), { stamp: false });
|
|
667
678
|
|
|
668
679
|
// Sync MCP servers into ~/.claude/settings.json if it exists
|
|
669
680
|
const claudeSettingsPath = path.join(home, ".claude", "settings.json");
|
|
@@ -797,7 +808,7 @@ function syncCopilot(entries) {
|
|
|
797
808
|
const promptsDir = path.join(home, ".github", "prompts");
|
|
798
809
|
if (!DRY_RUN) mkdirp(promptsDir);
|
|
799
810
|
for (const entry of entries) {
|
|
800
|
-
writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries));
|
|
811
|
+
writeFile(path.join(promptsDir, `${adapterName(entry)}.prompt.md`), copilotPrompt(entry, entries), { stamp: false });
|
|
801
812
|
}
|
|
802
813
|
removeStaleAdapters(promptsDir, ".prompt.md", entries);
|
|
803
814
|
|
|
@@ -811,7 +822,8 @@ function syncCopilot(entries) {
|
|
|
811
822
|
Copilot does not expose true spawnable subagents. Use these reusable prompt profiles for role-specific passes:
|
|
812
823
|
|
|
813
824
|
${list || "(all specialists are internal โ use construct for all tasks)"}`;
|
|
814
|
-
|
|
825
|
+
// User-managed file with our managed-block carved out โ never doc-stamp.
|
|
826
|
+
writeFile(instructionsPath, replaceManagedBlock(existing, note, mdManagedStart, mdManagedEnd), { stamp: false });
|
|
815
827
|
}
|
|
816
828
|
|
|
817
829
|
// --- VS Code adapter ---
|
|
@@ -1143,20 +1155,12 @@ function collectSkills() {
|
|
|
1143
1155
|
return results;
|
|
1144
1156
|
}
|
|
1145
1157
|
|
|
1146
|
-
/**
|
|
1147
|
-
* Prefix prepended to every generated SKILL.md. The comment must survive
|
|
1148
|
-
* round-trips โ it is the canonical signal that a file was produced by sync
|
|
1149
|
-
* and must not be hand-edited.
|
|
1150
|
-
*/
|
|
1151
|
-
const SKILL_GENERATED_PREFIX = "# Generated by construct sync\n\n";
|
|
1152
|
-
|
|
1153
1158
|
/**
|
|
1154
1159
|
* Write collected skills to both .claude/skills/ and .agents/skills/ in
|
|
1155
|
-
* SKILL.md directory format.
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
* and two-phase staging in normal mode produces identical results.
|
|
1160
|
+
* SKILL.md directory format. Each file gets Anthropic Agent Skills frontmatter
|
|
1161
|
+
* (name + description) so the loader can index it. Doc-stamping is opted out
|
|
1162
|
+
* โ a doc-stamp YAML block before the real frontmatter produces double-
|
|
1163
|
+
* frontmatter the loader can't parse.
|
|
1160
1164
|
*/
|
|
1161
1165
|
function syncSkills() {
|
|
1162
1166
|
const skills = collectSkills();
|
|
@@ -1166,9 +1170,13 @@ function syncSkills() {
|
|
|
1166
1170
|
const agentsSkillsDir = path.join(home, ".agents", "skills");
|
|
1167
1171
|
|
|
1168
1172
|
for (const { name, content } of skills) {
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
|
|
1173
|
+
const frontmatter = buildSkillFrontmatter(name, content);
|
|
1174
|
+
// Strip any existing frontmatter from the source body so we don't emit two
|
|
1175
|
+
// blocks if a hand-authored skill already carries one.
|
|
1176
|
+
const body = stripLeadingFrontmatter(content);
|
|
1177
|
+
const generated = `${frontmatter}\n${body}`;
|
|
1178
|
+
writeFile(path.join(claudeSkillsDir, name, "SKILL.md"), generated, { stamp: false });
|
|
1179
|
+
writeFile(path.join(agentsSkillsDir, name, "SKILL.md"), generated, { stamp: false });
|
|
1172
1180
|
}
|
|
1173
1181
|
|
|
1174
1182
|
return skills.length;
|