toga-ai 1.0.968 → 1.0.969

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.
@@ -18,6 +18,15 @@
18
18
  "timeout": 3000
19
19
  }
20
20
  ]
21
+ },
22
+ {
23
+ "hooks": [
24
+ {
25
+ "type": "command",
26
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/toga/session-files.js\"",
27
+ "timeout": 3000
28
+ }
29
+ ]
21
30
  }
22
31
  ],
23
32
  "PreToolUse": [
@@ -182,6 +191,16 @@
182
191
  "timeout": 3000
183
192
  }
184
193
  ]
194
+ },
195
+ {
196
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
197
+ "hooks": [
198
+ {
199
+ "type": "command",
200
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/toga/session-files.js\"",
201
+ "timeout": 3000
202
+ }
203
+ ]
185
204
  }
186
205
  ],
187
206
  "SessionStart": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.968",
3
+ "version": "1.0.969",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",
@@ -113,20 +113,24 @@ Keep the description short (3–5 words). Use hyphens, not underscores.
113
113
 
114
114
  ## Commit messages
115
115
 
116
- Format: `type: short description`
116
+ Format: `<TICKET>: <Subject>` — the same in every repo. It matches api2's CI check.
117
117
 
118
- Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`
118
+ - `TRUE-79868: Use client default tax code when invoice tax code is blank`
119
+ - `TRUE-80112: Add retry limit to NetSuite item sync`
119
120
 
120
- - `feat: add batch order processing worker`
121
- - `fix: null check on customer email before dispatch`
122
- - `refactor: extract order validation to dedicated class`
123
- - `docs: document recursive item fulfillment feature`
124
- - `test: add regression test for empty batch edge case`
125
- - `chore: update composer dependencies`
121
+ Rules:
126
122
 
127
- The description is lowercase, present tense, under 72 characters. No period at the end.
123
+ 1. **Ticket prefix from the branch name.** ClickUp names the branch (e.g. `TRUE-79868`); the
124
+ ticket id matches `^[A-Z]+-\d+`. If the branch has no ticket id, ask the developer.
125
+ 2. **Subject starts with a capital letter.**
126
+ 3. **Imperative mood** — "Use", "Add", "Fix". Not past tense ("Used", "Added", "Fixed", "Updated").
127
+ 4. **80 characters max** for the whole subject line.
128
+ 5. **No period at the end.**
129
+ 6. **Not a single word** — the subject says what the commit does.
128
130
 
129
- If a commit is substantial, add a blank line after the subject and a body paragraph explaining the why.
131
+ If a commit is substantial, add a blank line after the subject and a body explaining the why.
132
+
133
+ Claude's work is committed with `/commit`, which commits only the files this session changed and writes the session notes into the message body (`Claude-Notes: v1`). Changes made by hand outside Claude may be committed the normal way.
130
134
 
131
135
  ## What never goes in commits
132
136
 
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * pr-risk.js — deterministic PR risk score for the TOGA PR summary reviewer.
6
+ *
7
+ * The model must NOT pick the risk level. This script does, from git facts only.
8
+ *
9
+ * Usage:
10
+ * node pr-risk.js --base <sha> --head <sha> --repo <owner/name> [--out risk.json]
11
+ *
12
+ * Output JSON:
13
+ * { level, reasons[], stats{...}, readFirstCandidates[], manualCommitList[] }
14
+ *
15
+ * No dependencies. Read-only git commands only.
16
+ */
17
+
18
+ const { execFileSync } = require('child_process');
19
+ const fs = require('fs');
20
+
21
+ // ---- Thresholds -------------------------------------------------------------
22
+ const HIGH_LINES_THRESHOLD = 800;
23
+ const MEDIUM_LINES_THRESHOLD = 300;
24
+ const MEDIUM_FILES_THRESHOLD = 15;
25
+ const READ_FIRST_LIMIT = 8;
26
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
27
+
28
+ // ---- Repo / path rules ------------------------------------------------------
29
+ const DB_REPOS = ['dbchanges2', 'dbchanges'];
30
+ const CORE_REPOS = ['_underscore', 'library'];
31
+ // Whole words only (after splitting camelCase and non-letters), so "author" does not match.
32
+ const SENSITIVE_WORDS = new Set([
33
+ 'acl', 'acls', 'auth', 'authn', 'authz', 'authentication', 'authorization', 'authorize',
34
+ 'login', 'logout', 'token', 'tokens', 'saml', 'permission', 'permissions', 'oauth', 'sso',
35
+ 'password', 'credential', 'credentials',
36
+ ]);
37
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
38
+ const SQL_FILE_RE = /\.sql$/i;
39
+ const TEST_PATH_RE = /(^|\/)(test|tests|__tests__|spec)(\/|$)|\.(test|spec)\.[^/]+$/i;
40
+ const LOCKFILE_RE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|bun\.lockb)$/i;
41
+ const GENERATED_RE = /(^|\/)(dist|build)\/|\.min\.[^/]+$|\.map$|(^|\/)INVENTORY\.md$/i;
42
+ const PACKAGE_MANIFEST_RE = /(^|\/)(package\.json|composer\.json)$/i;
43
+ const CORE_DB_REF_RE = /\b(Core|Forecast|Team)`?\.`?\w/;
44
+ const CLIENT_DB_REF_RE = /\bClient_\w/;
45
+ const NOTES_TRAILER_RE = /^Claude-Notes:\s*v1\s*$/m;
46
+
47
+ function parseArgs(argv) {
48
+ const args = {};
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const key = argv[i];
51
+ if (!key.startsWith('--')) continue;
52
+ const name = key.slice(2);
53
+ const next = argv[i + 1];
54
+ if (next === undefined || next.startsWith('--')) {
55
+ args[name] = true;
56
+ continue;
57
+ }
58
+ args[name] = next;
59
+ i++;
60
+ }
61
+ return args;
62
+ }
63
+
64
+ function git(args) {
65
+ // core.quotePath=false: non-ASCII paths come back as-is, not octal-quoted.
66
+ return execFileSync('git', ['-c', 'core.quotePath=false'].concat(args), {
67
+ encoding: 'utf8',
68
+ maxBuffer: GIT_MAX_BUFFER,
69
+ });
70
+ }
71
+
72
+ /** Split a path into lowercase words: camelCase, digits and any non-letter are breaks. */
73
+ function pathWords(path) {
74
+ return path
75
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
76
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
77
+ .split(/[^A-Za-z]+/)
78
+ .filter(Boolean)
79
+ .map((w) => w.toLowerCase());
80
+ }
81
+
82
+ function isSensitivePath(path) {
83
+ return pathWords(path).some((w) => SENSITIVE_WORDS.has(w));
84
+ }
85
+
86
+ function isTest(path) {
87
+ return TEST_PATH_RE.test(path);
88
+ }
89
+
90
+ function isGenerated(path) {
91
+ return LOCKFILE_RE.test(path) || GENERATED_RE.test(path);
92
+ }
93
+
94
+ /** Resolve rename notation like "dir/{old => new}/file" or "old => new" to the new path. */
95
+ function resolveRenamePath(raw) {
96
+ if (!raw.includes('=>')) return raw;
97
+ const braced = raw.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
98
+ if (braced) return (braced[1] + braced[3] + braced[4]).replace(/\/\//g, '/');
99
+ return raw.split(' => ').pop();
100
+ }
101
+
102
+ /** Per-file line counts. ignoreWhitespace=true uses -w, so CRLF/LF or re-indent churn is not counted. */
103
+ function readFileStats(range, ignoreWhitespace) {
104
+ const flags = ignoreWhitespace ? ['--numstat', '-M', '-w'] : ['--numstat', '-M'];
105
+ const out = git(['diff'].concat(flags, [range]));
106
+ const files = [];
107
+ for (const line of out.split('\n')) {
108
+ if (!line.trim()) continue;
109
+ const [added, deleted, ...rest] = line.split('\t');
110
+ const path = resolveRenamePath(rest.join('\t'));
111
+ const isBinary = added === '-' || deleted === '-';
112
+ const lines = isBinary ? 0 : Number(added) + Number(deleted);
113
+ files.push({ path, lines });
114
+ }
115
+ return files;
116
+ }
117
+
118
+ function readDeletedPaths(range) {
119
+ const out = git(['diff', '--name-only', '--diff-filter=D', range]);
120
+ return out.split('\n').filter(Boolean);
121
+ }
122
+
123
+ function readAddedLines(range) {
124
+ const out = git(['diff', '-U0', '-w', '--no-color', range]);
125
+ return out
126
+ .split('\n')
127
+ .filter((line) => line.startsWith('+') && !line.startsWith('+++'))
128
+ .map((line) => line.slice(1));
129
+ }
130
+
131
+ function readCommits(base, head) {
132
+ const out = git(['log', '--no-merges', '--format=%H%x00%B%x1e', `${base}..${head}`]);
133
+ return out
134
+ .split('\x1e')
135
+ .map((chunk) => chunk.replace(/^\s+/, ''))
136
+ .filter(Boolean)
137
+ .map((chunk) => {
138
+ const [sha, body = ''] = chunk.split('\x00');
139
+ const subject = body.split('\n')[0].trim();
140
+ return { sha: sha.trim(), subject, hasNotes: NOTES_TRAILER_RE.test(body) };
141
+ });
142
+ }
143
+
144
+ function computeRisk({ repoName, files, rawLines, deletedPaths, addedLines, commits }) {
145
+ const high = [];
146
+ const medium = [];
147
+
148
+ const countedFiles = files.filter((f) => !isTest(f.path) && !isGenerated(f.path));
149
+ const linesChanged = files.reduce((sum, f) => sum + f.lines, 0);
150
+ const linesCounted = countedFiles.reduce((sum, f) => sum + f.lines, 0);
151
+ const notesCommits = commits.filter((c) => c.hasNotes);
152
+ const manualCommits = commits.filter((c) => !c.hasNotes);
153
+
154
+ if (DB_REPOS.includes(repoName) || files.some((f) => SQL_FILE_RE.test(f.path))) {
155
+ high.push('database change');
156
+ }
157
+ if (CORE_REPOS.includes(repoName)) {
158
+ high.push('edit to shared core — affects every app');
159
+ }
160
+ if (files.some((f) => isSensitivePath(f.path))) {
161
+ high.push('touches login or permissions');
162
+ }
163
+ const deletedTests = deletedPaths.filter(isTest);
164
+ if (deletedTests.length > 0) {
165
+ high.push(`${deletedTests.length} test file(s) deleted`);
166
+ }
167
+ if (addedLines.some((l) => CORE_DB_REF_RE.test(l) && CLIENT_DB_REF_RE.test(l))) {
168
+ high.push('a query may mix Core and a Client database — they are on different servers in production');
169
+ }
170
+ if (linesCounted > HIGH_LINES_THRESHOLD) {
171
+ high.push(`large change: ${linesCounted} lines (not counting tests and generated files)`);
172
+ }
173
+
174
+ if (linesCounted > MEDIUM_LINES_THRESHOLD && linesCounted <= HIGH_LINES_THRESHOLD) {
175
+ medium.push(`medium-size change: ${linesCounted} lines (not counting tests and generated files)`);
176
+ }
177
+ if (files.some((f) => PACKAGE_MANIFEST_RE.test(f.path) || LOCKFILE_RE.test(f.path))) {
178
+ medium.push('new or changed package');
179
+ }
180
+ if (manualCommits.length > 0) {
181
+ const noun = manualCommits.length === 1 ? 'commit has' : 'commits have';
182
+ medium.push(`${manualCommits.length} ${noun} no session notes`);
183
+ }
184
+ if (files.length > MEDIUM_FILES_THRESHOLD) {
185
+ medium.push(`${files.length} files changed`);
186
+ }
187
+ const whitespaceOnly = rawLines - linesChanged;
188
+ if (whitespaceOnly > MEDIUM_LINES_THRESHOLD) {
189
+ medium.push(`${whitespaceOnly} lines are whitespace or line-ending only (ignored)`);
190
+ }
191
+
192
+ let level = 'LOW';
193
+ let reasons = [commits.length > 0
194
+ ? 'small change with session notes on every commit'
195
+ : 'small change (no commits found in the range)'];
196
+ if (high.length > 0) {
197
+ level = 'HIGH';
198
+ reasons = high.concat(medium);
199
+ } else if (medium.length > 0) {
200
+ level = 'MEDIUM';
201
+ reasons = medium;
202
+ }
203
+
204
+ const readFirstCandidates = countedFiles
205
+ .slice()
206
+ .sort((a, b) => b.lines - a.lines || a.path.localeCompare(b.path))
207
+ .slice(0, READ_FIRST_LIMIT)
208
+ .map((f) => ({ path: f.path, linesChanged: f.lines }));
209
+
210
+ return {
211
+ level,
212
+ reasons,
213
+ stats: {
214
+ filesChanged: files.length,
215
+ linesChanged,
216
+ rawLines,
217
+ linesChangedExcludingTestsAndGenerated: linesCounted,
218
+ commits: commits.length,
219
+ commitsWithNotes: notesCommits.length,
220
+ manualCommits: manualCommits.length,
221
+ },
222
+ readFirstCandidates,
223
+ manualCommitList: manualCommits.map((c) => ({ sha: c.sha.slice(0, 7), subject: c.subject })),
224
+ };
225
+ }
226
+
227
+ function main() {
228
+ const args = parseArgs(process.argv.slice(2));
229
+ if (!args.base || !args.head || !args.repo || args.base === true || args.head === true) {
230
+ process.stderr.write('Usage: node pr-risk.js --base <sha> --head <sha> --repo <owner/name> [--out risk.json]\n');
231
+ process.exit(2);
232
+ }
233
+ if (!SHA_RE.test(String(args.base)) || !SHA_RE.test(String(args.head))) {
234
+ process.stderr.write('pr-risk.js: --base and --head must be commit shas (7-40 hex characters)\n');
235
+ process.exit(2);
236
+ }
237
+ const repoName = String(args.repo).split('/').pop();
238
+ const range = `${args.base}...${args.head}`;
239
+
240
+ const result = computeRisk({
241
+ repoName,
242
+ files: readFileStats(range, true),
243
+ rawLines: readFileStats(range, false).reduce((sum, f) => sum + f.lines, 0),
244
+ deletedPaths: readDeletedPaths(range),
245
+ addedLines: readAddedLines(range),
246
+ commits: readCommits(args.base, args.head),
247
+ });
248
+ result.repo = String(args.repo);
249
+
250
+ const json = JSON.stringify(result, null, 2) + '\n';
251
+ if (args.out && args.out !== true) {
252
+ fs.writeFileSync(args.out, json);
253
+ }
254
+ process.stdout.write(json);
255
+ }
256
+
257
+ try {
258
+ main();
259
+ } catch (err) {
260
+ process.stderr.write(`pr-risk.js failed: ${err.message}\n`);
261
+ process.exit(1);
262
+ }
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+ /* session-files.js — remembers which files THIS Claude Code session edited, per git repo.
3
+ *
4
+ * PostToolUse (Edit|Write|MultiEdit|NotebookEdit): appends one JSON line to
5
+ * <gitdir>/claude-sessions/<sessionId>.jsonl — read later by skills/commit (commit-helper.js).
6
+ * Silent on stdout. No child processes (fires on every edit — must stay fast).
7
+ * UserPromptSubmit: when the prompt starts with "/commit", prints the session id + transcript
8
+ * path so the /commit skill knows which list is its own.
9
+ *
10
+ * Never denies, never crashes, always exits 0. Disable: TOGA_SESSION_FILES_DISABLED=1.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const PRUNE_AFTER_DAYS = 14;
16
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
17
+ const MAX_SESSION_ID_LENGTH = 64;
18
+ const SESSIONS_DIR_NAME = 'claude-sessions';
19
+ const COMMIT_PROMPT_RE = /^\/(?:[\w-]+:)?commit(\s|$)/;
20
+
21
+ /* A closed pipe (EPIPE) on stdout must never crash the hook. */
22
+ process.stdout.on('error', () => {});
23
+
24
+ function readInput() {
25
+ try {
26
+ const raw = fs.readFileSync(0, 'utf8');
27
+ if (!raw || !raw.trim()) return null;
28
+ return JSON.parse(raw);
29
+ } catch (e) {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function safeSessionId(id) {
35
+ return String(id || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, MAX_SESSION_ID_LENGTH);
36
+ }
37
+
38
+ /* Walk up from `start` looking for .git. Returns { repoRoot, gitDir } or null. */
39
+ function findRepo(start) {
40
+ let dir = start;
41
+ for (;;) {
42
+ const dotGit = path.join(dir, '.git');
43
+ let st = null;
44
+ try { st = fs.statSync(dotGit); } catch (e) { st = null; }
45
+ if (st && st.isDirectory()) return { repoRoot: dir, gitDir: dotGit };
46
+ if (st && st.isFile()) {
47
+ try {
48
+ const m = fs.readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+)\s*$/m);
49
+ if (m) return { repoRoot: dir, gitDir: path.resolve(dir, m[1].trim()) };
50
+ } catch (e) { /* unreadable .git file — keep walking */ }
51
+ }
52
+ const parent = path.dirname(dir);
53
+ if (parent === dir) return null;
54
+ dir = parent;
55
+ }
56
+ }
57
+
58
+ function hasHeadFile(gitDir) {
59
+ try { return fs.statSync(path.join(gitDir, 'HEAD')).isFile(); } catch (e) { return false; }
60
+ }
61
+
62
+ function readBranch(gitDir) {
63
+ try {
64
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
65
+ const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
66
+ if (m) return m[1];
67
+ return head.slice(0, 7);
68
+ } catch (e) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function isInside(child, parent) {
74
+ const rel = path.relative(parent, child);
75
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
76
+ }
77
+
78
+ function pruneOld(dir, keepFile) {
79
+ const cutoff = Date.now() - PRUNE_AFTER_DAYS * MS_PER_DAY;
80
+ let names = [];
81
+ try { names = fs.readdirSync(dir); } catch (e) { return; }
82
+ for (const name of names) {
83
+ const full = path.join(dir, name);
84
+ if (full === keepFile || !name.endsWith('.jsonl')) continue;
85
+ try {
86
+ if (fs.statSync(full).mtimeMs < cutoff) fs.unlinkSync(full);
87
+ } catch (e) { /* another session may have removed it — fine */ }
88
+ }
89
+ }
90
+
91
+ function handlePostToolUse(input) {
92
+ const sessionId = safeSessionId(input.session_id);
93
+ if (!sessionId) return;
94
+ const ti = input.tool_input || {};
95
+ const rawPath = ti.file_path || ti.notebook_path;
96
+ if (!rawPath) return;
97
+
98
+ const filePath = path.resolve(input.cwd || process.cwd(), String(rawPath));
99
+ const repo = findRepo(path.dirname(filePath));
100
+ if (!repo) return;
101
+ if (!hasHeadFile(repo.gitDir)) return; /* not a real git dir — write nothing */
102
+ if (isInside(filePath, repo.gitDir) || isInside(filePath, path.join(repo.repoRoot, '.git'))) return;
103
+
104
+ const sessionsDir = path.join(repo.gitDir, SESSIONS_DIR_NAME);
105
+ const listFile = path.join(sessionsDir, sessionId + '.jsonl');
106
+ const isNew = !fs.existsSync(listFile);
107
+ fs.mkdirSync(sessionsDir, { recursive: true });
108
+
109
+ const entry = {
110
+ ts: new Date().toISOString(),
111
+ sessionId: String(input.session_id),
112
+ transcriptPath: input.transcript_path || null,
113
+ repoRoot: repo.repoRoot,
114
+ branch: readBranch(repo.gitDir),
115
+ file: path.relative(repo.repoRoot, filePath).split(path.sep).join('/'),
116
+ tool: input.tool_name || null,
117
+ };
118
+ if (input.agent_id) entry.agentId = input.agent_id;
119
+ fs.appendFileSync(listFile, JSON.stringify(entry) + '\n');
120
+
121
+ if (isNew) pruneOld(sessionsDir, listFile);
122
+ }
123
+
124
+ function handleUserPromptSubmit(input) {
125
+ const prompt = String(input.prompt || '').trim();
126
+ if (!COMMIT_PROMPT_RE.test(prompt)) return;
127
+ process.stdout.write('TOGA session id: ' + (input.session_id || 'unknown') +
128
+ ' (transcript: ' + (input.transcript_path || 'unknown') + ')\n');
129
+ }
130
+
131
+ function main() {
132
+ if (process.env.TOGA_SESSION_FILES_DISABLED === '1') return;
133
+ const input = readInput();
134
+ if (!input) return;
135
+ if (input.hook_event_name === 'UserPromptSubmit') return handleUserPromptSubmit(input);
136
+ if (input.hook_event_name === 'PostToolUse') return handlePostToolUse(input);
137
+ }
138
+
139
+ try {
140
+ main();
141
+ } catch (e) {
142
+ /* Never block or break a session over bookkeeping. */
143
+ }
144
+ process.exitCode = 0; /* not process.exit() — let stdout flush */
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: commit
3
+ description: Commit ONLY the files this Claude Code session changed, in every repo it touched, and write the session's notes (why the task exists, what was found, approach, turned-down options, who decided) into the commit message so the PR reviewer can build the PR summary from it. Safe when several sessions work on the same branch in the same checkout. Never pushes. Trigger on "/commit", "commit this", "commit my changes".
4
+ ---
5
+
6
+ # /commit — commit this session's files, with the session's notes
7
+
8
+ Helper: `node "$CLAUDE_PROJECT_DIR/.claude/skills/commit/scripts/commit-helper.js" <command>`
9
+ (below: `helper <command>`). It prints JSON. The edit lists come from the
10
+ `session-files.js` hook, stored in `<repo>/.git/claude-sessions/<sessionId>.jsonl`.
11
+
12
+ ## Step 0 — Rules
13
+
14
+ - Running `/commit` IS the developer's explicit commit authorization — **for these commits only**.
15
+ - Never push. Never `git add -A` or `git add .`. Never `--no-verify`. Never `--amend`.
16
+ Never switch branches. Never commit a file the developer did not approve.
17
+
18
+ ## Step 1 — Find your own session id
19
+
20
+ - Look in context for the line `TOGA session id: <id> (transcript: <path>)` — the hook adds it
21
+ when the prompt starts with `/commit`. Use that id and transcript path.
22
+ - If it is missing: run `helper sessions`. Show the developer each list (repo, branch, file
23
+ count, first/last time, first message). Ask which one is theirs. Use its `transcriptPath`.
24
+
25
+ ## Step 2 — See what will be committed
26
+
27
+ Run `helper status --session=<id>`. For each repo, tell the developer in plain words:
28
+
29
+ 1. **Will commit:** the `toCommit` files.
30
+ 2. **Other changed files, not from this session:** the `otherChanged` files (hand edits,
31
+ lockfiles, generated code, files changed by shell commands). Include them **only if the
32
+ developer says so**. Note any included ones under "Manual changes".
33
+ 3. **Overlap warning** for each `overlaps` file: "`X` was also changed by another session —
34
+ committing it includes their changes too." Ask before including it.
35
+ 4. **Branch mismatch:** `branchMismatch` files were last edited on another branch
36
+ (`entryBranch`). They are NOT in `toCommit`. If the list is not empty, **stop** for that
37
+ repo: say which files were edited on which branch, and ask the developer to switch
38
+ branches themselves.
39
+ 5. **Possibly yours from before a resume:** list `sameBranchOtherSessions` (time, file count).
40
+ If the developer says one is theirs, run `status` with that id too and merge the lists.
41
+ 6. **Ticket:** use `ticket` from the branch name. If it is `null`, ask the developer for it.
42
+
43
+ `alreadyClean` files have no change right now (committed, reverted, or stashed). Do not commit
44
+ them. They stay in the list, so a `git stash pop` brings them back into `toCommit`.
45
+
46
+ `repos[]` entries with an `error` field could not be read — tell the developer the error.
47
+
48
+ ## Step 3 — Checks before committing
49
+
50
+ On the files you will commit:
51
+
52
+ 1. `php -l <file>` on every PHP file. If `php` is not on PATH, say so — do not skip silently.
53
+ 2. After staging, read `git -C <repo> diff --cached` and look for:
54
+ - secrets: keys, tokens, passwords, connection strings, `.env` files;
55
+ - absolute local paths (`C:\`, `D:\`, `/Users/`);
56
+ - commented-out code.
57
+ 3. Confirm the branch is the ticket branch.
58
+
59
+ Fix what you can. If you cannot, unstage (`git -C <repo> restore --staged -- <files>`), stop,
60
+ and tell the developer.
61
+
62
+ ## Step 4 — Get the developer's own words
63
+
64
+ Run `helper devwords --transcript=<path>`. Use it to:
65
+
66
+ - fill "Developer's words" with a short real quote;
67
+ - decide honestly who made each decision. A decision is the **developer's** only if the
68
+ developer said it or approved it in their own message. Otherwise it is **Claude alone**.
69
+
70
+ If you still do not know why the task exists, ask the developer once, in one plain question.
71
+
72
+ ## Step 5 — Write the message and commit, per repo
73
+
74
+ Subject rules (api2 CI checks this; use it in every repo): `<TICKET>: <Subject>` — ticket from
75
+ the branch (`^[A-Z]+-\d+`), subject starts with a capital letter, imperative ("Use", "Add",
76
+ "Fix" — not "Used/Added/Fixed"), max 80 characters, no period at the end, more than one word.
77
+
78
+ Write the message to a temp file **outside every repo** — the OS temp dir (`$TMPDIR`, `$TEMP`,
79
+ or `os.tmpdir()`), named like `toga-commit-<session id>-<repo name>.txt`. Never inside the repo,
80
+ so it can never be staged. Use this format EXACTLY:
81
+
82
+ ```
83
+ TRUE-79868: Use client default tax code when invoice tax code is blank
84
+
85
+ Why this exists: <problem + scope in 1-3 plain lines — why the ticket exists, what is and isn't in scope>
86
+ Developer's words: "<short quote of how the developer described the task>"
87
+ Found: <what was found, 1-3 lines>
88
+ Approach: <what this commit does in THIS repo and why, 1-3 lines>
89
+ Turned down: <options considered and rejected, and why — or "none">
90
+ Decisions:
91
+ - <decision> (decided by: developer | Claude alone)
92
+ Manual changes: <files the developer changed by hand and asked to include — or "none">
93
+
94
+ Claude-Notes: v1
95
+ Claude-Session: <session id>
96
+ ```
97
+
98
+ - Same "Why this exists" and "Developer's words" in every repo of the same ticket.
99
+ "Approach" and "Decisions" are specific to each repo.
100
+ - 5-15 body lines. Plain everyday words. No jargon. No arrow chains.
101
+ - Small follow-up commit on the same ticket: keep every heading; one line each is fine.
102
+
103
+ `devwords` already replaces token shapes and `password=...` values with `[redacted]`. Still,
104
+ before you commit, read the finished message and check it for:
105
+
106
+ - secrets: API keys (`sk-ant-`, `ghp_`, `github_pat_`, `AKIA`), private keys, passwords,
107
+ tokens, connection strings — anything that looks like one, even `[redacted]` leftovers;
108
+ - absolute local paths (`C:\`, `D:\`, `/Users/`, `/home/`).
109
+
110
+ Remove or reword anything you find. The message goes into git history for good.
111
+
112
+ Then, per repo:
113
+
114
+ 1. `git -C <repo> add -- <exact files>`
115
+ 2. `git -C <repo> commit -F <temp file>`
116
+ 3. Delete the temp file (also when the commit fails).
117
+
118
+ ## Step 6 — After each commit
119
+
120
+ 1. `helper forget --session=<id> --repo=<repo> --files=<committed files, comma-separated>`
121
+ 2. Report per repo: short hash, subject, file count.
122
+ 3. Say: "Push when ready — /commit never pushes."
123
+ 4. Say: changes made by hand outside Claude can still be committed the normal way in VS Code;
124
+ the PR reviewer will show those as manual changes.
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* commit-helper.js — data helper for the /commit skill. No npm deps. Prints JSON.
4
+ *
5
+ * Reads the per-session edit lists written by scripts/hooks/session-files.js
6
+ * (<gitdir>/claude-sessions/<sessionId>.jsonl) and compares them with `git status`.
7
+ *
8
+ * status --session=<id> [--root=<dir>]
9
+ * sessions [--root=<dir>]
10
+ * devwords --transcript=<path> [--max=40]
11
+ * forget --session=<id> --repo=<path> --files=a,b (appends "forget" rows)
12
+ *
13
+ * List files are append-only: this helper never rewrites them (the hook may be appending).
14
+ *
15
+ * Git is called with execFileSync + argument arrays only (never a shell string).
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { execFileSync } = require('child_process');
20
+
21
+ const SESSIONS_DIR_NAME = 'claude-sessions';
22
+ const MAX_SESSION_ID_LENGTH = 64;
23
+ const DEFAULT_DEVWORDS_MAX = 40;
24
+ const DEVWORDS_TEXT_LIMIT = 500;
25
+ const FIRST_MESSAGE_LIMIT = 120;
26
+ const SHORT_SHA_LENGTH = 7;
27
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
28
+ const TICKET_PATTERN = /(?:^|[^A-Za-z0-9])([A-Z]+-\d+)/;
29
+ const IS_WINDOWS = process.platform === 'win32';
30
+ const TRANSCRIPT_HEAD_BYTES = 256 * 1024; /* `sessions` only needs the first human message */
31
+ const FORGET_TYPE = 'forget';
32
+ const REDACTED = '[redacted]';
33
+
34
+ /* Token shapes that must never reach git history through "Developer's words". Order matters:
35
+ * the most specific shapes run first. */
36
+ const SECRET_PATTERNS = [
37
+ /-----BEGIN [A-Z0-9 ]*KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*KEY-----|$)/g,
38
+ /sk-ant-[A-Za-z0-9_-]+/g,
39
+ /\bgh[pousr]_[A-Za-z0-9]+/g,
40
+ /\bgithub_pat_[A-Za-z0-9_]+/g,
41
+ /\bAKIA[0-9A-Z]{12,}/g,
42
+ /\beyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]+){0,2}/g,
43
+ /\b[0-9a-fA-F]{32,}\b/g,
44
+ ];
45
+ const SECRET_ASSIGNMENT_RE = /\b(password|passwd|secret|token|apikey|api_key)(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s,;&]+)/gi;
46
+ const LONG_RUN_RE = /[A-Za-z0-9+_-]{32,}={0,2}/g;
47
+
48
+ /* ---------- small utilities ---------- */
49
+
50
+ function parseArgs(argv) {
51
+ const out = { _: [] };
52
+ for (const a of argv) {
53
+ const m = a.match(/^--([^=]+)(?:=(.*))?$/);
54
+ if (m) out[m[1]] = m[2] === undefined ? true : m[2];
55
+ else out._.push(a);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function print(obj) {
61
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
62
+ }
63
+
64
+ function fail(message) {
65
+ print({ ok: false, error: message });
66
+ process.exitCode = 1;
67
+ }
68
+
69
+ function safeSessionId(id) {
70
+ return String(id || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, MAX_SESSION_ID_LENGTH);
71
+ }
72
+
73
+ /* Key used to compare repo-relative paths (Windows file names are case-insensitive). */
74
+ function fileKey(p) {
75
+ const norm = String(p).replace(/\\/g, '/').replace(/^\.\//, '');
76
+ return IS_WINDOWS ? norm.toLowerCase() : norm;
77
+ }
78
+
79
+ function git(repo, args) {
80
+ return execFileSync('git', ['-C', repo].concat(args), {
81
+ encoding: 'utf8',
82
+ maxBuffer: GIT_MAX_BUFFER,
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ windowsHide: true,
85
+ });
86
+ }
87
+
88
+ function isRepoDir(dir) {
89
+ try { return fs.existsSync(path.join(dir, '.git')); } catch (e) { return false; }
90
+ }
91
+
92
+ function listRepos(root) {
93
+ const repos = [];
94
+ if (isRepoDir(root)) repos.push(root);
95
+ let names = [];
96
+ try { names = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { names = []; }
97
+ for (const d of names) {
98
+ if (!d.isDirectory() || d.name === '.git') continue;
99
+ const full = path.join(root, d.name);
100
+ if (isRepoDir(full)) repos.push(full);
101
+ }
102
+ return repos;
103
+ }
104
+
105
+ function gitDirOf(repo) {
106
+ try { return path.resolve(git(repo, ['rev-parse', '--absolute-git-dir']).trim()); } catch (e) { return null; }
107
+ }
108
+
109
+ /* Branch name like the hook writes it: refs/heads/ stripped; detached HEAD → short sha. */
110
+ function currentBranch(repo) {
111
+ try {
112
+ const ref = git(repo, ['symbolic-ref', '-q', 'HEAD']).trim();
113
+ if (ref) return ref.replace(/^refs\/heads\//, '');
114
+ } catch (e) { /* detached HEAD: symbolic-ref exits 1 — fall back to the sha */ }
115
+ try {
116
+ return git(repo, ['rev-parse', 'HEAD']).trim().slice(0, SHORT_SHA_LENGTH);
117
+ } catch (e) {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function ticketFromBranch(branch) {
123
+ const m = String(branch || '').match(TICKET_PATTERN);
124
+ return m ? m[1] : null;
125
+ }
126
+
127
+ function readJsonl(file) {
128
+ let raw = '';
129
+ try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { return []; }
130
+ return parseJsonlText(raw);
131
+ }
132
+
133
+ /* List files are append-only (the hook appends while we read — never rewrite them).
134
+ * Fold rows in ts/file order: a `forget` row drops earlier edit rows for that file;
135
+ * a later edit row adds the file back. Returns the live edit rows. */
136
+ function foldEntries(rows) {
137
+ const sorted = rows
138
+ .map((row, i) => ({ row, i }))
139
+ .sort((a, b) => String(a.row.ts || '').localeCompare(String(b.row.ts || ''))
140
+ || fileKey(a.row.file).localeCompare(fileKey(b.row.file))
141
+ || a.i - b.i)
142
+ .map((x) => x.row);
143
+ const live = new Map();
144
+ for (const row of sorted) {
145
+ const key = fileKey(row.file);
146
+ if (row.type === FORGET_TYPE) { live.delete(key); continue; }
147
+ if (!live.has(key)) live.set(key, []);
148
+ live.get(key).push(row);
149
+ }
150
+ return [...live.values()].flat();
151
+ }
152
+
153
+ /* Newest edit row per file: Map(fileKey -> entry). */
154
+ function latestByFile(entries) {
155
+ const latest = new Map();
156
+ for (const e of entries) {
157
+ const key = fileKey(e.file);
158
+ const prev = latest.get(key);
159
+ if (!prev || String(e.ts || '') >= String(prev.ts || '')) latest.set(key, e);
160
+ }
161
+ return latest;
162
+ }
163
+
164
+ /* All session lists in one repo: Map(fileStem -> live entries[]), already folded. */
165
+ function readSessionLists(gitDir) {
166
+ const dir = path.join(gitDir, SESSIONS_DIR_NAME);
167
+ const lists = new Map();
168
+ let names = [];
169
+ try { names = fs.readdirSync(dir); } catch (e) { return null; }
170
+ for (const name of names) {
171
+ if (!name.endsWith('.jsonl')) continue;
172
+ const rows = readJsonl(path.join(dir, name)).filter((e) => e && typeof e.file === 'string');
173
+ lists.set(name.slice(0, -'.jsonl'.length), foldEntries(rows));
174
+ }
175
+ return lists;
176
+ }
177
+
178
+ function lastTs(entries) {
179
+ return entries.reduce((max, e) => (e.ts && e.ts > max ? e.ts : max), '') || null;
180
+ }
181
+
182
+ function firstTs(entries) {
183
+ return entries.reduce((min, e) => (e.ts && (!min || e.ts < min) ? e.ts : min), '') || null;
184
+ }
185
+
186
+ /* `git status --porcelain=v1 -z` → Map(fileKey -> {file, status}). Renames add both paths. */
187
+ function changedFiles(repo) {
188
+ const out = git(repo, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
189
+ const parts = out.split('\0');
190
+ const changed = new Map();
191
+ for (let i = 0; i < parts.length; i++) {
192
+ const item = parts[i];
193
+ if (!item || item.length < 4) continue;
194
+ const status = item.slice(0, 2);
195
+ const file = item.slice(3);
196
+ changed.set(fileKey(file), { file, status });
197
+ if (status[0] === 'R' || status[0] === 'C' || status[1] === 'R' || status[1] === 'C') {
198
+ const from = parts[i + 1];
199
+ i += 1;
200
+ if (from) changed.set(fileKey(from), { file: from, status: 'R-from' });
201
+ }
202
+ }
203
+ return changed;
204
+ }
205
+
206
+ function resolveRoot(args) {
207
+ return path.resolve(String(args.root || process.env.CLAUDE_PROJECT_DIR || process.cwd()));
208
+ }
209
+
210
+ /* ---------- transcript reading (format is internal to Claude Code — be tolerant) ---------- */
211
+
212
+ function isNoiseText(text) {
213
+ const t = text.trim();
214
+ if (!t) return true;
215
+ if (t.startsWith('<')) return true; // <system-reminder>, <command-name>, <local-command-stdout>...
216
+ if (t.startsWith('Caveat:')) return true;
217
+ if (t.startsWith('[Request interrupted')) return true;
218
+ if (/^TOGA session id:/.test(t)) return true;
219
+ return false;
220
+ }
221
+
222
+ function humanTexts(row) {
223
+ if (!row || row.type !== 'user') return [];
224
+ if (row.isMeta === true || row.isSidechain === true || row.toolUseResult !== undefined) return [];
225
+ const msg = row.message;
226
+ if (!msg) return [];
227
+ const content = msg.content;
228
+ const texts = [];
229
+ if (typeof content === 'string') {
230
+ texts.push(content);
231
+ } else if (Array.isArray(content)) {
232
+ for (const item of content) {
233
+ if (item && item.type === 'text' && typeof item.text === 'string') texts.push(item.text);
234
+ }
235
+ }
236
+ return texts
237
+ .filter((t) => !isNoiseText(t))
238
+ .map((t) => redactSecrets(t.trim()).slice(0, DEVWORDS_TEXT_LIMIT));
239
+ }
240
+
241
+ /* Replace token shapes and `password=...`-style values with [redacted]. */
242
+ function redactSecrets(text) {
243
+ let out = String(text);
244
+ for (const re of SECRET_PATTERNS) out = out.replace(re, REDACTED);
245
+ out = out.replace(SECRET_ASSIGNMENT_RE, (m, name, sep) => name + sep + REDACTED);
246
+ out = out.replace(LONG_RUN_RE, (m) => (/[0-9]/.test(m) && /[A-Za-z]/.test(m) ? REDACTED : m));
247
+ return out;
248
+ }
249
+
250
+ function parseJsonlText(raw) {
251
+ const rows = [];
252
+ for (const line of raw.split(/\r?\n/)) {
253
+ if (!line.trim()) continue;
254
+ try { rows.push(JSON.parse(line)); } catch (e) { /* skip a bad or cut-off line */ }
255
+ }
256
+ return rows;
257
+ }
258
+
259
+ /* First human message, reading only the head of the transcript (they can be very large). */
260
+ function readFirstHumanMessage(transcriptPath) {
261
+ let fd = null;
262
+ try {
263
+ fd = fs.openSync(transcriptPath, 'r');
264
+ const buf = Buffer.alloc(TRANSCRIPT_HEAD_BYTES);
265
+ const bytes = fs.readSync(fd, buf, 0, TRANSCRIPT_HEAD_BYTES, 0);
266
+ for (const row of parseJsonlText(buf.toString('utf8', 0, bytes))) {
267
+ let texts = [];
268
+ try { texts = humanTexts(row); } catch (e) { texts = []; }
269
+ if (texts.length) return texts[0].slice(0, FIRST_MESSAGE_LIMIT);
270
+ }
271
+ return null;
272
+ } catch (e) {
273
+ return null;
274
+ } finally {
275
+ if (fd !== null) {
276
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
277
+ }
278
+ }
279
+ }
280
+
281
+ function readDevWords(transcriptPath) {
282
+ const words = [];
283
+ for (const row of readJsonl(transcriptPath)) {
284
+ let texts = [];
285
+ try { texts = humanTexts(row); } catch (e) { texts = []; }
286
+ for (const text of texts) words.push({ ts: row.timestamp || null, text });
287
+ }
288
+ return words;
289
+ }
290
+
291
+ /* ---------- commands ---------- */
292
+
293
+ function cmdStatus(args) {
294
+ const sessionId = safeSessionId(args.session);
295
+ if (!sessionId) return fail('missing --session=<id>');
296
+ const root = resolveRoot(args);
297
+ const repos = [];
298
+
299
+ for (const repo of listRepos(root)) {
300
+ try {
301
+ const result = statusForRepo(repo, sessionId);
302
+ if (result) repos.push(result);
303
+ } catch (e) {
304
+ repos.push({ repo, error: String((e && e.message) || e) });
305
+ }
306
+ }
307
+ print({ ok: true, session: sessionId, root, repos });
308
+ }
309
+
310
+ /* One repo's status for a session, or null when the session has nothing here. Read-only. */
311
+ function statusForRepo(repo, sessionId) {
312
+ const gitDir = gitDirOf(repo);
313
+ if (!gitDir) return null;
314
+ const lists = readSessionLists(gitDir);
315
+ if (!lists) return null;
316
+
317
+ const branch = currentBranch(repo);
318
+ const changed = changedFiles(repo);
319
+ const mine = lists.get(sessionId) || [];
320
+ const others = [...lists.entries()].filter(([id]) => id !== sessionId);
321
+
322
+ const anyListKeys = new Set();
323
+ for (const [, entries] of lists) for (const e of entries) anyListKeys.add(fileKey(e.file));
324
+
325
+ /* Judge each file by its LATEST entry. alreadyClean is computed here only — the list
326
+ * is never rewritten, so a stash/pop or revert does not lose the record. */
327
+ const toCommit = [];
328
+ const alreadyClean = [];
329
+ const branchMismatch = [];
330
+ for (const [key, latest] of latestByFile(mine)) {
331
+ const change = changed.get(key);
332
+ if (!change) { alreadyClean.push(latest.file); continue; }
333
+ if (latest.branch && branch && latest.branch !== branch) {
334
+ branchMismatch.push({ file: change.file, status: change.status, entryBranch: latest.branch });
335
+ continue;
336
+ }
337
+ toCommit.push(change.file);
338
+ }
339
+
340
+ const otherChanged = [...changed.entries()]
341
+ .filter(([key, v]) => !anyListKeys.has(key) && v.status !== 'R-from')
342
+ .map(([, v]) => ({ file: v.file, status: v.status }));
343
+
344
+ const overlaps = [];
345
+ for (const file of toCommit) {
346
+ const key = fileKey(file);
347
+ const by = others
348
+ .map(([id, entries]) => ({ id, hits: entries.filter((e) => fileKey(e.file) === key) }))
349
+ .filter((o) => o.hits.length)
350
+ .map((o) => ({ sessionId: o.id, lastTs: lastTs(o.hits) }));
351
+ if (by.length) overlaps.push({ file, otherSessions: by });
352
+ }
353
+
354
+ const sameBranchOtherSessions = others
355
+ .map(([id, entries]) => ({ id, onBranch: entries.filter((e) => e.branch === branch) }))
356
+ .filter((o) => o.onBranch.length)
357
+ .map((o) => ({
358
+ sessionId: o.id,
359
+ lastTs: lastTs(o.onBranch),
360
+ fileCount: new Set(o.onBranch.map((e) => fileKey(e.file))).size,
361
+ }));
362
+
363
+ if (!mine.length && !sameBranchOtherSessions.length) return null;
364
+
365
+ return {
366
+ repo,
367
+ branch,
368
+ ticket: ticketFromBranch(branch),
369
+ toCommit,
370
+ alreadyClean,
371
+ otherChanged,
372
+ overlaps,
373
+ branchMismatch,
374
+ sameBranchOtherSessions,
375
+ };
376
+ }
377
+
378
+ function cmdSessions(args) {
379
+ const root = resolveRoot(args);
380
+ const sessions = [];
381
+ const firstMessageCache = new Map();
382
+ for (const repo of listRepos(root)) {
383
+ const gitDir = gitDirOf(repo);
384
+ if (!gitDir) continue;
385
+ const lists = readSessionLists(gitDir);
386
+ if (!lists) continue;
387
+ for (const [id, entries] of lists) {
388
+ if (!entries.length) continue; /* every file was forgotten (committed) */
389
+ const transcript = (entries.find((e) => e.transcriptPath) || {}).transcriptPath || null;
390
+ let firstMessage = null;
391
+ if (transcript) {
392
+ if (!firstMessageCache.has(transcript)) {
393
+ firstMessageCache.set(transcript, readFirstHumanMessage(transcript));
394
+ }
395
+ firstMessage = firstMessageCache.get(transcript);
396
+ }
397
+ sessions.push({
398
+ sessionId: (entries[0] && entries[0].sessionId) || id,
399
+ repo,
400
+ branches: [...new Set(entries.map((e) => e.branch).filter(Boolean))],
401
+ fileCount: new Set(entries.map((e) => fileKey(e.file))).size,
402
+ firstTs: firstTs(entries),
403
+ lastTs: lastTs(entries),
404
+ transcriptPath: transcript,
405
+ firstMessage,
406
+ });
407
+ }
408
+ }
409
+ sessions.sort((a, b) => String(b.lastTs).localeCompare(String(a.lastTs)));
410
+ print({ ok: true, root, sessions });
411
+ }
412
+
413
+ function cmdDevwords(args) {
414
+ if (!args.transcript || args.transcript === true) return fail('missing --transcript=<path>');
415
+ const transcript = path.resolve(String(args.transcript));
416
+ if (!fs.existsSync(transcript)) return fail('transcript not found: ' + transcript);
417
+ const max = Math.max(1, parseInt(args.max, 10) || DEFAULT_DEVWORDS_MAX);
418
+ const words = readDevWords(transcript);
419
+ let messages = words;
420
+ let skipped = 0;
421
+ if (words.length > max) {
422
+ const head = Math.ceil(max / 2);
423
+ const tail = max - head;
424
+ messages = words.slice(0, head).concat(tail ? words.slice(-tail) : []);
425
+ skipped = words.length - max;
426
+ }
427
+ print({ ok: true, transcript, total: words.length, skippedInMiddle: skipped, messages });
428
+ }
429
+
430
+ function cmdForget(args) {
431
+ const sessionId = safeSessionId(args.session);
432
+ if (!sessionId) return fail('missing --session=<id>');
433
+ if (!args.repo || args.repo === true) return fail('missing --repo=<path>');
434
+ if (!args.files || args.files === true) return fail('missing --files=a,b');
435
+ const repo = path.resolve(String(args.repo));
436
+ const gitDir = gitDirOf(repo);
437
+ if (!gitDir) return fail('not a git repo: ' + repo);
438
+ const files = [...new Set(String(args.files).split(',').map((f) => f.trim().replace(/\\/g, '/')).filter(Boolean))];
439
+ if (!files.length) return fail('missing --files=a,b');
440
+ const sessionsDir = path.join(gitDir, SESSIONS_DIR_NAME);
441
+ const listFile = path.join(sessionsDir, sessionId + '.jsonl');
442
+ /* Append tombstones only — never rewrite the list (the hook may be appending right now). */
443
+ const ts = new Date().toISOString();
444
+ fs.mkdirSync(sessionsDir, { recursive: true });
445
+ fs.appendFileSync(listFile, files.map((file) => JSON.stringify({ type: FORGET_TYPE, file, ts })).join('\n') + '\n');
446
+ const live = foldEntries(readJsonl(listFile).filter((e) => e && typeof e.file === 'string'));
447
+ print({
448
+ ok: true,
449
+ repo,
450
+ session: sessionId,
451
+ forgotten: files,
452
+ remainingFiles: new Set(live.map((e) => fileKey(e.file))).size,
453
+ });
454
+ }
455
+
456
+ const COMMANDS = { status: cmdStatus, sessions: cmdSessions, devwords: cmdDevwords, forget: cmdForget };
457
+
458
+ function main() {
459
+ const args = parseArgs(process.argv.slice(2));
460
+ const cmd = COMMANDS[args._[0]];
461
+ if (!cmd) return fail('usage: commit-helper.js <status|sessions|devwords|forget> [--options]');
462
+ try {
463
+ cmd(args);
464
+ } catch (e) {
465
+ fail(String((e && e.message) || e));
466
+ }
467
+ }
468
+
469
+ main();
@@ -141,9 +141,9 @@ For **each repo in the plan's `Repos:` header** that has changes, run inside tha
141
141
  1. **Verify the branch.** Confirm `git branch --show-current` is the `TRUE-XXXX` branch from
142
142
  Step 3 — if the repo somehow ended up back on a default branch, go redo Step 3 for it before
143
143
  committing anything.
144
- 2. **Commit.** Stage only the files the plan changed. Message format `type: short description`
145
- (`feat`/`fix`/`refactor`/`docs`/`test`/`chore`, lowercase, present tense, ≤72 chars). Add a
146
- body paragraph for substantial changes. **Pre-commit check:** `php -l` passes on changed PHP,
144
+ 2. **Commit.** Stage only the files the plan changed. Build the commit message exactly as the
145
+ `/commit` skill's format (`skills/commit/SKILL.md` Step 5: `<TICKET>: <Subject>` subject,
146
+ the notes body, and the `Claude-Notes: v1` / `Claude-Session:` trailer). **Pre-commit check:** `php -l` passes on changed PHP,
147
147
  no commented-out code, no credentials or local paths, branch name correct.
148
148
  3. **Push** the branch to origin: `git -C "<repo-path>" push -u origin <TICKET>`.
149
149