@codyswann/lisa 4.68.1 → 4.69.0

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.
Files changed (85) hide show
  1. package/all/copy-overwrite/scripts/lisa-work-item.mjs +96 -5
  2. package/all/deletions.json +4 -2
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/project-config.d.ts +6 -0
  8. package/dist/core/project-config.d.ts.map +1 -1
  9. package/dist/core/project-config.js.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +20 -14
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/dist/opencode/plugin-templates/lisa-session-bootstrap.ts +41 -1
  14. package/package.json +4 -4
  15. package/plugins/lisa/.claude-plugin/plugin.json +11 -1
  16. package/plugins/lisa/.codex-plugin/hooks.json +10 -0
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa/hooks/auto-update.mjs +756 -0
  19. package/plugins/lisa/hooks/auto-update.sh +31 -0
  20. package/plugins/lisa/hooks/enforcement-vintage-npm.mjs +2 -2
  21. package/plugins/lisa-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/.claude-plugin/plugin.json +11 -1
  28. package/plugins/lisa-copilot/hooks/auto-update.mjs +756 -0
  29. package/plugins/lisa-copilot/hooks/auto-update.sh +31 -0
  30. package/plugins/lisa-copilot/hooks/enforcement-vintage-npm.mjs +2 -2
  31. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cursor/hooks/auto-update.mjs +756 -0
  33. package/plugins/lisa-cursor/hooks/auto-update.sh +31 -0
  34. package/plugins/lisa-cursor/hooks/enforcement-vintage-npm.mjs +2 -2
  35. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  36. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-agy/plugin.json +1 -1
  39. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  59. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-agy/plugin.json +1 -1
  64. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  69. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  74. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/src/base/.claude-plugin/plugin.json +10 -0
  77. package/plugins/src/base/hooks/auto-update.mjs +756 -0
  78. package/plugins/src/base/hooks/auto-update.sh +31 -0
  79. package/plugins/src/base/hooks/enforcement-vintage-npm.mjs +2 -2
  80. package/scripts/lib/per-agent-hook-filter.mjs +13 -0
  81. package/scripts/two-channel-couplings.json +1 -1
  82. package/typescript/copy-overwrite/.prettierignore +0 -1
  83. package/all/copy-overwrite/scripts/lisa-self-update.mjs +0 -1047
  84. package/all/create-only/.github/workflows/lisa-update.yml +0 -99
  85. package/scripts/lisa-self-update.mjs +0 -14
@@ -0,0 +1,756 @@
1
+ /**
2
+ * SessionStart: keep the project on the latest Lisa, locally (CodySwannGT/lisa#4337).
3
+ *
4
+ * When a session starts in a project whose installed `@codyswann/lisa` is
5
+ * older than npm latest, this bumps it and runs the full explicit apply in the
6
+ * session's own worktree, then commits the result as ONE separate commit. It
7
+ * replaces a scheduled CI workflow that opened pull requests as a bot — which
8
+ * needed a personal access token in every repository, because a pull request
9
+ * opened with the built-in `GITHUB_TOKEN` never starts its own checks. Nothing
10
+ * here needs a token: npm is public, and the bump, the apply and the commit
11
+ * are local. The update then reaches the shared repository through the
12
+ * session's own pull request.
13
+ *
14
+ * ## On by default
15
+ *
16
+ * `autoUpdate` in `.lisa.config.json` (local file over shared) defaults to on;
17
+ * `false` opts out. `LISA_AUTO_UPDATE=0` opts one process out, and CI never
18
+ * updates — a build must test what was committed.
19
+ *
20
+ * ## It never mixes into feature work
21
+ *
22
+ * - It only runs on a CLEAN working tree. An agent mid-task is never touched.
23
+ * - The update is always its own commit. It is committed immediately when the
24
+ * branch is a feature branch and the commit can carry what the project's
25
+ * commit gates require (a bound work item, or no traceability configured).
26
+ * Otherwise — on a deploy branch, or before any work item is bound — the
27
+ * files stay uncommitted and a pending marker records them; binding a work
28
+ * item (`lisa-work-item.mjs link` / `attach-branch`) commits them first, with
29
+ * that item's trailer, before any feature commit exists.
30
+ * - A worktree whose `node_modules` is a symlink into another checkout is
31
+ * skipped: bumping through the link would change the other checkout's install.
32
+ *
33
+ * In Lisa's own repository it moves the self-dependency (no template apply,
34
+ * caret floor) and applies the #4331 loop guard so a self-bump never chases the
35
+ * release its own merge cut.
36
+ *
37
+ * FAIL SOFT, ALWAYS. Any failure is reported in the context and the session
38
+ * starts; a half-finished update is reported, never hidden.
39
+ * @module plugins/src/base/hooks/auto-update
40
+ */
41
+ import { execFile } from "child_process";
42
+ import {
43
+ existsSync,
44
+ lstatSync,
45
+ mkdirSync,
46
+ openSync,
47
+ closeSync,
48
+ readFileSync,
49
+ realpathSync,
50
+ rmSync,
51
+ statSync,
52
+ writeFileSync,
53
+ } from "fs";
54
+ import path from "path";
55
+ import { fileURLToPath } from "url";
56
+ import {
57
+ cachedNpmLatest,
58
+ npmLatestCachePath,
59
+ precedes,
60
+ refreshNpmLatest,
61
+ } from "./enforcement-vintage-npm.mjs";
62
+
63
+ /** Marker the context block is wrapped in. */
64
+ const BLOCK_TAG = "lisa-auto-update";
65
+
66
+ /** The package kept current. */
67
+ export const LISA_PACKAGE = "@codyswann/lisa";
68
+
69
+ /**
70
+ * `autoUpdate` when `.lisa.config.json` does not set it. Deliberately NOT in
71
+ * inject-resolved-config's BUILT_IN_DEFAULTS: that block is paid for in every
72
+ * session's context, and this hook's own block already says what it did.
73
+ */
74
+ export const AUTO_UPDATE_DEFAULT = true;
75
+
76
+ /** The project manifest. */
77
+ const MANIFEST = "package.json";
78
+
79
+ /**
80
+ * A lock with no readable owner older than this is reaped. Locks that name a
81
+ * live owner are never reaped by age (see {@link lockIsAbandoned}).
82
+ */
83
+ const STALE_LOCK_MS = 2 * 60 * 60 * 1000;
84
+
85
+ /** Lockfiles in tie-break order, with the manager that writes each. */
86
+ const LOCKFILES = [
87
+ ["bun.lock", "bun"],
88
+ ["bun.lockb", "bun"],
89
+ ["pnpm-lock.yaml", "pnpm"],
90
+ ["yarn.lock", "yarn"],
91
+ ["package-lock.json", "npm"],
92
+ ];
93
+
94
+ /** Subject of a release-bot commit, in either form the release workflow emits. */
95
+ const RELEASE_SUBJECT = /^chore\(release\): \S+ \[skip ci\](?: \[skip-cd\])?$/u;
96
+
97
+ /** Subject of a Lisa update commit; the self-mode loop guard keys on it. */
98
+ const UPDATE_SUBJECT = /^chore\(deps\): update Lisa to \S+$/u;
99
+
100
+ /**
101
+ * `git status` in its machine form: NUL-delimited, unquoted paths, one record
102
+ * per untracked FILE rather than per directory.
103
+ */
104
+ const STATUS_Z = [
105
+ "git",
106
+ "status",
107
+ "--porcelain=v1",
108
+ "-z",
109
+ "--untracked-files=all",
110
+ ];
111
+
112
+ /**
113
+ * Paths a NUL-delimited `git status --porcelain=v1 -z` reports. A rename or
114
+ * copy record (flagged in either status column) is followed by an extra record
115
+ * holding its SOURCE path; both are returned, because a path-limited commit
116
+ * must carry the source's removal as well as the destination.
117
+ * @param {string} output Raw command output.
118
+ * @returns {string[]} Changed paths.
119
+ */
120
+ export function parsePorcelainZ(output) {
121
+ const records = output.split("\0").filter(Boolean);
122
+ const paths = [];
123
+ for (let index = 0; index < records.length; index += 1) {
124
+ const record = records[index];
125
+ paths.push(record.slice(3));
126
+ // A rename or copy can be flagged in EITHER status column, and its source
127
+ // record follows. Both paths belong to the change: staging only the
128
+ // destination would leave the source's deletion behind.
129
+ if (/[RC]/u.test(record.slice(0, 2)) && records[index + 1] !== undefined) {
130
+ paths.push(records[index + 1]);
131
+ index += 1;
132
+ }
133
+ }
134
+ return paths;
135
+ }
136
+
137
+ /**
138
+ * Subject of the commit an update makes.
139
+ * @param {string} version Target version.
140
+ * @returns {string} Conventional commit subject.
141
+ */
142
+ export function updateSubject(version) {
143
+ return `chore(deps): update Lisa to ${version}`;
144
+ }
145
+
146
+ /**
147
+ * Parse a JSON file, or null.
148
+ * @param {string} file Absolute path.
149
+ * @returns {any} Parsed value, or null.
150
+ */
151
+ function readJson(file) {
152
+ try {
153
+ return JSON.parse(readFileSync(file, "utf8"));
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * The project's merged Lisa config: the local file's keys over the shared one.
161
+ * @param {string} projectDir Project root.
162
+ * @returns {Record<string, any>} Merged config (empty when neither exists).
163
+ */
164
+ export function readLisaConfig(projectDir) {
165
+ return {
166
+ ...readJson(path.join(projectDir, ".lisa.config.json")),
167
+ ...readJson(path.join(projectDir, ".lisa.config.local.json")),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Whether auto-update is on: the config's `autoUpdate`, defaulting to on.
173
+ * @param {Record<string, any>} config Merged Lisa config.
174
+ * @param {NodeJS.ProcessEnv} env Environment.
175
+ * @returns {{on: boolean, reason: string}} Decision and why.
176
+ */
177
+ export function autoUpdateSetting(config, env) {
178
+ if (env.CI) return { on: false, reason: "CI never updates Lisa" };
179
+ if (env.LISA_AUTO_UPDATE === "0") {
180
+ return { on: false, reason: "LISA_AUTO_UPDATE=0 is set" };
181
+ }
182
+ const on =
183
+ typeof config.autoUpdate === "boolean"
184
+ ? config.autoUpdate
185
+ : AUTO_UPDATE_DEFAULT;
186
+ return on
187
+ ? { on: true, reason: "" }
188
+ : { on: false, reason: "autoUpdate is false in .lisa.config.json" };
189
+ }
190
+
191
+ /**
192
+ * Choose the package manager: `packageManager` first, then an `engines`
193
+ * `please-use-<pm>` sentinel, then lockfiles minus any manager `engines`
194
+ * forbids — the same precedence as the install-pkgs hook.
195
+ * @param {Record<string, any>} manifest Parsed package.json.
196
+ * @param {readonly string[]} lockfiles Lockfile names present.
197
+ * @returns {"bun" | "npm" | "pnpm" | "yarn"} The manager.
198
+ */
199
+ export function choosePackageManager(manifest, lockfiles) {
200
+ const declared = /^(bun|npm|pnpm|yarn)@/u.exec(
201
+ String(manifest?.packageManager ?? "")
202
+ );
203
+ if (declared) return /** @type {any} */ (declared[1]);
204
+ const entries = Object.entries(manifest?.engines ?? {});
205
+ const named = entries
206
+ .map(([, value]) => /^please-use-(bun|npm|pnpm|yarn)$/u.exec(String(value)))
207
+ .find(Boolean);
208
+ if (named) return /** @type {any} */ (named[1]);
209
+ const forbidden = new Set(
210
+ entries
211
+ .filter(([, value]) => String(value).startsWith("please-use-"))
212
+ .map(([key]) => key)
213
+ );
214
+ const fromLock = LOCKFILES.find(
215
+ ([file, manager]) => lockfiles.includes(file) && !forbidden.has(manager)
216
+ );
217
+ return /** @type {any} */ (fromLock ? fromLock[1] : "npm");
218
+ }
219
+
220
+ /**
221
+ * The command that bumps Lisa.
222
+ * @param {"bun" | "npm" | "pnpm" | "yarn"} manager Package manager.
223
+ * @param {string} spec Version spec, e.g. `4.68.0` or `^4.68.0`.
224
+ * @returns {string[]} argv.
225
+ */
226
+ export function bumpCommand(manager, spec) {
227
+ const target = `${LISA_PACKAGE}@${spec}`;
228
+ return {
229
+ bun: ["bun", "add", "-D", target],
230
+ npm: ["npm", "install", "-D", target],
231
+ pnpm: ["pnpm", "add", "-D", target],
232
+ yarn: ["yarn", "add", "-D", target],
233
+ }[manager];
234
+ }
235
+
236
+ /**
237
+ * Whether the newer release differs from the pinned one only by update and
238
+ * release commits — the self-mode loop guard (CodySwannGT/lisa#4331). Every
239
+ * merge to Lisa's main cuts a release, so a self-bump publishes a newer version
240
+ * than it pinned; chasing it would bump forever.
241
+ * @param {readonly string[]} subjects Non-merge subjects between the tags.
242
+ * @returns {boolean} True when nothing else changed.
243
+ */
244
+ export function onlyUpdateCommits(subjects) {
245
+ return subjects.every(
246
+ subject => RELEASE_SUBJECT.test(subject) || UPDATE_SUBJECT.test(subject)
247
+ );
248
+ }
249
+
250
+ /**
251
+ * Whether the project enforces a work-item trailer on commits: it configures a
252
+ * tracker or work-item verification. Such a commit must carry a bound item.
253
+ * @param {Record<string, any>} config Merged Lisa config.
254
+ * @returns {boolean} True when a commit needs a `Work-Item:` trailer.
255
+ */
256
+ export function requiresWorkItem(config) {
257
+ return Boolean(config.tracker || config.workItem);
258
+ }
259
+
260
+ /**
261
+ * Branches that deploy: `deploy.branches` values, else the usual defaults.
262
+ * @param {Record<string, any>} config Merged Lisa config.
263
+ * @returns {Set<string>} Branch names never committed to directly.
264
+ */
265
+ export function deployBranches(config) {
266
+ const declared = Object.values(config.deploy?.branches ?? {}).filter(
267
+ value => typeof value === "string" && value.trim() !== ""
268
+ );
269
+ return new Set(declared.length > 0 ? declared : ["main", "master"]);
270
+ }
271
+
272
+ /**
273
+ * Decide whether the update can be committed now, and with which trailer.
274
+ * @param {{branch: string, config: Record<string, any>, boundRef: string | null}} input Facts.
275
+ * @returns {{commitNow: boolean, workItem: string | null, reason: string}} Decision.
276
+ */
277
+ export function commitDecision(input) {
278
+ if (!input.branch) {
279
+ return { commitNow: false, workItem: null, reason: "HEAD is detached" };
280
+ }
281
+ if (deployBranches(input.config).has(input.branch)) {
282
+ return {
283
+ commitNow: false,
284
+ workItem: null,
285
+ reason: `${input.branch} is a deploy branch`,
286
+ };
287
+ }
288
+ if (input.boundRef) {
289
+ return { commitNow: true, workItem: input.boundRef, reason: "" };
290
+ }
291
+ if (requiresWorkItem(input.config)) {
292
+ return {
293
+ commitNow: false,
294
+ workItem: null,
295
+ reason:
296
+ "no work item is bound yet and this project requires one on every commit",
297
+ };
298
+ }
299
+ return { commitNow: true, workItem: null, reason: "" };
300
+ }
301
+
302
+ /**
303
+ * The commit message for an update.
304
+ * @param {string} from Previous version.
305
+ * @param {string} to New version.
306
+ * @param {string | null} workItem Trailer, if any.
307
+ * @returns {string} Full message.
308
+ */
309
+ export function updateMessage(from, to, workItem) {
310
+ const trailer = workItem ? `\n\nWork-Item: ${workItem}` : "";
311
+ return `${updateSubject(to)}\n\nApplied automatically at session start: Lisa ${from} to ${to}, including the template changes \`lisa apply\` makes for it.${trailer}\n`;
312
+ }
313
+
314
+ /**
315
+ * Run a command and resolve with trimmed stdout; reject with its stderr.
316
+ * @param {string[]} argv Command and arguments.
317
+ * @param {{cwd: string, env?: NodeJS.ProcessEnv}} options Options.
318
+ * @returns {Promise<string>} Trimmed stdout.
319
+ */
320
+ export function runCommand(argv, options) {
321
+ return new Promise((resolve, reject) => {
322
+ execFile(
323
+ argv[0],
324
+ argv.slice(1),
325
+ {
326
+ cwd: options.cwd,
327
+ env: options.env ?? process.env,
328
+ maxBuffer: 64 * 1024 * 1024,
329
+ },
330
+ (error, stdout, stderr) => {
331
+ if (error) {
332
+ reject(
333
+ new Error(
334
+ `\`${argv.join(" ")}\` failed: ${String(stderr || error.message)
335
+ .trim()
336
+ .slice(-2000)}`
337
+ )
338
+ );
339
+ return;
340
+ }
341
+ // trimEnd, never trim: `git status --porcelain` starts a record with a
342
+ // significant space (" M path"), and trimming it corrupts the path.
343
+ resolve(String(stdout).trimEnd());
344
+ }
345
+ );
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Whether a process is alive. EPERM means it exists but is not ours to signal.
351
+ * @param {number} pid Process id.
352
+ * @returns {boolean} True when the process exists.
353
+ */
354
+ function processAlive(pid) {
355
+ try {
356
+ process.kill(pid, 0);
357
+ return true;
358
+ } catch (error) {
359
+ return /** @type {NodeJS.ErrnoException} */ (error).code === "EPERM";
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Whether an existing lock may be reaped: only when its owner is provably
365
+ * gone. An update has no deadline, so age alone never proves a holder dead;
366
+ * a lock whose owner cannot be read is protected until it is far older than
367
+ * any update could run.
368
+ * @param {string} lockFile Absolute lock path.
369
+ * @param {number} nowMs Current time.
370
+ * @returns {boolean} True when the holder is gone.
371
+ */
372
+ export function lockIsAbandoned(lockFile, nowMs) {
373
+ let text;
374
+ try {
375
+ text = readFileSync(lockFile, "utf8");
376
+ } catch (error) {
377
+ // Only a lock that is GONE is free. A permission or I/O error says nothing
378
+ // about whether its owner is running, so the lock stays protected.
379
+ return /** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT";
380
+ }
381
+ try {
382
+ const owner = JSON.parse(text);
383
+ if (Number.isInteger(owner?.pid)) return !processAlive(owner.pid);
384
+ } catch {
385
+ // empty or partly written: fall through to the grace period
386
+ }
387
+ try {
388
+ return nowMs - statSync(lockFile).mtimeMs > STALE_LOCK_MS;
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Take the per-worktree update lock, recording this process as its owner and
396
+ * reaping a lock only when its owner is gone.
397
+ * @param {string} lockFile Absolute lock path.
398
+ * @param {number} nowMs Current time.
399
+ * @returns {boolean} Whether the lock was taken.
400
+ */
401
+ export function takeLock(lockFile, nowMs) {
402
+ mkdirSync(path.dirname(lockFile), { recursive: true });
403
+ if (existsSync(lockFile) && lockIsAbandoned(lockFile, nowMs)) {
404
+ rmSync(lockFile, { force: true });
405
+ }
406
+ try {
407
+ const fd = openSync(lockFile, "wx");
408
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, at: nowMs }));
409
+ closeSync(fd);
410
+ return true;
411
+ } catch {
412
+ return false;
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Release the lock only if this process still owns it, so a session never
418
+ * deletes a lock another session legitimately holds.
419
+ * @param {string} lockFile Absolute lock path.
420
+ */
421
+ export function releaseLock(lockFile) {
422
+ try {
423
+ if (JSON.parse(readFileSync(lockFile, "utf8"))?.pid === process.pid) {
424
+ rmSync(lockFile, { force: true });
425
+ }
426
+ } catch {
427
+ // already gone, or not ours to judge
428
+ }
429
+ }
430
+
431
+ /**
432
+ * The installed Lisa version, or null.
433
+ * @param {string} projectDir Project root.
434
+ * @returns {string | null} Version.
435
+ */
436
+ function installedVersion(projectDir) {
437
+ return (
438
+ readJson(
439
+ path.join(projectDir, "node_modules", "@codyswann", "lisa", MANIFEST)
440
+ )?.version ?? null
441
+ );
442
+ }
443
+
444
+ /**
445
+ * npm latest from the shared cache, refreshing it first when stale or absent.
446
+ * @param {string} projectDir Project root.
447
+ * @param {{nowMs: number, refresh?: typeof refreshNpmLatest}} deps Clock and refresher.
448
+ * @returns {Promise<string | null>} Latest version, or null when npm is unreachable.
449
+ */
450
+ async function npmLatest(projectDir, deps) {
451
+ const cachePath = npmLatestCachePath(projectDir);
452
+ const cached = cachedNpmLatest(cachePath, deps.nowMs);
453
+ if (cached?.fresh) return cached.version;
454
+ // probe-direction: neutral — an unreachable registry yields no target and the
455
+ // update is skipped and reported; nothing is gated on the value.
456
+ const fetched = await (deps.refresh ?? refreshNpmLatest)(cachePath);
457
+ return fetched ?? cached?.version ?? null;
458
+ }
459
+
460
+ /**
461
+ * Everything decided before anything is changed.
462
+ * @param {object} ctx Context.
463
+ * @returns {Promise<{skip: string} | {from: string, to: string, selfMode: boolean, manager: string}>} Plan.
464
+ */
465
+ async function plan(ctx) {
466
+ const { projectDir, run } = ctx;
467
+ const manifest = readJson(path.join(projectDir, MANIFEST));
468
+ const selfMode = manifest?.name === LISA_PACKAGE;
469
+ const declares =
470
+ selfMode ||
471
+ Boolean(
472
+ manifest?.devDependencies?.[LISA_PACKAGE] ??
473
+ manifest?.dependencies?.[LISA_PACKAGE]
474
+ );
475
+ if (!declares) return { skip: "" };
476
+ const modules = path.join(projectDir, "node_modules");
477
+ if (!existsSync(modules)) return { skip: "" };
478
+ if (lstatSync(modules).isSymbolicLink()) {
479
+ return {
480
+ skip: "node_modules is a link to another checkout's install, so updating here would change that checkout too. Update Lisa from that checkout instead.",
481
+ };
482
+ }
483
+ const from = installedVersion(projectDir);
484
+ const to = await npmLatest(projectDir, ctx);
485
+ if (!from) return { skip: "" };
486
+ if (!to)
487
+ return {
488
+ skip: "npm could not be reached to check for a newer Lisa; nothing was changed.",
489
+ };
490
+ if (!precedes(from, to)) return { skip: "" };
491
+ const dirty = await run(["git", "status", "--porcelain"], {
492
+ cwd: projectDir,
493
+ });
494
+ if (dirty) {
495
+ return {
496
+ skip: `Lisa ${to} is available (this project has ${from}), but the working tree has uncommitted changes, so nothing was changed. It updates at the start of a session that begins on a clean tree.`,
497
+ };
498
+ }
499
+ if (selfMode) {
500
+ // probe-direction: fail-closed — an unreadable release range updates
501
+ // nothing; it is reported, never read as "current" or as "behind".
502
+ const current = await selfPinIsCurrent(ctx, from, to).catch(() => null);
503
+ if (current === null) {
504
+ return {
505
+ skip: `Lisa ${to} is available, but the release tags needed to check it could not be read (offline?), so nothing was changed.`,
506
+ };
507
+ }
508
+ if (current) return { skip: "" };
509
+ }
510
+ const lockfiles = LOCKFILES.map(([file]) => file).filter(file =>
511
+ existsSync(path.join(projectDir, file))
512
+ );
513
+ return {
514
+ from,
515
+ to,
516
+ selfMode,
517
+ manager: choosePackageManager(manifest, lockfiles),
518
+ };
519
+ }
520
+
521
+ /**
522
+ * The self-mode loop guard, reading release tags (fetched if missing).
523
+ * @param {object} ctx Context.
524
+ * @param {string} from Pinned version.
525
+ * @param {string} to Latest version.
526
+ * @returns {Promise<boolean>} True when the pin is effectively current.
527
+ */
528
+ async function selfPinIsCurrent(ctx, from, to) {
529
+ const range = [
530
+ "git",
531
+ "log",
532
+ "--no-merges",
533
+ "--format=%s",
534
+ `v${from}..v${to}`,
535
+ ];
536
+ const log = await ctx.run(range, { cwd: ctx.projectDir }).catch(async () => {
537
+ await ctx.run(["git", "fetch", "--tags", "--quiet", "origin"], {
538
+ cwd: ctx.projectDir,
539
+ });
540
+ return ctx.run(range, { cwd: ctx.projectDir });
541
+ });
542
+ return onlyUpdateCommits(log.split("\n").filter(Boolean));
543
+ }
544
+
545
+ /**
546
+ * Bump, apply (hosts only), and prove the result.
547
+ * @param {object} ctx Context.
548
+ * @param {{from: string, to: string, selfMode: boolean, manager: string}} update The plan.
549
+ * @returns {Promise<void>} Resolves when the update is on disk and proven.
550
+ */
551
+ async function applyUpdate(ctx, update) {
552
+ const { projectDir, run, env } = ctx;
553
+ const spec = update.selfMode ? `^${update.to}` : update.to;
554
+ await run(bumpCommand(/** @type {any} */ (update.manager), spec), {
555
+ cwd: projectDir,
556
+ });
557
+ if (installedVersion(projectDir) !== update.to) {
558
+ throw new Error(
559
+ `the bump installed ${installedVersion(projectDir) ?? "nothing"}, not ${update.to}`
560
+ );
561
+ }
562
+ if (update.selfMode) return;
563
+ await run(
564
+ [
565
+ "node",
566
+ "node_modules/@codyswann/lisa/dist/index.js",
567
+ "--yes",
568
+ "--skip-git-check",
569
+ ".",
570
+ ],
571
+ { cwd: projectDir, env: { ...env, LISA_BOOTSTRAP: "1" } }
572
+ );
573
+ const receipt = readJson(
574
+ path.join(projectDir, ".lisa", "apply-receipt.json")
575
+ );
576
+ if (receipt?.lisa_version !== update.to || receipt?.apply_mode !== "full") {
577
+ throw new Error(
578
+ `the apply did not record a full apply of ${update.to} in .lisa/apply-receipt.json`
579
+ );
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Commit the update now, or leave it pending for the first work-item binding.
585
+ * @param {object} ctx Context.
586
+ * @param {{from: string, to: string}} update The update.
587
+ * @returns {Promise<string>} One sentence describing where the update is.
588
+ */
589
+ async function settle(ctx, update) {
590
+ const { projectDir, run } = ctx;
591
+ const changed = parsePorcelainZ(await run(STATUS_Z, { cwd: projectDir }));
592
+ const branch = await run(["git", "branch", "--show-current"], {
593
+ cwd: projectDir,
594
+ });
595
+ const gitPath = rel =>
596
+ run(["git", "rev-parse", "--git-path", rel], { cwd: projectDir });
597
+ const binding = readJson(
598
+ path.resolve(projectDir, await gitPath("lisa/work-item.json"))
599
+ );
600
+ const decision = commitDecision({
601
+ branch,
602
+ config: ctx.config,
603
+ boundRef:
604
+ binding && (!binding.branch || binding.branch === branch)
605
+ ? binding.ref
606
+ : null,
607
+ });
608
+ if (decision.commitNow) {
609
+ try {
610
+ await run(["git", "add", "--", ...changed], { cwd: projectDir });
611
+ await run(
612
+ [
613
+ "git",
614
+ "commit",
615
+ "--only",
616
+ "-m",
617
+ updateMessage(update.from, update.to, decision.workItem),
618
+ "--",
619
+ ...changed,
620
+ ],
621
+ { cwd: projectDir }
622
+ );
623
+ const sha = await run(["git", "rev-parse", "--short", "HEAD"], {
624
+ cwd: projectDir,
625
+ });
626
+ return `It is committed on ${branch} as its own commit ${sha}, so it ships with this branch's pull request.`;
627
+ } catch (error) {
628
+ await run(["git", "reset", "--quiet"], { cwd: projectDir }).catch(
629
+ () => ""
630
+ );
631
+ decision.reason = `committing it failed (${String(error.message).split("\n")[0]})`;
632
+ }
633
+ }
634
+ const marker = path.resolve(
635
+ projectDir,
636
+ await gitPath("lisa/pending-update.json")
637
+ );
638
+ mkdirSync(path.dirname(marker), { recursive: true });
639
+ writeFileSync(
640
+ marker,
641
+ `${JSON.stringify({ from: update.from, to: update.to, files: changed }, null, 2)}\n`
642
+ );
643
+ return `It is NOT committed yet (${decision.reason}). The ${changed.length} changed file(s) are left in the working tree and recorded as a pending update; binding a work item on a feature branch (\`lisa-work-item.mjs link\` / \`attach-branch\`, which /lisa:track runs) commits them first, as their own commit. Do not fold these files into a feature commit.`;
644
+ }
645
+
646
+ /**
647
+ * Remind about a pending update, or clear a marker whose files were committed.
648
+ * @param {object} ctx Context.
649
+ * @returns {Promise<string | null>} A reminder, or null when nothing is pending.
650
+ */
651
+ async function pendingReminder(ctx) {
652
+ const markerPath = path.resolve(
653
+ ctx.projectDir,
654
+ await ctx.run(
655
+ ["git", "rev-parse", "--git-path", "lisa/pending-update.json"],
656
+ { cwd: ctx.projectDir }
657
+ )
658
+ );
659
+ const marker = readJson(markerPath);
660
+ if (!marker) return null;
661
+ const dirty = await ctx.run(["git", "status", "--porcelain"], {
662
+ cwd: ctx.projectDir,
663
+ });
664
+ if (!dirty) {
665
+ rmSync(markerPath, { force: true });
666
+ return null;
667
+ }
668
+ return `A Lisa update to ${marker.to} was applied in an earlier session and is still uncommitted (${(marker.files ?? []).length} file(s)). Binding a work item on a feature branch commits it first, as its own commit. Do not fold these files into a feature commit.`;
669
+ }
670
+
671
+ /**
672
+ * Run the whole session-start update.
673
+ * @param {{projectDir: string, env?: NodeJS.ProcessEnv, nowMs?: number, run?: typeof runCommand, refresh?: typeof refreshNpmLatest}} input Inputs.
674
+ * @returns {Promise<string>} The context sentence(s), or "" when there is nothing to say.
675
+ */
676
+ export async function autoUpdate(input) {
677
+ const ctx = {
678
+ env: process.env,
679
+ nowMs: Date.now(),
680
+ run: runCommand,
681
+ ...input,
682
+ };
683
+ ctx.config = readLisaConfig(ctx.projectDir);
684
+ const setting = autoUpdateSetting(ctx.config, ctx.env);
685
+ if (!setting.on) return "";
686
+ const pending = await pendingReminder(ctx);
687
+ if (pending) return pending;
688
+ const update = await plan(ctx);
689
+ if ("skip" in update) return update.skip;
690
+ const lock = path.resolve(
691
+ ctx.projectDir,
692
+ await ctx.run(["git", "rev-parse", "--git-path", "lisa/auto-update.lock"], {
693
+ cwd: ctx.projectDir,
694
+ })
695
+ );
696
+ if (!takeLock(lock, ctx.nowMs)) {
697
+ return `Lisa ${update.to} is available; another session is updating this worktree right now.`;
698
+ }
699
+ try {
700
+ await applyUpdate(ctx, update);
701
+ const where = await settle(ctx, update);
702
+ const what = update.selfMode
703
+ ? `Updated Lisa's own self-dependency from ${update.from} to ${update.to}.`
704
+ : `Updated Lisa from ${update.from} to ${update.to}, including the template changes \`lisa apply\` makes for it.`;
705
+ return `${what} ${where} Guard and skill behaviour you observe in THIS session is still the plugin copy it started with.`;
706
+ } catch (error) {
707
+ return `Lisa ${update.to} is available but the automatic update failed: ${String(error.message).split("\n")[0]}. The working tree may hold a partial update; review it with \`git status\` before other work, or set "autoUpdate": false to stop these attempts.`;
708
+ } finally {
709
+ releaseLock(lock);
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Wrap the context in the hook envelope.
715
+ * @param {string} text Context sentence(s).
716
+ * @param {string} event Hook event name.
717
+ * @returns {string} JSON envelope, or "" when there is nothing to say.
718
+ */
719
+ export function envelope(text, event) {
720
+ if (!text) return "";
721
+ return JSON.stringify({
722
+ hookSpecificOutput: {
723
+ hookEventName: event,
724
+ additionalContext: `<${BLOCK_TAG}>\n${text}\n</${BLOCK_TAG}>`,
725
+ },
726
+ });
727
+ }
728
+
729
+ /**
730
+ * Whether this module is the process entry point (realpaths both sides; see
731
+ * enforcement-vintage.mjs for why).
732
+ * @param {string} moduleUrl This module's URL.
733
+ * @param {string | undefined} [argv1] Entry path.
734
+ * @returns {boolean} True when run directly.
735
+ */
736
+ function invokedDirectly(moduleUrl, argv1 = process.argv[1]) {
737
+ if (!argv1) return false;
738
+ try {
739
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
740
+ } catch {
741
+ return false;
742
+ }
743
+ }
744
+
745
+ if (invokedDirectly(import.meta.url)) {
746
+ const index = process.argv.indexOf("--project-dir");
747
+ const projectDir = index >= 0 ? process.argv[index + 1] : process.cwd();
748
+ autoUpdate({ projectDir })
749
+ .then(text => {
750
+ const out = envelope(text, "SessionStart");
751
+ if (out) process.stdout.write(`${out}\n`);
752
+ })
753
+ .catch(() => {
754
+ // Fail soft: a session must always start.
755
+ });
756
+ }