pi-anchor 0.1.0 → 0.2.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.
@@ -931,16 +931,16 @@ function makeSafeTaskAnnotation(filePath, anchor) {
931
931
  }
932
932
  return "\n\n" + formatter(`\u2316 anchor context: #${anchor.id} ${anchor.title} (${anchor.priority.toUpperCase()})`);
933
933
  }
934
- function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
934
+ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg, preflightFailures) {
935
935
  let cwd;
936
936
  let now = Date.now();
937
937
  if (typeof cwdOrNow === "number") {
938
938
  now = cwdOrNow;
939
939
  } else if (typeof cwdOrNow === "string") {
940
940
  cwd = cwdOrNow;
941
- if (typeof nowArg === "number") {
942
- now = nowArg;
943
- }
941
+ }
942
+ if (typeof nowArg === "number") {
943
+ now = nowArg;
944
944
  }
945
945
  sweepStore(store, now);
946
946
  const activeAnchors = store.list({ status: "active", cwd });
@@ -968,7 +968,9 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
968
968
  }
969
969
  const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1e3));
970
970
  const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : "";
971
- return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
971
+ const failureSnippet = preflightFailures?.get(a.id);
972
+ const verifyHint = failureSnippet ? ` [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25: ${failureSnippet.replace(/\s+/g, " ").slice(-100)}]` : a.verifyCommand ? ` [verify: ${a.verifyCommand}]` : "";
973
+ return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${verifyHint}${ageHint}${tagHint}${fileHint}`;
972
974
  });
973
975
  return [
974
976
  `<active-anchors count="${activeAnchors.length}">`,
@@ -977,6 +979,7 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
977
979
  ``,
978
980
  `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
979
981
  `- If the user opens the session with a casual greeting or continuation ("hi", "\u5728\u5417", "\u7EE7\u7EED", "\u65E9\u4E0A\u597D"), naturally prioritize items tagged [\u4ECA\u65E5\u805A\u7126\xB7Due Today] in a friendly assistant tone (e.g. "\u55E8\uFF01\u4ECA\u5929\u89C4\u5212\u7684\u3010...\u3011\u73B0\u5728\u5904\u7406\u8FD8\u662F\u770B\u522B\u7684\uFF1F").`,
982
+ `- If an anchor has [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25], its verifyCommand failed on session start. Target the failing assertion directly.`,
980
983
  `- If the user issues a direct, concrete new instruction (e.g. "\u5E2E\u6211\u67E5\u4E0B\u8FD9\u4E2A\u62A5\u9519"), DO NOT interrupt their flow. Focus directly on their instruction.`,
981
984
  `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
982
985
  `</active-anchors>`
@@ -1191,22 +1194,32 @@ function updateStartupBanner(ctx, store) {
1191
1194
  }
1192
1195
  const groups = groupAnchorsByQuadrant(active);
1193
1196
  const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
1194
- const lines = [
1195
- `\u2316 Anchor \xB7 ${active.length} active commitments:`
1196
- ];
1197
1197
  const topItems = sorted.slice(0, 3);
1198
- for (let i = 0; i < topItems.length; i++) {
1199
- const a = topItems[i];
1200
- const num = (i + 1).toString().padStart(2, "0");
1201
- const q = classifyAnchor(a);
1202
- const tgt = formatTargetDate(a);
1203
- const titlePadded = a.title.length > 34 ? a.title.slice(0, 32) + ".." : a.title;
1204
- lines.push(` \u2022 [${q}] ${num} ${titlePadded} (${tgt})`);
1205
- }
1206
- if (sorted.length > 3) {
1207
- lines.push(` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`);
1208
- }
1209
- ctx.ui.setWidget("anchor-startup", lines, { placement: "aboveEditor" });
1198
+ const factory = (_tui, theme) => ({
1199
+ render: (width) => {
1200
+ const maxTitleWidth = Math.max(16, width - 36);
1201
+ const fg = theme?.fg ? theme.fg.bind(theme) : (_c, text) => text;
1202
+ const lines = [
1203
+ fg("accent", `\u2316 Anchor \xB7 ${active.length} active commitments:`)
1204
+ ];
1205
+ for (let i = 0; i < topItems.length; i++) {
1206
+ const a = topItems[i];
1207
+ const num = (i + 1).toString().padStart(2, "0");
1208
+ const q = classifyAnchor(a);
1209
+ const tgt = formatTargetDate(a);
1210
+ const titlePadded = truncateToWidth(a.title, maxTitleWidth);
1211
+ lines.push(` \u2022 ${fg("muted", `[${q}]`)} ${fg("dim", num)} ${titlePadded} ${fg("dim", `(${tgt})`)}`);
1212
+ }
1213
+ if (sorted.length > 3) {
1214
+ lines.push(fg("dim", ` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`));
1215
+ }
1216
+ return lines;
1217
+ },
1218
+ invalidate: () => {
1219
+ }
1220
+ });
1221
+ const content = ctx.ui?.theme ? factory : factory().render(80);
1222
+ ctx.ui.setWidget("anchor-startup", content, { placement: "aboveEditor" });
1210
1223
  }
1211
1224
  async function openAnchorDashboard(ctx, store) {
1212
1225
  if (!ctx.hasUI || !ctx.ui) return;
@@ -965,16 +965,16 @@ function makeSafeTaskAnnotation(filePath, anchor) {
965
965
  }
966
966
  return "\n\n" + formatter(`\u2316 anchor context: #${anchor.id} ${anchor.title} (${anchor.priority.toUpperCase()})`);
967
967
  }
968
- function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
968
+ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg, preflightFailures) {
969
969
  let cwd;
970
970
  let now = Date.now();
971
971
  if (typeof cwdOrNow === "number") {
972
972
  now = cwdOrNow;
973
973
  } else if (typeof cwdOrNow === "string") {
974
974
  cwd = cwdOrNow;
975
- if (typeof nowArg === "number") {
976
- now = nowArg;
977
- }
975
+ }
976
+ if (typeof nowArg === "number") {
977
+ now = nowArg;
978
978
  }
979
979
  sweepStore(store, now);
980
980
  const activeAnchors = store.list({ status: "active", cwd });
@@ -1002,7 +1002,9 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1002
1002
  }
1003
1003
  const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1e3));
1004
1004
  const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : "";
1005
- return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
1005
+ const failureSnippet = preflightFailures?.get(a.id);
1006
+ const verifyHint = failureSnippet ? ` [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25: ${failureSnippet.replace(/\s+/g, " ").slice(-100)}]` : a.verifyCommand ? ` [verify: ${a.verifyCommand}]` : "";
1007
+ return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${verifyHint}${ageHint}${tagHint}${fileHint}`;
1006
1008
  });
1007
1009
  return [
1008
1010
  `<active-anchors count="${activeAnchors.length}">`,
@@ -1011,6 +1013,7 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1011
1013
  ``,
1012
1014
  `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
1013
1015
  `- If the user opens the session with a casual greeting or continuation ("hi", "\u5728\u5417", "\u7EE7\u7EED", "\u65E9\u4E0A\u597D"), naturally prioritize items tagged [\u4ECA\u65E5\u805A\u7126\xB7Due Today] in a friendly assistant tone (e.g. "\u55E8\uFF01\u4ECA\u5929\u89C4\u5212\u7684\u3010...\u3011\u73B0\u5728\u5904\u7406\u8FD8\u662F\u770B\u522B\u7684\uFF1F").`,
1016
+ `- If an anchor has [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25], its verifyCommand failed on session start. Target the failing assertion directly.`,
1014
1017
  `- If the user issues a direct, concrete new instruction (e.g. "\u5E2E\u6211\u67E5\u4E0B\u8FD9\u4E2A\u62A5\u9519"), DO NOT interrupt their flow. Focus directly on their instruction.`,
1015
1018
  `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
1016
1019
  `</active-anchors>`
@@ -1205,22 +1208,32 @@ function updateStartupBanner(ctx, store) {
1205
1208
  }
1206
1209
  const groups = groupAnchorsByQuadrant(active);
1207
1210
  const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
1208
- const lines = [
1209
- `\u2316 Anchor \xB7 ${active.length} active commitments:`
1210
- ];
1211
1211
  const topItems = sorted.slice(0, 3);
1212
- for (let i = 0; i < topItems.length; i++) {
1213
- const a = topItems[i];
1214
- const num = (i + 1).toString().padStart(2, "0");
1215
- const q = classifyAnchor(a);
1216
- const tgt = formatTargetDate(a);
1217
- const titlePadded = a.title.length > 34 ? a.title.slice(0, 32) + ".." : a.title;
1218
- lines.push(` \u2022 [${q}] ${num} ${titlePadded} (${tgt})`);
1219
- }
1220
- if (sorted.length > 3) {
1221
- lines.push(` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`);
1222
- }
1223
- ctx.ui.setWidget("anchor-startup", lines, { placement: "aboveEditor" });
1212
+ const factory = (_tui, theme) => ({
1213
+ render: (width) => {
1214
+ const maxTitleWidth = Math.max(16, width - 36);
1215
+ const fg = theme?.fg ? theme.fg.bind(theme) : (_c, text) => text;
1216
+ const lines = [
1217
+ fg("accent", `\u2316 Anchor \xB7 ${active.length} active commitments:`)
1218
+ ];
1219
+ for (let i = 0; i < topItems.length; i++) {
1220
+ const a = topItems[i];
1221
+ const num = (i + 1).toString().padStart(2, "0");
1222
+ const q = classifyAnchor(a);
1223
+ const tgt = formatTargetDate(a);
1224
+ const titlePadded = truncateToWidth(a.title, maxTitleWidth);
1225
+ lines.push(` \u2022 ${fg("muted", `[${q}]`)} ${fg("dim", num)} ${titlePadded} ${fg("dim", `(${tgt})`)}`);
1226
+ }
1227
+ if (sorted.length > 3) {
1228
+ lines.push(fg("dim", ` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`));
1229
+ }
1230
+ return lines;
1231
+ },
1232
+ invalidate: () => {
1233
+ }
1234
+ });
1235
+ const content = ctx.ui?.theme ? factory : factory().render(80);
1236
+ ctx.ui.setWidget("anchor-startup", content, { placement: "aboveEditor" });
1224
1237
  }
1225
1238
  async function openAnchorDashboard(ctx, store) {
1226
1239
  if (!ctx.hasUI || !ctx.ui) return;
@@ -1267,18 +1280,52 @@ async function openAnchorDashboard(ctx, store) {
1267
1280
 
1268
1281
  // src/extension.ts
1269
1282
  var import_node_child_process2 = require("child_process");
1283
+ var import_node_fs3 = __toESM(require("fs"), 1);
1284
+ var import_node_path5 = __toESM(require("path"), 1);
1270
1285
  var MUTATION_TOOLS2 = /* @__PURE__ */ new Set(["edit", "write", "patch", "apply_diff", "create_file", "modify"]);
1271
1286
  function extension_default(pi) {
1272
1287
  const store = new AnchorStore();
1273
1288
  const observer = new SessionTouchObserver();
1274
1289
  const annotatedThisSession = /* @__PURE__ */ new Set();
1290
+ const preflightFailures = /* @__PURE__ */ new Map();
1275
1291
  pi.on("session_start", async (event, ctx) => {
1276
1292
  observer.clear();
1277
1293
  annotatedThisSession.clear();
1294
+ preflightFailures.clear();
1278
1295
  const sweep = sweepStore(store);
1279
1296
  if (sweep.transitionedToSleeping.length > 0) {
1280
1297
  ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} \u4E2A\u975E\u6D3B\u8DC3\u4EFB\u52A1\u5DF2\u8FDB\u5165\u4F11\u7720`, "info");
1281
1298
  }
1299
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active");
1300
+ for (const a of activeAnchors) {
1301
+ if (a.verifyCommand) {
1302
+ try {
1303
+ const res = runPhysicalVerification(a, ctx.cwd);
1304
+ if (res.success) {
1305
+ store.settle(a.id, {
1306
+ settledBy: "verification-test",
1307
+ summary: `Physical pre-flight test passed: ${a.verifyCommand}`
1308
+ });
1309
+ ctx.ui.notify(`\u2693 \u7269\u7406\u9A8C\u8BC1\u901A\u8FC7\uFF01\u951A\u70B9 #${a.id} ("${a.title}") \u5DF2\u81EA\u52A8\u7ED3\u6848\u5F52\u6863\u3002`, "info");
1310
+ } else {
1311
+ preflightFailures.set(a.id, res.output.slice(-250));
1312
+ }
1313
+ } catch (_) {
1314
+ }
1315
+ }
1316
+ }
1317
+ try {
1318
+ const home = process.env.HOME || process.env.USERPROFILE || "";
1319
+ const pendingPath = import_node_path5.default.join(home, ".anchor", "pending_exit.json");
1320
+ if (import_node_fs3.default.existsSync(pendingPath)) {
1321
+ const pending = JSON.parse(import_node_fs3.default.readFileSync(pendingPath, "utf-8"));
1322
+ if (pending.cwd === ctx.cwd && Date.now() - pending.timestamp < 24 * 60 * 60 * 1e3) {
1323
+ ctx.ui.notify(`\u2316 [Anchor \u9000\u51FA\u5B88\u62A4] \u68C0\u6D4B\u5230\u4E0A\u6B21\u5F02\u5E38\u9000\u51FA\u4E14\u6709 ${pending.modifiedCount} \u4E2A\u672A\u63D0\u4EA4\u6539\u52A8\uFF0C\u53EF\u7528 anchor(action="pin") \u56FA\u5316\u4EFB\u52A1\u3002`, "info");
1324
+ }
1325
+ import_node_fs3.default.unlinkSync(pendingPath);
1326
+ }
1327
+ } catch (_) {
1328
+ }
1282
1329
  updateAnchorStatusBar(ctx, store);
1283
1330
  if (event.reason !== "resume") {
1284
1331
  updateStartupBanner(ctx, store);
@@ -1294,7 +1341,7 @@ function extension_default(pi) {
1294
1341
  const messageTurns = entries.filter((e) => e.type === "message");
1295
1342
  const isColdStart = messageTurns.length <= 1;
1296
1343
  if (isColdStart) {
1297
- const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
1344
+ const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd, void 0, preflightFailures);
1298
1345
  if (contextSnippet) {
1299
1346
  return {
1300
1347
  systemPrompt: `${event.systemPrompt}
@@ -1426,6 +1473,42 @@ Mark as completed and archive?`
1426
1473
  verifyCommand: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Optional shell command for automated physical verification" })),
1427
1474
  id: import_typebox.Type.Optional(import_typebox.Type.String({ description: "Anchor ID, e.g. anc-1 (for settle or touch)" }))
1428
1475
  }),
1476
+ renderCall(args, theme) {
1477
+ const action = args.action || "list";
1478
+ let title = theme.fg("toolTitle", theme.bold("\u2316 anchor ")) + theme.fg("accent", action);
1479
+ if (action === "pin" && args.title) {
1480
+ const due = args.targetDate ? ` ${theme.fg("dim", `[${args.targetDate}]`)}` : "";
1481
+ const files = args.files && args.files.length > 0 ? ` ${theme.fg("muted", `(${args.files.slice(0, 2).join(", ")})`)}` : "";
1482
+ title += ` "${args.title}"${due}${files}`;
1483
+ } else if (action === "settle" && args.id) {
1484
+ title += ` ${theme.fg("dim", `#${args.id}`)}`;
1485
+ } else if (action === "touch" && args.id) {
1486
+ title += ` ${theme.fg("dim", `#${args.id}`)}`;
1487
+ } else if (action === "list") {
1488
+ if (args.priority) title += ` ${theme.fg("muted", `[${args.priority}]`)}`;
1489
+ }
1490
+ return {
1491
+ render: () => [title],
1492
+ invalidate: () => {
1493
+ }
1494
+ };
1495
+ },
1496
+ renderResult(result, _options, theme) {
1497
+ if (result.isError) {
1498
+ const errText = result.content?.[0]?.text || "Execution failed";
1499
+ return {
1500
+ render: () => [theme.fg("error", `\u2717 ${errText}`)],
1501
+ invalidate: () => {
1502
+ }
1503
+ };
1504
+ }
1505
+ const text = result.content?.[0]?.text || "OK";
1506
+ return {
1507
+ render: () => [theme.fg("success", `\u2714 ${text}`)],
1508
+ invalidate: () => {
1509
+ }
1510
+ };
1511
+ },
1429
1512
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1430
1513
  if (!ctx) return { content: [{ type: "text", text: "Error: context required" }], isError: true };
1431
1514
  if (params.action === "pin") {
@@ -1557,4 +1640,28 @@ Mark as completed and archive?`
1557
1640
  updateAnchorStatusBar(ctx, store);
1558
1641
  }
1559
1642
  });
1643
+ pi.on("session_shutdown", async (event, ctx) => {
1644
+ if (event.reason === "quit") {
1645
+ try {
1646
+ const gitStatus = (0, import_node_child_process2.execSync)("git status --porcelain", {
1647
+ cwd: ctx.cwd,
1648
+ encoding: "utf-8",
1649
+ timeout: 2e3,
1650
+ stdio: ["ignore", "pipe", "ignore"]
1651
+ }).trim();
1652
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active");
1653
+ if (gitStatus && activeAnchors.length === 0) {
1654
+ const home = process.env.HOME || process.env.USERPROFILE || "";
1655
+ const pendingPath = import_node_path5.default.join(home, ".anchor", "pending_exit.json");
1656
+ const data = {
1657
+ cwd: ctx.cwd,
1658
+ timestamp: Date.now(),
1659
+ modifiedCount: gitStatus.split("\n").filter(Boolean).length
1660
+ };
1661
+ import_node_fs3.default.writeFileSync(pendingPath, JSON.stringify(data, null, 2), "utf-8");
1662
+ }
1663
+ } catch (_) {
1664
+ }
1665
+ }
1666
+ });
1560
1667
  }
package/dist/extension.js CHANGED
@@ -11,23 +11,57 @@ import {
11
11
  sweepStore,
12
12
  updateAnchorStatusBar,
13
13
  updateStartupBanner
14
- } from "./chunk-NNYUD5O7.js";
14
+ } from "./chunk-T62PHIQI.js";
15
15
 
16
16
  // src/extension.ts
17
17
  import { Type } from "@sinclair/typebox";
18
18
  import { execSync } from "child_process";
19
+ import fs from "fs";
20
+ import path from "path";
19
21
  var MUTATION_TOOLS = /* @__PURE__ */ new Set(["edit", "write", "patch", "apply_diff", "create_file", "modify"]);
20
22
  function extension_default(pi) {
21
23
  const store = new AnchorStore();
22
24
  const observer = new SessionTouchObserver();
23
25
  const annotatedThisSession = /* @__PURE__ */ new Set();
26
+ const preflightFailures = /* @__PURE__ */ new Map();
24
27
  pi.on("session_start", async (event, ctx) => {
25
28
  observer.clear();
26
29
  annotatedThisSession.clear();
30
+ preflightFailures.clear();
27
31
  const sweep = sweepStore(store);
28
32
  if (sweep.transitionedToSleeping.length > 0) {
29
33
  ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} \u4E2A\u975E\u6D3B\u8DC3\u4EFB\u52A1\u5DF2\u8FDB\u5165\u4F11\u7720`, "info");
30
34
  }
35
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active");
36
+ for (const a of activeAnchors) {
37
+ if (a.verifyCommand) {
38
+ try {
39
+ const res = runPhysicalVerification(a, ctx.cwd);
40
+ if (res.success) {
41
+ store.settle(a.id, {
42
+ settledBy: "verification-test",
43
+ summary: `Physical pre-flight test passed: ${a.verifyCommand}`
44
+ });
45
+ ctx.ui.notify(`\u2693 \u7269\u7406\u9A8C\u8BC1\u901A\u8FC7\uFF01\u951A\u70B9 #${a.id} ("${a.title}") \u5DF2\u81EA\u52A8\u7ED3\u6848\u5F52\u6863\u3002`, "info");
46
+ } else {
47
+ preflightFailures.set(a.id, res.output.slice(-250));
48
+ }
49
+ } catch (_) {
50
+ }
51
+ }
52
+ }
53
+ try {
54
+ const home = process.env.HOME || process.env.USERPROFILE || "";
55
+ const pendingPath = path.join(home, ".anchor", "pending_exit.json");
56
+ if (fs.existsSync(pendingPath)) {
57
+ const pending = JSON.parse(fs.readFileSync(pendingPath, "utf-8"));
58
+ if (pending.cwd === ctx.cwd && Date.now() - pending.timestamp < 24 * 60 * 60 * 1e3) {
59
+ ctx.ui.notify(`\u2316 [Anchor \u9000\u51FA\u5B88\u62A4] \u68C0\u6D4B\u5230\u4E0A\u6B21\u5F02\u5E38\u9000\u51FA\u4E14\u6709 ${pending.modifiedCount} \u4E2A\u672A\u63D0\u4EA4\u6539\u52A8\uFF0C\u53EF\u7528 anchor(action="pin") \u56FA\u5316\u4EFB\u52A1\u3002`, "info");
60
+ }
61
+ fs.unlinkSync(pendingPath);
62
+ }
63
+ } catch (_) {
64
+ }
31
65
  updateAnchorStatusBar(ctx, store);
32
66
  if (event.reason !== "resume") {
33
67
  updateStartupBanner(ctx, store);
@@ -43,7 +77,7 @@ function extension_default(pi) {
43
77
  const messageTurns = entries.filter((e) => e.type === "message");
44
78
  const isColdStart = messageTurns.length <= 1;
45
79
  if (isColdStart) {
46
- const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
80
+ const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd, void 0, preflightFailures);
47
81
  if (contextSnippet) {
48
82
  return {
49
83
  systemPrompt: `${event.systemPrompt}
@@ -175,6 +209,42 @@ Mark as completed and archive?`
175
209
  verifyCommand: Type.Optional(Type.String({ description: "Optional shell command for automated physical verification" })),
176
210
  id: Type.Optional(Type.String({ description: "Anchor ID, e.g. anc-1 (for settle or touch)" }))
177
211
  }),
212
+ renderCall(args, theme) {
213
+ const action = args.action || "list";
214
+ let title = theme.fg("toolTitle", theme.bold("\u2316 anchor ")) + theme.fg("accent", action);
215
+ if (action === "pin" && args.title) {
216
+ const due = args.targetDate ? ` ${theme.fg("dim", `[${args.targetDate}]`)}` : "";
217
+ const files = args.files && args.files.length > 0 ? ` ${theme.fg("muted", `(${args.files.slice(0, 2).join(", ")})`)}` : "";
218
+ title += ` "${args.title}"${due}${files}`;
219
+ } else if (action === "settle" && args.id) {
220
+ title += ` ${theme.fg("dim", `#${args.id}`)}`;
221
+ } else if (action === "touch" && args.id) {
222
+ title += ` ${theme.fg("dim", `#${args.id}`)}`;
223
+ } else if (action === "list") {
224
+ if (args.priority) title += ` ${theme.fg("muted", `[${args.priority}]`)}`;
225
+ }
226
+ return {
227
+ render: () => [title],
228
+ invalidate: () => {
229
+ }
230
+ };
231
+ },
232
+ renderResult(result, _options, theme) {
233
+ if (result.isError) {
234
+ const errText = result.content?.[0]?.text || "Execution failed";
235
+ return {
236
+ render: () => [theme.fg("error", `\u2717 ${errText}`)],
237
+ invalidate: () => {
238
+ }
239
+ };
240
+ }
241
+ const text = result.content?.[0]?.text || "OK";
242
+ return {
243
+ render: () => [theme.fg("success", `\u2714 ${text}`)],
244
+ invalidate: () => {
245
+ }
246
+ };
247
+ },
178
248
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
179
249
  if (!ctx) return { content: [{ type: "text", text: "Error: context required" }], isError: true };
180
250
  if (params.action === "pin") {
@@ -306,6 +376,30 @@ Mark as completed and archive?`
306
376
  updateAnchorStatusBar(ctx, store);
307
377
  }
308
378
  });
379
+ pi.on("session_shutdown", async (event, ctx) => {
380
+ if (event.reason === "quit") {
381
+ try {
382
+ const gitStatus = execSync("git status --porcelain", {
383
+ cwd: ctx.cwd,
384
+ encoding: "utf-8",
385
+ timeout: 2e3,
386
+ stdio: ["ignore", "pipe", "ignore"]
387
+ }).trim();
388
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active");
389
+ if (gitStatus && activeAnchors.length === 0) {
390
+ const home = process.env.HOME || process.env.USERPROFILE || "";
391
+ const pendingPath = path.join(home, ".anchor", "pending_exit.json");
392
+ const data = {
393
+ cwd: ctx.cwd,
394
+ timestamp: Date.now(),
395
+ modifiedCount: gitStatus.split("\n").filter(Boolean).length
396
+ };
397
+ fs.writeFileSync(pendingPath, JSON.stringify(data, null, 2), "utf-8");
398
+ }
399
+ } catch (_) {
400
+ }
401
+ }
402
+ });
309
403
  }
310
404
  export {
311
405
  extension_default as default
package/dist/index.cjs CHANGED
@@ -1038,22 +1038,32 @@ function updateStartupBanner(ctx, store) {
1038
1038
  }
1039
1039
  const groups = groupAnchorsByQuadrant(active);
1040
1040
  const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
1041
- const lines = [
1042
- `\u2316 Anchor \xB7 ${active.length} active commitments:`
1043
- ];
1044
1041
  const topItems = sorted.slice(0, 3);
1045
- for (let i = 0; i < topItems.length; i++) {
1046
- const a = topItems[i];
1047
- const num = (i + 1).toString().padStart(2, "0");
1048
- const q = classifyAnchor(a);
1049
- const tgt = formatTargetDate(a);
1050
- const titlePadded = a.title.length > 34 ? a.title.slice(0, 32) + ".." : a.title;
1051
- lines.push(` \u2022 [${q}] ${num} ${titlePadded} (${tgt})`);
1052
- }
1053
- if (sorted.length > 3) {
1054
- lines.push(` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`);
1055
- }
1056
- ctx.ui.setWidget("anchor-startup", lines, { placement: "aboveEditor" });
1042
+ const factory = (_tui, theme) => ({
1043
+ render: (width) => {
1044
+ const maxTitleWidth = Math.max(16, width - 36);
1045
+ const fg = theme?.fg ? theme.fg.bind(theme) : (_c, text) => text;
1046
+ const lines = [
1047
+ fg("accent", `\u2316 Anchor \xB7 ${active.length} active commitments:`)
1048
+ ];
1049
+ for (let i = 0; i < topItems.length; i++) {
1050
+ const a = topItems[i];
1051
+ const num = (i + 1).toString().padStart(2, "0");
1052
+ const q = classifyAnchor(a);
1053
+ const tgt = formatTargetDate(a);
1054
+ const titlePadded = truncateToWidth(a.title, maxTitleWidth);
1055
+ lines.push(` \u2022 ${fg("muted", `[${q}]`)} ${fg("dim", num)} ${titlePadded} ${fg("dim", `(${tgt})`)}`);
1056
+ }
1057
+ if (sorted.length > 3) {
1058
+ lines.push(fg("dim", ` (+${sorted.length - 3} more \xB7 run /anchor to inspect)`));
1059
+ }
1060
+ return lines;
1061
+ },
1062
+ invalidate: () => {
1063
+ }
1064
+ });
1065
+ const content = ctx.ui?.theme ? factory : factory().render(80);
1066
+ ctx.ui.setWidget("anchor-startup", content, { placement: "aboveEditor" });
1057
1067
  }
1058
1068
  async function openAnchorDashboard(ctx, store) {
1059
1069
  if (!ctx.hasUI || !ctx.ui) return;
@@ -1276,16 +1286,16 @@ function makeSafeTaskAnnotation(filePath, anchor) {
1276
1286
  }
1277
1287
  return "\n\n" + formatter(`\u2316 anchor context: #${anchor.id} ${anchor.title} (${anchor.priority.toUpperCase()})`);
1278
1288
  }
1279
- function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1289
+ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg, preflightFailures) {
1280
1290
  let cwd;
1281
1291
  let now = Date.now();
1282
1292
  if (typeof cwdOrNow === "number") {
1283
1293
  now = cwdOrNow;
1284
1294
  } else if (typeof cwdOrNow === "string") {
1285
1295
  cwd = cwdOrNow;
1286
- if (typeof nowArg === "number") {
1287
- now = nowArg;
1288
- }
1296
+ }
1297
+ if (typeof nowArg === "number") {
1298
+ now = nowArg;
1289
1299
  }
1290
1300
  sweepStore(store, now);
1291
1301
  const activeAnchors = store.list({ status: "active", cwd });
@@ -1313,7 +1323,9 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1313
1323
  }
1314
1324
  const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1e3));
1315
1325
  const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : "";
1316
- return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
1326
+ const failureSnippet = preflightFailures?.get(a.id);
1327
+ const verifyHint = failureSnippet ? ` [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25: ${failureSnippet.replace(/\s+/g, " ").slice(-100)}]` : a.verifyCommand ? ` [verify: ${a.verifyCommand}]` : "";
1328
+ return `\u2022 #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${verifyHint}${ageHint}${tagHint}${fileHint}`;
1317
1329
  });
1318
1330
  return [
1319
1331
  `<active-anchors count="${activeAnchors.length}">`,
@@ -1322,6 +1334,7 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
1322
1334
  ``,
1323
1335
  `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
1324
1336
  `- If the user opens the session with a casual greeting or continuation ("hi", "\u5728\u5417", "\u7EE7\u7EED", "\u65E9\u4E0A\u597D"), naturally prioritize items tagged [\u4ECA\u65E5\u805A\u7126\xB7Due Today] in a friendly assistant tone (e.g. "\u55E8\uFF01\u4ECA\u5929\u89C4\u5212\u7684\u3010...\u3011\u73B0\u5728\u5904\u7406\u8FD8\u662F\u770B\u522B\u7684\uFF1F").`,
1337
+ `- If an anchor has [\u26A1 \u7269\u7406\u9A8C\u8BC1\u5931\u8D25], its verifyCommand failed on session start. Target the failing assertion directly.`,
1325
1338
  `- If the user issues a direct, concrete new instruction (e.g. "\u5E2E\u6211\u67E5\u4E0B\u8FD9\u4E2A\u62A5\u9519"), DO NOT interrupt their flow. Focus directly on their instruction.`,
1326
1339
  `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
1327
1340
  `</active-anchors>`
package/dist/index.d.cts CHANGED
@@ -397,7 +397,7 @@ declare function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): strin
397
397
  * Only active anchors for the current project context are injected.
398
398
  * From turn 2 onwards, this returns empty string (0 tokens).
399
399
  */
400
- declare function renderColdStartAnchorsContext(store: AnchorStore, cwdOrNow?: string | number, nowArg?: number): string;
400
+ declare function renderColdStartAnchorsContext(store: AnchorStore, cwdOrNow?: string | number, nowArg?: number, preflightFailures?: Map<string, string>): string;
401
401
  /** Legacy alias for backwards compatibility */
402
402
  declare const renderActiveAnchorsContext: typeof renderColdStartAnchorsContext;
403
403
 
package/dist/index.d.ts CHANGED
@@ -397,7 +397,7 @@ declare function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): strin
397
397
  * Only active anchors for the current project context are injected.
398
398
  * From turn 2 onwards, this returns empty string (0 tokens).
399
399
  */
400
- declare function renderColdStartAnchorsContext(store: AnchorStore, cwdOrNow?: string | number, nowArg?: number): string;
400
+ declare function renderColdStartAnchorsContext(store: AnchorStore, cwdOrNow?: string | number, nowArg?: number, preflightFailures?: Map<string, string>): string;
401
401
  /** Legacy alias for backwards compatibility */
402
402
  declare const renderActiveAnchorsContext: typeof renderColdStartAnchorsContext;
403
403
 
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  truncateToWidth,
40
40
  updateAnchorStatusBar,
41
41
  updateStartupBanner
42
- } from "./chunk-NNYUD5O7.js";
42
+ } from "./chunk-T62PHIQI.js";
43
43
 
44
44
  // src/protocol.ts
45
45
  var AnchorProtocol = class {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anchor",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Zero-pollution, 0-idle-token task memory for AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -89,7 +89,8 @@ export function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): string
89
89
  export function renderColdStartAnchorsContext(
90
90
  store: AnchorStore,
91
91
  cwdOrNow?: string | number,
92
- nowArg?: number
92
+ nowArg?: number,
93
+ preflightFailures?: Map<string, string>
93
94
  ): string {
94
95
  let cwd: string | undefined;
95
96
  let now = Date.now();
@@ -98,9 +99,9 @@ export function renderColdStartAnchorsContext(
98
99
  now = cwdOrNow;
99
100
  } else if (typeof cwdOrNow === 'string') {
100
101
  cwd = cwdOrNow;
101
- if (typeof nowArg === 'number') {
102
- now = nowArg;
103
- }
102
+ }
103
+ if (typeof nowArg === 'number') {
104
+ now = nowArg;
104
105
  }
105
106
 
106
107
  // 1. Run opportunistic sweep to ensure expired anchors don't leak into context
@@ -137,7 +138,14 @@ export function renderColdStartAnchorsContext(
137
138
  const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1000));
138
139
  const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : '';
139
140
 
140
- return `• #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
141
+ const failureSnippet = preflightFailures?.get(a.id);
142
+ const verifyHint = failureSnippet
143
+ ? ` [⚡ 物理验证失败: ${failureSnippet.replace(/\s+/g, ' ').slice(-100)}]`
144
+ : a.verifyCommand
145
+ ? ` [verify: ${a.verifyCommand}]`
146
+ : '';
147
+
148
+ return `• #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${verifyHint}${ageHint}${tagHint}${fileHint}`;
141
149
  });
142
150
 
143
151
  return [
@@ -147,6 +155,7 @@ export function renderColdStartAnchorsContext(
147
155
  ``,
148
156
  `CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
149
157
  `- If the user opens the session with a casual greeting or continuation ("hi", "在吗", "继续", "早上好"), naturally prioritize items tagged [今日聚焦·Due Today] in a friendly assistant tone (e.g. "嗨!今天规划的【...】现在处理还是看别的?").`,
158
+ `- If an anchor has [⚡ 物理验证失败], its verifyCommand failed on session start. Target the failing assertion directly.`,
150
159
  `- If the user issues a direct, concrete new instruction (e.g. "帮我查下这个报错"), DO NOT interrupt their flow. Focus directly on their instruction.`,
151
160
  `- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
152
161
  `</active-anchors>`
package/src/extension.ts CHANGED
@@ -8,6 +8,7 @@ import { normalizePath, findMatchedAnchors } from './matcher.ts';
8
8
  import { sweepStore } from './decay.ts';
9
9
  import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner } from './tui.ts';
10
10
  import { execSync } from 'node:child_process';
11
+ import fs from 'node:fs';
11
12
  import path from 'node:path';
12
13
 
13
14
  const MUTATION_TOOLS = new Set(['edit', 'write', 'patch', 'apply_diff', 'create_file', 'modify']);
@@ -17,17 +18,52 @@ export default function (pi: ExtensionAPI) {
17
18
  const store = new AnchorStore();
18
19
  const observer = new SessionTouchObserver();
19
20
  const annotatedThisSession = new Set<string>();
21
+ const preflightFailures = new Map<string, string>();
20
22
 
21
- // 1. Session start: sweep stale tasks, update TUI status bar, and render startup banner
23
+ // 1. Session start: sweep stale tasks, run physical pre-flight tests, update TUI status bar
22
24
  pi.on('session_start', async (event: any, ctx: ExtensionContext) => {
23
25
  observer.clear();
24
26
  annotatedThisSession.clear();
27
+ preflightFailures.clear();
28
+
25
29
  const sweep = sweepStore(store);
26
30
 
27
31
  if (sweep.transitionedToSleeping.length > 0) {
28
32
  ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} 个非活跃任务已进入休眠`, 'info');
29
33
  }
30
34
 
35
+ // Physical Pre-flight Verification: auto-settle passing tasks or record failing output
36
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
37
+ for (const a of activeAnchors) {
38
+ if (a.verifyCommand) {
39
+ try {
40
+ const res = runPhysicalVerification(a, ctx.cwd);
41
+ if (res.success) {
42
+ store.settle(a.id, {
43
+ settledBy: 'verification-test',
44
+ summary: `Physical pre-flight test passed: ${a.verifyCommand}`
45
+ });
46
+ ctx.ui.notify(`⚓ 物理验证通过!锚点 #${a.id} ("${a.title}") 已自动结案归档。`, 'info');
47
+ } else {
48
+ preflightFailures.set(a.id, res.output.slice(-250));
49
+ }
50
+ } catch (_) {}
51
+ }
52
+ }
53
+
54
+ // Exit Guard notification check
55
+ try {
56
+ const home = process.env.HOME || process.env.USERPROFILE || '';
57
+ const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
58
+ if (fs.existsSync(pendingPath)) {
59
+ const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
60
+ if (pending.cwd === ctx.cwd && Date.now() - pending.timestamp < 24 * 60 * 60 * 1000) {
61
+ ctx.ui.notify(`⌖ [Anchor 退出守护] 检测到上次异常退出且有 ${pending.modifiedCount} 个未提交改动,可用 anchor(action="pin") 固化任务。`, 'info');
62
+ }
63
+ fs.unlinkSync(pendingPath);
64
+ }
65
+ } catch (_) {}
66
+
31
67
  updateAnchorStatusBar(ctx, store);
32
68
 
33
69
  // Only render startup banner on initial startup or new session, not on mid-session resume
@@ -50,7 +86,7 @@ export default function (pi: ExtensionAPI) {
50
86
  const isColdStart = messageTurns.length <= 1;
51
87
 
52
88
  if (isColdStart) {
53
- const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
89
+ const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd, undefined, preflightFailures);
54
90
  if (contextSnippet) {
55
91
  return {
56
92
  systemPrompt: `${event.systemPrompt}\n\n${contextSnippet}`
@@ -212,6 +248,44 @@ export default function (pi: ExtensionAPI) {
212
248
  verifyCommand: Type.Optional(Type.String({ description: 'Optional shell command for automated physical verification' })),
213
249
  id: Type.Optional(Type.String({ description: 'Anchor ID, e.g. anc-1 (for settle or touch)' }))
214
250
  }),
251
+ renderCall(args: any, theme: any) {
252
+ const action = args.action || 'list';
253
+ let title = theme.fg('toolTitle', theme.bold('⌖ anchor ')) + theme.fg('accent', action);
254
+
255
+ if (action === 'pin' && args.title) {
256
+ const due = args.targetDate ? ` ${theme.fg('dim', `[${args.targetDate}]`)}` : '';
257
+ const files = (args.files && args.files.length > 0)
258
+ ? ` ${theme.fg('muted', `(${args.files.slice(0, 2).join(', ')})`)}`
259
+ : '';
260
+ title += ` "${args.title}"${due}${files}`;
261
+ } else if (action === 'settle' && args.id) {
262
+ title += ` ${theme.fg('dim', `#${args.id}`)}`;
263
+ } else if (action === 'touch' && args.id) {
264
+ title += ` ${theme.fg('dim', `#${args.id}`)}`;
265
+ } else if (action === 'list') {
266
+ if (args.priority) title += ` ${theme.fg('muted', `[${args.priority}]`)}`;
267
+ }
268
+
269
+ return {
270
+ render: () => [title],
271
+ invalidate: () => {}
272
+ };
273
+ },
274
+ renderResult(result: any, _options: any, theme: any) {
275
+ if (result.isError) {
276
+ const errText = result.content?.[0]?.text || 'Execution failed';
277
+ return {
278
+ render: () => [theme.fg('error', `✗ ${errText}`)],
279
+ invalidate: () => {}
280
+ };
281
+ }
282
+
283
+ const text = result.content?.[0]?.text || 'OK';
284
+ return {
285
+ render: () => [theme.fg('success', `✔ ${text}`)],
286
+ invalidate: () => {}
287
+ };
288
+ },
215
289
  async execute(_toolCallId: string, params: any, _signal?: AbortSignal, _onUpdate?: (partial: any) => void, ctx?: ExtensionContext) {
216
290
  if (!ctx) return { content: [{ type: 'text', text: 'Error: context required' }], isError: true };
217
291
  if (params.action === 'pin') {
@@ -360,4 +434,30 @@ export default function (pi: ExtensionAPI) {
360
434
  updateAnchorStatusBar(ctx, store);
361
435
  }
362
436
  });
437
+
438
+ // 7. Exit Guard: detect uncommitted changes on abrupt session shutdown
439
+ pi.on('session_shutdown', async (event: any, ctx: ExtensionContext) => {
440
+ if (event.reason === 'quit') {
441
+ try {
442
+ const gitStatus = execSync('git status --porcelain', {
443
+ cwd: ctx.cwd,
444
+ encoding: 'utf-8',
445
+ timeout: 2000,
446
+ stdio: ['ignore', 'pipe', 'ignore']
447
+ }).trim();
448
+
449
+ const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
450
+ if (gitStatus && activeAnchors.length === 0) {
451
+ const home = process.env.HOME || process.env.USERPROFILE || '';
452
+ const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
453
+ const data = {
454
+ cwd: ctx.cwd,
455
+ timestamp: Date.now(),
456
+ modifiedCount: gitStatus.split('\n').filter(Boolean).length
457
+ };
458
+ fs.writeFileSync(pendingPath, JSON.stringify(data, null, 2), 'utf-8');
459
+ }
460
+ } catch (_) {}
461
+ }
462
+ });
363
463
  }
package/src/pi-types.d.ts CHANGED
@@ -18,6 +18,8 @@ declare module '@earendil-works/pi-coding-agent' {
18
18
  promptSnippet?: string;
19
19
  promptGuidelines?: string[];
20
20
  parameters: unknown;
21
+ renderCall?: (args: any, theme: any, context?: any) => any;
22
+ renderResult?: (result: any, options: any, theme: any, context?: any) => any;
21
23
  execute: (
22
24
  toolCallId: string,
23
25
  params: any,
package/src/tui.ts CHANGED
@@ -298,26 +298,39 @@ export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore):
298
298
 
299
299
  const groups = groupAnchorsByQuadrant(active);
300
300
  const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
301
-
302
- const lines: string[] = [
303
- `⌖ Anchor · ${active.length} active commitments:`
304
- ];
305
-
306
301
  const topItems = sorted.slice(0, 3);
307
- for (let i = 0; i < topItems.length; i++) {
308
- const a = topItems[i];
309
- const num = (i + 1).toString().padStart(2, '0');
310
- const q = classifyAnchor(a);
311
- const tgt = formatTargetDate(a);
312
- const titlePadded = a.title.length > 34 ? a.title.slice(0, 32) + '..' : a.title;
313
- lines.push(` • [${q}] ${num} ${titlePadded} (${tgt})`);
314
- }
315
-
316
- if (sorted.length > 3) {
317
- lines.push(` (+${sorted.length - 3} more · run /anchor to inspect)`);
318
- }
319
302
 
320
- ctx.ui.setWidget('anchor-startup', lines, { placement: 'aboveEditor' });
303
+ const factory = (_tui?: any, theme?: any) => ({
304
+ render: (width: number) => {
305
+ const maxTitleWidth = Math.max(16, width - 36);
306
+ const fg = theme?.fg ? theme.fg.bind(theme) : (_c: string, text: string) => text;
307
+
308
+ const lines: string[] = [
309
+ fg('accent', `⌖ Anchor · ${active.length} active commitments:`)
310
+ ];
311
+
312
+ for (let i = 0; i < topItems.length; i++) {
313
+ const a = topItems[i];
314
+ const num = (i + 1).toString().padStart(2, '0');
315
+ const q = classifyAnchor(a);
316
+ const tgt = formatTargetDate(a);
317
+ const titlePadded = truncateToWidth(a.title, maxTitleWidth);
318
+ lines.push(` • ${fg('muted', `[${q}]`)} ${fg('dim', num)} ${titlePadded} ${fg('dim', `(${tgt})`)}`);
319
+ }
320
+
321
+ if (sorted.length > 3) {
322
+ lines.push(fg('dim', ` (+${sorted.length - 3} more · run /anchor to inspect)`));
323
+ }
324
+
325
+ return lines;
326
+ },
327
+ invalidate: () => {}
328
+ });
329
+
330
+ // Reactive component factory in real TUI mode, static string[] in mock tests
331
+ const content = (ctx as any).ui?.theme ? factory : factory().render(80);
332
+
333
+ ctx.ui.setWidget('anchor-startup', content as any, { placement: 'aboveEditor' });
321
334
  }
322
335
 
323
336
  /**