@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.16

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,44 @@
1
+ /**
2
+ * Persistent REPL session store — Sprint α6.4 (PR-PUGI-CLI-SESSION-STORE).
3
+ *
4
+ * Public types consumed by the REPL session module + the `pugi sessions`
5
+ * / `pugi resume` dispatchers. Wire format is stable:
6
+ *
7
+ * - `SessionRow` mirrors the SQLite `sessions` table one-to-one and is
8
+ * also the JSON shape returned by `pugi sessions --json`. Field names
9
+ * are camelCase (PascalCase types, camelCase fields per CLAUDE.md
10
+ * conventions). The SQL columns themselves stay snake_case because
11
+ * they originate from raw SQL.
12
+ *
13
+ * - `SessionEvent` is the on-disk shape of one line in
14
+ * `events.<n>.jsonl`. `kind` is a closed union; `payload` is an
15
+ * opaque JSON value so the producer can attach whatever fields the
16
+ * event type requires without forcing the store to change.
17
+ *
18
+ * - `SessionListOptions` / `SessionLoadEventsOptions` /
19
+ * `SessionSearchOptions` are inputs the store accepts. Defaults are
20
+ * spec'd inline so the test plan can pin them without reading the
21
+ * implementation.
22
+ *
23
+ * The blob store + `pugi undo` + named checkpoints are α6.4b follow-ups
24
+ * — out of scope for THIS PR per spec. The types here intentionally do
25
+ * NOT model blob refs so a future blob-store landing can extend without
26
+ * a wire break.
27
+ */
28
+ /**
29
+ * Concrete shape of the lock detection error so the caller can branch
30
+ * on `error.code === 'EBUSY_SESSION_LOCK'` rather than parsing the
31
+ * message. The store is the only thrower of this error.
32
+ */
33
+ export class SessionLockBusyError extends Error {
34
+ code = 'EBUSY_SESSION_LOCK';
35
+ holderPid;
36
+ lockPath;
37
+ constructor(holderPid, lockPath) {
38
+ super(`Another pugi process (pid ${holderPid}) holds the session lock at ${lockPath}.`);
39
+ this.name = 'SessionLockBusyError';
40
+ this.holderPid = holderPid;
41
+ this.lockPath = lockPath;
42
+ }
43
+ }
44
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * UUID v7 generator — Sprint α6.4 (PR-PUGI-CLI-SESSION-STORE).
3
+ *
4
+ * uuid v7 (RFC 9562 draft) is a time-sortable 128-bit identifier whose
5
+ * first 48 bits are the unix-epoch milliseconds, the next 4 bits are
6
+ * the version (0b0111), the next 12 bits are sub-millisecond random
7
+ * entropy, the next 2 bits are the IETF variant (0b10), and the last
8
+ * 62 bits are random.
9
+ *
10
+ * Why v7 and not v4 (random) or v6 (gregorian time):
11
+ *
12
+ * - The session id IS the primary key of the SQLite table. We want
13
+ * inserts to land at the end of the b-tree to keep page fanout
14
+ * small. v4 fragments the tree (uniformly random keys); v7 sorts
15
+ * in time order so inserts are append-only.
16
+ * - `pugi sessions` sorts by `updated_at DESC` for display, but the
17
+ * pagination cursor uses the session id directly — a v7 id IS the
18
+ * creation timestamp, so the cursor is a single column compare
19
+ * instead of (updated_at, id) tuple.
20
+ * - Operators see ids in `/resume` picker; v7 prefix is monotonically
21
+ * increasing so the most recent session lands at the bottom of an
22
+ * id sort, matching the human expectation of "newest last".
23
+ *
24
+ * Node 22 does not ship a v7 generator (`crypto.randomUUID()` is v4
25
+ * only). We implement it inline with `crypto.randomBytes(10)` for the
26
+ * entropy bits — 10 bytes covers the 12-bit random + 62-bit random
27
+ * fields with one syscall.
28
+ *
29
+ * Format: `xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx` where `y` is 8/9/a/b.
30
+ */
31
+ import { randomBytes } from 'node:crypto';
32
+ /**
33
+ * Mint a fresh uuid v7. The `now` parameter is injectable for tests so
34
+ * the generated id is deterministic when the test fixes the clock.
35
+ * Production callers pass `Date.now`.
36
+ */
37
+ export function uuidV7(now = Date.now) {
38
+ const ms = Math.floor(now());
39
+ // 48-bit timestamp (6 bytes).
40
+ const tsHex = ms.toString(16).padStart(12, '0');
41
+ // 10 bytes of entropy: 12 random bits go into octet 6 (after the 4
42
+ // version bits), 2 variant bits go into octet 8 (high nibble 8/9/a/b),
43
+ // the remaining 62 bits fill octets 9..15.
44
+ const rand = randomBytes(10);
45
+ // Set version (high nibble of octet 6) to 7.
46
+ rand[0] = ((rand[0] ?? 0) & 0x0f) | 0x70;
47
+ // Set IETF variant (high two bits of octet 8) to 0b10.
48
+ rand[2] = ((rand[2] ?? 0) & 0x3f) | 0x80;
49
+ const hex = Array.from(rand, (b) => b.toString(16).padStart(2, '0')).join('');
50
+ // Assemble: 8-4-4-4-12.
51
+ return (`${tsHex.slice(0, 8)}-${tsHex.slice(8, 12)}-${hex.slice(0, 4)}-`
52
+ + `${hex.slice(4, 8)}-${hex.slice(8, 20)}`);
53
+ }
54
+ /**
55
+ * Extract the unix-ms timestamp from a uuid v7. Returns null when the
56
+ * input is not a v7 id (wrong version nibble, wrong shape). Used by
57
+ * `pugi sessions` to render "created N seconds ago" without reading
58
+ * the SQLite row.
59
+ */
60
+ export function uuidV7Timestamp(id) {
61
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
62
+ .test(id)) {
63
+ return null;
64
+ }
65
+ const hex = id.slice(0, 8) + id.slice(9, 13);
66
+ return Number.parseInt(hex, 16);
67
+ }
68
+ //# sourceMappingURL=uuid-v7.js.map
@@ -29,6 +29,16 @@ import { basename, resolve as resolvePath } from 'node:path';
29
29
  import { slugForCwd } from './history.js';
30
30
  /** Cap on the PUGI.md head we forward. Mirrors the admin-api clamp. */
31
31
  const PUGI_MD_HEAD_LIMIT = 200;
32
+ /**
33
+ * Workspace label shown when the cwd has no project markers (no .git,
34
+ * no package.json, no PUGI.md). Per CEO 2026-05-25 dogfood, the
35
+ * previous behaviour leaked the parent directory basename (e.g.
36
+ * `codeforge-io`) into the splash as if it were a real workspace,
37
+ * confusing Mira/Pugi about what repo she was looking at. The
38
+ * unbound label is a single explicit string so the splash + status bar
39
+ * read the same warning. (α6.14.2 wave 5.)
40
+ */
41
+ export const UNBOUND_WORKSPACE_LABEL = '(not bound - run /init OR cd into project)';
32
42
  /**
33
43
  * Resolve a `ReplWorkspaceContext` from the operator's working directory.
34
44
  * Returns a bundle with at least `workspaceCwd` + `workspaceSlug` +
@@ -38,13 +48,74 @@ const PUGI_MD_HEAD_LIMIT = 200;
38
48
  export function resolveWorkspaceContext(cwd) {
39
49
  const normalised = resolvePath(cwd);
40
50
  const slug = slugForCwd(normalised);
41
- const summary = readPugiSummary(normalised) ?? basename(normalised) ?? 'workspace';
51
+ // α6.14.2 wave 5: when the cwd has no project markers, prefer the
52
+ // explicit "not bound" summary so admin-api's prompt builder knows
53
+ // not to fabricate a workspace context for Mira/Pugi. The cwd +
54
+ // slug still travel so the server can record where the operator
55
+ // launched from for telemetry, but the summary no longer leaks a
56
+ // stray parent-dir basename as if it were a real workspace.
57
+ const summary = isBoundWorkspace(normalised)
58
+ ? (readPugiSummary(normalised) ?? basename(normalised) ?? 'workspace')
59
+ : UNBOUND_WORKSPACE_LABEL;
42
60
  return {
43
61
  workspaceCwd: normalised,
44
62
  workspaceSlug: slug,
45
63
  workspaceSummary: summary,
46
64
  };
47
65
  }
66
+ /**
67
+ * Project-marker probe used by the REPL splash + status bar to decide
68
+ * whether the cwd is a real workspace or a stray parent dir the
69
+ * operator wandered into. The probe is intentionally cheap — three
70
+ * `existsSync` calls — and the order matches the brand convention:
71
+ *
72
+ * 1. `.git` — any clone of a real repo
73
+ * 2. `package.json` — JS/TS workspace root
74
+ * 3. `PUGI.md` — a Pugi-initialised workspace (root or
75
+ * `.pugi/PUGI.md`)
76
+ *
77
+ * Hitting any one of these counts the cwd as "bound" — the operator
78
+ * intentionally landed in a project. Hitting none means they ran
79
+ * `pugi` from `$HOME` or from the parent of a checkout; in that case
80
+ * the splash surfaces an explicit "not bound" label instead of leaking
81
+ * the parent-dir basename as if it were a workspace. The check
82
+ * mirrors the Claude Code "no CLAUDE.md → silent context" rule —
83
+ * never fake-bind. (α6.14.2 wave 5 — CEO dogfood fix.)
84
+ */
85
+ export function isBoundWorkspace(cwd) {
86
+ const normalised = resolvePath(cwd);
87
+ // Case-sensitive checks; the resolver lower-cases PUGI.md when it
88
+ // reads the head, but the existence probe stays strict so a stray
89
+ // `pugi.md` does not accidentally pass without `pugi init` having
90
+ // run.
91
+ if (existsSync(resolvePath(normalised, '.git')))
92
+ return true;
93
+ if (existsSync(resolvePath(normalised, 'package.json')))
94
+ return true;
95
+ if (existsSync(resolvePath(normalised, 'PUGI.md')))
96
+ return true;
97
+ if (existsSync(resolvePath(normalised, '.pugi', 'PUGI.md')))
98
+ return true;
99
+ return false;
100
+ }
101
+ /**
102
+ * Resolve the workspace label shown on the splash + the REPL header.
103
+ * When the cwd has project markers, returns the directory basename;
104
+ * otherwise returns the explicit "not bound" warning so the operator
105
+ * understands no real workspace was detected. The label is the only
106
+ * string the splash and status bar agree on, so we centralise the
107
+ * decision here instead of re-deriving in two places.
108
+ * (α6.14.2 wave 5.)
109
+ */
110
+ export function resolveWorkspaceLabel(cwd) {
111
+ if (!isBoundWorkspace(cwd))
112
+ return UNBOUND_WORKSPACE_LABEL;
113
+ const normalised = resolvePath(cwd);
114
+ const segment = basename(normalised);
115
+ if (!segment || segment.length === 0)
116
+ return 'workspace';
117
+ return segment;
118
+ }
48
119
  /**
49
120
  * Read the first ~200 chars of `.pugi/PUGI.md` if the file exists. The
50
121
  * project's own description is the highest-signal one-line summary we