safeword 0.48.0 → 0.49.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 +1 -1
- package/dist/{ticket-new-BATYGHWL.js → ticket-new-BWMAVY7D.js} +1 -1
- package/dist/ticket-new-BWMAVY7D.js.map +1 -0
- package/package.json +4 -4
- package/templates/commands/audit.md +6 -4
- package/templates/commands/self-review.md +12 -2
- package/templates/commands/verify.md +7 -5
- package/templates/doc-templates/task-spec-template.md +2 -2
- package/templates/guides/architecture-guide.md +1 -1
- package/templates/guides/planning-guide.md +5 -5
- package/templates/hooks/lib/active-ticket.ts +3 -3
- package/templates/hooks/record-skill-invocation.ts +3 -2
- package/templates/hooks/stop-quality.ts +1 -1
- package/templates/skills/audit/SKILL.md +6 -4
- package/templates/skills/bdd/DISCOVERY.md +1 -1
- package/templates/skills/bdd/SCENARIOS.md +1 -1
- package/templates/skills/bdd/SKILL.md +2 -2
- package/templates/skills/self-review/SKILL.md +12 -3
- package/templates/skills/ticket-system/SKILL.md +5 -3
- package/templates/skills/verify/SKILL.md +7 -5
- package/dist/ticket-new-BATYGHWL.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -47,7 +47,7 @@ program.command("sync-config").description("Regenerate depcruise config from cur
|
|
|
47
47
|
});
|
|
48
48
|
var ticket = program.command("ticket").description("Ticket management");
|
|
49
49
|
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) => {
|
|
50
|
-
const { ticketNew } = await import("./ticket-new-
|
|
50
|
+
const { ticketNew } = await import("./ticket-new-BWMAVY7D.js");
|
|
51
51
|
await ticketNew(slug, options);
|
|
52
52
|
});
|
|
53
53
|
program.command("sync-learnings").description("Regenerate the namespace learnings/INDEX.md").option("-q, --quiet", "Suppress success output (still prints skipped-file warnings to stderr)").action(async (options) => {
|
|
@@ -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 `<namespace-root>/tickets/{ID}-{slug}/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 { formatTicketReference } from '../utils/ticket-reference.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 ${formatTicketReference(result.id, normalizedSlug)}`);\n info(`Folder: ${result.folderPath}`);\n info(`File: ${result.ticketPath}`);\n // NB: deliberately no index regen here — writing INDEX.md into the tickets\n // dir on every `ticket new` pollutes \"tickets dir = ticket folders\" and makes\n // the index a cross-branch merge-conflict magnet (the most concurrent op).\n // The index refreshes via `safeword sync-tickets` and `safeword check`. 1GGD28.\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: `<namespace-root>/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, readFileSync, writeFileSync } from 'node:fs';\nimport nodePath from 'node:path';\n\nimport { resolveTicketsDirectory } from './configured-paths.js';\nimport { getTemplatesDirectory } from './fs.js';\nimport type { IdMinter } from './id-minter.js';\n\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 = resolveTicketsDirectory(cwd);\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 // Features carry a product-framing spec.md sibling (epic DZ2NM5/D2 + D4).\n // Tasks and patches don't pay the persona/JTBD tax.\n if ((options.type ?? 'task') === 'feature') {\n const title = options.title ?? options.slug;\n writeFileSync(nodePath.join(folderPath, 'spec.md'), renderSpecMarkdown(title));\n }\n\n return { id, folderPath, ticketPath };\n}\n\nfunction renderSpecMarkdown(title: string): string {\n const template = readFileSync(nodePath.join(getTemplatesDirectory(), 'spec-template.md'), 'utf8');\n return template.replace('{title}', title);\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 // Features keep motivation in spec.md's ## Intent (single source of truth)\n // and point there; tasks/patches have no spec.md, so they keep **Why:**.\n const motivation =\n type === 'feature'\n ? '**See:** [spec.md](./spec.md) for personas, jobs-to-be-done, and outcomes.'\n : '**Why:** {One sentence: why does this matter?}';\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${motivation}\n\n## Work Log\n\n- ${now} Started: Created ticket ${id}\n`;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safeword",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "CLI for setting up and managing safeword development environments",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"eslint-plugin-security": "4.0.1",
|
|
82
82
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
|
83
83
|
"eslint-plugin-sonarjs": "4.0.3",
|
|
84
|
-
"eslint-plugin-storybook": "10.4.
|
|
84
|
+
"eslint-plugin-storybook": "10.4.5",
|
|
85
85
|
"eslint-plugin-turbo": "2.9.18",
|
|
86
86
|
"eslint-plugin-unicorn": "64.0.0",
|
|
87
87
|
"typescript-eslint": "8.61.0",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"@cucumber/cucumber": "^13.0.0",
|
|
92
92
|
"@types/estree": "^1.0.9",
|
|
93
93
|
"@types/node": "^25.9.2",
|
|
94
|
-
"@vitest/coverage-v8": "^4.1.
|
|
94
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
95
95
|
"eslint": "^9.39.4",
|
|
96
96
|
"eslint-v10": "npm:eslint@^10.5.0",
|
|
97
97
|
"knip": "^6.16.1",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"tsup": "^8.5.1",
|
|
101
101
|
"tsx": "^4.22.4",
|
|
102
102
|
"typescript": "^5.9.3",
|
|
103
|
-
"vitest": "^4.1.
|
|
103
|
+
"vitest": "^4.1.9"
|
|
104
104
|
},
|
|
105
105
|
"peerDependencies": {
|
|
106
106
|
"eslint": "^9.22.0"
|
|
@@ -8,18 +8,20 @@ Run a comprehensive code audit. Execute checks and report results by severity.
|
|
|
8
8
|
|
|
9
9
|
## Invocation log
|
|
10
10
|
|
|
11
|
-
This skill is required at the done-gate (ticket 147). The line below appends a session-scoped entry to `skill-invocations.log` under the project namespace root (`.project/`, or legacy `.safeword-project/` where that exists) so the done-gate hook can verify /audit was actually invoked. Claude Code expands the `!` line automatically and
|
|
11
|
+
This skill is required at the feature-ticket done-gate (ticket 147). The line below appends a session-scoped entry to `skill-invocations.log` under the project namespace root (`.project/`, or legacy `.safeword-project/` where that exists) so the done-gate hook can verify /audit was actually invoked. Claude Code expands the `!` line automatically and substitutes `${CLAUDE_SESSION_ID}` for session binding. Codex and Cursor docs do not document Claude-style `!` expansion or `${CLAUDE_SESSION_ID}` substitution, so the fallback below is explicit. Hand-writing audit results cannot produce this feature-gate proof.
|
|
12
12
|
|
|
13
|
-
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit || echo "[skill-invocation-log] FAILED -
|
|
13
|
+
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit "${CLAUDE_SESSION_ID}" || echo "[skill-invocation-log] FAILED - no session-scoped proof logged"`
|
|
14
14
|
|
|
15
15
|
If no `[skill-invocation-log] audit ✓` line appears above, run this fallback before continuing:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
|
|
19
|
-
bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit
|
|
19
|
+
bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit "${CLAUDE_SESSION_ID:-}"
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
**If the automatic line or fallback prints `[skill-invocation-log] FAILED`, reports `Missing
|
|
22
|
+
**If the automatic line or fallback prints `[skill-invocation-log] FAILED`, reports `Missing session id for skill invocation log`, or still does not print `audit ✓`**: Feature tickets must fail closed if no real current-session proof can be logged. Do not mark a feature ticket done or hand-write audit results as a substitute for the feature-gate proof. Report the failure to the user (most likely cause: inline shell execution was denied, the client lacks a compatible session id, or Bun could not run the installed helper) and ask them to resolve it before re-invoking /audit.
|
|
23
|
+
|
|
24
|
+
Task, patch, and no-ticket audit work may continue after recording that session-scoped proof was unavailable and not required by the gate.
|
|
23
25
|
|
|
24
26
|
## Instructions
|
|
25
27
|
|
|
@@ -16,8 +16,18 @@ per-asset gate reads it back.
|
|
|
16
16
|
|
|
17
17
|
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && CLAUDE_PROJECT_DIR="$PROJECT_DIR" bun "$PROJECT_DIR/.safeword/hooks/write-review-stamp.ts" spec`
|
|
18
18
|
|
|
19
|
-
If
|
|
20
|
-
|
|
19
|
+
If no `[skill-invocation-log] ... ✓` line appears above, run this fallback before stopping:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
|
|
23
|
+
CLAUDE_PROJECT_DIR="$PROJECT_DIR" bun "$PROJECT_DIR/.safeword/hooks/write-review-stamp.ts" spec
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The review stamp is content-bound and does not require `CLAUDE_SESSION_ID`.
|
|
27
|
+
|
|
28
|
+
If the automatic line and fallback both print `[skill-invocation-log] FAILED`,
|
|
29
|
+
or still do not print `✓`, the stamp was not written and the gate will keep
|
|
30
|
+
blocking — resolve before retrying.
|
|
21
31
|
|
|
22
32
|
## Review the spec
|
|
23
33
|
|
|
@@ -8,18 +8,20 @@ Prove a ticket meets its criteria. Works with or without an active ticket.
|
|
|
8
8
|
|
|
9
9
|
## Invocation log
|
|
10
10
|
|
|
11
|
-
This skill is required at the done-gate (ticket 147). The line below appends a session-scoped entry to `skill-invocations.log` under the project namespace root (`.project/`, or legacy `.safeword-project/` where that exists) so the done-gate hook can verify /verify was actually invoked. Claude Code expands the `!` line automatically and
|
|
11
|
+
This skill is required at the feature-ticket done-gate (ticket 147). The line below appends a session-scoped entry to `skill-invocations.log` under the project namespace root (`.project/`, or legacy `.safeword-project/` where that exists) so the done-gate hook can verify /verify was actually invoked. Claude Code expands the `!` line automatically and substitutes `${CLAUDE_SESSION_ID}` for session binding. Codex and Cursor docs do not document Claude-style `!` expansion or `${CLAUDE_SESSION_ID}` substitution, so the fallback below is explicit. Hand-writing verify.md cannot produce this feature-gate proof.
|
|
12
12
|
|
|
13
|
-
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify || echo "[skill-invocation-log] FAILED -
|
|
13
|
+
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify "${CLAUDE_SESSION_ID}" || echo "[skill-invocation-log] FAILED - no session-scoped proof logged"`
|
|
14
14
|
|
|
15
15
|
If no `[skill-invocation-log] verify ✓` line appears above, run this fallback before continuing:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
|
|
19
|
-
bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify
|
|
19
|
+
bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify "${CLAUDE_SESSION_ID:-}"
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
**If the automatic line or fallback prints `[skill-invocation-log] FAILED`, reports `Missing
|
|
22
|
+
**If the automatic line or fallback prints `[skill-invocation-log] FAILED`, reports `Missing session id for skill invocation log`, or still does not print `verify ✓`**: Feature tickets must fail closed if no real current-session proof can be logged. Do not mark a feature ticket done or hand-write verify.md as a substitute for the feature-gate proof. Report the failure to the user (most likely cause: inline shell execution was denied, the client lacks a compatible session id, or Bun could not run the installed helper) and ask them to resolve it before re-invoking /verify.
|
|
23
|
+
|
|
24
|
+
Task, patch, and no-ticket verify work may continue after recording that session-scoped proof was unavailable and not required by the gate.
|
|
23
25
|
|
|
24
26
|
## Instructions
|
|
25
27
|
|
|
@@ -68,7 +70,7 @@ The `/lint` command handles linting with auto-fix. Report any remaining unfixabl
|
|
|
68
70
|
|
|
69
71
|
### 3. Validate Test Definitions (skip if no ticket)
|
|
70
72
|
|
|
71
|
-
1. Find matching file: `$NS_ROOT/tickets/{
|
|
73
|
+
1. Find matching file: `$NS_ROOT/tickets/{ID}-{slug}/test-definitions.md`
|
|
72
74
|
2. Count scenarios: total `- [` lines
|
|
73
75
|
3. Count completed: `- [x]` lines
|
|
74
76
|
4. Report: "Scenarios: X/Y complete"
|
|
@@ -29,7 +29,7 @@ Use for: bugs, improvements, internal work, refactors.
|
|
|
29
29
|
- [ ] [Test scenario 2 - edge case or secondary behavior]
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
**Location:** `<namespace-root>/tickets/{
|
|
32
|
+
**Location:** `<namespace-root>/tickets/{ID}-{slug}/ticket.md`
|
|
33
33
|
|
|
34
34
|
---
|
|
35
35
|
|
|
@@ -55,7 +55,7 @@ Use for: typos, config changes, trivial fixes. Still scoped to prevent creep.
|
|
|
55
55
|
- [ ] Existing tests pass (no new test needed)
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
**Location:** `<namespace-root>/tickets/{
|
|
58
|
+
**Location:** `<namespace-root>/tickets/{ID}-{slug}/ticket.md`
|
|
59
59
|
|
|
60
60
|
---
|
|
61
61
|
|
|
@@ -288,7 +288,7 @@ Answer **IN ORDER**:
|
|
|
288
288
|
project/
|
|
289
289
|
├── ARCHITECTURE.md # Single comprehensive doc
|
|
290
290
|
├── <namespace-root>/tickets/
|
|
291
|
-
│ └── {
|
|
291
|
+
│ └── {ID}-{slug}/
|
|
292
292
|
│ ├── ticket.md
|
|
293
293
|
│ ├── test-definitions.md
|
|
294
294
|
│ └── design.md # Feature-specific design docs
|
|
@@ -14,7 +14,7 @@ How to write specs, user stories, and test definitions before implementation.
|
|
|
14
14
|
| Bug, improvement, internal, or refactor? | **task** | Task Spec with inline tests |
|
|
15
15
|
| Typo, config, or trivial change? | **patch** | Minimal Task Spec, existing tests |
|
|
16
16
|
|
|
17
|
-
**Location:** `<namespace-root>/tickets/{
|
|
17
|
+
**Location:** `<namespace-root>/tickets/{ID}-{slug}/`
|
|
18
18
|
|
|
19
19
|
Ticket artifacts live in the ticket folder:
|
|
20
20
|
|
|
@@ -320,18 +320,18 @@ GFM checkbox state IS the status. Don't add emoji indicators (`✅ Passing`, `
|
|
|
320
320
|
|
|
321
321
|
Feature source: `features/<slug>.feature`
|
|
322
322
|
|
|
323
|
-
Ledger: `<namespace-root>/tickets/{
|
|
323
|
+
Ledger: `<namespace-root>/tickets/{ID}-{slug}/test-definitions.md`
|
|
324
324
|
|
|
325
325
|
---
|
|
326
326
|
|
|
327
327
|
## Ticket Folder Naming
|
|
328
328
|
|
|
329
|
-
**Structure:** `<namespace-root>/tickets/{
|
|
329
|
+
**Structure:** `<namespace-root>/tickets/{ID}-{slug}/`
|
|
330
330
|
|
|
331
331
|
**Good folder names:**
|
|
332
332
|
|
|
333
|
-
- `
|
|
334
|
-
- `
|
|
333
|
+
- `7K9M3P-campaign-switching/`
|
|
334
|
+
- `BHR7DK-fix-login-timeout/`
|
|
335
335
|
|
|
336
336
|
**Bad folder names:**
|
|
337
337
|
|
|
@@ -38,10 +38,10 @@ const EMPTY_DETAILS: TicketDetails = {
|
|
|
38
38
|
* Look up a specific ticket's phase and status by ID.
|
|
39
39
|
*
|
|
40
40
|
* Resolves two folder layouts:
|
|
41
|
-
* - `{
|
|
41
|
+
* - `{ID}-{slug}/` — folder name starts with `${ID}-` (e.g. `080-foo`,
|
|
42
42
|
* `G2E72G-yolo-mode`). Canonical going forward; case-sensitive on the prefix
|
|
43
|
-
* since
|
|
44
|
-
* - `{
|
|
43
|
+
* since Crockford IDs are minted uppercase and legacy numeric IDs have no case.
|
|
44
|
+
* - `{ID}/` — folder name equals the ID exactly. Historical shape used by
|
|
45
45
|
* opaque Base32 tickets minted before the slug suffix was added; lookup is
|
|
46
46
|
* case-insensitive on input.
|
|
47
47
|
*
|
|
@@ -18,7 +18,7 @@ export function recordSkillInvocation(
|
|
|
18
18
|
throw new Error(`Invalid skill name "${skillName}"`);
|
|
19
19
|
}
|
|
20
20
|
if (sessionId === undefined || sessionId.trim().length === 0) {
|
|
21
|
-
throw new Error('Missing
|
|
21
|
+
throw new Error('Missing session id for skill invocation log');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const namespaceRoot = resolveNamespaceRoot(projectDirectory);
|
|
@@ -33,13 +33,14 @@ export function recordSkillInvocation(
|
|
|
33
33
|
if (import.meta.main) {
|
|
34
34
|
const projectDirectory = process.argv[2] ?? process.cwd();
|
|
35
35
|
const skillName = process.argv[3];
|
|
36
|
+
const sessionId = process.argv[4] ?? process.env.CLAUDE_SESSION_ID;
|
|
36
37
|
|
|
37
38
|
try {
|
|
38
39
|
if (skillName === undefined) {
|
|
39
40
|
throw new Error('Missing skill name');
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
recordSkillInvocation(projectDirectory, skillName);
|
|
43
|
+
recordSkillInvocation(projectDirectory, skillName, sessionId);
|
|
43
44
|
console.log(`[skill-invocation-log] ${skillName} ✓`);
|
|
44
45
|
} catch (error) {
|
|
45
46
|
console.error(error instanceof Error ? error.message : String(error));
|
|
@@ -531,7 +531,7 @@ if (currentPhase === 'done') {
|
|
|
531
531
|
recordFailure(projectDir, input.session_id, 'done-gate-tests-failed');
|
|
532
532
|
const missingList = skillCheck.missing.map(s => `/${s}`).join(' and ');
|
|
533
533
|
hardBlockDone(
|
|
534
|
-
`Required skill invocation(s) missing in this session: ${missingList}. Run ${missingList} before marking done. The helper-written log (skill-invocations.log under the project namespace root) proves invocation; hand-written verify.md does not satisfy this gate. If you ran ${missingList} but no
|
|
534
|
+
`Required skill invocation(s) missing in this session: ${missingList}. Run ${missingList} before marking a feature ticket done. The helper-written log (skill-invocations.log under the project namespace root) proves current-session invocation; hand-written verify.md does not satisfy this feature-ticket gate. If you ran ${missingList} but no session-scoped proof was logged, inline shell execution may have been denied, the fallback helper may not have been run, the client may not have provided a compatible session id, or Bun could not run the installed helper. Check the invocation-log block at the top of the skill and .safeword/hooks/record-skill-invocation.ts.`,
|
|
535
535
|
);
|
|
536
536
|
}
|
|
537
537
|
}
|
|
@@ -12,18 +12,20 @@ Run a comprehensive code audit. Execute checks and report results by severity.
|
|
|
12
12
|
|
|
13
13
|
## Invocation log
|
|
14
14
|
|
|
15
|
-
This skill is required at the done-gate (ticket 147). The line below appends a session-scoped entry to `skill-invocations.log` under the project namespace root (`.project/`, or legacy `.safeword-project/` where that exists) so the done-gate hook can verify /audit was actually invoked. Claude Code expands the `!` line automatically and
|
|
15
|
+
This skill is required at the feature-ticket done-gate (ticket 147). The line below appends a session-scoped entry to `skill-invocations.log` under the project namespace root (`.project/`, or legacy `.safeword-project/` where that exists) so the done-gate hook can verify /audit was actually invoked. Claude Code expands the `!` line automatically and substitutes `${CLAUDE_SESSION_ID}` for session binding. Codex and Cursor docs do not document Claude-style `!` expansion or `${CLAUDE_SESSION_ID}` substitution, so the fallback below is explicit. Hand-writing audit results cannot produce this feature-gate proof.
|
|
16
16
|
|
|
17
|
-
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit || echo "[skill-invocation-log] FAILED -
|
|
17
|
+
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit "${CLAUDE_SESSION_ID}" || echo "[skill-invocation-log] FAILED - no session-scoped proof logged"`
|
|
18
18
|
|
|
19
19
|
If no `[skill-invocation-log] audit ✓` line appears above, run this fallback before continuing:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
|
|
23
|
-
bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit
|
|
23
|
+
bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit "${CLAUDE_SESSION_ID:-}"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
**If the automatic line or fallback prints `[skill-invocation-log] FAILED`, reports `Missing
|
|
26
|
+
**If the automatic line or fallback prints `[skill-invocation-log] FAILED`, reports `Missing session id for skill invocation log`, or still does not print `audit ✓`**: Feature tickets must fail closed if no real current-session proof can be logged. Do not mark a feature ticket done or hand-write audit results as a substitute for the feature-gate proof. Report the failure to the user (most likely cause: inline shell execution was denied, the client lacks a compatible session id, or Bun could not run the installed helper) and ask them to resolve it before re-invoking /audit.
|
|
27
|
+
|
|
28
|
+
Task, patch, and no-ticket audit work may continue after recording that session-scoped proof was unavailable and not required by the gate.
|
|
27
29
|
|
|
28
30
|
## Instructions
|
|
29
31
|
|
|
@@ -171,7 +171,7 @@ Before proceeding to define-behavior:
|
|
|
171
171
|
|
|
172
172
|
0. **Specificity self-test passed** — you can concretely answer: what changes, what stays the same, observable done state
|
|
173
173
|
1. **Open Questions resolved** — `spec.md`'s `## Open Questions` is empty/answered, or each remaining line carries `defer: <reason>`. A long unresolved list means intake isn't done — keep converging.
|
|
174
|
-
2. **Verify ticket exists:** `<namespace-root>/tickets/{
|
|
174
|
+
2. **Verify ticket exists:** `<namespace-root>/tickets/{ID}-{slug}/ticket.md`
|
|
175
175
|
3. **Verify frontmatter has:** `scope`, `out_of_scope`, `done_when` fields (non-empty)
|
|
176
176
|
4. **Update frontmatter:** `phase: define-behavior`
|
|
177
177
|
5. **Add work log entry:**
|
|
@@ -153,7 +153,7 @@ as advisories (never a gate):
|
|
|
153
153
|
### Define Behavior Exit (REQUIRED)
|
|
154
154
|
|
|
155
155
|
1. **Save scenarios** to `features/<slug>.feature`
|
|
156
|
-
2. **Save the R/G/R ledger** to `<namespace-root>/tickets/{
|
|
156
|
+
2. **Save the R/G/R ledger** to `<namespace-root>/tickets/{ID}-{slug}/test-definitions.md`
|
|
157
157
|
3. **Update frontmatter:** `phase: scenario-gate`
|
|
158
158
|
4. **Add work log entry:**
|
|
159
159
|
|
|
@@ -101,8 +101,8 @@ When user references a ticket, resume work:
|
|
|
101
101
|
- If yes → resume at current phase
|
|
102
102
|
4. **If ticket exists:** Read phase, resume at appropriate point
|
|
103
103
|
5. **Artifact-first rule:** Before doing work, create/verify the phase artifact:
|
|
104
|
-
- intake → ticket at `<namespace-root>/tickets/{
|
|
105
|
-
- define-behavior → feature source at `features/<slug>.feature` plus R/G/R ledger at `<namespace-root>/tickets/{
|
|
104
|
+
- intake → ticket at `<namespace-root>/tickets/{ID}-{slug}/ticket.md`
|
|
105
|
+
- define-behavior → feature source at `features/<slug>.feature` plus R/G/R ledger at `<namespace-root>/tickets/{ID}-{slug}/test-definitions.md`
|
|
106
106
|
6. **Execute phase** using the appropriate phase file
|
|
107
107
|
7. **Update phase** in ticket when transitioning
|
|
108
108
|
|
|
@@ -24,10 +24,19 @@ hand-editing the log is the gameable floor this tier deliberately accepts.
|
|
|
24
24
|
|
|
25
25
|
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && CLAUDE_PROJECT_DIR="$PROJECT_DIR" bun "$PROJECT_DIR/.safeword/hooks/write-review-stamp.ts" spec`
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
If no `[skill-invocation-log] ... ✓` line appears above, run this fallback before stopping:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
|
|
31
|
+
CLAUDE_PROJECT_DIR="$PROJECT_DIR" bun "$PROJECT_DIR/.safeword/hooks/write-review-stamp.ts" spec
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The review stamp is content-bound and does not require `CLAUDE_SESSION_ID`.
|
|
35
|
+
|
|
36
|
+
**If the automatic line and fallback both print `[skill-invocation-log] FAILED`, or still do not print `✓`**: STOP.
|
|
28
37
|
The stamp was not written and the gate will keep blocking. Most likely the bash
|
|
29
|
-
injection was denied
|
|
30
|
-
and resolve before retrying.
|
|
38
|
+
injection was denied, no in_progress ticket was found, or Bun could not run the
|
|
39
|
+
installed helper — report it to the user and resolve before retrying.
|
|
31
40
|
|
|
32
41
|
## Review the spec (do this now, with the stamp written)
|
|
33
42
|
|
|
@@ -15,19 +15,21 @@ allowed-tools: '*'
|
|
|
15
15
|
|
|
16
16
|
**Creating a ticket:** Run `safeword ticket new <slug>` (optionally with `--type=patch|task|feature` and `--title="..."`). The CLI mints a 6-char Crockford Base32 ID, creates the folder atomically, and writes a starter ticket.md. **Do not scan the tickets directory and pick the next ID yourself** — that races between parallel sessions and silently collides across git branches.
|
|
17
17
|
|
|
18
|
-
**Location:** `<namespace-root>/tickets/{ID}/` for
|
|
18
|
+
**Location:** `<namespace-root>/tickets/{ID}-{slug}/` for tickets created by `safeword ticket new` (6-char Crockford ID plus normalized slug). Lookup remains backward-compatible with older `{ID}/` Crockford folders and legacy `{numeric-id}-{slug}/` folders — all formats remain reachable by ID.
|
|
19
19
|
|
|
20
20
|
**Folder structure:**
|
|
21
21
|
|
|
22
22
|
```text
|
|
23
23
|
<namespace-root>/
|
|
24
24
|
├── tickets/
|
|
25
|
-
│ ├── 7K9M3P/
|
|
25
|
+
│ ├── 7K9M3P-login-bug/ # Current format: Crockford ID + normalized slug
|
|
26
26
|
│ │ ├── ticket.md # Ticket definition (frontmatter + work log)
|
|
27
27
|
│ │ ├── test-definitions.md # BDD scenarios (Given/When/Then)
|
|
28
28
|
│ │ ├── spec.md # Feature spec for epics (optional)
|
|
29
29
|
│ │ └── design.md # Design doc for complex features (optional)
|
|
30
|
-
│ ├──
|
|
30
|
+
│ ├── 7K9M3P/ # Historical Crockford ID-only format, still readable
|
|
31
|
+
│ │ └── ticket.md
|
|
32
|
+
│ ├── 080-ticket-id-collision/ # Legacy numeric format, still readable
|
|
31
33
|
│ │ └── ticket.md
|
|
32
34
|
│ └── completed/ # Archive for done tickets
|
|
33
35
|
├── learnings/ # Extracted knowledge (gotchas, discoveries)
|
|
@@ -12,18 +12,20 @@ Prove a ticket meets its criteria. Works with or without an active ticket.
|
|
|
12
12
|
|
|
13
13
|
## Invocation log
|
|
14
14
|
|
|
15
|
-
This skill is required at the done-gate (ticket 147). The line below appends a session-scoped entry to `skill-invocations.log` under the project namespace root (`.project/`, or legacy `.safeword-project/` where that exists) so the done-gate hook can verify /verify was actually invoked. Claude Code expands the `!` line automatically and
|
|
15
|
+
This skill is required at the feature-ticket done-gate (ticket 147). The line below appends a session-scoped entry to `skill-invocations.log` under the project namespace root (`.project/`, or legacy `.safeword-project/` where that exists) so the done-gate hook can verify /verify was actually invoked. Claude Code expands the `!` line automatically and substitutes `${CLAUDE_SESSION_ID}` for session binding. Codex and Cursor docs do not document Claude-style `!` expansion or `${CLAUDE_SESSION_ID}` substitution, so the fallback below is explicit. Hand-writing verify.md cannot produce this feature-gate proof.
|
|
16
16
|
|
|
17
|
-
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify || echo "[skill-invocation-log] FAILED -
|
|
17
|
+
!`PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" && bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify "${CLAUDE_SESSION_ID}" || echo "[skill-invocation-log] FAILED - no session-scoped proof logged"`
|
|
18
18
|
|
|
19
19
|
If no `[skill-invocation-log] verify ✓` line appears above, run this fallback before continuing:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
|
|
23
|
-
bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify
|
|
23
|
+
bun "$PROJECT_DIR/.safeword/hooks/record-skill-invocation.ts" "$PROJECT_DIR" verify "${CLAUDE_SESSION_ID:-}"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
**If the automatic line or fallback prints `[skill-invocation-log] FAILED`, reports `Missing
|
|
26
|
+
**If the automatic line or fallback prints `[skill-invocation-log] FAILED`, reports `Missing session id for skill invocation log`, or still does not print `verify ✓`**: Feature tickets must fail closed if no real current-session proof can be logged. Do not mark a feature ticket done or hand-write verify.md as a substitute for the feature-gate proof. Report the failure to the user (most likely cause: inline shell execution was denied, the client lacks a compatible session id, or Bun could not run the installed helper) and ask them to resolve it before re-invoking /verify.
|
|
27
|
+
|
|
28
|
+
Task, patch, and no-ticket verify work may continue after recording that session-scoped proof was unavailable and not required by the gate.
|
|
27
29
|
|
|
28
30
|
## Instructions
|
|
29
31
|
|
|
@@ -72,7 +74,7 @@ The `/lint` command handles linting with auto-fix. Report any remaining unfixabl
|
|
|
72
74
|
|
|
73
75
|
### 3. Validate Test Definitions (skip if no ticket)
|
|
74
76
|
|
|
75
|
-
1. Find matching file: `$NS_ROOT/tickets/{
|
|
77
|
+
1. Find matching file: `$NS_ROOT/tickets/{ID}-{slug}/test-definitions.md`
|
|
76
78
|
2. Count scenarios: total `- [` lines
|
|
77
79
|
3. Count completed: `- [x]` lines
|
|
78
80
|
4. Report: "Scenarios: X/Y complete"
|
|
@@ -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 `<namespace-root>/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 { formatTicketReference } from '../utils/ticket-reference.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 ${formatTicketReference(result.id, normalizedSlug)}`);\n info(`Folder: ${result.folderPath}`);\n info(`File: ${result.ticketPath}`);\n // NB: deliberately no index regen here — writing INDEX.md into the tickets\n // dir on every `ticket new` pollutes \"tickets dir = ticket folders\" and makes\n // the index a cross-branch merge-conflict magnet (the most concurrent op).\n // The index refreshes via `safeword sync-tickets` and `safeword check`. 1GGD28.\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: `<namespace-root>/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, readFileSync, writeFileSync } from 'node:fs';\nimport nodePath from 'node:path';\n\nimport { resolveTicketsDirectory } from './configured-paths.js';\nimport { getTemplatesDirectory } from './fs.js';\nimport type { IdMinter } from './id-minter.js';\n\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 = resolveTicketsDirectory(cwd);\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 // Features carry a product-framing spec.md sibling (epic DZ2NM5/D2 + D4).\n // Tasks and patches don't pay the persona/JTBD tax.\n if ((options.type ?? 'task') === 'feature') {\n const title = options.title ?? options.slug;\n writeFileSync(nodePath.join(folderPath, 'spec.md'), renderSpecMarkdown(title));\n }\n\n return { id, folderPath, ticketPath };\n}\n\nfunction renderSpecMarkdown(title: string): string {\n const template = readFileSync(nodePath.join(getTemplatesDirectory(), 'spec-template.md'), 'utf8');\n return template.replace('{title}', title);\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 // Features keep motivation in spec.md's ## Intent (single source of truth)\n // and point there; tasks/patches have no spec.md, so they keep **Why:**.\n const motivation =\n type === 'feature'\n ? '**See:** [spec.md](./spec.md) for personas, jobs-to-be-done, and outcomes.'\n : '**Why:** {One sentence: why does this matter?}';\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${motivation}\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,cAAc,qBAAqB;AAChF,OAAO,cAAc;AAMrB,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,wBAAwB,GAAG;AACpD,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;AAI3D,OAAK,QAAQ,QAAQ,YAAY,WAAW;AAC1C,UAAM,QAAQ,QAAQ,SAAS,QAAQ;AACvC,kBAAc,SAAS,KAAK,YAAY,SAAS,GAAG,mBAAmB,KAAK,CAAC;AAAA,EAC/E;AAEA,SAAO,EAAE,IAAI,YAAY,WAAW;AACtC;AAEA,SAAS,mBAAmB,OAAuB;AACjD,QAAM,WAAW,aAAa,SAAS,KAAK,sBAAsB,GAAG,kBAAkB,GAAG,MAAM;AAChG,SAAO,SAAS,QAAQ,WAAW,KAAK;AAC1C;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;AAIvC,QAAM,aACJ,SAAS,YACL,+EACA;AAEN,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,EAIP,UAAU;AAAA;AAAA;AAAA;AAAA,IAIR,GAAG,4BAA4B,EAAE;AAAA;AAErC;;;AHlJA,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,sBAAsB,OAAO,IAAI,cAAc,CAAC,EAAE;AAC5E,SAAK,WAAW,OAAO,UAAU,EAAE;AACnC,SAAK,WAAW,OAAO,UAAU,EAAE;AAAA,EAKrC,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":[]}
|