@ggdna/dna-gg 0.12.0 → 0.13.1
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 +31 -0
- package/dna/_generated.json +15 -3
- package/dna/_vars.json +1 -0
- package/dna/doc/guides/develop-guide.md +9 -0
- package/dna/doc/guides/install-guide/install-gg-workspace-guide.md +4 -2
- package/dna/dot-claude/hooks/pm-prompts.json +3 -0
- package/dna/dot-claude/hooks/pm-prompts.mjs +597 -0
- package/dna/dot-claude/settings.json +37 -0
- package/package.json +3 -2
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,11 @@ 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: its name
|
|
95
|
+
with `.git` appended, e.g. `my_pm.git`, which finds it beside the repo's
|
|
96
|
+
own origin, or a full git URL. helix re-cases a bare name like `my_pm`
|
|
97
|
+
to the case of the reference, `.git` keeps it as written. Empty by
|
|
98
|
+
default, then no prompt is kept
|
|
68
99
|
|
|
69
100
|
`dnaCopyrightHolder`, `dnaCompany` and `dnaJiraPrefix` name the
|
|
70
101
|
organization, so they are never inherited: every layer or project listing
|
package/dna/_generated.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"ecosystem": null,
|
|
40
40
|
"resolvedVersion": null,
|
|
41
41
|
"via": null,
|
|
42
|
-
"hash": "
|
|
42
|
+
"hash": "0xfe22fbb14f0e05fd"
|
|
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": "0x988aed96a8073727"
|
|
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"
|
|
@@ -91,7 +103,7 @@
|
|
|
91
103
|
},
|
|
92
104
|
{
|
|
93
105
|
"path": "doc/guides/develop-guide.md",
|
|
94
|
-
"hash": "
|
|
106
|
+
"hash": "0x39718de56fdb48f0"
|
|
95
107
|
},
|
|
96
108
|
{
|
|
97
109
|
"path": "doc/guides/develop-without-ai-guide.md",
|
|
@@ -135,7 +147,7 @@
|
|
|
135
147
|
},
|
|
136
148
|
{
|
|
137
149
|
"path": "doc/guides/install-guide/install-gg-workspace-guide.md",
|
|
138
|
-
"hash": "
|
|
150
|
+
"hash": "0x7fca95fb9895a191"
|
|
139
151
|
},
|
|
140
152
|
{
|
|
141
153
|
"path": "doc/guides/install-guide/install-github-cli-guide.md",
|
package/dna/_vars.json
CHANGED
|
@@ -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/`.
|
|
@@ -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
|
|
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,597 @@
|
|
|
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
|
+
* The name may end in `.git`: the DNA writes it that way, because helix
|
|
161
|
+
* would re-case a bare name like `my_pm` into `myPm`.
|
|
162
|
+
*/
|
|
163
|
+
export function pmRepoUrl(pmRepo, origin) {
|
|
164
|
+
if (!pmRepo) return '';
|
|
165
|
+
if (/^[a-z][a-z0-9+.-]*:\/\/|^[^/\\\s]+@[^/\\\s]+:/i.test(pmRepo)) {
|
|
166
|
+
return pmRepo;
|
|
167
|
+
}
|
|
168
|
+
const name = pmRepo.replace(/\.git$/, '');
|
|
169
|
+
const match = (origin ?? '').match(/^(.*[/:\\])[^/:\\]+?(\.git)?[/\\]?$/);
|
|
170
|
+
return match ? `${match[1]}${name}${match[2] ?? ''}` : '';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The project management repos a session in [projectDir] reports to. A repo
|
|
175
|
+
* names its own; a folder holding several repos, like a gg ticket, reports
|
|
176
|
+
* to the ones its repos name.
|
|
177
|
+
*/
|
|
178
|
+
export function findTargets(projectDir) {
|
|
179
|
+
let folders = [projectDir];
|
|
180
|
+
if (!readPmRepo(projectDir)) {
|
|
181
|
+
let entries = [];
|
|
182
|
+
try {
|
|
183
|
+
entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
184
|
+
} catch {
|
|
185
|
+
// An unreadable folder reports nowhere.
|
|
186
|
+
}
|
|
187
|
+
folders = entries
|
|
188
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
189
|
+
.map((e) => path.join(projectDir, e.name))
|
|
190
|
+
.filter((dir) => readPmRepo(dir));
|
|
191
|
+
}
|
|
192
|
+
const targets = [];
|
|
193
|
+
const repos = [];
|
|
194
|
+
for (const dir of folders.sort()) {
|
|
195
|
+
const url = pmRepoUrl(readPmRepo(dir), originOf(dir));
|
|
196
|
+
if (!url) continue;
|
|
197
|
+
repos.push(path.basename(dir));
|
|
198
|
+
if (!targets.includes(url)) targets.push(url);
|
|
199
|
+
}
|
|
200
|
+
return { targets, repos };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// .............................................................................
|
|
204
|
+
// The buffer
|
|
205
|
+
|
|
206
|
+
function appendLine(file, entry) {
|
|
207
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
208
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function readSession(file) {
|
|
212
|
+
const session = { meta: undefined, prompts: [], ended: false };
|
|
213
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
214
|
+
if (!line.trim()) continue;
|
|
215
|
+
try {
|
|
216
|
+
const entry = JSON.parse(line);
|
|
217
|
+
if (entry.type === 'meta') session.meta = entry;
|
|
218
|
+
if (entry.type === 'prompt') session.prompts.push(entry);
|
|
219
|
+
if (entry.type === 'end') session.ended = true;
|
|
220
|
+
} catch {
|
|
221
|
+
// A line cut short by a crash is skipped.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Notes the prompt of a `UserPromptSubmit` [input] in the buffer. */
|
|
228
|
+
export function onPrompt(input) {
|
|
229
|
+
if (!input.session_id || typeof input.prompt !== 'string') return;
|
|
230
|
+
const file = sessionFile(input.session_id);
|
|
231
|
+
if (!fs.existsSync(file)) {
|
|
232
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || input.cwd;
|
|
233
|
+
const { targets, repos } = findTargets(projectDir);
|
|
234
|
+
if (!targets.length) return;
|
|
235
|
+
appendLine(file, {
|
|
236
|
+
type: 'meta',
|
|
237
|
+
session: input.session_id,
|
|
238
|
+
started: new Date().toISOString(),
|
|
239
|
+
folder: path.basename(projectDir),
|
|
240
|
+
repos,
|
|
241
|
+
email: git(['-C', projectDir, 'config', 'user.email']).out,
|
|
242
|
+
targets,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
appendLine(file, {
|
|
246
|
+
type: 'prompt',
|
|
247
|
+
at: new Date().toISOString(),
|
|
248
|
+
text: redact(input.prompt),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Marks the session of a `SessionEnd` [input] as ended. */
|
|
253
|
+
export function onEnd(input) {
|
|
254
|
+
const file = sessionFile(input.session_id ?? '');
|
|
255
|
+
if (fs.existsSync(file)) {
|
|
256
|
+
appendLine(file, { type: 'end', at: new Date().toISOString() });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Starts a sync that outlives Claude Code and keeps the session waiting for
|
|
261
|
+
// nothing.
|
|
262
|
+
function startSync() {
|
|
263
|
+
if (!fs.existsSync(sessionsDir())) return;
|
|
264
|
+
if (!fs.readdirSync(sessionsDir()).some((f) => f.endsWith('.jsonl'))) return;
|
|
265
|
+
const child = spawn(
|
|
266
|
+
process.execPath,
|
|
267
|
+
[fileURLToPath(import.meta.url), 'sync'],
|
|
268
|
+
{ detached: true, stdio: 'ignore', windowsHide: true },
|
|
269
|
+
);
|
|
270
|
+
child.unref();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// .............................................................................
|
|
274
|
+
// Rendering
|
|
275
|
+
|
|
276
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
277
|
+
|
|
278
|
+
function localDate(iso) {
|
|
279
|
+
const d = new Date(iso);
|
|
280
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function localTime(iso) {
|
|
284
|
+
const d = new Date(iso);
|
|
285
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The folder name of a person: lower case, umlauts spelled out. */
|
|
289
|
+
export function slug(name) {
|
|
290
|
+
return name
|
|
291
|
+
.toLowerCase()
|
|
292
|
+
.replace(/ä/g, 'ae')
|
|
293
|
+
.replace(/ö/g, 'oe')
|
|
294
|
+
.replace(/ü/g, 'ue')
|
|
295
|
+
.replace(/ß/g, 'ss')
|
|
296
|
+
.normalize('NFD')
|
|
297
|
+
.replace(/[̀-ͯ]/g, '')
|
|
298
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
299
|
+
.replace(/^-+|-+$/g, '');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The first name in [peopleMd] whose `Git` line lists [email]. */
|
|
303
|
+
export function personOf(email, peopleMd) {
|
|
304
|
+
if (!email) return '';
|
|
305
|
+
let name = '';
|
|
306
|
+
for (const line of peopleMd.split(/\r?\n/)) {
|
|
307
|
+
const heading = line.match(/^##\s+(.+?)\s*$/);
|
|
308
|
+
if (heading) {
|
|
309
|
+
name = heading[1];
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const emails = line.match(/^\s*-\s*Git:\s*(.+)$/i);
|
|
313
|
+
if (
|
|
314
|
+
name &&
|
|
315
|
+
emails &&
|
|
316
|
+
emails[1]
|
|
317
|
+
.split(/[,;\s]+/)
|
|
318
|
+
.some((e) => e.toLowerCase() === email.toLowerCase())
|
|
319
|
+
) {
|
|
320
|
+
return name;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Where the prompts of a session go in the project management repo. */
|
|
327
|
+
export function promptsPath(meta, person) {
|
|
328
|
+
const date = localDate(meta.started);
|
|
329
|
+
const folder = person ? slug(person) : '_unknown';
|
|
330
|
+
const id = String(meta.session).slice(0, 8);
|
|
331
|
+
return `${promptsDir}/${folder}/${date.slice(0, 7)}/${date}_${slug(meta.folder) || 'session'}_${id}.md`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The head of the file a session's prompts start. */
|
|
335
|
+
export function renderHead(meta, person) {
|
|
336
|
+
const lines = [
|
|
337
|
+
`# ${localDate(meta.started)} · ${meta.folder} · ${String(meta.session).slice(0, 8)}`,
|
|
338
|
+
'',
|
|
339
|
+
`- Person: ${person || meta.email || 'unknown'}`,
|
|
340
|
+
];
|
|
341
|
+
const repos = meta.repos ?? [];
|
|
342
|
+
if (repos.length === 1 && repos[0] === meta.folder) {
|
|
343
|
+
lines.push(`- Repo: ${meta.folder}`);
|
|
344
|
+
} else {
|
|
345
|
+
lines.push(`- Folder: ${meta.folder}`, `- Repos: ${repos.join(', ')}`);
|
|
346
|
+
}
|
|
347
|
+
lines.push(`- Session: ${meta.session}`);
|
|
348
|
+
return lines.join('\n') + '\n';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** [prompts] as Markdown sections, a heading with the time each. */
|
|
352
|
+
export function renderPrompts(prompts) {
|
|
353
|
+
return prompts
|
|
354
|
+
.map((p) => {
|
|
355
|
+
// A prompt's own headings must not break the structure of the file.
|
|
356
|
+
const text = p.text.replace(/\r\n/g, '\n').replace(/^#/gm, '\\#');
|
|
357
|
+
return `\n## ${localTime(p.at)}\n\n${text.trim()}\n`;
|
|
358
|
+
})
|
|
359
|
+
.join('');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// .............................................................................
|
|
363
|
+
// Sync
|
|
364
|
+
|
|
365
|
+
// A short folder per URL: Windows refuses long object paths.
|
|
366
|
+
function cacheDir(url) {
|
|
367
|
+
const hash = createHash('sha1').update(url).digest('hex').slice(0, 10);
|
|
368
|
+
const name = slug(url.replace(/\.git\/?$/, '').split(/[/:]/).at(-1) ?? '');
|
|
369
|
+
return path.join(home(), 'repos', `${name.slice(0, 30)}-${hash}.git`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function ensureCache(url) {
|
|
373
|
+
const dir = cacheDir(url);
|
|
374
|
+
if (!fs.existsSync(path.join(dir, 'HEAD'))) {
|
|
375
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
376
|
+
const init = git(['init', '--bare', '-q', dir]);
|
|
377
|
+
if (!init.ok) throw new Error(`git init ${dir}: ${init.err}`);
|
|
378
|
+
}
|
|
379
|
+
if (!git(['-C', dir, 'remote', 'set-url', 'origin', url]).ok) {
|
|
380
|
+
git(['-C', dir, 'remote', 'add', 'origin', url]);
|
|
381
|
+
}
|
|
382
|
+
return dir;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function readPeople(dir) {
|
|
386
|
+
const fetched = git(['-C', dir, 'fetch', '-q', '--depth=1', 'origin', 'HEAD']);
|
|
387
|
+
if (!fetched.ok) throw new Error(`fetch HEAD: ${fetched.err}`);
|
|
388
|
+
return git(['-C', dir, 'show', `FETCH_HEAD:${peopleFile}`]).out;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// The tip of the prompts branch, '' while it does not exist yet.
|
|
392
|
+
function fetchBranch(dir) {
|
|
393
|
+
const heads = git(['-C', dir, 'ls-remote', '--heads', 'origin', promptsBranch]);
|
|
394
|
+
if (!heads.ok) throw new Error(`ls-remote: ${heads.err}`);
|
|
395
|
+
if (!heads.out) return '';
|
|
396
|
+
const ref = `refs/heads/${promptsBranch}`;
|
|
397
|
+
const fetched = git(['-C', dir, 'fetch', '-q', 'origin', `+${ref}:${ref}`]);
|
|
398
|
+
if (!fetched.ok) throw new Error(`fetch ${promptsBranch}: ${fetched.err}`);
|
|
399
|
+
return git(['-C', dir, 'rev-parse', ref]).out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function identityEnv(dir) {
|
|
403
|
+
const env = { ...gitEnv };
|
|
404
|
+
if (!git(['-C', dir, 'config', 'user.name']).out) {
|
|
405
|
+
env.GIT_AUTHOR_NAME = env.GIT_COMMITTER_NAME = 'pm-prompts';
|
|
406
|
+
}
|
|
407
|
+
if (!git(['-C', dir, 'config', 'user.email']).out) {
|
|
408
|
+
env.GIT_AUTHOR_EMAIL = env.GIT_COMMITTER_EMAIL = 'pm-prompts@localhost';
|
|
409
|
+
}
|
|
410
|
+
return env;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Commits the not yet pushed prompts of [sessions] to the prompts branch of
|
|
414
|
+
// [url]. Returns how many prompts of each session the branch now holds.
|
|
415
|
+
function pushTarget(url, sessions) {
|
|
416
|
+
const dir = ensureCache(url);
|
|
417
|
+
const people = readPeople(dir);
|
|
418
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
419
|
+
const base = fetchBranch(dir);
|
|
420
|
+
const index = path.join(
|
|
421
|
+
os.tmpdir(),
|
|
422
|
+
`pm-prompts-${process.pid}-${Date.now()}.index`,
|
|
423
|
+
);
|
|
424
|
+
const env = { ...identityEnv(dir), GIT_INDEX_FILE: index };
|
|
425
|
+
try {
|
|
426
|
+
const read = base
|
|
427
|
+
? git(['-C', dir, 'read-tree', base], { env })
|
|
428
|
+
: git(['-C', dir, 'read-tree', '--empty'], { env });
|
|
429
|
+
if (!read.ok) throw new Error(`read-tree: ${read.err}`);
|
|
430
|
+
const names = new Set();
|
|
431
|
+
const done = new Map();
|
|
432
|
+
for (const { file, session, pushed } of sessions) {
|
|
433
|
+
const fresh = session.prompts.slice(pushed);
|
|
434
|
+
done.set(file, session.prompts.length);
|
|
435
|
+
if (!fresh.length) continue;
|
|
436
|
+
const person = personOf(session.meta.email, people);
|
|
437
|
+
names.add(person || session.meta.email || 'unknown');
|
|
438
|
+
const target = promptsPath(session.meta, person);
|
|
439
|
+
const existing = base
|
|
440
|
+
? git(['-C', dir, 'cat-file', '-p', `${base}:${target}`])
|
|
441
|
+
: { ok: false, out: '' };
|
|
442
|
+
const head = existing.ok
|
|
443
|
+
? existing.out + '\n'
|
|
444
|
+
: renderHead(session.meta, person);
|
|
445
|
+
const blob = git(['-C', dir, 'hash-object', '-w', '--stdin'], {
|
|
446
|
+
env,
|
|
447
|
+
input: head + renderPrompts(fresh),
|
|
448
|
+
});
|
|
449
|
+
if (!blob.ok) throw new Error(`hash-object: ${blob.err}`);
|
|
450
|
+
const added = git(
|
|
451
|
+
[
|
|
452
|
+
'-C',
|
|
453
|
+
dir,
|
|
454
|
+
'update-index',
|
|
455
|
+
'--add',
|
|
456
|
+
'--cacheinfo',
|
|
457
|
+
`100644,${blob.out},${target}`,
|
|
458
|
+
],
|
|
459
|
+
{ env },
|
|
460
|
+
);
|
|
461
|
+
if (!added.ok) throw new Error(`update-index: ${added.err}`);
|
|
462
|
+
}
|
|
463
|
+
if (!names.size) return done;
|
|
464
|
+
const tree = git(['-C', dir, 'write-tree'], { env });
|
|
465
|
+
if (!tree.ok) throw new Error(`write-tree: ${tree.err}`);
|
|
466
|
+
const message = `Add prompts of ${[...names].sort().join(', ')}`;
|
|
467
|
+
const commit = git(
|
|
468
|
+
[
|
|
469
|
+
'-C',
|
|
470
|
+
dir,
|
|
471
|
+
'commit-tree',
|
|
472
|
+
tree.out,
|
|
473
|
+
...(base ? ['-p', base] : []),
|
|
474
|
+
'-m',
|
|
475
|
+
message,
|
|
476
|
+
],
|
|
477
|
+
{ env },
|
|
478
|
+
);
|
|
479
|
+
if (!commit.ok) throw new Error(`commit-tree: ${commit.err}`);
|
|
480
|
+
const pushed = git([
|
|
481
|
+
'-C',
|
|
482
|
+
dir,
|
|
483
|
+
'push',
|
|
484
|
+
'-q',
|
|
485
|
+
'origin',
|
|
486
|
+
`${commit.out}:refs/heads/${promptsBranch}`,
|
|
487
|
+
]);
|
|
488
|
+
if (pushed.ok) return done;
|
|
489
|
+
log(`push to ${url} rejected, retrying: ${pushed.err}`);
|
|
490
|
+
} finally {
|
|
491
|
+
fs.rmSync(index, { force: true });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
throw new Error(`push to ${url} failed three times`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function takeLock() {
|
|
498
|
+
const lock = path.join(home(), 'sync.lock');
|
|
499
|
+
fs.mkdirSync(home(), { recursive: true });
|
|
500
|
+
try {
|
|
501
|
+
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
|
|
502
|
+
} catch {
|
|
503
|
+
if (Date.now() - fs.statSync(lock).mtimeMs < lockMs) return undefined;
|
|
504
|
+
fs.writeFileSync(lock, String(process.pid));
|
|
505
|
+
}
|
|
506
|
+
return () => fs.rmSync(lock, { force: true });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Pushes every buffered prompt and drops the sessions that are through. */
|
|
510
|
+
export function sync() {
|
|
511
|
+
const release = takeLock();
|
|
512
|
+
if (!release) return;
|
|
513
|
+
try {
|
|
514
|
+
if (!fs.existsSync(sessionsDir())) return;
|
|
515
|
+
const byTarget = new Map();
|
|
516
|
+
const all = [];
|
|
517
|
+
for (const name of fs.readdirSync(sessionsDir())) {
|
|
518
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
519
|
+
const file = path.join(sessionsDir(), name);
|
|
520
|
+
const session = readSession(file);
|
|
521
|
+
if (!session.meta) {
|
|
522
|
+
fs.rmSync(file, { force: true });
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const stateFile = file.replace(/\.jsonl$/, '.state.json');
|
|
526
|
+
let state = {};
|
|
527
|
+
try {
|
|
528
|
+
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
529
|
+
} catch {
|
|
530
|
+
// No state yet: nothing of the session was pushed.
|
|
531
|
+
}
|
|
532
|
+
const entry = { file, stateFile, session, state };
|
|
533
|
+
all.push(entry);
|
|
534
|
+
for (const url of session.meta.targets) {
|
|
535
|
+
if (!byTarget.has(url)) byTarget.set(url, []);
|
|
536
|
+
byTarget.get(url).push({ file, session, pushed: state[url] ?? 0 });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
for (const [url, sessions] of byTarget) {
|
|
540
|
+
try {
|
|
541
|
+
const done = pushTarget(url, sessions);
|
|
542
|
+
for (const entry of all) {
|
|
543
|
+
if (done.has(entry.file)) entry.state[url] = done.get(entry.file);
|
|
544
|
+
}
|
|
545
|
+
} catch (e) {
|
|
546
|
+
log(`sync ${url}: ${e.message}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
for (const { file, stateFile, session, state } of all) {
|
|
550
|
+
const through = session.meta.targets.every(
|
|
551
|
+
(url) => (state[url] ?? 0) >= session.prompts.length,
|
|
552
|
+
);
|
|
553
|
+
const lastAt = session.prompts.at(-1)?.at ?? session.meta.started;
|
|
554
|
+
const age = Date.now() - new Date(lastAt).getTime();
|
|
555
|
+
const ended = session.ended || age > staleMs;
|
|
556
|
+
if (!through && age > giveUpMs) {
|
|
557
|
+
log(`dropped ${file}: not taken within a month`);
|
|
558
|
+
}
|
|
559
|
+
if ((through && ended) || age > giveUpMs) {
|
|
560
|
+
fs.rmSync(file, { force: true });
|
|
561
|
+
fs.rmSync(stateFile, { force: true });
|
|
562
|
+
} else {
|
|
563
|
+
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} finally {
|
|
567
|
+
release();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// .............................................................................
|
|
572
|
+
|
|
573
|
+
function readStdin() {
|
|
574
|
+
try {
|
|
575
|
+
return JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
576
|
+
} catch {
|
|
577
|
+
return {};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function main(event) {
|
|
582
|
+
try {
|
|
583
|
+
if (event === 'prompt') onPrompt(readStdin());
|
|
584
|
+
if (event === 'end') {
|
|
585
|
+
onEnd(readStdin());
|
|
586
|
+
startSync();
|
|
587
|
+
}
|
|
588
|
+
if (event === 'start') startSync();
|
|
589
|
+
if (event === 'sync') sync();
|
|
590
|
+
} catch (e) {
|
|
591
|
+
log(`${event}: ${e.stack ?? e}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
596
|
+
main(process.argv[2]);
|
|
597
|
+
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggdna/dna-gg",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
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": "
|
|
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
|
}
|