@ghl-ai/aw 0.2.0 → 0.2.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/aw-usage-wiring.mjs +1 -0
- package/c4/detect.mjs +4 -2
- package/commands/c4.mjs +1 -1
- package/ecc.mjs +2 -2
- package/hooks/aw-usage/hooks/aw-usage-commit-trailer.js +124 -0
- package/hooks/aw-usage/lib/aw-usage-commands.js +1 -0
- package/hooks/aw-usage/lib/aw-usage-skill-source.js +98 -7
- package/hooks/aw-usage/lib/aw-usage-telemetry.js +99 -13
- package/hooks.mjs +16 -26
- package/package.json +1 -1
package/aw-usage-wiring.mjs
CHANGED
package/c4/detect.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 'cursor-cloud' | 'codex-web' | 'claude-web' | 'unknown'
|
|
6
6
|
*
|
|
7
7
|
* Detection signals (precedence top → bottom):
|
|
8
|
-
* 1. cursor-cloud ← env.
|
|
8
|
+
* 1. cursor-cloud ← env.CURSOR_CONVERSATION_ID starts with 'bc-'
|
|
9
|
+
* || env.CURSOR_AGENT === 'true' || env.CURSOR_BACKGROUND_AGENT_ID
|
|
9
10
|
* 2. codex-web ← env.CODEX_ENVIRONMENT === '1' || /\.codex(\/|$)/.test(cwd)
|
|
10
11
|
* 3. claude-web ← env.CLAUDE_CODE_REMOTE === 'true' || fsProbe('/home/user/.claude')
|
|
11
12
|
* 4. unknown ← fallback
|
|
@@ -46,7 +47,8 @@ export function detectHarness({
|
|
|
46
47
|
return /** @type {HarnessId} */ (override);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
// Cloud VMs set CURSOR_AGENT=1 (as local agents do) and a bc-<uuid> conversation id.
|
|
51
|
+
if (env.CURSOR_CONVERSATION_ID?.startsWith('bc-') || env.CURSOR_AGENT === 'true' || env.CURSOR_BACKGROUND_AGENT_ID) {
|
|
50
52
|
return 'cursor-cloud';
|
|
51
53
|
}
|
|
52
54
|
|
package/commands/c4.mjs
CHANGED
|
@@ -259,7 +259,7 @@ export async function c4Command(rawArgs, overrides = {}) {
|
|
|
259
259
|
|
|
260
260
|
// Step 1 — harness detection (override wins).
|
|
261
261
|
const cliHarness = args['--harness'];
|
|
262
|
-
const harness = SUPPORTED_HARNESSES.includes(cliHarness) ? cliHarness : c4.detectHarness({ env })
|
|
262
|
+
const harness = SUPPORTED_HARNESSES.includes(cliHarness) ? cliHarness : c4.detectHarness({ env });
|
|
263
263
|
const home = env.HOME ?? '~';
|
|
264
264
|
const awHome = join(home, '.aw');
|
|
265
265
|
const awRegistry = join(awHome, '.aw_registry');
|
package/ecc.mjs
CHANGED
|
@@ -19,10 +19,10 @@ const REQUIRED_ECC_FILES = [
|
|
|
19
19
|
"scripts/sync-ecc-to-codex.sh",
|
|
20
20
|
];
|
|
21
21
|
|
|
22
|
-
const MARKETPLACE_NAME = "aw-marketplace";
|
|
22
|
+
export const MARKETPLACE_NAME = "aw-marketplace";
|
|
23
23
|
const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
|
|
24
24
|
|
|
25
|
-
function eccDir() { return join(homedir(), ".aw-ecc"); }
|
|
25
|
+
export function eccDir() { return join(homedir(), ".aw-ecc"); }
|
|
26
26
|
|
|
27
27
|
// "claude" uses a no-commands module set so hooks, rules, shared references,
|
|
28
28
|
// and install-state land in ~/.claude/ — while slash commands stay owned by
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Commit attribution trailers — called from the prepare-commit-msg git hook.
|
|
4
|
+
*
|
|
5
|
+
* Adds to the commit message file:
|
|
6
|
+
* Agent-Harness: <harness>; session=<id> when git runs inside an agent session
|
|
7
|
+
* (harness only when the id is unsafe)
|
|
8
|
+
* AW-Skills: <a>, <b>, stage:<s>, when that session ran an AW skill or
|
|
9
|
+
* <co-author line> and AW-Version: <v> stage at any point (aw_skills)
|
|
10
|
+
*
|
|
11
|
+
* The laptop records which AW skills ran; the server decides which are SDLC.
|
|
12
|
+
*
|
|
13
|
+
* The session comes from the variable each agent sets in its shell
|
|
14
|
+
* (CLAUDE_CODE_SESSION_ID, CURSOR_CONVERSATION_ID, CODEX_THREAD_ID). It is the
|
|
15
|
+
* same id the AW hooks send as session_id, so the server can join on it. A
|
|
16
|
+
* hand-typed commit has none of them and gets no trailer. Trailers the agent
|
|
17
|
+
* wrote itself are left alone. On --amend in the same session our own lines
|
|
18
|
+
* are replaced, not repeated; a message that already carries another session's
|
|
19
|
+
* Agent-Harness (rebase, cherry-pick, amend) is left unchanged. Any error
|
|
20
|
+
* leaves the message unchanged.
|
|
21
|
+
*
|
|
22
|
+
* Usage: node aw-usage-commit-trailer.js <commit-msg-file> <co-author-line>
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const { readSessionAwSkills, getAwCliVersionDetails } = require('../lib/aw-usage-telemetry');
|
|
29
|
+
|
|
30
|
+
const SESSION_ENV = [
|
|
31
|
+
['CLAUDE_CODE_SESSION_ID', 'claude'],
|
|
32
|
+
['CURSOR_CONVERSATION_ID', 'cursor'],
|
|
33
|
+
['CODEX_THREAD_ID', 'codex'],
|
|
34
|
+
];
|
|
35
|
+
// Also keeps the id from adding lines to the message or leaving the sessions folder.
|
|
36
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
37
|
+
// Lines this script owns: replaced on --amend so the latest values win.
|
|
38
|
+
const OWN_TRAILER = /^(?:Agent-Harness|AW-Skills|AW-Version): /;
|
|
39
|
+
|
|
40
|
+
function commitSession(env) {
|
|
41
|
+
for (const [name, harness] of SESSION_ENV) {
|
|
42
|
+
const id = env[name];
|
|
43
|
+
if (!id) continue;
|
|
44
|
+
// An unsafe id still tells us the harness; it is never written or used as a path.
|
|
45
|
+
if (!SAFE_SESSION_ID.test(id)) return { harness, sessionId: null };
|
|
46
|
+
// Cursor cloud agents get a bc-<uuid> conversation id; local agents a plain UUID.
|
|
47
|
+
return { harness: harness === 'cursor' && id.startsWith('bc-') ? 'cursor-cloud' : harness, sessionId: id };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildTrailers({ env, coAuthor }) {
|
|
53
|
+
const session = commitSession(env);
|
|
54
|
+
if (!session) return [];
|
|
55
|
+
if (!session.sessionId) return [`Agent-Harness: ${session.harness}`];
|
|
56
|
+
const trailers = [`Agent-Harness: ${session.harness}; session=${session.sessionId}`];
|
|
57
|
+
const skills = readSessionAwSkills(session.sessionId);
|
|
58
|
+
if (coAuthor && skills.length > 0) {
|
|
59
|
+
trailers.push(`AW-Skills: ${skills.join(', ')}`, coAuthor);
|
|
60
|
+
const version = getAwCliVersionDetails().version;
|
|
61
|
+
if (version) trailers.push(`AW-Version: ${version}`);
|
|
62
|
+
}
|
|
63
|
+
return trailers;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// git commit -v: git drops this line and everything below it (the diff).
|
|
67
|
+
const SCISSORS = /^# -+ >8 -+$/;
|
|
68
|
+
const TRAILER_LINE = /^[A-Za-z][A-Za-z0-9-]*: /;
|
|
69
|
+
// Editor commits end with git's `#` help text, which git strips after the editor.
|
|
70
|
+
// shortcut: assumes the default core.commentChar '#'; read it from git config if teams change it.
|
|
71
|
+
const isComment = (line) => line.startsWith('#');
|
|
72
|
+
|
|
73
|
+
function appendTrailers(message, trailers) {
|
|
74
|
+
const eol = message.includes('\r\n') ? '\r\n' : '\n';
|
|
75
|
+
const lines = message.split(/\r?\n/);
|
|
76
|
+
// A commit another session wrote (rebase, cherry-pick, amend) keeps its own
|
|
77
|
+
// attribution: replacing it would erase that session's evidence.
|
|
78
|
+
const harnessLines = lines.filter((line) => line.startsWith('Agent-Harness: '));
|
|
79
|
+
if (harnessLines.length > 0 && !harnessLines.includes(trailers[0])) return message;
|
|
80
|
+
|
|
81
|
+
// The message git keeps ends at the last line that is not blank, a comment or below the cut.
|
|
82
|
+
const cut = lines.findIndex((line) => SCISSORS.test(line));
|
|
83
|
+
const head = cut === -1 ? lines : lines.slice(0, cut);
|
|
84
|
+
let end = head.length;
|
|
85
|
+
while (end > 0 && (!head[end - 1].trim() || isComment(head[end - 1]))) end -= 1;
|
|
86
|
+
// Drop our own stale lines (from an earlier commit of this message) first.
|
|
87
|
+
const kept = head.slice(0, end).filter((line) => !OWN_TRAILER.test(line) || trailers.includes(line));
|
|
88
|
+
// No real content: leave it, so git still aborts an empty commit message.
|
|
89
|
+
if (!kept.some((line) => line.trim() && !isComment(line))) return message;
|
|
90
|
+
const missing = trailers.filter((trailer) => !kept.includes(trailer));
|
|
91
|
+
if (missing.length === 0) return message;
|
|
92
|
+
|
|
93
|
+
// Join the existing trailer block when the message already ends with one.
|
|
94
|
+
const body = kept.join('\n').replace(/\s+$/, '');
|
|
95
|
+
const lastParagraph = body.split(/\n\s*\n/).pop();
|
|
96
|
+
const endsWithTrailers = body.includes('\n') && lastParagraph.split('\n').every((line) => TRAILER_LINE.test(line));
|
|
97
|
+
const rest = [...head.slice(end), ...(cut === -1 ? [] : lines.slice(cut))].join('\n');
|
|
98
|
+
const updated = `${body}${endsWithTrailers ? '\n' : '\n\n'}${missing.join('\n')}\n${rest}`;
|
|
99
|
+
return eol === '\n' ? updated : updated.replace(/\n/g, eol);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function main() {
|
|
103
|
+
const [file, coAuthor] = process.argv.slice(2);
|
|
104
|
+
if (!file) return;
|
|
105
|
+
try {
|
|
106
|
+
const trailers = buildTrailers({ env: process.env, coAuthor });
|
|
107
|
+
if (trailers.length === 0) return;
|
|
108
|
+
const message = fs.readFileSync(file, 'utf8');
|
|
109
|
+
const updated = appendTrailers(message, trailers);
|
|
110
|
+
if (updated !== message) fs.writeFileSync(file, updated);
|
|
111
|
+
} catch {
|
|
112
|
+
// Never block or change a commit because attribution failed.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (require.main === module) {
|
|
117
|
+
main();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
appendTrailers,
|
|
122
|
+
buildTrailers,
|
|
123
|
+
commitSession,
|
|
124
|
+
};
|
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
* this hook does not know
|
|
9
9
|
*
|
|
10
10
|
* skill_origin rule (P11):
|
|
11
|
-
* - Skill tool: `aw` for `aw:<x
|
|
12
|
-
* -
|
|
11
|
+
* - Skill tool: `aw` for `aw:<x>`. Otherwise the skill's folder in ~/.claude/skills
|
|
12
|
+
* (`ns:name` -> `ns-name`, else the folder whose SKILL.md declares that name),
|
|
13
|
+
* when present, decides by the file rule below; else
|
|
14
|
+
* `other` for any other namespace, omitted for a bare name.
|
|
15
|
+
* - File or shell read: `aw` when the SKILL.md, or the file a link to it points
|
|
16
|
+
* to (aw links registry skills into ~/.codex/skills and ~/.cursor/skills), is
|
|
17
|
+
* under ~/.aw-ecc/, ~/.aw/,
|
|
13
18
|
* ~/.claude/plugins/cache/aw-marketplace/, or is a destinationPath in
|
|
14
19
|
* ~/.cursor/ecc-install-state.json or ~/.codex/ecc-install-state.json
|
|
15
20
|
* (those folders mix AW and other skills; the install state lists what AW
|
|
@@ -66,10 +71,36 @@ function listedSkillPaths(home) {
|
|
|
66
71
|
return listedCache.get(home);
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
// Read-only: resolves links, never creates, moves or deletes anything. The OS
|
|
75
|
+
// resolver (.native) applies `..` after following links; the JS one collapses
|
|
76
|
+
// `..` first, so `~/.aw/link/../x` would wrongly stay under ~/.aw.
|
|
77
|
+
function realPath(filePath) {
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync.native(filePath);
|
|
80
|
+
} catch {
|
|
81
|
+
// Broken link, loop, unreadable folder or missing file.
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isUnderAwRoot(filePath, homes) {
|
|
87
|
+
const file = toForwardSlashes(filePath);
|
|
88
|
+
return homes.some(home => AW_ROOTS.some(segments => file.startsWith(`${toForwardSlashes(path.join(home, ...segments))}/`)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The real file decides: aw links registry skills into ~/.codex/skills,
|
|
92
|
+
// ~/.cursor/skills and ~/.claude/skills, pointing into ~/.aw. A path that cannot
|
|
93
|
+
// be resolved (missing, broken link) is judged as given.
|
|
94
|
+
// Known limit: an AW folder that is itself a link (e.g. ~/.aw-ecc -> a dev
|
|
95
|
+
// checkout) is not followed, so its skills count as other. `aw init` clones into
|
|
96
|
+
// the real folders.
|
|
69
97
|
function originFromPath(filePath, home) {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
98
|
+
const given = expandHome(filePath, home);
|
|
99
|
+
const absolute = path.normalize(given);
|
|
100
|
+
if (listedSkillPaths(home).has(toForwardSlashes(absolute))) return 'aw';
|
|
101
|
+
const real = realPath(given);
|
|
102
|
+
if (!real) return isUnderAwRoot(absolute, [home]) ? 'aw' : 'other';
|
|
103
|
+
return isUnderAwRoot(real, [home, realPath(home) || home]) ? 'aw' : 'other';
|
|
73
104
|
}
|
|
74
105
|
|
|
75
106
|
function originFromName(name) {
|
|
@@ -78,10 +109,69 @@ function originFromName(name) {
|
|
|
78
109
|
return name.slice(0, colon).toLowerCase() === 'aw' ? 'aw' : 'other';
|
|
79
110
|
}
|
|
80
111
|
|
|
112
|
+
// A Skill-tool name as one folder name: `platform-core:echo-direct` -> `platform-core-echo-direct`.
|
|
113
|
+
const SKILL_NAME = /^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/;
|
|
114
|
+
|
|
115
|
+
// Claude's Skill tool gives only a name. Its folder in ~/.claude/skills (often a
|
|
116
|
+
// link aw made into ~/.aw/.aw_registry) is where the skill came from.
|
|
117
|
+
const SKILL_HEAD_BYTES = 2048;
|
|
118
|
+
const namedCache = new Map();
|
|
119
|
+
|
|
120
|
+
function frontmatterName(file) {
|
|
121
|
+
let fd;
|
|
122
|
+
try {
|
|
123
|
+
fd = fs.openSync(file, 'r');
|
|
124
|
+
const buf = Buffer.alloc(SKILL_HEAD_BYTES);
|
|
125
|
+
const head = buf.toString('utf8', 0, fs.readSync(fd, buf, 0, SKILL_HEAD_BYTES, 0)).replace(/^\uFEFF/, '');
|
|
126
|
+
// Only the front matter (between the opening and closing ---) names the skill.
|
|
127
|
+
const frontMatter = head.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
128
|
+
const match = frontMatter && frontMatter[1].match(/^name:[ \t]*["']?([^"'\r\n]+?)["']?[ \t]*$/m);
|
|
129
|
+
return match ? match[1] : null;
|
|
130
|
+
} catch {
|
|
131
|
+
// Unreadable or broken link: it cannot be the skill Claude loaded.
|
|
132
|
+
return null;
|
|
133
|
+
} finally {
|
|
134
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// name -> SKILL.md for skills whose folder name differs from their `name:`
|
|
139
|
+
// (e.g. platform-core-architecture-design declares platform-shared:architecture-design).
|
|
140
|
+
// Read once per process.
|
|
141
|
+
function skillsByName(dir) {
|
|
142
|
+
if (!namedCache.has(dir)) {
|
|
143
|
+
const byName = new Map();
|
|
144
|
+
let entries = [];
|
|
145
|
+
try { entries = fs.readdirSync(dir); } catch { /* no skills folder: nothing to match */ }
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
const file = path.join(dir, entry, 'SKILL.md');
|
|
148
|
+
const name = frontmatterName(file);
|
|
149
|
+
if (name && !byName.has(name)) byName.set(name, file);
|
|
150
|
+
}
|
|
151
|
+
namedCache.set(dir, byName);
|
|
152
|
+
}
|
|
153
|
+
return namedCache.get(dir);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function claudeSkillFile(name, home) {
|
|
157
|
+
if (!SKILL_NAME.test(name) || name.split(':').some(part => /^\.+$/.test(part))) return null;
|
|
158
|
+
const dir = path.join(home, '.claude', 'skills');
|
|
159
|
+
const file = path.join(dir, name.replace(':', '-'), 'SKILL.md');
|
|
160
|
+
if (fs.existsSync(file)) return file;
|
|
161
|
+
return skillsByName(dir).get(name) || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
81
164
|
/** @param {{ name?: string, path?: string }} skill */
|
|
82
|
-
function skillOrigin(skill, { home = os.homedir() } = {}) {
|
|
165
|
+
function skillOrigin(skill, { home: givenHome = os.homedir() } = {}) {
|
|
166
|
+
// One spelling of HOME (no trailing slash) for prefix checks and caches.
|
|
167
|
+
const home = path.resolve(givenHome);
|
|
83
168
|
if (typeof skill?.path === 'string' && skill.path) return originFromPath(skill.path, home);
|
|
84
|
-
if (typeof skill?.name === 'string' && skill.name)
|
|
169
|
+
if (typeof skill?.name === 'string' && skill.name) {
|
|
170
|
+
const byName = originFromName(skill.name);
|
|
171
|
+
if (byName === 'aw') return byName;
|
|
172
|
+
const file = claudeSkillFile(skill.name, home);
|
|
173
|
+
return file ? originFromPath(file, home) : byName;
|
|
174
|
+
}
|
|
85
175
|
return null;
|
|
86
176
|
}
|
|
87
177
|
|
|
@@ -130,6 +220,7 @@ function cursorProbeSkillReads(toolName, toolInput) {
|
|
|
130
220
|
}
|
|
131
221
|
|
|
132
222
|
module.exports = {
|
|
223
|
+
AW_ROOTS,
|
|
133
224
|
skillOrigin,
|
|
134
225
|
skillSourceFields,
|
|
135
226
|
shellSkillReads,
|
|
@@ -17,7 +17,7 @@ const os = require('os');
|
|
|
17
17
|
const { spawn, execSync } = require('child_process');
|
|
18
18
|
const crypto = require('crypto');
|
|
19
19
|
const { newEventId, readHookVersion, nextSeq, withFileLock } = require('./aw-usage-envelope');
|
|
20
|
-
const { commandKey } = require('./aw-usage-commands');
|
|
20
|
+
const { commandKey, BARE_SLASH_COMMANDS } = require('./aw-usage-commands');
|
|
21
21
|
|
|
22
22
|
const SENDER_SCRIPT = path.join(__dirname, '..', 'hooks', 'aw-usage-telemetry-send.js');
|
|
23
23
|
const AW_HOME = path.join(os.homedir(), '.aw');
|
|
@@ -171,24 +171,31 @@ function resolveProjectHash(context) {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
// ── Session file cleanup ─────────────────────────────────────────────
|
|
174
|
-
// Prune session files
|
|
175
|
-
// Called once per session start — best-effort, never blocks.
|
|
174
|
+
// Prune session files idle longer than SESSION_FILE_RETENTION_MS to prevent
|
|
175
|
+
// unbounded growth. Called once per session start — best-effort, never blocks.
|
|
176
176
|
|
|
177
|
-
const SESSION_MAX_AGE_MS = 72 * 60 * 60 * 1000; // 72 hours
|
|
177
|
+
const SESSION_MAX_AGE_MS = 72 * 60 * 60 * 1000; // 72 hours: v1 commit link only
|
|
178
|
+
// Long enough that a session's aw_skills survive until its last commit.
|
|
179
|
+
const SESSION_FILE_RETENTION_MS = 5 * 24 * 60 * 60 * 1000;
|
|
178
180
|
|
|
179
181
|
function pruneStaleSessionFiles() {
|
|
182
|
+
let entries;
|
|
180
183
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
entries = fs.readdirSync(SESSION_DIR);
|
|
185
|
+
} catch {
|
|
186
|
+
return; // No sessions folder yet.
|
|
187
|
+
}
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
// .tmp: left by a crash between an atomic write and its rename.
|
|
191
|
+
if (!entry.endsWith('.json') && !entry.endsWith('.seq') && !entry.endsWith('.tmp')) continue;
|
|
192
|
+
try {
|
|
185
193
|
const filePath = path.join(SESSION_DIR, entry);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
194
|
+
if (now - fs.statSync(filePath).mtimeMs > SESSION_FILE_RETENTION_MS) fs.unlinkSync(filePath);
|
|
195
|
+
} catch {
|
|
196
|
+
// Gone meanwhile, or not a plain file: skip it and keep pruning the rest.
|
|
190
197
|
}
|
|
191
|
-
}
|
|
198
|
+
}
|
|
192
199
|
}
|
|
193
200
|
|
|
194
201
|
// ── Session model persistence ────────────────────────────────────────
|
|
@@ -213,6 +220,8 @@ function readSessionModel(sessionId) {
|
|
|
213
220
|
|
|
214
221
|
function readSessionState(sessionId) {
|
|
215
222
|
if (!sessionId) return {};
|
|
223
|
+
// Keep the session's AW-skills file alive with the session, main file or not.
|
|
224
|
+
try { const now = new Date(); fs.utimesSync(awSkillsPath(sessionId), now, now); } catch { /* none recorded yet */ }
|
|
216
225
|
try {
|
|
217
226
|
const filePath = path.join(SESSION_DIR, sessionId + '.json');
|
|
218
227
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
@@ -333,6 +342,80 @@ function persistSessionPrompt(sessionId, { turn_id: turnId = null, commands = []
|
|
|
333
342
|
|
|
334
343
|
const TYPED_COMMAND_WINDOW_MS = 10 * 60 * 1000;
|
|
335
344
|
|
|
345
|
+
// aw_skills: the AW skills and stages this session ran, as the harness reported
|
|
346
|
+
// them. The prepare-commit-msg hook writes them as the AW-Skills trailer; the
|
|
347
|
+
// server decides which count as SDLC. Only where a skill came from is checked
|
|
348
|
+
// here, so non-AW skill names never reach a commit.
|
|
349
|
+
const AW_SKILLS_MAX = 20;
|
|
350
|
+
const SAFE_AW_SKILL = /^[a-z0-9:_/$.-]{1,60}$/;
|
|
351
|
+
// The AW router in any form: using-aw-skills, aw:/platform-core:using-aw-skills,
|
|
352
|
+
// aw-using-aw-skills, platform-core-using-aw-skills (registry link folders).
|
|
353
|
+
const ROUTER_SKILL = /(?:^|[:/-])using-aw-skills$/;
|
|
354
|
+
|
|
355
|
+
// Typed forms other than slash_ns only match `aw-` names (aw-usage-commands.js);
|
|
356
|
+
// slash_ns also carries other plugins' namespaces (`/caveman`, `/codex:rescue`).
|
|
357
|
+
function isAwTypedCommand(name, commandForm) {
|
|
358
|
+
return commandForm !== 'slash_ns' || name.startsWith('aw:') || BARE_SLASH_COMMANDS.has(name);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The aw_skills entry for an event, or null when it is not AW evidence. */
|
|
362
|
+
function awSkillEntry(eventType, payload) {
|
|
363
|
+
let entry = null;
|
|
364
|
+
if (eventType === 'sdlc_stage_started' || eventType === 'sdlc_stage_completed') {
|
|
365
|
+
entry = typeof payload?.stage === 'string' ? `stage:${payload.stage.toLowerCase()}` : null;
|
|
366
|
+
} else if (eventType === 'skill_invoked' && typeof payload?.skill_name === 'string') {
|
|
367
|
+
const name = payload.skill_name.toLowerCase();
|
|
368
|
+
const isAw = payload.source === 'user_prompt' ? isAwTypedCommand(name, payload.command_form) : payload.skill_origin === 'aw';
|
|
369
|
+
entry = isAw ? name : null;
|
|
370
|
+
}
|
|
371
|
+
if (!entry || !SAFE_AW_SKILL.test(entry)) return null;
|
|
372
|
+
return ROUTER_SKILL.test(entry) ? null : entry;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Own file per session: the older session writers (model, last skill) rewrite
|
|
376
|
+
// <session>.json without the lock, so they could wipe or half-write aw_skills.
|
|
377
|
+
function awSkillsPath(sessionId) {
|
|
378
|
+
return path.join(SESSION_DIR, sessionId + '.aw.json');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function readAwSkillsState(sessionId) {
|
|
382
|
+
try {
|
|
383
|
+
const state = JSON.parse(fs.readFileSync(awSkillsPath(sessionId), 'utf8'));
|
|
384
|
+
return state && typeof state === 'object' ? state : {};
|
|
385
|
+
} catch {
|
|
386
|
+
// Missing or unreadable: no AW skills recorded for this session.
|
|
387
|
+
return {};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function recordSessionAwSkill(sessionId, entry, { now = Date.now() } = {}) {
|
|
392
|
+
if (!sessionId || sessionId === 'unknown') return;
|
|
393
|
+
const write = () => {
|
|
394
|
+
const state = readAwSkillsState(sessionId);
|
|
395
|
+
const skills = Array.isArray(state.aw_skills) ? state.aw_skills : [];
|
|
396
|
+
const filePath = awSkillsPath(sessionId);
|
|
397
|
+
const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
398
|
+
fs.writeFileSync(tmpPath, JSON.stringify({
|
|
399
|
+
aw_skills: skills.includes(entry) || skills.length >= AW_SKILLS_MAX ? skills : [...skills, entry],
|
|
400
|
+
aw_used_at: new Date(now).toISOString(),
|
|
401
|
+
}));
|
|
402
|
+
fs.renameSync(tmpPath, filePath);
|
|
403
|
+
};
|
|
404
|
+
try {
|
|
405
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
406
|
+
if (!withFileLock(sessionLockPath(sessionId), write, { waitMs: SESSION_LOCK_WAIT_MS }).ok) write();
|
|
407
|
+
} catch {
|
|
408
|
+
// Not writable: commits from this session carry no AW trailer.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The AW skills the session ran, safe for a trailer; [] when unknown. */
|
|
413
|
+
function readSessionAwSkills(sessionId) {
|
|
414
|
+
if (!sessionId) return [];
|
|
415
|
+
const skills = readAwSkillsState(sessionId).aw_skills;
|
|
416
|
+
return Array.isArray(skills) ? skills.filter(s => typeof s === 'string' && SAFE_AW_SKILL.test(s)) : [];
|
|
417
|
+
}
|
|
418
|
+
|
|
336
419
|
function isCurrentPrompt(prompt, turnId, now) {
|
|
337
420
|
if (prompt.turn_id && turnId) return prompt.turn_id === turnId;
|
|
338
421
|
return now - parseUpdatedAt(prompt.at) <= TYPED_COMMAND_WINDOW_MS;
|
|
@@ -689,6 +772,8 @@ function buildEvent(hookInput, eventType, payload) {
|
|
|
689
772
|
const seq = nextSeq(sessionId);
|
|
690
773
|
const projectHash = computeProjectHash(cwd);
|
|
691
774
|
if (eventType !== 'commit_created') touchSessionActivity(projectHash, sessionId, harness);
|
|
775
|
+
const awSkill = awSkillEntry(eventType, payload);
|
|
776
|
+
if (awSkill) recordSessionAwSkill(sessionId, awSkill);
|
|
692
777
|
|
|
693
778
|
return {
|
|
694
779
|
session_id: sessionId,
|
|
@@ -748,6 +833,7 @@ module.exports = {
|
|
|
748
833
|
computeProjectHash,
|
|
749
834
|
findRecentSdlcSessionForProject,
|
|
750
835
|
findRecentActiveSessionForProject,
|
|
836
|
+
readSessionAwSkills,
|
|
751
837
|
touchSessionActivity,
|
|
752
838
|
readLastAssistantFromTranscript,
|
|
753
839
|
resolvePromptText,
|
package/hooks.mjs
CHANGED
|
@@ -13,6 +13,15 @@ import { AW_CO_AUTHOR } from './constants.mjs';
|
|
|
13
13
|
import { awUsageShellScriptPath } from './aw-usage-wiring.mjs';
|
|
14
14
|
|
|
15
15
|
const COMMIT_TELEMETRY_HOOK = awUsageShellScriptPath('aw-usage-commit-created.js');
|
|
16
|
+
const COMMIT_TRAILER_HOOK = awUsageShellScriptPath('aw-usage-commit-trailer.js');
|
|
17
|
+
|
|
18
|
+
// Adds Agent-Harness, and AW-Skills + AW co-author + AW-Version for sessions
|
|
19
|
+
// that ran an AW skill or stage (see aw-usage-commit-trailer.js). Never fails the commit.
|
|
20
|
+
const ADD_COMMIT_TRAILERS = `\
|
|
21
|
+
TRAILER_HOOK="${COMMIT_TRAILER_HOOK}"
|
|
22
|
+
if command -v node >/dev/null 2>&1 && [ -f "$TRAILER_HOOK" ]; then
|
|
23
|
+
node "$TRAILER_HOOK" "$1" "${AW_CO_AUTHOR}" >/dev/null 2>&1 || true
|
|
24
|
+
fi`;
|
|
16
25
|
|
|
17
26
|
const HOME = homedir();
|
|
18
27
|
const HOOKS_DIR = join(HOME, '.aw', 'hooks');
|
|
@@ -127,24 +136,15 @@ fi
|
|
|
127
136
|
exit 0
|
|
128
137
|
`;
|
|
129
138
|
|
|
130
|
-
// prepare-commit-msg: synchronous hook —
|
|
139
|
+
// prepare-commit-msg: synchronous hook — adds attribution trailers to commits
|
|
131
140
|
// in AW-linked repos. Skips merge commits and respects AW_NO_COAUTHOR=1 opt-out.
|
|
141
|
+
// Trailers the agent wrote (Claude, Cursor, Codex) are kept.
|
|
132
142
|
const PREPARE_COMMIT_MSG = makeDispatcher('prepare-commit-msg', `\
|
|
133
|
-
#
|
|
143
|
+
# Add attribution trailers unless it's a merge commit or opt-out is set
|
|
134
144
|
if [ "$2" != "merge" ] && [ "$AW_NO_COAUTHOR" != "1" ]; then
|
|
135
|
-
# Only
|
|
145
|
+
# Only AW-linked repos (check for .aw/ symlink or worktree)
|
|
136
146
|
if [ -L ".aw" ] || [ -f ".aw/.git" ]; then
|
|
137
|
-
|
|
138
|
-
# Replace Claude/Cursor co-author trailers with AW
|
|
139
|
-
sed -i.bak '/^Co-Authored-By:.*Claude/d' "$1" 2>/dev/null || true
|
|
140
|
-
sed -i.bak '/^Co-Authored-By:.*noreply@anthropic\\.com/d' "$1" 2>/dev/null || true
|
|
141
|
-
sed -i.bak '/^Co-Authored-By:.*Cursor/d' "$1" 2>/dev/null || true
|
|
142
|
-
rm -f "$1.bak"
|
|
143
|
-
# Only append if not already present (idempotent)
|
|
144
|
-
if ! grep -qF "$TRAILER" "$1"; then
|
|
145
|
-
echo "" >> "$1"
|
|
146
|
-
echo "$TRAILER" >> "$1"
|
|
147
|
-
fi
|
|
147
|
+
${ADD_COMMIT_TRAILERS}
|
|
148
148
|
fi
|
|
149
149
|
fi`);
|
|
150
150
|
|
|
@@ -253,19 +253,9 @@ case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*|*/.aw|*/.aw/*) exit 0 ;; esac
|
|
|
253
253
|
# Opt-out escape hatch
|
|
254
254
|
[ "$AW_NO_COAUTHOR" = "1" ] && exit 0
|
|
255
255
|
|
|
256
|
-
# Only
|
|
256
|
+
# Only AW-linked repos (check for .aw/ symlink or worktree)
|
|
257
257
|
if [ -L ".aw" ] || [ -f ".aw/.git" ]; then
|
|
258
|
-
|
|
259
|
-
# Replace Claude/Cursor co-author trailers with AW
|
|
260
|
-
sed -i.bak '/^Co-Authored-By:.*Claude/d' "$1" 2>/dev/null || true
|
|
261
|
-
sed -i.bak '/^Co-Authored-By:.*noreply@anthropic\\.com/d' "$1" 2>/dev/null || true
|
|
262
|
-
sed -i.bak '/^Co-Authored-By:.*Cursor/d' "$1" 2>/dev/null || true
|
|
263
|
-
rm -f "$1.bak"
|
|
264
|
-
# Only append if not already present (idempotent)
|
|
265
|
-
if ! grep -qF "$TRAILER" "$1"; then
|
|
266
|
-
echo "" >> "$1"
|
|
267
|
-
echo "$TRAILER" >> "$1"
|
|
268
|
-
fi
|
|
258
|
+
${ADD_COMMIT_TRAILERS}
|
|
269
259
|
fi
|
|
270
260
|
|
|
271
261
|
exit 0
|