@ggdna/dna-gg 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,32 @@ All skills of this layer are prefixed with `gg-`:
33
33
  - `/gg-publish` — releases the ticket
34
34
  - `/gg-cleanup` — removes what the ticket left behind
35
35
 
36
+ ## Hooks
37
+
38
+ - `dna/dot-claude/settings.json` — runs `.claude/hooks/pm-prompts.mjs` on
39
+ the Claude Code hooks `SessionStart`, `UserPromptSubmit` and `SessionEnd`
40
+ - `dna/dot-claude/hooks/pm-prompts.mjs` — keeps every prompt written to
41
+ Claude Code in the project management repo `dnaPmRepo` names. The
42
+ prompts wait in `~/.claude/pm-prompts/sessions/` and are committed to the
43
+ branch `prompts` of that repo when a session starts or ends, under
44
+ `doc/prompts/<first name>/<yyyy-mm>/<yyyy-mm-dd>_<repo>_<session>.md`.
45
+ The first name comes from the `Git` line of `doc/team/people.md` there,
46
+ an unknown email goes to `doc/prompts/_unknown/`. Only the prompts are
47
+ kept, never the answers; tokens and passwords are replaced by
48
+ `[redacted]`. No working tree is touched: the commit is built in a bare
49
+ cache below `~/.claude/pm-prompts/repos/`. Needs `node` and `git`; a
50
+ failure never stops a prompt and ends up in
51
+ `~/.claude/pm-prompts/sync.log`.
52
+ - `dna/dot-claude/hooks/pm-prompts.json` — the name of the project
53
+ management repo, `dnaPmRepo`. Empty, nothing is kept. In a folder that
54
+ holds several repos, like a gg ticket, the prompts go to the project
55
+ management repos those repos name.
56
+
57
+ A repo with a `.claude/settings.json` of its own moves its settings to
58
+ `dna/dot-claude/settings.overrides.json`, otherwise the DNA overwrites
59
+ them. Arrays are appended with a `+` after the key, e.g.
60
+ `"SessionStart+": [ … ]` below `"hooks"`.
61
+
36
62
  ## Scripts
37
63
 
38
64
  - `dna/scripts/delete-feature-branch.js` — the one script this layer calls
@@ -65,6 +91,9 @@ appends the `Workspace` section.
65
91
  - `dnaJiraPrefix` — the prefix of a ticket id
66
92
  - `dnaGitQuickCheckPipelineName` — the name the branch rules require
67
93
  - `dnaPubDevPublisher` — the pub.dev publisher packages are transferred to
94
+ - `dnaPmRepo` — the project management repo the prompts go to: a repo
95
+ name beside the repo's own origin, or a full git URL. Empty by default,
96
+ then no prompt is kept
68
97
 
69
98
  `dnaCopyrightHolder`, `dnaCompany` and `dnaJiraPrefix` name the
70
99
  organization, so they are never inherited: every layer or project listing
@@ -39,7 +39,7 @@
39
39
  "ecosystem": null,
40
40
  "resolvedVersion": null,
41
41
  "via": null,
42
- "hash": "0x0c681b9f487d5439"
42
+ "hash": "0x5cf663c108f9dc04"
43
43
  }
44
44
  ],
45
45
  "claude": {
@@ -49,6 +49,18 @@
49
49
  "baseVersion": "1.13.0",
50
50
  "baseHash": "0x6883da335b415f9d",
51
51
  "instances": [
52
+ {
53
+ "path": ".claude/hooks/pm-prompts.json",
54
+ "hash": "0x988e344de6ee6200"
55
+ },
56
+ {
57
+ "path": ".claude/hooks/pm-prompts.mjs",
58
+ "hash": "0xd579ad4abeb87732"
59
+ },
60
+ {
61
+ "path": ".claude/settings.json",
62
+ "hash": "0x6c6b768c3e99e867"
63
+ },
52
64
  {
53
65
  "path": ".claude/skills/gg-cleanup/SKILL.md",
54
66
  "hash": "0x4a6903a51168486a"
@@ -71,7 +83,7 @@
71
83
  },
72
84
  {
73
85
  "path": ".claude/skills/gg-ticket/SKILL.md",
74
- "hash": "0xe2e320fd71159027"
86
+ "hash": "0xa238364995ac50f8"
75
87
  },
76
88
  {
77
89
  "path": ".claude/skills/gg/SKILL.md",
@@ -91,7 +103,7 @@
91
103
  },
92
104
  {
93
105
  "path": "doc/guides/develop-guide.md",
94
- "hash": "0x84e26705681de36f"
106
+ "hash": "0x39718de56fdb48f0"
95
107
  },
96
108
  {
97
109
  "path": "doc/guides/develop-without-ai-guide.md",
@@ -99,7 +111,7 @@
99
111
  },
100
112
  {
101
113
  "path": "doc/guides/for-ai/ai-dev-guide.md",
102
- "hash": "0x89583fdaaa73aa60"
114
+ "hash": "0xb4ae698a82bc9e2c"
103
115
  },
104
116
  {
105
117
  "path": "doc/guides/index-guide.md",
@@ -135,7 +147,7 @@
135
147
  },
136
148
  {
137
149
  "path": "doc/guides/install-guide/install-gg-workspace-guide.md",
138
- "hash": "0xc06dc45d20e1bf79"
150
+ "hash": "0x7fca95fb9895a191"
139
151
  },
140
152
  {
141
153
  "path": "doc/guides/install-guide/install-github-cli-guide.md",
package/dna/_vars.json CHANGED
@@ -5,5 +5,6 @@
5
5
  "dnaGitOrgUrl": "https://github.com/dnaGitOrg",
6
6
  "dnaGitQuickCheckPipelineName": "Quick Check",
7
7
  "dnaJiraPrefix": "GGS",
8
+ "dnaPmRepo": "",
8
9
  "dnaPubDevPublisher": "inlavigo.com"
9
10
  }
@@ -41,3 +41,12 @@ Use the following skills in Claude
41
41
  ```
42
42
 
43
43
  `/gg` lists the skills of the workflow and says which one comes next.
44
+
45
+ ## Your prompts
46
+
47
+ When the repo belongs to a project management repo, every prompt you write
48
+ to Claude Code is kept there, under `doc/prompts/<your first name>/` on the
49
+ branch `prompts`. Only the prompts, not the answers. Tokens and passwords
50
+ are replaced by `[redacted]`. Your first name comes from the `Git` line of
51
+ `doc/team/people.md` in the project management repo; add your git email
52
+ there, or your prompts land in `doc/prompts/_unknown/`.
@@ -96,13 +96,20 @@ gg do code
96
96
 
97
97
  ## Plan (optional)
98
98
 
99
+ Requirements come before the plan. If the project management repo keeps
100
+ requirements (`doc/requirements`) and none of its accepted requirements
101
+ covers the ticket, engineer them with the user first — with
102
+ `/plan-requirements` if the repo has it — and plan the ticket only after
103
+ that. Bug tickets are the exception, they name their bug file.
104
+
99
105
  Ask the user whether the ticket is planned before it is implemented.
100
106
 
101
107
  If yes, write the plan into the project management repo, following its
102
108
  `doc/guides/pm-repo-guide.md` if present, otherwise its other guides: the
103
- goal, the affected repos, the rough steps and open questions. Without a
104
- project management repo, put the plan into the ticket itself. Let the user
105
- review the plan and revise it until they confirm it.
109
+ goal, the requirements it fulfils, the affected repos, the rough steps and
110
+ open questions. Without a project management repo, put the plan into the
111
+ ticket itself. Let the user review the plan and revise it until they confirm
112
+ it.
106
113
 
107
114
  ## Implement (optional)
108
115
 
@@ -22,9 +22,11 @@ gg do init workspace
22
22
  ```
23
23
 
24
24
  This creates the `.ocean` folder and instantiates the latest `dna_gg` in
25
- the workspace folder: the gg guides, the skills and the managed block of
25
+ the workspace folder: the gg guides, the skills, the hooks that keep your
26
+ prompts in the project management repo and the managed block of
26
27
  `CLAUDE.md`. It is the same as running `gg dna init`, `gg dna add dna_gg`
27
- and `gg dna build` by hand.
28
+ and `gg dna build` by hand. Every ticket gets a copy of the skills and
29
+ the hooks.
28
30
 
29
31
  ## Add your repositories
30
32
 
@@ -0,0 +1,3 @@
1
+ {
2
+ "pmRepo": "dnaPmRepo"
3
+ }
@@ -0,0 +1,594 @@
1
+ // Keeps the prompts written to Claude Code in the project management repo.
2
+ //
3
+ // Claude Code runs this script on the hooks of .claude/settings.json:
4
+ // `prompt` notes each prompt in a buffer below ~/.claude/pm-prompts,
5
+ // `start` and `end` hand the buffer to a detached `sync`. The sync commits
6
+ // the prompts to the branch `prompts` of the project management repo
7
+ // without touching any working tree. Only the prompts are kept, never the
8
+ // answers. Nothing here may stop a prompt: every failure ends up in the log.
9
+
10
+ import { spawn, spawnSync } from 'node:child_process';
11
+ import { createHash } from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+
17
+ /** The branch of the project management repo holding the prompts. */
18
+ export const promptsBranch = 'prompts';
19
+
20
+ const promptsDir = 'doc/prompts';
21
+ const peopleFile = 'doc/team/people.md';
22
+ const configFile = path.join('.claude', 'hooks', 'pm-prompts.json');
23
+
24
+ // A session that ended without an end event counts as ended after a week.
25
+ const staleMs = 7 * 24 * 60 * 60 * 1000;
26
+
27
+ // Prompts a project management repo did not take within a month are dropped.
28
+ const giveUpMs = 30 * 24 * 60 * 60 * 1000;
29
+
30
+ // A sync that holds its lock longer than this is taken to have crashed.
31
+ const lockMs = 10 * 60 * 1000;
32
+
33
+ const gitEnv = {
34
+ ...process.env,
35
+ GIT_TERMINAL_PROMPT: '0',
36
+ GCM_INTERACTIVE: 'never',
37
+ };
38
+
39
+ // .............................................................................
40
+ /** The folder holding buffer, caches and log. */
41
+ export function home() {
42
+ return (
43
+ process.env.PM_PROMPTS_HOME ||
44
+ path.join(os.homedir(), '.claude', 'pm-prompts')
45
+ );
46
+ }
47
+
48
+ const sessionsDir = () => path.join(home(), 'sessions');
49
+
50
+ function sessionFile(sessionId) {
51
+ const safe = String(sessionId).replace(/[^A-Za-z0-9-]/g, '');
52
+ return path.join(sessionsDir(), `${safe}.jsonl`);
53
+ }
54
+
55
+ function log(message) {
56
+ try {
57
+ const file = path.join(home(), 'sync.log');
58
+ fs.mkdirSync(home(), { recursive: true });
59
+ const old = fs.existsSync(file)
60
+ ? fs.readFileSync(file, 'utf8').split('\n').slice(-200)
61
+ : [];
62
+ old.push(`${new Date().toISOString()} ${message}`);
63
+ fs.writeFileSync(file, old.join('\n').trimStart() + '\n');
64
+ } catch {
65
+ // Logging must never fail a hook.
66
+ }
67
+ }
68
+
69
+ // .............................................................................
70
+ // Secrets
71
+
72
+ const secretPatterns = [
73
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
74
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
75
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
76
+ /\bglpat-[A-Za-z0-9_-]{20,}/g,
77
+ /\bnpm_[A-Za-z0-9]{20,}/g,
78
+ /\bsk-[A-Za-z0-9_-]{20,}/g,
79
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
80
+ /\bAKIA[0-9A-Z]{16}\b/g,
81
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
82
+ ];
83
+
84
+ const keyedSecret =
85
+ /\b(bearer|basic|password|passwort|passwd|pwd|secret|token|pat|api[_-]?key)(\s*[:=]\s*|\s+)("[^"]+"|'[^']+'|\S+)/gi;
86
+
87
+ const longToken = /[A-Za-z0-9][A-Za-z0-9_+=-]{31,}/g;
88
+
89
+ // Whether a long run of characters looks like a generated secret rather than
90
+ // a word, a path or a commit hash.
91
+ function looksRandom(token) {
92
+ const upper = /[A-Z]/.test(token);
93
+ const lower = /[a-z]/.test(token);
94
+ const digit = /[0-9]/.test(token);
95
+ if (upper && lower && digit) return true;
96
+ return /^[a-z0-9]{40,}$/.test(token) && digit && !/^[0-9a-f]+$/.test(token);
97
+ }
98
+
99
+ /** [text] with everything that looks like a secret replaced. */
100
+ export function redact(text) {
101
+ let result = text;
102
+ for (const pattern of secretPatterns) {
103
+ result = result.replace(pattern, '[redacted]');
104
+ }
105
+ result = result.replace(keyedSecret, (match, key, separator, value) =>
106
+ value.length >= 6 && !value.startsWith('[redacted')
107
+ ? `${key}${separator}[redacted]`
108
+ : match,
109
+ );
110
+ return result.replace(longToken, (token) =>
111
+ looksRandom(token) ? '[redacted]' : token,
112
+ );
113
+ }
114
+
115
+ // .............................................................................
116
+ // Where the prompts go
117
+
118
+ function git(args, options = {}) {
119
+ const result = spawnSync('git', args, {
120
+ encoding: 'utf8',
121
+ env: options.env ?? gitEnv,
122
+ input: options.input,
123
+ windowsHide: true,
124
+ timeout: options.timeout ?? 120000,
125
+ });
126
+ return {
127
+ ok: result.status === 0,
128
+ out: (result.stdout ?? '').trim(),
129
+ err: (result.stderr ?? '').trim() || String(result.error ?? ''),
130
+ };
131
+ }
132
+
133
+ // The project management repo [dir] reports to, as named by its DNA.
134
+ function readPmRepo(dir) {
135
+ try {
136
+ const config = JSON.parse(
137
+ fs.readFileSync(path.join(dir, configFile), 'utf8'),
138
+ );
139
+ return typeof config.pmRepo === 'string' ? config.pmRepo.trim() : '';
140
+ } catch {
141
+ return '';
142
+ }
143
+ }
144
+
145
+ // The remote of [dir]. A clone of a local working clone reports the remote
146
+ // of that one.
147
+ function originOf(dir, depth = 0) {
148
+ const origin = git(['-C', dir, 'remote', 'get-url', 'origin']);
149
+ if (!origin.ok || !origin.out) return '';
150
+ const local = path.resolve(dir, origin.out);
151
+ if (depth < 3 && fs.existsSync(path.join(local, '.git'))) {
152
+ return originOf(local, depth + 1) || origin.out;
153
+ }
154
+ return origin.out;
155
+ }
156
+
157
+ /**
158
+ * The URL of the project management repo [pmRepo] beside the repo cloned
159
+ * from [origin]: same host, same organization. A full URL is taken as is.
160
+ */
161
+ export function pmRepoUrl(pmRepo, origin) {
162
+ if (!pmRepo) return '';
163
+ if (/^[a-z][a-z0-9+.-]*:\/\/|^[^/\\\s]+@[^/\\\s]+:/i.test(pmRepo)) {
164
+ return pmRepo;
165
+ }
166
+ const match = (origin ?? '').match(/^(.*[/:\\])[^/:\\]+?(\.git)?[/\\]?$/);
167
+ return match ? `${match[1]}${pmRepo}${match[2] ?? ''}` : '';
168
+ }
169
+
170
+ /**
171
+ * The project management repos a session in [projectDir] reports to. A repo
172
+ * names its own; a folder holding several repos, like a gg ticket, reports
173
+ * to the ones its repos name.
174
+ */
175
+ export function findTargets(projectDir) {
176
+ let folders = [projectDir];
177
+ if (!readPmRepo(projectDir)) {
178
+ let entries = [];
179
+ try {
180
+ entries = fs.readdirSync(projectDir, { withFileTypes: true });
181
+ } catch {
182
+ // An unreadable folder reports nowhere.
183
+ }
184
+ folders = entries
185
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
186
+ .map((e) => path.join(projectDir, e.name))
187
+ .filter((dir) => readPmRepo(dir));
188
+ }
189
+ const targets = [];
190
+ const repos = [];
191
+ for (const dir of folders.sort()) {
192
+ const url = pmRepoUrl(readPmRepo(dir), originOf(dir));
193
+ if (!url) continue;
194
+ repos.push(path.basename(dir));
195
+ if (!targets.includes(url)) targets.push(url);
196
+ }
197
+ return { targets, repos };
198
+ }
199
+
200
+ // .............................................................................
201
+ // The buffer
202
+
203
+ function appendLine(file, entry) {
204
+ fs.mkdirSync(path.dirname(file), { recursive: true });
205
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
206
+ }
207
+
208
+ function readSession(file) {
209
+ const session = { meta: undefined, prompts: [], ended: false };
210
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
211
+ if (!line.trim()) continue;
212
+ try {
213
+ const entry = JSON.parse(line);
214
+ if (entry.type === 'meta') session.meta = entry;
215
+ if (entry.type === 'prompt') session.prompts.push(entry);
216
+ if (entry.type === 'end') session.ended = true;
217
+ } catch {
218
+ // A line cut short by a crash is skipped.
219
+ }
220
+ }
221
+ return session;
222
+ }
223
+
224
+ /** Notes the prompt of a `UserPromptSubmit` [input] in the buffer. */
225
+ export function onPrompt(input) {
226
+ if (!input.session_id || typeof input.prompt !== 'string') return;
227
+ const file = sessionFile(input.session_id);
228
+ if (!fs.existsSync(file)) {
229
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || input.cwd;
230
+ const { targets, repos } = findTargets(projectDir);
231
+ if (!targets.length) return;
232
+ appendLine(file, {
233
+ type: 'meta',
234
+ session: input.session_id,
235
+ started: new Date().toISOString(),
236
+ folder: path.basename(projectDir),
237
+ repos,
238
+ email: git(['-C', projectDir, 'config', 'user.email']).out,
239
+ targets,
240
+ });
241
+ }
242
+ appendLine(file, {
243
+ type: 'prompt',
244
+ at: new Date().toISOString(),
245
+ text: redact(input.prompt),
246
+ });
247
+ }
248
+
249
+ /** Marks the session of a `SessionEnd` [input] as ended. */
250
+ export function onEnd(input) {
251
+ const file = sessionFile(input.session_id ?? '');
252
+ if (fs.existsSync(file)) {
253
+ appendLine(file, { type: 'end', at: new Date().toISOString() });
254
+ }
255
+ }
256
+
257
+ // Starts a sync that outlives Claude Code and keeps the session waiting for
258
+ // nothing.
259
+ function startSync() {
260
+ if (!fs.existsSync(sessionsDir())) return;
261
+ if (!fs.readdirSync(sessionsDir()).some((f) => f.endsWith('.jsonl'))) return;
262
+ const child = spawn(
263
+ process.execPath,
264
+ [fileURLToPath(import.meta.url), 'sync'],
265
+ { detached: true, stdio: 'ignore', windowsHide: true },
266
+ );
267
+ child.unref();
268
+ }
269
+
270
+ // .............................................................................
271
+ // Rendering
272
+
273
+ const pad = (n) => String(n).padStart(2, '0');
274
+
275
+ function localDate(iso) {
276
+ const d = new Date(iso);
277
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
278
+ }
279
+
280
+ function localTime(iso) {
281
+ const d = new Date(iso);
282
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
283
+ }
284
+
285
+ /** The folder name of a person: lower case, umlauts spelled out. */
286
+ export function slug(name) {
287
+ return name
288
+ .toLowerCase()
289
+ .replace(/ä/g, 'ae')
290
+ .replace(/ö/g, 'oe')
291
+ .replace(/ü/g, 'ue')
292
+ .replace(/ß/g, 'ss')
293
+ .normalize('NFD')
294
+ .replace(/[̀-ͯ]/g, '')
295
+ .replace(/[^a-z0-9]+/g, '-')
296
+ .replace(/^-+|-+$/g, '');
297
+ }
298
+
299
+ /** The first name in [peopleMd] whose `Git` line lists [email]. */
300
+ export function personOf(email, peopleMd) {
301
+ if (!email) return '';
302
+ let name = '';
303
+ for (const line of peopleMd.split(/\r?\n/)) {
304
+ const heading = line.match(/^##\s+(.+?)\s*$/);
305
+ if (heading) {
306
+ name = heading[1];
307
+ continue;
308
+ }
309
+ const emails = line.match(/^\s*-\s*Git:\s*(.+)$/i);
310
+ if (
311
+ name &&
312
+ emails &&
313
+ emails[1]
314
+ .split(/[,;\s]+/)
315
+ .some((e) => e.toLowerCase() === email.toLowerCase())
316
+ ) {
317
+ return name;
318
+ }
319
+ }
320
+ return '';
321
+ }
322
+
323
+ /** Where the prompts of a session go in the project management repo. */
324
+ export function promptsPath(meta, person) {
325
+ const date = localDate(meta.started);
326
+ const folder = person ? slug(person) : '_unknown';
327
+ const id = String(meta.session).slice(0, 8);
328
+ return `${promptsDir}/${folder}/${date.slice(0, 7)}/${date}_${slug(meta.folder) || 'session'}_${id}.md`;
329
+ }
330
+
331
+ /** The head of the file a session's prompts start. */
332
+ export function renderHead(meta, person) {
333
+ const lines = [
334
+ `# ${localDate(meta.started)} · ${meta.folder} · ${String(meta.session).slice(0, 8)}`,
335
+ '',
336
+ `- Person: ${person || meta.email || 'unknown'}`,
337
+ ];
338
+ const repos = meta.repos ?? [];
339
+ if (repos.length === 1 && repos[0] === meta.folder) {
340
+ lines.push(`- Repo: ${meta.folder}`);
341
+ } else {
342
+ lines.push(`- Folder: ${meta.folder}`, `- Repos: ${repos.join(', ')}`);
343
+ }
344
+ lines.push(`- Session: ${meta.session}`);
345
+ return lines.join('\n') + '\n';
346
+ }
347
+
348
+ /** [prompts] as Markdown sections, a heading with the time each. */
349
+ export function renderPrompts(prompts) {
350
+ return prompts
351
+ .map((p) => {
352
+ // A prompt's own headings must not break the structure of the file.
353
+ const text = p.text.replace(/\r\n/g, '\n').replace(/^#/gm, '\\#');
354
+ return `\n## ${localTime(p.at)}\n\n${text.trim()}\n`;
355
+ })
356
+ .join('');
357
+ }
358
+
359
+ // .............................................................................
360
+ // Sync
361
+
362
+ // A short folder per URL: Windows refuses long object paths.
363
+ function cacheDir(url) {
364
+ const hash = createHash('sha1').update(url).digest('hex').slice(0, 10);
365
+ const name = slug(url.replace(/\.git\/?$/, '').split(/[/:]/).at(-1) ?? '');
366
+ return path.join(home(), 'repos', `${name.slice(0, 30)}-${hash}.git`);
367
+ }
368
+
369
+ function ensureCache(url) {
370
+ const dir = cacheDir(url);
371
+ if (!fs.existsSync(path.join(dir, 'HEAD'))) {
372
+ fs.mkdirSync(dir, { recursive: true });
373
+ const init = git(['init', '--bare', '-q', dir]);
374
+ if (!init.ok) throw new Error(`git init ${dir}: ${init.err}`);
375
+ }
376
+ if (!git(['-C', dir, 'remote', 'set-url', 'origin', url]).ok) {
377
+ git(['-C', dir, 'remote', 'add', 'origin', url]);
378
+ }
379
+ return dir;
380
+ }
381
+
382
+ function readPeople(dir) {
383
+ const fetched = git(['-C', dir, 'fetch', '-q', '--depth=1', 'origin', 'HEAD']);
384
+ if (!fetched.ok) throw new Error(`fetch HEAD: ${fetched.err}`);
385
+ return git(['-C', dir, 'show', `FETCH_HEAD:${peopleFile}`]).out;
386
+ }
387
+
388
+ // The tip of the prompts branch, '' while it does not exist yet.
389
+ function fetchBranch(dir) {
390
+ const heads = git(['-C', dir, 'ls-remote', '--heads', 'origin', promptsBranch]);
391
+ if (!heads.ok) throw new Error(`ls-remote: ${heads.err}`);
392
+ if (!heads.out) return '';
393
+ const ref = `refs/heads/${promptsBranch}`;
394
+ const fetched = git(['-C', dir, 'fetch', '-q', 'origin', `+${ref}:${ref}`]);
395
+ if (!fetched.ok) throw new Error(`fetch ${promptsBranch}: ${fetched.err}`);
396
+ return git(['-C', dir, 'rev-parse', ref]).out;
397
+ }
398
+
399
+ function identityEnv(dir) {
400
+ const env = { ...gitEnv };
401
+ if (!git(['-C', dir, 'config', 'user.name']).out) {
402
+ env.GIT_AUTHOR_NAME = env.GIT_COMMITTER_NAME = 'pm-prompts';
403
+ }
404
+ if (!git(['-C', dir, 'config', 'user.email']).out) {
405
+ env.GIT_AUTHOR_EMAIL = env.GIT_COMMITTER_EMAIL = 'pm-prompts@localhost';
406
+ }
407
+ return env;
408
+ }
409
+
410
+ // Commits the not yet pushed prompts of [sessions] to the prompts branch of
411
+ // [url]. Returns how many prompts of each session the branch now holds.
412
+ function pushTarget(url, sessions) {
413
+ const dir = ensureCache(url);
414
+ const people = readPeople(dir);
415
+ for (let attempt = 0; attempt < 3; attempt++) {
416
+ const base = fetchBranch(dir);
417
+ const index = path.join(
418
+ os.tmpdir(),
419
+ `pm-prompts-${process.pid}-${Date.now()}.index`,
420
+ );
421
+ const env = { ...identityEnv(dir), GIT_INDEX_FILE: index };
422
+ try {
423
+ const read = base
424
+ ? git(['-C', dir, 'read-tree', base], { env })
425
+ : git(['-C', dir, 'read-tree', '--empty'], { env });
426
+ if (!read.ok) throw new Error(`read-tree: ${read.err}`);
427
+ const names = new Set();
428
+ const done = new Map();
429
+ for (const { file, session, pushed } of sessions) {
430
+ const fresh = session.prompts.slice(pushed);
431
+ done.set(file, session.prompts.length);
432
+ if (!fresh.length) continue;
433
+ const person = personOf(session.meta.email, people);
434
+ names.add(person || session.meta.email || 'unknown');
435
+ const target = promptsPath(session.meta, person);
436
+ const existing = base
437
+ ? git(['-C', dir, 'cat-file', '-p', `${base}:${target}`])
438
+ : { ok: false, out: '' };
439
+ const head = existing.ok
440
+ ? existing.out + '\n'
441
+ : renderHead(session.meta, person);
442
+ const blob = git(['-C', dir, 'hash-object', '-w', '--stdin'], {
443
+ env,
444
+ input: head + renderPrompts(fresh),
445
+ });
446
+ if (!blob.ok) throw new Error(`hash-object: ${blob.err}`);
447
+ const added = git(
448
+ [
449
+ '-C',
450
+ dir,
451
+ 'update-index',
452
+ '--add',
453
+ '--cacheinfo',
454
+ `100644,${blob.out},${target}`,
455
+ ],
456
+ { env },
457
+ );
458
+ if (!added.ok) throw new Error(`update-index: ${added.err}`);
459
+ }
460
+ if (!names.size) return done;
461
+ const tree = git(['-C', dir, 'write-tree'], { env });
462
+ if (!tree.ok) throw new Error(`write-tree: ${tree.err}`);
463
+ const message = `Add prompts of ${[...names].sort().join(', ')}`;
464
+ const commit = git(
465
+ [
466
+ '-C',
467
+ dir,
468
+ 'commit-tree',
469
+ tree.out,
470
+ ...(base ? ['-p', base] : []),
471
+ '-m',
472
+ message,
473
+ ],
474
+ { env },
475
+ );
476
+ if (!commit.ok) throw new Error(`commit-tree: ${commit.err}`);
477
+ const pushed = git([
478
+ '-C',
479
+ dir,
480
+ 'push',
481
+ '-q',
482
+ 'origin',
483
+ `${commit.out}:refs/heads/${promptsBranch}`,
484
+ ]);
485
+ if (pushed.ok) return done;
486
+ log(`push to ${url} rejected, retrying: ${pushed.err}`);
487
+ } finally {
488
+ fs.rmSync(index, { force: true });
489
+ }
490
+ }
491
+ throw new Error(`push to ${url} failed three times`);
492
+ }
493
+
494
+ function takeLock() {
495
+ const lock = path.join(home(), 'sync.lock');
496
+ fs.mkdirSync(home(), { recursive: true });
497
+ try {
498
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
499
+ } catch {
500
+ if (Date.now() - fs.statSync(lock).mtimeMs < lockMs) return undefined;
501
+ fs.writeFileSync(lock, String(process.pid));
502
+ }
503
+ return () => fs.rmSync(lock, { force: true });
504
+ }
505
+
506
+ /** Pushes every buffered prompt and drops the sessions that are through. */
507
+ export function sync() {
508
+ const release = takeLock();
509
+ if (!release) return;
510
+ try {
511
+ if (!fs.existsSync(sessionsDir())) return;
512
+ const byTarget = new Map();
513
+ const all = [];
514
+ for (const name of fs.readdirSync(sessionsDir())) {
515
+ if (!name.endsWith('.jsonl')) continue;
516
+ const file = path.join(sessionsDir(), name);
517
+ const session = readSession(file);
518
+ if (!session.meta) {
519
+ fs.rmSync(file, { force: true });
520
+ continue;
521
+ }
522
+ const stateFile = file.replace(/\.jsonl$/, '.state.json');
523
+ let state = {};
524
+ try {
525
+ state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
526
+ } catch {
527
+ // No state yet: nothing of the session was pushed.
528
+ }
529
+ const entry = { file, stateFile, session, state };
530
+ all.push(entry);
531
+ for (const url of session.meta.targets) {
532
+ if (!byTarget.has(url)) byTarget.set(url, []);
533
+ byTarget.get(url).push({ file, session, pushed: state[url] ?? 0 });
534
+ }
535
+ }
536
+ for (const [url, sessions] of byTarget) {
537
+ try {
538
+ const done = pushTarget(url, sessions);
539
+ for (const entry of all) {
540
+ if (done.has(entry.file)) entry.state[url] = done.get(entry.file);
541
+ }
542
+ } catch (e) {
543
+ log(`sync ${url}: ${e.message}`);
544
+ }
545
+ }
546
+ for (const { file, stateFile, session, state } of all) {
547
+ const through = session.meta.targets.every(
548
+ (url) => (state[url] ?? 0) >= session.prompts.length,
549
+ );
550
+ const lastAt = session.prompts.at(-1)?.at ?? session.meta.started;
551
+ const age = Date.now() - new Date(lastAt).getTime();
552
+ const ended = session.ended || age > staleMs;
553
+ if (!through && age > giveUpMs) {
554
+ log(`dropped ${file}: not taken within a month`);
555
+ }
556
+ if ((through && ended) || age > giveUpMs) {
557
+ fs.rmSync(file, { force: true });
558
+ fs.rmSync(stateFile, { force: true });
559
+ } else {
560
+ fs.writeFileSync(stateFile, JSON.stringify(state));
561
+ }
562
+ }
563
+ } finally {
564
+ release();
565
+ }
566
+ }
567
+
568
+ // .............................................................................
569
+
570
+ function readStdin() {
571
+ try {
572
+ return JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
573
+ } catch {
574
+ return {};
575
+ }
576
+ }
577
+
578
+ function main(event) {
579
+ try {
580
+ if (event === 'prompt') onPrompt(readStdin());
581
+ if (event === 'end') {
582
+ onEnd(readStdin());
583
+ startSync();
584
+ }
585
+ if (event === 'start') startSync();
586
+ if (event === 'sync') sync();
587
+ } catch (e) {
588
+ log(`${event}: ${e.stack ?? e}`);
589
+ }
590
+ }
591
+
592
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
593
+ main(process.argv[2]);
594
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/pm-prompts.mjs\" start || true",
9
+ "timeout": 10
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "UserPromptSubmit": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/pm-prompts.mjs\" prompt || true",
20
+ "timeout": 10
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "SessionEnd": [
26
+ {
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/pm-prompts.mjs\" end || true",
31
+ "timeout": 10
32
+ }
33
+ ]
34
+ }
35
+ ]
36
+ }
37
+ }
@@ -80,6 +80,11 @@ gg do code
80
80
 
81
81
  ## 6. Plan or implement
82
82
 
83
+ If the project management repo keeps requirements (`doc/requirements`)
84
+ and no accepted requirement covers the ticket, engineer them with the user
85
+ first — with `/plan-requirements` if the repo has it — before any plan or
86
+ code. Bug tickets are the exception, they name their bug file.
87
+
83
88
  Ask the user whether the ticket is planned first. If yes, write the plan
84
89
  into the project management repo as its `pm-repo-guide.md` describes (or as
85
90
  its other guides do) and let the user confirm it. Without a project
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggdna/dna-gg",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "DNA layer: gg CLI workflow guides, repo setup scripts and CI check",
5
5
  "keywords": [
6
6
  "dna",
@@ -38,7 +38,8 @@
38
38
  },
39
39
  "scripts": {
40
40
  "test:dart": "dart test",
41
- "test": "pnpm run test:dart",
41
+ "test:node": "node --test test/hooks/pm_prompts.test.mjs",
42
+ "test": "pnpm run test:dart && pnpm run test:node",
42
43
  "format": "prettier --write .",
43
44
  "format:check": "prettier --check ."
44
45
  }