@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.35

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 (43) hide show
  1. package/dist/commands/smoke.js +133 -0
  2. package/dist/core/auth/ensure-authenticated.js +129 -0
  3. package/dist/core/bash-classifier.js +108 -1
  4. package/dist/core/codegraph/decision-store.js +248 -0
  5. package/dist/core/codegraph/detect-repo.js +459 -0
  6. package/dist/core/codegraph/install.js +134 -0
  7. package/dist/core/codegraph/offer-hook.js +220 -0
  8. package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
  9. package/dist/core/onboarding/ensure-initialized.js +133 -0
  10. package/dist/core/repl/session.js +370 -9
  11. package/dist/core/repl/slash-commands.js +68 -5
  12. package/dist/core/smoke/headless-driver.js +174 -0
  13. package/dist/core/smoke/orchestrator.js +194 -0
  14. package/dist/core/smoke/runner.js +238 -0
  15. package/dist/core/smoke/scenario-parser.js +316 -0
  16. package/dist/runtime/cli.js +453 -11
  17. package/dist/runtime/commands/cancel.js +231 -0
  18. package/dist/runtime/commands/codegraph-status.js +227 -0
  19. package/dist/runtime/commands/permissions.js +23 -0
  20. package/dist/runtime/commands/redo-blob-store.js +92 -0
  21. package/dist/runtime/commands/redo.js +361 -0
  22. package/dist/runtime/commands/status.js +11 -3
  23. package/dist/runtime/commands/undo.js +32 -0
  24. package/dist/runtime/headless-repl.js +195 -0
  25. package/dist/runtime/version.js +1 -1
  26. package/dist/tui/permissions-picker.js +78 -0
  27. package/dist/tui/render.js +35 -0
  28. package/dist/tui/status-bar.js +1 -1
  29. package/dist/tui/tool-stream-pane.js +45 -3
  30. package/package.json +7 -4
  31. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  32. package/test/scenarios/compact-force.scenario.txt +11 -0
  33. package/test/scenarios/identity.scenario.txt +11 -0
  34. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  35. package/test/scenarios/walkback.scenario.txt +12 -0
  36. package/dist/core/engine/compaction-hook.js +0 -154
  37. package/dist/core/init/scaffold.js +0 -195
  38. package/dist/core/memory/dual-write.spec.js +0 -297
  39. package/dist/core/memory-sync/queue.spec.js +0 -105
  40. package/dist/core/repl/codebase-survey.js +0 -308
  41. package/dist/core/repl/init-interview.js +0 -457
  42. package/dist/core/repl/onboarding-state.js +0 -297
  43. package/dist/runtime/commands/memory.spec.js +0 -174
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `pugi smoke` — runs the bundled scenario corpus through the headless
3
+ * harness and reports pass/fail per scenario (BIG TRACK 10 Phase 1,
4
+ * 2026-05-27).
5
+ *
6
+ * The CLI surface lives here rather than in `runtime/cli.ts` so the
7
+ * dispatch surface stays focused on argv routing. This module owns:
8
+ *
9
+ * - Resolving the scenarios directory (default
10
+ * `<cli-root>/test/scenarios/` when bundled, configurable via
11
+ * `--scenarios-dir`).
12
+ * - Selecting the `pugi` binary the headless driver should spawn.
13
+ * Local development: `node <cli-root>/bin/run.js`. CI / published
14
+ * usage: the `pugi` on PATH.
15
+ * - Forwarding the orchestrator output to the unified
16
+ * `writeOutput` writer so `--json` mode works without a second
17
+ * code path.
18
+ *
19
+ * Phase 1 deliberately ships ONE flag (`--filter`) and one option
20
+ * (`--scenarios-dir`); the rest of the surface comes in Phase 2 once
21
+ * the engine path is wired and we know which scenarios actually need
22
+ * per-run plumbing (timeouts, fixture credentials, hermetic stubs).
23
+ */
24
+ import { existsSync } from 'node:fs';
25
+ import { dirname, resolve } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { runSmoke, renderReportText, } from '../core/smoke/orchestrator.js';
28
+ import { runHeadlessScenario } from '../core/smoke/headless-driver.js';
29
+ /**
30
+ * Entry point invoked by `runtime/cli.ts::dispatchSmoke`. Returns the
31
+ * desired process exit code so the dispatcher can set
32
+ * `process.exitCode` without a second round-trip.
33
+ */
34
+ export async function runSmokeCommand(ctx) {
35
+ // Parse the (small) command-local argv. We only honor flags this
36
+ // command actually consumes; unknown args produce a usage error so
37
+ // typos surface immediately.
38
+ let scenariosDirOverride = ctx.scenariosDir ?? undefined;
39
+ let filter = ctx.filter ?? '';
40
+ for (let i = 0; i < ctx.args.length; i += 1) {
41
+ const arg = ctx.args[i] ?? '';
42
+ if (arg === '--filter') {
43
+ const next = ctx.args[i + 1];
44
+ if (!next || next.startsWith('--')) {
45
+ ctx.writeOutput({ ok: false, error: '--filter requires a pattern' }, 'pugi smoke: --filter requires a pattern (substring or *-glob)');
46
+ return 2;
47
+ }
48
+ filter = next;
49
+ i += 1;
50
+ }
51
+ else if (arg.startsWith('--filter=')) {
52
+ filter = arg.slice('--filter='.length);
53
+ }
54
+ else if (arg === '--scenarios-dir') {
55
+ const next = ctx.args[i + 1];
56
+ if (!next || next.startsWith('--')) {
57
+ ctx.writeOutput({ ok: false, error: '--scenarios-dir requires a path' }, 'pugi smoke: --scenarios-dir requires a path');
58
+ return 2;
59
+ }
60
+ scenariosDirOverride = next;
61
+ i += 1;
62
+ }
63
+ else if (arg.startsWith('--scenarios-dir=')) {
64
+ scenariosDirOverride = arg.slice('--scenarios-dir='.length);
65
+ }
66
+ else if (arg === '--help' || arg === '-h') {
67
+ ctx.writeOutput({
68
+ ok: true,
69
+ usage: 'pugi smoke [--filter <pattern>] [--scenarios-dir <path>]',
70
+ }, [
71
+ 'pugi smoke — run the bundled scenario corpus headlessly.',
72
+ '',
73
+ 'Flags:',
74
+ ' --filter <pattern> Run a subset (substring or *-glob match on scenario id).',
75
+ ' --scenarios-dir <path> Override the scenarios directory (default: bundled corpus).',
76
+ ].join('\n'));
77
+ return 0;
78
+ }
79
+ else {
80
+ ctx.writeOutput({ ok: false, error: `unknown arg: ${arg}` }, `pugi smoke: unknown arg ${arg}`);
81
+ return 2;
82
+ }
83
+ }
84
+ const scenariosDir = scenariosDirOverride ?? resolveBundledScenariosDir();
85
+ if (!existsSync(scenariosDir)) {
86
+ ctx.writeOutput({ ok: false, error: `scenarios dir not found: ${scenariosDir}` }, `pugi smoke: scenarios dir not found: ${scenariosDir}`);
87
+ return 2;
88
+ }
89
+ const pugiBin = ctx.pugiBin ?? process.env.PUGI_SMOKE_BIN ?? 'pugi';
90
+ const log = ctx.log ?? ((line) => process.stderr.write(`${line}\n`));
91
+ const report = await runSmoke({
92
+ scenariosDir,
93
+ filter,
94
+ executor: (scenario) => runHeadlessScenario(scenario, { pugiBin }),
95
+ log,
96
+ });
97
+ if (ctx.json) {
98
+ ctx.writeOutput(report, renderReportText(report));
99
+ }
100
+ else {
101
+ process.stdout.write(`${renderReportText(report)}\n`);
102
+ }
103
+ return report.exitCode;
104
+ }
105
+ /**
106
+ * Resolve the scenarios directory that ships alongside the CLI.
107
+ *
108
+ * Both the dev source layout (`<cli-root>/src/commands/smoke.ts`) and
109
+ * the built output layout (`<cli-root>/dist/commands/smoke.js`) land
110
+ * on the same `../../test/scenarios` path relative to this file.
111
+ * The bundled `npm i -g @pugi/cli` install replicates that structure
112
+ * by shipping `test/scenarios` (glob `**\/*.scenario.txt`) via the
113
+ * `package.json` `files` field — so the single resolved path works in
114
+ * dev, in `tsx` runs, and in published installs without a config knob.
115
+ *
116
+ * If a future restructure ever bundles scenarios at
117
+ * `<cli-root>/dist/scenarios/` instead, add that to the fallback chain
118
+ * here AND mirror the path in `package.json` `files`. Until then we
119
+ * keep the resolver intentionally one-line.
120
+ */
121
+ export function resolveBundledScenariosDir() {
122
+ const here = dirname(fileURLToPath(import.meta.url));
123
+ // Works for both src/commands/smoke.ts and dist/commands/smoke.js —
124
+ // both live two directories below the cli root.
125
+ const bundled = resolve(here, '..', '..', 'test', 'scenarios');
126
+ if (existsSync(bundled))
127
+ return bundled;
128
+ // Last resort: return the expected path so the orchestrator surfaces
129
+ // a clean "scenarios dir not found" diagnostic at the call site
130
+ // rather than the resolver swallowing it silently.
131
+ return bundled;
132
+ }
133
+ //# sourceMappingURL=smoke.js.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Wave 6 UX (2026-05-27) — `ensureAuthenticated` helper.
3
+ *
4
+ * Auto-login pre-flight for every Pugi command that needs an Anvil
5
+ * credential. Before this helper landed, cold-start without a stored
6
+ * credential surfaced a generic "Login required" message and the
7
+ * operator had к run `pugi login` separately, which broke the muscle-
8
+ * memory of "open terminal, type the command, see the answer".
9
+ *
10
+ * The helper exposes a single contract: `ensureAuthenticated(opts)`.
11
+ * On a happy path (credential resolves) it returns the credential.
12
+ * On a cold-start it either:
13
+ *
14
+ * - launches the device-flow login inline (interactive TTY only,
15
+ * `--no-login` not set), waits for completion, then re-resolves
16
+ * the credential and returns it. The surrounding command continues
17
+ * transparently;
18
+ * - returns `{ status: 'missing' }` with a reason describing why no
19
+ * auto-login was attempted (non-interactive, opted-out, or login
20
+ * aborted by user). The caller bails with a clean message.
21
+ *
22
+ * Cross-command parity: the helper is wired into every command that
23
+ * needs auth (engine commands `code`/`fix`/`build`/`explain`/`plan`,
24
+ * plus `sync`, `chain new`, `smoke`, `review`, `deploy`, ...). The
25
+ * previous patchwork of `resolveActiveCredential() ?? throw` /
26
+ * `if (!config) writeOutput unauthenticated` calls now all funnel
27
+ * through here so future auth changes are one-edit.
28
+ *
29
+ * Session cache: the helper caches the resolved credential per-process.
30
+ * A second command in the same process never re-launches login even if
31
+ * the operator deletes credentials.json mid-run (that is a footgun, not
32
+ * a supported use case — the cached credential is still valid because
33
+ * the auth token in memory has not been revoked).
34
+ *
35
+ * Framework-free: the actual login call is injected via the `login`
36
+ * callback. The CLI passes a closure that calls
37
+ * `performDeviceFlowLogin` (or the interactive picker for token /
38
+ * env). The spec passes a fake that flips an in-memory env var so
39
+ * subsequent `resolveActiveCredential` calls see a credential.
40
+ */
41
+ /**
42
+ * Process-local cache of resolved credentials. Keyed by `apiUrl` so a
43
+ * future `pugi accounts switch` invocation does not return stale data
44
+ * (different apiUrl → cache miss). Cache is additive-only.
45
+ */
46
+ const credentialCache = new Map();
47
+ /**
48
+ * Reset the cache. Exported for spec teardown — production callers
49
+ * never need this.
50
+ */
51
+ export function resetAuthenticatedCache() {
52
+ credentialCache.clear();
53
+ }
54
+ /**
55
+ * Auth pre-flight. Returns the resolved credential or a structured
56
+ * `missing` envelope. The cached path skips the `resolve()` callback
57
+ * entirely — useful when `resolveActiveCredential` is expensive
58
+ * (filesystem read of ~/.pugi/credentials.json + Zod parse).
59
+ *
60
+ * Headless contract: even on a TTY, when `headless === true` the
61
+ * helper bails with `non_interactive`. Reason: a browser-popup login
62
+ * in the middle of an automated stdin → engine → stdout loop would
63
+ * silently freeze the run.
64
+ */
65
+ export async function ensureAuthenticated(opts) {
66
+ // Resolve once. Cache by the resolved apiUrl so a subsequent call
67
+ // after `pugi accounts switch` produces a fresh resolution.
68
+ const initial = opts.resolve();
69
+ if (initial) {
70
+ credentialCache.set(initial.apiUrl, initial);
71
+ return { status: 'ready', credential: initial };
72
+ }
73
+ if (opts.skip) {
74
+ return {
75
+ status: 'missing',
76
+ reason: 'disabled',
77
+ detail: 'Authentication skipped (--no-login or PUGI_NO_AUTO_LOGIN). Run `pugi login` to authenticate.',
78
+ };
79
+ }
80
+ if (opts.headless) {
81
+ return {
82
+ status: 'missing',
83
+ reason: 'non_interactive',
84
+ detail: 'Headless mode cannot launch browser-popup login. Run `pugi login` once with a TTY, then re-run with --headless.',
85
+ };
86
+ }
87
+ if (!opts.interactive) {
88
+ return {
89
+ status: 'missing',
90
+ reason: 'non_interactive',
91
+ detail: 'No credential found and stdin is not a TTY. Run `pugi login` with a TTY OR set PUGI_API_KEY before invoking Pugi in CI.',
92
+ };
93
+ }
94
+ const write = opts.write ?? ((line) => process.stderr.write(line));
95
+ write('No Pugi credential found. Launching login...\n');
96
+ let succeeded;
97
+ try {
98
+ succeeded = await opts.login();
99
+ }
100
+ catch (error) {
101
+ return {
102
+ status: 'missing',
103
+ reason: 'login_failed',
104
+ detail: `Login failed: ${error.message ?? String(error)}`,
105
+ };
106
+ }
107
+ if (!succeeded) {
108
+ return {
109
+ status: 'missing',
110
+ reason: 'login_cancelled',
111
+ detail: 'Authentication required to continue. Run `pugi login` when ready.',
112
+ };
113
+ }
114
+ // Re-resolve. If a successful login was reported but no credential
115
+ // landed on disk, surface that as `login_failed` rather than a
116
+ // silent miss — would otherwise produce a confusing "you said it
117
+ // worked but I still see nothing" loop.
118
+ const resolved = opts.resolve();
119
+ if (!resolved) {
120
+ return {
121
+ status: 'missing',
122
+ reason: 'login_failed',
123
+ detail: 'Login reported success but no credential persisted. Check `pugi whoami`.',
124
+ };
125
+ }
126
+ credentialCache.set(resolved.apiUrl, resolved);
127
+ return { status: 'ready', credential: resolved };
128
+ }
129
+ //# sourceMappingURL=ensure-authenticated.js.map
@@ -367,7 +367,7 @@ const WRITE_WORKSPACE_PREFIXES = [
367
367
  * the class is `write_protected` regardless of the operation type.
368
368
  *
369
369
  * Wildcards are handled as substring matches (e.g. `/.ssh/` matches
370
- * `~/.ssh/foo` and `/Users/x/.ssh/bar`).
370
+ * `~/.ssh/foo` and `[HOME]/USER/.ssh/bar`).
371
371
  */
372
372
  const PROTECTED_PATH_SUBSTRINGS = [
373
373
  '/.ssh/',
@@ -388,6 +388,40 @@ const PROTECTED_PATH_SUBSTRINGS = [
388
388
  '/usr/',
389
389
  '/var/',
390
390
  ];
391
+ /**
392
+ * Protected basename triggers — files whose CONTENT must never leak
393
+ * through the bash surface, even when the literal path is workspace-
394
+ * local. Mirrors `permission.ts::protectedBasenames` and `.env.*`
395
+ * pattern so the read-tool gate (which fires on `read .env`) and the
396
+ * bash gate (which fires on `cat .env`) stay symmetric.
397
+ *
398
+ * P0 fix 2026-05-28 (Codex audit): before this list existed, the
399
+ * engine model could circumvent the `read` tool's `protectedTargetReason`
400
+ * check by emitting `bash cat .env` — the classifier saw `cat` (read
401
+ * token) + `.env` (not in PROTECTED_PATH_SUBSTRINGS) and returned class
402
+ * `read`, which the permission matrix allows under every mode. The
403
+ * `local-first-invariants` spec proved the leak: `pugi explain .env`
404
+ * surfaced `SECRET=should_never_leak` in the engine summary.
405
+ *
406
+ * Match shape: the substring must touch a `.` boundary (`/.env`,
407
+ * ` .env`, `.env\b`) or appear as the full token so a path like
408
+ * `apps/codeforge/file.env-template` (no real secret) does not
409
+ * over-trigger.
410
+ */
411
+ const PROTECTED_BASENAME_PATTERNS = [
412
+ // `.env`, `.env.production`, `.env.local` — anywhere in the command.
413
+ // Boundary on the left is start/whitespace/quote/`/`, on the right
414
+ // start/whitespace/end/quote/`>`/`|`/`;`.
415
+ /(^|[\s'"\/=])\.env(\.[A-Za-z0-9_-]+)?($|[\s'"<>|;&])/,
416
+ // SSH key basenames (covers both `id_rsa` and `id_ed25519` even
417
+ // outside `~/.ssh/`). The `/.ssh/` substring above gates the
418
+ // directory case; this catches a key file copied to the workspace.
419
+ /(^|[\s'"\/])id_(rsa|ed25519|ecdsa|dsa)(\.pub)?($|[\s'"<>|;&])/,
420
+ // Other credential basenames mirrored from permission.ts.
421
+ /(^|[\s'"\/])\.npmrc($|[\s'"<>|;&])/,
422
+ /(^|[\s'"\/])\.pypirc($|[\s'"<>|;&])/,
423
+ /(^|[\s'"\/])\.gitconfig($|[\s'"<>|;&])/,
424
+ ];
391
425
  /**
392
426
  * Obfuscation triggers — any of these forces the `unknown` class so
393
427
  * the permission engine can fail closed.
@@ -469,6 +503,26 @@ function classifyComponent(cmd, ctx) {
469
503
  matched: protectedRead.matched,
470
504
  };
471
505
  }
506
+ // 4a-bis. Parent-traversal in read arguments. The file-tools layer
507
+ // refuses `..` segments via `resolveWorkspacePath`, but the bash
508
+ // surface had no equivalent gate — the engine could emit
509
+ // `cat ../README.md` or `ls ..` to enumerate / read outside the
510
+ // workspace, sidestepping the path-security check that the `read`
511
+ // and `glob` tools enforce.
512
+ //
513
+ // P0 fix 2026-05-28 (Codex audit): treat `..` as a path segment
514
+ // (`../`, ` ..`, `..\n`) in any read-class command as a workspace
515
+ // escape. We classify it as `write_protected` so the auto/dontAsk
516
+ // modes refuse, mirroring the `Path escapes workspace` semantics
517
+ // the file-tools layer already provides.
518
+ const traversal = detectParentTraversalRead(trimmed);
519
+ if (traversal) {
520
+ return {
521
+ class: 'write_protected',
522
+ reason: traversal.reason,
523
+ matched: traversal.matched,
524
+ };
525
+ }
472
526
  // 4b. .env writes are always protected, even inside the workspace
473
527
  // (CEO directive feedback_never_delete_untracked_env.md).
474
528
  const envWrite = detectEnvWrite(trimmed);
@@ -785,6 +839,59 @@ function detectProtectedRead(cmd) {
785
839
  };
786
840
  }
787
841
  }
842
+ // P0 fix 2026-05-28: extend protected-read detection to credential
843
+ // basenames (`cat .env`, `head id_rsa`, `grep TOKEN .env.production`).
844
+ // Without this branch, the engine model can bypass the `read` tool's
845
+ // `protectedTargetReason` gate by emitting a bash `cat` — the read
846
+ // tool refuses, the model falls back to bash, and the classifier
847
+ // (which only knew about full-path substrings) classified `cat .env`
848
+ // as benign `read`. The `local-first-invariants` spec proved the leak.
849
+ for (const pattern of PROTECTED_BASENAME_PATTERNS) {
850
+ const match = cmd.match(pattern);
851
+ if (match) {
852
+ return {
853
+ reason: `Read from protected basename: ${match[0].trim()}`,
854
+ matched: match[0].trim(),
855
+ };
856
+ }
857
+ }
858
+ return null;
859
+ }
860
+ /**
861
+ * Detect parent-traversal segments (`..`) inside read-class commands.
862
+ * The file-tools layer (`resolveWorkspacePath`) refuses these for the
863
+ * `read`/`glob`/`grep` tools, but bash had no equivalent gate. We
864
+ * trigger on the SAME shape `path-security.ts` rejects: a `..` segment
865
+ * separated by `/` or whitespace. Quoted/escaped variants get the same
866
+ * treatment.
867
+ *
868
+ * Returns null on the safe path (no `..` segment) so the caller falls
869
+ * through to the regular read classification.
870
+ */
871
+ function detectParentTraversalRead(cmd) {
872
+ const firstToken = cmd.split(/\s+/)[0] ?? '';
873
+ const isReadTool = READ_TOKENS.has(firstToken) ||
874
+ READ_PREFIX_TOKENS.has(firstToken) ||
875
+ firstToken === 'sed' ||
876
+ firstToken === 'awk' ||
877
+ firstToken === 'find';
878
+ if (!isReadTool)
879
+ return null;
880
+ // Match `..` as a path segment: preceded by start/whitespace/quote/`/`
881
+ // and followed by `/`, end-of-string, whitespace, or shell metas.
882
+ // Avoids over-matching `v1..v2` (range syntax inside a single token)
883
+ // and `1..5` (numeric ranges) because those lack the path boundary.
884
+ const traversalPattern = /(^|[\s'"\/])\.\.(\/|$|[\s'"<>|;&])/;
885
+ const m = cmd.match(traversalPattern);
886
+ if (m) {
887
+ return {
888
+ reason: 'Read command escapes workspace via parent traversal',
889
+ matched: '..',
890
+ };
891
+ }
892
+ // Absolute path read of /etc, /usr, /var, etc is already covered by
893
+ // PROTECTED_PATH_SUBSTRINGS in detectProtectedRead — no extra branch
894
+ // needed here.
788
895
  return null;
789
896
  }
790
897
  function detectEnvWrite(cmd) {
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Codegraph install-decision store — Wave 6 BIG TRACK 9 Phase 2.
3
+ *
4
+ * Persists the operator's verdict on the codegraph install prompt so we
5
+ * never spam them after a single decline. The 30-day reminder cadence
6
+ * lets us re-surface the offer on big-enough repos in case the operator
7
+ * said "not now" the first time and then forgot codegraph exists.
8
+ *
9
+ * Schema (workspace-scoped at `.pugi/codegraph-decision.json`):
10
+ *
11
+ * {
12
+ * "schema": 1,
13
+ * "offeredAt": "2026-05-27T00:00:00.000Z",
14
+ * "accepted": false,
15
+ * "decliningCount": 1,
16
+ * "remindAfter": "2026-06-26T00:00:00.000Z", // 30 days from offeredAt
17
+ * "lastIndexedAt": null, // ISO date string OR null
18
+ * "lastReindexCheckAt": null
19
+ * }
20
+ *
21
+ * The store is workspace-local (each repo gets its own decision) so
22
+ * declining codegraph in repo A does not suppress the prompt in repo B.
23
+ * `.pugi/` already exists by the time we land here (pugi init scaffolds
24
+ * it), so the directory creation is best-effort defence-in-depth.
25
+ *
26
+ * Concurrency: every write is `tmp + rename` so a partial write cannot
27
+ * surface a corrupt JSON. Reads tolerate missing files + corrupt JSON
28
+ * by returning `null` — the caller decides whether to fall back to
29
+ * "offer again" (safe default) or "do nothing" (cold-start path).
30
+ *
31
+ * Pure persistence. No telemetry, no logging. The emitter lives in the
32
+ * call sites so the decision store stays unit-testable in isolation.
33
+ */
34
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
35
+ import { resolve } from 'node:path';
36
+ /**
37
+ * Reminder cadence — 30 days from the last decline. Operators who said
38
+ * no in a small repo that grew к medium during a sprint deserve a
39
+ * follow-up; operators who said no last week do not. The window is
40
+ * exposed as a const so the spec can pin it.
41
+ */
42
+ export const REMIND_AFTER_DAYS = 30;
43
+ /**
44
+ * Stale-index threshold for the cold-start "refresh me" reminder.
45
+ * Seven days is the cadence the upstream codegraph docs recommend for
46
+ * monorepos that ship multiple times a day; lower repos can wait
47
+ * longer. The spec pins it.
48
+ */
49
+ export const STALE_INDEX_DAYS = 7;
50
+ /**
51
+ * Resolve the decision file path for a workspace root. Pure — exposed
52
+ * для spec parity.
53
+ */
54
+ export function decisionPath(workspaceRoot) {
55
+ return resolve(workspaceRoot, '.pugi/codegraph-decision.json');
56
+ }
57
+ /**
58
+ * Read the persisted decision. Returns null on missing file, malformed
59
+ * JSON, or wrong schema version. The caller MUST treat null as "no
60
+ * decision yet" — not "operator declined".
61
+ */
62
+ export function readDecision(workspaceRoot) {
63
+ const path = decisionPath(workspaceRoot);
64
+ if (!existsSync(path))
65
+ return null;
66
+ let raw;
67
+ try {
68
+ raw = readFileSync(path, 'utf8');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ if (!isDecisionShape(parsed))
81
+ return null;
82
+ return parsed;
83
+ }
84
+ /**
85
+ * Type guard. Defensive — a future schema bump should land a migration
86
+ * here. For now: schema MUST be 1; required string fields MUST be
87
+ * strings; optional fields may be null OR string.
88
+ */
89
+ function isDecisionShape(value) {
90
+ if (!value || typeof value !== 'object')
91
+ return false;
92
+ const v = value;
93
+ if (v.schema !== 1)
94
+ return false;
95
+ if (typeof v.offeredAt !== 'string')
96
+ return false;
97
+ if (typeof v.accepted !== 'boolean')
98
+ return false;
99
+ if (typeof v.decliningCount !== 'number' || !Number.isFinite(v.decliningCount))
100
+ return false;
101
+ if (typeof v.remindAfter !== 'string')
102
+ return false;
103
+ if (v.lastIndexedAt !== null && typeof v.lastIndexedAt !== 'string')
104
+ return false;
105
+ if (v.lastReindexCheckAt !== null && typeof v.lastReindexCheckAt !== 'string')
106
+ return false;
107
+ return true;
108
+ }
109
+ /**
110
+ * Atomic write. Creates `.pugi/` if it does not exist (pugi init owns
111
+ * that surface ordinarily; we defend the rare cold-start path).
112
+ */
113
+ export function writeDecision(workspaceRoot, decision) {
114
+ const path = decisionPath(workspaceRoot);
115
+ const dir = resolve(workspaceRoot, '.pugi');
116
+ if (!existsSync(dir)) {
117
+ mkdirSync(dir, { recursive: true });
118
+ }
119
+ const tmp = `${path}.tmp.${process.pid}`;
120
+ writeFileSync(tmp, `${JSON.stringify(decision, null, 2)}\n`, { mode: 0o600 });
121
+ try {
122
+ renameSync(tmp, path);
123
+ }
124
+ catch (error) {
125
+ // Rename can fail if the destination was concurrently swapped on
126
+ // some platforms (Windows). Fall back to unlink + rename so the
127
+ // best-effort write does not throw to the caller.
128
+ try {
129
+ unlinkSync(path);
130
+ }
131
+ catch {
132
+ // ignore
133
+ }
134
+ renameSync(tmp, path);
135
+ void error;
136
+ }
137
+ }
138
+ /**
139
+ * Decide whether to surface the install prompt on init. Returns the
140
+ * full decision shape for callers that want to inspect the cadence;
141
+ * a `true` verdict means "yes, ask the operator now".
142
+ */
143
+ export function shouldOfferOnInit(workspaceRoot, nowIso = new Date().toISOString()) {
144
+ const prior = readDecision(workspaceRoot);
145
+ if (!prior) {
146
+ return { shouldOffer: true, reason: 'first-run' };
147
+ }
148
+ if (prior.accepted) {
149
+ return { shouldOffer: false, reason: 'accepted-already' };
150
+ }
151
+ if (Date.parse(nowIso) >= Date.parse(prior.remindAfter)) {
152
+ return { shouldOffer: true, reason: 'reminder-due' };
153
+ }
154
+ return { shouldOffer: false, reason: 'recent-decline' };
155
+ }
156
+ /**
157
+ * Record the operator's decision atomically. Mirrors the structure on
158
+ * disk — callers do NOT hand-craft the schema.
159
+ */
160
+ export function recordDecision(workspaceRoot, input) {
161
+ const nowIso = input.nowIso ?? new Date().toISOString();
162
+ const prior = readDecision(workspaceRoot);
163
+ const decliningCount = input.accepted ? 0 : (prior?.decliningCount ?? 0) + 1;
164
+ const remindAfter = new Date(Date.parse(nowIso) + REMIND_AFTER_DAYS * 24 * 60 * 60 * 1000).toISOString();
165
+ const decision = {
166
+ schema: 1,
167
+ offeredAt: nowIso,
168
+ accepted: input.accepted,
169
+ decliningCount,
170
+ remindAfter,
171
+ lastIndexedAt: prior?.lastIndexedAt ?? null,
172
+ lastReindexCheckAt: prior?.lastReindexCheckAt ?? null,
173
+ };
174
+ writeDecision(workspaceRoot, decision);
175
+ return decision;
176
+ }
177
+ /**
178
+ * Stamp the last-indexed timestamp. Called by /codegraph-status when
179
+ * the operator triggers a reindex from inside Pugi. Updates the
180
+ * `accepted` decision in place — never flips the install state.
181
+ */
182
+ export function markIndexed(workspaceRoot, nowIso = new Date().toISOString()) {
183
+ const prior = readDecision(workspaceRoot);
184
+ if (!prior)
185
+ return null;
186
+ const next = {
187
+ ...prior,
188
+ lastIndexedAt: nowIso,
189
+ lastReindexCheckAt: nowIso,
190
+ };
191
+ writeDecision(workspaceRoot, next);
192
+ return next;
193
+ }
194
+ /**
195
+ * Stamp the last reindex-check timestamp without changing the index
196
+ * itself. Used by the cold-start hook so we do not show the "index is
197
+ * stale" hint on every keystroke once the operator has acknowledged
198
+ * it.
199
+ */
200
+ export function markReindexChecked(workspaceRoot, nowIso = new Date().toISOString()) {
201
+ const prior = readDecision(workspaceRoot);
202
+ if (!prior)
203
+ return null;
204
+ const next = {
205
+ ...prior,
206
+ lastReindexCheckAt: nowIso,
207
+ };
208
+ writeDecision(workspaceRoot, next);
209
+ return next;
210
+ }
211
+ /**
212
+ * Compute the staleness of the codegraph index. Pure — no IO.
213
+ *
214
+ * - returns null when `lastIndexedAt` is null (never indexed)
215
+ * - returns the day-delta (rounded down) otherwise
216
+ */
217
+ export function indexAgeDays(decision, nowIso = new Date().toISOString()) {
218
+ if (!decision.lastIndexedAt)
219
+ return null;
220
+ const deltaMs = Date.parse(nowIso) - Date.parse(decision.lastIndexedAt);
221
+ if (!Number.isFinite(deltaMs) || deltaMs < 0)
222
+ return 0;
223
+ return Math.floor(deltaMs / (24 * 60 * 60 * 1000));
224
+ }
225
+ /**
226
+ * Convenience predicate — should the cold-start hook show the stale-
227
+ * index reminder? `true` when the index is older than STALE_INDEX_DAYS
228
+ * AND we did NOT already remind the operator today.
229
+ */
230
+ export function shouldNudgeStaleIndex(decision, nowIso = new Date().toISOString()) {
231
+ if (!decision.accepted)
232
+ return false;
233
+ const age = indexAgeDays(decision, nowIso);
234
+ if (age === null)
235
+ return false;
236
+ if (age < STALE_INDEX_DAYS)
237
+ return false;
238
+ // Throttle the nudge to once per day so the operator does not see it
239
+ // on every REPL keystroke.
240
+ if (decision.lastReindexCheckAt) {
241
+ const lastCheckDelta = Date.parse(nowIso) - Date.parse(decision.lastReindexCheckAt);
242
+ if (Number.isFinite(lastCheckDelta) && lastCheckDelta < 24 * 60 * 60 * 1000) {
243
+ return false;
244
+ }
245
+ }
246
+ return true;
247
+ }
248
+ //# sourceMappingURL=decision-store.js.map