@noir-ai/create 1.14.0-beta.1 → 1.15.0-beta.1

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/dist/index.js CHANGED
@@ -63,6 +63,10 @@ var MANIFEST_PATH_PARITY = [
63
63
  [P.noirMd, paths.noirMd],
64
64
  [P.rulesMd, paths.rulesMd]
65
65
  ];
66
+ var REFRESHABLE_SEED_KIND = {
67
+ [P.envExample]: "envExample",
68
+ [P.rulesMd]: "rulesSeed"
69
+ };
66
70
  function buildManifest(ctx) {
67
71
  return [...hostAgnosticEntries(ctx), ...buildHostArtifacts(resolveAdapter(ctx.host), ctx)];
68
72
  }
@@ -82,13 +86,17 @@ function hostAgnosticEntries(ctx) {
82
86
  description: "user config seed (host + mode)"
83
87
  },
84
88
  {
89
+ // Refreshable: the variable set Noir documents grows between releases,
90
+ // and this file is pure documentation the user is never asked to edit —
91
+ // so an upgrade may replace an untouched older copy of it.
85
92
  path: P.envExample,
86
93
  mode: "skipIfExists",
87
94
  template: "env.example.tmpl",
95
+ refreshIfStale: true,
88
96
  description: ".noir/.env.example committable documentation (never loaded)"
89
97
  },
90
98
  {
91
- // §8.1: init creates the REAL env file, not just the example. The body is
99
+ // init creates the REAL env file, not just the example. The body is
92
100
  // all-comment, so the file parses to an EMPTY overlay and creating it
93
101
  // changes no behaviour — it exists so the user never has to copy the
94
102
  // example by hand. `fileMode: 0o600` because the file holds tokens the
@@ -108,13 +116,16 @@ function hostAgnosticEntries(ctx) {
108
116
  description: "NOIR.md auto-brief (project id pointer)"
109
117
  },
110
118
  {
119
+ // Refreshable: shipped as a starting contract the user is INVITED to
120
+ // rewrite, so only the never-edited copy may be swapped for a newer one.
111
121
  path: P.rulesMd,
112
122
  mode: "skipIfExists",
113
123
  template: "rules-seed.md.tmpl",
124
+ refreshIfStale: true,
114
125
  description: "AI working-rules seed"
115
126
  },
116
127
  {
117
- // Slice E (spec §9): the `.noir/` runtime map — what init just wrote,
128
+ // The `.noir/` runtime map — what init just wrote,
118
129
  // what appears later (and which command creates it), and where to go
119
130
  // next. Host-agnostic: it describes the canonical store, which every
120
131
  // host shares. Co-owned (README_BLOCK) so user notes survive while the
@@ -289,7 +300,7 @@ function buildHostArtifacts(adapter, ctx) {
289
300
  return entries;
290
301
  }
291
302
  var SESSION_START_HOOK_SCRIPT = `#!/usr/bin/env node
292
- // Noir C3 SessionStart hook \u2014 injects the skill router contract at session start.
303
+ // Noir SessionStart hook \u2014 injects the skill router contract at session start.
293
304
  // The mutable contract lives in .noir/router.md (a Noir managed block, so user
294
305
  // edits outside the markers survive noir sync). This script is a pure runner.
295
306
  import { readFileSync, existsSync } from 'node:fs';
@@ -470,176 +481,292 @@ function mergeThreeWay(base, ours, theirs, style = "minimal") {
470
481
  }
471
482
 
472
483
  // src/migrations/index.ts
473
- import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
474
- import { join as join3 } from "path";
484
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
485
+ import { join as join5 } from "path";
475
486
 
476
- // src/migrations/runner.ts
477
- function runMigrations(root, from, to, opts = {}) {
478
- const window = pickWindow(from ?? "0.0.0", to, MIGRATIONS);
479
- const ctx = { root, dryRun: opts.dryRun === true };
480
- const aggregate = { changed: [], conflicts: [], notes: [] };
481
- const ran = [];
482
- for (const script of window) {
483
- ran.push(`${script.from}\u2192${script.to}`);
484
- let res;
485
- try {
486
- res = script.run(ctx);
487
- } catch (err) {
488
- const msg = err instanceof Error ? err.message : String(err);
489
- aggregate.conflicts.push(`<runner>:${script.from}\u2192${script.to} threw: ${msg}`);
490
- continue;
491
- }
492
- aggregate.changed.push(...res.changed);
493
- aggregate.conflicts.push(...res.conflicts);
494
- aggregate.notes.push(...res.notes);
495
- }
496
- return {
497
- ...aggregate,
498
- from,
499
- to,
500
- ran
501
- };
502
- }
503
- function pickWindow(from, to, scripts) {
504
- return scripts.filter((s) => compareVer(s.from) >= compareVer(from) && compareVer(s.to) <= compareVer(to)).sort((a, b) => compareVer(a.to) - compareVer(b.to));
505
- }
506
- function compareVer(v) {
507
- const parts = v.split("-")[0]?.split(".") ?? [];
508
- let n = 0;
509
- for (let i = 0; i < 3; i++) {
510
- const p = Number(parts[i] ?? "0");
511
- n = n * 1e3 + (Number.isFinite(p) ? p : 0);
512
- }
513
- return n;
487
+ // src/template.ts
488
+ var TOKEN = /\{\{\s*([^}{}]+?)\s*\}\}/g;
489
+ function render(template, vars) {
490
+ return template.replace(TOKEN, (whole, name) => {
491
+ if (!Object.hasOwn(vars, name)) return whole;
492
+ const v = vars[name];
493
+ if (v === void 0 || v === null) return whole;
494
+ return String(v);
495
+ });
514
496
  }
515
497
 
516
- // src/migrations/index.ts
517
- var synthetic = {
518
- from: "1.0.0",
519
- to: "1.0.0",
520
- description: "no-op synthetic migration (runner smoke test)",
521
- run: (ctx) => {
522
- const result = { changed: [], conflicts: [], notes: [] };
523
- if (process.env.NOIR_TEST_FORCE_CONFLICT === "1" && !ctx.dryRun) {
524
- const file = join3(ctx.root, ".noir", "scaffold-version");
525
- if (existsSync3(file)) {
526
- const prev = readFileSync2(file, "utf8");
527
- const merged = applyInlineConflict(prev, "noir-scaffold=1.0.0\n", "ours", "theirs");
528
- writeFileSync2(file, merged, "utf8");
529
- result.conflicts.push(".noir/scaffold-version");
530
- }
531
- }
532
- result.notes.push("synthetic 1.0.0\u21921.0.0 migration ran");
533
- return result;
534
- }
535
- };
536
- var ENV_POINTER_MARKER = "# noir:env-pointer";
537
- var ENV_POINTER_BLOCK = [
538
- ENV_POINTER_MARKER,
539
- "# This file is safe to commit: it holds no secrets.",
540
- "# Project-scoped values and secrets belong in .noir/.env (gitignored, mode",
541
- "# 0600). .noir/.env.example documents the full variable set; `noir env`",
542
- "# reports which source wins for each key.",
543
- ""
544
- ].join("\n");
545
- function appendSeparated(prev, block) {
546
- if (prev.length === 0) return block;
547
- if (prev.endsWith("\n\n")) return `${prev}${block}`;
548
- return prev.endsWith("\n") ? `${prev}
549
- ${block}` : `${prev}
498
+ // src/template-history.ts
499
+ var ENV_EXAMPLE_1_1_0 = `# .noir/.env.example \u2014 committable documentation of the format.
500
+ #
501
+ # This file is a TEMPLATE and is never loaded. Noir reads \`.noir/.env\`, which
502
+ # \`noir init\` already created for you at mode 0600 \u2014 edit that file and
503
+ # uncomment the entries you need. Keep this example in git so the variable set
504
+ # stays discoverable.
505
+ #
506
+ # ---------------------------------------------------------------------------
507
+ # The block below is the same header \`.noir/.env\` carries (spec 12.1).
508
+ # ---------------------------------------------------------------------------
509
+ #
510
+ # .noir/.env \u2014 project-scoped configuration for this repo.
511
+ #
512
+ # This file is the recommended home for project-scoped configuration and
513
+ # secrets. Precedence:
514
+ # 1. run profile env run.profiles.<n>.env (per-invocation; merges OVER)
515
+ # 2. .noir/.env <- recommended home for project-scoped configuration
516
+ # 3. real environment CI / container / launchd / shell rc
517
+ # 4. built-in default
518
+ #
519
+ # A real environment variable is a FALLBACK: it applies only to keys this file
520
+ # leaves unset. A machine-global export therefore cannot shadow a value here.
521
+ # Run \`noir env\` to see which source is winning for each key.
522
+ #
523
+ # Keep this file private: chmod 600 .noir/.env
524
+ # It is gitignored by Noir's managed .gitignore block, and Noir REFUSES to load
525
+ # it if it is tracked by git (a cloned repo could redirect credentials).
526
+ # Never commit \`.noir/.env\`. (THIS file is the committable one.)
550
527
 
551
- ${block}`;
552
- }
553
- var envPointer = {
554
- from: "1.0.0",
555
- to: "1.1.0",
556
- description: "point .noir/config.yml at .noir/.env (the secrets home)",
557
- run: (ctx) => {
558
- const result = { changed: [], conflicts: [], notes: [] };
559
- const rel = ".noir/config.yml";
560
- const abs = join3(ctx.root, ".noir", "config.yml");
561
- if (!existsSync3(abs)) {
562
- result.notes.push(`${rel}: absent \u2014 the template seed carries the pointer`);
563
- return result;
564
- }
565
- let prev;
566
- try {
567
- prev = readFileSync2(abs, "utf8");
568
- } catch (err) {
569
- result.conflicts.push(rel);
570
- result.notes.push(`${rel}: unreadable (${errorMessage(err)}) \u2014 left untouched`);
571
- return result;
572
- }
573
- if (prev.includes(ENV_POINTER_MARKER)) {
574
- result.notes.push(`${rel}: already carries ${ENV_POINTER_MARKER}`);
575
- return result;
576
- }
577
- if (ctx.dryRun) {
578
- result.changed.push(rel);
579
- result.notes.push(`${rel}: would append the .noir/.env pointer`);
580
- return result;
581
- }
582
- try {
583
- writeFileSync2(abs, appendSeparated(prev, ENV_POINTER_BLOCK), "utf8");
584
- } catch (err) {
585
- result.conflicts.push(rel);
586
- result.notes.push(`${rel}: write failed (${errorMessage(err)}) \u2014 left untouched`);
587
- return result;
588
- }
589
- result.changed.push(rel);
590
- result.notes.push(`${rel}: appended the .noir/.env pointer`);
591
- return result;
528
+ # --- Integrations -----------------------------------------------------------
529
+ # ClickUp \u2014 only if the noir-clickup integration is enabled.
530
+ # CLICKUP_API_TOKEN=pk_00000000000000000000000000000000
531
+ #
532
+ # CLICKUP_TEAM_ID is NOT an env var \u2014 Noir never reads it. Workspace binding
533
+ # (team/list/space ids) is config.yml instead:
534
+ # integrations:
535
+ # clickup:
536
+ # teamId: "ABC123" # optional \u2014 only for custom task IDs (#ABC-123)
537
+
538
+ # --- Remote embedders (context.embedder.kind: remote) -----------------------
539
+ # The default local embedder (kind: local) needs no key.
540
+ # OPENAI_API_KEY=sk-000000000000000000000000000000000000000000000000
541
+ # VOYAGE_API_KEY=pa-0000000000000000000000000000000000000000000000
542
+ # COHERE_API_KEY=0000000000000000000000000000000000000000
543
+ #
544
+ # Ollama (kind: ollama) reads its base URL from config.yml
545
+ # (context.embedder.baseURL); this variable is the fallback:
546
+ # OLLAMA_BASE_URL=http://localhost:11434
547
+
548
+ # --- Model provider key -----------------------------------------------------
549
+ # There is NO fixed "model key" variable, and setting one is not enough on its
550
+ # own. \`apiKeyEnv\` is a NAME, not an interpolation: the config stores the
551
+ # variable NAME and Noir reads process.env[<that name>] at call time. Write the
552
+ # bare name \u2014 never \`\${...}\` \u2014 in .noir/config.yml:
553
+ #
554
+ # model:
555
+ # providers:
556
+ # anthropic:
557
+ # apiKeyEnv: ANTHROPIC_API_KEY # -> reads $ANTHROPIC_API_KEY
558
+ # tiers:
559
+ # default:
560
+ # provider: anthropic
561
+ #
562
+ # Only \`run.profiles.<name>.env\` interpolates \`\${VAR}\`. With no provider
563
+ # configured the model layer degrades to templates \u2014 Noir never makes a silent
564
+ # paid call. \`ANTHROPIC_API_KEY\` is the conventional example, not a variable
565
+ # Noir looks up on its own:
566
+ # ANTHROPIC_API_KEY=sk-ant-0000000000000000000000000000000000000000000000
567
+
568
+ # --- Run profile selection --------------------------------------------------
569
+ # Selects a \`run.profiles.<name>\` entry for \`noir run\`.
570
+ # Precedence: --profile flag > NOIR_PROFILE > run.defaultProfile > host default.
571
+ # NOIR_PROFILE=work
572
+
573
+ # --- Update kill-switches ---------------------------------------------------
574
+ # NOIR_DISABLE_UPDATE_CHECK=1 # suppress the background startup check only
575
+ # NOIR_DISABLE_UPDATES=1 # make \`noir update\` refuse (exit 2)
576
+
577
+ # See docs/reference/environment.md for every variable Noir reads.
578
+ `;
579
+ var ENV_EXAMPLE_1_2_0 = `# .noir/.env.example \u2014 the committable reference for .noir/.env.
580
+ #
581
+ # What .noir/.env is for
582
+ # ----------------------
583
+ # .noir/.env is this project's configuration-and-secrets file. \`noir init\`
584
+ # creates it at mode 0600 and gitignores it, and Noir loads it on every
585
+ # command. For every key it defines, it WINS over your shell environment, so it
586
+ # is the recommended home for project-scoped values and secrets.
587
+ #
588
+ # Precedence (highest wins):
589
+ # 1. run profile env run.profiles.<n>.env (per-invocation; merges over)
590
+ # 2. .noir/.env the project-scoped file this example documents
591
+ # 3. real environment CI / container / launchd / shell rc
592
+ # 4. built-in default
593
+ #
594
+ # A real environment variable is only a FALLBACK for keys .noir/.env leaves
595
+ # unset. For a value scoped to one invocation, add a \`run.profiles.<name>.env\`
596
+ # entry and reference it there as \`\${VAR}\`, so the literal value stays out of
597
+ # config.yml; values that must not be committed belong in \`.noir/.env\`
598
+ # (gitignored) or the real environment. \`noir env\` reports the winning source
599
+ # for each key.
600
+ #
601
+ # What to commit
602
+ # --------------
603
+ # Keep .noir/.env OUT of git: it holds real secrets, Noir's managed .gitignore
604
+ # block excludes it, and Noir refuses to load a copy that IS tracked (a cloned
605
+ # repo could redirect credentials). chmod 600 .noir/.env to keep it private.
606
+ # Never commit the real file \u2014 commit THIS example, so the keys stay
607
+ # discoverable and documented.
608
+ #
609
+ # Every line below is documentation. Copy a key to .noir/.env and set a real
610
+ # value to enable it.
611
+ #
612
+ # Full reference: docs/reference/environment.md
613
+
614
+ # --- Host gateway (Claude Code via Z.AI / LiteLLM / OpenRouter / Kimi ...) ---
615
+ # Point the host at an Anthropic-shaped endpoint instead of api.anthropic.com.
616
+ #
617
+ # ANTHROPIC_BASE_URL \u2014 the gateway's base URL. Host ONLY: give the origin (for
618
+ # example https://gateway.example) and NOT the /v1/messages path \u2014 the host
619
+ # appends that itself.
620
+ # ANTHROPIC_AUTH_TOKEN \u2014 sent as \`Authorization: Bearer <token>\`. It is the
621
+ # HEADER variant of a credential: AUTH_TOKEN becomes Bearer, while
622
+ # ANTHROPIC_API_KEY becomes x-api-key. Do not set BOTH \u2014 that is an auth
623
+ # conflict.
624
+ # ANTHROPIC_DEFAULT_HAIKU_MODEL / _SONNET_MODEL / _OPUS_MODEL \u2014 remap the
625
+ # haiku / sonnet / opus aliases to the model ids your gateway serves.
626
+ # API_TIMEOUT_MS \u2014 per-request timeout in milliseconds (Claude Code default is
627
+ # 600000).
628
+ # CLAUDE_CODE_AUTO_COMPACT_WINDOW \u2014 auto-compact window (100000 to 1000000).
629
+ #
630
+ # ANTHROPIC_BASE_URL=https://gateway.example
631
+ # ANTHROPIC_AUTH_TOKEN=replace-with-your-gateway-token
632
+ # ANTHROPIC_DEFAULT_HAIKU_MODEL=replace-with-your-gateway-haiku-model
633
+ # ANTHROPIC_DEFAULT_SONNET_MODEL=replace-with-your-gateway-sonnet-model
634
+ # ANTHROPIC_DEFAULT_OPUS_MODEL=replace-with-your-gateway-opus-model
635
+ # API_TIMEOUT_MS=600000
636
+ # CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000
637
+
638
+ # --- Integrations ---
639
+ # CLICKUP_API_TOKEN \u2014 the token for the noir-clickup integration; set it only
640
+ # when that integration is enabled.
641
+ # CLICKUP_TEAM_ID is NOT an env var. Noir never reads it \u2014 workspace binding
642
+ # (team / list / space ids) belongs in .noir/config.yml under
643
+ # integrations.clickup.
644
+ # CLICKUP_API_TOKEN=replace-with-your-clickup-token
645
+
646
+ # --- Remote embedders (context.embedder.kind: remote) ---
647
+ # The default local embedder (kind: local) needs no key. The three API-key
648
+ # variables below apply only when config.yml selects a remote embedder.
649
+ # OPENAI_API_KEY=replace-with-your-openai-key
650
+ # VOYAGE_API_KEY=replace-with-your-voyage-key
651
+ # COHERE_API_KEY=replace-with-your-cohere-key
652
+ #
653
+ # Ollama (kind: ollama) reads its base URL from config.yml
654
+ # (context.embedder.baseURL); this variable is the fallback:
655
+ # OLLAMA_BASE_URL=http://localhost:11434
656
+
657
+ # --- Model provider key (read via apiKeyEnv in .noir/config.yml) ---
658
+ # There is no fixed "model key" variable, and setting a key is not enough on
659
+ # its own. \`apiKeyEnv\` is a NAME, not an interpolation: config.yml stores the
660
+ # variable NAME, and Noir reads process.env[<that name>] at call time. Write
661
+ # the bare name \u2014 never \`\${...}\` \u2014 in .noir/config.yml:
662
+ #
663
+ # model:
664
+ # providers:
665
+ # anthropic:
666
+ # apiKeyEnv: ANTHROPIC_API_KEY # -> reads $ANTHROPIC_API_KEY
667
+ #
668
+ # Only \`run.profiles.<name>.env\` interpolates \`\${VAR}\`. With no provider
669
+ # configured the model layer degrades to templates \u2014 Noir never makes a silent
670
+ # paid call. \`ANTHROPIC_API_KEY\` is the conventional example, not a variable
671
+ # Noir looks up on its own.
672
+ # ANTHROPIC_API_KEY=replace-with-your-anthropic-key
673
+
674
+ # --- Run profile selection (noir run) ---
675
+ # Selects a \`run.profiles.<name>\` entry for \`noir run\`.
676
+ # Precedence: --profile flag > NOIR_PROFILE > run.defaultProfile > host default.
677
+ # NOIR_PROFILE=work
678
+
679
+ # --- Update kill-switches ---
680
+ # NOIR_DISABLE_UPDATE_CHECK=1 # suppress the background startup check only
681
+ # NOIR_DISABLE_UPDATES=1 # make \`noir update\` refuse (exit 2)
682
+
683
+ # See docs/reference/environment.md for every variable Noir reads.
684
+ `;
685
+ var RULES_SEED_1_1_0 = `# Noir working rules
686
+
687
+ > Canonical AI working-contract for this project. The host context file (CLAUDE.md) @imports this.
688
+ > Keep LEAN: every line must be failure-backed, tool-enforceable, decision-encoding, or triggerable \u2014 else delete it.
689
+ > Edit freely \u2014 Noir re-emits only the @import pointer, never this body.
690
+
691
+ ## Identity & scope
692
+ - This project uses **Noir** (discipline/context/memory layer) inside **Claude Code**.
693
+ - Noir is an orchestration layer \u2014 the host CLI is the execution engine; Noir is the spec/context/memory brain.
694
+ - Stay in scope: do only what the current Noir task (\`.noir/tasks/\`) requires. Surface scope creep BEFORE acting.
695
+
696
+ ## Anti-assumption contract
697
+ - **Never fabricate** facts, APIs, file contents, or command output. Use only what you have read or verified.
698
+ - **Never assume** \u2014 if a path, signature, or convention is unclear, STOP and ask before acting.
699
+ - Cite the file/line or command you relied on for any non-obvious claim.
700
+ - Detect non-conventional setups; do not paper over them with assumed conventions.
701
+
702
+ ## Spec-Driven Development workflow
703
+ - Follow the Noir SDD lifecycle: intake \u2192 clarify \u2192 spec \u2192 plan \u2192 execute \u2192 verify \u2192 document.
704
+ - Gates are observable: every gate decision is recorded (approved / forced / skipped). \`--force\` requires a reason.
705
+ - Do not edit/run/execute before facts are gathered AND the human confirms understanding.
706
+
707
+ ## Verification (run before claiming done)
708
+ - \`pnpm build && pnpm lint && pnpm typecheck && pnpm test\` \u2014 all green, with real output. No hedging.
709
+ - Report failures truthfully with the actual output; never claim green without evidence.
710
+
711
+ ## Coding standards & architecture
712
+ - Follow existing patterns; match surrounding code's style, naming, comment density.
713
+ - Architecture decisions live in \`.noir/decisions/\` (ADR-style) \u2014 read them before cross-cutting changes.
714
+ - See \`docs/roadmap.md\` for direction and \`docs/specs/\` for design.
715
+
716
+ ## Conventions gotchas (project-specific \u2014 fill in)
717
+ - Commits stay local until explicitly pushed.
718
+ - The full test suite runs offline/free (no network in CI).
719
+ `;
720
+ var SEED_TEMPLATE_HISTORY = [
721
+ {
722
+ scaffoldVersion: "1.1.0",
723
+ envExample: ENV_EXAMPLE_1_1_0,
724
+ rulesSeed: RULES_SEED_1_1_0
725
+ },
726
+ {
727
+ scaffoldVersion: "1.2.0",
728
+ envExample: ENV_EXAMPLE_1_2_0,
729
+ rulesSeed: RULES_SEED_1_1_0
592
730
  }
593
- };
594
- var MIGRATIONS = [synthetic, envPointer];
595
- function errorMessage(err) {
596
- return err instanceof Error ? err.message : String(err);
731
+ ];
732
+ function isStaleSeed(seed, fileBytes, currentRender) {
733
+ if (fileBytes === currentRender) return false;
734
+ return SEED_TEMPLATE_HISTORY.some((entry) => entry[seed] === fileBytes);
597
735
  }
598
- function applyInlineConflict(ours, theirs, oursLabel = "ours", theirsLabel = "theirs") {
599
- return `<<<<<<< ${oursLabel}
600
- ${ours}=======
601
- ${theirs}>>>>>>> ${theirsLabel}
602
- `;
736
+
737
+ // src/template-loader.ts
738
+ import { readFileSync as readFileSync2 } from "fs";
739
+ import { dirname as dirname2, join as join3, resolve } from "path";
740
+ import { fileURLToPath } from "url";
741
+ var DEFAULT_TEMPLATES_DIR = resolveTemplatesDir();
742
+ function resolveTemplatesDir() {
743
+ const override = process.env.NOIR_TEMPLATES_DIR;
744
+ if (override && override.length > 0) return resolve(override);
745
+ const here = dirname2(fileURLToPath(import.meta.url));
746
+ return join3(here, "..", "templates");
603
747
  }
604
- function applyWithConflict(ours, theirs, path) {
605
- if (ours === theirs) return { text: ours, conflicted: false };
606
- return {
607
- text: applyInlineConflict(ours, theirs, path, path),
608
- conflicted: true
609
- };
748
+ function loadTemplate(name) {
749
+ return readFileSync2(join3(DEFAULT_TEMPLATES_DIR, name), "utf8");
750
+ }
751
+ function templatesDir() {
752
+ return DEFAULT_TEMPLATES_DIR;
610
753
  }
611
-
612
- // src/scaffold.ts
613
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, renameSync as renameSync2, rmSync as rmSync2 } from "fs";
614
- import { basename as basename2, dirname as dirname5, join as join8 } from "path";
615
- import {
616
- createProjectId,
617
- paths as paths2,
618
- readManagedBlock,
619
- resolveNoirCommand,
620
- sha256Hex12
621
- } from "@noir-ai/core";
622
-
623
- // src/scaffold-version.ts
624
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "fs";
625
- import { dirname as dirname3, join as join5 } from "path";
626
- import { NOIR_DIR as NOIR_DIR2 } from "@noir-ai/core";
627
754
 
628
755
  // src/writers.ts
629
756
  import {
630
757
  closeSync,
631
- existsSync as existsSync4,
758
+ existsSync as existsSync3,
632
759
  openSync,
633
760
  readFileSync as readFileSync3,
634
761
  renameSync,
635
762
  rmSync,
636
- writeFileSync as writeFileSync3,
763
+ writeFileSync as writeFileSync2,
637
764
  writeSync
638
765
  } from "fs";
639
- import { basename, dirname as dirname2, join as join4 } from "path";
766
+ import { basename, dirname as dirname3, join as join4 } from "path";
640
767
  import { atomicWriteFile, stripManagedBlock, writeManagedRegion } from "@noir-ai/core";
641
768
  function regenerate(absPath, content) {
642
- const dir = dirname2(absPath);
769
+ const dir = dirname3(absPath);
643
770
  const tmp = join4(
644
771
  dir,
645
772
  `.${basename(absPath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
@@ -691,7 +818,7 @@ function managedBlocks(absPath, regions) {
691
818
  const next = stripped.trim().length > 0 ? `${stripped.trimEnd()}
692
819
 
693
820
  ${regionsJoined}` : regionsJoined;
694
- writeFileSync3(absPath, next, "utf8");
821
+ writeFileSync2(absPath, next, "utf8");
695
822
  return { path: absPath, mode: "managedBlock", written: true };
696
823
  }
697
824
  function buildRegion(block, body) {
@@ -725,15 +852,19 @@ function predictManagedBlocks(currentContent, regions) {
725
852
  ${regionsJoined}` : regionsJoined;
726
853
  }
727
854
  function skipIfExists(absPath, content, fileMode) {
728
- if (existsSync4(absPath)) {
855
+ if (existsSync3(absPath)) {
729
856
  return { path: absPath, mode: "skipIfExists", written: false };
730
857
  }
731
858
  atomicWriteFile(absPath, content, fileMode !== void 0 ? { mode: fileMode } : {});
732
859
  return { path: absPath, mode: "skipIfExists", written: true };
733
860
  }
861
+ function refreshSeed(absPath, content) {
862
+ atomicWriteFile(absPath, content);
863
+ return { path: absPath, mode: "skipIfExists", written: true };
864
+ }
734
865
  function mergeJson(absPath, patch, dedupSubstring) {
735
866
  let existing = {};
736
- if (existsSync4(absPath)) {
867
+ if (existsSync3(absPath)) {
737
868
  try {
738
869
  existing = JSON.parse(readFileSync3(absPath, "utf8"));
739
870
  } catch {
@@ -743,7 +874,7 @@ function mergeJson(absPath, patch, dedupSubstring) {
743
874
  const merged = deepMergePreservingHooks(existing, patch, dedupSubstring);
744
875
  const content = `${JSON.stringify(merged, null, 2)}
745
876
  `;
746
- if (existsSync4(absPath)) {
877
+ if (existsSync3(absPath)) {
747
878
  try {
748
879
  if (readFileSync3(absPath, "utf8") === content) {
749
880
  return { path: absPath, mode: "mergeJson", written: false };
@@ -777,16 +908,214 @@ function deepMergePreservingHooks(existing, patch, dedupSubstring) {
777
908
  return out;
778
909
  }
779
910
 
911
+ // src/migrations/runner.ts
912
+ function runMigrations(root, from, to, opts = {}) {
913
+ const window = pickWindow(from ?? "0.0.0", to, MIGRATIONS);
914
+ const ctx = { root, dryRun: opts.dryRun === true };
915
+ const aggregate = { changed: [], conflicts: [], notes: [] };
916
+ const ran = [];
917
+ for (const script of window) {
918
+ ran.push(`${script.from}\u2192${script.to}`);
919
+ let res;
920
+ try {
921
+ res = script.run(ctx);
922
+ } catch (err) {
923
+ const msg = err instanceof Error ? err.message : String(err);
924
+ aggregate.conflicts.push(`<runner>:${script.from}\u2192${script.to} threw: ${msg}`);
925
+ continue;
926
+ }
927
+ aggregate.changed.push(...res.changed);
928
+ aggregate.conflicts.push(...res.conflicts);
929
+ aggregate.notes.push(...res.notes);
930
+ }
931
+ return {
932
+ ...aggregate,
933
+ from,
934
+ to,
935
+ ran
936
+ };
937
+ }
938
+ function pickWindow(from, to, scripts) {
939
+ return scripts.filter((s) => compareVer(s.from) >= compareVer(from) && compareVer(s.to) <= compareVer(to)).sort((a, b) => compareVer(a.to) - compareVer(b.to));
940
+ }
941
+ function compareVer(v) {
942
+ const parts = v.split("-")[0]?.split(".") ?? [];
943
+ let n = 0;
944
+ for (let i = 0; i < 3; i++) {
945
+ const p = Number(parts[i] ?? "0");
946
+ n = n * 1e3 + (Number.isFinite(p) ? p : 0);
947
+ }
948
+ return n;
949
+ }
950
+
951
+ // src/migrations/index.ts
952
+ var synthetic = {
953
+ from: "1.0.0",
954
+ to: "1.0.0",
955
+ description: "no-op synthetic migration (runner smoke test)",
956
+ run: (ctx) => {
957
+ const result = { changed: [], conflicts: [], notes: [] };
958
+ if (process.env.NOIR_TEST_FORCE_CONFLICT === "1" && !ctx.dryRun) {
959
+ const file = join5(ctx.root, ".noir", "scaffold-version");
960
+ if (existsSync4(file)) {
961
+ const prev = readFileSync4(file, "utf8");
962
+ const merged = applyInlineConflict(prev, "noir-scaffold=1.0.0\n", "ours", "theirs");
963
+ writeFileSync3(file, merged, "utf8");
964
+ result.conflicts.push(".noir/scaffold-version");
965
+ }
966
+ }
967
+ result.notes.push("synthetic 1.0.0\u21921.0.0 migration ran");
968
+ return result;
969
+ }
970
+ };
971
+ var ENV_POINTER_MARKER = "# noir:env-pointer";
972
+ var ENV_POINTER_BLOCK = [
973
+ ENV_POINTER_MARKER,
974
+ "# This file is safe to commit: it holds no secrets.",
975
+ "# Project-scoped values and secrets belong in .noir/.env (gitignored, mode",
976
+ "# 0600). .noir/.env.example documents the full variable set; `noir env`",
977
+ "# reports which source wins for each key.",
978
+ ""
979
+ ].join("\n");
980
+ function appendSeparated(prev, block) {
981
+ if (prev.length === 0) return block;
982
+ if (prev.endsWith("\n\n")) return `${prev}${block}`;
983
+ return prev.endsWith("\n") ? `${prev}
984
+ ${block}` : `${prev}
985
+
986
+ ${block}`;
987
+ }
988
+ var envPointer = {
989
+ from: "1.0.0",
990
+ to: "1.1.0",
991
+ description: "point .noir/config.yml at .noir/.env (the secrets home)",
992
+ run: (ctx) => {
993
+ const result = { changed: [], conflicts: [], notes: [] };
994
+ const rel = ".noir/config.yml";
995
+ const abs = join5(ctx.root, ".noir", "config.yml");
996
+ if (!existsSync4(abs)) {
997
+ result.notes.push(`${rel}: absent \u2014 the template seed carries the pointer`);
998
+ return result;
999
+ }
1000
+ let prev;
1001
+ try {
1002
+ prev = readFileSync4(abs, "utf8");
1003
+ } catch (err) {
1004
+ result.conflicts.push(rel);
1005
+ result.notes.push(`${rel}: unreadable (${errorMessage(err)}) \u2014 left untouched`);
1006
+ return result;
1007
+ }
1008
+ if (prev.includes(ENV_POINTER_MARKER)) {
1009
+ result.notes.push(`${rel}: already carries ${ENV_POINTER_MARKER}`);
1010
+ return result;
1011
+ }
1012
+ if (ctx.dryRun) {
1013
+ result.changed.push(rel);
1014
+ result.notes.push(`${rel}: would append the .noir/.env pointer`);
1015
+ return result;
1016
+ }
1017
+ try {
1018
+ writeFileSync3(abs, appendSeparated(prev, ENV_POINTER_BLOCK), "utf8");
1019
+ } catch (err) {
1020
+ result.conflicts.push(rel);
1021
+ result.notes.push(`${rel}: write failed (${errorMessage(err)}) \u2014 left untouched`);
1022
+ return result;
1023
+ }
1024
+ result.changed.push(rel);
1025
+ result.notes.push(`${rel}: appended the .noir/.env pointer`);
1026
+ return result;
1027
+ }
1028
+ };
1029
+ var DOC_SEEDS = [
1030
+ { rel: ".noir/.env.example", kind: "envExample", template: "env.example.tmpl" },
1031
+ { rel: ".noir/rules/RULES.md", kind: "rulesSeed", template: "rules-seed.md.tmpl" }
1032
+ ];
1033
+ var envTemplates = {
1034
+ from: "1.1.0",
1035
+ to: "1.2.0",
1036
+ description: "refresh the .env.example doc seed and keep RULES.md in step",
1037
+ run: (ctx) => {
1038
+ const result = { changed: [], conflicts: [], notes: [] };
1039
+ for (const seed of DOC_SEEDS) {
1040
+ const abs = join5(ctx.root, seed.rel);
1041
+ if (!existsSync4(abs)) {
1042
+ result.notes.push(`${seed.rel}: absent \u2014 nothing to refresh`);
1043
+ continue;
1044
+ }
1045
+ let prev;
1046
+ try {
1047
+ prev = readFileSync4(abs, "utf8");
1048
+ } catch (err) {
1049
+ result.conflicts.push(seed.rel);
1050
+ result.notes.push(`${seed.rel}: unreadable (${errorMessage(err)}) \u2014 left untouched`);
1051
+ continue;
1052
+ }
1053
+ const current = render(loadTemplate(seed.template), {});
1054
+ if (!isStaleSeed(seed.kind, prev, current)) {
1055
+ result.notes.push(`${seed.rel}: user-owned or already current \u2014 left untouched`);
1056
+ continue;
1057
+ }
1058
+ if (ctx.dryRun) {
1059
+ result.changed.push(seed.rel);
1060
+ result.notes.push(`${seed.rel}: would refresh to the current seed`);
1061
+ continue;
1062
+ }
1063
+ try {
1064
+ refreshSeed(abs, current);
1065
+ } catch (err) {
1066
+ result.conflicts.push(seed.rel);
1067
+ result.notes.push(`${seed.rel}: refresh failed (${errorMessage(err)}) \u2014 left untouched`);
1068
+ continue;
1069
+ }
1070
+ result.changed.push(seed.rel);
1071
+ result.notes.push(`${seed.rel}: refreshed to the current seed`);
1072
+ }
1073
+ return result;
1074
+ }
1075
+ };
1076
+ var MIGRATIONS = [synthetic, envPointer, envTemplates];
1077
+ function errorMessage(err) {
1078
+ return err instanceof Error ? err.message : String(err);
1079
+ }
1080
+ function applyInlineConflict(ours, theirs, oursLabel = "ours", theirsLabel = "theirs") {
1081
+ return `<<<<<<< ${oursLabel}
1082
+ ${ours}=======
1083
+ ${theirs}>>>>>>> ${theirsLabel}
1084
+ `;
1085
+ }
1086
+ function applyWithConflict(ours, theirs, path) {
1087
+ if (ours === theirs) return { text: ours, conflicted: false };
1088
+ return {
1089
+ text: applyInlineConflict(ours, theirs, path, path),
1090
+ conflicted: true
1091
+ };
1092
+ }
1093
+
1094
+ // src/scaffold.ts
1095
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, renameSync as renameSync2, rmSync as rmSync2 } from "fs";
1096
+ import { basename as basename2, dirname as dirname5, join as join8 } from "path";
1097
+ import {
1098
+ createProjectId,
1099
+ loadProjectInfo,
1100
+ paths as paths2,
1101
+ readManagedBlock,
1102
+ resolveNoirCommand,
1103
+ sha256Hex12
1104
+ } from "@noir-ai/core";
1105
+
780
1106
  // src/scaffold-version.ts
781
- var CURRENT_SCAFFOLD_VERSION = "1.1.0";
1107
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync5 } from "fs";
1108
+ import { dirname as dirname4, join as join6 } from "path";
1109
+ import { NOIR_DIR as NOIR_DIR2 } from "@noir-ai/core";
1110
+ var CURRENT_SCAFFOLD_VERSION = "1.2.0";
782
1111
  var PREFIX = "noir-scaffold=";
783
1112
  function scaffoldVersionPath(root) {
784
- return join5(root, NOIR_DIR2, "scaffold-version");
1113
+ return join6(root, NOIR_DIR2, "scaffold-version");
785
1114
  }
786
1115
  function readScaffoldVersion(root) {
787
1116
  let raw;
788
1117
  try {
789
- raw = readFileSync4(scaffoldVersionPath(root), "utf8");
1118
+ raw = readFileSync5(scaffoldVersionPath(root), "utf8");
790
1119
  } catch {
791
1120
  return null;
792
1121
  }
@@ -801,14 +1130,14 @@ function readScaffoldVersion(root) {
801
1130
  }
802
1131
  function writeScaffoldVersion(root, version) {
803
1132
  const file = scaffoldVersionPath(root);
804
- mkdirSync2(dirname3(file), { recursive: true });
1133
+ mkdirSync2(dirname4(file), { recursive: true });
805
1134
  regenerate(file, `${PREFIX}${version}
806
1135
  `);
807
1136
  }
808
1137
 
809
1138
  // src/stack-detect.ts
810
- import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync5 } from "fs";
811
- import { join as join6 } from "path";
1139
+ import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync6 } from "fs";
1140
+ import { join as join7 } from "path";
812
1141
  var NODE_FRAMEWORKS = [
813
1142
  ["next", "next"],
814
1143
  ["vite", "vite"],
@@ -835,7 +1164,7 @@ var PYTHON_FRAMEWORKS = [
835
1164
  ];
836
1165
  function readJson(file) {
837
1166
  try {
838
- return JSON.parse(readFileSync5(file, "utf8"));
1167
+ return JSON.parse(readFileSync6(file, "utf8"));
839
1168
  } catch {
840
1169
  return void 0;
841
1170
  }
@@ -845,11 +1174,11 @@ function detectStack(root) {
845
1174
  const frameworks = /* @__PURE__ */ new Set();
846
1175
  let monorepo = false;
847
1176
  let packageManager = null;
848
- const pjPath = join6(root, "package.json");
1177
+ const pjPath = join7(root, "package.json");
849
1178
  if (existsSync5(pjPath)) {
850
1179
  const pj = readJson(pjPath);
851
1180
  if (pj) {
852
- const hasTs = Boolean(pj.devDependencies?.typescript) || existsSync5(join6(root, "tsconfig.json"));
1181
+ const hasTs = Boolean(pj.devDependencies?.typescript) || existsSync5(join7(root, "tsconfig.json"));
853
1182
  languages.add(hasTs ? "typescript" : "javascript");
854
1183
  const deps = /* @__PURE__ */ new Set([
855
1184
  ...Object.keys(pj.dependencies ?? {}),
@@ -868,22 +1197,22 @@ function detectStack(root) {
868
1197
  }
869
1198
  }
870
1199
  }
871
- if (existsSync5(join6(root, "pnpm-workspace.yaml"))) {
1200
+ if (existsSync5(join7(root, "pnpm-workspace.yaml"))) {
872
1201
  monorepo = true;
873
1202
  packageManager = packageManager ?? "pnpm";
874
1203
  }
875
- if (existsSync5(join6(root, "turbo.json")) || existsSync5(join6(root, "nx.json"))) {
1204
+ if (existsSync5(join7(root, "turbo.json")) || existsSync5(join7(root, "nx.json"))) {
876
1205
  monorepo = true;
877
1206
  }
878
1207
  if (!packageManager) {
879
- if (existsSync5(join6(root, "pnpm-lock.yaml"))) packageManager = "pnpm";
880
- else if (existsSync5(join6(root, "yarn.lock"))) packageManager = "yarn";
881
- else if (existsSync5(join6(root, "package-lock.json"))) packageManager = "npm";
1208
+ if (existsSync5(join7(root, "pnpm-lock.yaml"))) packageManager = "pnpm";
1209
+ else if (existsSync5(join7(root, "yarn.lock"))) packageManager = "yarn";
1210
+ else if (existsSync5(join7(root, "package-lock.json"))) packageManager = "npm";
882
1211
  }
883
- if (existsSync5(join6(root, "pyproject.toml")) || existsSync5(join6(root, "requirements.txt")) || existsSync5(join6(root, "Pipfile")) || existsSync5(join6(root, "setup.py"))) {
1212
+ if (existsSync5(join7(root, "pyproject.toml")) || existsSync5(join7(root, "requirements.txt")) || existsSync5(join7(root, "Pipfile")) || existsSync5(join7(root, "setup.py"))) {
884
1213
  languages.add("python");
885
- if (existsSync5(join6(root, "pyproject.toml"))) {
886
- const raw = safeRead(join6(root, "pyproject.toml"));
1214
+ if (existsSync5(join7(root, "pyproject.toml"))) {
1215
+ const raw = safeRead(join7(root, "pyproject.toml"));
887
1216
  if (raw) {
888
1217
  for (const [dep, id] of PYTHON_FRAMEWORKS) {
889
1218
  if (pyprojectHasDep(raw, dep)) frameworks.add(id);
@@ -891,13 +1220,13 @@ function detectStack(root) {
891
1220
  }
892
1221
  }
893
1222
  }
894
- if (existsSync5(join6(root, "go.mod"))) {
1223
+ if (existsSync5(join7(root, "go.mod"))) {
895
1224
  languages.add("go");
896
1225
  packageManager = packageManager ?? "go-modules";
897
1226
  }
898
- if (existsSync5(join6(root, "Cargo.toml"))) {
1227
+ if (existsSync5(join7(root, "Cargo.toml"))) {
899
1228
  languages.add("rust");
900
- const raw = safeRead(join6(root, "Cargo.toml"));
1229
+ const raw = safeRead(join7(root, "Cargo.toml"));
901
1230
  if (raw) {
902
1231
  if (/^\s*actix-web\b/m.test(raw)) frameworks.add("actix-web");
903
1232
  if (/^\s*actix\s*=/m.test(raw)) frameworks.add("actix");
@@ -938,10 +1267,10 @@ function detectStack(root) {
938
1267
  }
939
1268
  }
940
1269
  let ci = null;
941
- if (existsSync5(join6(root, ".github", "workflows"))) ci = "github";
942
- else if (existsSync5(join6(root, ".gitlab-ci.yml"))) ci = "gitlab";
943
- else if (existsSync5(join6(root, ".circleci", "config.yml"))) ci = "circleci";
944
- else if (existsSync5(join6(root, "Jenkinsfile"))) ci = "jenkins";
1270
+ if (existsSync5(join7(root, ".github", "workflows"))) ci = "github";
1271
+ else if (existsSync5(join7(root, ".gitlab-ci.yml"))) ci = "gitlab";
1272
+ else if (existsSync5(join7(root, ".circleci", "config.yml"))) ci = "circleci";
1273
+ else if (existsSync5(join7(root, "Jenkinsfile"))) ci = "jenkins";
945
1274
  const aiFiles = [
946
1275
  ["AGENTS.md", "agents-md"],
947
1276
  ["CLAUDE.md", "claude"],
@@ -951,10 +1280,10 @@ function detectStack(root) {
951
1280
  ];
952
1281
  const existingAiFiles = [];
953
1282
  for (const [p, kind] of aiFiles) {
954
- const full = join6(root, p);
1283
+ const full = join7(root, p);
955
1284
  if (existsSync5(full)) existingAiFiles.push({ path: p, kind });
956
1285
  }
957
- const cursorRulesDir = join6(root, ".cursor", "rules");
1286
+ const cursorRulesDir = join7(root, ".cursor", "rules");
958
1287
  try {
959
1288
  if (existsSync5(cursorRulesDir)) {
960
1289
  const entries = readdirSync(cursorRulesDir);
@@ -978,7 +1307,7 @@ function detectStack(root) {
978
1307
  }
979
1308
  function safeRead(file) {
980
1309
  try {
981
- return readFileSync5(file, "utf8");
1310
+ return readFileSync6(file, "utf8");
982
1311
  } catch {
983
1312
  return void 0;
984
1313
  }
@@ -990,35 +1319,6 @@ function pyprojectHasDep(raw, dep) {
990
1319
  return new RegExp(`${before}${esc}${after}`, "m").test(raw);
991
1320
  }
992
1321
 
993
- // src/template.ts
994
- var TOKEN = /\{\{\s*([^}{}]+?)\s*\}\}/g;
995
- function render(template, vars) {
996
- return template.replace(TOKEN, (whole, name) => {
997
- if (!Object.hasOwn(vars, name)) return whole;
998
- const v = vars[name];
999
- if (v === void 0 || v === null) return whole;
1000
- return String(v);
1001
- });
1002
- }
1003
-
1004
- // src/template-loader.ts
1005
- import { readFileSync as readFileSync6 } from "fs";
1006
- import { dirname as dirname4, join as join7, resolve } from "path";
1007
- import { fileURLToPath } from "url";
1008
- var DEFAULT_TEMPLATES_DIR = resolveTemplatesDir();
1009
- function resolveTemplatesDir() {
1010
- const override = process.env.NOIR_TEMPLATES_DIR;
1011
- if (override && override.length > 0) return resolve(override);
1012
- const here = dirname4(fileURLToPath(import.meta.url));
1013
- return join7(here, "..", "templates");
1014
- }
1015
- function loadTemplate(name) {
1016
- return readFileSync6(join7(DEFAULT_TEMPLATES_DIR, name), "utf8");
1017
- }
1018
- function templatesDir() {
1019
- return DEFAULT_TEMPLATES_DIR;
1020
- }
1021
-
1022
1322
  // src/scaffold.ts
1023
1323
  var EMITTED_IN = {
1024
1324
  regenerate: { sync: true, upgrade: true },
@@ -1039,9 +1339,17 @@ function assertSafeRoot(root) {
1039
1339
  cur = parent;
1040
1340
  }
1041
1341
  }
1342
+ function readConfiguredHost(root) {
1343
+ if (!existsSync6(paths2.config(root))) return "claude";
1344
+ try {
1345
+ return loadProjectInfo(root).config.host;
1346
+ } catch {
1347
+ return "claude";
1348
+ }
1349
+ }
1042
1350
  async function scaffold(opts) {
1043
1351
  assertSafeRoot(opts.root);
1044
- const host = opts.host ?? "claude";
1352
+ const host = opts.host ?? readConfiguredHost(opts.root);
1045
1353
  const transport = opts.transport ?? "stdio";
1046
1354
  if (transport === "streamable-http" && !opts.url) {
1047
1355
  throw new Error("transport 'streamable-http' requires opts.url");
@@ -1071,11 +1379,13 @@ async function scaffold(opts) {
1071
1379
  written: [],
1072
1380
  skipped: [],
1073
1381
  identical: [],
1382
+ refreshed: [],
1074
1383
  conflicts: [],
1075
1384
  fileModes: {},
1076
1385
  noop: true,
1077
1386
  migrationsRan: [],
1078
1387
  migrationConflicts: [],
1388
+ migrationChanged: [],
1079
1389
  stack,
1080
1390
  projectId,
1081
1391
  fromVersion,
@@ -1085,12 +1395,14 @@ async function scaffold(opts) {
1085
1395
  }
1086
1396
  const migrationsRan = [];
1087
1397
  const migrationConflicts = [];
1088
- if (opts.mode === "init" && opts.upgrade === true && fromVersion !== null) {
1398
+ const migrationChanged = [];
1399
+ if (opts.mode === "init" && opts.upgrade === true && (fromVersion !== null || idFile.state === "valid")) {
1089
1400
  const m = runMigrations(opts.root, fromVersion, CURRENT_SCAFFOLD_VERSION, {
1090
1401
  dryRun: opts.dryRun === true
1091
1402
  });
1092
1403
  migrationsRan.push(...m.ran);
1093
1404
  migrationConflicts.push(...m.conflicts);
1405
+ migrationChanged.push(...m.changed);
1094
1406
  }
1095
1407
  const command = resolveNoirCommand();
1096
1408
  const manifest = buildManifest({
@@ -1114,6 +1426,7 @@ async function scaffold(opts) {
1114
1426
  const written = [];
1115
1427
  const skipped = [];
1116
1428
  const identical = [];
1429
+ const refreshed = [];
1117
1430
  const fileModes = {};
1118
1431
  const conflictMemory = /* @__PURE__ */ new Map();
1119
1432
  const conflictRecords = [];
@@ -1180,21 +1493,34 @@ async function scaffold(opts) {
1180
1493
  if (!opts.dryRun) mkdirSync3(dirname5(abs), { recursive: true });
1181
1494
  for (const entry of entries) {
1182
1495
  if (opts.dryRun) {
1183
- if (entry.mode === "skipIfExists") {
1496
+ if (entry.mode !== "skipIfExists") {
1497
+ written.push(entry.path);
1498
+ continue;
1499
+ }
1500
+ const seedKind = refreshableSeedKind(entry, emitContext);
1501
+ if (seedKind === void 0) {
1184
1502
  if (existsSync6(abs)) {
1185
1503
  skipped.push(entry.path);
1186
1504
  } else {
1187
1505
  written.push(entry.path);
1188
1506
  if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
1189
1507
  }
1190
- } else {
1508
+ continue;
1509
+ }
1510
+ const plan = planSeedWrite(abs, seedKind, renderEntry(entry, vars));
1511
+ if (plan === "create") {
1191
1512
  written.push(entry.path);
1513
+ if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
1514
+ } else if (plan === "refresh") {
1515
+ refreshed.push(entry.path);
1516
+ } else {
1517
+ skipped.push(entry.path);
1192
1518
  }
1193
1519
  continue;
1194
1520
  }
1195
1521
  const body = renderEntry(entry, vars);
1196
1522
  if (entry.mode === "regenerate") {
1197
- const out = await writeRegenerateWithConflict(abs, entry.path, body, opts, {
1523
+ const out = await writeWithConflict(abs, entry.path, body, opts, {
1198
1524
  memory: conflictMemory,
1199
1525
  record: recordConflict
1200
1526
  });
@@ -1249,12 +1575,40 @@ async function scaffold(opts) {
1249
1575
  if (out.written) written.push(entry.path);
1250
1576
  else identical.push(entry.path);
1251
1577
  } else {
1252
- const out = skipIfExists(abs, body, entry.fileMode);
1253
- if (out.written) {
1578
+ const seedKind = refreshableSeedKind(entry, emitContext);
1579
+ if (seedKind === void 0) {
1580
+ const out = skipIfExists(abs, body, entry.fileMode);
1581
+ if (out.written) {
1582
+ written.push(entry.path);
1583
+ if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
1584
+ } else {
1585
+ skipped.push(entry.path);
1586
+ }
1587
+ continue;
1588
+ }
1589
+ const plan = planSeedWrite(abs, seedKind, body);
1590
+ if (plan === "create") {
1591
+ skipIfExists(abs, body, entry.fileMode);
1254
1592
  written.push(entry.path);
1255
1593
  if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
1256
- } else {
1594
+ } else if (plan === "refresh") {
1595
+ refreshSeed(abs, body);
1596
+ refreshed.push(entry.path);
1597
+ } else if (plan === "keep") {
1257
1598
  skipped.push(entry.path);
1599
+ } else {
1600
+ const out = await writeWithConflict(abs, entry.path, body, opts, {
1601
+ memory: conflictMemory,
1602
+ record: recordConflict,
1603
+ artifactClass: "skipIfExists",
1604
+ // No resolver answered (no TTY, `--no-input`, CI): keep the user's
1605
+ // file. An explicit overwrite policy is an instruction rather than a
1606
+ // missing answer, so `--force` still replaces.
1607
+ defaultResolution: opts.conflictPolicy === "overwrite" ? "replace" : "preserve"
1608
+ });
1609
+ written.push(...out.written);
1610
+ skipped.push(...out.skipped);
1611
+ identical.push(...out.identical);
1258
1612
  }
1259
1613
  }
1260
1614
  }
@@ -1269,11 +1623,13 @@ async function scaffold(opts) {
1269
1623
  written,
1270
1624
  skipped,
1271
1625
  identical,
1626
+ refreshed,
1272
1627
  conflicts: conflictRecords,
1273
1628
  fileModes,
1274
1629
  noop: false,
1275
1630
  migrationsRan,
1276
1631
  migrationConflicts,
1632
+ migrationChanged,
1277
1633
  stack,
1278
1634
  projectId,
1279
1635
  fromVersion,
@@ -1347,7 +1703,29 @@ function resolveProjectId(opts, idFile) {
1347
1703
  }
1348
1704
  return createProjectId();
1349
1705
  }
1350
- async function writeRegenerateWithConflict(abs, relPath, proposed, opts, internals) {
1706
+ function planSeedWrite(abs, seed, currentRender) {
1707
+ let existing;
1708
+ try {
1709
+ existing = readOptional(abs);
1710
+ } catch {
1711
+ return "keep";
1712
+ }
1713
+ if (existing === void 0) return "create";
1714
+ if (isStaleSeed(seed, existing, currentRender)) return "refresh";
1715
+ if (existing === currentRender) return "keep";
1716
+ return "conflict";
1717
+ }
1718
+ function refreshableSeedKind(entry, emitContext) {
1719
+ if (emitContext !== "upgrade" || entry.refreshIfStale !== true) return void 0;
1720
+ const kind = REFRESHABLE_SEED_KIND[entry.path];
1721
+ if (kind === void 0) {
1722
+ throw new Error(
1723
+ `manifest entry ${entry.path}: 'refreshIfStale' is set but no recorded seed is declared for that path`
1724
+ );
1725
+ }
1726
+ return kind;
1727
+ }
1728
+ async function writeWithConflict(abs, relPath, proposed, opts, internals) {
1351
1729
  let existing;
1352
1730
  try {
1353
1731
  existing = readFileSync7(abs, "utf8");
@@ -1361,7 +1739,7 @@ async function writeRegenerateWithConflict(abs, relPath, proposed, opts, interna
1361
1739
  if (existing === proposed) {
1362
1740
  return { written: [], skipped: [], identical: [relPath] };
1363
1741
  }
1364
- const MODE = "regenerate";
1742
+ const MODE = internals.artifactClass ?? "regenerate";
1365
1743
  const sim = similarity(existing, proposed);
1366
1744
  let resolution;
1367
1745
  const remembered = internals.memory.get(MODE);
@@ -1375,7 +1753,7 @@ async function writeRegenerateWithConflict(abs, relPath, proposed, opts, interna
1375
1753
  }
1376
1754
  resolution = unwrapped;
1377
1755
  } else {
1378
- resolution = opts.conflictPolicy === "preserve" ? "preserve" : "replace";
1756
+ resolution = opts.conflictPolicy === "preserve" ? "preserve" : internals.defaultResolution ?? "replace";
1379
1757
  }
1380
1758
  internals.record(relPath, MODE, existing, proposed, resolution, sim);
1381
1759
  switch (resolution) {
@@ -1460,6 +1838,7 @@ export {
1460
1838
  CURRENT_SCAFFOLD_VERSION,
1461
1839
  MANIFEST_PATH_PARITY,
1462
1840
  MIGRATIONS,
1841
+ SEED_TEMPLATE_HISTORY,
1463
1842
  ancestorsPath,
1464
1843
  applyInlineConflict,
1465
1844
  applyWithConflict,
@@ -1468,6 +1847,7 @@ export {
1468
1847
  buildManifest,
1469
1848
  buildRegion,
1470
1849
  detectStack,
1850
+ isStaleSeed,
1471
1851
  lineDiff,
1472
1852
  loadTemplate,
1473
1853
  managedBlock2 as managedBlock,