portable-agent-layer 0.85.17 → 0.85.19

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.
@@ -70,6 +70,10 @@
70
70
  "type": "boolean",
71
71
  "description": "One line of the current time with every prompt, in identity.principal.timezone, so a session resumed hours later knows the day changed."
72
72
  },
73
+ "repoState": {
74
+ "type": "boolean",
75
+ "description": "One line with every prompt saying whether the work in the current directory is committed, pushed and released; a registered project without git is named as such."
76
+ },
73
77
  "dueReminders": {
74
78
  "type": "boolean",
75
79
  "description": "Opt-in, off by default until 1.0. Due maintenance reminders (learning analysis, relationship reflect) with every prompt until a reply names their command; then quiet until the next day."
@@ -26,6 +26,7 @@
26
26
  "contextualSteering": true,
27
27
  "skillMatching": true,
28
28
  "wallClock": true,
29
+ "repoState": true,
29
30
  "dueReminders": false,
30
31
  "interactionAwareness": true,
31
32
  "reactionSampling": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.17",
3
+ "version": "0.85.19",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@ import { dueNudgeReminder } from "../lib/daily-nudge";
12
12
  import { observeTurn } from "../lib/interaction";
13
13
  import { logDebug, logError } from "../lib/log";
14
14
  import { parkPromptContext } from "../lib/parked-context";
15
+ import { getRepoStateReminder } from "../lib/repo-state";
15
16
  import { runRetrieval } from "../lib/retrieval";
16
17
  import { ensureIndex } from "../lib/retrieval-index";
17
18
  import { isEnabled } from "../lib/settings";
@@ -70,7 +71,7 @@ function writeForAgent(reminder: string, sessionId?: string): void {
70
71
  else if (sessionId) parkPromptContext(sessionId, reminder);
71
72
  }
72
73
 
73
- /** Merge every prompt-time source — the wall clock, contextual steering, skill
74
+ /** Merge every prompt-time source — the wall clock, the repo state, contextual steering, skill
74
75
  * matches, prior-lesson retrieval — into one payload, or null when none of them
75
76
  * produced anything. The clock leads: it is the only part that is true of the
76
77
  * moment rather than of the prompt.
@@ -81,6 +82,7 @@ export async function getPromptContext(
81
82
  ): Promise<string | null> {
82
83
  const parts = [
83
84
  getWallClockReminder(),
85
+ withinBudget(() => getRepoStateReminder(), BUDGET_MS),
84
86
  withinBudget(() => observeTurn(prompt, sessionId), BUDGET_MS),
85
87
  withinBudget(() => dueNudgeReminder(), BUDGET_MS),
86
88
  getSteeringReminder(prompt),
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Whether the work here is committed, pushed and released, read fresh with every
3
+ * prompt — so the agent answers from the checkout, not from what it remembers.
4
+ */
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import { basename } from "node:path";
8
+ import { readAllProjects, resolveProjectFromCwd } from "./projects";
9
+ import { isEnabled } from "./settings";
10
+
11
+ const GIT_TIMEOUT_MS = 1000;
12
+
13
+ interface Status {
14
+ head: string;
15
+ hasCommits: boolean;
16
+ upstream: string | null;
17
+ ahead: number;
18
+ behind: number;
19
+ changed: number;
20
+ }
21
+
22
+ function git(cwd: string, ...args: string[]): string | null {
23
+ const result = spawnSync("git", ["-C", cwd, ...args], {
24
+ encoding: "utf-8",
25
+ timeout: GIT_TIMEOUT_MS,
26
+ });
27
+ return result.status === 0 ? result.stdout.trim() : null;
28
+ }
29
+
30
+ function header(lines: string[], key: string): string | null {
31
+ const line = lines.find((l) => l.startsWith(`# branch.${key} `));
32
+ return line ? line.slice(`# branch.${key} `.length) : null;
33
+ }
34
+
35
+ function parseStatus(porcelain: string): Status {
36
+ const lines = porcelain.split("\n").filter(Boolean);
37
+ const [ahead, behind] = (header(lines, "ab") ?? "+0 -0")
38
+ .split(" ")
39
+ .map((n) => Math.abs(Number(n)));
40
+ return {
41
+ head: header(lines, "head") ?? "(detached)",
42
+ hasCommits: header(lines, "oid") !== "(initial)",
43
+ upstream: header(lines, "upstream"),
44
+ ahead,
45
+ behind,
46
+ changed: lines.filter((l) => !l.startsWith("#")).length,
47
+ };
48
+ }
49
+
50
+ const count = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`;
51
+
52
+ function syncState(status: Status): string[] {
53
+ if (!status.upstream) return ["never pushed"];
54
+ const unsynced = [
55
+ status.ahead > 0 ? `${count(status.ahead, "commit")} not pushed` : "",
56
+ status.behind > 0
57
+ ? `${count(status.behind, "commit")} behind ${status.upstream}`
58
+ : "",
59
+ ].filter(Boolean);
60
+ return unsynced.length > 0 ? unsynced : ["pushed"];
61
+ }
62
+
63
+ function releaseState(cwd: string): string[] {
64
+ const described = git(cwd, "describe", "--tags", "--long");
65
+ const match = described ? /^(.*)-(\d+)-g[0-9a-f]+$/.exec(described) : null;
66
+ if (!match) return [];
67
+ const [, tag, since] = match;
68
+ return Number(since) === 0
69
+ ? [`at tag ${tag}`]
70
+ : [`${count(Number(since), "commit")} since tag ${tag}`];
71
+ }
72
+
73
+ function workState(status: Status): string {
74
+ if (!status.hasCommits) return "no commits yet";
75
+ return status.changed > 0 ? `${count(status.changed, "file")} uncommitted` : "clean";
76
+ }
77
+
78
+ function gitLine(cwd: string, toplevel: string): string | null {
79
+ const porcelain = git(cwd, "status", "--porcelain=v2", "--branch");
80
+ if (porcelain === null) return null;
81
+ const status = parseStatus(porcelain);
82
+ return [
83
+ `Repo: ${basename(toplevel)} on ${status.head}`,
84
+ workState(status),
85
+ ...syncState(status),
86
+ ...releaseState(cwd),
87
+ ].join(" · ");
88
+ }
89
+
90
+ function unversionedProjectLine(cwd: string): string | null {
91
+ const project = resolveProjectFromCwd(cwd, readAllProjects());
92
+ if (!project) return null;
93
+ return `Repo: ${project.name} is not under git — nothing in it is committed, pushed or released; the files on disk are its whole state`;
94
+ }
95
+
96
+ export function getRepoStateReminder(cwd: string = process.cwd()): string | null {
97
+ if (!isEnabled("repoState")) return null;
98
+ const toplevel = git(cwd, "rev-parse", "--show-toplevel");
99
+ const line = toplevel ? gitLine(cwd, toplevel) : unversionedProjectLine(cwd);
100
+ return line ? `<system-reminder>${line}</system-reminder>` : null;
101
+ }