@amsterdamdatalabs/enact-extensions 0.1.30 → 0.1.35

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.
@@ -1,130 +1,70 @@
1
1
  ---
2
2
  name: cmux-leader
3
- description: "Become the cmux LEADER — spawn, brief, poll, unblock, and verify up to 5 parallel claude follower terminals in one cmux workspace. Use when orchestrating multi-session work across sibling terminals, driving followers, or coordinating parallel agents. Followers message back via the cmux-send skill."
3
+ description: "Become the cmux LEADER — spawn, root-per-repo, brief, poll, unblock, and verify parallel claude follower terminals from a neutral leader. Use when orchestrating multi-session work across sibling terminals. Followers reply via the cmux-send skill."
4
4
  ---
5
5
 
6
6
  # cmux-leader — you are the LEADER
7
7
 
8
- You are a Claude Code session running **inside a cmux workspace**. You act as the
9
- **leader/orchestrator**: you spawn, brief, poll, unblock, and verify **follower** claude sessions
10
- running in sibling terminals of the same workspace. You coordinate; followers do the hands-on work.
8
+ You run inside a cmux workspace and orchestrate **follower** claude sessions in sibling terminals.
9
+ You coordinate; followers do the hands-on work. Keep your own context for the plan and the gates.
11
10
 
12
- ## Hard rules
11
+ ## Hard rules (learned the hard way)
13
12
 
14
- - **Terminal surfaces only.** Followers are `claude` running in a **terminal** surface. `agent-session`
15
- surfaces (`new-surface --type agent-session`) are NOT scriptable `send` / `read-screen` fail on them
16
- with `Surface is not a terminal`. Always launch followers as `claude` in a terminal.
17
- - **Max 5 parallel claude sessions**, all **horizontal splits** in **one** workspace.
18
- - **Start each follower from the directory its work lives in** so enact-context warms the right repo
19
- (`enact-context: ON` must print on that session's shell start).
20
- - **Lead from a NEUTRAL parent dir** (e.g. the common parent of all repos). The enact-loop **Stop hook
21
- resolves the active loop by SHELL cwd** — if you `cd` into a follower's repo you inherit that loop's
22
- Stop boulder and it blocks YOUR exit. To inspect a follower's repo use `git -C <path> …` /
23
- absolute paths; **never `cd` into a follower repo.**
24
- - **You orchestrate, you don't do the followers' work.** Assign poll unblock verify. Keep your own
25
- context for the plan and the merge/verification gates.
26
- - One leader per workspace. If another session is already leading, don't fight it — coordinate.
13
+ - **One surface per directory.** A working tree cannot have two drivers two followers, or leader +
14
+ a follower, in the same repo causes git collisions and duplicate PRs. If two must touch one repo,
15
+ give each its own `git worktree`.
16
+ - **Verify cwd BEFORE launching claude.** `new-split`/`new-surface` do NOT take a working-directory
17
+ flag a new pane inherits the parent's cwd. Always `cd <repo>` then confirm `pwd` *before* you run
18
+ `claude`, or the follower boots in the wrong repo.
19
+ - **Lead from a NEUTRAL dir** (the common parent of all repos). The enact-loop Stop hook resolves the
20
+ active loop by SHELL cwd — if you `cd` into a follower's repo you inherit its Stop boulder and it
21
+ blocks your exit. Inspect a follower's repo with `git -C <path> …`, never a persistent `cd`.
22
+ - **Spawn on demand.** Don't pre-create followers for ungreenlit work — idle sessions burn tokens.
23
+ Create one only when a concrete task is ready; `close-surface` it when its work is done.
24
+ - **Before opening a PR, check no follower already opened one** for that branch (avoid duplicate PRs).
25
+ - **Don't accept self-reported green** verify against real output (tests, build, PR state).
26
+ - **Max 5 claude sessions**, horizontal splits, one workspace.
27
27
 
28
- ## Orient first (always run these)
28
+ ## Commands I use
29
29
 
30
30
  ```bash
31
- cmux current-window # which window
32
- cmux workspace list # workspaces (this leader's is [selected])
33
- cmux list-panes # panes in the workspace
34
- cmux list-pane-surfaces # surfaces (sessions) in the focused pane
35
- cmux list-pane-surfaces --pane pane:N # surfaces in a specific pane
36
- ```
37
-
38
- Identify each surface by name (e.g. `FOLLOWER-enact-wiki`). A surface you can `read-screen` is a
39
- terminal session you can drive. Re-discover topology each session — never hardcode surface numbers.
40
-
41
- ## Read a follower
42
-
43
- ```bash
44
- cmux read-screen --surface surface:N --lines 40 # tail of the live screen
45
- cmux read-screen --surface surface:N --scrollback # full scrollback
46
- ```
47
-
48
- **In every follower briefing, tell the follower to use the `cmux-send` skill** for all replies — the
49
- `[from] to [to]` envelope and the send→verify→enter submit protocol. A follower that doesn't know
50
- cmux-send will park half-formed messages and break the relay.
51
-
52
- ## Drive a follower (see the cmux-send skill for the full messaging protocol)
53
-
54
- ```bash
55
- cmux send --surface surface:N "your prompt here\n" # \n = Enter
56
- cmux read-screen --surface surface:N --lines 12 # VERIFY it submitted
57
- cmux send-key --surface surface:N enter # if it parked in the ❯ box (booting/thinking)
58
- cmux send-key --surface surface:N ctrl+c # interrupt a runaway follower
59
- ```
60
-
61
- **Always `read-screen` after every `send`** to confirm it landed. If still in the `❯` box, follow with
62
- `send-key … enter` — NOT `ctrl+b`. `ctrl+b` only flushes a *queued* message when the follower has a
63
- **running bash command** to background; while it is purely thinking, `ctrl+b` does nothing — just wait.
64
-
65
- ## Spawn a new follower (horizontal split, rooted in the work dir)
31
+ # Orient (re-discover every turn; surface numbers renumber)
32
+ cmux list-panes
33
+ cmux list-pane-surfaces [--pane pane:N] # name each surface
34
+ cmux tree # process/Surface tree (optional)
35
+
36
+ # Read a follower
37
+ cmux read-screen --surface surface:N [--lines 40] [--scrollback]
38
+
39
+ # Drive a follower (\n = Enter)
40
+ cmux send --surface surface:N "text\n"
41
+ cmux send-key --surface surface:N enter # submit a parked message
42
+ cmux send-key --surface surface:N ctrl+c # interrupt a runaway
43
+ cmux send-key --surface surface:N ctrl+b # flush a QUEUED msg — only if a command is running
44
+
45
+ # Spawn a follower (root it correctly, THEN launch claude)
46
+ cmux new-split right --focus false # -> surface:N
47
+ cmux send --surface surface:N "cd /abs/repo && pwd\n"
48
+ cmux read-screen --surface surface:N --lines 5 # CONFIRM pwd == the repo
49
+ cmux send --surface surface:N "claude\n"
66
50
 
67
- ```bash
68
- cmux new-split right --focus false # -> "OK surface:N pane:M ..."
69
- cmux send --surface surface:N "cd /abs/path/to/repo && claude\n"
70
- cmux read-screen --surface surface:N --lines 20 # confirm boot
71
- cmux send --surface surface:N "You are a FOLLOWER under the cmux leader. <role + task>.\n"
72
- cmux send-key --surface surface:N enter # Enter explicitly (it was booting)
51
+ # Close a finished/dead follower
52
+ cmux close-surface --surface surface:N
73
53
  ```
74
54
 
75
- One-shot terminal with an explicit cwd (no `cd` needed):
76
-
77
- ```bash
78
- cmux new-surface --type terminal --working-directory /abs/path/to/repo --pane pane:M --focus false
79
- cmux send --surface surface:N "claude\n"
80
- ```
55
+ ## Messaging always use the cmux-send skill
81
56
 
82
- Keep all splits horizontal; stop at 5 claude sessions total.
57
+ Every follower briefing must tell the follower to use the **cmux-send** skill. Each message: prefix
58
+ `[LEADER] to [FOLLOWER-<name>] `, end with `Respond back once you are done with using prefix
59
+ "[FOLLOWER-<name>] to [LEADER] "`. After every `send`, `read-screen` to confirm it submitted; if it's
60
+ parked in the `❯` box, `send-key … enter` (NOT `ctrl+b`).
83
61
 
84
62
  ## Leader loop
85
63
 
86
- 1. **Plan & shard.** Break the goal into independent slices, one per follower (≤5). Pick each slice's
87
- repo/dir so enact-context warms the right context.
88
- 2. **Spawn & brief.** Split `cd <dir> && claude` → send a self-contained briefing (role, scope,
89
- exit criteria, "report back in one line when done/blocked") using the cmux-send envelope.
90
- 3. **Poll.** Round-robin `read-screen` each follower. Watch for: finished, blocked, asking a question,
91
- or idle at the `❯` prompt.
92
- 4. **Unblock.** Answer questions / re-scope via `send`. Never reach into a follower's repo yourself
93
- unless you're the session that owns it.
94
- 5. **Verify & gate.** When a follower claims done, verify against real output (tests, build, PR state)
95
- before you treat the slice as green. Don't accept self-reported green.
96
- 6. **Close.** Summarize across followers; drive the merge / final gate from the leader.
97
-
98
- ## Standing watch — poll every ~15 min, never go idle while followers run
99
-
100
- A follower that finishes its task goes **idle at the `❯` prompt** — from the leader's seat that looks
101
- identical to "stalled." `done` ≠ `handled`. If you go idle too, every lane stops and the user sees a
102
- stalled board. Keep a **~15-minute poll cadence** for as long as any follower is live:
103
-
104
- 1. **(Re)discover topology first** — `cmux list-pane-surfaces [--pane pane:N]`. Never assume surface
105
- numbers held across turns; they renumber.
106
- 2. **`read-screen` every follower** and classify each: *working* · *idle-done* · *parked message in the
107
- `❯` box* · *blocked / asking* · *ctx ≥ 80% (needs `/compact`)*.
108
- 3. **Act per state:** idle-done → assign the next work package or explicitly park it · parked → `send-key
109
- enter` · blocked/asking → answer · ctx ≥ 80% → tell it to `/compact` then resume.
110
- 4. **Report deltas to the user**, then schedule the next poll ~15 min out (e.g. via the `loop` skill or a
111
- self-scheduled wake-up). Idle followers are wasted parallelism — the poll is how you keep all lanes fed.
112
-
113
- ## Quick reference (verified surface)
114
-
115
- | Need | Command |
116
- |---|---|
117
- | List sessions | `cmux list-pane-surfaces [--pane pane:N]` |
118
- | Read a session | `cmux read-screen --surface surface:N --lines 40` |
119
- | Send + submit | `cmux send --surface surface:N "text\n"` |
120
- | Submit a parked msg | `cmux send-key --surface surface:N enter` |
121
- | Flush a queued msg (only if a command is running) | `cmux send-key --surface surface:N ctrl+b` |
122
- | Interrupt | `cmux send-key --surface surface:N ctrl+c` |
123
- | New right split | `cmux new-split right --focus false` |
124
- | Terminal w/ cwd | `cmux new-surface --type terminal --working-directory <path> --pane pane:N` |
125
- | Focus a pane | `cmux focus-pane --pane pane:N` |
126
-
127
- Gotchas: agent-session ≠ scriptable (terminal only) · `\n` may not submit during boot/thinking (use
128
- `send-key enter`) · `read-screen` takes `--surface`, not `--pane` · a `send` to a busy follower queues
129
- and delivers at turn-end — `ctrl+b` flushes it **only if a command is running** · leader must not `cd`
130
- into a follower repo (Stop-hook boulder capture — use `git -C`).
64
+ 1. **Shard** the goal into independent slices, one repo per follower.
65
+ 2. **Spawn & brief** — split → `cd <repo>` (verify pwd) `claude` self-contained briefing (role,
66
+ scope, exit criteria, "use cmux-send to reply, report in one line").
67
+ 3. **Poll on a ~15-min cadence** never go idle while followers run. Re-discover topology, `read-screen`
68
+ each, classify (working / idle-done / parked / blocked / ctx≥80%), and act: parked → `send-key enter`;
69
+ ctx≥80% → tell it to `/compact`; blocked → answer; idle-done assign next or `close-surface`.
70
+ 4. **Verify & gate** confirm real output before treating a slice as green; drive merges from the leader.
@@ -1,28 +1,26 @@
1
1
  ---
2
2
  name: cmux-send
3
- description: "How to correctly send a message to a cmux follower surface — the send→verify→enter protocol and the [from] to [to] message envelope. Use whenever messaging a cmux session (leader→follower or follower→leader)."
3
+ description: "How to correctly message a cmux surface — the send→verify→enter protocol and the [from] to [to] envelope. Use whenever messaging a cmux session (leader→follower or follower→leader)."
4
4
  ---
5
5
 
6
6
  # cmux-send — message a cmux surface correctly
7
7
 
8
- Sending a cmux message has two parts that are easy to get wrong: **submitting** it, and **wrapping** it.
8
+ Two parts are easy to get wrong: **submitting** the message and **wrapping** it.
9
9
 
10
10
  ## 1. Submit protocol (always verify the Enter landed)
11
11
 
12
12
  ```bash
13
13
  cmux send --surface surface:N "<one-line message>\n" # \n = Enter
14
14
  cmux read-screen --surface surface:N --lines 12 # VERIFY it submitted
15
- # If the text is still sitting in the `❯` box (not submitted):
16
- cmux send-key --surface surface:N enter
15
+ cmux send-key --surface surface:N enter # if still in the box (booting/thinking)
17
16
  ```
18
17
 
19
- - **Always `read-screen` after `send`.** A `\n` may NOT submit if the follower was booting or thinking —
20
- the text parks in the `❯` box.
21
- - **To submit a parked message, use `send-key … enter` NOT `ctrl+b`.** `ctrl+b` only flushes a
22
- *queued* message when the follower has a **running bash command** to background; on an idle or
23
- purely-thinking follower it does nothing.
18
+ - **Always `read-screen` after `send`.** A `\n` may not submit while the follower is booting or
19
+ thinking — the text parks in the `❯` box.
20
+ - **To submit a parked message use `send-key … enter`, NOT `ctrl+b`.** `ctrl+b` only flushes a *queued*
21
+ message when the follower has a **running bash command** to background; while it is purely thinking,
22
+ `ctrl+b` does nothing just wait.
24
23
  - **One line only.** Every literal `\n` submits, so a multi-line prompt fires early and fragments.
25
- Compose the whole message on a single line; the trailing `\n` is the only submit.
26
24
  - `read-screen` takes `--surface`, not `--pane`.
27
25
 
28
26
  ## 2. Message envelope (every message)
@@ -30,8 +28,8 @@ cmux send-key --surface surface:N enter
30
28
  - **Start with:** `[<from-surface-name>] to [<to-surface-name>] `
31
29
  - **End with:** `Respond back once you are done with using prefix "[<to-surface-name>] to [<from-surface-name>] "`
32
30
 
33
- Surface names are the human labels (e.g. `LEADER`, `FOLLOWER-enact-wiki`), not `surface:N`. Map them with
34
- `cmux list-pane-surfaces [--pane pane:N]`.
31
+ Surface names are the human labels (e.g. `LEADER`, `FOLLOWER-enact-wiki`), not `surface:N`. Map them
32
+ with `cmux list-pane-surfaces [--pane pane:N]`.
35
33
 
36
34
  ### Example (leader → follower)
37
35
 
@@ -40,13 +38,3 @@ cmux send --surface surface:3 "[LEADER] to [FOLLOWER-enact-wiki] <instruction on
40
38
  cmux read-screen --surface surface:3 --lines 12
41
39
  # still in the ❯ box? -> cmux send-key --surface surface:3 enter
42
40
  ```
43
-
44
- ## Quick reference
45
-
46
- | Need | Command |
47
- |---|---|
48
- | Send + submit | `cmux send --surface surface:N "text\n"` |
49
- | Verify it landed | `cmux read-screen --surface surface:N --lines 12` |
50
- | Submit a parked message | `cmux send-key --surface surface:N enter` |
51
- | Flush a QUEUED msg (only if a command is running) | `cmux send-key --surface surface:N ctrl+b` |
52
- | Interrupt a runaway | `cmux send-key --surface surface:N ctrl+c` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/enact-extensions",
3
- "version": "0.1.30",
3
+ "version": "0.1.35",
4
4
  "description": "Create and validate Enact multi-platform plugin manifests",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -938,6 +938,8 @@ if (command === "serve") {
938
938
  // Reads a UserPromptSubmit hook payload from stdin, extracts the prompt,
939
939
  // builds the skill catalog from the bundled extensions dir, routes the
940
940
  // prompt, and writes the hook response to stdout.
941
+ // Set ENACT_EXTENSIONS_UPS_ROUTER=0 to disable this hook locally without
942
+ // removing the installed plugin.
941
943
  //
942
944
  // Output contract (Claude Code hooks):
943
945
  // Match: { continue: true, hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: <string> } }
@@ -960,13 +962,12 @@ if (command === "hook") {
960
962
  (async () => {
961
963
  if (subCommand === "session-start") {
962
964
  try {
963
- const { computeOutdated } = await import("./lib/outdated.mjs");
964
- const outdated = (computeOutdated() ?? []).filter((entry) => entry?.status === "outdated");
965
- if (outdated.length > 0) {
966
- const pairs = outdated.map((entry) => `${entry.name}/${entry.platform}`).join(", ");
967
- process.stdout.write(
968
- `[enact-extensions] ${outdated.length} plugin surface${outdated.length === 1 ? "" : "s"} outdated: ${pairs}. Run: enact-extensions update --all\n`,
969
- );
965
+ const { handleSessionStartDrift } = await import("./lib/auto-update.mjs");
966
+ // Opt-in is the USER's: gate auto-apply on the env var they set.
967
+ const autoApply = !!process.env.ENACT_EXTENSIONS_AUTO_UPDATE;
968
+ const result = handleSessionStartDrift({ autoApply });
969
+ if (result.line) {
970
+ process.stdout.write(`${result.line}\n`);
970
971
  }
971
972
  } catch {
972
973
  // Fail-silent for session-start hooks.
@@ -974,6 +975,11 @@ if (command === "hook") {
974
975
  process.exit(0);
975
976
  }
976
977
 
978
+ if (process.env.ENACT_EXTENSIONS_UPS_ROUTER === "0") {
979
+ process.stdout.write(JSON.stringify({ continue: true }) + "\n");
980
+ process.exit(0);
981
+ }
982
+
977
983
  // Read stdin to get the hook payload JSON.
978
984
  let payload;
979
985
  try {
@@ -5,12 +5,13 @@
5
5
  * Registered as a session-start hook for Claude, Codex, Cursor, and Enact.
6
6
  * Checks whether any installed enact-extensions plugins have drifted from
7
7
  * their canonical source (i.e. are outdated) and prints a concise advisory
8
- * if so.
8
+ * if so. When the user opts in via ENACT_EXTENSIONS_AUTO_UPDATE, the drifted
9
+ * surfaces are refreshed in place instead of merely advised.
9
10
  *
10
11
  * Design constraints:
11
12
  * - FAIL-SILENT: wrap everything in try/catch; never throw; always exit 0.
12
- * - FAST: imports computeOutdated directly (no subprocess) for speed.
13
- * - READ-ONLY: never writes any state.
13
+ * - FAST: imports the helper directly (no subprocess) for speed.
14
+ * - READ-ONLY by default: only mutates state when the user opts into auto-apply.
14
15
  * - NO OUTPUT on success (or error): only print when there are outdated entries.
15
16
  * - DEPENDENCY-FREE: only node builtins + enact-extensions own modules.
16
17
  */
@@ -24,33 +25,22 @@ const REPO_ROOT = resolve(__dirname, "..", "..");
24
25
 
25
26
  try {
26
27
  // Dynamically import so any module-load failure is caught by the outer try/catch.
27
- const { computeOutdated } = await import(join(REPO_ROOT, "scripts", "lib", "outdated.mjs"));
28
+ const { handleSessionStartDrift } = await import(join(REPO_ROOT, "scripts", "lib", "auto-update.mjs"));
28
29
 
29
- let entries;
30
+ // Opt-in is the USER's: gate auto-apply on the env var they set.
31
+ const autoApply = !!process.env.ENACT_EXTENSIONS_AUTO_UPDATE;
32
+
33
+ let result;
30
34
  try {
31
- entries = computeOutdated();
35
+ result = handleSessionStartDrift({ autoApply });
32
36
  } catch {
33
- // computeOutdated threw (ledger missing, I/O error, etc.) — stay silent.
37
+ // Drift resolution threw (ledger missing, I/O error, etc.) — stay silent.
34
38
  process.exit(0);
35
39
  }
36
40
 
37
- // Filter to only outdated entries (not fresh, not orphaned).
38
- const outdated = (entries ?? []).filter((e) => e && e.status === "outdated");
39
-
40
- if (outdated.length === 0) {
41
- // Nothing to report.
42
- process.exit(0);
41
+ if (result.line) {
42
+ process.stdout.write(`${result.line}\n`);
43
43
  }
44
-
45
- // Build a concise list of (plugin, surface) pairs.
46
- const pairs = outdated
47
- .map((e) => `${e.name}/${e.platform}`)
48
- .join(", ");
49
-
50
- // Single advisory line to stdout.
51
- process.stdout.write(
52
- `[enact-extensions] ${outdated.length} plugin surface${outdated.length === 1 ? "" : "s"} outdated: ${pairs}. Run: enact-extensions update --all\n`,
53
- );
54
44
  } catch {
55
45
  // Any top-level error (import failure, parse error, etc.) — silent exit 0.
56
46
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * auto-update.mjs — SessionStart drift resolution: advise by default, or
3
+ * auto-apply when opted in via ENACT_EXTENSIONS_AUTO_UPDATE.
4
+ *
5
+ * Single export:
6
+ * handleSessionStartDrift({ autoApply, home, cwd }) → { outdated, applied, updated, line }
7
+ *
8
+ * Behaviour:
9
+ * - Compute the outdated (drifted) surfaces from the ledger vs canonical source.
10
+ * - No outdated surfaces → { outdated: [], applied: false, line: null }.
11
+ * - Outdated + autoApply=false → advisory line (default; mutates nothing).
12
+ * - Outdated + autoApply=true → runUpdate({ all: true }); summary line naming
13
+ * what was refreshed. If the refresh updated
14
+ * nothing actionable (e.g. all orphaned), fall
15
+ * back to the advisory line.
16
+ *
17
+ * The opt-in is the USER's: callers derive `autoApply` from
18
+ * process.env.ENACT_EXTENSIONS_AUTO_UPDATE. This module never reads env itself.
19
+ * Reuses computeOutdated + runUpdate; it adds no new install/refresh logic.
20
+ */
21
+
22
+ import { computeOutdated } from "./outdated.mjs";
23
+ import { runUpdate } from "./run-update.mjs";
24
+
25
+ /** Format the "name/platform, ..." pair list for a set of entries. */
26
+ function pairs(entries) {
27
+ return entries.map((e) => `${e.name}/${e.platform}`).join(", ");
28
+ }
29
+
30
+ /** The default advisory line (unchanged wording: names surfaces + manual command). */
31
+ export function driftAdvisoryLine(outdated) {
32
+ return (
33
+ `[enact-extensions] ${outdated.length} plugin surface${outdated.length === 1 ? "" : "s"} ` +
34
+ `outdated: ${pairs(outdated)}. Run: enact-extensions update --all`
35
+ );
36
+ }
37
+
38
+ /** The auto-apply summary line, naming the surfaces that were refreshed. */
39
+ export function autoUpdateSummaryLine(updated) {
40
+ return (
41
+ `[enact-extensions] auto-updated ${updated.length} plugin surface${updated.length === 1 ? "" : "s"}: ` +
42
+ `${pairs(updated)}`
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Resolve SessionStart drift: advise by default, or auto-apply when opted in.
48
+ *
49
+ * @param {object} [opts]
50
+ * @param {boolean} [opts.autoApply] — when true, refresh outdated surfaces in place.
51
+ * @param {string} [opts.home] — ledger home override (defaults to os.homedir()).
52
+ * @param {string} [opts.cwd] — canonical-source resolution dir (defaults to cwd).
53
+ * @returns {{ outdated: object[], applied: boolean, updated: object[], line: string|null }}
54
+ */
55
+ export function handleSessionStartDrift({ autoApply, home, cwd } = {}) {
56
+ const outdated = (computeOutdated({ home, cwd }) ?? []).filter(
57
+ (e) => e && e.status === "outdated",
58
+ );
59
+
60
+ if (outdated.length === 0) {
61
+ return { outdated, applied: false, updated: [], line: null };
62
+ }
63
+
64
+ if (autoApply) {
65
+ const summary = runUpdate({ all: true, home, cwd });
66
+ const updated = summary?.updated ?? [];
67
+ if (updated.length === 0) {
68
+ // Nothing actionable was refreshed (e.g. orphaned source) — advise instead.
69
+ return { outdated, applied: false, updated, line: driftAdvisoryLine(outdated) };
70
+ }
71
+ return { outdated, applied: true, updated, line: autoUpdateSummaryLine(updated) };
72
+ }
73
+
74
+ return { outdated, applied: false, updated: [], line: driftAdvisoryLine(outdated) };
75
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * postinstall-offer.mjs — interactive drift-refresh offer shown at postinstall.
3
+ *
4
+ * Exports ONE function:
5
+ * offerDriftRefresh({ isTTY, computeOutdatedFn, promptYesNo, runUpdateFn, write })
6
+ *
7
+ * Contract:
8
+ * - non-TTY → silent; return { offered: false, updated: false }
9
+ * - TTY + zero outdated → silent; return { offered: false, updated: false }
10
+ * - TTY + outdated entries → write summary, then:
11
+ * - autoYes set → apply WITHOUT prompting; return { offered: true, updated: true }
12
+ * - else prompt:
13
+ * - user yes → runUpdateFn({ all: true }); return { offered: true, updated: true }
14
+ * - user no → write manual command hint; return { offered: true, updated: false }
15
+ * - Any error → fail-silent; return { offered: false, updated: false }
16
+ * (except: if runUpdateFn throws after a yes answer,
17
+ * return { offered: true, updated: false })
18
+ *
19
+ * `autoYes` is the USER's opt-in (callers derive it from
20
+ * process.env.ENACT_EXTENSIONS_AUTO_UPDATE); this module never reads env itself.
21
+ */
22
+
23
+ /**
24
+ * Offer the user a drift refresh if any installed plugins are outdated.
25
+ *
26
+ * @param {object} opts
27
+ * @param {boolean} opts.isTTY - whether the terminal is interactive
28
+ * @param {boolean} [opts.autoYes] - when true, apply without prompting (user opt-in)
29
+ * @param {() => Promise<import("./outdated.mjs").OutdatedEntry[]>} opts.computeOutdatedFn
30
+ * @param {(question: string) => Promise<boolean>} opts.promptYesNo
31
+ * @param {(opts: { all: boolean }) => void | Promise<void>} opts.runUpdateFn
32
+ * @param {(msg: string) => void} opts.write
33
+ * @returns {Promise<{ offered: boolean, updated: boolean }>}
34
+ */
35
+ export async function offerDriftRefresh({ isTTY, autoYes, computeOutdatedFn, promptYesNo, runUpdateFn, write }) {
36
+ try {
37
+ // Non-interactive: exit immediately without any work.
38
+ if (!isTTY) {
39
+ return { offered: false, updated: false };
40
+ }
41
+
42
+ // Compute outdated entries and filter to status === "outdated" only.
43
+ const entries = await computeOutdatedFn();
44
+ const outdated = entries.filter((e) => e.status === "outdated");
45
+
46
+ // Nothing to do.
47
+ if (outdated.length === 0) {
48
+ return { offered: false, updated: false };
49
+ }
50
+
51
+ // Write summary line listing the outdated name/platform pairs.
52
+ const pairs = outdated.map((e) => `${e.name}/${e.platform}`).join(", ");
53
+ write(`\nenact-extensions: ${outdated.length} plugin(s) have drifted from their source: ${pairs}\n`);
54
+
55
+ // Opt-in auto-apply: skip the prompt and refresh directly.
56
+ if (autoYes) {
57
+ try {
58
+ await runUpdateFn({ all: true });
59
+ return { offered: true, updated: true };
60
+ } catch {
61
+ return { offered: true, updated: false };
62
+ }
63
+ }
64
+
65
+ // Prompt user.
66
+ const yes = await promptYesNo("Update all now? [Y/n] ");
67
+
68
+ if (yes) {
69
+ try {
70
+ await runUpdateFn({ all: true });
71
+ return { offered: true, updated: true };
72
+ } catch {
73
+ return { offered: true, updated: false };
74
+ }
75
+ } else {
76
+ write(`Run \`enact-extensions update --all\` to update manually.\n`);
77
+ return { offered: true, updated: false };
78
+ }
79
+ } catch {
80
+ // Fail-silent: never throw from postinstall-related code.
81
+ return { offered: false, updated: false };
82
+ }
83
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * ups-router.mjs — pure, testable module for the UserPromptSubmit keyword-router.
3
3
  *
4
- * Discovers plugin bundles and their skills, extracts trigger keywords from
5
- * SKILL.md frontmatter, and routes a user prompt to matching skills.
4
+ * Discovers plugin bundles and their skills, extracts explicit trigger phrases
5
+ * from SKILL.md frontmatter, and routes a user prompt to matching skills.
6
6
  *
7
7
  * Two exports:
8
8
  * buildSkillCatalog(extensionsDir) → CatalogEntry[]
@@ -147,49 +147,11 @@ function extractExplicitTriggers(description) {
147
147
  return quoted;
148
148
  }
149
149
 
150
- /**
151
- * Derive salient keywords from a skill's description text.
152
- *
153
- * Conservative: only extracts tokens that are plausibly skill identifiers:
154
- * - Hyphenated compound tokens (e.g. "contract-runner", "test-driven")
155
- * - $skill-name mentions (e.g. "$loop", "$tdd")
156
- * - Quoted short phrases other than the "Triggers on" section
157
- * - Unquoted tokens that are longer (>=6 chars) and not stop-words
158
- *
159
- * @param {string} description
160
- * @returns {string[]}
161
- */
162
- function descriptionKeywords(description) {
163
- const tokens = new Set();
164
-
165
- // $skill mentions: "$foo", "$foo-bar"
166
- const dollarRe = /\$([a-z][a-z0-9-]+)/gi;
167
- let m;
168
- while ((m = dollarRe.exec(description)) !== null) {
169
- tokens.add(m[1].toLowerCase());
170
- }
171
-
172
- // Hyphenated compound tokens: at least two segments, each 2+ chars
173
- const hyphenRe = /\b([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)\b/gi;
174
- while ((m = hyphenRe.exec(description)) !== null) {
175
- const tok = m[1].toLowerCase();
176
- if (!isStopWord(tok)) tokens.add(tok);
177
- }
178
-
179
- // Longer bare words (>=6 chars) that don't look like common English prose.
180
- const longWordRe = /\b([a-z]{6,})\b/gi;
181
- while ((m = longWordRe.exec(description)) !== null) {
182
- const tok = m[1].toLowerCase();
183
- if (!isStopWord(tok)) tokens.add(tok);
184
- }
185
-
186
- return [...tokens];
187
- }
188
-
189
150
  /**
190
151
  * Derive the full keyword set for a single skill.
191
- * Priority: explicit "Triggers on" > skill name > salient description terms.
192
- * Deduplicates and excludes stop-words.
152
+ * Only explicit `Triggers on "..."` phrases and the skill name are used.
153
+ * Do not mine arbitrary words from descriptions; that makes everyday prompts
154
+ * like "change this contract" trigger unrelated skill hints.
193
155
  *
194
156
  * @param {string} skillName
195
157
  * @param {string|null} description
@@ -208,8 +170,6 @@ function deriveKeywords(skillName, description) {
208
170
  if (description) {
209
171
  // Explicit triggers have highest priority.
210
172
  for (const t of extractExplicitTriggers(description)) push(t);
211
- // Salient terms from the description.
212
- for (const t of descriptionKeywords(description)) push(t);
213
173
  }
214
174
 
215
175
  return [...seen];
@@ -48,6 +48,44 @@ try {
48
48
  stdio: "inherit",
49
49
  env: { ...process.env },
50
50
  });
51
+
52
+ // Offer a drift refresh if any installed plugins are outdated.
53
+ const { offerDriftRefresh } = await import("./lib/postinstall-offer.mjs");
54
+ const { computeOutdated } = await import("./lib/outdated.mjs");
55
+ const { runUpdate } = await import("./lib/run-update.mjs");
56
+ const { createInterface } = await import("node:readline");
57
+
58
+ /**
59
+ * promptYesNo — asks a yes/no question on stdin/stdout.
60
+ * Default is YES: empty input / "y" / "yes" (case-insensitive) → true.
61
+ * EOF (Ctrl-D) resolves as false (decline) via the 'close' event so the
62
+ * script can never hang waiting for a promise that will never settle.
63
+ * @param {string} question
64
+ * @returns {Promise<boolean>}
65
+ */
66
+ function promptYesNo(question) {
67
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
68
+ return new Promise((resolve) => {
69
+ // EOF / Ctrl-D fires 'close' before the question callback — treat as decline.
70
+ rl.once("close", () => resolve(false));
71
+ rl.question(question, (answer) => {
72
+ rl.close();
73
+ const t = (answer ?? "").trim().toLowerCase();
74
+ resolve(t === "" || t === "y" || t === "yes");
75
+ });
76
+ });
77
+ }
78
+
79
+ await offerDriftRefresh({
80
+ isTTY: true,
81
+ // User opt-in: ENACT_EXTENSIONS_AUTO_UPDATE=1 applies drift refreshes without prompting.
82
+ autoYes: !!env.ENACT_EXTENSIONS_AUTO_UPDATE,
83
+ computeOutdatedFn: () => computeOutdated(),
84
+ runUpdateFn: (opts) => runUpdate(opts),
85
+ write: (s) => process.stdout.write(s),
86
+ promptYesNo,
87
+ });
88
+
51
89
  process.exit(0);
52
90
  } catch {
53
91
  // CRITICAL: never let an error propagate from postinstall.