projectstore-codex 0.28.2 → 0.29.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.
@@ -0,0 +1,147 @@
1
+ // projectstore — term.mjs: terminal presentation for the write verbs, with no
2
+ // dependency (the zero-dependency property the MCP and link-graph ADRs keep).
3
+ //
4
+ // One question decides everything here: is this a person at a terminal? Then
5
+ // colour and in-place step lines. Otherwise — a pipe, an agent's tool call,
6
+ // CI — the same layout as plain text, so a reader that is not a terminal loses
7
+ // nothing but the escapes (install spec, contract 18).
8
+ //
9
+ // caps(stream, env) → { color, live, width, ascii }
10
+ // painter(caps) → paint(style, text)
11
+ // icon(caps, name) → the glyph for an action or a result
12
+ // duration(ms) → "0.4s", "12s", "1m 03s"
13
+ // wrap(text, width, indent) → prose broken at spaces, hanging indent
14
+ // stepReporter(stream, caps) → { start(label), end(ok, note), abort() }
15
+ // askLine(question, in, out) → the answer, or null on end of input
16
+
17
+ import { createInterface } from "node:readline/promises";
18
+
19
+ // FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
20
+ // "2", "3" or "true" turn colour on even without a terminal, anything else
21
+ // ("0", "false") turns it off. Otherwise NO_COLOR (https://no-color.org) turns
22
+ // colour off when set to anything non-empty, and TERM=dumb means a terminal
23
+ // that understands no escapes.
24
+ export function caps(stream = process.stdout, env = process.env) {
25
+ const tty = Boolean(stream && stream.isTTY);
26
+ const dumb = env.TERM === "dumb";
27
+ const off = env.NO_COLOR !== undefined && env.NO_COLOR !== "";
28
+ const color = env.FORCE_COLOR !== undefined ? ["", "1", "2", "3", "true"].includes(String(env.FORCE_COLOR)) : tty && !dumb && !off;
29
+ const columns = tty && Number(stream.columns) > 0 ? Number(stream.columns) : 100;
30
+ // `width` lays out prose (never below 40); `columns` is the terminal's
31
+ // real width, which a live line must not reach.
32
+ return { color, live: tty && !dumb, width: Math.max(40, Math.min(columns, 120)), columns, ascii: dumb };
33
+ }
34
+
35
+ const SGR = {
36
+ bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39],
37
+ blue: [34, 39], magenta: [35, 39], cyan: [36, 39], gray: [90, 39],
38
+ };
39
+
40
+ // Raw SGR rather than util.styleText: that arrived in Node 20.12 and 21.7, and
41
+ // the package promises >=20.0.0.
42
+ export function painter(c) {
43
+ return (style, text) => {
44
+ const s = String(text);
45
+ if (!c.color || !SGR[style] || !s) return s;
46
+ return `\x1b[${SGR[style][0]}m${s}\x1b[${SGR[style][1]}m`;
47
+ };
48
+ }
49
+
50
+ const ICONS = {
51
+ create: ["+", "+"], update: ["↻", "~"], migrate: ["↻", "~"], refresh: ["↻", "~"],
52
+ remove: ["✕", "x"], cleanup: ["✕", "x"], skip: ["·", "."], refuse: ["!", "!"],
53
+ ok: ["✓", "ok"], fail: ["✗", "FAIL"], running: ["…", "..."], note: ["!", "!"],
54
+ };
55
+ export function icon(c, name) {
56
+ const pair = ICONS[name] || ICONS.update;
57
+ return c.ascii ? pair[1] : pair[0];
58
+ }
59
+
60
+ export function duration(ms) {
61
+ if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
62
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
63
+ const m = Math.floor(ms / 60_000), s = Math.round((ms % 60_000) / 1000);
64
+ return `${m}m ${String(s).padStart(2, "0")}s`;
65
+ }
66
+
67
+ // Strip SGR escapes — for measuring a painted string and for tests.
68
+ export const plain = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "");
69
+
70
+ // Prose only: broken at spaces to `width` columns, every line after the first
71
+ // indented by `indent`. A word longer than the line — a path, a command — is
72
+ // never broken: a path cut in two cannot be copied back. Width 0 is a reader
73
+ // that is not a terminal: nothing is broken, so a search never meets a split.
74
+ export function wrap(text, width, indent = "") {
75
+ if (!width) return String(text);
76
+ const room = Math.max(20, width - indent.length);
77
+ const lines = [];
78
+ let line = "";
79
+ for (const word of String(text).split(" ")) {
80
+ if (line && plain(line).length + 1 + plain(word).length > room) { lines.push(line); line = word; }
81
+ else line = line ? `${line} ${word}` : word;
82
+ }
83
+ lines.push(line);
84
+ return lines.map((l, k) => (k ? indent + l : l)).join("\n");
85
+ }
86
+
87
+ // A live line must fit one physical row: "\r\x1b[2K" clears only the row the
88
+ // cursor is on, so a label that wrapped would leave its first half behind.
89
+ const fit = (text, room) => (text.length <= room ? text : text.slice(0, Math.max(1, room - 1)) + "…");
90
+
91
+ // One line per step. On a live terminal the line appears when the step starts
92
+ // (ending in "…") and is rewritten in place with the result and duration when
93
+ // it ends; anything else gets the finished line only. Steps run synchronously
94
+ // (spawnSync), so nothing animates in between — the "…" is the honest state.
95
+ export function stepReporter(stream = process.stdout, c = caps(stream), { indent = 2 } = {}) {
96
+ const paint = painter(c);
97
+ let open = null;
98
+ // One column short of the terminal: a line that fills the last column
99
+ // leaves some terminals waiting to wrap.
100
+ const width = Math.min(c.width, c.columns || c.width, 100) - 1;
101
+ const line = (mark, label, right) => {
102
+ const room = width - indent - 2 - plain(right).length - 1;
103
+ const left = `${" ".repeat(indent)}${mark} ${c.live ? fit(label, room) : label}`;
104
+ const gap = Math.max(1, width - plain(left).length - plain(right).length);
105
+ return left + " ".repeat(gap) + right;
106
+ };
107
+ const self = {
108
+ start(label) {
109
+ // A step started while another is still open (a host command run by a
110
+ // rollback) closes the open line where it stands rather than overwrite it.
111
+ if (open && c.live) stream.write("\n");
112
+ open = { label, t0: Date.now() };
113
+ if (c.live) stream.write(line(paint("gray", icon(c, "running")), label, ""));
114
+ },
115
+ end(ok = true, note = "", { kept = false } = {}) {
116
+ if (!open) return;
117
+ const ms = Date.now() - open.t0;
118
+ // A step that ran and left its target in place is neither ✓ nor ✗.
119
+ const mark = !ok ? paint("red", icon(c, "fail")) : kept ? paint("gray", icon(c, "skip")) : paint("green", icon(c, "ok"));
120
+ const right = paint("gray", [note, duration(ms)].filter(Boolean).join(" "));
121
+ const text = line(mark, open.label, right);
122
+ stream.write((c.live ? "\r\x1b[2K" : "") + text + "\n");
123
+ open = null;
124
+ },
125
+ // An exception left a step open: end its line as failed, so whatever is
126
+ // printed next starts on a line of its own.
127
+ abort() { if (open) self.end(false); },
128
+ };
129
+ return self;
130
+ }
131
+
132
+ // One line of input, read the way a shell's own prompt is: in the terminal's
133
+ // line mode, so readline writes nothing but the question (no cursor escapes),
134
+ // the terminal does the editing, and Ctrl+C is a real SIGINT (exit 130, before
135
+ // anything is written). End of input — Ctrl+D, a closed pipe — is null, which
136
+ // every caller reads as no; it never leaves the question pending (an unsettled
137
+ // promise is exit 13 with a warning). The promise API rejects on close in
138
+ // some Node versions and stays pending in others; both settle here.
139
+ export async function askLine(question, input, output) {
140
+ const rl = createInterface({ input, output, terminal: false });
141
+ return await new Promise((settle) => {
142
+ let done = false;
143
+ const finish = (answer) => { if (done) return; done = true; settle(answer); rl.close(); };
144
+ rl.on("close", () => finish(null));
145
+ rl.question(question).then((a) => finish(a), () => finish(null));
146
+ });
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-codex",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "ProjectStore for Codex: portable project memory, rendered workflow skills and lifecycle hooks, with the core pinned and bundled.",
5
5
  "keywords": [
6
6
  "projectstore",
@@ -40,7 +40,7 @@
40
40
  "hooks/"
41
41
  ],
42
42
  "dependencies": {
43
- "projectstore": "=0.28.2"
43
+ "projectstore": "=0.29.0"
44
44
  },
45
45
  "bundleDependencies": [
46
46
  "projectstore"
@@ -26,10 +26,11 @@ You are managing ProjectStore's Codex agent integration. Require a bound project
26
26
 
27
27
  ## register / unregister
28
28
 
29
- Preview the requested change and ask for explicit approval. On approval, run the
30
- core's `install` or `uninstall` verb with `--harness codex --surface
31
- agents_block --project "$PWD"`. Print its output verbatim. Never edit the
32
- managed block by hand.
29
+ Preview the requested change with `plan --harness codex --surface agents_block
30
+ --project "$PWD"` and ask for explicit approval. On approval, run the core's
31
+ `install` or `uninstall` verb with `--harness codex --surface agents_block
32
+ --project "$PWD" --json` — `--json` never waits on a terminal's question — and
33
+ report the envelope's result. Never edit the managed block by hand.
33
34
 
34
35
  ## status
35
36
 
@@ -28,6 +28,8 @@ arguments and `--json`. Summarize every finding without re-deriving it.
28
28
  When `--fix` is absent, remain read-only. When it is present, separate fixes
29
29
  by owner: derived vault views use `$projectstore-reconcile`; Codex plugin or
30
30
  agents-block drift uses the core's `upgrade --harness codex` path. Preview
31
- each mutation and ask for explicit approval before running it. Unsupported
31
+ each mutation with `plan --harness codex` and ask for explicit approval before
32
+ running it; then run `upgrade --harness codex --json`, whose envelope is the
33
+ result — `--json` never waits on a terminal's question. Unsupported
32
34
  surfaces remain unsupported; do not create host configuration by hand. Never
33
35
  claim a fix after a non-zero exit.