@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.
- package/README.md +55 -18
- package/bin/construct +259 -11
- package/bin/construct-postinstall.mjs +23 -2
- package/lib/auto-docs.mjs +23 -22
- package/lib/cli-commands.mjs +2 -0
- package/lib/dashboard-static.mjs +7 -4
- package/lib/doc-stamp.mjs +16 -0
- package/lib/docs-verify.mjs +1 -8
- package/lib/doctor/watchers/bd-watch.mjs +6 -2
- package/lib/embed/daemon.mjs +20 -0
- package/lib/embed/docs-lifecycle.mjs +19 -0
- package/lib/embed/inbox.mjs +85 -2
- package/lib/embed/recommendation-store.mjs +29 -0
- package/lib/gates-audit.mjs +18 -12
- package/lib/hooks/_lib/input.mjs +52 -0
- package/lib/hooks/adaptive-lint.mjs +4 -0
- package/lib/hooks/agent-tracker.mjs +59 -15
- package/lib/hooks/audit-reads.mjs +83 -42
- package/lib/hooks/audit-trail.mjs +28 -18
- package/lib/hooks/bash-output-logger.mjs +8 -2
- package/lib/hooks/block-no-verify.mjs +4 -0
- package/lib/hooks/ci-status-check.mjs +4 -0
- package/lib/hooks/comment-lint.mjs +6 -4
- package/lib/hooks/config-protection.mjs +4 -0
- package/lib/hooks/context-watch.mjs +4 -0
- package/lib/hooks/context-window-recovery.mjs +4 -0
- package/lib/hooks/dep-audit.mjs +12 -5
- package/lib/hooks/doc-coupling-check.mjs +5 -1
- package/lib/hooks/edit-accumulator.mjs +25 -10
- package/lib/hooks/edit-error-recovery.mjs +4 -0
- package/lib/hooks/edit-guard.mjs +4 -0
- package/lib/hooks/guard-bash.mjs +4 -0
- package/lib/hooks/mcp-audit.mjs +4 -0
- package/lib/hooks/mcp-health-check.mjs +4 -0
- package/lib/hooks/model-fallback.mjs +7 -11
- package/lib/hooks/policy-engine.mjs +4 -0
- package/lib/hooks/post-merge-docs-check.mjs +4 -0
- package/lib/hooks/post-merge-tracking.mjs +82 -0
- package/lib/hooks/pre-compact.mjs +4 -0
- package/lib/hooks/pre-push-gate.mjs +84 -231
- package/lib/hooks/proactive-activation.mjs +5 -2
- package/lib/hooks/readme-age-check.mjs +4 -0
- package/lib/hooks/registry-sync.mjs +35 -20
- package/lib/hooks/rule-verifier.mjs +3 -0
- package/lib/hooks/scan-secrets.mjs +4 -0
- package/lib/hooks/session-optimize.mjs +4 -0
- package/lib/hooks/session-reflect.mjs +4 -0
- package/lib/hooks/session-start.mjs +48 -1
- package/lib/hooks/session-tracking-refresh.mjs +70 -0
- package/lib/hooks/stop-notify.mjs +13 -2
- package/lib/hooks/stop-typecheck.mjs +4 -0
- package/lib/hooks/test-watch.mjs +8 -4
- package/lib/init-unified.mjs +90 -23
- package/lib/intake/attribution.mjs +77 -0
- package/lib/intake/intake-config.mjs +3 -0
- package/lib/intake/manifest.mjs +107 -0
- package/lib/intake/poll-lock.mjs +136 -0
- package/lib/intake/prepare.mjs +42 -4
- package/lib/logging/rotate.mjs +394 -0
- package/lib/mcp/tools/project.mjs +2 -2
- package/lib/mcp/tools/telemetry.mjs +1 -1
- package/lib/opencode-config.mjs +10 -3
- package/lib/orchestration/routing-tables.mjs +176 -0
- package/lib/orchestration-policy.mjs +18 -106
- package/lib/parity.mjs +48 -7
- package/lib/profiles/lifecycle.mjs +19 -1
- package/lib/project-init-shared.mjs +11 -5
- package/lib/project-root.mjs +104 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/event-bus.mjs +29 -9
- package/lib/roles/router.mjs +8 -7
- package/lib/server/index.mjs +31 -9
- package/lib/server/static/index.html +1 -15
- package/lib/specialist-contracts-enforce.mjs +24 -10
- package/lib/status.mjs +1 -1
- package/lib/storage/backup.mjs +2 -2
- package/lib/sync/skill-frontmatter.mjs +71 -0
- package/lib/telemetry/intent-verifications.mjs +16 -3
- package/lib/telemetry/skill-calls.mjs +12 -3
- package/lib/tracking-surfaces.mjs +375 -0
- package/lib/worker/trace.mjs +19 -2
- package/package.json +6 -2
- package/platforms/claude/settings.template.json +28 -27
- package/scripts/sync-specialists.mjs +296 -94
- package/specialists/registry.json +158 -13
- package/lib/hooks/env-check.mjs +0 -83
- package/lib/hooks/read-tracker.mjs +0 -61
- package/lib/policy/unified-gates.mjs +0 -96
- package/lib/server/static/assets/index-ab25c707.js +0 -70
- 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,
|
|
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
|
-
|
|
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;
|
package/lib/hooks/test-watch.mjs
CHANGED
|
@@ -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,
|
|
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 {
|
|
68
|
+
} catch (err) {
|
|
69
|
+
logHookFailure({ hook: 'test-watch', err, phase: 'flake-track' });
|
|
70
|
+
}
|
|
67
71
|
|
|
68
72
|
process.exit(0);
|
package/lib/init-unified.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
+
}
|