toga-ai 1.0.967 → 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.
- package/.claude/settings.json +19 -0
- package/knowledge/1.0/apps/library/INDEX.md +1 -0
- package/knowledge/1.0/apps/library/features/email-ses-sender-identity.md +42 -0
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/compass-canada/profile.md +15 -1
- package/package.json +1 -1
- package/rules/common/git-workflow.md +14 -10
- package/scripts/ci/pr-risk.js +262 -0
- package/scripts/hooks/session-files.js +144 -0
- package/skills/commit/SKILL.md +124 -0
- package/skills/commit/scripts/commit-helper.js +469 -0
- package/skills/work-ticket/SKILL.md +3 -3
package/.claude/settings.json
CHANGED
|
@@ -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": [
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
| [Diagnostic Dialog — View Recommended Services Routing](features/diagnostic-dialog-view-recommended-services.md) | TOGa Refresh 2026 "View Recommended Services" routing — per-device diagnostic `resultCode` decides serviceselection vs techsupport; open when touching that rout |
|
|
13
13
|
| [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | Elite bidirectional ticket/attachment sync between TOGA 2 and TOGaDesk (`App_Api_Toga2::syncWithTogadesk`); open for the sync mechanics, watermarks, and Elite-s |
|
|
14
14
|
| [App_Email Queued Sending & Attachments (Common.EmailsQueued)](features/email-queue-attachments.md) | 1.0 `App_Email` queued sending + attachment embedding via `Common.EmailsQueued`; open when a queued email loses attachments or when deploying worker/togadesk to |
|
|
15
|
+
| [App_Email SES Sender Identity (every From address must be verified in SES first)](features/email-ses-sender-identity.md) | 1.0 `App_Email` sends through Amazon SES SMTP; any new From address must be a verified SES identity first or SES rejects the send. |
|
|
15
16
|
| [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | Branded HTML email base class `App_Email_Template` (header/body/footer); open when building or fixing a 1.0 branded email or its Outlook rendering. |
|
|
16
17
|
| [App_Email Side Effects & Test Mode (how to send a real email that writes nothing)](features/email-test-mode-and-write-free-sends.md) | 1.0 `App_Email::send()` side effects (log/queue writes, test-mode recipient rewrite) and how to send a genuinely write-free test; open before writing any 1.0 dr |
|
|
17
18
|
| [Error Capture in 1.0 (App_Error_Capture → shared 2.0 Logs DB)](features/error-capture-1-0.md) | 1.0 error-reporting into the shared 2.0 Logs DB (`App_Error_Capture::captureException`), mirroring 2.0's Issue/Event; open when adding a 1.0 capture path or deb |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: App_Email SES Sender Identity (every From address must be verified in SES first)
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: library
|
|
5
|
+
project: Library
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-10-07
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- library/app/email.php
|
|
13
|
+
related:
|
|
14
|
+
- ./email-queue-attachments.md
|
|
15
|
+
- ./email-test-mode-and-write-free-sends.md
|
|
16
|
+
- ../../tools/features/legacy-email-notifier.md
|
|
17
|
+
- ../../test/features/goagilant-to-togatech-email-migration.md
|
|
18
|
+
- ../../../../clients/compass-canada/profile.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
1.0 `App_Email` sends through Amazon SES SMTP; any new From address must be a verified SES identity first or SES rejects the send. Open before changing a `sendFromEmailAddress` / `fromEmailAddress` to a new address or a customer domain.
|
|
22
|
+
|
|
23
|
+
## Summary
|
|
24
|
+
|
|
25
|
+
`App_Email` (`library/app/email.php`) sends via PHPMailer to SES SMTP at `email-smtp.us-west-2.amazonaws.com`, in the **toga-legacy** AWS account, region **us-west-2** (not us-east-1). SES only accepts a From address that is a verified identity in that account and region. The account is out of the SES sandbox, so To/CC recipients need no verification. Only the sender does.
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
- **Domain identity:** `togatech.com` is verified as a whole domain, so any `@togatech.com` From works.
|
|
30
|
+
- **Customer-domain addresses are single EMAIL_ADDRESS identities**, not domains. Verified so far: `techhub@compass-usa.com`, `Techhub.Canada@compass-canada.com`.
|
|
31
|
+
- **Verifying one address:** SES mails a link from `no-reply-aws@amazon.com`, subject "Amazon Web Services – Email Address Verification Request in region US West (Oregon)". The customer's mailbox owner must click it within **24h**. Until then the identity is pending and sends fail.
|
|
32
|
+
- **Alternative:** a domain identity for the customer domain. Needs the customer's IT to add DKIM DNS records. Use it when several addresses on that domain will send.
|
|
33
|
+
- **Order of work:** verify the identity in SES, confirm it shows verified, *then* run the SQL that switches `EmailTemplates.sendFromEmailAddress` / `IntegrationsEmail.fromEmailAddress`. Flipping the data first breaks every send from those templates.
|
|
34
|
+
|
|
35
|
+
## Gotchas
|
|
36
|
+
|
|
37
|
+
- **The AWS verification email often lands in the customer's quarantine or spam.** Tell the mailbox owner to look there, and to release it within the 24h window. If it expires, re-send the verification.
|
|
38
|
+
- **A verified customer address is not proof it is used as a sender.** Compass USA vendor PO emails send from `compasssupport@togatech.com` (`Client_Compass.IntegrationsEmail`, 6 rows). `techhub@compass-usa.com` appears there only as To/CC.
|
|
39
|
+
- Apps that cannot run `App_Email::send()` (the tools app) still hit SES with the same rule. See [legacy email notifier](../../tools/features/legacy-email-notifier.md).
|
|
40
|
+
|
|
41
|
+
## Change history
|
|
42
|
+
- 2026-10-07 — Documented the SES sender-identity rule and customer-address verification flow, after moving the Compass Canada TechHub sender to `Techhub.Canada@compass-canada.com` (bala)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
4
4
|
|
|
5
5
|
## 1.0 framework
|
|
6
6
|
|
|
7
|
-
- **library** (Library) _(framework core)_ —
|
|
7
|
+
- **library** (Library) _(framework core)_ — 33 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
|
|
8
8
|
- **worker** (Worker) — 42 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
|
|
9
9
|
- **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
|
|
10
10
|
- **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
|
|
@@ -18,7 +18,7 @@ project: _Underscore
|
|
|
18
18
|
client: compass-canada
|
|
19
19
|
type: profile
|
|
20
20
|
status: active
|
|
21
|
-
updated: 2026-10-
|
|
21
|
+
updated: 2026-10-07
|
|
22
22
|
owners: [jcardinal, bala, tcox, apeterson, ajean, mhammontree, rgirish]
|
|
23
23
|
files: []
|
|
24
24
|
related:
|
|
@@ -43,6 +43,7 @@ related:
|
|
|
43
43
|
- ../compass-usa/features/saml-sso.md
|
|
44
44
|
- ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
|
|
45
45
|
- ../../2.0/apps/_underscore/features/surface-resolver.md
|
|
46
|
+
- ../../1.0/apps/library/features/email-ses-sender-identity.md
|
|
46
47
|
---
|
|
47
48
|
|
|
48
49
|
## Summary
|
|
@@ -81,6 +82,19 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
|
|
|
81
82
|
CXML). Prod `Vendors` 4 COMPASS CANADA and 5 AGILANT SOLUTIONS INC have no integration, and the 1.0
|
|
82
83
|
constants for them (`'2'`, `'3'`) do not match prod. PO transmit cron quirks and the OneUptime monitor
|
|
83
84
|
(cron 87): [PO-to-vendor monitor](features/oneuptime-po-transmissions-to-vendors-monitor.md).
|
|
85
|
+
- **TechHub sender address is `Techhub.Canada@compass-canada.com` (prod, 2026-10-07).** Was
|
|
86
|
+
`TechHubCanada@togatech.com`. Changed on 28 `EmailTemplates.sendFromEmailAddress` rows (EN+FR
|
|
87
|
+
order / approval / transit / delivered / reminder / auto-cancel) and `IntegrationsEmail` id 1
|
|
88
|
+
(integration 2, "Grand & Toy - Email") by
|
|
89
|
+
`dbchanges2/Client_CompassCanada/2026-10-07a - TechHubCanadaFromEmail.sql`. Kept on togatech.com
|
|
90
|
+
on purpose: Request Submitted (`compasssupport@`), Get Support (`donotreply@`), Forgot Password x2
|
|
91
|
+
(`noreply@`). It is a single verified SES identity, so any new sender needs SES verification
|
|
92
|
+
first: [SES sender identity](../../1.0/apps/library/features/email-ses-sender-identity.md).
|
|
93
|
+
The hardcoded TechHub CC in 4 worker crons had a typo (`TechHub@compass-canada.com`, missing
|
|
94
|
+
`.Canada`); now `Techhub.Canada@compass-canada.com` in `workflow/2_transmit_mits_purchase_orders_to_vendors.php`
|
|
95
|
+
(G&T PO email CC), `workflow_beta/2_…`, `compass_email_reminders.php` and
|
|
96
|
+
`compass_cancel_pending_approval_orders.php` (all under `worker/crons/toga2/compasscanada/`).
|
|
97
|
+
**Not yet deployed.**
|
|
84
98
|
- **Grand & Toy (G&T)** — primary hardware vendor. SOs flow toga → MITS → PO to G&T; G&T sends
|
|
85
99
|
back ASNs. ASN ingestion (email CSV + the auto-created ItemFulfillment chain + bilingual
|
|
86
100
|
in-transit email) is documented in [Grand & Toy ASN Import](features/grand-and-toy-asn-import.md).
|
package/package.json
CHANGED
|
@@ -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:
|
|
116
|
+
Format: `<TICKET>: <Subject>` — the same in every repo. It matches api2's CI check.
|
|
117
117
|
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
145
|
-
|
|
146
|
-
body
|
|
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
|
|