@ghl-ai/aw 0.1.93 → 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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * AW Usage Telemetry — how a skill was reached and where its file came from (B1, B6).
3
+ *
4
+ * skillOrigin({ name } | { path }) — 'aw' | 'other' | null (P11)
5
+ * skillSourceFields(source, input, skill) — payload fields for a skill_invoked event
6
+ * shellSkillReads(command) — every SKILL.md a shell command reads
7
+ * cursorProbeSkillReads(tool, toolInput) — B6 probe: a SKILL.md path given to a tool
8
+ * this hook does not know
9
+ *
10
+ * skill_origin rule (P11):
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/,
18
+ * ~/.claude/plugins/cache/aw-marketplace/, or is a destinationPath in
19
+ * ~/.cursor/ecc-install-state.json or ~/.codex/ecc-install-state.json
20
+ * (those folders mix AW and other skills; the install state lists what AW
21
+ * copied). Otherwise `other`. No skill list lives in the hook (spec decision 1).
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const path = require('path');
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+
30
+ const MAX_STATE_BYTES = 2 * 1024 * 1024;
31
+ const INSTALL_STATE_FILES = [['.cursor', 'ecc-install-state.json'], ['.codex', 'ecc-install-state.json']];
32
+ const AW_ROOTS = [['.aw-ecc'], ['.aw'], ['.claude', 'plugins', 'cache', 'aw-marketplace']];
33
+ // B6 probe skips tools whose skill reads are already counted, and search tools:
34
+ // Cursor Greps for `**/<name>/SKILL.md` before reading it, which is not a load.
35
+ const PROBE_SKIPPED_TOOLS = new Set(['Read', 'Bash', 'Shell', 'Skill', 'Grep', 'Glob']);
36
+ const SKILL_FILE = /(?<=^|[\s'"`;|&<>()=])((?:[^\s'"`;|&<>()]*\/)?skills\/([A-Za-z0-9._:-]+)\/SKILL\.md)(?![\w.-])/g;
37
+
38
+ const listedCache = new Map();
39
+
40
+ function toForwardSlashes(value) {
41
+ return String(value).replace(/\\/g, '/');
42
+ }
43
+
44
+ function expandHome(filePath, home) {
45
+ const match = filePath.match(/^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/);
46
+ return match ? path.join(home, filePath.slice(match[0].length)) : filePath;
47
+ }
48
+
49
+ function readInstallState(statePath) {
50
+ try {
51
+ if (fs.statSync(statePath).size > MAX_STATE_BYTES) return [];
52
+ const operations = JSON.parse(fs.readFileSync(statePath, 'utf8'))?.operations;
53
+ return Array.isArray(operations) ? operations.map(op => op?.destinationPath).filter(p => typeof p === 'string') : [];
54
+ } catch {
55
+ // Missing, too big or not JSON: nothing is listed, so the skill counts as `other`.
56
+ return [];
57
+ }
58
+ }
59
+
60
+ // Install-state files are read once per process.
61
+ function listedSkillPaths(home) {
62
+ if (!listedCache.has(home)) {
63
+ const listed = new Set();
64
+ for (const segments of INSTALL_STATE_FILES) {
65
+ for (const destination of readInstallState(path.join(home, ...segments))) {
66
+ if (/\/SKILL\.md$/i.test(toForwardSlashes(destination))) listed.add(toForwardSlashes(path.normalize(destination)));
67
+ }
68
+ }
69
+ listedCache.set(home, listed);
70
+ }
71
+ return listedCache.get(home);
72
+ }
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.
97
+ function originFromPath(filePath, home) {
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';
104
+ }
105
+
106
+ function originFromName(name) {
107
+ const colon = String(name).indexOf(':');
108
+ if (colon <= 0) return null;
109
+ return name.slice(0, colon).toLowerCase() === 'aw' ? 'aw' : 'other';
110
+ }
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
+
164
+ /** @param {{ name?: string, path?: string }} skill */
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);
168
+ if (typeof skill?.path === 'string' && skill.path) return originFromPath(skill.path, home);
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
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * @param {'skill_tool'|'file_read'|'shell_read'|'other_tool'} source
180
+ * @param {object} input hook input
181
+ * @param {{ name?: string, path?: string }} skill
182
+ */
183
+ function skillSourceFields(source, input, skill, opts = {}) {
184
+ const origin = skillOrigin(skill, opts);
185
+ return {
186
+ source,
187
+ tool_name: input?.tool_name || 'unknown',
188
+ ...(typeof input?.tool_use_id === 'string' && input.tool_use_id ? { tool_use_id: input.tool_use_id } : {}),
189
+ ...(origin ? { skill_origin: origin } : {}),
190
+ };
191
+ }
192
+
193
+ /** Every distinct skill whose SKILL.md appears in the text, first-seen order. */
194
+ function shellSkillReads(text) {
195
+ const reads = [];
196
+ const seen = new Set();
197
+ for (const match of toForwardSlashes(text || '').matchAll(SKILL_FILE)) {
198
+ if (seen.has(match[2])) continue;
199
+ seen.add(match[2]);
200
+ reads.push({ name: match[2], path: match[1] });
201
+ }
202
+ return reads;
203
+ }
204
+
205
+ function shellSkillNames(command) {
206
+ return shellSkillReads(command).map(read => read.name);
207
+ }
208
+
209
+ function stringsToDepth(value, depth) {
210
+ if (typeof value === 'string') return [value];
211
+ if (depth === 0 || !value || typeof value !== 'object') return [];
212
+ return Object.values(value).flatMap(child => stringsToDepth(child, depth - 1));
213
+ }
214
+
215
+ // B6 probe: remove in the release after the one that ships it
216
+ // (https://app.clickup.com/t/14zhcp5e0b5).
217
+ function cursorProbeSkillReads(toolName, toolInput) {
218
+ if (!toolName || PROBE_SKIPPED_TOOLS.has(toolName)) return [];
219
+ return shellSkillReads(stringsToDepth(toolInput, 2).join('\n'));
220
+ }
221
+
222
+ module.exports = {
223
+ AW_ROOTS,
224
+ skillOrigin,
225
+ skillSourceFields,
226
+ shellSkillReads,
227
+ shellSkillNames,
228
+ cursorProbeSkillReads,
229
+ };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * AW Usage Telemetry — loop-aware stage events (B4).
3
+ *
4
+ * isCompletionStatus(status) — P13 text rule for a finished stage
5
+ * decideStageEvents(history, { stage, status, now }) — pure: events + new history
6
+ * processStateJsonWrite({ filePath, … }) — read the written state.json, update the
7
+ * laptop history, return the events to send
8
+ *
9
+ * A write to .aw_docs/features/<slug>/state.json is a start when its `stage`
10
+ * is new or changed (or the same stage again after it was completed), and a
11
+ * completion when its `status` reads as finished. History lives on the laptop
12
+ * in ~/.aw/telemetry/stages/<project_hash>/<slug>.json (P12): the hook never
13
+ * writes into the repo. When the stage skills write a fixed status and a
14
+ * stage history into state.json (https://app.clickup.com/t/14zhcp5e0bh), only
15
+ * the input of decideStageEvents changes.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const path = require('path');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const { withFileLock, sessionFileName } = require('./aw-usage-envelope');
24
+ const { commandKey } = require('./aw-usage-commands');
25
+
26
+ const STAGES_DIR = path.join(os.homedir(), '.aw', 'telemetry', 'stages');
27
+ // `investigate` is a valid stage outside the plan → ship order.
28
+ const KNOWN_STAGES = new Set(['plan', 'build', 'test', 'verify', 'review', 'deploy', 'ship', 'investigate']);
29
+ const STAGE_FOR_COMMAND = {
30
+ plan: 'plan', build: 'build', execute: 'build', test: 'test', verify: 'verify',
31
+ review: 'review', deploy: 'deploy', ship: 'ship', investigate: 'investigate',
32
+ };
33
+ const STATE_JSON = /(?:^|\/)\.aw_docs\/features\/([^/]+)\/state\.json$/;
34
+ const MAX_STATE_BYTES = 1024 * 1024;
35
+ const MAX_STATUS_RAW = 60;
36
+ const MAX_PATTERN_ID = 100;
37
+ const COMPLETED = /(?:^|_)(?:complete|completed|done|passed|pass)(?:_|$)/;
38
+ const NOT_COMPLETED = /(?:^|_)(?:blocked|failed|fail)(?:_|$)/;
39
+
40
+ // `stage`: the stage the write is in. `ready_for_<stage>` for that same stage
41
+ // means it has not started yet, so it is not a completion.
42
+ function isCompletionStatus(status, stage = null) {
43
+ if (typeof status !== 'string' || !status) return false;
44
+ const value = status.toLowerCase().replace(/[^a-z0-9]+/g, '_');
45
+ if (NOT_COMPLETED.test(value)) return false;
46
+ if (value.startsWith('ready_for_')) return !stage || value !== `ready_for_${stage}`;
47
+ return COMPLETED.test(value);
48
+ }
49
+
50
+ function decideStageEvents(history, { stage, status, now }) {
51
+ if (!KNOWN_STAGES.has(stage)) return { events: [], history };
52
+ const entries = Array.isArray(history?.entries) ? [...history.entries] : [];
53
+ const events = [];
54
+ const completion = isCompletionStatus(status, stage);
55
+ const current = entries[entries.length - 1];
56
+
57
+ const isNewStage = !current || current.stage !== stage;
58
+ if (isNewStage || (current.completed && !completion)) {
59
+ const loopIndex = entries.filter(entry => entry.stage === stage).length;
60
+ events.push({ type: 'started', stage, from_stage: current ? current.stage : null, loop_index: loopIndex });
61
+ entries.push({ stage, loop_index: loopIndex, started_at: now, completed: false });
62
+ }
63
+
64
+ const open = entries[entries.length - 1];
65
+ if (completion && open.stage === stage && !open.completed) {
66
+ events.push({ type: 'completed', stage, loop_index: open.loop_index });
67
+ entries[entries.length - 1] = { ...open, completed: true };
68
+ }
69
+ return { events, history: { entries } };
70
+ }
71
+
72
+ /** 'user' when the last prompt typed the command for this stage, else 'agent'. */
73
+ function stageStartedBy(stage, lastPromptCommands) {
74
+ const typed = Array.isArray(lastPromptCommands) ? lastPromptCommands : [];
75
+ return typed.some(command => STAGE_FOR_COMMAND[commandKey(command?.raw_name)] === stage) ? 'user' : 'agent';
76
+ }
77
+
78
+ function readStateJson(absolutePath) {
79
+ try {
80
+ if (fs.statSync(absolutePath).size > MAX_STATE_BYTES) return null;
81
+ const state = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
82
+ return state && typeof state === 'object' && !Array.isArray(state) ? state : null;
83
+ } catch {
84
+ // Unreadable or not JSON: no stage event for this write.
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function readHistory(historyPath) {
90
+ try {
91
+ return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
92
+ } catch {
93
+ // First write for this feature on this laptop.
94
+ return { entries: [] };
95
+ }
96
+ }
97
+
98
+ // tmp file + rename: a crash mid-write never leaves half a history behind.
99
+ function writeHistory(historyPath, history) {
100
+ const tmpPath = `${historyPath}.${process.pid}.tmp`;
101
+ fs.writeFileSync(tmpPath, JSON.stringify(history), { mode: 0o600 });
102
+ fs.renameSync(tmpPath, historyPath);
103
+ }
104
+
105
+ function toEvent(decision, { slug, relativePath, state, startedBy }) {
106
+ if (decision.type === 'started') {
107
+ const patternId = typeof state.pattern_id === 'string' && state.pattern_id.length <= MAX_PATTERN_ID ? state.pattern_id : null;
108
+ return {
109
+ eventType: 'sdlc_stage_started',
110
+ payload: {
111
+ feature_slug: slug,
112
+ stage: decision.stage,
113
+ from_stage: decision.from_stage,
114
+ loop_index: decision.loop_index,
115
+ started_by: startedBy,
116
+ ...(patternId ? { pattern_id: patternId } : {}),
117
+ file_path: relativePath,
118
+ },
119
+ };
120
+ }
121
+ return {
122
+ eventType: 'sdlc_stage_completed',
123
+ payload: {
124
+ feature_slug: slug,
125
+ stage: decision.stage,
126
+ loop_index: decision.loop_index,
127
+ status_raw: String(state.status).slice(0, MAX_STATUS_RAW),
128
+ file_path: relativePath,
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * @param {{ filePath: string, projectRoot: string, projectHash: string|null,
135
+ * lastPromptCommands?: Array<{ raw_name: string }>, stagesDir?: string, now?: () => string }} opts
136
+ * @returns {Array<{ eventType: string, payload: object }>}
137
+ */
138
+ function processStateJsonWrite({ filePath, projectRoot, projectHash, lastPromptCommands = [], stagesDir = STAGES_DIR, now = () => new Date().toISOString() }) {
139
+ const norm = String(filePath || '').replace(/\\/g, '/');
140
+ if (!projectHash || !projectRoot) return [];
141
+ const root = path.posix.normalize(String(projectRoot).replace(/\\/g, '/')).replace(/\/+$/, '');
142
+ const isAbsolute = norm.startsWith('/') || /^[A-Za-z]:\//.test(norm);
143
+ // Resolve `..` before the check, so a path cannot step out of the project.
144
+ const absolutePath = path.posix.normalize(isAbsolute ? norm : `${root}/${norm}`);
145
+ // Only the project's own feature folders are read.
146
+ if (!root || !absolutePath.startsWith(`${root}/`)) return [];
147
+ const relativePath = absolutePath.slice(root.length + 1);
148
+ const match = relativePath.match(STATE_JSON);
149
+ if (!match) return [];
150
+
151
+ const state = readStateJson(absolutePath);
152
+ const stage = typeof state?.stage === 'string' ? state.stage.trim().toLowerCase() : null;
153
+ if (!state || !KNOWN_STAGES.has(stage)) return [];
154
+
155
+ const slug = match[1];
156
+ const featureDir = path.join(stagesDir, sessionFileName(projectHash));
157
+ const historyPath = path.join(featureDir, `${sessionFileName(slug)}.json`);
158
+ try {
159
+ fs.mkdirSync(featureDir, { recursive: true, mode: 0o700 });
160
+ const result = withFileLock(`${historyPath}.lock`, () => {
161
+ const decision = decideStageEvents(readHistory(historyPath), { stage, status: state.status, now: now() });
162
+ if (decision.events.length > 0) writeHistory(historyPath, decision.history);
163
+ return decision.events;
164
+ }, { waitMs: 200 });
165
+ if (!result.ok) return [];
166
+ const startedBy = stageStartedBy(stage, lastPromptCommands);
167
+ return result.value.map(decision => toEvent(decision, { slug, relativePath, state, startedBy }));
168
+ } catch {
169
+ // Telemetry folder not writable: no stage event, the rest of the hook is unaffected.
170
+ return [];
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ isCompletionStatus,
176
+ decideStageEvents,
177
+ stageStartedBy,
178
+ processStateJsonWrite,
179
+ };