@noir-ai/create 1.17.0-beta.1 → 1.18.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.d.ts CHANGED
@@ -239,13 +239,8 @@ interface WriteOutcome {
239
239
  * Parent directories are NOT created here — the orchestrator does that once
240
240
  * for the whole manifest so a missing dir is a single, attributable failure
241
241
  * rather than N silent ones inside the writer.
242
- *
243
- * `mode` applies to the temp file at creation and rides the rename onto the
244
- * target, so an entry executed directly (the SessionStart hook runner) lands
245
- * with its exec bit on a first write and a rewrite alike. As with core's
246
- * `atomicWriteFile`, the requested bits are an upper bound (the process umask
247
- * can narrow them) and Windows ignores them (ACL-based permissions). */
248
- declare function regenerate(absPath: string, content: string, mode?: number): WriteOutcome;
242
+ */
243
+ declare function regenerate(absPath: string, content: string): WriteOutcome;
249
244
  /** Re-emit a managed region, delegating to core's `writeManagedRegion`.
250
245
  * `regionText` MUST already include the begin/end markers (matches the shape
251
246
  * `writeManagedRegion` expects and that `IGNORE_BLOCK`/`CONTEXT_BLOCK`
@@ -581,11 +576,11 @@ declare function mergeThreeWay(base: string, ours: string, theirs: string, style
581
576
  * `result.conflicts` so the caller (doctor/CI) can fail loudly with a
582
577
  * reviewable artifact rather than hang on a prompt.
583
578
  *
584
- * The registry ({@link MIGRATIONS}) carries four entries: the synthetic
585
- * `1.0.0 → 1.0.0` smoke test that exercises the runner end-to-end, and three
586
- * real version-to-version migrations, `1.0.0 → 1.1.0`, `1.1.0 → 1.2.0` and
587
- * `1.2.0 → 1.3.0`. The synthetic one is the copy-pasteable template for the
588
- * next contributor.
579
+ * The registry ({@link MIGRATIONS}) carries five entries: the synthetic
580
+ * `1.0.0 → 1.0.0` smoke test that exercises the runner end-to-end, and four
581
+ * real version-to-version migrations, `1.0.0 → 1.1.0`, `1.1.0 → 1.2.0`,
582
+ * `1.2.0 → 1.3.0` and `1.3.0 → 1.4.0`. The synthetic one is the copy-pasteable
583
+ * template for the next contributor.
589
584
  */
590
585
  interface MigrationContext {
591
586
  /** Absolute repo root the migration operates on. */
@@ -888,9 +883,9 @@ declare function scaffold(opts: ScaffoldOptions): Promise<ScaffoldResult>;
888
883
  /** The scaffold version this build of @noir-ai/create ships. Bumped atomically
889
884
  * whenever a manifest entry, template, or migration changes shape. 1.3.0 adds
890
885
  * the migration that points a joined repo's MCP entry back at the workspace
891
- * bridge — a repair only the upgrade path can perform, so projects stamped
892
- * 1.2.0 and below need a window that starts at their version to reach it. */
893
- declare const CURRENT_SCAFFOLD_VERSION = "1.3.0";
886
+ * bridge; 1.4.0 adds the migration that moves the SessionStart hook command off
887
+ * the `.mjs` runner and onto `noir hook` (see migrations/index.ts). */
888
+ declare const CURRENT_SCAFFOLD_VERSION = "1.4.0";
894
889
  /** Path to the stamp file under `root`. Exposed for tests + doctor. */
895
890
  declare function scaffoldVersionPath(root: string): string;
896
891
  /** Read the applied scaffold version, or `null` if the stamp is absent/unparseable.
package/dist/index.js CHANGED
@@ -316,7 +316,7 @@ function buildHostArtifacts(adapter, ctx) {
316
316
  });
317
317
  }
318
318
  if (host === "claude") {
319
- const HOOK_DEDUP = "noir-session-start";
319
+ const HOOK_DEDUP = " hook";
320
320
  const hookEntry = {
321
321
  hooks: {
322
322
  SessionStart: [
@@ -324,7 +324,7 @@ function buildHostArtifacts(adapter, ctx) {
324
324
  hooks: [
325
325
  {
326
326
  type: "command",
327
- command: `"${ctx.root}/.noir/hooks/noir-session-start.mjs"`
327
+ command: `"${ctx.command}" hook`
328
328
  }
329
329
  ]
330
330
  }
@@ -334,7 +334,7 @@ function buildHostArtifacts(adapter, ctx) {
334
334
  hooks: [
335
335
  {
336
336
  type: "command",
337
- command: `"${ctx.root}/.noir/hooks/noir-session-start.mjs"`
337
+ command: `"${ctx.command}" hook`
338
338
  }
339
339
  ]
340
340
  }
@@ -349,14 +349,6 @@ function buildHostArtifacts(adapter, ctx) {
349
349
  dedupSubstring: HOOK_DEDUP,
350
350
  description: "SessionStart + SubagentStart hook entry (user-owned, written once)"
351
351
  });
352
- entries.push({
353
- path: ".noir/hooks/noir-session-start.mjs",
354
- mode: "regenerate",
355
- fileMode: 493,
356
- host,
357
- content: SESSION_START_HOOK_SCRIPT,
358
- description: "SessionStart hook runner (Noir-owned, re-emitted)"
359
- });
360
352
  entries.push({
361
353
  path: ".noir/router.md",
362
354
  mode: "managedBlock",
@@ -377,40 +369,6 @@ function buildHostArtifacts(adapter, ctx) {
377
369
  }
378
370
  return entries;
379
371
  }
380
- var SESSION_START_HOOK_SCRIPT = `#!/usr/bin/env node
381
- // Noir SessionStart hook \u2014 injects the skill router contract + anti-slop ruleset
382
- // at session start. The mutable contract lives in .noir/router.md (a Noir
383
- // managed block, so user edits outside the markers survive noir sync) and the
384
- // ruleset in .noir/rules/anti-slop.md (regenerated). This script is a pure runner.
385
- import { readFileSync, existsSync } from 'node:fs';
386
- import { join, dirname } from 'node:path';
387
- import { fileURLToPath } from 'node:url';
388
-
389
- const HERE = dirname(fileURLToPath(import.meta.url));
390
- const ROOT = join(HERE, '..', '..');
391
- const ROUTER = join(ROOT, '.noir', 'router.md');
392
- const RULES = join(ROOT, '.noir', 'rules', 'anti-slop.md');
393
-
394
- function readIfPresent(path) {
395
- if (!existsSync(path)) return '';
396
- return readFileSync(path, 'utf8').trim();
397
- }
398
-
399
- function main() {
400
- const parts = [readIfPresent(ROUTER), readIfPresent(RULES)].filter((part) => part !== '');
401
- if (parts.length === 0) {
402
- // Neither contract present (project not initialized / both removed) \u2014 silent.
403
- process.stdout.write(JSON.stringify({ hookSpecificOutput: {} }));
404
- process.exit(0);
405
- }
406
- const context = parts.join('\\n\\n');
407
- process.stdout.write(
408
- JSON.stringify({ hookSpecificOutput: { additionalContext: context } }),
409
- );
410
- }
411
-
412
- main();
413
- `;
414
372
  function hostRel(abs, root) {
415
373
  const rel = relative(root, abs);
416
374
  if (rel.length === 0 || rel.startsWith("..") || rel.startsWith("/")) {
@@ -567,7 +525,7 @@ function mergeThreeWay(base, ours, theirs, style = "minimal") {
567
525
  }
568
526
 
569
527
  // src/migrations/index.ts
570
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
528
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4, rmdirSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
571
529
  import { join as join5, relative as relative2, sep } from "path";
572
530
  import { mcpConfigPathFor, noirStdioArgs, TRANSPORT_KEYS } from "@noir-ai/adapters";
573
531
  import {
@@ -829,7 +787,7 @@ import {
829
787
  import { basename, dirname as dirname3, join as join4 } from "path";
830
788
  import { atomicWriteFile, stripManagedBlock, writeManagedRegion } from "@noir-ai/core";
831
789
  import { ensureOwnerOnly } from "@noir-ai/core";
832
- function regenerate(absPath, content, mode) {
790
+ function regenerate(absPath, content) {
833
791
  const dir = dirname3(absPath);
834
792
  const tmp = join4(
835
793
  dir,
@@ -837,7 +795,7 @@ function regenerate(absPath, content, mode) {
837
795
  );
838
796
  let fd;
839
797
  try {
840
- fd = openSync(tmp, "w", mode);
798
+ fd = openSync(tmp, "w");
841
799
  writeSync(fd, content, 0, "utf8");
842
800
  closeSync(fd);
843
801
  fd = void 0;
@@ -949,6 +907,17 @@ function mergeJson(absPath, patch, dedupSubstring) {
949
907
  regenerate(absPath, content);
950
908
  return { path: absPath, mode: "mergeJson", written: true };
951
909
  }
910
+ function entryHasCommand(entry, marker) {
911
+ if (typeof entry !== "object" || entry === null) return false;
912
+ const obj = entry;
913
+ if (typeof obj.command === "string" && obj.command.includes(marker)) return true;
914
+ if (!Array.isArray(obj.hooks)) return false;
915
+ return obj.hooks.some((h) => {
916
+ if (typeof h !== "object" || h === null) return false;
917
+ const command = h.command;
918
+ return typeof command === "string" && command.includes(marker);
919
+ });
920
+ }
952
921
  function deepMergePreservingHooks(existing, patch, dedupSubstring) {
953
922
  const out = { ...existing };
954
923
  for (const [key, value] of Object.entries(patch)) {
@@ -958,9 +927,7 @@ function deepMergePreservingHooks(existing, patch, dedupSubstring) {
958
927
  for (const [event, entries] of Object.entries(value)) {
959
928
  const existingEntries = Array.isArray(mergedHooks[event]) ? mergedHooks[event] : [];
960
929
  const patchEntries = Array.isArray(entries) ? entries : [];
961
- const alreadyRegistered = dedupSubstring !== void 0 && existingEntries.some(
962
- (x) => x?.command?.includes(dedupSubstring) ?? false
963
- );
930
+ const alreadyRegistered = dedupSubstring !== void 0 && existingEntries.some((x) => entryHasCommand(x, dedupSubstring));
964
931
  const toAdd = alreadyRegistered ? [] : patchEntries;
965
932
  mergedHooks[event] = [...existingEntries, ...toAdd];
966
933
  }
@@ -1234,11 +1201,98 @@ var workspaceBridge = {
1234
1201
  return result;
1235
1202
  }
1236
1203
  };
1204
+ var OLD_HOOK_MARKER = "noir-session-start";
1205
+ var hookCommand = {
1206
+ from: "1.3.0",
1207
+ to: "1.4.0",
1208
+ description: "route the SessionStart hook through `noir hook` (drop the .mjs runner)",
1209
+ run: (ctx) => {
1210
+ const result = { changed: [], conflicts: [], notes: [] };
1211
+ const settingsRel = ".claude/settings.local.json";
1212
+ const settingsPath = join5(ctx.root, ".claude", "settings.local.json");
1213
+ if (existsSync4(settingsPath)) {
1214
+ let settings;
1215
+ try {
1216
+ settings = JSON.parse(readFileSync4(settingsPath, "utf8"));
1217
+ } catch (err) {
1218
+ result.conflicts.push(settingsRel);
1219
+ result.notes.push(
1220
+ `${settingsRel}: unreadable (${errorMessage(err)}) \u2014 command left untouched`
1221
+ );
1222
+ settings = {};
1223
+ }
1224
+ let rewritten = false;
1225
+ const hooks = settings.hooks;
1226
+ if (typeof hooks === "object" && hooks !== null && !Array.isArray(hooks)) {
1227
+ for (const eventEntries of Object.values(hooks)) {
1228
+ if (!Array.isArray(eventEntries)) continue;
1229
+ for (const group of eventEntries) {
1230
+ if (typeof group !== "object" || group === null) continue;
1231
+ const inner = group.hooks;
1232
+ if (!Array.isArray(inner)) continue;
1233
+ for (const entry of inner) {
1234
+ if (typeof entry !== "object" || entry === null) continue;
1235
+ const hookObj = entry;
1236
+ if (typeof hookObj.command === "string" && hookObj.command.includes(OLD_HOOK_MARKER)) {
1237
+ hookObj.command = `"${resolveNoirCommand()}" hook`;
1238
+ rewritten = true;
1239
+ }
1240
+ }
1241
+ }
1242
+ }
1243
+ }
1244
+ if (!rewritten) {
1245
+ result.notes.push(`${settingsRel}: no .mjs hook command to migrate`);
1246
+ } else if (ctx.dryRun) {
1247
+ result.changed.push(settingsRel);
1248
+ result.notes.push(`${settingsRel}: would route the hook through \`noir hook\``);
1249
+ } else {
1250
+ try {
1251
+ atomicWriteFile2(settingsPath, `${JSON.stringify(settings, null, 2)}
1252
+ `);
1253
+ result.changed.push(settingsRel);
1254
+ result.notes.push(`${settingsRel}: hook now routes through \`noir hook\``);
1255
+ } catch (err) {
1256
+ result.conflicts.push(settingsRel);
1257
+ result.notes.push(`${settingsRel}: write failed (${errorMessage(err)}) \u2014 left untouched`);
1258
+ }
1259
+ }
1260
+ } else {
1261
+ result.notes.push(`${settingsRel}: absent \u2014 nothing to migrate`);
1262
+ }
1263
+ const runnerRel = ".noir/hooks/noir-session-start.mjs";
1264
+ const runnerPath = join5(ctx.root, ".noir", "hooks", "noir-session-start.mjs");
1265
+ if (!existsSync4(runnerPath)) {
1266
+ return result;
1267
+ }
1268
+ if (ctx.dryRun) {
1269
+ result.changed.push(runnerRel);
1270
+ result.notes.push(`${runnerRel}: would remove the orphaned runner`);
1271
+ return result;
1272
+ }
1273
+ try {
1274
+ rmSync2(runnerPath, { force: true });
1275
+ } catch (err) {
1276
+ result.conflicts.push(runnerRel);
1277
+ result.notes.push(`${runnerRel}: remove failed (${errorMessage(err)})`);
1278
+ return result;
1279
+ }
1280
+ result.changed.push(runnerRel);
1281
+ result.notes.push(`${runnerRel}: removed (orphaned runner)`);
1282
+ const hooksDir = join5(ctx.root, ".noir", "hooks");
1283
+ try {
1284
+ if (readdirSync(hooksDir).length === 0) rmdirSync(hooksDir);
1285
+ } catch {
1286
+ }
1287
+ return result;
1288
+ }
1289
+ };
1237
1290
  var MIGRATIONS = [
1238
1291
  synthetic,
1239
1292
  envPointer,
1240
1293
  envTemplates,
1241
- workspaceBridge
1294
+ workspaceBridge,
1295
+ hookCommand
1242
1296
  ];
1243
1297
  function errorMessage(err) {
1244
1298
  return err instanceof Error ? err.message : String(err);
@@ -1258,15 +1312,7 @@ function applyWithConflict(ours, theirs, path) {
1258
1312
  }
1259
1313
 
1260
1314
  // src/scaffold.ts
1261
- import {
1262
- chmodSync,
1263
- existsSync as existsSync6,
1264
- mkdirSync as mkdirSync3,
1265
- readFileSync as readFileSync7,
1266
- renameSync as renameSync2,
1267
- rmSync as rmSync2,
1268
- statSync
1269
- } from "fs";
1315
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, renameSync as renameSync2, rmSync as rmSync3 } from "fs";
1270
1316
  import { basename as basename2, dirname as dirname5, join as join8 } from "path";
1271
1317
  import {
1272
1318
  createProjectId,
@@ -1282,7 +1328,7 @@ import {
1282
1328
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync5 } from "fs";
1283
1329
  import { dirname as dirname4, join as join6 } from "path";
1284
1330
  import { NOIR_DIR as NOIR_DIR2 } from "@noir-ai/core";
1285
- var CURRENT_SCAFFOLD_VERSION = "1.3.0";
1331
+ var CURRENT_SCAFFOLD_VERSION = "1.4.0";
1286
1332
  var PREFIX = "noir-scaffold=";
1287
1333
  function scaffoldVersionPath(root) {
1288
1334
  return join6(root, NOIR_DIR2, "scaffold-version");
@@ -1311,7 +1357,7 @@ function writeScaffoldVersion(root, version) {
1311
1357
  }
1312
1358
 
1313
1359
  // src/stack-detect.ts
1314
- import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync6 } from "fs";
1360
+ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
1315
1361
  import { join as join7 } from "path";
1316
1362
  var NODE_FRAMEWORKS = [
1317
1363
  ["next", "next"],
@@ -1461,7 +1507,7 @@ function detectStack(root) {
1461
1507
  const cursorRulesDir = join7(root, ".cursor", "rules");
1462
1508
  try {
1463
1509
  if (existsSync5(cursorRulesDir)) {
1464
- const entries = readdirSync(cursorRulesDir);
1510
+ const entries = readdirSync2(cursorRulesDir);
1465
1511
  for (const e of entries) {
1466
1512
  if (e.endsWith(".md"))
1467
1513
  existingAiFiles.push({ path: `.cursor/rules/${e}`, kind: "cursor-rules" });
@@ -1542,7 +1588,7 @@ async function scaffold(opts) {
1542
1588
  const idFile = readProjectIdFile(opts.root);
1543
1589
  const projectId = resolveProjectId(opts, idFile);
1544
1590
  if (!opts.dryRun && idFile.state === "corrupt") {
1545
- rmSync2(paths2.projectId(opts.root), { force: true });
1591
+ rmSync3(paths2.projectId(opts.root), { force: true });
1546
1592
  }
1547
1593
  const fromVersion = readScaffoldVersion(opts.root);
1548
1594
  const stack = detectStack(opts.root);
@@ -1715,8 +1761,7 @@ async function scaffold(opts) {
1715
1761
  if (entry.mode === "regenerate") {
1716
1762
  const out = await writeWithConflict(abs, entry.path, body, opts, {
1717
1763
  memory: conflictMemory,
1718
- record: recordConflict,
1719
- mode: entry.fileMode
1764
+ record: recordConflict
1720
1765
  });
1721
1766
  written.push(...out.written);
1722
1767
  skipped.push(...out.skipped);
@@ -1931,14 +1976,10 @@ async function writeWithConflict(abs, relPath, proposed, opts, internals) {
1931
1976
  existing = void 0;
1932
1977
  }
1933
1978
  if (existing === void 0) {
1934
- regenerate(abs, proposed, internals.mode);
1979
+ regenerate(abs, proposed);
1935
1980
  return { written: [relPath], skipped: [], identical: [] };
1936
1981
  }
1937
1982
  if (existing === proposed) {
1938
- if (internals.mode !== void 0 && (statSync(abs).mode & 511) !== internals.mode) {
1939
- chmodSync(abs, internals.mode);
1940
- return { written: [relPath], skipped: [], identical: [] };
1941
- }
1942
1983
  return { written: [], skipped: [], identical: [relPath] };
1943
1984
  }
1944
1985
  const MODE = internals.artifactClass ?? "regenerate";
@@ -1960,21 +2001,21 @@ async function writeWithConflict(abs, relPath, proposed, opts, internals) {
1960
2001
  internals.record(relPath, MODE, existing, proposed, resolution, sim);
1961
2002
  switch (resolution) {
1962
2003
  case "replace":
1963
- regenerate(abs, proposed, internals.mode);
2004
+ regenerate(abs, proposed);
1964
2005
  return { written: [relPath], skipped: [], identical: [] };
1965
2006
  case "merge": {
1966
- regenerate(abs, proposed, internals.mode);
2007
+ regenerate(abs, proposed);
1967
2008
  return { written: [relPath], skipped: [], identical: [] };
1968
2009
  }
1969
2010
  case "rename": {
1970
2011
  const aside = uniqueAside(abs, relPath, ".local");
1971
2012
  renameSync2(abs, aside.abs);
1972
- regenerate(abs, proposed, internals.mode);
2013
+ regenerate(abs, proposed);
1973
2014
  return { written: [relPath], skipped: [aside.rel], identical: [] };
1974
2015
  }
1975
2016
  case "duplicate": {
1976
2017
  const aside = uniqueAside(abs, relPath, ".noir");
1977
- regenerate(aside.abs, proposed, internals.mode);
2018
+ regenerate(aside.abs, proposed);
1978
2019
  return { written: [aside.rel], skipped: [relPath], identical: [] };
1979
2020
  }
1980
2021
  case "preserve":
@@ -2016,7 +2057,7 @@ function healLegacyNoirMd(absPath) {
2016
2057
  return;
2017
2058
  }
2018
2059
  if (/<!-- noir:[a-z]+ begin -->/.test(content)) return;
2019
- rmSync2(absPath, { force: true });
2060
+ rmSync3(absPath, { force: true });
2020
2061
  }
2021
2062
  function renderEntry(entry, vars) {
2022
2063
  if (entry.template !== void 0) {