@pugi/cli 0.1.0-beta.31 → 0.1.0-beta.36
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.
- package/dist/commands/smoke.js +133 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +50 -4
- package/dist/core/mcp/orchestrator-tools.js +595 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/repl/session.js +370 -9
- package/dist/core/repl/slash-commands.js +68 -5
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/runtime/cli.js +453 -11
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/mcp.js +66 -11
- package/dist/runtime/commands/permissions.js +23 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/status.js +11 -3
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/permissions-picker.js +78 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/status-bar.js +1 -1
- package/dist/tui/tool-stream-pane.js +45 -3
- package/package.json +7 -4
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/memory/dual-write.spec.js +0 -297
- package/dist/core/memory-sync/queue.spec.js +0 -105
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
- 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
|
|
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
|