safeword 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -40,7 +40,7 @@ program.command("sync-config").description("Regenerate depcruise config from cur
40
40
  });
41
41
  var ticket = program.command("ticket").description("Ticket management");
42
42
  ticket.command("new <slug>").description("Create a new ticket with a Crockford Base32 ID").option("--type <type>", "Ticket type: patch, task, or feature", "task").option("--title <title>", "Ticket title (defaults to slug)").action(async (slug, options) => {
43
- const { ticketNew } = await import("./ticket-new-FMXU2P4A.js");
43
+ const { ticketNew } = await import("./ticket-new-X7N6UD45.js");
44
44
  await ticketNew(slug, options);
45
45
  });
46
46
  program.command("sync-learnings").description("Regenerate .safeword-project/learnings/INDEX.md").option("-q, --quiet", "Suppress success output (still prints skipped-file warnings to stderr)").action(async (options) => {
@@ -50,10 +50,11 @@ function stripDashEdges(value) {
50
50
  }
51
51
 
52
52
  // src/utils/ticket-writer.ts
53
- import { existsSync, mkdirSync, writeFileSync } from "fs";
53
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs";
54
54
  import nodePath from "path";
55
55
  var TICKETS_SUBPATH = [".safeword-project", "tickets"];
56
56
  var RETRY_BUDGET = 5;
57
+ var NON_TICKET_ENTRIES = /* @__PURE__ */ new Set(["completed", "tmp"]);
57
58
  var TicketIdCollisionError = class extends Error {
58
59
  constructor(attemptedIds, retryBudget) {
59
60
  super(
@@ -71,16 +72,21 @@ function createTicket(cwd, minter, options) {
71
72
  if (!existsSync(ticketsDirectory)) {
72
73
  mkdirSync(ticketsDirectory, { recursive: true });
73
74
  }
74
- const { id, folderPath } = mintAndClaim(ticketsDirectory, minter);
75
+ const { id, folderPath } = mintAndClaim(ticketsDirectory, minter, options.slug);
75
76
  const ticketPath = nodePath.join(folderPath, "ticket.md");
76
77
  writeFileSync(ticketPath, renderTicketMarkdown(id, options));
77
78
  return { id, folderPath, ticketPath };
78
79
  }
79
- function mintAndClaim(ticketsDirectory, minter) {
80
+ function mintAndClaim(ticketsDirectory, minter, slug) {
81
+ const takenIds = idsAlreadyTaken(ticketsDirectory);
80
82
  const attempted = [];
81
83
  for (let attempt = 0; attempt < RETRY_BUDGET; attempt++) {
82
84
  const id = minter.mint();
83
- const folderPath = nodePath.join(ticketsDirectory, id);
85
+ if (takenIds.has(id)) {
86
+ attempted.push(id);
87
+ continue;
88
+ }
89
+ const folderPath = nodePath.join(ticketsDirectory, `${id}-${slug}`);
84
90
  try {
85
91
  mkdirSync(folderPath);
86
92
  return { id, folderPath };
@@ -92,6 +98,18 @@ function mintAndClaim(ticketsDirectory, minter) {
92
98
  }
93
99
  throw new TicketIdCollisionError(attempted, RETRY_BUDGET);
94
100
  }
101
+ function idsAlreadyTaken(ticketsDirectory) {
102
+ const ids = /* @__PURE__ */ new Set();
103
+ try {
104
+ for (const entry of readdirSync(ticketsDirectory)) {
105
+ if (NON_TICKET_ENTRIES.has(entry)) continue;
106
+ const dashIndex = entry.indexOf("-");
107
+ ids.add(dashIndex === -1 ? entry : entry.slice(0, dashIndex));
108
+ }
109
+ } catch {
110
+ }
111
+ return ids;
112
+ }
95
113
  function renderTicketMarkdown(id, options) {
96
114
  const type = options.type ?? "task";
97
115
  const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
@@ -177,4 +195,4 @@ function resolveMinter() {
177
195
  export {
178
196
  ticketNew
179
197
  };
180
- //# sourceMappingURL=ticket-new-FMXU2P4A.js.map
198
+ //# sourceMappingURL=ticket-new-X7N6UD45.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands/ticket-new.ts","../src/utils/id-minter.ts","../src/utils/slug.ts","../src/utils/ticket-writer.ts"],"sourcesContent":["/**\n * `safeword ticket new <slug>` — mint a Crockford Base32 ticket ID and create\n * the ticket folder at `.safeword-project/tickets/{ID}/ticket.md` (ticket 158).\n *\n * Replaces the prompt-driven \"find highest folder + 1\" instruction in the\n * ticket-system skill, which was a race condition across parallel sessions\n * and silently colliding across git branches.\n */\n\nimport process from 'node:process';\n\nimport { cryptoIdMinter, type IdMinter } from '../utils/id-minter.js';\nimport { header, info, success } from '../utils/output.js';\nimport { normalizeSlug, SlugError } from '../utils/slug.js';\nimport { createTicket, TicketIdCollisionError, type TicketType } from '../utils/ticket-writer.js';\n\nconst VALID_TYPES: ReadonlySet<TicketType> = new Set(['patch', 'task', 'feature']);\n\nexport interface TicketNewOptions {\n type?: string;\n title?: string;\n}\n\nexport function ticketNew(slug: string, options: TicketNewOptions): Promise<void> {\n ticketNewSync(slug, options);\n return Promise.resolve();\n}\n\nfunction ticketNewSync(slug: string, options: TicketNewOptions): void {\n const type = resolveType(options.type);\n if (type === 'invalid') {\n process.stderr.write(\n `Invalid --type=${String(options.type)}. Must be one of: patch, task, feature.\\n`,\n );\n process.exit(1);\n }\n\n let normalizedSlug: string;\n try {\n normalizedSlug = normalizeSlug(slug);\n } catch (error: unknown) {\n if (error instanceof SlugError) {\n process.stderr.write(`${error.message}\\n`);\n process.exit(1);\n }\n throw error;\n }\n\n header('Create ticket');\n\n try {\n const result = createTicket(process.cwd(), resolveMinter(), {\n slug: normalizedSlug,\n type,\n title: options.title,\n });\n success(`Created ticket ${result.id}`);\n info(`Folder: ${result.folderPath}`);\n info(`File: ${result.ticketPath}`);\n } catch (error: unknown) {\n if (error instanceof TicketIdCollisionError) {\n process.stderr.write(`${error.message}\\n`);\n process.exit(1);\n }\n throw error;\n }\n}\n\nfunction resolveType(value: string | undefined): TicketType | undefined | 'invalid' {\n if (value === undefined) return undefined;\n return VALID_TYPES.has(value as TicketType) ? (value as TicketType) : 'invalid';\n}\n\n// Test-only injection point: SAFEWORD_TICKET_ID_OVERRIDE forces a specific\n// minted ID so cross-branch collision scenarios can be exercised deterministically.\n// The override is never set in production — the env var is intentionally\n// undocumented to discourage real-world use.\nfunction resolveMinter(): IdMinter {\n const override = process.env.SAFEWORD_TICKET_ID_OVERRIDE;\n if (override !== undefined && override !== '') {\n return { mint: () => override };\n }\n return cryptoIdMinter();\n}\n","/**\n * Crockford Base32 ticket ID minter (ticket 158).\n *\n * Mints uppercase 6-char IDs from the Crockford alphabet\n * `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (no I/L/O/U). 32^6 ≈ 10⁹ ID space.\n *\n * Two implementations:\n * - cryptoIdMinter() — production default, uses crypto.randomInt\n * - seededIdMinter(seed) — deterministic, for tests that need reproducibility\n */\n\nimport { randomInt } from 'node:crypto';\n\nexport const CROCKFORD_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';\nconst ID_LENGTH = 6;\n\nexport interface IdMinter {\n mint(): string;\n}\n\n/**\n * Build an ID from a constrained-range RNG that returns indices in\n * `[0, CROCKFORD_ALPHABET.length)`. The two exported minters differ only in\n * which RNG they pass in.\n */\nfunction buildId(nextIndex: () => number): string {\n const chars: string[] = [];\n for (let index = 0; index < ID_LENGTH; index++) {\n chars.push(CROCKFORD_ALPHABET.charAt(nextIndex()));\n }\n return chars.join('');\n}\n\nexport function cryptoIdMinter(): IdMinter {\n return { mint: () => buildId(() => randomInt(CROCKFORD_ALPHABET.length)) };\n}\n\n/**\n * Seeded PRNG (mulberry32) for deterministic test sequences.\n * Not cryptographically secure — production must use cryptoIdMinter.\n */\nexport function seededIdMinter(seed: number): IdMinter {\n let state = seed >>> 0;\n function nextUint32(): number {\n state = (state + 0x6d_2b_79_f5) >>> 0;\n let t = state;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return (t ^ (t >>> 14)) >>> 0;\n }\n return { mint: () => buildId(() => nextUint32() % CROCKFORD_ALPHABET.length) };\n}\n","/**\n * Slug normalization for `safeword ticket new <slug>` (ticket 158, slice 3).\n *\n * Slugs are stored in frontmatter and feed work-log filenames. Normalize at the\n * CLI boundary so the canonical form is always lowercase kebab-case:\n * - NFKD-fold (decomposes accents)\n * - drop combining marks (Unicode block U+0300–U+036F)\n * - lowercase\n * - replace non-alphanumeric runs with a single `-`\n * - strip leading/trailing `-`\n * Empty result throws SlugError so the CLI can exit with a clear message.\n */\n\nexport class SlugError extends Error {\n constructor(public readonly input: string) {\n super(\n input === ''\n ? 'Slug cannot be empty.'\n : `Slug \"${input}\" normalizes to empty (no alphanumeric content).`,\n );\n this.name = 'SlugError';\n }\n}\n\n// `\\p{Mn}` matches Nonspacing-Mark characters — the combining marks NFKD\n// decomposition exposes when it pulls accents off their base letter.\nconst COMBINING_MARKS = /\\p{Mn}/gu;\nconst NON_ALNUM = /[^a-z\\d]+/g;\n\nexport function normalizeSlug(input: string): string {\n const folded = input.normalize('NFKD').replaceAll(COMBINING_MARKS, '');\n const collapsed = stripDashEdges(folded.toLowerCase().replaceAll(NON_ALNUM, '-'));\n if (collapsed === '') throw new SlugError(input);\n return collapsed;\n}\n\nfunction stripDashEdges(value: string): string {\n let start = 0;\n let end = value.length;\n while (start < end && value.charAt(start) === '-') start++;\n while (end > start && value.charAt(end - 1) === '-') end--;\n return value.slice(start, end);\n}\n","/**\n * Creates a new ticket folder + ticket.md (ticket 158).\n *\n * Folder layout: `.safeword-project/tickets/{ID}-{slug}/ticket.md`. The ID\n * stays the unique key (stored in frontmatter `id:` and used by the duplicate\n * detector); the slug suffix is for human/agent legibility when scanning\n * `ls` output. Mint-time collision check rejects any minted ID already in\n * use by an existing folder, regardless of that folder's slug suffix.\n *\n * Safety layers against duplicate IDs (PR #160 trade-off):\n * 1. Mint-time: idsAlreadyTaken() — within one working copy, blocks re-mint.\n * 2. Post-merge: check-ticket-ids.ts (pre-commit + CI) — across branches,\n * duplicate `id:` in frontmatter is the loud failure. The previous\n * layout (`{ID}/` alone) used identical filesystem paths as an extra\n * merge-time conflict layer; the slug suffix breaks that, so detection\n * shifts entirely to the post-merge detector.\n *\n * Mint-collision retry + fresh-install (no tickets dir yet) handled here.\n */\n\nimport { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';\nimport nodePath from 'node:path';\n\nimport type { IdMinter } from './id-minter.js';\n\nconst TICKETS_SUBPATH = ['.safeword-project', 'tickets'];\nconst RETRY_BUDGET = 5;\nconst NON_TICKET_ENTRIES = new Set(['completed', 'tmp']);\n\nexport type TicketType = 'patch' | 'task' | 'feature';\n\nexport interface NewTicketOptions {\n slug: string;\n type?: TicketType;\n title?: string;\n /** Override `new Date()` for tests. */\n now?: () => Date;\n}\n\nexport interface NewTicketResult {\n id: string;\n folderPath: string;\n ticketPath: string;\n}\n\nexport class TicketIdCollisionError extends Error {\n constructor(\n public readonly attemptedIds: string[],\n public readonly retryBudget: number,\n ) {\n super(\n `Failed to mint a unique ticket ID after ${retryBudget} attempts. Tried: ${attemptedIds.join(', ')}.`,\n );\n this.name = 'TicketIdCollisionError';\n }\n}\n\nexport function createTicket(\n cwd: string,\n minter: IdMinter,\n options: NewTicketOptions,\n): NewTicketResult {\n const ticketsDirectory = nodePath.join(cwd, ...TICKETS_SUBPATH);\n if (!existsSync(ticketsDirectory)) {\n mkdirSync(ticketsDirectory, { recursive: true });\n }\n\n const { id, folderPath } = mintAndClaim(ticketsDirectory, minter, options.slug);\n const ticketPath = nodePath.join(folderPath, 'ticket.md');\n writeFileSync(ticketPath, renderTicketMarkdown(id, options));\n\n return { id, folderPath, ticketPath };\n}\n\nfunction mintAndClaim(\n ticketsDirectory: string,\n minter: IdMinter,\n slug: string,\n): { id: string; folderPath: string } {\n const takenIds = idsAlreadyTaken(ticketsDirectory);\n const attempted: string[] = [];\n for (let attempt = 0; attempt < RETRY_BUDGET; attempt++) {\n const id = minter.mint();\n if (takenIds.has(id)) {\n attempted.push(id);\n continue;\n }\n const folderPath = nodePath.join(ticketsDirectory, `${id}-${slug}`);\n try {\n mkdirSync(folderPath);\n return { id, folderPath };\n } catch (error: unknown) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code !== 'EEXIST') throw error;\n attempted.push(id);\n }\n }\n throw new TicketIdCollisionError(attempted, RETRY_BUDGET);\n}\n\n// Extract the ID portion of every existing ticket folder. Folders use either\n// `{id}` (legacy opaque) or `{id}-{slug}` — split on the first `-`. This is the\n// loud-failure mechanism that keeps mint-time ID collisions from coexisting on\n// disk regardless of slug suffix.\nfunction idsAlreadyTaken(ticketsDirectory: string): Set<string> {\n const ids = new Set<string>();\n try {\n for (const entry of readdirSync(ticketsDirectory)) {\n if (NON_TICKET_ENTRIES.has(entry)) continue;\n const dashIndex = entry.indexOf('-');\n ids.add(dashIndex === -1 ? entry : entry.slice(0, dashIndex));\n }\n } catch {\n // tickets dir may not exist yet on fresh installs — caller creates it.\n }\n return ids;\n}\n\nfunction renderTicketMarkdown(id: string, options: NewTicketOptions): string {\n const type = options.type ?? 'task';\n const now = (options.now ?? (() => new Date()))().toISOString();\n const title = options.title ?? options.slug;\n\n return `---\nid: ${id}\nslug: ${options.slug}\ntype: ${type}\nphase: intake\nstatus: in_progress\ncreated: ${now}\nlast_modified: ${now}\n---\n\n# ${title}\n\n**Goal:** {One sentence: what are we trying to achieve?}\n\n**Why:** {One sentence: why does this matter?}\n\n## Work Log\n\n- ${now} Started: Created ticket ${id}\n`;\n}\n"],"mappings":";;;;;;;AASA,OAAO,aAAa;;;ACEpB,SAAS,iBAAiB;AAEnB,IAAM,qBAAqB;AAClC,IAAM,YAAY;AAWlB,SAAS,QAAQ,WAAiC;AAChD,QAAM,QAAkB,CAAC;AACzB,WAAS,QAAQ,GAAG,QAAQ,WAAW,SAAS;AAC9C,UAAM,KAAK,mBAAmB,OAAO,UAAU,CAAC,CAAC;AAAA,EACnD;AACA,SAAO,MAAM,KAAK,EAAE;AACtB;AAEO,SAAS,iBAA2B;AACzC,SAAO,EAAE,MAAM,MAAM,QAAQ,MAAM,UAAU,mBAAmB,MAAM,CAAC,EAAE;AAC3E;;;ACtBO,IAAM,YAAN,cAAwB,MAAM;AAAA,EACnC,YAA4B,OAAe;AACzC;AAAA,MACE,UAAU,KACN,0BACA,SAAS,KAAK;AAAA,IACpB;AAL0B;AAM1B,SAAK,OAAO;AAAA,EACd;AAAA,EAP4B;AAQ9B;AAIA,IAAM,kBAAkB,WAAC,WAAO,IAAE;AAClC,IAAM,YAAY;AAEX,SAAS,cAAc,OAAuB;AACnD,QAAM,SAAS,MAAM,UAAU,MAAM,EAAE,WAAW,iBAAiB,EAAE;AACrE,QAAM,YAAY,eAAe,OAAO,YAAY,EAAE,WAAW,WAAW,GAAG,CAAC;AAChF,MAAI,cAAc,GAAI,OAAM,IAAI,UAAU,KAAK;AAC/C,SAAO;AACT;AAEA,SAAS,eAAe,OAAuB;AAC7C,MAAI,QAAQ;AACZ,MAAI,MAAM,MAAM;AAChB,SAAO,QAAQ,OAAO,MAAM,OAAO,KAAK,MAAM,IAAK;AACnD,SAAO,MAAM,SAAS,MAAM,OAAO,MAAM,CAAC,MAAM,IAAK;AACrD,SAAO,MAAM,MAAM,OAAO,GAAG;AAC/B;;;ACtBA,SAAS,YAAY,WAAW,aAAa,qBAAqB;AAClE,OAAO,cAAc;AAIrB,IAAM,kBAAkB,CAAC,qBAAqB,SAAS;AACvD,IAAM,eAAe;AACrB,IAAM,qBAAqB,oBAAI,IAAI,CAAC,aAAa,KAAK,CAAC;AAkBhD,IAAM,yBAAN,cAAqC,MAAM;AAAA,EAChD,YACkB,cACA,aAChB;AACA;AAAA,MACE,2CAA2C,WAAW,qBAAqB,aAAa,KAAK,IAAI,CAAC;AAAA,IACpG;AALgB;AACA;AAKhB,SAAK,OAAO;AAAA,EACd;AAAA,EAPkB;AAAA,EACA;AAOpB;AAEO,SAAS,aACd,KACA,QACA,SACiB;AACjB,QAAM,mBAAmB,SAAS,KAAK,KAAK,GAAG,eAAe;AAC9D,MAAI,CAAC,WAAW,gBAAgB,GAAG;AACjC,cAAU,kBAAkB,EAAE,WAAW,KAAK,CAAC;AAAA,EACjD;AAEA,QAAM,EAAE,IAAI,WAAW,IAAI,aAAa,kBAAkB,QAAQ,QAAQ,IAAI;AAC9E,QAAM,aAAa,SAAS,KAAK,YAAY,WAAW;AACxD,gBAAc,YAAY,qBAAqB,IAAI,OAAO,CAAC;AAE3D,SAAO,EAAE,IAAI,YAAY,WAAW;AACtC;AAEA,SAAS,aACP,kBACA,QACA,MACoC;AACpC,QAAM,WAAW,gBAAgB,gBAAgB;AACjD,QAAM,YAAsB,CAAC;AAC7B,WAAS,UAAU,GAAG,UAAU,cAAc,WAAW;AACvD,UAAM,KAAK,OAAO,KAAK;AACvB,QAAI,SAAS,IAAI,EAAE,GAAG;AACpB,gBAAU,KAAK,EAAE;AACjB;AAAA,IACF;AACA,UAAM,aAAa,SAAS,KAAK,kBAAkB,GAAG,EAAE,IAAI,IAAI,EAAE;AAClE,QAAI;AACF,gBAAU,UAAU;AACpB,aAAO,EAAE,IAAI,WAAW;AAAA,IAC1B,SAAS,OAAgB;AACvB,YAAM,OAAQ,MAAgC;AAC9C,UAAI,SAAS,SAAU,OAAM;AAC7B,gBAAU,KAAK,EAAE;AAAA,IACnB;AAAA,EACF;AACA,QAAM,IAAI,uBAAuB,WAAW,YAAY;AAC1D;AAMA,SAAS,gBAAgB,kBAAuC;AAC9D,QAAM,MAAM,oBAAI,IAAY;AAC5B,MAAI;AACF,eAAW,SAAS,YAAY,gBAAgB,GAAG;AACjD,UAAI,mBAAmB,IAAI,KAAK,EAAG;AACnC,YAAM,YAAY,MAAM,QAAQ,GAAG;AACnC,UAAI,IAAI,cAAc,KAAK,QAAQ,MAAM,MAAM,GAAG,SAAS,CAAC;AAAA,IAC9D;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAEA,SAAS,qBAAqB,IAAY,SAAmC;AAC3E,QAAM,OAAO,QAAQ,QAAQ;AAC7B,QAAM,OAAO,QAAQ,QAAQ,MAAM,oBAAI,KAAK,IAAI,EAAE,YAAY;AAC9D,QAAM,QAAQ,QAAQ,SAAS,QAAQ;AAEvC,SAAO;AAAA,MACH,EAAE;AAAA,QACA,QAAQ,IAAI;AAAA,QACZ,IAAI;AAAA;AAAA;AAAA,WAGD,GAAG;AAAA,iBACG,GAAG;AAAA;AAAA;AAAA,IAGhB,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAQL,GAAG,4BAA4B,EAAE;AAAA;AAErC;;;AH/HA,IAAM,cAAuC,oBAAI,IAAI,CAAC,SAAS,QAAQ,SAAS,CAAC;AAO1E,SAAS,UAAU,MAAc,SAA0C;AAChF,gBAAc,MAAM,OAAO;AAC3B,SAAO,QAAQ,QAAQ;AACzB;AAEA,SAAS,cAAc,MAAc,SAAiC;AACpE,QAAM,OAAO,YAAY,QAAQ,IAAI;AACrC,MAAI,SAAS,WAAW;AACtB,YAAQ,OAAO;AAAA,MACb,kBAAkB,OAAO,QAAQ,IAAI,CAAC;AAAA;AAAA,IACxC;AACA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,MAAI;AACJ,MAAI;AACF,qBAAiB,cAAc,IAAI;AAAA,EACrC,SAAS,OAAgB;AACvB,QAAI,iBAAiB,WAAW;AAC9B,cAAQ,OAAO,MAAM,GAAG,MAAM,OAAO;AAAA,CAAI;AACzC,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,UAAM;AAAA,EACR;AAEA,SAAO,eAAe;AAEtB,MAAI;AACF,UAAM,SAAS,aAAa,QAAQ,IAAI,GAAG,cAAc,GAAG;AAAA,MAC1D,MAAM;AAAA,MACN;AAAA,MACA,OAAO,QAAQ;AAAA,IACjB,CAAC;AACD,YAAQ,kBAAkB,OAAO,EAAE,EAAE;AACrC,SAAK,WAAW,OAAO,UAAU,EAAE;AACnC,SAAK,WAAW,OAAO,UAAU,EAAE;AAAA,EACrC,SAAS,OAAgB;AACvB,QAAI,iBAAiB,wBAAwB;AAC3C,cAAQ,OAAO,MAAM,GAAG,MAAM,OAAO;AAAA,CAAI;AACzC,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,UAAM;AAAA,EACR;AACF;AAEA,SAAS,YAAY,OAA+D;AAClF,MAAI,UAAU,OAAW,QAAO;AAChC,SAAO,YAAY,IAAI,KAAmB,IAAK,QAAuB;AACxE;AAMA,SAAS,gBAA0B;AACjC,QAAM,WAAW,QAAQ,IAAI;AAC7B,MAAI,aAAa,UAAa,aAAa,IAAI;AAC7C,WAAO,EAAE,MAAM,MAAM,SAAS;AAAA,EAChC;AACA,SAAO,eAAe;AACxB;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safeword",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "CLI for setting up and managing safeword development environments",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,10 +35,12 @@ const EMPTY_DETAILS: TicketDetails = {
35
35
  * Look up a specific ticket's phase and status by ID.
36
36
  *
37
37
  * Resolves two folder layouts:
38
- * - Legacy `{id}-{slug}/` — folder name starts with `${id}-` (e.g. `080-foo`,
39
- * `102a-foo`). Case-sensitive on the legacy prefix to match historical IDs.
40
- * - New `{id}/` folder name equals the ID exactly. Crockford Base32 IDs are
41
- * canonical-uppercase on disk; lookup is case-insensitive on input.
38
+ * - `{id}-{slug}/` — folder name starts with `${id}-` (e.g. `080-foo`,
39
+ * `G2E72G-yolo-mode`). Canonical going forward; case-sensitive on the prefix
40
+ * since Base32 IDs are minted uppercase and legacy numeric IDs have no case.
41
+ * - `{id}/` folder name equals the ID exactly. Historical shape used by
42
+ * opaque Base32 tickets minted before the slug suffix was added; lookup is
43
+ * case-insensitive on input.
42
44
  *
43
45
  * If two folders both resolve to the input ID (manual mistake or copy-paste),
44
46
  * lookup writes a warning to stderr and returns empty details rather than
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/commands/ticket-new.ts","../src/utils/id-minter.ts","../src/utils/slug.ts","../src/utils/ticket-writer.ts"],"sourcesContent":["/**\n * `safeword ticket new <slug>` — mint a Crockford Base32 ticket ID and create\n * the ticket folder at `.safeword-project/tickets/{ID}/ticket.md` (ticket 158).\n *\n * Replaces the prompt-driven \"find highest folder + 1\" instruction in the\n * ticket-system skill, which was a race condition across parallel sessions\n * and silently colliding across git branches.\n */\n\nimport process from 'node:process';\n\nimport { cryptoIdMinter, type IdMinter } from '../utils/id-minter.js';\nimport { header, info, success } from '../utils/output.js';\nimport { normalizeSlug, SlugError } from '../utils/slug.js';\nimport { createTicket, TicketIdCollisionError, type TicketType } from '../utils/ticket-writer.js';\n\nconst VALID_TYPES: ReadonlySet<TicketType> = new Set(['patch', 'task', 'feature']);\n\nexport interface TicketNewOptions {\n type?: string;\n title?: string;\n}\n\nexport function ticketNew(slug: string, options: TicketNewOptions): Promise<void> {\n ticketNewSync(slug, options);\n return Promise.resolve();\n}\n\nfunction ticketNewSync(slug: string, options: TicketNewOptions): void {\n const type = resolveType(options.type);\n if (type === 'invalid') {\n process.stderr.write(\n `Invalid --type=${String(options.type)}. Must be one of: patch, task, feature.\\n`,\n );\n process.exit(1);\n }\n\n let normalizedSlug: string;\n try {\n normalizedSlug = normalizeSlug(slug);\n } catch (error: unknown) {\n if (error instanceof SlugError) {\n process.stderr.write(`${error.message}\\n`);\n process.exit(1);\n }\n throw error;\n }\n\n header('Create ticket');\n\n try {\n const result = createTicket(process.cwd(), resolveMinter(), {\n slug: normalizedSlug,\n type,\n title: options.title,\n });\n success(`Created ticket ${result.id}`);\n info(`Folder: ${result.folderPath}`);\n info(`File: ${result.ticketPath}`);\n } catch (error: unknown) {\n if (error instanceof TicketIdCollisionError) {\n process.stderr.write(`${error.message}\\n`);\n process.exit(1);\n }\n throw error;\n }\n}\n\nfunction resolveType(value: string | undefined): TicketType | undefined | 'invalid' {\n if (value === undefined) return undefined;\n return VALID_TYPES.has(value as TicketType) ? (value as TicketType) : 'invalid';\n}\n\n// Test-only injection point: SAFEWORD_TICKET_ID_OVERRIDE forces a specific\n// minted ID so cross-branch collision scenarios can be exercised deterministically.\n// The override is never set in production — the env var is intentionally\n// undocumented to discourage real-world use.\nfunction resolveMinter(): IdMinter {\n const override = process.env.SAFEWORD_TICKET_ID_OVERRIDE;\n if (override !== undefined && override !== '') {\n return { mint: () => override };\n }\n return cryptoIdMinter();\n}\n","/**\n * Crockford Base32 ticket ID minter (ticket 158).\n *\n * Mints uppercase 6-char IDs from the Crockford alphabet\n * `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (no I/L/O/U). 32^6 ≈ 10⁹ ID space.\n *\n * Two implementations:\n * - cryptoIdMinter() — production default, uses crypto.randomInt\n * - seededIdMinter(seed) — deterministic, for tests that need reproducibility\n */\n\nimport { randomInt } from 'node:crypto';\n\nexport const CROCKFORD_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';\nconst ID_LENGTH = 6;\n\nexport interface IdMinter {\n mint(): string;\n}\n\n/**\n * Build an ID from a constrained-range RNG that returns indices in\n * `[0, CROCKFORD_ALPHABET.length)`. The two exported minters differ only in\n * which RNG they pass in.\n */\nfunction buildId(nextIndex: () => number): string {\n const chars: string[] = [];\n for (let index = 0; index < ID_LENGTH; index++) {\n chars.push(CROCKFORD_ALPHABET.charAt(nextIndex()));\n }\n return chars.join('');\n}\n\nexport function cryptoIdMinter(): IdMinter {\n return { mint: () => buildId(() => randomInt(CROCKFORD_ALPHABET.length)) };\n}\n\n/**\n * Seeded PRNG (mulberry32) for deterministic test sequences.\n * Not cryptographically secure — production must use cryptoIdMinter.\n */\nexport function seededIdMinter(seed: number): IdMinter {\n let state = seed >>> 0;\n function nextUint32(): number {\n state = (state + 0x6d_2b_79_f5) >>> 0;\n let t = state;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return (t ^ (t >>> 14)) >>> 0;\n }\n return { mint: () => buildId(() => nextUint32() % CROCKFORD_ALPHABET.length) };\n}\n","/**\n * Slug normalization for `safeword ticket new <slug>` (ticket 158, slice 3).\n *\n * Slugs are stored in frontmatter and feed work-log filenames. Normalize at the\n * CLI boundary so the canonical form is always lowercase kebab-case:\n * - NFKD-fold (decomposes accents)\n * - drop combining marks (Unicode block U+0300–U+036F)\n * - lowercase\n * - replace non-alphanumeric runs with a single `-`\n * - strip leading/trailing `-`\n * Empty result throws SlugError so the CLI can exit with a clear message.\n */\n\nexport class SlugError extends Error {\n constructor(public readonly input: string) {\n super(\n input === ''\n ? 'Slug cannot be empty.'\n : `Slug \"${input}\" normalizes to empty (no alphanumeric content).`,\n );\n this.name = 'SlugError';\n }\n}\n\n// `\\p{Mn}` matches Nonspacing-Mark characters — the combining marks NFKD\n// decomposition exposes when it pulls accents off their base letter.\nconst COMBINING_MARKS = /\\p{Mn}/gu;\nconst NON_ALNUM = /[^a-z\\d]+/g;\n\nexport function normalizeSlug(input: string): string {\n const folded = input.normalize('NFKD').replaceAll(COMBINING_MARKS, '');\n const collapsed = stripDashEdges(folded.toLowerCase().replaceAll(NON_ALNUM, '-'));\n if (collapsed === '') throw new SlugError(input);\n return collapsed;\n}\n\nfunction stripDashEdges(value: string): string {\n let start = 0;\n let end = value.length;\n while (start < end && value.charAt(start) === '-') start++;\n while (end > start && value.charAt(end - 1) === '-') end--;\n return value.slice(start, end);\n}\n","/**\n * Creates a new ticket folder + ticket.md (ticket 158).\n *\n * Folder layout: `.safeword-project/tickets/{ID}/ticket.md`. Folder name is the\n * Crockford ID alone — slug lives in frontmatter. Any duplicate ID becomes a\n * real git merge conflict instead of two silently-coexisting folders.\n *\n * EEXIST retry + fresh-install (no tickets dir yet) handled here. Slice 1 sets\n * up the structure; slice 2 wires the deterministic retry tests.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport nodePath from 'node:path';\n\nimport type { IdMinter } from './id-minter.js';\n\nconst TICKETS_SUBPATH = ['.safeword-project', 'tickets'];\nconst RETRY_BUDGET = 5;\n\nexport type TicketType = 'patch' | 'task' | 'feature';\n\nexport interface NewTicketOptions {\n slug: string;\n type?: TicketType;\n title?: string;\n /** Override `new Date()` for tests. */\n now?: () => Date;\n}\n\nexport interface NewTicketResult {\n id: string;\n folderPath: string;\n ticketPath: string;\n}\n\nexport class TicketIdCollisionError extends Error {\n constructor(\n public readonly attemptedIds: string[],\n public readonly retryBudget: number,\n ) {\n super(\n `Failed to mint a unique ticket ID after ${retryBudget} attempts. Tried: ${attemptedIds.join(', ')}.`,\n );\n this.name = 'TicketIdCollisionError';\n }\n}\n\nexport function createTicket(\n cwd: string,\n minter: IdMinter,\n options: NewTicketOptions,\n): NewTicketResult {\n const ticketsDirectory = nodePath.join(cwd, ...TICKETS_SUBPATH);\n if (!existsSync(ticketsDirectory)) {\n mkdirSync(ticketsDirectory, { recursive: true });\n }\n\n const { id, folderPath } = mintAndClaim(ticketsDirectory, minter);\n const ticketPath = nodePath.join(folderPath, 'ticket.md');\n writeFileSync(ticketPath, renderTicketMarkdown(id, options));\n\n return { id, folderPath, ticketPath };\n}\n\nfunction mintAndClaim(\n ticketsDirectory: string,\n minter: IdMinter,\n): { id: string; folderPath: string } {\n const attempted: string[] = [];\n for (let attempt = 0; attempt < RETRY_BUDGET; attempt++) {\n const id = minter.mint();\n const folderPath = nodePath.join(ticketsDirectory, id);\n try {\n mkdirSync(folderPath);\n return { id, folderPath };\n } catch (error: unknown) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code !== 'EEXIST') throw error;\n attempted.push(id);\n }\n }\n throw new TicketIdCollisionError(attempted, RETRY_BUDGET);\n}\n\nfunction renderTicketMarkdown(id: string, options: NewTicketOptions): string {\n const type = options.type ?? 'task';\n const now = (options.now ?? (() => new Date()))().toISOString();\n const title = options.title ?? options.slug;\n\n return `---\nid: ${id}\nslug: ${options.slug}\ntype: ${type}\nphase: intake\nstatus: in_progress\ncreated: ${now}\nlast_modified: ${now}\n---\n\n# ${title}\n\n**Goal:** {One sentence: what are we trying to achieve?}\n\n**Why:** {One sentence: why does this matter?}\n\n## Work Log\n\n- ${now} Started: Created ticket ${id}\n`;\n}\n"],"mappings":"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","names":[]}