@noir-ai/create 1.15.0-beta.1 → 1.16.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
@@ -32,10 +32,11 @@ function writeAncestors(root, map) {
32
32
 
33
33
  // src/manifest.ts
34
34
  import { existsSync as existsSync2 } from "fs";
35
- import { join as join2, relative } from "path";
35
+ import { join as join3, relative } from "path";
36
36
  import {
37
37
  AGENTS_MD_FILENAME,
38
38
  emitAgentsMd,
39
+ mcpConfigPathForAdapter,
39
40
  resolveAdapter
40
41
  } from "@noir-ai/adapters";
41
42
  import {
@@ -44,10 +45,48 @@ import {
44
45
  managedBlock,
45
46
  NOIR_DIR,
46
47
  paths,
47
- RULES_BLOCK
48
+ RULES_BLOCK,
49
+ readWorkspaceMarker
48
50
  } from "@noir-ai/core";
51
+
52
+ // src/template.ts
53
+ var TOKEN = /\{\{\s*([^}{}]+?)\s*\}\}/g;
54
+ function render(template, vars) {
55
+ return template.replace(TOKEN, (whole, name) => {
56
+ if (!Object.hasOwn(vars, name)) return whole;
57
+ const v = vars[name];
58
+ if (v === void 0 || v === null) return whole;
59
+ return String(v);
60
+ });
61
+ }
62
+
63
+ // src/template-loader.ts
64
+ import { readFileSync as readFileSync2 } from "fs";
65
+ import { dirname as dirname2, join as join2, resolve } from "path";
66
+ import { fileURLToPath } from "url";
67
+ var DEFAULT_TEMPLATES_DIR = resolveTemplatesDir();
68
+ function resolveTemplatesDir() {
69
+ const override = process.env.NOIR_TEMPLATES_DIR;
70
+ if (override && override.length > 0) return resolve(override);
71
+ const here = dirname2(fileURLToPath(import.meta.url));
72
+ return join2(here, "..", "templates");
73
+ }
74
+ function loadTemplate(name) {
75
+ return readFileSync2(join2(DEFAULT_TEMPLATES_DIR, name), "utf8");
76
+ }
77
+ function templatesDir() {
78
+ return DEFAULT_TEMPLATES_DIR;
79
+ }
80
+
81
+ // src/manifest.ts
49
82
  var BRIEF_BLOCK = managedBlock("brief", "html");
50
83
  var README_BLOCK = managedBlock("readme", "html");
84
+ var RULES_MAP_ROW_PREFIX = "| `rules/RULES.md` |";
85
+ function readmeMapContent(ctx) {
86
+ const rendered = render(loadTemplate("noir-readme.md.tmpl"), ctx);
87
+ if (ctx.rulesEnabled !== false) return rendered;
88
+ return rendered.split("\n").filter((line) => !line.startsWith(RULES_MAP_ROW_PREFIX)).join("\n");
89
+ }
51
90
  var P = {
52
91
  projectId: `${NOIR_DIR}/project.id`,
53
92
  config: `${NOIR_DIR}/config.yml`,
@@ -100,8 +139,9 @@ function hostAgnosticEntries(ctx) {
100
139
  // all-comment, so the file parses to an EMPTY overlay and creating it
101
140
  // changes no behaviour — it exists so the user never has to copy the
102
141
  // example by hand. `fileMode: 0o600` because the file holds tokens the
103
- // moment the user edits it (set on creation only — skipIfExists never
104
- // touches an existing file, so a user's own chmod stands).
142
+ // moment the user edits it. The mode is applied when the file is created;
143
+ // a copy that predates this contract (or that an editor saved by rename)
144
+ // has its mode re-asserted on every init/sync/create run — see `ensureOwnerOnly`.
105
145
  path: P.env,
106
146
  mode: "skipIfExists",
107
147
  template: "config.env.tmpl",
@@ -130,10 +170,14 @@ function hostAgnosticEntries(ctx) {
130
170
  // next. Host-agnostic: it describes the canonical store, which every
131
171
  // host shares. Co-owned (README_BLOCK) so user notes survive while the
132
172
  // map stays current through `noir sync` / `init --upgrade`.
173
+ //
174
+ // Rendered here rather than left to `renderEntry` because one row of the
175
+ // map depends on the project: the working-rules row is dropped when
176
+ // `rules.enabled` is off (see {@link readmeMapContent}).
133
177
  path: P.readme,
134
178
  mode: "managedBlock",
135
179
  block: README_BLOCK,
136
- template: "noir-readme.md.tmpl",
180
+ content: readmeMapContent(ctx),
137
181
  description: ".noir/ runtime map (what exists now / what appears later)"
138
182
  },
139
183
  // --- ignore files (host-agnostic; co-owned via IGNORE_BLOCK) ------------
@@ -168,21 +212,24 @@ function hostAgnosticEntries(ctx) {
168
212
  ];
169
213
  const isEmpty = (ctx.stack?.languages?.length ?? 0) === 0 && !ctx.stack?.packageManager;
170
214
  const isJs = isEmpty || (ctx.stack?.languages?.some((l) => l === "typescript" || l === "javascript") ?? false) || ["npm", "pnpm", "yarn"].includes(ctx.stack?.packageManager ?? "");
171
- const hasDocker = isEmpty || existsSync2(join2(ctx.root, "Dockerfile")) || existsSync2(join2(ctx.root, "docker-compose.yml")) || existsSync2(join2(ctx.root, "compose.yaml"));
215
+ const hasDocker = isEmpty || existsSync2(join3(ctx.root, "Dockerfile")) || existsSync2(join3(ctx.root, "docker-compose.yml")) || existsSync2(join3(ctx.root, "compose.yaml"));
216
+ const rulesEnabled = ctx.rulesEnabled !== false;
172
217
  return entries.filter((e) => {
218
+ if (e.path === P.rulesMd) return rulesEnabled;
173
219
  if (e.path === ".npmignore" || e.path === ".prettierignore") return isJs;
174
220
  if (e.path === ".dockerignore") return hasDocker;
175
221
  return true;
176
222
  });
177
223
  }
178
224
  function buildHostArtifacts(adapter, ctx) {
179
- const ectx = { root: ctx.root };
225
+ const rulesEnabled = ctx.rulesEnabled !== false;
226
+ const ectx = { root: ctx.root, rulesEnabled };
180
227
  const host = adapter.id;
181
228
  const entries = [];
182
229
  const emitsAgentsMd = host === "agents-md" || host === "cursor" || host === "opencode";
183
230
  if (emitsAgentsMd) {
184
231
  entries.push({
185
- path: hostRel(adapter.agentsMdPath?.(ectx) ?? join2(ctx.root, AGENTS_MD_FILENAME), ctx.root),
232
+ path: hostRel(adapter.agentsMdPath?.(ectx) ?? join3(ctx.root, AGENTS_MD_FILENAME), ctx.root),
186
233
  mode: "regenerate",
187
234
  host,
188
235
  content: emitAgentsMd(ectx),
@@ -199,14 +246,16 @@ function buildHostArtifacts(adapter, ctx) {
199
246
  template: "claude-context-block.md.tmpl",
200
247
  description: "CLAUDE.md context @import block"
201
248
  });
202
- entries.push({
203
- path: "CLAUDE.md",
204
- mode: "managedBlock",
205
- host,
206
- block: RULES_BLOCK,
207
- template: "claude-rules-block.md.tmpl",
208
- description: "CLAUDE.md rules @import block"
209
- });
249
+ if (rulesEnabled) {
250
+ entries.push({
251
+ path: "CLAUDE.md",
252
+ mode: "managedBlock",
253
+ host,
254
+ block: RULES_BLOCK,
255
+ template: "claude-rules-block.md.tmpl",
256
+ description: "CLAUDE.md rules @import block"
257
+ });
258
+ }
210
259
  break;
211
260
  case "gemini":
212
261
  entries.push({
@@ -217,23 +266,26 @@ function buildHostArtifacts(adapter, ctx) {
217
266
  content: "@.noir/NOIR.md",
218
267
  description: "GEMINI.md context @-import block"
219
268
  });
220
- entries.push({
221
- path: "GEMINI.md",
222
- mode: "managedBlock",
223
- host,
224
- block: RULES_BLOCK,
225
- content: "@.noir/rules/RULES.md",
226
- description: "GEMINI.md rules @-import block"
227
- });
269
+ if (rulesEnabled) {
270
+ entries.push({
271
+ path: "GEMINI.md",
272
+ mode: "managedBlock",
273
+ host,
274
+ block: RULES_BLOCK,
275
+ content: "@.noir/rules/RULES.md",
276
+ description: "GEMINI.md rules @-import block"
277
+ });
278
+ }
228
279
  break;
229
280
  case "agents-md":
230
281
  case "cursor":
231
282
  case "opencode":
232
283
  break;
233
284
  }
234
- const mcpAbs = adapter.mcpConfigPath?.(ectx) ?? join2(ctx.root, ".mcp.json");
285
+ const mcpAbs = mcpConfigPathForAdapter(adapter, ctx.root);
235
286
  const mcpRel = hostRel(mcpAbs, ctx.root);
236
- if (host === "claude") {
287
+ const workspace = readWorkspaceMarker(ctx.root);
288
+ if (host === "claude" && workspace === null) {
237
289
  const mcpTemplate = ctx.transport === "streamable-http" ? "mcp.http.json.tmpl" : "mcp.stdio.json.tmpl";
238
290
  entries.push({
239
291
  path: mcpRel,
@@ -243,18 +295,24 @@ function buildHostArtifacts(adapter, ctx) {
243
295
  description: "host MCP server pointer"
244
296
  });
245
297
  } else {
246
- const mcpContent = `${adapter.emitMcpConfig(ectx, {
247
- transport: ctx.transport,
248
- command: ctx.command,
249
- ...ctx.url !== void 0 ? { url: ctx.url } : {}
250
- })}
298
+ const mcpContent = `${adapter.emitMcpConfig(
299
+ ectx,
300
+ workspace !== null ? (
301
+ // Joined: stdio + the workspace name, whatever transport was asked for.
302
+ { transport: "stdio", command: ctx.command, workspace }
303
+ ) : {
304
+ transport: ctx.transport,
305
+ command: ctx.command,
306
+ ...ctx.url !== void 0 ? { url: ctx.url } : {}
307
+ }
308
+ )}
251
309
  `;
252
310
  entries.push({
253
311
  path: mcpRel,
254
312
  mode: "regenerate",
255
313
  host,
256
314
  content: mcpContent,
257
- description: `${host} MCP server pointer`
315
+ description: workspace !== null ? `${host} MCP server pointer (workspace "${workspace}")` : `${host} MCP server pointer`
258
316
  });
259
317
  }
260
318
  if (host === "claude") {
@@ -481,19 +539,15 @@ function mergeThreeWay(base, ours, theirs, style = "minimal") {
481
539
  }
482
540
 
483
541
  // src/migrations/index.ts
484
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
485
- import { join as join5 } from "path";
486
-
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
- });
496
- }
542
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
543
+ import { join as join5, relative as relative2, sep } from "path";
544
+ import { mcpConfigPathFor, noirStdioArgs, TRANSPORT_KEYS } from "@noir-ai/adapters";
545
+ import {
546
+ atomicWriteFile as atomicWriteFile2,
547
+ loadProjectInfo,
548
+ readWorkspaceMarker as readWorkspaceMarker2,
549
+ resolveNoirCommand
550
+ } from "@noir-ai/core";
497
551
 
498
552
  // src/template-history.ts
499
553
  var ENV_EXAMPLE_1_1_0 = `# .noir/.env.example \u2014 committable documentation of the format.
@@ -734,24 +788,6 @@ function isStaleSeed(seed, fileBytes, currentRender) {
734
788
  return SEED_TEMPLATE_HISTORY.some((entry) => entry[seed] === fileBytes);
735
789
  }
736
790
 
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");
747
- }
748
- function loadTemplate(name) {
749
- return readFileSync2(join3(DEFAULT_TEMPLATES_DIR, name), "utf8");
750
- }
751
- function templatesDir() {
752
- return DEFAULT_TEMPLATES_DIR;
753
- }
754
-
755
791
  // src/writers.ts
756
792
  import {
757
793
  closeSync,
@@ -760,11 +796,11 @@ import {
760
796
  readFileSync as readFileSync3,
761
797
  renameSync,
762
798
  rmSync,
763
- writeFileSync as writeFileSync2,
764
799
  writeSync
765
800
  } from "fs";
766
801
  import { basename, dirname as dirname3, join as join4 } from "path";
767
802
  import { atomicWriteFile, stripManagedBlock, writeManagedRegion } from "@noir-ai/core";
803
+ import { ensureOwnerOnly } from "@noir-ai/core";
768
804
  function regenerate(absPath, content) {
769
805
  const dir = dirname3(absPath);
770
806
  const tmp = join4(
@@ -818,7 +854,7 @@ function managedBlocks(absPath, regions) {
818
854
  const next = stripped.trim().length > 0 ? `${stripped.trimEnd()}
819
855
 
820
856
  ${regionsJoined}` : regionsJoined;
821
- writeFileSync2(absPath, next, "utf8");
857
+ atomicWriteFile(absPath, next);
822
858
  return { path: absPath, mode: "managedBlock", written: true };
823
859
  }
824
860
  function buildRegion(block, body) {
@@ -911,7 +947,11 @@ function deepMergePreservingHooks(existing, patch, dedupSubstring) {
911
947
  // src/migrations/runner.ts
912
948
  function runMigrations(root, from, to, opts = {}) {
913
949
  const window = pickWindow(from ?? "0.0.0", to, MIGRATIONS);
914
- const ctx = { root, dryRun: opts.dryRun === true };
950
+ const ctx = {
951
+ root,
952
+ dryRun: opts.dryRun === true,
953
+ rulesEnabled: opts.rulesEnabled
954
+ };
915
955
  const aggregate = { changed: [], conflicts: [], notes: [] };
916
956
  const ran = [];
917
957
  for (const script of window) {
@@ -960,7 +1000,7 @@ var synthetic = {
960
1000
  if (existsSync4(file)) {
961
1001
  const prev = readFileSync4(file, "utf8");
962
1002
  const merged = applyInlineConflict(prev, "noir-scaffold=1.0.0\n", "ours", "theirs");
963
- writeFileSync3(file, merged, "utf8");
1003
+ writeFileSync2(file, merged, "utf8");
964
1004
  result.conflicts.push(".noir/scaffold-version");
965
1005
  }
966
1006
  }
@@ -1015,7 +1055,7 @@ var envPointer = {
1015
1055
  return result;
1016
1056
  }
1017
1057
  try {
1018
- writeFileSync3(abs, appendSeparated(prev, ENV_POINTER_BLOCK), "utf8");
1058
+ writeFileSync2(abs, appendSeparated(prev, ENV_POINTER_BLOCK), "utf8");
1019
1059
  } catch (err) {
1020
1060
  result.conflicts.push(rel);
1021
1061
  result.notes.push(`${rel}: write failed (${errorMessage(err)}) \u2014 left untouched`);
@@ -1037,6 +1077,10 @@ var envTemplates = {
1037
1077
  run: (ctx) => {
1038
1078
  const result = { changed: [], conflicts: [], notes: [] };
1039
1079
  for (const seed of DOC_SEEDS) {
1080
+ if (seed.kind === "rulesSeed" && ctx.rulesEnabled === false) {
1081
+ result.notes.push(`${seed.rel}: skipped \u2014 the working-rules switch is off`);
1082
+ continue;
1083
+ }
1040
1084
  const abs = join5(ctx.root, seed.rel);
1041
1085
  if (!existsSync4(abs)) {
1042
1086
  result.notes.push(`${seed.rel}: absent \u2014 nothing to refresh`);
@@ -1073,7 +1117,101 @@ var envTemplates = {
1073
1117
  return result;
1074
1118
  }
1075
1119
  };
1076
- var MIGRATIONS = [synthetic, envPointer, envTemplates];
1120
+ function mcpConfigPath(root) {
1121
+ let host = "claude";
1122
+ try {
1123
+ host = loadProjectInfo(root).config.host;
1124
+ } catch {
1125
+ }
1126
+ const abs = mcpConfigPathFor(host, root);
1127
+ if (!existsSync4(abs)) return null;
1128
+ return { rel: relative2(root, abs).split(sep).join("/"), abs };
1129
+ }
1130
+ var workspaceBridge = {
1131
+ from: "1.2.0",
1132
+ to: "1.3.0",
1133
+ description: "point a joined repo's MCP entry at the workspace bridge",
1134
+ run: (ctx) => {
1135
+ const result = { changed: [], conflicts: [], notes: [] };
1136
+ const name = readWorkspaceMarker2(ctx.root);
1137
+ if (name === null) {
1138
+ result.notes.push("not joined to a workspace \u2014 nothing to rewrite");
1139
+ return result;
1140
+ }
1141
+ const target = mcpConfigPath(ctx.root);
1142
+ if (target === null) {
1143
+ result.notes.push(`workspace "${name}": no MCP config file to migrate`);
1144
+ return result;
1145
+ }
1146
+ let config;
1147
+ try {
1148
+ config = JSON.parse(readFileSync4(target.abs, "utf8"));
1149
+ } catch (err) {
1150
+ result.conflicts.push(target.rel);
1151
+ result.notes.push(`${target.rel}: unreadable (${errorMessage(err)}) \u2014 left untouched`);
1152
+ return result;
1153
+ }
1154
+ const servers = config.mcpServers;
1155
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
1156
+ result.notes.push(`${target.rel}: no mcpServers block \u2014 left untouched`);
1157
+ return result;
1158
+ }
1159
+ const entry = servers.noir;
1160
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
1161
+ result.notes.push(`${target.rel}: no noir entry \u2014 left untouched`);
1162
+ return result;
1163
+ }
1164
+ const entryObj = entry;
1165
+ const args = entryObj.args;
1166
+ const namesWorkspace = Array.isArray(args) && args.includes("--workspace");
1167
+ const isHttpPointer = entryObj.type === "http";
1168
+ const isRepoScopedStdio = typeof entryObj.command === "string" && !namesWorkspace;
1169
+ if (!isHttpPointer && !isRepoScopedStdio) {
1170
+ result.notes.push(
1171
+ namesWorkspace ? `${target.rel}: already reaches Noir over the bridge \u2014 left untouched` : `${target.rel}: noir entry is not a transport this migration owns \u2014 left untouched`
1172
+ );
1173
+ return result;
1174
+ }
1175
+ const kept = Object.entries(entryObj).filter(([key]) => !TRANSPORT_KEYS.has(key));
1176
+ if (ctx.dryRun) {
1177
+ result.changed.push(target.rel);
1178
+ result.notes.push(`${target.rel}: would point the noir entry at workspace "${name}"`);
1179
+ return result;
1180
+ }
1181
+ const next = {
1182
+ ...config,
1183
+ mcpServers: {
1184
+ ...servers,
1185
+ noir: {
1186
+ command: resolveNoirCommand(),
1187
+ // The argv comes from the adapters' own helper, so this rewrite lands
1188
+ // on exactly the entry a fresh join writes — the name is the whole
1189
+ // address, and the bridge resolves the daemon (and reads its token)
1190
+ // itself, so none of that is written into a committed config file.
1191
+ args: noirStdioArgs(name),
1192
+ ...Object.fromEntries(kept)
1193
+ }
1194
+ }
1195
+ };
1196
+ try {
1197
+ atomicWriteFile2(target.abs, `${JSON.stringify(next, null, 2)}
1198
+ `);
1199
+ } catch (err) {
1200
+ result.conflicts.push(target.rel);
1201
+ result.notes.push(`${target.rel}: write failed (${errorMessage(err)}) \u2014 left untouched`);
1202
+ return result;
1203
+ }
1204
+ result.changed.push(target.rel);
1205
+ result.notes.push(`${target.rel}: noir entry now reaches workspace "${name}" over the bridge`);
1206
+ return result;
1207
+ }
1208
+ };
1209
+ var MIGRATIONS = [
1210
+ synthetic,
1211
+ envPointer,
1212
+ envTemplates,
1213
+ workspaceBridge
1214
+ ];
1077
1215
  function errorMessage(err) {
1078
1216
  return err instanceof Error ? err.message : String(err);
1079
1217
  }
@@ -1096,10 +1234,11 @@ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as rea
1096
1234
  import { basename as basename2, dirname as dirname5, join as join8 } from "path";
1097
1235
  import {
1098
1236
  createProjectId,
1099
- loadProjectInfo,
1237
+ loadProjectInfo as loadProjectInfo2,
1100
1238
  paths as paths2,
1101
1239
  readManagedBlock,
1102
- resolveNoirCommand,
1240
+ readProjectConfig,
1241
+ resolveNoirCommand as resolveNoirCommand2,
1103
1242
  sha256Hex12
1104
1243
  } from "@noir-ai/core";
1105
1244
 
@@ -1107,7 +1246,7 @@ import {
1107
1246
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync5 } from "fs";
1108
1247
  import { dirname as dirname4, join as join6 } from "path";
1109
1248
  import { NOIR_DIR as NOIR_DIR2 } from "@noir-ai/core";
1110
- var CURRENT_SCAFFOLD_VERSION = "1.2.0";
1249
+ var CURRENT_SCAFFOLD_VERSION = "1.3.0";
1111
1250
  var PREFIX = "noir-scaffold=";
1112
1251
  function scaffoldVersionPath(root) {
1113
1252
  return join6(root, NOIR_DIR2, "scaffold-version");
@@ -1339,14 +1478,21 @@ function assertSafeRoot(root) {
1339
1478
  cur = parent;
1340
1479
  }
1341
1480
  }
1481
+ function envOwnerOnly(opts) {
1482
+ if (opts.dryRun === true) return void 0;
1483
+ return ensureOwnerOnly(join8(paths2.noirDir(opts.root), ".env"));
1484
+ }
1342
1485
  function readConfiguredHost(root) {
1343
1486
  if (!existsSync6(paths2.config(root))) return "claude";
1344
1487
  try {
1345
- return loadProjectInfo(root).config.host;
1488
+ return loadProjectInfo2(root).config.host;
1346
1489
  } catch {
1347
1490
  return "claude";
1348
1491
  }
1349
1492
  }
1493
+ function readConfiguredRulesEnabled(root) {
1494
+ return readProjectConfig(root)?.rules.enabled ?? true;
1495
+ }
1350
1496
  async function scaffold(opts) {
1351
1497
  assertSafeRoot(opts.root);
1352
1498
  const host = opts.host ?? readConfiguredHost(opts.root);
@@ -1382,6 +1528,7 @@ async function scaffold(opts) {
1382
1528
  refreshed: [],
1383
1529
  conflicts: [],
1384
1530
  fileModes: {},
1531
+ envMode: envOwnerOnly(opts),
1385
1532
  noop: true,
1386
1533
  migrationsRan: [],
1387
1534
  migrationConflicts: [],
@@ -1398,13 +1545,18 @@ async function scaffold(opts) {
1398
1545
  const migrationChanged = [];
1399
1546
  if (opts.mode === "init" && opts.upgrade === true && (fromVersion !== null || idFile.state === "valid")) {
1400
1547
  const m = runMigrations(opts.root, fromVersion, CURRENT_SCAFFOLD_VERSION, {
1401
- dryRun: opts.dryRun === true
1548
+ dryRun: opts.dryRun === true,
1549
+ // The migration runs BEFORE the manifest, so it cannot see the manifest's
1550
+ // own rules-gating. Hand the switch through so a migration that would
1551
+ // rewrite the working-rules seed (the doc-seed refresh keyed on recorded
1552
+ // older bytes) leaves a switch-off project's RULES.md alone.
1553
+ rulesEnabled: readConfiguredRulesEnabled(opts.root)
1402
1554
  });
1403
1555
  migrationsRan.push(...m.ran);
1404
1556
  migrationConflicts.push(...m.conflicts);
1405
1557
  migrationChanged.push(...m.changed);
1406
1558
  }
1407
- const command = resolveNoirCommand();
1559
+ const command = resolveNoirCommand2();
1408
1560
  const manifest = buildManifest({
1409
1561
  root: opts.root,
1410
1562
  projectId,
@@ -1412,7 +1564,12 @@ async function scaffold(opts) {
1412
1564
  transport,
1413
1565
  url: opts.url,
1414
1566
  command,
1415
- stack
1567
+ stack,
1568
+ // The project's own working-rules switch: a project that set
1569
+ // `rules.enabled: false` gets no `.noir/rules/RULES.md` — not on the first
1570
+ // scaffold, and not backfilled by an upgrade. Unchanged (enabled) whenever
1571
+ // the config is absent or says nothing about rules.
1572
+ rulesEnabled: readConfiguredRulesEnabled(opts.root)
1416
1573
  });
1417
1574
  const emitContext = opts.mode === "sync" ? "sync" : opts.mode === "init" && opts.upgrade === true ? "upgrade" : null;
1418
1575
  const vars = {
@@ -1626,6 +1783,10 @@ async function scaffold(opts) {
1626
1783
  refreshed,
1627
1784
  conflicts: conflictRecords,
1628
1785
  fileModes,
1786
+ // Re-asserted here, after the emit, so the mode on disk is the one this run
1787
+ // leaves behind: the seed writer only applies 0600 at creation (see
1788
+ // `envOwnerOnly`).
1789
+ envMode: envOwnerOnly(opts),
1629
1790
  noop: false,
1630
1791
  migrationsRan,
1631
1792
  migrationConflicts,
@@ -1847,6 +2008,7 @@ export {
1847
2008
  buildManifest,
1848
2009
  buildRegion,
1849
2010
  detectStack,
2011
+ ensureOwnerOnly,
1850
2012
  isStaleSeed,
1851
2013
  lineDiff,
1852
2014
  loadTemplate,