@ghl-ai/aw 0.1.73 → 0.1.77

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/codex.mjs CHANGED
@@ -88,14 +88,27 @@ set -euo pipefail
88
88
 
89
89
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
90
90
  ROOT_DIR="$(cd "\${SCRIPT_DIR}/.." && pwd)"
91
- TARGET_SCRIPT="\$ROOT_DIR/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh"
92
91
 
93
- if [[ ! -f "\$TARGET_SCRIPT" ]]; then
94
- echo '{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "WARNING: AW session-start hook not found at .aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh. Run aw pull platform or aw init."}}'
95
- exit 0
96
- fi
97
-
98
- exec bash "\$TARGET_SCRIPT"
92
+ # Resolve the using-aw-skills router. Prefer the aw/ namespace (canonical since the
93
+ # platform/core -> aw skills rename); fall back to the platform/core mirror and the
94
+ # global ~/.aw registry so a workspace with a partial local registry still routes.
95
+ # Pointing only at platform/core injected an EMPTY router after the rename (the cause
96
+ # of the SDLC skill-invocation regression).
97
+ TARGET_CANDIDATES=(
98
+ "\$ROOT_DIR/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh"
99
+ "\$HOME/.aw/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh"
100
+ "\$ROOT_DIR/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh"
101
+ "\$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh"
102
+ )
103
+
104
+ for TARGET_SCRIPT in "\${TARGET_CANDIDATES[@]}"; do
105
+ if [[ -f "\$TARGET_SCRIPT" ]]; then
106
+ exec bash "\$TARGET_SCRIPT"
107
+ fi
108
+ done
109
+
110
+ echo '{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "WARNING: AW using-aw-skills router not found in .aw_registry (aw/ or platform/core). Run aw pull aw or aw init."}}'
111
+ exit 0
99
112
  `;
100
113
 
101
114
  export function ensureCodexWorkspaceDefaults(cwd) {
@@ -141,6 +141,9 @@ function parseLegacyClaudeHookTargets(sessionStartEntries = []) {
141
141
  const targets = [];
142
142
  for (const entry of sessionStartEntries) {
143
143
  if (!Array.isArray(entry?.hooks)) continue;
144
+ // Only the deprecated platform/core path is "legacy"; the canonical aw/ path (used by
145
+ // the managed router's for-loop command) must NOT be treated as a stale hook.
146
+ if (entry?.description === 'AW-managed: using-aw-skills session router') continue;
144
147
  for (const hook of entry.hooks) {
145
148
  const command = String(hook?.command || '');
146
149
  if (!command.includes('.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh')) continue;
@@ -582,21 +585,80 @@ function missingCoreClaudeCommandFiles(pluginRoot) {
582
585
  .filter(relativePath => !existsSync(join(pluginRoot, relativePath)));
583
586
  }
584
587
 
588
+ const RUNTIME_HOOK_FIX = 'Run `aw init` or `aw pull platform` to restore the AW runtime hook.';
589
+
590
+ // Anti-false-green gate. A session-start hook can be WIRED yet resolve to a HOLLOW
591
+ // script that emits no routing card — the platform/core -> aw rename left exactly such
592
+ // a stub. Existence alone is not enough: execute the resolved router and require its
593
+ // output to actually name the router, so `aw doctor` reports a problem instead of PASS.
594
+ function probeRouterCard(resolvedPath) {
595
+ if (process.platform === 'win32') return { status: 'warn', reason: 'exec-unavailable' };
596
+ let res;
597
+ try {
598
+ res = spawnSync('bash', [resolvedPath], {
599
+ input: '',
600
+ encoding: 'utf8',
601
+ timeout: 5000,
602
+ env: { ...process.env, CLAUDE_PLUGIN_ROOT: '1' },
603
+ });
604
+ } catch (error) {
605
+ return { status: 'fail', reason: 'exec-error', detail: error instanceof Error ? error.message : String(error) };
606
+ }
607
+ if (res.error || res.status !== 0) {
608
+ const detail = (res.error?.message || `exit ${res.status}`)
609
+ + (res.stderr ? ` :: ${String(res.stderr).trim().slice(-200)}` : '');
610
+ return { status: 'fail', reason: 'exec-error', detail };
611
+ }
612
+ // Substring detection on raw stdout — robust to JSON-escaping quirks (the real script
613
+ // escapes via python3 with an awk fallback); the hollow stub prints "ok" with no card.
614
+ const out = String(res.stdout || '');
615
+ if (!out.includes('using-aw-skills')) return { status: 'fail', reason: 'hollow' };
616
+ if (!out.includes('/aw:plan')) return { status: 'warn', reason: 'partial' };
617
+ return { status: 'pass', reason: 'card' };
618
+ }
619
+
620
+ function buildRuntimeHookCheck(resolvedPath, homeDir) {
621
+ if (!resolvedPath) {
622
+ return makeCheck('runtime-hook', 'AW runtime hook', 'fail',
623
+ 'using-aw-skills hook is missing from ~/.aw_registry', RUNTIME_HOOK_FIX);
624
+ }
625
+ const shortPath = resolvedPath.replace(`${homeDir}/`, '~/');
626
+ const probe = probeRouterCard(resolvedPath);
627
+ if (probe.status === 'pass') {
628
+ return makeCheck('runtime-hook', 'AW runtime hook', 'pass',
629
+ `using-aw-skills router at ${shortPath} emits the routing card`);
630
+ }
631
+ if (probe.reason === 'exec-unavailable') {
632
+ return makeCheck('runtime-hook', 'AW runtime hook', 'warn',
633
+ `Found using-aw-skills hook at ${shortPath}; card content not verified on this platform`);
634
+ }
635
+ if (probe.status === 'warn') {
636
+ return makeCheck('runtime-hook', 'AW runtime hook', 'warn',
637
+ `using-aw-skills router at ${shortPath} emits a partial card (missing /aw:plan surface)`, RUNTIME_HOOK_FIX);
638
+ }
639
+ const why = probe.reason === 'hollow'
640
+ ? `using-aw-skills router at ${shortPath} resolves to a hollow script that emits no routing card`
641
+ : `using-aw-skills router at ${shortPath} resolved but failed to emit a card (${probe.detail || probe.reason})`;
642
+ return makeCheck('runtime-hook', 'AW runtime hook', 'fail', why, RUNTIME_HOOK_FIX);
643
+ }
644
+
585
645
  function buildDoctorChecks(homeDir, cwd) {
586
646
  const startup = getStartupStatus(homeDir);
587
647
  const checks = [];
588
648
  const awRegistryDir = resolveAwRegistryDir(homeDir);
589
649
  const rulesDir = resolveRulesDir(homeDir);
590
650
 
651
+ // Resolve the on-disk using-aw-skills router, preferring the aw/ namespace (canonical
652
+ // since the platform/core -> aw rename) over the legacy platform/core mirror, then probe
653
+ // that it actually emits a routing card (not a hollow stub).
591
654
  const usingAwHookPath = [
655
+ join(homeDir, '.aw_registry', 'aw', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh'),
656
+ join(homeDir, '.aw', '.aw_registry', 'aw', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh'),
592
657
  join(homeDir, '.aw_registry', 'platform', 'core', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh'),
593
658
  join(homeDir, '.aw', '.aw_registry', 'platform', 'core', 'skills', 'using-aw-skills', 'hooks', 'session-start.sh'),
594
659
  ].find(existsSync);
595
660
 
596
- checks.push(usingAwHookPath
597
- ? makeCheck('runtime-hook', 'AW runtime hook', 'pass', `Found using-aw-skills hook at ${usingAwHookPath.replace(`${homeDir}/`, '~/')}`)
598
- : makeCheck('runtime-hook', 'AW runtime hook', 'fail', 'using-aw-skills hook is missing from ~/.aw_registry', 'Run `aw init` or `aw pull platform` to restore the AW runtime hook.'),
599
- );
661
+ checks.push(buildRuntimeHookCheck(usingAwHookPath, homeDir));
600
662
 
601
663
  checks.push(startup.mode === 'enabled'
602
664
  ? makeCheck('routing-mode', 'Routing mode', 'pass', `Global routing mode is ${startup.mode}`)
package/commands/init.mjs CHANGED
@@ -78,6 +78,19 @@ function writeHookManifestBestEffort(manifest, context) {
78
78
  }
79
79
  }
80
80
 
81
+ function installAwUsageHooksBestEffort({ silent = false } = {}) {
82
+ ensureTelemetryConfig();
83
+
84
+ try {
85
+ if (!isDefaultRoutingEnabled(HOME, process.env)) return null;
86
+ const result = installAwUsageHooks();
87
+ return formatAwUsageHooksInstallReport(result);
88
+ } catch (e) {
89
+ if (!silent) fmt.note(`aw-usage hooks install: ${e.message}`, 'Telemetry');
90
+ return null;
91
+ }
92
+ }
93
+
81
94
  function formatIntegrationStatusSummary(statuses, installedNow = []) {
82
95
  if (!statuses || statuses.length === 0) return null;
83
96
 
@@ -512,6 +525,8 @@ export async function initCommand(args) {
512
525
  if (cwd !== HOME) installLocalCommitHook(cwd);
513
526
  if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
514
527
 
528
+ const awUsageHooksReport = installAwUsageHooksBestEffort({ silent });
529
+
515
530
  // Write hook manifest after all hook installation is complete
516
531
  writeHookManifestBestEffort({ eccVersion: AW_ECC_TAG, awVersion: VERSION });
517
532
 
@@ -539,6 +554,7 @@ export async function initCommand(args) {
539
554
  '',
540
555
  ` ${chalk.green('✓')} Registry synced`,
541
556
  ` ${chalk.green('✓')} IDE refreshed — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`,
557
+ awUsageHooksReport ? ` ${chalk.green('✓')} ${awUsageHooksReport}` : null,
542
558
  removedLegacyStartupFiles.length > 0
543
559
  ? ` ${chalk.green('✓')} Removed ${removedLegacyStartupFiles.length} legacy repo startup file${removedLegacyStartupFiles.length > 1 ? 's' : ''}`
544
560
  : null,
@@ -686,25 +702,12 @@ export async function initCommand(args) {
686
702
  ];
687
703
  if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
688
704
 
689
- // Ensure telemetry config exists (generates machine_id on first run)
690
- ensureTelemetryConfig();
691
-
692
705
  // Install bundled aw-usage producer hooks into ~/.claude.
693
706
  // Copies the scripts + lib files and non-destructively merges 5 hook phases
694
707
  // (SessionStart, UserPromptSubmit, PostToolUse, PostToolUseFailure, Stop)
695
708
  // into ~/.claude/settings.json. Idempotent. After this, every Claude Code
696
709
  // session emits usage_events to the live telemetry API automatically.
697
- let awUsageHooksReport = null;
698
- try {
699
- if (isDefaultRoutingEnabled(HOME, process.env)) {
700
- const result = installAwUsageHooks();
701
- awUsageHooksReport = formatAwUsageHooksInstallReport(result);
702
- }
703
- } catch (e) {
704
- // Non-fatal — telemetry is observational. Surface the error in silent mode
705
- // logs but don't block the install.
706
- if (!silent) fmt.note(`aw-usage hooks install: ${e.message}`, 'Telemetry');
707
- }
710
+ const awUsageHooksReport = installAwUsageHooksBestEffort({ silent });
708
711
 
709
712
  // Write hook manifest after all hook installation is complete, including
710
713
  // bundled usage hooks, so `aw nuke` can prune AW-managed settings entries.
package/commands/nuke.mjs CHANGED
@@ -14,6 +14,7 @@ import { chalk } from '../fmt.mjs';
14
14
  import { removeGlobalHooks } from '../hooks.mjs';
15
15
  import { uninstallAwEcc } from '../ecc.mjs';
16
16
  import { removeMcpConfig } from '../mcp.mjs';
17
+ import { purgeManagedClaudeStartupHooks, disableCursorAwRouting } from '../startup.mjs';
17
18
  import { removeContextModeIntegration } from '../integrations/context-mode.mjs';
18
19
  import { listProjectWorktrees } from '../git.mjs';
19
20
  import { removeWorkspaceHookDefaults } from '../codex.mjs';
@@ -245,6 +246,11 @@ export async function nukeCommand(args) {
245
246
  const createdCount = removeCreatedFiles(manifest);
246
247
  const ideCount = removeIdeSymlinks();
247
248
  uninstallAwEcc();
249
+ // Strip AW-managed startup hooks that enable installs into the user's settings
250
+ // (Claude SessionStart router + UserPromptSubmit reminder, Cursor AW session hook).
251
+ // uninstallAwEcc deliberately preserves user settings files, so these are removed here.
252
+ purgeManagedClaudeStartupHooks(HOME);
253
+ disableCursorAwRouting(HOME);
248
254
  removeMcpConfig();
249
255
  removeContextModeIntegration(HOME);
250
256
  // Prune stale hook entries using manifest (catches entries not tracked by install-state)
package/commands/push.mjs CHANGED
@@ -201,6 +201,18 @@ function awDocsFeatureScope(featureSlug) {
201
201
  if (!slug || slug === 'true') {
202
202
  throw new Error('Missing feature slug. Use: aw push --aw-docs-only --feature <feature-slug>');
203
203
  }
204
+ if (slug.includes('/')) {
205
+ const stripped = normalizeRelPath(slug).replace(/^\.aw_docs\//, '');
206
+ if (AW_DOCS_ROOT_NAMESPACES.includes(stripped.split('/')[0])) {
207
+ // Root-namespace trees (e.g. pr-reviews/<repo>/pr-<n>/<run>) are valid —
208
+ // they are just passed positionally, not via --feature.
209
+ throw new Error(
210
+ `--feature takes a flat slug, but "${slug}" is a nested ${stripped.split('/')[0]} path. ` +
211
+ `Pass it positionally instead: aw push --aw-docs-only .aw_docs/${stripped}`
212
+ );
213
+ }
214
+ throw new Error(`Invalid feature slug "${slug}". Feature slugs may not contain "/". For nested doc trees use a root namespace path: aw push --aw-docs-only .aw_docs/pr-reviews/<repo>/pr-<n>/<run>`);
215
+ }
204
216
  if (!/^[A-Za-z0-9._-]+$/.test(slug)) {
205
217
  throw new Error(`Invalid feature slug "${slug}". Feature slugs may contain letters, numbers, dot, underscore, and dash only.`);
206
218
  }
@@ -1138,6 +1150,11 @@ function resolveColocatedEvals(registrySubDir, namespace, parentType, slug, chan
1138
1150
  // ── CODEOWNERS helpers ────────────────────────────────────────────────
1139
1151
 
1140
1152
  async function getGitHubUser() {
1153
+ // GitHub App installation tokens cannot call `gh api user` (403), which made
1154
+ // headless publishes namespace docs under "unknown". Allow CI to set the
1155
+ // publish identity explicitly.
1156
+ const override = (process.env.AW_DOCS_PUBLISH_USER || '').trim();
1157
+ if (override) return override;
1141
1158
  try {
1142
1159
  const { stdout } = await exec('gh api user --jq .login');
1143
1160
  return stdout.trim();
@@ -1669,4 +1686,5 @@ export const __test__ = {
1669
1686
  awDocsRootScope,
1670
1687
  resolveAwDocsScope,
1671
1688
  assertDocsOnlyScopeOrAll,
1689
+ getGitHubUser,
1672
1690
  };
@@ -73,13 +73,15 @@ function renderStatus() {
73
73
  : 'disabled';
74
74
  const claudeStatus = status.claudeDisabled
75
75
  ? 'disabled via ~/.claude/settings.json override'
76
- : status.claudeLegacySessionStartPresent
77
- ? 'enabled (legacy hook present; run aw routing enable to clean)'
78
- : status.claudePluginEnabled && status.claudePluginInstalled
79
- ? 'enabled (plugin installed)'
80
- : status.claudePluginEnabled && !status.claudePluginInstalled
81
- ? 'misconfigured (plugin enabled in settings but not installed)'
82
- : 'disabled (plugin not installed)';
76
+ : status.claudeRouterPresent
77
+ ? 'enabled (managed router)'
78
+ : status.claudeLegacySessionStartPresent
79
+ ? 'enabled (legacy hook present; run aw routing enable to clean)'
80
+ : status.claudePluginEnabled && status.claudePluginInstalled
81
+ ? 'enabled (plugin installed)'
82
+ : status.claudePluginEnabled && !status.claudePluginInstalled
83
+ ? 'misconfigured (plugin enabled in settings but not installed)'
84
+ : 'disabled (plugin not installed)';
83
85
  const cursorStatus = status.cursorSessionStartPresent
84
86
  ? 'enabled'
85
87
  : status.cursorAnySessionStartPresent
package/ecc.mjs CHANGED
@@ -12,7 +12,7 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
12
12
 
13
13
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
14
14
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
15
- export const AW_ECC_TAG = "v1.4.66";
15
+ export const AW_ECC_TAG = "v1.4.67";
16
16
  const REQUIRED_ECC_FILES = [
17
17
  "package.json",
18
18
  "scripts/install-apply.js",
@@ -11,14 +11,23 @@
11
11
 
12
12
  'use strict';
13
13
 
14
- const { buildEvent, sendAsync, isDisabled } = require('../lib/aw-usage-telemetry');
14
+ const {
15
+ buildEvent,
16
+ sendAsync,
17
+ isDisabled,
18
+ findRecentSdlcSessionForProject,
19
+ } = require('../lib/aw-usage-telemetry');
15
20
 
16
21
  function buildCommitCreatedEvent({ commitHash = 'unknown', branch = 'unknown', cwd = process.cwd() } = {}) {
17
- // Git hooks have no harness session context, but cwd lets buildEvent derive
18
- // project_hash so the dashboard can correlate commits back to /aw:* sessions.
19
- const event = buildEvent({ cwd }, 'commit_created', {
22
+ const linkedSession = findRecentSdlcSessionForProject(cwd);
23
+ const event = buildEvent({
24
+ cwd,
25
+ ...(linkedSession?.session_id ? { session_id: linkedSession.session_id } : {}),
26
+ }, 'commit_created', {
20
27
  commit_hash: commitHash,
28
+ commit_sha: commitHash,
21
29
  branch,
30
+ ...(linkedSession?.session_id ? { linked_session_source: 'project_recent_sdlc_session' } : {}),
22
31
  });
23
32
 
24
33
  // Override harness to 'git' since this fires from a git hook, not a harness.
@@ -112,6 +112,44 @@ function getCommand(input) {
112
112
  );
113
113
  }
114
114
 
115
+ function commandSegments(command) {
116
+ return String(command || '')
117
+ .split(/\n|&&|\|\||;/)
118
+ .map(segment => segment.trim())
119
+ .filter(Boolean)
120
+ .map(segment => segment.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, ''))
121
+ .filter(segment => !segment.startsWith('#'));
122
+ }
123
+
124
+ function detectDeployCommand(command) {
125
+ const patterns = [
126
+ { regex: /^(?:npx\s+)?aw\s+deploy\b/i, tool: 'aw', provider: 'aw' },
127
+ { regex: /^(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?deploy(?::[a-z0-9_-]+)?\b/i, tool: 'package-script', provider: 'package-script' },
128
+ { regex: /^gcloud\s+run\s+deploy\b/i, tool: 'gcloud', provider: 'cloud-run' },
129
+ { regex: /^firebase\s+deploy\b/i, tool: 'firebase', provider: 'firebase' },
130
+ { regex: /^vercel\s+(?:deploy\b|--prod\b)/i, tool: 'vercel', provider: 'vercel' },
131
+ { regex: /^wrangler\s+(?:deploy|pages\s+deploy)\b/i, tool: 'wrangler', provider: 'cloudflare' },
132
+ { regex: /^helm\s+upgrade\b/i, tool: 'helm', provider: 'kubernetes' },
133
+ { regex: /^kubectl\s+(?:rollout\s+restart|set\s+image)\b/i, tool: 'kubectl', provider: 'kubernetes' },
134
+ { regex: /^gh\s+workflow\s+run\b.*\b(?:deploy|release|staging|production|prod)\b/i, tool: 'gh', provider: 'github-actions' },
135
+ { regex: /^aws\s+(?:ecs\s+update-service|lambda\s+update-function-code)\b/i, tool: 'aws', provider: 'aws' },
136
+ { regex: /^(?:sam|serverless|sls)\s+deploy\b/i, tool: 'serverless', provider: 'serverless' },
137
+ ];
138
+
139
+ for (const segment of commandSegments(command)) {
140
+ for (const pattern of patterns) {
141
+ if (pattern.regex.test(segment)) {
142
+ return {
143
+ deploy_tool: pattern.tool,
144
+ deploy_provider: pattern.provider,
145
+ };
146
+ }
147
+ }
148
+ }
149
+
150
+ return null;
151
+ }
152
+
115
153
  function normalizeToolResult(input) {
116
154
  const rawToolResponse = parseMaybeJsonObject(input?.tool_response);
117
155
  const rawToolOutput = parseMaybeJsonObject(input?.tool_output);
@@ -232,6 +270,32 @@ function collectPostToolUseEvents(input, options = {}) {
232
270
 
233
271
  if (toolName === 'Shell' || toolName === 'Bash') {
234
272
  const cmd = getCommand(input);
273
+ const deploy = detectDeployCommand(cmd);
274
+ if (deploy) {
275
+ const deployPayload = {
276
+ ...deploy,
277
+ tool_name: toolName,
278
+ sdlc_correlated_command: slashCmd ? slashCmd.command_name : null,
279
+ sdlc_correlated_namespace: slashCmd ? slashCmd.command_namespace : null,
280
+ sdlc_correlated_is_sdlc_stage: slashCmd ? Boolean(slashCmd.is_sdlc_stage) : false,
281
+ };
282
+ const failed = isExplicitFailureExitCode(toolResult.exitCode)
283
+ || inferShellFailureFromMessage(toolName, toolResult.rawErrorMessage);
284
+
285
+ events.push({
286
+ eventType: 'deploy_triggered',
287
+ payload: deployPayload,
288
+ });
289
+ events.push({
290
+ eventType: failed ? 'deploy_failed' : 'deploy_success',
291
+ payload: {
292
+ ...deployPayload,
293
+ status: failed ? 'failed' : 'success',
294
+ ...(isExplicitFailureExitCode(toolResult.exitCode) ? { exit_code: toolResult.exitCode } : {}),
295
+ },
296
+ });
297
+ }
298
+
235
299
  // Codex skill detection: Bash commands that read SKILL.md files.
236
300
  if (!promptSkillOverride) {
237
301
  const skillCmdMatch = cmd.match(/\/skills\/([^/]+)\/SKILL\.md/i);
@@ -362,6 +426,7 @@ if (require.main === module) {
362
426
 
363
427
  module.exports = {
364
428
  collectPostToolUseEvents,
429
+ detectDeployCommand,
365
430
  detectSdlcArtifact,
366
431
  detectTestFramework,
367
432
  inferShellFailureFromMessage,
@@ -109,7 +109,9 @@ function processPromptSubmitInput(input, deps = {}) {
109
109
 
110
110
  if (slash) {
111
111
  persistSkill(getSessionId(input), input?.turn_id || null, slash);
112
- persistSlashCmd(getSessionId(input), slash);
112
+ persistSlashCmd(getSessionId(input), slash, {
113
+ cwd: input?.cwd || (input?.workspace_roots && input.workspace_roots[0]) || null,
114
+ });
113
115
  events.push({
114
116
  eventType: 'skill_invoked',
115
117
  payload: {
@@ -21,6 +21,7 @@ const SENDER_SCRIPT = path.join(__dirname, '..', 'hooks', 'aw-usage-telemetry-se
21
21
  const AW_HOME = path.join(os.homedir(), '.aw');
22
22
  const CONFIG_PATH = path.join(AW_HOME, 'telemetry', 'config.json');
23
23
  const SESSION_DIR = path.join(AW_HOME, 'telemetry', 'sessions');
24
+ const SESSION_PROJECT_DIR = path.join(SESSION_DIR, 'by-project');
24
25
  const DEDUPE_DIR = path.join(os.tmpdir(), 'aw-usage-telemetry-dedupe');
25
26
 
26
27
  // ── Git config cache (once per process) ──────────────────────────────
@@ -157,6 +158,16 @@ function computeProjectHash(cwd) {
157
158
  return crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 16);
158
159
  }
159
160
 
161
+ function normalizeProjectHash(value) {
162
+ return typeof value === 'string' && /^[a-f0-9]{16}$/i.test(value) ? value.toLowerCase() : null;
163
+ }
164
+
165
+ function resolveProjectHash(context) {
166
+ if (!context || typeof context !== 'object') return null;
167
+ return normalizeProjectHash(context.project_hash || context.projectHash)
168
+ || computeProjectHash(context.cwd);
169
+ }
170
+
160
171
  // ── Session file cleanup ─────────────────────────────────────────────
161
172
  // Prune session files older than SESSION_MAX_AGE_MS to prevent unbounded growth.
162
173
  // Called once per session start — best-effort, never blocks.
@@ -241,21 +252,40 @@ function readSessionSkill(sessionId, turnId) {
241
252
  // to the originating /aw:* invocation. Separate from `last_skill` because
242
253
  // the source is the prompt, not the tool, and the lifetime is the whole
243
254
  // session (not just one turn).
244
- function persistSessionSlashCommand(sessionId, slashCommand) {
255
+ function writeProjectSessionIndex(projectHash, sessionId, slashCommand) {
256
+ if (!projectHash || !sessionId || !slashCommand?.is_sdlc_stage) return;
257
+ try {
258
+ fs.mkdirSync(SESSION_PROJECT_DIR, { recursive: true });
259
+ fs.writeFileSync(path.join(SESSION_PROJECT_DIR, `${projectHash}.json`), JSON.stringify({
260
+ project_hash: projectHash,
261
+ session_id: sessionId,
262
+ command_namespace: slashCommand.command_namespace || null,
263
+ command_name: slashCommand.command_name,
264
+ is_sdlc_stage: Boolean(slashCommand.is_sdlc_stage),
265
+ updated_at: new Date().toISOString(),
266
+ }));
267
+ } catch { /* ignore */ }
268
+ }
269
+
270
+ function persistSessionSlashCommand(sessionId, slashCommand, context = {}) {
245
271
  if (!sessionId || !slashCommand?.command_name) return;
246
272
  try {
247
273
  fs.mkdirSync(SESSION_DIR, { recursive: true });
248
274
  const state = readSessionState(sessionId);
275
+ const projectHash = resolveProjectHash(context) || state.project_hash || null;
249
276
  fs.writeFileSync(path.join(SESSION_DIR, sessionId + '.json'), JSON.stringify({
250
277
  ...state,
278
+ ...(projectHash ? { project_hash: projectHash } : {}),
251
279
  last_slash_command: {
252
280
  command_namespace: slashCommand.command_namespace || null,
253
281
  command_name: slashCommand.command_name,
254
282
  command_args: slashCommand.command_args || '',
255
283
  is_sdlc_stage: Boolean(slashCommand.is_sdlc_stage),
284
+ ...(projectHash ? { project_hash: projectHash } : {}),
256
285
  updated_at: new Date().toISOString(),
257
286
  },
258
287
  }));
288
+ writeProjectSessionIndex(projectHash, sessionId, slashCommand);
259
289
  } catch { /* ignore */ }
260
290
  }
261
291
 
@@ -265,6 +295,59 @@ function readSessionLastSlashCommand(sessionId) {
265
295
  return cmd;
266
296
  }
267
297
 
298
+ function parseUpdatedAt(value) {
299
+ const millis = Date.parse(value || '');
300
+ return Number.isFinite(millis) ? millis : 0;
301
+ }
302
+
303
+ function isRecentSdlcSessionCandidate(candidate, projectHash, maxAgeMs) {
304
+ if (!candidate?.session_id) return false;
305
+ if (!candidate?.is_sdlc_stage) return false;
306
+ if (candidate.project_hash !== projectHash) return false;
307
+ const updatedAt = parseUpdatedAt(candidate.updated_at);
308
+ if (!updatedAt) return false;
309
+ return Date.now() - updatedAt <= maxAgeMs;
310
+ }
311
+
312
+ function readProjectSessionIndex(projectHash, maxAgeMs) {
313
+ try {
314
+ const candidate = JSON.parse(fs.readFileSync(path.join(SESSION_PROJECT_DIR, `${projectHash}.json`), 'utf8'));
315
+ return isRecentSdlcSessionCandidate(candidate, projectHash, maxAgeMs) ? candidate : null;
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
321
+ function findRecentSdlcSessionForProject(cwd, maxAgeMs = SESSION_MAX_AGE_MS) {
322
+ const projectHash = computeProjectHash(cwd);
323
+ if (!projectHash) return null;
324
+
325
+ const indexed = readProjectSessionIndex(projectHash, maxAgeMs);
326
+ if (indexed) return indexed;
327
+
328
+ let best = null;
329
+ try {
330
+ const entries = fs.readdirSync(SESSION_DIR);
331
+ for (const entry of entries) {
332
+ if (!entry.endsWith('.json')) continue;
333
+ const sessionId = entry.slice(0, -'.json'.length);
334
+ const state = readSessionState(sessionId);
335
+ const cmd = state.last_slash_command;
336
+ const candidate = {
337
+ session_id: sessionId,
338
+ project_hash: cmd?.project_hash || state.project_hash || null,
339
+ is_sdlc_stage: Boolean(cmd?.is_sdlc_stage),
340
+ updated_at: cmd?.updated_at || state.updated_at || null,
341
+ };
342
+ if (!isRecentSdlcSessionCandidate(candidate, projectHash, maxAgeMs)) continue;
343
+ if (!best || parseUpdatedAt(candidate.updated_at) > parseUpdatedAt(best.updated_at)) {
344
+ best = candidate;
345
+ }
346
+ }
347
+ } catch { /* ignore */ }
348
+ return best;
349
+ }
350
+
268
351
  // ── Short-TTL dedupe guards ──────────────────────────────────────────
269
352
 
270
353
  function normalizeDedupePart(value) {
@@ -503,6 +586,7 @@ module.exports = {
503
586
  readSessionSkill,
504
587
  persistSessionSlashCommand,
505
588
  readSessionLastSlashCommand,
589
+ findRecentSdlcSessionForProject,
506
590
  readLastAssistantFromTranscript,
507
591
  resolvePromptText,
508
592
  getAwCliVersionDetails,
@@ -26,6 +26,8 @@ const CODEX_HOME_PHASE_BLUEPRINTS = {
26
26
  marker: this.scriptMarker,
27
27
  phase: 'SessionStart',
28
28
  targetCandidates: [
29
+ '$HOME/.aw/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh',
30
+ '$HOME/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh',
29
31
  '$HOME/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh',
30
32
  '$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh',
31
33
  ],
package/hooks.mjs CHANGED
@@ -102,7 +102,7 @@ if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; th
102
102
  TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
103
103
  fi
104
104
  if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
105
- COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
105
+ COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
106
106
  BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
107
107
  node "$TELEMETRY_HOOK" "$COMMIT_HASH" "$BRANCH" "$(pwd)" >/dev/null 2>&1
108
108
  fi
@@ -286,7 +286,7 @@ if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; th
286
286
  TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
287
287
  fi
288
288
  if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
289
- COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
289
+ COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
290
290
  BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
291
291
  node "$TELEMETRY_HOOK" "$COMMIT_HASH" "$BRANCH" "$(pwd)" >/dev/null 2>&1
292
292
  fi
package/link.mjs CHANGED
@@ -1,19 +1,41 @@
1
1
  // link.mjs — Create symlinks from IDE dirs → .aw_registry/
2
2
 
3
- import { existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, symlinkSync, rmdirSync, realpathSync } from 'node:fs';
3
+ import { existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, symlinkSync, rmSync, rmdirSync, realpathSync } from 'node:fs';
4
4
  import { join, relative } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import * as fmt from './fmt.mjs';
7
7
  import { getLocalRegistryDir } from './git.mjs';
8
8
 
9
- function forceSymlink(target, linkPath) {
10
- try { unlinkSync(linkPath); } catch { /* not there yet */ }
9
+ function forceSymlink(target, linkPath, { replaceManagedDir = false } = {}) {
10
+ try {
11
+ unlinkSync(linkPath);
12
+ } catch {
13
+ // unlinkSync does not remove a real directory. A stale copied dir from an older
14
+ // install (e.g. an ECC copy, or a previous copy-not-symlink install) can collide
15
+ // with the canonical symlink name — clear it so the registry symlink can take the
16
+ // name. Only do this for AW-managed catalog links, scoped by replaceManagedDir:
17
+ // 'skill' → only when the dir is itself a skill (has SKILL.md), so an unrelated
18
+ // non-skill directory still surfaces the EEXIST error to the caller
19
+ // instead of being silently deleted (no user-data loss).
20
+ // true → managed catalog dirs (evals/docs) that AW fully owns; a stale real
21
+ // copy must be replaced or the canonical link is never refreshed.
22
+ try {
23
+ if (existsSync(linkPath) && lstatSync(linkPath).isDirectory()) {
24
+ const shouldReplace = replaceManagedDir === 'skill'
25
+ ? existsSync(join(linkPath, 'SKILL.md'))
26
+ : replaceManagedDir === true;
27
+ if (shouldReplace) {
28
+ rmSync(linkPath, { recursive: true, force: true });
29
+ }
30
+ }
31
+ } catch { /* best effort */ }
32
+ }
11
33
  symlinkSync(target, linkPath);
12
34
  }
13
35
 
14
36
  function linkNestedSkillRoot(target, linkPath, { silent }) {
15
37
  try {
16
- forceSymlink(target, linkPath);
38
+ forceSymlink(target, linkPath, { replaceManagedDir: 'skill' });
17
39
  return true;
18
40
  } catch (error) {
19
41
  if (!silent) {
@@ -161,6 +183,9 @@ function cleanSymlinksRecursive(dir) {
161
183
  * Compute flat IDE name: namespace-slug (always includes namespace).
162
184
  */
163
185
  function flatName(ns, name) {
186
+ // Avoid a redundant namespace prefix when the skill/agent name already begins with
187
+ // the namespace (e.g. ns=aw, name=aw-plan -> aw-plan, not aw-aw-plan).
188
+ if (name === ns || name.startsWith(`${ns}-`)) return name;
164
189
  return `${ns}-${name}`;
165
190
  }
166
191
 
@@ -219,7 +244,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
219
244
  // Skills: per-skill directory symlinks (recursive for nested domain dirs)
220
245
  for (const { typeDirPath: skillsDir, segments } of findNestedTypeDirs(join(awDir, ns), 'skills')) {
221
246
  for (const skill of listDirs(skillsDir)) {
222
- const flat = [ns, ...segments, skill].join('-');
247
+ const flat = flatName(ns, [...segments, skill].join('-'));
223
248
 
224
249
  for (const ide of IDE_DIRS) {
225
250
  const linkDir = join(cwd, ide, 'skills');
@@ -227,13 +252,13 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
227
252
  const linkPath = join(linkDir, flat);
228
253
  const targetPath = join(skillsDir, skill);
229
254
  const relTarget = relative(linkDir, targetPath);
230
- try { forceSymlink(relTarget, linkPath); created++; } catch { /* best effort */ }
255
+ try { forceSymlink(relTarget, linkPath, { replaceManagedDir: 'skill' }); created++; } catch { /* best effort */ }
231
256
  }
232
257
  }
233
258
 
234
259
  for (const { skillDirPath, segments: skillSegments } of findSkillRootDirs(skillsDir)) {
235
260
  if (skillSegments.length <= 1) continue;
236
- const flat = [ns, ...segments, ...skillSegments].join('-');
261
+ const flat = flatName(ns, [...segments, ...skillSegments].join('-'));
237
262
 
238
263
  for (const ide of IDE_DIRS) {
239
264
  const linkDir = join(cwd, ide, 'skills');
@@ -261,7 +286,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
261
286
  const linkPath = join(linkDir, flat);
262
287
  const targetPath = join(evalsDir, evalSlug);
263
288
  const relTarget = relative(linkDir, targetPath);
264
- try { forceSymlink(relTarget, linkPath); created++; } catch { /* best effort */ }
289
+ try { forceSymlink(relTarget, linkPath, { replaceManagedDir: true }); created++; } catch { /* best effort */ }
265
290
  }
266
291
  }
267
292
  } else {
@@ -276,7 +301,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
276
301
  const linkPath = join(linkDir, flat);
277
302
  const targetPath = join(subDir, evalName);
278
303
  const relTarget = relative(linkDir, targetPath);
279
- try { forceSymlink(relTarget, linkPath); created++; } catch { /* best effort */ }
304
+ try { forceSymlink(relTarget, linkPath, { replaceManagedDir: true }); created++; } catch { /* best effort */ }
280
305
  }
281
306
  }
282
307
  }
@@ -310,16 +335,16 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
310
335
  for (const { typeDirPath: skillsDir, segments } of findNestedTypeDirs(join(awDir, ns), 'skills')) {
311
336
  mkdirSync(agentsSkillsDir, { recursive: true });
312
337
  for (const skill of listDirs(skillsDir)) {
313
- const flat = [ns, ...segments, skill].join('-');
338
+ const flat = flatName(ns, [...segments, skill].join('-'));
314
339
  const linkPath = join(agentsSkillsDir, flat);
315
340
  const targetPath = join(skillsDir, skill);
316
341
  const relTarget = relative(agentsSkillsDir, targetPath);
317
- try { forceSymlink(relTarget, linkPath); created++; } catch { /* best effort */ }
342
+ try { forceSymlink(relTarget, linkPath, { replaceManagedDir: 'skill' }); created++; } catch { /* best effort */ }
318
343
  }
319
344
 
320
345
  for (const { skillDirPath, segments: skillSegments } of findSkillRootDirs(skillsDir)) {
321
346
  if (skillSegments.length <= 1) continue;
322
- const flat = [ns, ...segments, ...skillSegments].join('-');
347
+ const flat = flatName(ns, [...segments, ...skillSegments].join('-'));
323
348
  const linkPath = join(agentsSkillsDir, flat);
324
349
  const relTarget = relative(agentsSkillsDir, skillDirPath);
325
350
  if (linkNestedSkillRoot(relTarget, linkPath, { silent })) created++;
@@ -378,7 +403,7 @@ export function linkWorkspace(cwd, awDirOverride = null, { silent = false } = {}
378
403
  mkdirSync(linkDir, { recursive: true });
379
404
  const linkPath = join(linkDir, 'docs');
380
405
  const relTarget = relative(linkDir, platformDocsDir);
381
- try { forceSymlink(relTarget, linkPath); created++; } catch { /* best effort */ }
406
+ try { forceSymlink(relTarget, linkPath, { replaceManagedDir: true }); created++; } catch { /* best effort */ }
382
407
  }
383
408
  }
384
409
 
package/mcp.mjs CHANGED
@@ -46,6 +46,10 @@ function envDisablesMcp(env = process.env) {
46
46
  return ['1', 'true', 'yes', 'on'].includes(value);
47
47
  }
48
48
 
49
+ function hasInteractiveTerminal() {
50
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
51
+ }
52
+
49
53
  export function loadMcpPreferences(homeDir = HOME) {
50
54
  const prefs = readJson(mcpPrefsPath(homeDir), {});
51
55
  return {
@@ -126,8 +130,8 @@ function resolveGitHubToken(silent = false) {
126
130
  }
127
131
  } catch { /* not authenticated yet */ }
128
132
 
129
- // 2b. Not authenticated — skip browser login in silent mode (no terminal)
130
- if (silent) return null;
133
+ // 2b. Not authenticated — skip browser login when no terminal is available.
134
+ if (silent || !hasInteractiveTerminal()) return null;
131
135
 
132
136
  fmt.logStep('GitHub CLI found but not authenticated — launching login...');
133
137
  try {
@@ -237,8 +241,13 @@ async function resolveClickUpToken(silent = false, cwd = process.cwd()) {
237
241
  return existing.token;
238
242
  }
239
243
 
240
- // In silent mode (git hooks, auto-pull) there is no terminal — skip prompt
241
- if (silent) return null;
244
+ // In silent/non-TTY mode (git hooks, Jenkins, auto-pull) there is no safe
245
+ // terminal prompt. ClickUp is optional, so skip instead of letting Clack
246
+ // initialize node:tty and crash.
247
+ if (silent || !hasInteractiveTerminal()) {
248
+ if (!silent) fmt.logInfo('Skipping ClickUp — no interactive terminal available');
249
+ return null;
250
+ }
242
251
 
243
252
  // 3. Ask first — don't assume the user wants ClickUp
244
253
  const wantsClickUp = await p.select({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.73",
4
- "description": "Agentic Workspace CLI \u2014 pull, push & manage agents, skills and commands from the registry",
3
+ "version": "0.1.77",
4
+ "description": "Agentic Workspace CLI pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "aw": "bin.js"
@@ -74,4 +74,4 @@
74
74
  "devDependencies": {
75
75
  "vitest": "^4.1.2"
76
76
  }
77
- }
77
+ }
package/render-rules.mjs CHANGED
@@ -358,11 +358,11 @@ ${GENERATED_MARKER}
358
358
 
359
359
  For every non-trivial request, execute these steps in order before any substantive response:
360
360
 
361
- 1. **Load the router** — Read the using-aw-skills SKILL.md from the skills directory.
361
+ 1. **Load the router** — Read \`.cursor/skills/aw-using-aw-skills/SKILL.md\` from the active workspace first; if it is missing, fall back to \`~/.cursor/skills/aw-using-aw-skills/SKILL.md\`, then \`~/.agents/skills/aw-using-aw-skills/SKILL.md\`.
362
362
 
363
363
  2. **Select route** — using the decision tree below, pick the smallest correct AW route.
364
364
 
365
- 3. **Read the stage skill** — you MUST Read the matching skill file before responding:
365
+ 3. **Read the stage skill** — you MUST Read the matching skill file before responding. Skills are in the active workspace \`.cursor/skills/\` first, then \`~/.cursor/skills/\`, then \`~/.agents/skills/\`:
366
366
  - /aw-adk → Read aw-adk/SKILL.md
367
367
  - /aw-publish → Read aw-publish/SKILL.md
368
368
  - /aw-plan → Read aw-plan/SKILL.md
package/startup.mjs CHANGED
@@ -16,6 +16,18 @@ const DISABLED_MODE = 'disabled';
16
16
  const DISABLE_DEFAULT_ROUTING_ENV = 'AW_DISABLE_DEFAULT_ROUTING';
17
17
 
18
18
  const CLAUDE_DISABLE_DESCRIPTION = 'AW-managed override: disable automatic AW session routing';
19
+
20
+ // using-aw-skills router hooks. The SessionStart hook injects the AW routing card once
21
+ // per session; the UserPromptSubmit hook re-asserts it on every prompt (the stronger
22
+ // always-on auto-trigger). Both were missing after the ecc->registry migration, which is
23
+ // why SDLC skills stopped auto-triggering in Claude. The command resolves the first
24
+ // existing on-disk router script across both registry roots and namespaces.
25
+ const CLAUDE_ROUTER_DESCRIPTION = 'AW-managed: using-aw-skills session router';
26
+ const CLAUDE_REMINDER_DESCRIPTION = 'AW-managed: using-aw-skills per-prompt reminder';
27
+ const CLAUDE_ROUTER_COMMAND =
28
+ 'for f in "$HOME/.aw/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh"; do [ -f "$f" ] && exec bash "$f"; done; exit 0';
29
+ const CLAUDE_REMINDER_COMMAND =
30
+ 'echo \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"[AW] using-aw-skills is active. Select the smallest correct AW route (/aw:plan, /aw:execute, /aw:verify, /aw:deploy, /aw:ship) before substantive work."}}\'';
19
31
  const CLAUDE_TELEMETRY_DESCRIPTION = 'AW usage telemetry';
20
32
  const CLAUDE_TELEMETRY_HOOKS = [
21
33
  {
@@ -47,6 +59,24 @@ const CLAUDE_TELEMETRY_HOOKS = [
47
59
  const CURSOR_SESSION_START_COMMAND = 'node .cursor/hooks/session-start.js';
48
60
  const CURSOR_SESSION_START_DESCRIPTION = 'Load previous context and detect environment';
49
61
  const REPO_CURSOR_SESSION_START_COMMAND = 'bash "$(git rev-parse --show-toplevel)/hooks/aw-session-start"';
62
+
63
+ // Deterministic local-Cursor AW router. Local Cursor previously had no AW session hook
64
+ // (only an advisory .mdc rule the model could under-weight), so SDLC skills did not
65
+ // auto-trigger. This managed sessionStart hook injects the using-aw-skills router via the
66
+ // concise session-start.sh (CURSOR_PLUGIN_ROOT branch emits Cursor's {additional_context}).
67
+ const CURSOR_AW_ROUTER_DESCRIPTION = 'AW-managed: using-aw-skills router';
68
+ const CURSOR_AW_ROUTER_COMMAND = 'bash "$HOME/.cursor/hooks/aw-session-start.sh"';
69
+ const CURSOR_AW_HOOK_MARKER = '# aw-managed: cursor-aw-session-start';
70
+ const CURSOR_AW_HOOK_SCRIPT = `#!/usr/bin/env bash
71
+ ${CURSOR_AW_HOOK_MARKER}
72
+ set -euo pipefail
73
+ for f in "$HOME/.aw/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw_registry/aw/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh" "$HOME/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh"; do
74
+ if [[ -f "$f" ]]; then
75
+ CURSOR_PLUGIN_ROOT=1 exec bash "$f"
76
+ fi
77
+ done
78
+ echo '{"additional_context": "[AW] using-aw-skills router not found in ~/.aw registry. Run aw pull aw or aw init."}'
79
+ `;
50
80
  const REPO_CLAUDE_SESSION_START_COMMAND = '"$CLAUDE_PROJECT_DIR"/hooks/aw-session-start';
51
81
  const CODEX_HOME_PHASE_DEFINITIONS = getCodexHomePhaseDefinitions();
52
82
  const CODEX_HOME_PHASE_BY_NAME = new Map(
@@ -164,6 +194,9 @@ function hasClaudePluginCache(homeDir = homedir()) {
164
194
  }
165
195
 
166
196
  function isLegacyClaudeSessionStartEntry(entry) {
197
+ // The managed router intentionally lists the platform/core path as a fallback candidate;
198
+ // it is the current managed entry, not a stale legacy hook.
199
+ if (entry?.description === CLAUDE_ROUTER_DESCRIPTION) return false;
167
200
  return Array.isArray(entry?.hooks)
168
201
  && entry.hooks.some(hook =>
169
202
  hook?.type === 'command'
@@ -172,6 +205,38 @@ function isLegacyClaudeSessionStartEntry(entry) {
172
205
  );
173
206
  }
174
207
 
208
+ function isManagedClaudeRouterEntry(entry) {
209
+ return entry?.description === CLAUDE_ROUTER_DESCRIPTION;
210
+ }
211
+
212
+ function isManagedClaudeReminderEntry(entry) {
213
+ return entry?.description === CLAUDE_REMINDER_DESCRIPTION;
214
+ }
215
+
216
+ // Orphaned `{ matcher: '*', hooks: [] }` stubs with no description accumulated in some
217
+ // settings.json files and are invisible to the disable-override filter. Strip them.
218
+ function isEmptyClaudeSessionStartStub(entry) {
219
+ return entry?.matcher === '*'
220
+ && Array.isArray(entry?.hooks)
221
+ && entry.hooks.length === 0
222
+ && !entry?.description;
223
+ }
224
+
225
+ function buildClaudeRouterEntry() {
226
+ return {
227
+ matcher: 'startup|resume|clear|compact',
228
+ hooks: [{ type: 'command', command: CLAUDE_ROUTER_COMMAND }],
229
+ description: CLAUDE_ROUTER_DESCRIPTION,
230
+ };
231
+ }
232
+
233
+ function buildClaudeReminderEntry() {
234
+ return {
235
+ hooks: [{ type: 'command', command: CLAUDE_REMINDER_COMMAND }],
236
+ description: CLAUDE_REMINDER_DESCRIPTION,
237
+ };
238
+ }
239
+
175
240
  function hasCommandHook(entry, command) {
176
241
  return Array.isArray(entry?.hooks)
177
242
  && entry.hooks.some(hook =>
@@ -336,45 +401,98 @@ function disableClaudeStartup(homeDir = homedir()) {
336
401
  const config = readJson(settingsPath, {});
337
402
  ensureHooksObject(config);
338
403
 
404
+ const before = JSON.stringify(config.hooks);
405
+
339
406
  const current = Array.isArray(config.hooks.SessionStart) ? config.hooks.SessionStart : [];
340
- const next = [
407
+ config.hooks.SessionStart = [
341
408
  {
342
409
  matcher: '*',
343
410
  hooks: [],
344
411
  description: CLAUDE_DISABLE_DESCRIPTION,
345
412
  },
346
- ...current.filter(entry => !isManagedClaudeSessionStartOverride(entry) && !isLegacyClaudeSessionStartEntry(entry)),
413
+ // Strip everything enableClaudeStartup installs: the managed router, orphaned
414
+ // empty stubs, legacy platform/core entries, and any prior disable override.
415
+ ...current.filter(entry =>
416
+ !isManagedClaudeSessionStartOverride(entry)
417
+ && !isLegacyClaudeSessionStartEntry(entry)
418
+ && !isEmptyClaudeSessionStartStub(entry)
419
+ && !isManagedClaudeRouterEntry(entry)),
347
420
  ];
348
421
 
349
- if (JSON.stringify(current) === JSON.stringify(next)) {
422
+ // Remove the managed per-prompt reminder installed by enableClaudeStartup.
423
+ if (Array.isArray(config.hooks.UserPromptSubmit)) {
424
+ const filtered = config.hooks.UserPromptSubmit.filter(entry => !isManagedClaudeReminderEntry(entry));
425
+ if (filtered.length > 0) config.hooks.UserPromptSubmit = filtered;
426
+ else delete config.hooks.UserPromptSubmit;
427
+ }
428
+
429
+ if (JSON.stringify(config.hooks) === before) {
350
430
  return [];
351
431
  }
352
432
 
353
- config.hooks.SessionStart = next;
354
433
  writeJson(settingsPath, config);
355
434
  return [settingsPath];
356
435
  }
357
436
 
358
- function enableClaudeStartup(homeDir = homedir()) {
437
+ // Full removal of every AW-managed Claude startup hook (router, reminder, disable
438
+ // override, orphaned stubs, legacy entries) for `aw nuke` — restores settings.json
439
+ // toward its pre-AW state and prunes the file/hooks object if nothing else remains.
440
+ export function purgeManagedClaudeStartupHooks(homeDir = homedir()) {
359
441
  const settingsPath = join(homeDir, '.claude', 'settings.json');
360
442
  if (!existsSync(settingsPath)) return [];
361
-
362
443
  const config = readJson(settingsPath, {});
363
- if (!isObject(config.hooks) || !Array.isArray(config.hooks.SessionStart)) {
364
- return [];
444
+ if (!isObject(config.hooks)) return [];
445
+
446
+ const before = JSON.stringify(config.hooks);
447
+
448
+ if (Array.isArray(config.hooks.SessionStart)) {
449
+ const filtered = config.hooks.SessionStart.filter(entry =>
450
+ !isManagedClaudeSessionStartOverride(entry)
451
+ && !isLegacyClaudeSessionStartEntry(entry)
452
+ && !isEmptyClaudeSessionStartStub(entry)
453
+ && !isManagedClaudeRouterEntry(entry));
454
+ if (filtered.length > 0) config.hooks.SessionStart = filtered;
455
+ else delete config.hooks.SessionStart;
365
456
  }
457
+ if (Array.isArray(config.hooks.UserPromptSubmit)) {
458
+ const filtered = config.hooks.UserPromptSubmit.filter(entry => !isManagedClaudeReminderEntry(entry));
459
+ if (filtered.length > 0) config.hooks.UserPromptSubmit = filtered;
460
+ else delete config.hooks.UserPromptSubmit;
461
+ }
462
+
463
+ if (JSON.stringify(config.hooks) === before) return [];
464
+
465
+ if (isEmptyObject(config.hooks)) delete config.hooks;
466
+ if (!pruneEmptySettingsFile(settingsPath, config)) writeJson(settingsPath, config);
467
+ return [settingsPath];
468
+ }
469
+
470
+ function enableClaudeStartup(homeDir = homedir()) {
471
+ const settingsPath = join(homeDir, '.claude', 'settings.json');
472
+ const config = readJson(settingsPath, {});
473
+ ensureHooksObject(config);
366
474
 
367
- const filtered = config.hooks.SessionStart.filter(
368
- entry => !isManagedClaudeSessionStartOverride(entry) && !isLegacyClaudeSessionStartEntry(entry),
475
+ const before = JSON.stringify(config.hooks);
476
+
477
+ // SessionStart: install the using-aw-skills router. Drop AW disable-overrides,
478
+ // legacy platform/core router entries, orphaned empty stubs, and any prior managed
479
+ // router entry so re-runs replace rather than accumulate (idempotent).
480
+ const ssCurrent = Array.isArray(config.hooks.SessionStart) ? config.hooks.SessionStart : [];
481
+ const ssCleaned = ssCurrent.filter(entry =>
482
+ !isManagedClaudeSessionStartOverride(entry)
483
+ && !isLegacyClaudeSessionStartEntry(entry)
484
+ && !isEmptyClaudeSessionStartStub(entry)
485
+ && !isManagedClaudeRouterEntry(entry),
369
486
  );
370
- if (filtered.length === config.hooks.SessionStart.length) {
371
- return [];
372
- }
487
+ config.hooks.SessionStart = [...ssCleaned, buildClaudeRouterEntry()];
373
488
 
374
- if (filtered.length > 0) {
375
- config.hooks.SessionStart = filtered;
376
- } else {
377
- delete config.hooks.SessionStart;
489
+ // UserPromptSubmit: install the per-prompt reminder (the strongest always-on trigger).
490
+ const upCurrent = Array.isArray(config.hooks.UserPromptSubmit) ? config.hooks.UserPromptSubmit : [];
491
+ const upCleaned = upCurrent.filter(entry => !isManagedClaudeReminderEntry(entry));
492
+ config.hooks.UserPromptSubmit = [...upCleaned, buildClaudeReminderEntry()];
493
+
494
+ if (JSON.stringify(config.hooks) === before) {
495
+ return [];
378
496
  }
379
497
 
380
498
  if (isEmptyObject(config.hooks)) {
@@ -575,7 +693,8 @@ function isManagedCursorSessionStartEntry(entry, homeDir = homedir()) {
575
693
  }
576
694
 
577
695
  function hasCursorSessionStartScript(homeDir = homedir()) {
578
- return existsSync(join(homeDir, '.cursor', 'hooks', 'session-start.js'));
696
+ return existsSync(join(homeDir, '.cursor', 'hooks', 'session-start.js'))
697
+ || existsSync(join(homeDir, '.cursor', 'hooks', 'aw-session-start.sh'));
579
698
  }
580
699
 
581
700
  function disableCursorStartup(homeDir = homedir()) {
@@ -641,6 +760,71 @@ function enableCursorStartup(homeDir = homedir()) {
641
760
  return [hooksPath];
642
761
  }
643
762
 
763
+ function isManagedCursorAwRouterEntry(entry) {
764
+ return entry?.description === CURSOR_AW_ROUTER_DESCRIPTION
765
+ || String(entry?.command || '') === CURSOR_AW_ROUTER_COMMAND;
766
+ }
767
+
768
+ function enableCursorAwRouting(homeDir = homedir()) {
769
+ const updated = [];
770
+
771
+ // 1. Write the managed AW router hook script (idempotent).
772
+ const scriptPath = join(homeDir, '.cursor', 'hooks', 'aw-session-start.sh');
773
+ const existing = existsSync(scriptPath) ? readFileSync(scriptPath, 'utf8') : null;
774
+ if (existing !== CURSOR_AW_HOOK_SCRIPT) {
775
+ mkdirSync(dirname(scriptPath), { recursive: true });
776
+ writeFileSync(scriptPath, CURSOR_AW_HOOK_SCRIPT);
777
+ try { chmodSync(scriptPath, 0o755); } catch { /* best effort */ }
778
+ updated.push(scriptPath);
779
+ }
780
+
781
+ // 2. Wire it into ~/.cursor/hooks.json sessionStart, additive and idempotent — preserve
782
+ // Cursor's native session-start hook and any unrelated entries.
783
+ const hooksPath = join(homeDir, '.cursor', 'hooks.json');
784
+ const config = readJson(hooksPath, {});
785
+ if (!isObject(config.hooks)) config.hooks = {};
786
+ config.version = Number.isInteger(config.version) ? config.version : 1;
787
+ const current = Array.isArray(config.hooks.sessionStart) ? config.hooks.sessionStart : [];
788
+ if (!current.some(isManagedCursorAwRouterEntry)) {
789
+ config.hooks.sessionStart = [
790
+ ...current,
791
+ { command: CURSOR_AW_ROUTER_COMMAND, event: 'sessionStart', description: CURSOR_AW_ROUTER_DESCRIPTION },
792
+ ];
793
+ writeJson(hooksPath, config);
794
+ updated.push(hooksPath);
795
+ }
796
+
797
+ return updated;
798
+ }
799
+
800
+ export function disableCursorAwRouting(homeDir = homedir()) {
801
+ const updated = [];
802
+
803
+ const hooksPath = join(homeDir, '.cursor', 'hooks.json');
804
+ if (existsSync(hooksPath)) {
805
+ const config = readJson(hooksPath, {});
806
+ if (isObject(config.hooks) && Array.isArray(config.hooks.sessionStart)) {
807
+ const filtered = config.hooks.sessionStart.filter(entry => !isManagedCursorAwRouterEntry(entry));
808
+ if (filtered.length !== config.hooks.sessionStart.length) {
809
+ if (filtered.length > 0) config.hooks.sessionStart = filtered;
810
+ else delete config.hooks.sessionStart;
811
+ if (isEmptyObject(config.hooks)) delete config.hooks;
812
+ if (config.version === undefined) config.version = 1;
813
+ if (!pruneEmptySettingsFile(hooksPath, config)) writeJson(hooksPath, config);
814
+ updated.push(hooksPath);
815
+ }
816
+ }
817
+ }
818
+
819
+ const scriptPath = join(homeDir, '.cursor', 'hooks', 'aw-session-start.sh');
820
+ if (existsSync(scriptPath) && readFileSync(scriptPath, 'utf8').includes(CURSOR_AW_HOOK_MARKER)) {
821
+ rmSync(scriptPath, { force: true });
822
+ updated.push(scriptPath);
823
+ }
824
+
825
+ return updated;
826
+ }
827
+
644
828
  export function loadStartupPreferences(homeDir = homedir()) {
645
829
  const prefs = readJson(startupPrefsPath(homeDir), {});
646
830
  return {
@@ -685,11 +869,13 @@ export function applyGlobalStartupMode(mode, homeDir = homedir()) {
685
869
  updatedFiles.push(...disableClaudeTelemetryHooks(homeDir));
686
870
  updatedFiles.push(...disableCodexStartup(homeDir));
687
871
  updatedFiles.push(...disableCursorStartup(homeDir));
872
+ updatedFiles.push(...disableCursorAwRouting(homeDir));
688
873
  } else {
689
874
  updatedFiles.push(...enableClaudeStartup(homeDir));
690
875
  updatedFiles.push(...enableClaudeTelemetryHooks(homeDir));
691
876
  updatedFiles.push(...enableCodexStartup(homeDir));
692
877
  updatedFiles.push(...enableCursorStartup(homeDir));
878
+ updatedFiles.push(...enableCursorAwRouting(homeDir));
693
879
  }
694
880
 
695
881
  return [...new Set(updatedFiles)];
@@ -719,6 +905,8 @@ export function getStartupStatus(homeDir = homedir(), env = process.env) {
719
905
  preferencesPath: startupPrefsPath(homeDir),
720
906
  claudePluginEnabled: claudeSettings?.enabledPlugins?.['aw@aw-marketplace'] === true,
721
907
  claudePluginInstalled: hasClaudePluginCache(homeDir),
908
+ claudeRouterPresent: Array.isArray(claudeSettings?.hooks?.SessionStart) &&
909
+ claudeSettings.hooks.SessionStart.some(isManagedClaudeRouterEntry),
722
910
  claudeDisabled: Array.isArray(claudeSettings?.hooks?.SessionStart) &&
723
911
  claudeSettings.hooks.SessionStart.some(isManagedClaudeSessionStartOverride),
724
912
  claudeLegacySessionStartPresent: Array.isArray(claudeSettings?.hooks?.SessionStart) &&
@@ -728,7 +916,7 @@ export function getStartupStatus(homeDir = homedir(), env = process.env) {
728
916
  codexHooks.hooks.SessionStart.some(isManagedCodexSessionStartEntry),
729
917
  codexSessionStartScriptInstalled: hasCodexSessionStartScript(homeDir),
730
918
  cursorSessionStartPresent: cursorSessionStartEntries.some(entry =>
731
- isManagedCursorSessionStartEntry(entry, homeDir)),
919
+ isManagedCursorSessionStartEntry(entry, homeDir) || isManagedCursorAwRouterEntry(entry)),
732
920
  cursorAnySessionStartPresent: cursorSessionStartEntries.length > 0,
733
921
  cursorSessionStartScriptInstalled: hasCursorSessionStartScript(homeDir),
734
922
  };