@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.
- package/aw-usage-wiring.mjs +387 -0
- package/c4/detect.mjs +4 -2
- package/codex.mjs +74 -19
- package/commands/c4.mjs +8 -6
- package/commands/init.mjs +89 -28
- package/ecc.mjs +3 -3
- package/hook-cleanup.mjs +4 -3
- package/hooks/aw-usage/hooks/aw-usage-commit-created.js +21 -0
- package/hooks/aw-usage/hooks/aw-usage-commit-trailer.js +124 -0
- package/hooks/aw-usage/hooks/aw-usage-post-tool-use.js +159 -33
- package/hooks/aw-usage/hooks/aw-usage-prompt-submit.js +39 -74
- package/hooks/aw-usage/hooks/aw-usage-telemetry-send.js +67 -7
- package/hooks/aw-usage/lib/aw-usage-commands.js +123 -0
- package/hooks/aw-usage/lib/aw-usage-envelope.js +121 -0
- package/hooks/aw-usage/lib/aw-usage-queue.js +168 -0
- package/hooks/aw-usage/lib/aw-usage-skill-source.js +229 -0
- package/hooks/aw-usage/lib/aw-usage-stage-events.js +179 -0
- package/hooks/aw-usage/lib/aw-usage-telemetry.js +260 -14
- package/hooks/aw-usage/package.json +1 -1
- package/hooks/codex-home.mjs +8 -6
- package/hooks.mjs +21 -34
- package/install-aw-usage-hooks.mjs +65 -215
- package/package.json +2 -1
- package/startup.mjs +54 -155
|
@@ -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
|
+
};
|