its-magic 0.1.2 → 0.1.3-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.
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { spawnSync } = require("child_process");
5
+
2
6
  const M = "\x1b[1;35m";
3
7
  const C = "\x1b[1;36m";
4
8
  const Y = "\x1b[1;33m";
@@ -19,3 +23,71 @@ console.log(`${G} Installation complete!${R}`);
19
23
  console.log("");
20
24
  console.log(`${W} Run: its-magic --help${R}`);
21
25
  console.log("");
26
+
27
+ const pkgRoot = path.resolve(__dirname, "..");
28
+ const templateRoot = path.join(pkgRoot, "template");
29
+ const bootstrapScript = path.join(pkgRoot, "scripts", "dev_environment_lib.py");
30
+
31
+ function detectConsumerRepoRoot() {
32
+ let dir = process.cwd();
33
+ for (let depth = 0; depth <= 6; depth += 1) {
34
+ const scratchpad = path.join(dir, ".cursor", "scratchpad.md");
35
+ const versionSentinel = path.join(dir, "its_magic", ".its-magic-version");
36
+ if (fs.existsSync(scratchpad) || fs.existsSync(versionSentinel)) {
37
+ return dir;
38
+ }
39
+ const parent = path.dirname(dir);
40
+ if (parent === dir) {
41
+ break;
42
+ }
43
+ dir = parent;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function resolvePythonCommand() {
49
+ if (process.platform === "win32") {
50
+ return "python";
51
+ }
52
+ return "python3";
53
+ }
54
+
55
+ function runDevEnvBootstrap() {
56
+ const repoRoot = detectConsumerRepoRoot();
57
+ if (!repoRoot) {
58
+ console.log("[DEV_ENV_BOOTSTRAP_SKIP] no consumer repository detected");
59
+ return;
60
+ }
61
+ if (!fs.existsSync(bootstrapScript)) {
62
+ console.log(
63
+ "[DEV_ENV_BOOTSTRAP_ERROR] bootstrap helper missing; copy template/.cursor/dev-environment.json.example manually"
64
+ );
65
+ return;
66
+ }
67
+ const py = resolvePythonCommand();
68
+ const res = spawnSync(
69
+ py,
70
+ [
71
+ bootstrapScript,
72
+ "--bootstrap",
73
+ "--target",
74
+ repoRoot,
75
+ "--source-root",
76
+ templateRoot,
77
+ ],
78
+ { encoding: "utf-8" }
79
+ );
80
+ if (res.stdout) {
81
+ process.stdout.write(res.stdout);
82
+ }
83
+ if (res.stderr) {
84
+ process.stderr.write(res.stderr);
85
+ }
86
+ if (res.status === 1) {
87
+ console.log(
88
+ "[DEV_ENV_BOOTSTRAP_ERROR] bootstrap failed; copy template/.cursor/dev-environment.json.example to .cursor/dev-environment.json"
89
+ );
90
+ }
91
+ }
92
+
93
+ runDevEnvBootstrap();
package/installer.py CHANGED
@@ -345,6 +345,57 @@ def _load_doc_profile_lib():
345
345
  return mod
346
346
 
347
347
 
348
+ def _load_dev_environment_lib():
349
+ """Load dev_environment_lib from scripts/ adjacent to this installer."""
350
+ here = os.path.dirname(os.path.abspath(__file__))
351
+ path = os.path.join(here, "scripts", "dev_environment_lib.py")
352
+ if not os.path.isfile(path):
353
+ raise RuntimeError(
354
+ "[DEV_ENVIRONMENT_LIB_MISSING] Expected dev environment library at "
355
+ f"{path} (same directory as installer.py). "
356
+ f"Reinstall or upgrade its-magic ({REPO_URL})."
357
+ )
358
+ spec = importlib.util.spec_from_file_location("dev_environment_lib", path)
359
+ if spec is None or spec.loader is None:
360
+ raise RuntimeError(
361
+ "[DEV_ENVIRONMENT_LIB_LOAD_ERROR] Could not create import spec for "
362
+ f"{path}. Reinstall its-magic ({REPO_URL})."
363
+ )
364
+ mod = importlib.util.module_from_spec(spec)
365
+ sys.modules["dev_environment_lib"] = mod
366
+ try:
367
+ spec.loader.exec_module(mod)
368
+ except Exception as e:
369
+ sys.modules.pop("dev_environment_lib", None)
370
+ raise RuntimeError(
371
+ "[DEV_ENVIRONMENT_LIB_LOAD_ERROR] dev_environment_lib failed to load "
372
+ f"({e!r}). Reinstall its-magic ({REPO_URL})."
373
+ ) from e
374
+ return mod
375
+
376
+
377
+ def bootstrap_dev_environment_profile_installer_hook(target_root, source_root):
378
+ """
379
+ Non-destructive dev-environment profile bootstrap.
380
+ Fail-closed only on PATH_INVALID / SOURCE_MISSING.
381
+ """
382
+ try:
383
+ lib = _load_dev_environment_lib()
384
+ except RuntimeError as exc:
385
+ print(str(exc))
386
+ return False
387
+ merged, _paths = merge_scratchpad_layers(target_root)
388
+ reason, _channel = lib.bootstrap_dev_environment_profile(
389
+ target_root, source_root, merged
390
+ )
391
+ if reason in (
392
+ lib.DEV_ENV_BOOTSTRAP_PATH_INVALID,
393
+ lib.DEV_ENV_BOOTSTRAP_SOURCE_MISSING,
394
+ ):
395
+ return False
396
+ return True
397
+
398
+
348
399
  def _doc_profile_sync(target_root, merged, print_ok=True):
349
400
  """Append missing normative README/developer doc sections from merged profile (non-destructive)."""
350
401
  doc_profile_lib = _load_doc_profile_lib()
@@ -878,6 +929,8 @@ def main():
878
929
 
879
930
  if not run_scratchpad_postinstall(target_root, source_root, "upgrade", print_ok=True):
880
931
  return 1
932
+ if not bootstrap_dev_environment_profile_installer_hook(target_root, source_root):
933
+ return 1
881
934
  if not validate_install_completeness(target_root, source_root, required_script_paths, manifest_path):
882
935
  return 1
883
936
 
@@ -958,6 +1011,8 @@ def main():
958
1011
 
959
1012
  if not run_scratchpad_postinstall(target_root, source_root, mode, print_ok=True):
960
1013
  return 1
1014
+ if not bootstrap_dev_environment_profile_installer_hook(target_root, source_root):
1015
+ return 1
961
1016
  if not validate_install_completeness(target_root, source_root, required_script_paths, manifest_path):
962
1017
  return 1
963
1018
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "its-magic",
3
- "version": "0.1.2",
3
+ "version": "0.1.3-0",
4
4
  "description": "its-magic - AI dev team workflow for Cursor.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -202,6 +202,32 @@ DEV_ENVIRONMENT_PAIRS: tuple[tuple[str, str], ...] = (
202
202
  ),
203
203
  )
204
204
 
205
+ RELEASE_CHANGELOG_PAIRS: tuple[tuple[str, str], ...] = (
206
+ (
207
+ "scripts/release_changelog_lib.py",
208
+ "template/scripts/release_changelog_lib.py",
209
+ ),
210
+ (
211
+ "scripts/release_changelog_validate.py",
212
+ "template/scripts/release_changelog_validate.py",
213
+ ),
214
+ (
215
+ "scripts/release_changelog_backfill.py",
216
+ "template/scripts/release_changelog_backfill.py",
217
+ ),
218
+ ("CHANGELOG.md", "template/CHANGELOG.md"),
219
+ (".cursor/commands/release.md", "template/.cursor/commands/release.md"),
220
+ ("scripts/release-all.sh", "template/scripts/release-all.sh"),
221
+ (
222
+ "template/handoffs/releases/vX.Y.Z-release-notes.md.example",
223
+ "template/handoffs/releases/vX.Y.Z-release-notes.md.example",
224
+ ),
225
+ (
226
+ "scripts/check_intake_template_parity.py",
227
+ "template/scripts/check_intake_template_parity.py",
228
+ ),
229
+ )
230
+
205
231
  DOWNSTREAM_CI_GUARD_PAIRS: tuple[tuple[str, str], ...] = (
206
232
  (
207
233
  "scripts/check_downstream_ci_guard.py",
@@ -225,6 +251,7 @@ SCOPES: dict[str, tuple[tuple[str, str], ...]] = {
225
251
  "us-0096": US0096_PAIRS,
226
252
  "project-readme": PROJECT_README_PAIRS,
227
253
  "dev-environment": DEV_ENVIRONMENT_PAIRS,
254
+ "release-changelog": RELEASE_CHANGELOG_PAIRS,
228
255
  "all": (
229
256
  INTAKE_TEMPLATE_PAIRS
230
257
  + CAVEMAN_COMPRESS_PAIRS
@@ -237,6 +264,7 @@ SCOPES: dict[str, tuple[tuple[str, str], ...]] = {
237
264
  + US0096_PAIRS
238
265
  + PROJECT_README_PAIRS
239
266
  + DEV_ENVIRONMENT_PAIRS
267
+ + RELEASE_CHANGELOG_PAIRS
240
268
  ),
241
269
  }
242
270
 
@@ -425,6 +425,24 @@ Guardrails:
425
425
  - `RELEASE_OPERATOR_HINTS_SECRET_EXPOSURE`
426
426
  - Remediation: populate required fields in canonical sprint notes with
427
427
  sanitized env-ref-only credential guidance, then rerun `/release`.
428
+ 19. Version changelog derivation (US-0100 / DEC-0085):
429
+ - Runs **only after** step **9** successful finalization (`unreleased → released`)
430
+ and step **18** operator hints (**US-0067**). Doc writes are **not** publish
431
+ execution — **`RELEASE_PUBLISH_MODE=disabled`** remains valid (**US-0054**).
432
+ - **19a — Resolve semver**: read target queue row **`release_version`**; when
433
+ blank, workflow-only release → **`[Unreleased]`** path only (no per-version file).
434
+ - **19b — Derive work items**: `derive_work_items` for target sprint + coalesce
435
+ peer **`released`** rows sharing normalized semver when semver known
436
+ (`coalesce_sprints_by_semver`).
437
+ - **19c — Write docs**: when semver known → `build_version_doc` +
438
+ `promote_unreleased` + `bind_queue_release_version`; else `append_unreleased`
439
+ only. Per-version SOT = `handoffs/releases/{semver}-release-notes.md` (stem
440
+ without leading **`v`**). Never pass `Sxxxx-release-notes.md` to **`gh -F`**.
441
+ - **19d — Validate (optional enforce)**: when scratchpad
442
+ **`RELEASE_CHANGELOG_ENFORCE=1`** (default **`1`** post-bootstrap), run
443
+ `python scripts/release_changelog_validate.py --repo . --enforce`; record
444
+ outcome in `sprints/Sxxxx/release-findings.md` § version-doc gates. When
445
+ **`0`**, report `skipped` evidence only.
428
446
 
429
447
  ## Fail-safe reason codes and remediation guidance
430
448
 
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ <!-- semver-sections-newest-first -->
9
+
10
+ ## [Unreleased]
11
+
@@ -247,40 +247,58 @@ Normative contract: `decisions/DEC-0083.md`, `docs/engineering/architecture.md`
247
247
  implementation changes — distinct from **US-0065** phase QA, **US-0086** test routing, and
248
248
  **US-0067** release hints. Default-off scratchpad gate; **no** `.env` reads (**US-0085**).
249
249
 
250
+ **Install-time bootstrap (US-0099):** on **`missing`**, **`upgrade`**, and **npm `postinstall`**, the
251
+ framework copies **`template/.cursor/dev-environment.json.example`** → resolved profile path
252
+ (**`.cursor/dev-environment.json`** by default) **only when the target file is absent** — never
253
+ overwrites operator-customized profiles. Customize **after** bootstrap (compose **`service`**, **`*Env`**
254
+ connect refs); manual copy is no longer a prerequisite to enable the gate.
255
+
256
+ | Before (US-0098) | After (US-0099) |
257
+ |------------------|-----------------|
258
+ | "Seed profile" = manual copy prerequisite | Bootstrap automatic on install/upgrade/postinstall |
259
+ | **`DEV_ENV_PROFILE_MISSING`** → manual copy first | Troubleshooting references auto-bootstrap + customize-after-bootstrap |
260
+
250
261
  ### Operator recipes
251
262
 
252
263
  | Scenario | Operator action |
253
264
  |----------|-----------------|
254
265
  | Enable dev auto-launch | Set **`DEV_AUTO_LAUNCH_PROFILE=deterministic_v1`** in scratchpad |
255
- | Seed profile | Copy **`template/.cursor/dev-environment.json.example`** **`.cursor/dev-environment.json`**; set compose **`service`** + **`*Env`** connect refs |
266
+ | Customize profile after bootstrap | Edit **`.cursor/dev-environment.json`** copied from example; set compose **`service`** + **`*Env`** connect refs |
256
267
  | Force relaunch | Send exact phrase **`refresh dev environment`** (case-sensitive whole phrase) |
257
268
  | Profile off / manual mode | Leave **`DEV_AUTO_LAUNCH_PROFILE=off`** (default) — execute step **24** zero overhead |
258
269
  | Ambiguous stack | Fix compose path or seed profile; remediate **`DEV_ENV_DETECT_AMBIGUOUS`** |
259
270
  | Remote + local both on | **US-0086** remote wins over **docker-host-local** — see precedence in **`DEC-0084`** §3 |
260
271
  | Bind-mount hot reload | Default skip on source-only docker changes; use refresh or **`restart_on_source_change=true`** |
272
+ | Global npm install (no consumer repo) | **`[DEV_ENV_BOOTSTRAP_SKIP] no consumer repository detected`** — run **`its-magic`** install into target repo |
261
273
 
262
274
  ### Troubleshooting (`DEV_ENV_*` reason codes)
263
275
 
276
+ **Bootstrap family (install-time; distinct from runtime profile/relaunch families):**
277
+ **`DEV_ENV_BOOTSTRAP_COPIED`**, **`DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS`**, **`DEV_ENV_BOOTSTRAP_PATH_INVALID`**,
278
+ **`DEV_ENV_BOOTSTRAP_SOURCE_MISSING`**.
279
+
264
280
  **Profile family:** **`DEV_ENV_PROFILE_DISABLED`**, **`DEV_ENV_PROFILE_INVALID`**,
265
- **`DEV_ENV_PROFILE_MISSING`**, **`DEV_ENV_DETECT_AMBIGUOUS`**, **`DEV_ENV_COMPOSE_UNRESOLVED`**,
266
- **`DEV_ENV_TARGET_DISABLED`**, **`DEV_ENV_SECRET_SURFACE_VIOLATION`**.
281
+ **`DEV_ENV_PROFILE_MISSING`** (if bootstrap skipped or profile deleted — re-run install/upgrade or
282
+ **`python scripts/dev_environment_lib.py --bootstrap --target <repo>`** then customize),
283
+ **`DEV_ENV_DETECT_AMBIGUOUS`**, **`DEV_ENV_COMPOSE_UNRESOLVED`**, **`DEV_ENV_TARGET_DISABLED`**, **`DEV_ENV_SECRET_SURFACE_VIOLATION`**.
267
284
 
268
285
  **Relaunch family:** **`DEV_ENV_RELAUNCH_SKIPPED_NO_SURFACE`**, **`DEV_ENV_RELAUNCH_SKIPPED_PROFILE_OFF`**,
269
- **`DEV_ENV_RELAUNCH_FAILED`**, **`DEV_ENV_RELAUNCH_RETRY_EXHAUSTED`**, **`DEV_ENV_RELAUNCH_TIMEOUT`**,
270
- **`DEV_ENV_CONNECT_UNAVAILABLE`**.
286
+ **`DEV_ENV_RELAUNCH_FAILED`**, **`DEV_ENV_RELAUNCH_RETRY_EXHAUSTED`**, **`DEV_ENV_RELAUNCH_TIMEOUT`**, **`DEV_ENV_CONNECT_UNAVAILABLE`**.
271
287
 
272
288
  ### Commands
273
289
 
274
290
  ```bash
275
291
  python scripts/dev_environment_lib.py --self-test
276
292
  python scripts/dev_environment_lib.py --load .cursor/dev-environment.json
293
+ python scripts/dev_environment_lib.py --bootstrap --target .
277
294
  python scripts/check_intake_template_parity.py --scope=dev-environment
278
295
  pytest -k us0098 tests/auto_command_contract_test.py
296
+ pytest -k us0099 tests/auto_command_contract_test.py
279
297
  ```
280
298
 
281
299
  Implementation tranche order: **A** (schema + scratchpad) → **B** (stdlib helper) → **C** (execute step **24** + docs) → **D** (contract tests + parity + harness).
282
300
 
283
- Normative contract: `decisions/DEC-0084.md`, `docs/engineering/architecture.md` `# US-0098`.
301
+ Normative contract: `decisions/DEC-0084.md`, `docs/engineering/architecture.md` `# US-0098`, `# US-0099` (bootstrap posture).
284
302
 
285
303
  ## User-visible internal metadata guard (US-0071 / DEC-0053)
286
304
 
@@ -792,6 +810,59 @@ Fail-closed reason codes:
792
810
  - `RELEASE_OPERATOR_HINTS_AMBIGUOUS`
793
811
  - `RELEASE_OPERATOR_HINTS_SECRET_EXPOSURE`
794
812
 
813
+ ## Version-scoped release docs (US-0100 / DEC-0085)
814
+
815
+ Cumulative and per-version release documentation compose with **US-0040** sprint
816
+ notes — they do **not** replace `handoffs/releases/Sxxxx-release-notes.md`.
817
+
818
+ | Artifact | Path | Role |
819
+ |----------|------|------|
820
+ | Cumulative changelog | `CHANGELOG.md` | Keep a Changelog 1.1.0; mandatory top `## [Unreleased]` |
821
+ | Per-version GitHub body | `handoffs/releases/{semver}-release-notes.md` | **`gh -F` SOT** (semver stem without `v`) |
822
+ | Sprint workflow evidence | `handoffs/releases/Sxxxx-release-notes.md` | Unchanged (**US-0040**); derivation input only |
823
+ | Backfill manifest | `docs/engineering/context/release-version-backfill.manifest.yaml` | Tier B operator `sprint_id`→`semver` overrides |
824
+
825
+ ### Operator workflow (deterministic order)
826
+
827
+ 1. **`/release`** (local workflow): after step **9** finalization, step **19**
828
+ derives work items, writes version docs when semver known, or appends
829
+ `[Unreleased]` only when semver blank.
830
+ 2. **`release-all.sh`** (npm/choco/brew + GitHub): post-`npm version`, ensure
831
+ `handoffs/releases/${NEW_VERSION}-release-notes.md`, run
832
+ `release_changelog_validate.py --enforce`, then `gh release create -F`.
833
+ 3. **CI tag push** (when **US-0054** publish targets enabled): same `-F` path;
834
+ confirmation gates unchanged.
835
+
836
+ ### Scratchpad keys
837
+
838
+ | Key | Default | Role |
839
+ |-----|---------|------|
840
+ | `RELEASE_CHANGELOG_ENFORCE` | `1` | Blocking validator at `/release` step **19d** + `release-all.sh` |
841
+ | `RELEASE_CHANGELOG_ALLOW_GENERATE_NOTES` | `0` | Opt-in `gh --generate-notes` when version doc missing |
842
+
843
+ ### Backfill tiers (one-time / idempotent)
844
+
845
+ | Tier | Source | Semver |
846
+ |------|--------|--------|
847
+ | A | Queue `release_version` non-empty | As-is |
848
+ | B | `release-version-backfill.manifest.yaml` | Operator map |
849
+ | C | Remaining released rows | Synthetic `0.0.0-wf.{NNN}` (`S0089`→`0.0.0-wf.089`) |
850
+
851
+ Run: `python scripts/release_changelog_backfill.py --repo .` (idempotent).
852
+ Ambiguous manifest collision → `RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS`.
853
+
854
+ ### Troubleshooting (`RELEASE_CHANGELOG_*`)
855
+
856
+ | Code | Remediation |
857
+ |------|-------------|
858
+ | `RELEASE_CHANGELOG_VERSION_DOC_MISSING` | Run `build_version_doc` / backfill `--ensure-version` before `gh -F` |
859
+ | `RELEASE_CHANGELOG_UNRELEASED_MISSING` | Add `## [Unreleased]` header to `CHANGELOG.md` |
860
+ | `RELEASE_CHANGELOG_QUEUE_DRIFT` | Re-run `bind_queue_release_version` for target sprints |
861
+ | `RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS` | Fix manifest duplicate semver mapping |
862
+
863
+ Contract tests: `pytest -k us0100 tests/auto_command_contract_test.py`; parity:
864
+ `python scripts/check_intake_template_parity.py --scope=release-changelog`.
865
+
795
866
  ## Deterministic status reconciliation mode (US-0055 / DEC-0037)
796
867
 
797
868
  Use the dedicated reconciliation command to normalize status drift across
@@ -0,0 +1,21 @@
1
+ # Per-version release notes — pattern example (US-0100 / DEC-0085)
2
+
3
+ > **Rename at use**: copy this file to `handoffs/releases/{semver}-release-notes.md`
4
+ > where `{semver}` is the normalized semver stem **without** a leading `v`
5
+ > (e.g. `0.1.2-41-release-notes.md`, not `v0.1.2-41-release-notes.md`).
6
+ >
7
+ > **Do not overwrite** unrelated version files — target only the semver you are publishing.
8
+ > Sprint-scoped workflow evidence stays in `handoffs/releases/Sxxxx-release-notes.md`
9
+ > (**US-0040**); never pass `Sxxxx` notes to `gh -F`.
10
+
11
+ ## Work items
12
+
13
+ - **US-xxxx** — One-line summary derived from sprint notes / backlog / queue precedence.
14
+ - **BUG-xxxx** — One-line fix summary (category **Fixed** in cumulative `CHANGELOG.md`).
15
+
16
+ ## Sprint evidence
17
+
18
+ - [`S0070`](handoffs/releases/S0070-release-notes.md)
19
+ - [`S0071`](handoffs/releases/S0071-release-notes.md)
20
+
21
+ <!-- Example coalesce: S0070 + S0071 → semver 0.1.2-41 -->
@@ -202,6 +202,32 @@ DEV_ENVIRONMENT_PAIRS: tuple[tuple[str, str], ...] = (
202
202
  ),
203
203
  )
204
204
 
205
+ RELEASE_CHANGELOG_PAIRS: tuple[tuple[str, str], ...] = (
206
+ (
207
+ "scripts/release_changelog_lib.py",
208
+ "template/scripts/release_changelog_lib.py",
209
+ ),
210
+ (
211
+ "scripts/release_changelog_validate.py",
212
+ "template/scripts/release_changelog_validate.py",
213
+ ),
214
+ (
215
+ "scripts/release_changelog_backfill.py",
216
+ "template/scripts/release_changelog_backfill.py",
217
+ ),
218
+ ("CHANGELOG.md", "template/CHANGELOG.md"),
219
+ (".cursor/commands/release.md", "template/.cursor/commands/release.md"),
220
+ ("scripts/release-all.sh", "template/scripts/release-all.sh"),
221
+ (
222
+ "template/handoffs/releases/vX.Y.Z-release-notes.md.example",
223
+ "template/handoffs/releases/vX.Y.Z-release-notes.md.example",
224
+ ),
225
+ (
226
+ "scripts/check_intake_template_parity.py",
227
+ "template/scripts/check_intake_template_parity.py",
228
+ ),
229
+ )
230
+
205
231
  DOWNSTREAM_CI_GUARD_PAIRS: tuple[tuple[str, str], ...] = (
206
232
  (
207
233
  "scripts/check_downstream_ci_guard.py",
@@ -225,6 +251,7 @@ SCOPES: dict[str, tuple[tuple[str, str], ...]] = {
225
251
  "us-0096": US0096_PAIRS,
226
252
  "project-readme": PROJECT_README_PAIRS,
227
253
  "dev-environment": DEV_ENVIRONMENT_PAIRS,
254
+ "release-changelog": RELEASE_CHANGELOG_PAIRS,
228
255
  "all": (
229
256
  INTAKE_TEMPLATE_PAIRS
230
257
  + CAVEMAN_COMPRESS_PAIRS
@@ -237,6 +264,7 @@ SCOPES: dict[str, tuple[tuple[str, str], ...]] = {
237
264
  + US0096_PAIRS
238
265
  + PROJECT_README_PAIRS
239
266
  + DEV_ENVIRONMENT_PAIRS
267
+ + RELEASE_CHANGELOG_PAIRS
240
268
  ),
241
269
  }
242
270
 
@@ -9,6 +9,7 @@ import fnmatch
9
9
  import json
10
10
  import os
11
11
  import re
12
+ import shutil
12
13
  import subprocess
13
14
  import sys
14
15
  from pathlib import Path
@@ -62,6 +63,24 @@ RELAUNCH_REASON_CODES = (
62
63
  DEV_ENV_CONNECT_UNAVAILABLE,
63
64
  )
64
65
 
66
+ # DEV_ENV_BOOTSTRAP_* reason codes (DEC-0084 § bootstrap posture / US-0099)
67
+ DEV_ENV_BOOTSTRAP_COPIED = "DEV_ENV_BOOTSTRAP_COPIED"
68
+ DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS = "DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS"
69
+ DEV_ENV_BOOTSTRAP_PATH_INVALID = "DEV_ENV_BOOTSTRAP_PATH_INVALID"
70
+ DEV_ENV_BOOTSTRAP_SOURCE_MISSING = "DEV_ENV_BOOTSTRAP_SOURCE_MISSING"
71
+
72
+ BOOTSTRAP_REASON_CODES = (
73
+ DEV_ENV_BOOTSTRAP_COPIED,
74
+ DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS,
75
+ DEV_ENV_BOOTSTRAP_PATH_INVALID,
76
+ DEV_ENV_BOOTSTRAP_SOURCE_MISSING,
77
+ )
78
+
79
+ SCRATCHPAD_EXAMPLE_REL = ".cursor/scratchpad.local.example.md"
80
+ SCRATCHPAD_BASELINE_REL = ".cursor/scratchpad.md"
81
+ SCRATCHPAD_LOCAL_REL = ".cursor/scratchpad.local.md"
82
+ DEV_ENV_EXAMPLE_REL = ".cursor/dev-environment.json.example"
83
+
65
84
  TIER_A_PATTERNS = (
66
85
  "Dockerfile",
67
86
  "Dockerfile.*",
@@ -110,6 +129,110 @@ def _normalize_path(path: str) -> str:
110
129
  return path.replace("\\", "/")
111
130
 
112
131
 
132
+ def _parse_scratchpad_file(path: Path) -> Dict[str, str]:
133
+ if not path.is_file():
134
+ return {}
135
+ out: Dict[str, str] = {}
136
+ with open(path, "r", encoding="utf-8") as f:
137
+ for raw in f:
138
+ line = raw.strip()
139
+ if not line or line.startswith("#") or line.startswith("- "):
140
+ continue
141
+ if "=" not in line:
142
+ continue
143
+ key, _, val = line.partition("=")
144
+ key = key.strip()
145
+ if key:
146
+ out[key] = val.strip()
147
+ return out
148
+
149
+
150
+ def read_merged_scratchpad(target_root: Path) -> Dict[str, str]:
151
+ """Model B merge precedence: local > materialized baseline > example."""
152
+ example = _parse_scratchpad_file(target_root / SCRATCHPAD_EXAMPLE_REL)
153
+ baseline = _parse_scratchpad_file(target_root / SCRATCHPAD_BASELINE_REL)
154
+ local = _parse_scratchpad_file(target_root / SCRATCHPAD_LOCAL_REL)
155
+ merged: Dict[str, str] = {}
156
+ for key in set(example) | set(baseline) | set(local):
157
+ if key in local:
158
+ merged[key] = local[key]
159
+ elif key in baseline:
160
+ merged[key] = baseline[key]
161
+ elif key in example:
162
+ merged[key] = example[key]
163
+ return merged
164
+
165
+
166
+ def resolve_profile_path(
167
+ target_root: Path | str,
168
+ scratchpad: Optional[Dict[str, str]] = None,
169
+ ) -> Tuple[Optional[Path], Optional[str]]:
170
+ """Resolve profile path from scratchpad override or default; fail closed on invalid override."""
171
+ root = Path(target_root).resolve()
172
+ pad = scratchpad or {}
173
+ override = (pad.get("DEV_ENVIRONMENT_CONFIG") or "").strip()
174
+ rel = override if override else DEFAULT_PROFILE_PATH
175
+ norm = _normalize_path(rel)
176
+
177
+ if os.path.isabs(rel) or (len(rel) > 1 and rel[1] == ":"):
178
+ return None, DEV_ENV_BOOTSTRAP_PATH_INVALID
179
+ if ".." in norm.split("/"):
180
+ return None, DEV_ENV_BOOTSTRAP_PATH_INVALID
181
+ if not norm.endswith(".json"):
182
+ return None, DEV_ENV_BOOTSTRAP_PATH_INVALID
183
+
184
+ resolved = (root / norm).resolve()
185
+ try:
186
+ resolved.relative_to(root)
187
+ except ValueError:
188
+ return None, DEV_ENV_BOOTSTRAP_PATH_INVALID
189
+ return resolved, None
190
+
191
+
192
+ def bootstrap_dev_environment_profile(
193
+ target_root: Path | str,
194
+ source_root: Optional[Path | str] = None,
195
+ scratchpad: Optional[Dict[str, str]] = None,
196
+ ) -> Tuple[str, str]:
197
+ """
198
+ Copy example profile when target absent (non-destructive).
199
+ Returns (reason_code, log_channel) where log_channel is stdout or stderr.
200
+ """
201
+ root = Path(target_root).resolve()
202
+ if source_root is None:
203
+ src_root = Path(__file__).resolve().parent.parent / "template"
204
+ else:
205
+ src_root = Path(source_root).resolve()
206
+
207
+ profile_path, path_err = resolve_profile_path(root, scratchpad)
208
+ if path_err:
209
+ print(f"[DEV_ENV_BOOTSTRAP_ERROR] {path_err}", file=sys.stderr)
210
+ return path_err, "stderr"
211
+
212
+ source_path = src_root / DEV_ENV_EXAMPLE_REL
213
+ if not source_path.is_file():
214
+ print(f"[DEV_ENV_BOOTSTRAP_ERROR] {DEV_ENV_BOOTSTRAP_SOURCE_MISSING}", file=sys.stderr)
215
+ return DEV_ENV_BOOTSTRAP_SOURCE_MISSING, "stderr"
216
+
217
+ rel_target = profile_path.relative_to(root).as_posix()
218
+ if profile_path.is_file():
219
+ print(f"[DEV_ENV_BOOTSTRAP_OK] skipped: profile exists at {rel_target}")
220
+ return DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS, "stdout"
221
+
222
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
223
+ shutil.copy2(source_path, profile_path)
224
+ print(f"[DEV_ENV_BOOTSTRAP_OK] copied: {rel_target}")
225
+ return DEV_ENV_BOOTSTRAP_COPIED, "stdout"
226
+
227
+
228
+ def run_bootstrap_cli(target: Path, source_root: Path) -> int:
229
+ scratchpad = read_merged_scratchpad(target)
230
+ reason, _channel = bootstrap_dev_environment_profile(target, source_root, scratchpad)
231
+ if reason in (DEV_ENV_BOOTSTRAP_PATH_INVALID, DEV_ENV_BOOTSTRAP_SOURCE_MISSING):
232
+ return 1
233
+ return 0
234
+
235
+
113
236
  def _check_secret_literals(obj: Any, path: str = "") -> Optional[str]:
114
237
  if isinstance(obj, dict):
115
238
  for key, value in obj.items():
@@ -442,11 +565,26 @@ def main() -> int:
442
565
  p = argparse.ArgumentParser(description="Dev environment profile helper (US-0098).")
443
566
  p.add_argument("--self-test", action="store_true", help="Run built-in checks.")
444
567
  p.add_argument("--load", metavar="PATH", help="Load and validate profile path.")
568
+ p.add_argument("--bootstrap", action="store_true", help="Bootstrap dev-environment profile (US-0099).")
569
+ p.add_argument("--target", metavar="PATH", help="Consumer repository root (default: cwd).")
570
+ p.add_argument(
571
+ "--source-root",
572
+ metavar="PATH",
573
+ help="Packaged template root (default: <pkg>/template).",
574
+ )
445
575
  args = p.parse_args()
446
576
 
447
577
  if args.self_test:
448
578
  return run_self_test()
449
579
 
580
+ if args.bootstrap:
581
+ target = Path(args.target or ".").resolve()
582
+ if args.source_root:
583
+ source_root = Path(args.source_root).resolve()
584
+ else:
585
+ source_root = Path(__file__).resolve().parent.parent / "template"
586
+ return run_bootstrap_cli(target, source_root)
587
+
450
588
  if args.load:
451
589
  _, err = load_profile(args.load)
452
590
  if err: