engsys 1.0.0
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/LICENSE +21 -0
- package/README.md +202 -0
- package/core/agents/aaron.md +152 -0
- package/core/agents/bert.md +115 -0
- package/core/agents/isabelle.md +136 -0
- package/core/agents/jody.md +150 -0
- package/core/agents/leith.md +111 -0
- package/core/agents/marcelo.md +282 -0
- package/core/agents/melvin.md +101 -0
- package/core/agents/nyx.md +152 -0
- package/core/agents/otto.md +168 -0
- package/core/agents/patricia.md +283 -0
- package/core/commands/design-audit-local.md +155 -0
- package/core/commands/design-audit.md +235 -0
- package/core/commands/design-critique.md +96 -0
- package/core/commands/file-issue.md +22 -0
- package/core/commands/generate-project.md +45 -0
- package/core/commands/implement-issue.md +37 -0
- package/core/commands/implement-project.md +40 -0
- package/core/commands/naturalize.md +61 -0
- package/core/commands/pre-push.md +29 -0
- package/core/commands/prep-review-collect.md +130 -0
- package/core/commands/prep-review-finalize.md +121 -0
- package/core/commands/prep-review-publish.md +113 -0
- package/core/commands/prep-review.md +65 -0
- package/core/commands/project-closeout.md +25 -0
- package/core/skills/agentic-eval/SKILL.md +195 -0
- package/core/skills/chrome-devtools/SKILL.md +97 -0
- package/core/skills/code-review/SKILL.md +26 -0
- package/core/skills/gh-cli/SKILL.md +2202 -0
- package/core/skills/git-commit/SKILL.md +124 -0
- package/core/skills/git-workflow-agents/SKILL.md +462 -0
- package/core/skills/git-workflow-agents/reference.md +220 -0
- package/core/skills/github-actions/SKILL.md +190 -0
- package/core/skills/github-issues/SKILL.md +154 -0
- package/core/skills/llm-structured-outputs/SKILL.md +323 -0
- package/core/skills/llm-structured-outputs/references/provider-details.md +392 -0
- package/core/skills/pre-push/SKILL.md +115 -0
- package/core/skills/refactor/SKILL.md +645 -0
- package/core/skills/web-design-reviewer/SKILL.md +371 -0
- package/core/skills/webapp-testing/SKILL.md +127 -0
- package/core/skills/webapp-testing/test-helper.js +56 -0
- package/core/templates/CLAUDE.md.tmpl +98 -0
- package/core/templates/adr-template.md +67 -0
- package/core/templates/gh-issue-templates/bug.md +39 -0
- package/core/templates/gh-issue-templates/content.md +42 -0
- package/core/templates/gh-issue-templates/enhancement.md +36 -0
- package/core/templates/gh-issue-templates/feature.md +39 -0
- package/core/templates/gh-issue-templates/infrastructure.md +41 -0
- package/core/templates/post-edit-reminders.sh.tmpl +19 -0
- package/core/templates/settings.json.tmpl +90 -0
- package/core/templates/settings.local.json.tmpl +3 -0
- package/core/workflows/agent-implementation-workflow.md +346 -0
- package/core/workflows/generate-project.md +258 -0
- package/core/workflows/implement-project-workflow.md +190 -0
- package/core/workflows/issue-tracking.md +89 -0
- package/core/workflows/project-closeout-ceremony.md +77 -0
- package/core/workflows/review-workflow.md +266 -0
- package/engsys.config.example.yaml +46 -0
- package/install +202 -0
- package/lessons-library/README.md +80 -0
- package/lessons-library/async-callbacks-verify-liveness.md +15 -0
- package/lessons-library/change-isnt-done-until-every-surface-updated.md +15 -0
- package/lessons-library/claim-then-act-for-irreversible-ops.md +16 -0
- package/lessons-library/co-commit-entangled-work.md +15 -0
- package/lessons-library/dependabot-triage-playbook.md +17 -0
- package/lessons-library/deploy-by-digest-and-verify-the-running-revision.md +15 -0
- package/lessons-library/enforce-your-guarantee-at-your-boundary.md +16 -0
- package/lessons-library/gate-changes-on-measurement-not-vibes.md +15 -0
- package/lessons-library/iac-first-no-console-changes.md +15 -0
- package/lessons-library/independent-objective-review-gate.md +15 -0
- package/lessons-library/keep-an-immutable-source-of-truth.md +15 -0
- package/lessons-library/long-agent-runs-checkpoint-not-poll.md +15 -0
- package/lessons-library/model-identity-with-stable-ids-and-provenance.md +15 -0
- package/lessons-library/operator-choices-are-first-class.md +15 -0
- package/lessons-library/prefer-tool-enforced-structured-output.md +15 -0
- package/lessons-library/prove-causation-before-acting.md +15 -0
- package/lessons-library/re-read-state-before-acting.md +14 -0
- package/lessons-library/read-layer-tolerates-unbackfilled-rows.md +15 -0
- package/lessons-library/shell-safety-pipefail-and-validate-before-teardown.md +14 -0
- package/lessons-library/shift-correctness-left-and-distrust-false-greens.md +15 -0
- package/lessons-library/stray-control-bytes-hide-changes.md +14 -0
- package/lessons-library/tests-can-assert-the-bug.md +15 -0
- package/lessons-library/verify-ground-truth-not-reports.md +15 -0
- package/lessons-library/worktrees-need-bootstrap-from-origin-main.md +15 -0
- package/lib/commands.js +356 -0
- package/lib/generate-team-avatars.mjs +251 -0
- package/lib/manifest.js +155 -0
- package/lib/render.js +135 -0
- package/lib/selftest.js +90 -0
- package/lib/util.js +89 -0
- package/lib/yaml.js +156 -0
- package/optional-agents/gary.md +86 -0
- package/optional-agents/jos.md +136 -0
- package/optional-agents/sandy.md +101 -0
- package/optional-agents/steve.md +161 -0
- package/package.json +43 -0
- package/stacks/cloud/aws/claude.fragment.md +17 -0
- package/stacks/cloud/aws/settings.fragment.json +39 -0
- package/stacks/cloud/aws/skills/aws-deployment-preflight/SKILL.md +165 -0
- package/stacks/cloud/aws/skills/cloud-architecture-aws/SKILL.md +265 -0
- package/stacks/cloud/azure/claude.fragment.md +17 -0
- package/stacks/cloud/azure/settings.fragment.json +45 -0
- package/stacks/cloud/azure/skills/azure-deployment-preflight/SKILL.md +175 -0
- package/stacks/cloud/azure/skills/cloud-architecture-azure/SKILL.md +211 -0
- package/stacks/cloud/cloudflare/claude.fragment.md +21 -0
- package/stacks/cloud/cloudflare/settings.fragment.json +31 -0
- package/stacks/cloud/cloudflare/skills/cloud-architecture-cloudflare/SKILL.md +294 -0
- package/stacks/cloud/cloudflare/skills/cloudflare-deployment-preflight/SKILL.md +175 -0
- package/stacks/cloud/gcp/claude.fragment.md +17 -0
- package/stacks/cloud/gcp/settings.fragment.json +40 -0
- package/stacks/cloud/gcp/skills/cloud-architecture-gcp/SKILL.md +208 -0
- package/stacks/cloud/gcp/skills/gcp-deployment-preflight/SKILL.md +137 -0
- package/stacks/db/mongo/skills/mongo-conventions/SKILL.md +96 -0
- package/stacks/db/prisma/claude.fragment.md +49 -0
- package/stacks/db/prisma/skills/docker-database-package-copy/SKILL.md +44 -0
- package/stacks/db/prisma/skills/prisma-conventions/SKILL.md +37 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/SKILL.md +184 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/references/benchmark-notes.md +47 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/references/official-links.md +53 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/SKILL.md +197 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/references/benchmark-notes.md +47 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/references/official-links.md +45 -0
- package/stacks/iac/bicep/claude.fragment.md +14 -0
- package/stacks/iac/bicep/settings.fragment.json +20 -0
- package/stacks/iac/bicep/skills/iac-bicep/SKILL.md +113 -0
- package/stacks/iac/cdk/claude.fragment.md +14 -0
- package/stacks/iac/cdk/settings.fragment.json +23 -0
- package/stacks/iac/cdk/skills/iac-cdk/SKILL.md +104 -0
- package/stacks/iac/terraform/claude.fragment.md +13 -0
- package/stacks/iac/terraform/settings.fragment.json +25 -0
- package/stacks/iac/terraform/skills/iac-terraform/SKILL.md +93 -0
- package/stacks/iac/terraform/skills/terraform-conventions/SKILL.md +87 -0
- package/stacks/lang/kotlin/skills/android-testing/SKILL.md +263 -0
- package/stacks/lang/kotlin/skills/jetpack-compose/SKILL.md +264 -0
- package/stacks/lang/kotlin/skills/kotlin-coroutines/SKILL.md +329 -0
- package/stacks/lang/python/skills/python-conventions/SKILL.md +61 -0
- package/stacks/lang/shell/skills/shell-scripting/SKILL.md +110 -0
- package/stacks/lang/swift/skills/swift-concurrency/SKILL.md +423 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/approachable-concurrency.md +80 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/concurrency-patterns.md +233 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/swiftui-concurrency.md +187 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/synchronization-primitives.md +341 -0
- package/stacks/lang/swift/skills/swift-testing/SKILL.md +497 -0
- package/stacks/lang/swift/skills/swift-testing/references/testing-advanced.md +106 -0
- package/stacks/lang/swift/skills/swift-testing/references/testing-patterns.md +504 -0
- package/stacks/lang/swift/skills/swiftdata/SKILL.md +334 -0
- package/stacks/lang/swift/skills/swiftdata/references/core-data-coexistence.md +504 -0
- package/stacks/lang/swift/skills/swiftdata/references/swiftdata-advanced.md +975 -0
- package/stacks/lang/swift/skills/swiftdata/references/swiftdata-queries.md +675 -0
- package/stacks/lang/swift/skills/swiftui-patterns/SKILL.md +371 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/architecture-patterns.md +486 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/deprecated-migration.md +1097 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/design-polish.md +780 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/platform-and-sharing.md +696 -0
- package/stacks/lang/typescript/skills/typescript-conventions/SKILL.md +91 -0
- package/stacks/platform/android/claude.fragment.md +40 -0
- package/stacks/platform/android/hooks/pre-push-gradle.sh +70 -0
- package/stacks/platform/android/settings.fragment.json +13 -0
- package/stacks/platform/android/skills/android-build-conventions/SKILL.md +247 -0
- package/stacks/platform/ios/claude.fragment.md +24 -0
- package/stacks/platform/ios/hooks/pre-push-xcodebuild.sh +82 -0
- package/stacks/platform/ios/settings.fragment.json +21 -0
- package/stacks/platform/ios/skills/xcodebuildmcp-simulator-logs/SKILL.md +76 -0
- package/stacks/platform/web/skills/frontend-testing/SKILL.md +246 -0
- package/stacks/platform/web/skills/react-conventions/SKILL.md +261 -0
- package/stacks/platform/web/skills/web-platform-conventions/SKILL.md +55 -0
- package/stacks/tooling/issue-tracker-github/claude.fragment.md +10 -0
- package/stacks/tooling/issue-tracker-github/settings.fragment.json +24 -0
- package/stacks/tooling/issue-tracker-github/skills/issue-tracker-github/SKILL.md +278 -0
- package/stacks/tooling/issue-tracker-linear/claude.fragment.md +17 -0
- package/stacks/tooling/issue-tracker-linear/settings.fragment.json +9 -0
- package/stacks/tooling/issue-tracker-linear/skills/issue-tracker-linear/SKILL.md +183 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Shell safety: pipefail and validate before teardown
|
|
2
|
+
|
|
3
|
+
**Trigger:** A shell script with a pipeline, a risky one-shot step (synth, change-set, single deploy), or sourcing a `.env`.
|
|
4
|
+
|
|
5
|
+
**Failure mode:** Without `pipefail`, a failed upstream command in a pipe is masked by a successful filter — the script "succeeds" on broken data. Tearing down a working path before validating the novel step leaves you with neither. Unquoted `.env` values with spaces split and corrupt env.
|
|
6
|
+
|
|
7
|
+
**Correct behavior:**
|
|
8
|
+
- Set `set -o pipefail` and/or check `${PIPESTATUS[0]}` so a filtered pipeline doesn't mask an upstream failure.
|
|
9
|
+
- Validate the novel/risky step (synth, change-set, single deploy) BEFORE tearing down the working path.
|
|
10
|
+
- Quote `.env` values containing spaces when sourcing in bash.
|
|
11
|
+
|
|
12
|
+
**Check:** Does the script fail loudly when an upstream pipe command fails, and is the old path still intact until the new one is proven?
|
|
13
|
+
|
|
14
|
+
**Seen in:** recurring across multiple production projects.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Shift correctness left, distrust false greens
|
|
2
|
+
|
|
3
|
+
**Trigger:** You're relying on CI to catch correctness late, or a check is green and you're treating that as proof it ran.
|
|
4
|
+
|
|
5
|
+
**Failure mode:** Cheap correctness checks deferred to CI cost slow feedback and burn expensive matrix jobs on draft PRs. Worse, a gate that didn't actually RUN shows green: unregistered test specs, missing trigger types, empty gates all pass vacuously.
|
|
6
|
+
|
|
7
|
+
**Correct behavior:**
|
|
8
|
+
- Move fast correctness checks from CI to pre-push hooks.
|
|
9
|
+
- Don't fire expensive matrix jobs on draft PRs.
|
|
10
|
+
- Treat a green check as suspect until you confirm the job actually executed work.
|
|
11
|
+
- Verify execution (specs registered, triggers present, gate non-empty), not just the green badge.
|
|
12
|
+
|
|
13
|
+
**Check:** Did this gate actually run assertions, or did it pass because it had nothing to do?
|
|
14
|
+
|
|
15
|
+
**Seen in:** recurring across multiple production projects.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Stray control bytes hide changes
|
|
2
|
+
|
|
3
|
+
**Trigger:** A changed file shows as "Bin" in `git diff --stat`, or review bots / grep silently skip it.
|
|
4
|
+
|
|
5
|
+
**Failure mode:** NUL or other control bytes make git classify a text file as binary. Review bots ignore binary diffs and grep won't match inside them, so real changes ship unreviewed and unsearchable. Literal control bytes often sneak in via regex character ranges written as raw bytes.
|
|
6
|
+
|
|
7
|
+
**Correct behavior:**
|
|
8
|
+
- Write regex control-character ranges as backslash escapes (e.g. `\x00`), never as literal bytes.
|
|
9
|
+
- After editing, confirm every changed file shows +/- line counts in `git diff --stat`, not "Bin".
|
|
10
|
+
- If a text file reads as binary, hunt down and remove the stray control byte before committing.
|
|
11
|
+
|
|
12
|
+
**Check:** Does `git diff --stat` show numeric +/- for every changed text file (no "Bin" entries)?
|
|
13
|
+
|
|
14
|
+
**Seen in:** recurring across multiple production projects.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Tests can assert the bug
|
|
2
|
+
|
|
3
|
+
**Trigger:** You root-caused a bug, but a test is green over exactly that code path.
|
|
4
|
+
|
|
5
|
+
**Failure mode:** A test that contradicts a confirmed bug is asserting the buggy behavior, or isn't exercising real behavior at all. Mocks looser than reality and docstrings that claim coverage create false confidence — the green is the lie, not the bug report.
|
|
6
|
+
|
|
7
|
+
**Correct behavior:**
|
|
8
|
+
- Treat a green test that contradicts a root cause as a suspect to investigate, not as proof you're wrong.
|
|
9
|
+
- Check whether mocks are looser than production reality, and whether the assertion actually pins the behavior.
|
|
10
|
+
- Fix behavior and test together in one commit.
|
|
11
|
+
- Add an integration-level test that would fail on the real bug.
|
|
12
|
+
|
|
13
|
+
**Check:** Does a test now fail when you reintroduce the original bug? If not, it never covered it.
|
|
14
|
+
|
|
15
|
+
**Seen in:** recurring across multiple production projects.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Verify ground truth, not reports
|
|
2
|
+
|
|
3
|
+
**Trigger:** Something or someone says "done", "passing", "merged", "deployed", or "I will…" — and you're about to act on that claim.
|
|
4
|
+
|
|
5
|
+
**Failure mode:** Agents narrate intent ("I will run the tests") as if it already happened; status reports are optimistic; local-green is mistaken for CI-green. Acting on the narration instead of the state means building on a result that never actually landed.
|
|
6
|
+
|
|
7
|
+
**Correct behavior:**
|
|
8
|
+
- Check real state directly: the merged PR view, the pushed SHA, the actual CI run, the rendered UI — not the prose describing it.
|
|
9
|
+
- Re-run the gate yourself rather than trusting that it was run.
|
|
10
|
+
- Treat local-green and CI-green as different facts; CI is the authority.
|
|
11
|
+
- Watch CI to completion; a started run is not a passed run.
|
|
12
|
+
|
|
13
|
+
**Check:** Can you point to a concrete artifact (run URL, SHA, screenshot) that proves the claim, independent of who reported it?
|
|
14
|
+
|
|
15
|
+
**Seen in:** recurring across multiple production projects.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Worktrees need bootstrap from origin/main
|
|
2
|
+
|
|
3
|
+
**Trigger:** Creating a fresh worktree (or having a subagent work in one) and tests immediately fail or outputs go missing.
|
|
4
|
+
|
|
5
|
+
**Failure mode:** A worktree branched off a diverged local tree inherits the divergence. A fresh worktree lacks env files, hook shims, and generated clients/builds, so tests fail for environmental reasons. Subagents resolve relative paths against the MAIN checkout, so their outputs land in the wrong tree.
|
|
6
|
+
|
|
7
|
+
**Correct behavior:**
|
|
8
|
+
- Create worktrees off origin/main when local has diverged.
|
|
9
|
+
- Bootstrap the worktree before running tests: env files, hook shims, regenerated clients/builds.
|
|
10
|
+
- Pass subagents ABSOLUTE worktree paths, never relative ones.
|
|
11
|
+
- Verify outputs actually landed in the worktree, not the main checkout.
|
|
12
|
+
|
|
13
|
+
**Check:** Does a clean test run pass in the worktree, and are new files physically inside it?
|
|
14
|
+
|
|
15
|
+
**Seen in:** recurring across multiple production projects.
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
fs, path, ensureDir, readText, writeText, exists, sha256,
|
|
5
|
+
copyDir, loadConfig,
|
|
6
|
+
} = require('./util');
|
|
7
|
+
const { buildManifest } = require('./manifest');
|
|
8
|
+
const render = require('./render');
|
|
9
|
+
|
|
10
|
+
const ENGSYS_VERSION = require('../package.json').version;
|
|
11
|
+
const PF_MARKER = 'ENGSYS:PROJECT-FACTS:START';
|
|
12
|
+
const BACKUP_DIR = '.claude/.engsys-backup';
|
|
13
|
+
|
|
14
|
+
function resolveConfigPath(into, explicit) {
|
|
15
|
+
if (explicit) return explicit;
|
|
16
|
+
for (const name of ['engsys.config.yaml', 'engsys.config.yml', 'engsys.config.json']) {
|
|
17
|
+
const p = path.join(into, name);
|
|
18
|
+
if (exists(p)) return p;
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`No engsys.config.{yaml,yml,json} found in ${into} (pass --config to override).`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function plural(n, w) { return `${n} ${w}${n === 1 ? '' : 's'}`; }
|
|
24
|
+
function safeJson(t) { try { return JSON.parse(t); } catch { return null; } }
|
|
25
|
+
function nowIso() { return new Date().toISOString(); }
|
|
26
|
+
|
|
27
|
+
function* walk(dir) {
|
|
28
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
+
const p = path.join(dir, e.name);
|
|
30
|
+
if (e.isDirectory()) yield* walk(p);
|
|
31
|
+
else yield p;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Remove a file and any now-empty parent directories, stopping at `stopAt`.
|
|
36
|
+
function removeAndPrune(absFile, stopAt) {
|
|
37
|
+
try { fs.unlinkSync(absFile); } catch { /* already gone */ }
|
|
38
|
+
let dir = path.dirname(absFile);
|
|
39
|
+
while (dir.startsWith(stopAt) && dir !== stopAt) {
|
|
40
|
+
try {
|
|
41
|
+
if (fs.readdirSync(dir).length === 0) { fs.rmdirSync(dir); dir = path.dirname(dir); }
|
|
42
|
+
else break;
|
|
43
|
+
} catch { break; }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Detect AI config from other tools (Copilot, Cursor, Windsurf) so we can import it.
|
|
48
|
+
function detectForeignAiConfig(into) {
|
|
49
|
+
const found = [];
|
|
50
|
+
const files = [
|
|
51
|
+
['Copilot', '.github/copilot-instructions.md'],
|
|
52
|
+
['Cursor', '.cursorrules'],
|
|
53
|
+
['Windsurf', '.windsurfrules'],
|
|
54
|
+
['Aider', 'CONVENTIONS.md'],
|
|
55
|
+
];
|
|
56
|
+
for (const [tool, rel] of files) if (exists(path.join(into, rel))) found.push({ tool, rel });
|
|
57
|
+
const dirs = [
|
|
58
|
+
['Copilot', '.github/instructions', /\.instructions\.md$/],
|
|
59
|
+
['Copilot', '.github/agents', /\.agent\.md$/],
|
|
60
|
+
['Copilot', '.github/prompts', /\.prompt\.md$/],
|
|
61
|
+
['Cursor', '.cursor/rules', /\.mdc$/],
|
|
62
|
+
];
|
|
63
|
+
for (const [tool, dir, re] of dirs) {
|
|
64
|
+
const abs = path.join(into, dir);
|
|
65
|
+
if (!exists(abs)) continue;
|
|
66
|
+
for (const f of fs.readdirSync(abs)) if (re.test(f)) found.push({ tool, rel: `${dir}/${f}` });
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Existing .claude agents/commands/skills that engsys does NOT manage — the
|
|
72
|
+
// project's own (sub)agents and tooling. We preserve them, never prune them,
|
|
73
|
+
// and surface them so the operator (and /naturalize) can reconcile.
|
|
74
|
+
function detectPreexisting(into, managedSet) {
|
|
75
|
+
const out = { agents: [], commands: [], skills: [] };
|
|
76
|
+
for (const sub of ['agents', 'commands']) {
|
|
77
|
+
const d = path.join(into, '.claude', sub);
|
|
78
|
+
if (!exists(d)) continue;
|
|
79
|
+
for (const f of fs.readdirSync(d)) {
|
|
80
|
+
if (!f.endsWith('.md')) continue;
|
|
81
|
+
const rel = path.relative(into, path.join(d, f));
|
|
82
|
+
if (!managedSet.has(rel)) out[sub].push(rel);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const sd = path.join(into, '.claude', 'skills');
|
|
86
|
+
if (exists(sd)) for (const dir of fs.readdirSync(sd, { withFileTypes: true })) {
|
|
87
|
+
if (!dir.isDirectory()) continue;
|
|
88
|
+
const rel = path.relative(into, path.join(sd, dir.name));
|
|
89
|
+
const ours = [...managedSet].some((m) => m.startsWith(rel + path.sep));
|
|
90
|
+
if (!ours) out.skills.push(rel + '/');
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Core install/update routine. mode: 'install' | 'update'.
|
|
96
|
+
function runInstall(opts) {
|
|
97
|
+
const { engsysRoot, into, dryRun, force } = opts;
|
|
98
|
+
const configPath = resolveConfigPath(into, opts.config);
|
|
99
|
+
const config = loadConfig(configPath);
|
|
100
|
+
const plan = buildManifest(engsysRoot, config);
|
|
101
|
+
|
|
102
|
+
const claudeDir = path.join(into, '.claude');
|
|
103
|
+
const lockPath = path.join(claudeDir, 'engsys.lock');
|
|
104
|
+
const hadLock = exists(lockPath);
|
|
105
|
+
const oldLock = hadLock ? (safeJson(readText(lockPath)) || {}) : null;
|
|
106
|
+
const mode = (opts.mode === 'install' && hadLock) ? 'update' : opts.mode;
|
|
107
|
+
const adopting = opts.mode === 'install' && hadLock;
|
|
108
|
+
|
|
109
|
+
// Rollback baseline: on a genuine first install, snapshot every pre-existing
|
|
110
|
+
// file engsys is about to overwrite/merge into .claude/.engsys-backup/, so
|
|
111
|
+
// `engsys uninstall` can restore the project's prior system exactly.
|
|
112
|
+
const firstInstall = !hadLock;
|
|
113
|
+
const backupFilesDir = path.join(into, BACKUP_DIR, 'files');
|
|
114
|
+
const snapshot = { engsysVersion: ENGSYS_VERSION, createdAt: nowIso(), restore: [] };
|
|
115
|
+
const snapped = new Set();
|
|
116
|
+
const snapBefore = (destFile) => {
|
|
117
|
+
if (!firstInstall || dryRun) return;
|
|
118
|
+
const rel = path.relative(into, destFile);
|
|
119
|
+
if (snapped.has(rel) || !exists(destFile)) { snapped.add(rel); return; }
|
|
120
|
+
snapped.add(rel);
|
|
121
|
+
const bdest = path.join(backupFilesDir, rel);
|
|
122
|
+
ensureDir(path.dirname(bdest));
|
|
123
|
+
fs.copyFileSync(destFile, bdest);
|
|
124
|
+
snapshot.restore.push(rel);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const managed = {};
|
|
128
|
+
const generated = [];
|
|
129
|
+
const imported = [];
|
|
130
|
+
const warnings = [];
|
|
131
|
+
const actions = [];
|
|
132
|
+
const realPath = (f) => (fs.lstatSync(f).isSymbolicLink() ? fs.realpathSync(f) : f);
|
|
133
|
+
|
|
134
|
+
const writeManaged = (srcFile, destFile) => {
|
|
135
|
+
const rel = path.relative(into, destFile);
|
|
136
|
+
const src = realPath(srcFile);
|
|
137
|
+
const hash = sha256(readText(src));
|
|
138
|
+
const wasOurs = oldLock && oldLock.managed && rel in oldLock.managed;
|
|
139
|
+
if (!dryRun) {
|
|
140
|
+
if (exists(destFile) && !wasOurs && sha256(readText(destFile)) !== hash) {
|
|
141
|
+
snapBefore(destFile);
|
|
142
|
+
warnings.push(`overwrote the project's own ${rel} (original snapshotted for rollback)`);
|
|
143
|
+
}
|
|
144
|
+
ensureDir(path.dirname(destFile));
|
|
145
|
+
fs.copyFileSync(src, destFile);
|
|
146
|
+
}
|
|
147
|
+
managed[rel] = hash;
|
|
148
|
+
return rel;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// --- managed copies ---
|
|
152
|
+
for (const a of plan.agents) writeManaged(a.src, path.join(claudeDir, 'agents', a.name));
|
|
153
|
+
for (const c of plan.commands) writeManaged(c.src, path.join(claudeDir, 'commands', c.name));
|
|
154
|
+
for (const w of plan.workflows) writeManaged(w.src, path.join(claudeDir, 'workflows', w.name));
|
|
155
|
+
for (const h of plan.packHooks) {
|
|
156
|
+
const rel = writeManaged(h.src, path.join(claudeDir, 'hooks', h.name));
|
|
157
|
+
if (!dryRun) fs.chmodSync(path.join(into, rel), 0o755);
|
|
158
|
+
}
|
|
159
|
+
for (const s of plan.skillDirs) {
|
|
160
|
+
const destDir = path.join(claudeDir, 'skills', s.name);
|
|
161
|
+
if (dryRun) {
|
|
162
|
+
for (const f of walk(s.src)) {
|
|
163
|
+
const rel = path.relative(into, path.join(destDir, path.relative(s.src, f)));
|
|
164
|
+
managed[rel] = sha256(readText(realPath(f)));
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
for (const f of walk(s.src)) snapBefore(path.join(destDir, path.relative(s.src, f)));
|
|
168
|
+
for (const rel of copyDir(s.src, destDir, into, [])) managed[rel] = sha256(readText(path.join(into, rel)));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- seed lessons ---
|
|
173
|
+
const lessonsCfg = config.lessons || {};
|
|
174
|
+
let lessonsCount = 0;
|
|
175
|
+
if (lessonsCfg.seed !== false) {
|
|
176
|
+
const libDir = path.join(engsysRoot, 'lessons-library');
|
|
177
|
+
const lessonsInto = lessonsCfg.into || 'docs/agent-lessons/library';
|
|
178
|
+
if (exists(libDir)) {
|
|
179
|
+
for (const f of fs.readdirSync(libDir)) {
|
|
180
|
+
if (!f.endsWith('.md') || f === 'README.md') continue;
|
|
181
|
+
writeManaged(path.join(libDir, f), path.join(into, lessonsInto, f));
|
|
182
|
+
lessonsCount++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- scenario 3: import foreign AI config (Copilot/Cursor/…) on first install ---
|
|
188
|
+
const foreign = detectForeignAiConfig(into);
|
|
189
|
+
const importDir = path.join(into, 'docs', 'imported-ai-config');
|
|
190
|
+
let importedNow = false;
|
|
191
|
+
if (foreign.length && !exists(importDir) && opts.mode === 'install') {
|
|
192
|
+
importedNow = true;
|
|
193
|
+
const index = ['# Imported AI config', '',
|
|
194
|
+
'Snapshots of pre-existing AI assistant config found at install time. Run',
|
|
195
|
+
'`/naturalize` to fold the durable rules into `CLAUDE.md` (and convert any',
|
|
196
|
+
'agent definitions to engsys agents). One-time snapshots — originals are left',
|
|
197
|
+
'in place; delete this folder once folded in.', '', '| Tool | Original | Snapshot |', '|------|----------|----------|'];
|
|
198
|
+
for (const { tool, rel } of foreign) {
|
|
199
|
+
const flat = rel.replace(/^[./]+/, '').replace(/[/\\]/g, '__');
|
|
200
|
+
if (!dryRun) writeText(path.join(importDir, flat), readText(path.join(into, rel)));
|
|
201
|
+
imported.push(path.relative(into, path.join(importDir, flat)));
|
|
202
|
+
index.push(`| ${tool} | \`${rel}\` | \`docs/imported-ai-config/${flat}\` |`);
|
|
203
|
+
}
|
|
204
|
+
if (!dryRun) writeText(path.join(importDir, 'README.md'), index.join('\n') + '\n');
|
|
205
|
+
imported.push('docs/imported-ai-config/README.md');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- generated files (always merge/preserve; never clobber) ---
|
|
209
|
+
const claudeMdPath = path.join(into, 'CLAUDE.md');
|
|
210
|
+
let existingRegion = null, seedFacts = null, foldedClaude = false;
|
|
211
|
+
if (exists(claudeMdPath)) {
|
|
212
|
+
const cur = readText(claudeMdPath);
|
|
213
|
+
if (cur.includes(PF_MARKER)) {
|
|
214
|
+
existingRegion = cur;
|
|
215
|
+
} else if (!force) {
|
|
216
|
+
foldedClaude = true;
|
|
217
|
+
seedFacts = `> Imported from this project's prior CLAUDE.md (preserved for rollback in \`${BACKUP_DIR}/\`). Review and trim:\n\n${cur.trim()}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (importedNow && !seedFacts && !existingRegion) {
|
|
221
|
+
seedFacts = '> TODO (naturalize): fold the imported rules in `docs/imported-ai-config/` into these project facts, then delete that folder.';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const existingSettings = exists(path.join(claudeDir, 'settings.json'))
|
|
225
|
+
? safeJson(readText(path.join(claudeDir, 'settings.json'))) : null;
|
|
226
|
+
const existingMcp = exists(path.join(into, '.mcp.json'))
|
|
227
|
+
? safeJson(readText(path.join(into, '.mcp.json'))) : null;
|
|
228
|
+
|
|
229
|
+
const writeGen = (destFile, content) => {
|
|
230
|
+
snapBefore(destFile);
|
|
231
|
+
if (!dryRun) writeText(destFile, content);
|
|
232
|
+
generated.push(path.relative(into, destFile));
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
writeGen(claudeMdPath, render.renderClaudeMd(engsysRoot, config, plan, existingRegion, seedFacts));
|
|
236
|
+
writeGen(path.join(claudeDir, 'settings.json'), render.renderSettings(engsysRoot, plan, force ? null : existingSettings));
|
|
237
|
+
writeGen(path.join(claudeDir, 'settings.local.json'), render.renderSettingsLocal(engsysRoot, plan));
|
|
238
|
+
if (Object.keys(plan.mcpServers).length || existingMcp) {
|
|
239
|
+
writeGen(path.join(into, '.mcp.json'), render.renderMcpJson(plan, force ? null : existingMcp));
|
|
240
|
+
}
|
|
241
|
+
const hookDest = path.join(claudeDir, 'hooks', 'post-edit-reminders.sh');
|
|
242
|
+
snapBefore(hookDest);
|
|
243
|
+
if (!dryRun) { writeText(hookDest, render.renderHook(engsysRoot, config)); fs.chmodSync(hookDest, 0o755); }
|
|
244
|
+
generated.push(path.relative(into, hookDest));
|
|
245
|
+
|
|
246
|
+
// Write the rollback manifest (first install only).
|
|
247
|
+
if (firstInstall && !dryRun) {
|
|
248
|
+
writeText(path.join(into, BACKUP_DIR, 'manifest.json'), JSON.stringify(snapshot, null, 2) + '\n');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- scenario 2: prune managed files orphaned since the last install ---
|
|
252
|
+
const pruned = [];
|
|
253
|
+
if (oldLock && oldLock.managed) {
|
|
254
|
+
for (const rel of Object.keys(oldLock.managed)) {
|
|
255
|
+
if (rel in managed || generated.includes(rel)) continue;
|
|
256
|
+
if (!dryRun && exists(path.join(into, rel))) removeAndPrune(path.join(into, rel), into);
|
|
257
|
+
pruned.push(rel);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const preexisting = detectPreexisting(into, new Set(Object.keys(managed)));
|
|
262
|
+
const preexistingCount = preexisting.agents.length + preexisting.commands.length + preexisting.skills.length;
|
|
263
|
+
|
|
264
|
+
const prev = (oldLock && oldLock.managed) || {};
|
|
265
|
+
const changes = { added: 0, updated: 0, unchanged: 0, removed: pruned.length };
|
|
266
|
+
for (const [rel, h] of Object.entries(managed)) {
|
|
267
|
+
if (!(rel in prev)) changes.added++;
|
|
268
|
+
else if (prev[rel] !== h) changes.updated++;
|
|
269
|
+
else changes.unchanged++;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
actions.push(`agents: ${plural(plan.agents.length, 'file')}`);
|
|
273
|
+
actions.push(`commands: ${plural(plan.commands.length, 'file')}`);
|
|
274
|
+
actions.push(`skills: ${plural(plan.skillDirs.length, 'pack')}`);
|
|
275
|
+
actions.push(`lessons: ${plural(lessonsCount, 'file')} seeded`);
|
|
276
|
+
actions.push(`workflows: ${plural(plan.workflows.length, 'file')}`);
|
|
277
|
+
actions.push(`stack: ${plan.packs.length ? plan.packs.join(', ') : 'none'}`);
|
|
278
|
+
|
|
279
|
+
const lock = {
|
|
280
|
+
engsysVersion: ENGSYS_VERSION,
|
|
281
|
+
engsysRef: (config.engsys && config.engsys.version) || null,
|
|
282
|
+
configHash: sha256(readText(configPath)),
|
|
283
|
+
mode, packs: plan.packs, managed, generated,
|
|
284
|
+
imported: imported.length ? imported : undefined,
|
|
285
|
+
};
|
|
286
|
+
if (!dryRun) writeText(lockPath, JSON.stringify(lock, null, 2) + '\n');
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
plan, actions, into, configPath, dryRun, mode, adopting, force, firstInstall,
|
|
290
|
+
managedCount: Object.keys(managed).length, generated,
|
|
291
|
+
snapshotted: snapshot.restore, foldedClaude,
|
|
292
|
+
pruned, imported, importedNow, foreign, warnings, changes,
|
|
293
|
+
preexisting, preexistingCount,
|
|
294
|
+
versionFrom: oldLock ? oldLock.engsysVersion : null, versionTo: ENGSYS_VERSION,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// uninstall: remove everything engsys added and restore the pre-install originals.
|
|
299
|
+
// The project's own agents/files (never in the lock) are left untouched.
|
|
300
|
+
function runUninstall(opts) {
|
|
301
|
+
const { into, dryRun } = opts;
|
|
302
|
+
const claudeDir = path.join(into, '.claude');
|
|
303
|
+
const lockPath = path.join(claudeDir, 'engsys.lock');
|
|
304
|
+
if (!exists(lockPath)) throw new Error(`No engsys install found in ${into} (.claude/engsys.lock missing).`);
|
|
305
|
+
const lock = JSON.parse(readText(lockPath));
|
|
306
|
+
|
|
307
|
+
const manifestPath = path.join(into, BACKUP_DIR, 'manifest.json');
|
|
308
|
+
const hadManifest = exists(manifestPath);
|
|
309
|
+
const manifest = hadManifest ? (safeJson(readText(manifestPath)) || { restore: [] }) : { restore: [] };
|
|
310
|
+
const restoreSet = new Set(manifest.restore || []);
|
|
311
|
+
|
|
312
|
+
const engsysFiles = [
|
|
313
|
+
...Object.keys(lock.managed || {}),
|
|
314
|
+
...(lock.generated || []),
|
|
315
|
+
...(lock.imported || []),
|
|
316
|
+
];
|
|
317
|
+
const removed = [], restored = [];
|
|
318
|
+
|
|
319
|
+
// Delete engsys-created files (anything in the lock that wasn't a pre-existing original).
|
|
320
|
+
for (const rel of engsysFiles) {
|
|
321
|
+
if (restoreSet.has(rel)) continue;
|
|
322
|
+
if (!dryRun && exists(path.join(into, rel))) removeAndPrune(path.join(into, rel), into);
|
|
323
|
+
removed.push(rel);
|
|
324
|
+
}
|
|
325
|
+
// Restore originals engsys overwrote/merged.
|
|
326
|
+
for (const rel of restoreSet) {
|
|
327
|
+
const bsrc = path.join(into, BACKUP_DIR, 'files', rel);
|
|
328
|
+
if (!exists(bsrc)) continue;
|
|
329
|
+
if (!dryRun) { ensureDir(path.dirname(path.join(into, rel))); fs.copyFileSync(bsrc, path.join(into, rel)); }
|
|
330
|
+
restored.push(rel);
|
|
331
|
+
}
|
|
332
|
+
// Remove engsys bookkeeping (lock + backup dir).
|
|
333
|
+
if (!dryRun) {
|
|
334
|
+
removeAndPrune(lockPath, into);
|
|
335
|
+
fs.rmSync(path.join(into, BACKUP_DIR), { recursive: true, force: true });
|
|
336
|
+
}
|
|
337
|
+
return { into, dryRun, removed, restored, hadManifest,
|
|
338
|
+
preexisting: detectPreexisting(into, new Set(Object.keys(lock.managed || {}))) };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// verify: compare on-disk managed files against the lock's hashes.
|
|
342
|
+
function runVerify(opts) {
|
|
343
|
+
const { into } = opts;
|
|
344
|
+
const lockPath = path.join(into, '.claude', 'engsys.lock');
|
|
345
|
+
if (!exists(lockPath)) throw new Error(`No engsys.lock in ${into}/.claude — run install first.`);
|
|
346
|
+
const lock = JSON.parse(readText(lockPath));
|
|
347
|
+
const missing = [], modified = [];
|
|
348
|
+
for (const [rel, hash] of Object.entries(lock.managed || {})) {
|
|
349
|
+
const abs = path.join(into, rel);
|
|
350
|
+
if (!exists(abs)) { missing.push(rel); continue; }
|
|
351
|
+
if (sha256(readText(abs)) !== hash) modified.push(rel);
|
|
352
|
+
}
|
|
353
|
+
return { lock, missing, modified, ok: !missing.length && !modified.length };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = { runInstall, runUninstall, runVerify, ENGSYS_VERSION };
|