create-ailk 0.19.0 → 0.20.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/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
package/dist/update.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create-ailk update [<target-dir>]` — bring template fixes into a site's
|
|
3
|
+
* already-copied `apps/web`, `apps/api` and `apps/mcp` code (#8522).
|
|
4
|
+
*
|
|
5
|
+
* Reads `.ailk-template-files.json` (`template-record.ts`) to tell an
|
|
6
|
+
* untouched copy from an owner's edit. An untouched file whose template
|
|
7
|
+
* copy changed is overwritten. An edited file whose template copy changed
|
|
8
|
+
* is three-way merged (`lib/merge-three-way.ts`) against its recorded
|
|
9
|
+
* version's copy — read from this package's own `templates/` when that is
|
|
10
|
+
* the running version, else re-materialised from the published npm tarball
|
|
11
|
+
* (`lib/fetch-template-snapshot.ts`) — and the running version's copy. A
|
|
12
|
+
* conflict leaves the file untouched and is listed under "Needs review". A
|
|
13
|
+
* site with no record gets one backfilled from `.ailk-site-manifest.json`'s
|
|
14
|
+
* `patternVersion` on its first run, then updates normally. Every refusal
|
|
15
|
+
* throws before the first write.
|
|
16
|
+
*
|
|
17
|
+
* Pure module: no `import.meta.url` — the CLI supplies `templatesDir`, the
|
|
18
|
+
* `adopt.ts` / `add-route.ts` split.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
22
|
+
import { parseArgs } from "node:util";
|
|
23
|
+
import { AddModuleRefusedError, readArchitectureHeader } from "./add-module-core.js";
|
|
24
|
+
import { buildScaffoldMarkers, isExcluded, isTextFile, renderTemplateFile, templateDestination } from "./copy.js";
|
|
25
|
+
import { fetchTemplateSnapshot } from "./lib/fetch-template-snapshot.js";
|
|
26
|
+
import { mergeThreeWay } from "./lib/merge-three-way.js";
|
|
27
|
+
import { ParseError } from "./parse-args.js";
|
|
28
|
+
import { SITE_ROSTER_PATH } from "./site-roster.js";
|
|
29
|
+
import { excludedRoutePaths } from "./surfaces.js";
|
|
30
|
+
import { TEMPLATE_RECORD_PATH, isRecordable, parseTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
|
|
31
|
+
import { TopologyError, isSpokeExcluded, readSpokeShape, spokeExclusions, } from "./topology.js";
|
|
32
|
+
/** A refusal the CLI exits 2 on, thrown before anything is written. */
|
|
33
|
+
export class UpdateRefusedError extends Error {
|
|
34
|
+
exitCode = 2;
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "UpdateRefusedError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** `buildScaffoldMarkers`, with `CREATE_AILK_VERSION` set to the version being rendered (D3). */
|
|
41
|
+
function markersForVersion(projectName, version) {
|
|
42
|
+
return { ...buildScaffoldMarkers(projectName), CREATE_AILK_VERSION: version };
|
|
43
|
+
}
|
|
44
|
+
const VERSION_TUPLE = /^(\d+)\.(\d+)\.(\d+)/;
|
|
45
|
+
/** `true` iff semver `a` is strictly older than semver `b` (major, minor, patch, numerically). */
|
|
46
|
+
function versionOlder(a, b) {
|
|
47
|
+
const ma = VERSION_TUPLE.exec(a);
|
|
48
|
+
const mb = VERSION_TUPLE.exec(b);
|
|
49
|
+
if (ma === null || mb === null)
|
|
50
|
+
return false;
|
|
51
|
+
for (let i = 1; i <= 3; i++) {
|
|
52
|
+
const na = Number(ma[i]);
|
|
53
|
+
const nb = Number(mb[i]);
|
|
54
|
+
if (na !== nb)
|
|
55
|
+
return na < nb;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/** Every file under `templatesDir`'s three app trees, raw bytes keyed by destination path (D3). */
|
|
60
|
+
function walkLocalTemplates(templatesDir) {
|
|
61
|
+
const out = new Map();
|
|
62
|
+
for (const app of ["web", "api", "mcp"]) {
|
|
63
|
+
const root = join(templatesDir, "apps", app);
|
|
64
|
+
if (!existsSync(root))
|
|
65
|
+
continue;
|
|
66
|
+
const stack = [root];
|
|
67
|
+
while (stack.length > 0) {
|
|
68
|
+
const dir = stack.pop();
|
|
69
|
+
if (dir === undefined)
|
|
70
|
+
break;
|
|
71
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
72
|
+
const abs = join(dir, entry.name);
|
|
73
|
+
if (entry.isSymbolicLink())
|
|
74
|
+
continue;
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
stack.push(abs);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const rel = `apps/${app}/${relativeToRoot(root, abs)}`;
|
|
80
|
+
const dest = templateDestination(rel);
|
|
81
|
+
if (dest === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
out.set(dest, readFileSync(abs));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** `abs`'s path relative to `root` (both under the same app directory), `/`-separated. */
|
|
90
|
+
function relativeToRoot(root, abs) {
|
|
91
|
+
return abs.slice(root.length + 1).split(sep).join("/");
|
|
92
|
+
}
|
|
93
|
+
/** The running or an old template version's recordable files, rendered (D3). */
|
|
94
|
+
function renderSnapshot(raw, projectName, version) {
|
|
95
|
+
const markers = markersForVersion(projectName, version);
|
|
96
|
+
const out = new Map();
|
|
97
|
+
for (const [dest, bytes] of raw) {
|
|
98
|
+
if (!isRecordable(dest))
|
|
99
|
+
continue;
|
|
100
|
+
out.set(dest, renderTemplateFile(bytes, dest, {}, markers));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** The root `package.json` `name`, falling back to the target's basename (D8). */
|
|
105
|
+
function backfillProjectName(targetDir) {
|
|
106
|
+
try {
|
|
107
|
+
const pkg = JSON.parse(readFileSync(join(targetDir, "package.json"), "utf8"));
|
|
108
|
+
if (typeof pkg.name === "string" && pkg.name.length > 0)
|
|
109
|
+
return pkg.name;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// no readable root package.json — fall back below.
|
|
113
|
+
}
|
|
114
|
+
return targetDir.split(sep).filter(Boolean).pop() ?? targetDir;
|
|
115
|
+
}
|
|
116
|
+
/** The highest directory or file missing on disk on `destRel`'s chain below `apps/<app>/` (D4 rule iii). */
|
|
117
|
+
function highestMissingAncestor(destRel, targetDir) {
|
|
118
|
+
const segments = destRel.split("/");
|
|
119
|
+
let current = segments.slice(0, 2).join("/");
|
|
120
|
+
for (let i = 2; i < segments.length; i++) {
|
|
121
|
+
current = `${current}/${segments[i]}`;
|
|
122
|
+
if (!existsSync(join(targetDir, ...current.split("/"))))
|
|
123
|
+
return current;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
/** Whether `prefix` (or something under it) is one of `knownPaths` (D4 rule iii). */
|
|
128
|
+
function containsKnownPath(prefix, knownPaths) {
|
|
129
|
+
for (const path of knownPaths) {
|
|
130
|
+
if (path === prefix || path.startsWith(`${prefix}/`))
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
export async function updateSite(opts) {
|
|
136
|
+
const targetDir = resolve(opts.targetDir);
|
|
137
|
+
const version = opts.version;
|
|
138
|
+
// ── Load the record, or determine the backfill (D8) ──────────────────
|
|
139
|
+
const recordPath = join(targetDir, TEMPLATE_RECORD_PATH);
|
|
140
|
+
let record;
|
|
141
|
+
let backfillPatternVersion;
|
|
142
|
+
if (existsSync(recordPath)) {
|
|
143
|
+
try {
|
|
144
|
+
record = parseTemplateRecord(readFileSync(recordPath, "utf8"));
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
throw new UpdateRefusedError(err.message);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const manifestPath = join(targetDir, SITE_ROSTER_PATH);
|
|
152
|
+
let patternVersion;
|
|
153
|
+
if (existsSync(manifestPath)) {
|
|
154
|
+
try {
|
|
155
|
+
const doc = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
156
|
+
const value = doc["patternVersion"];
|
|
157
|
+
if (typeof value === "string" && value.length > 0)
|
|
158
|
+
patternVersion = value;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// an unreadable manifest is the same refusal as a missing one, below.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (patternVersion === undefined) {
|
|
165
|
+
throw new UpdateRefusedError(`no ${TEMPLATE_RECORD_PATH} and no "patternVersion" in ${SITE_ROSTER_PATH} to rebuild it from; run \`create-ailk adopt\` first`);
|
|
166
|
+
}
|
|
167
|
+
backfillPatternVersion = patternVersion;
|
|
168
|
+
record = { schema: 1, projectName: backfillProjectName(targetDir), files: {} };
|
|
169
|
+
}
|
|
170
|
+
// ── Refuse a downgrade before any fetch (D8) ──────────────────────────
|
|
171
|
+
const recordedVersions = backfillPatternVersion !== undefined
|
|
172
|
+
? [backfillPatternVersion]
|
|
173
|
+
: [...new Set(Object.values(record.files).map((row) => row.version))];
|
|
174
|
+
for (const recorded of recordedVersions) {
|
|
175
|
+
if (versionOlder(version, recorded)) {
|
|
176
|
+
throw new UpdateRefusedError(`create-ailk ${version} is older than ${recorded}, already recorded in ${TEMPLATE_RECORD_PATH} — use a newer create-ailk to run update`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ── The site's scope: gated-off surfaces and spoke exclusions (D4 i/ii) ─
|
|
180
|
+
const archPath = join(targetDir, "architecture.yaml");
|
|
181
|
+
if (!existsSync(archPath)) {
|
|
182
|
+
throw new UpdateRefusedError(`${targetDir} has no architecture.yaml; run \`create-ailk adopt\` or \`--sync-architecture\` first`);
|
|
183
|
+
}
|
|
184
|
+
let header;
|
|
185
|
+
try {
|
|
186
|
+
header = readArchitectureHeader(readFileSync(archPath, "utf8"));
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
if (err instanceof AddModuleRefusedError) {
|
|
190
|
+
throw new UpdateRefusedError(err.message);
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
let spoke;
|
|
195
|
+
try {
|
|
196
|
+
spoke = readSpokeShape(targetDir);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
if (err instanceof TopologyError)
|
|
200
|
+
throw new UpdateRefusedError(err.message);
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
const excludedRoutes = excludedRoutePaths(header.surfaces);
|
|
204
|
+
const spokeExclusionList = spoke !== undefined ? spokeExclusions(opts.templatesDir, spoke) : undefined;
|
|
205
|
+
const outOfScope = (destRel) => {
|
|
206
|
+
if (isExcluded(destRel, excludedRoutes))
|
|
207
|
+
return true;
|
|
208
|
+
if (spoke !== undefined && spokeExclusionList !== undefined) {
|
|
209
|
+
return isSpokeExcluded(destRel, { ...spoke, exclusions: spokeExclusionList });
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
};
|
|
213
|
+
// ── The running version's rendered copies ─────────────────────────────
|
|
214
|
+
const runningRaw = walkLocalTemplates(opts.templatesDir);
|
|
215
|
+
const newMap = renderSnapshot(runningRaw, record.projectName, version);
|
|
216
|
+
// ── A cache of rendered copies by version, fetched at most once each ──
|
|
217
|
+
const versionCache = new Map();
|
|
218
|
+
async function loadVersionMap(v) {
|
|
219
|
+
const cached = versionCache.get(v);
|
|
220
|
+
if (cached !== undefined)
|
|
221
|
+
return cached;
|
|
222
|
+
let result;
|
|
223
|
+
if (v === version) {
|
|
224
|
+
result = newMap;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
try {
|
|
228
|
+
const raw = await fetchTemplateSnapshot({
|
|
229
|
+
version: v,
|
|
230
|
+
...(opts.registryUrl !== undefined ? { registryUrl: opts.registryUrl } : {}),
|
|
231
|
+
...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
|
|
232
|
+
});
|
|
233
|
+
result = renderSnapshot(raw, record.projectName, v);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
result = { error: err.message };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
versionCache.set(v, result);
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
// ── The backfill: fetch the old base and build the synthetic record ──
|
|
243
|
+
let knownSnapshotPaths;
|
|
244
|
+
if (backfillPatternVersion !== undefined) {
|
|
245
|
+
const base = await loadVersionMap(backfillPatternVersion);
|
|
246
|
+
if ("error" in base) {
|
|
247
|
+
throw new UpdateRefusedError(`backfill: the ${backfillPatternVersion} template could not be read: ${base.error}`);
|
|
248
|
+
}
|
|
249
|
+
knownSnapshotPaths = new Set(base.keys());
|
|
250
|
+
const files = {};
|
|
251
|
+
for (const [path, bytes] of [...base.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
252
|
+
if (outOfScope(path))
|
|
253
|
+
continue;
|
|
254
|
+
if (!existsSync(join(targetDir, ...path.split("/"))))
|
|
255
|
+
continue;
|
|
256
|
+
files[path] = { sha256: sha256Hex(bytes), version: backfillPatternVersion };
|
|
257
|
+
}
|
|
258
|
+
record = { schema: 1, projectName: record.projectName, files };
|
|
259
|
+
}
|
|
260
|
+
// ── Every recorded row (D4) ────────────────────────────────────────────
|
|
261
|
+
const updated = [];
|
|
262
|
+
const merged = [];
|
|
263
|
+
const added = [];
|
|
264
|
+
const removed = [];
|
|
265
|
+
const current = [];
|
|
266
|
+
const review = [];
|
|
267
|
+
const skipped = [];
|
|
268
|
+
const nextFiles = { ...record.files };
|
|
269
|
+
const writes = [];
|
|
270
|
+
const deletes = [];
|
|
271
|
+
for (const path of Object.keys(record.files).sort()) {
|
|
272
|
+
const rec = record.files[path];
|
|
273
|
+
if (outOfScope(path)) {
|
|
274
|
+
skipped.push({ path, reason: "outside this site's scope" });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const abs = join(targetDir, ...path.split("/"));
|
|
278
|
+
if (!existsSync(abs)) {
|
|
279
|
+
skipped.push({ path, reason: "deleted locally" });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const diskBytes = readFileSync(abs);
|
|
283
|
+
const diskHash = sha256Hex(diskBytes);
|
|
284
|
+
const edited = diskHash !== rec.sha256;
|
|
285
|
+
const newBytes = newMap.get(path);
|
|
286
|
+
if (!edited) {
|
|
287
|
+
if (newBytes === undefined) {
|
|
288
|
+
deletes.push(path);
|
|
289
|
+
delete nextFiles[path];
|
|
290
|
+
removed.push(path);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const newHash = sha256Hex(newBytes);
|
|
294
|
+
if (newHash === rec.sha256)
|
|
295
|
+
continue; // no template change
|
|
296
|
+
writes.push({ path, bytes: Buffer.from(newBytes) });
|
|
297
|
+
nextFiles[path] = { sha256: newHash, version };
|
|
298
|
+
updated.push(path);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// Edited.
|
|
302
|
+
if (newBytes === undefined) {
|
|
303
|
+
review.push({ path, reason: `edited here, removed from the template in ${version}` });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const newHash = sha256Hex(newBytes);
|
|
307
|
+
if (newHash === rec.sha256)
|
|
308
|
+
continue; // template unchanged for this file
|
|
309
|
+
if (diskHash === newHash) {
|
|
310
|
+
nextFiles[path] = { sha256: newHash, version };
|
|
311
|
+
current.push(path);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// Template changed, and the edit does not already match it: three-way merge.
|
|
315
|
+
const base = await loadVersionMap(rec.version);
|
|
316
|
+
if ("error" in base) {
|
|
317
|
+
review.push({
|
|
318
|
+
path,
|
|
319
|
+
reason: `edited here, and the ${rec.version} template could not be read: ${base.error}`,
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const baseBytes = base.get(path);
|
|
324
|
+
if (baseBytes === undefined) {
|
|
325
|
+
review.push({
|
|
326
|
+
path,
|
|
327
|
+
reason: `edited here, and the ${rec.version} template could not be read: not present in that version's snapshot`,
|
|
328
|
+
});
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (sha256Hex(baseBytes) !== rec.sha256) {
|
|
332
|
+
review.push({
|
|
333
|
+
path,
|
|
334
|
+
reason: `edited here, and the ${rec.version} template copy does not match the record`,
|
|
335
|
+
});
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!isTextFile(path)) {
|
|
339
|
+
review.push({ path, reason: "edited here; binary files are not merged" });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const mergeResult = mergeThreeWay({
|
|
343
|
+
ours: diskBytes.toString("utf8"),
|
|
344
|
+
base: Buffer.from(baseBytes).toString("utf8"),
|
|
345
|
+
theirs: Buffer.from(newBytes).toString("utf8"),
|
|
346
|
+
oursLabel: "yours",
|
|
347
|
+
baseLabel: `template ${rec.version}`,
|
|
348
|
+
theirsLabel: `template ${version}`,
|
|
349
|
+
});
|
|
350
|
+
if (mergeResult.status === "merged") {
|
|
351
|
+
const mergedBytes = Buffer.from(mergeResult.text, "utf8");
|
|
352
|
+
writes.push({ path, bytes: mergedBytes });
|
|
353
|
+
// D1/D5 — the row keeps the TEMPLATE's hash, never the merged disk
|
|
354
|
+
// bytes': that is what lets the next update still see the owner's
|
|
355
|
+
// edit (the disk bytes keep differing from the recorded hash) and
|
|
356
|
+
// merge against the right base, instead of reading a now-current
|
|
357
|
+
// file as "edited" against its own past self forever.
|
|
358
|
+
nextFiles[path] = { sha256: newHash, version };
|
|
359
|
+
merged.push(path);
|
|
360
|
+
}
|
|
361
|
+
else if (mergeResult.status === "conflict") {
|
|
362
|
+
review.push({
|
|
363
|
+
path,
|
|
364
|
+
reason: `conflicts with the template change ${rec.version} → ${version}`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
review.push({ path, reason: `the three-way merge could not run: ${mergeResult.reason}` });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// ── Every recordable path the running template has that this record does not (D4) ──
|
|
372
|
+
const knownPaths = new Set([...Object.keys(record.files), ...(knownSnapshotPaths ?? [])]);
|
|
373
|
+
for (const path of [...newMap.keys()].sort()) {
|
|
374
|
+
if (Object.prototype.hasOwnProperty.call(record.files, path))
|
|
375
|
+
continue;
|
|
376
|
+
const abs = join(targetDir, ...path.split("/"));
|
|
377
|
+
const newBytes = newMap.get(path);
|
|
378
|
+
if (existsSync(abs)) {
|
|
379
|
+
const diskBytes = readFileSync(abs);
|
|
380
|
+
if (sha256Hex(diskBytes) !== sha256Hex(newBytes)) {
|
|
381
|
+
skipped.push({ path, reason: "exists, not in the record" });
|
|
382
|
+
}
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (outOfScope(path))
|
|
386
|
+
continue; // rule i/ii
|
|
387
|
+
const highest = highestMissingAncestor(path, targetDir);
|
|
388
|
+
if (highest !== undefined && containsKnownPath(highest, knownPaths))
|
|
389
|
+
continue; // rule iii
|
|
390
|
+
writes.push({ path, bytes: Buffer.from(newBytes) });
|
|
391
|
+
nextFiles[path] = { sha256: sha256Hex(newBytes), version };
|
|
392
|
+
added.push(path);
|
|
393
|
+
}
|
|
394
|
+
// ── Apply: writes and removals, then the record (D4 Order) ───────────
|
|
395
|
+
for (const write of writes) {
|
|
396
|
+
const abs = join(targetDir, ...write.path.split("/"));
|
|
397
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
398
|
+
writeFileSync(abs, write.bytes);
|
|
399
|
+
}
|
|
400
|
+
for (const path of deletes) {
|
|
401
|
+
rmSync(join(targetDir, ...path.split("/")));
|
|
402
|
+
}
|
|
403
|
+
const backfilled = backfillPatternVersion !== undefined
|
|
404
|
+
? { version: backfillPatternVersion, files: Object.keys(record.files).length }
|
|
405
|
+
: undefined;
|
|
406
|
+
const recordChanged = backfilled !== undefined ||
|
|
407
|
+
updated.length > 0 ||
|
|
408
|
+
merged.length > 0 ||
|
|
409
|
+
added.length > 0 ||
|
|
410
|
+
removed.length > 0 ||
|
|
411
|
+
current.length > 0;
|
|
412
|
+
if (recordChanged) {
|
|
413
|
+
writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
targetDir,
|
|
417
|
+
version,
|
|
418
|
+
updated,
|
|
419
|
+
merged,
|
|
420
|
+
added,
|
|
421
|
+
removed,
|
|
422
|
+
current,
|
|
423
|
+
review,
|
|
424
|
+
skipped,
|
|
425
|
+
...(backfilled !== undefined ? { backfilled } : {}),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** The printed report (D9): one section per non-empty list, in order. */
|
|
429
|
+
export function formatUpdateReport(result) {
|
|
430
|
+
const lines = [`[create-ailk] update → ${result.targetDir}`];
|
|
431
|
+
if (result.backfilled !== undefined) {
|
|
432
|
+
lines.push(`Record written from ${result.backfilled.version}: ${result.backfilled.files} files`);
|
|
433
|
+
}
|
|
434
|
+
const sections = [
|
|
435
|
+
["Updated", result.updated],
|
|
436
|
+
["Merged", result.merged],
|
|
437
|
+
["Added", result.added],
|
|
438
|
+
["Removed", result.removed],
|
|
439
|
+
["Already current", result.current],
|
|
440
|
+
];
|
|
441
|
+
for (const [label, paths] of sections) {
|
|
442
|
+
if (paths.length === 0)
|
|
443
|
+
continue;
|
|
444
|
+
lines.push(`${label} (${paths.length}):`);
|
|
445
|
+
lines.push(...paths.map((path) => ` ${path}`));
|
|
446
|
+
}
|
|
447
|
+
if (result.review.length > 0) {
|
|
448
|
+
lines.push(`Needs review (${result.review.length}):`);
|
|
449
|
+
lines.push(...result.review.map((row) => ` ${row.path} — ${row.reason}`));
|
|
450
|
+
}
|
|
451
|
+
if (result.skipped.length > 0) {
|
|
452
|
+
lines.push(`Skipped (${result.skipped.length}):`);
|
|
453
|
+
lines.push(...result.skipped.map((row) => ` ${row.path} — ${row.reason}`));
|
|
454
|
+
}
|
|
455
|
+
const nothingHappened = result.backfilled === undefined &&
|
|
456
|
+
result.updated.length === 0 &&
|
|
457
|
+
result.merged.length === 0 &&
|
|
458
|
+
result.added.length === 0 &&
|
|
459
|
+
result.removed.length === 0 &&
|
|
460
|
+
result.current.length === 0 &&
|
|
461
|
+
result.review.length === 0 &&
|
|
462
|
+
result.skipped.length === 0;
|
|
463
|
+
if (nothingHappened) {
|
|
464
|
+
return `[create-ailk] Already up to date (create-ailk ${result.version}).\n`;
|
|
465
|
+
}
|
|
466
|
+
return lines.join("\n") + "\n";
|
|
467
|
+
}
|
|
468
|
+
export const UPDATE_HELP = `create-ailk update — bring template fixes into a site's copied app code.
|
|
469
|
+
|
|
470
|
+
Usage:
|
|
471
|
+
create-ailk update [<target-dir>]
|
|
472
|
+
|
|
473
|
+
Arguments:
|
|
474
|
+
<target-dir> The existing site. Defaults to the current dir.
|
|
475
|
+
|
|
476
|
+
Options:
|
|
477
|
+
--help, -h Print this message.
|
|
478
|
+
|
|
479
|
+
Reads .ailk-template-files.json (written beside .ailk-site-manifest.json by
|
|
480
|
+
every template scaffold) to tell an untouched copy from an edit. An
|
|
481
|
+
untouched apps/web, apps/api or apps/mcp file whose template copy changed is
|
|
482
|
+
overwritten. An edited file whose template copy also changed is three-way
|
|
483
|
+
merged with \`git\` (git must be on PATH); a conflict leaves the file alone
|
|
484
|
+
and is listed under "Needs review" — nothing else in the run is held back.
|
|
485
|
+
A site with no record gets one on its first run, from the version its
|
|
486
|
+
.ailk-site-manifest.json already records. Commit before running, and review
|
|
487
|
+
the diff after.
|
|
488
|
+
`;
|
|
489
|
+
/** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
|
|
490
|
+
export function parseUpdateArgs(argv) {
|
|
491
|
+
const parsed = parseArgs({
|
|
492
|
+
args: argv,
|
|
493
|
+
strict: true,
|
|
494
|
+
allowPositionals: true,
|
|
495
|
+
options: {
|
|
496
|
+
help: { type: "boolean", default: false, short: "h" },
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
const [target, ...rest] = parsed.positionals;
|
|
500
|
+
if (rest.length > 0) {
|
|
501
|
+
throw new ParseError(`update takes at most one target directory, got ${parsed.positionals.length} arguments`);
|
|
502
|
+
}
|
|
503
|
+
return { target, help: parsed.values.help === true };
|
|
504
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ailk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"tar": "^7.5.21"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@working-theory/schema": "0.
|
|
32
|
-
"@working-theory/templates": "0.
|
|
31
|
+
"@working-theory/schema": "0.41.0",
|
|
32
|
+
"@working-theory/templates": "0.41.0"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PostToolUse (Edit|Write): run the architecture boundary lint after an edit.
|
|
3
|
+
# Exits 0 until dependencies are installed, and 2 on a violation so Claude
|
|
4
|
+
# reads the finding. Registered in .claude/settings.json and declared in
|
|
5
|
+
# .claude/cloudparity-repo-hooks.json; keep the two in step.
|
|
6
|
+
cd "$CLAUDE_PROJECT_DIR" || exit 0
|
|
7
|
+
[ -x node_modules/.bin/tsx ] || exit 0
|
|
8
|
+
pnpm --silent architecture >&2 || exit 2
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Per-Issue Workflow
|
|
2
|
+
|
|
3
|
+
The contract every change goes through. One issue in, one PR out, no
|
|
4
|
+
scope creep.
|
|
5
|
+
|
|
6
|
+
This is the single-developer flow. If you're working solo on this
|
|
7
|
+
project, this is what every meaningful change looks like.
|
|
8
|
+
|
|
9
|
+
## Pick
|
|
10
|
+
|
|
11
|
+
Open an issue or claim one from your backlog. The issue must have:
|
|
12
|
+
|
|
13
|
+
- A `## Acceptance Criteria` section listing observable, verifiable
|
|
14
|
+
outcomes.
|
|
15
|
+
- A `## Scope` section listing the files or path globs you intend
|
|
16
|
+
to touch.
|
|
17
|
+
|
|
18
|
+
If either is missing, write them before you start — not as a
|
|
19
|
+
formality but because the rest of the workflow depends on them.
|
|
20
|
+
|
|
21
|
+
## Read
|
|
22
|
+
|
|
23
|
+
Before any edits:
|
|
24
|
+
|
|
25
|
+
1. The issue body in full — every acceptance bullet, scope path,
|
|
26
|
+
and dependency note.
|
|
27
|
+
2. `CLAUDE.md` — the operating manual you're already reading.
|
|
28
|
+
3. The files inside `## Scope` — confirm they exist and your mental
|
|
29
|
+
model matches what's actually there.
|
|
30
|
+
|
|
31
|
+
If anything is missing or the issue is infeasible as written, stop
|
|
32
|
+
and refine the issue before coding.
|
|
33
|
+
|
|
34
|
+
## Branch
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
git checkout main
|
|
38
|
+
git pull --ff-only origin main
|
|
39
|
+
git checkout -b issue-<N>-<kebab-slug>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use a 3–6 word slug from the issue title. Branch names show up in
|
|
43
|
+
your PR list — make them scannable.
|
|
44
|
+
|
|
45
|
+
## TDD: RED → GREEN
|
|
46
|
+
|
|
47
|
+
For functional acceptance criteria:
|
|
48
|
+
|
|
49
|
+
1. Write tests from the criteria first. They should fail (RED) —
|
|
50
|
+
your implementation doesn't exist yet.
|
|
51
|
+
2. Implement the smallest change that makes the tests pass (GREEN).
|
|
52
|
+
3. Refactor if the implementation is messy. Tests stay green
|
|
53
|
+
throughout.
|
|
54
|
+
|
|
55
|
+
Skip TDD only for: pure documentation, prompt-only changes, or
|
|
56
|
+
single-line fixes already covered by an existing test.
|
|
57
|
+
|
|
58
|
+
## Verify
|
|
59
|
+
|
|
60
|
+
Before commit AND before push:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm preflight
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This runs `architecture` + `claude-md:check` + `type-check` + `lint` +
|
|
67
|
+
`build`. It runs no tests, so it is not a stand-in for CI: run
|
|
68
|
+
`pnpm test` as well whenever your change has tests, and read CI's own
|
|
69
|
+
result on the PR.
|
|
70
|
+
|
|
71
|
+
For PRs touching specific surfaces:
|
|
72
|
+
|
|
73
|
+
- `database/` schema changes — also run `pnpm db:migrate` locally if
|
|
74
|
+
you added a migration.
|
|
75
|
+
|
|
76
|
+
## Commit + PR
|
|
77
|
+
|
|
78
|
+
- Commit message format: `feat(area): description (#N)`. Include
|
|
79
|
+
the issue number when one exists.
|
|
80
|
+
- Multiple commits on the branch are fine; squash-merge collapses
|
|
81
|
+
them.
|
|
82
|
+
- PR title: same as the commit message format.
|
|
83
|
+
- PR body must include:
|
|
84
|
+
- **Summary** — what the PR does, in plain language.
|
|
85
|
+
- **Scope** — paths actually touched (a subset of the issue's
|
|
86
|
+
scope).
|
|
87
|
+
- **Verification** — checklist of what you ran, with
|
|
88
|
+
`[x] pnpm preflight` ticked.
|
|
89
|
+
- `Closes #N` line — required for issue PRs; triggers GitHub's
|
|
90
|
+
auto-close on merge.
|
|
91
|
+
|
|
92
|
+
## After PR open
|
|
93
|
+
|
|
94
|
+
1. Wait for CI. If it goes red, diagnose and fix; do not merge red.
|
|
95
|
+
2. Wait for any automated reviewers (Bugbot, code review bots) to
|
|
96
|
+
return. Address every finding — either fix it or write a short
|
|
97
|
+
reply explaining why it's not actionable.
|
|
98
|
+
3. Merge once CI is green and reviews are reconciled.
|
|
99
|
+
|
|
100
|
+
## After merge
|
|
101
|
+
|
|
102
|
+
1. Close the issue if the PR didn't auto-close it.
|
|
103
|
+
2. Delete the local and remote branch.
|
|
104
|
+
3. Pick the next issue.
|
|
105
|
+
|
|
106
|
+
## Mid-task scope expansion
|
|
107
|
+
|
|
108
|
+
If you realize mid-task that the issue's `## Scope` was insufficient:
|
|
109
|
+
|
|
110
|
+
1. Stop before writing outside scope.
|
|
111
|
+
2. Update the PR description to add the new path with a one-line
|
|
112
|
+
reason.
|
|
113
|
+
3. Continue.
|
|
114
|
+
|
|
115
|
+
That short pause is the entire point — it forces you to acknowledge
|
|
116
|
+
that the work has grown, instead of letting it grow silently.
|
|
117
|
+
|
|
118
|
+
## When to escalate
|
|
119
|
+
|
|
120
|
+
Stop and surface the question rather than make the call alone if:
|
|
121
|
+
|
|
122
|
+
- An architecture invariant in `.claude/rules/architecture.md` would
|
|
123
|
+
have to bend.
|
|
124
|
+
- A change to a published `@working-theory/*` package is needed (file the
|
|
125
|
+
issue upstream; don't fork locally).
|
|
126
|
+
- The acceptance criteria contradict each other or the existing code.
|
|
127
|
+
- You'd be deleting or moving more than five files at once.
|
|
128
|
+
|
|
129
|
+
A short clarifying question beats a long detour down the wrong path.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "
|
|
9
|
+
"command": "bash .claude/hooks/architecture-check.sh"
|
|
10
10
|
}
|
|
11
11
|
]
|
|
12
12
|
}
|
|
@@ -23,5 +23,6 @@
|
|
|
23
23
|
},
|
|
24
24
|
"enabledPlugins": {
|
|
25
25
|
"ailk@working-theory": true
|
|
26
|
-
}
|
|
26
|
+
},
|
|
27
|
+
"x-cloudparity-managed-keys": ["hooks"]
|
|
27
28
|
}
|