@pugi/cli 0.1.0-beta.92 → 0.1.0-beta.94
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/retro.js +210 -0
- package/dist/core/diagnostics/probes/sandbox.js +65 -33
- package/dist/core/engine/native-pugi.js +185 -11
- package/dist/core/engine/prompts.js +1 -1
- package/dist/core/engine/tool-bridge.js +35 -0
- package/dist/core/engine/verification-patterns.js +195 -0
- package/dist/core/mcp/orchestrator-config.js +192 -0
- package/dist/core/mcp/orchestrator-tools.js +147 -3
- package/dist/core/pugi-gitignore.js +52 -0
- package/dist/core/repl/engine-bridge.js +199 -0
- package/dist/core/repl/session.js +395 -6
- package/dist/core/repl/tool-route.js +382 -0
- package/dist/core/retro/git-collector.js +251 -0
- package/dist/core/retro/health-card.js +25 -0
- package/dist/core/retro/metrics.js +342 -0
- package/dist/core/retro/narrative.js +249 -0
- package/dist/core/retro/plane-collector.js +274 -0
- package/dist/core/retro/pr-issue-link.js +65 -0
- package/dist/core/retro/types.js +16 -0
- package/dist/core/sandboxing/adapter.js +29 -0
- package/dist/core/sandboxing/index.js +49 -0
- package/dist/core/sandboxing/none.js +19 -0
- package/dist/core/sandboxing/seatbelt.js +183 -0
- package/dist/core/session.js +27 -0
- package/dist/core/settings.js +22 -0
- package/dist/runtime/cli.js +167 -33
- package/dist/runtime/commands/compact.js +1 -1
- package/dist/runtime/commands/config.js +1 -1
- package/dist/runtime/commands/mcp.js +64 -8
- package/dist/runtime/commands/memory.js +1 -1
- package/dist/runtime/deprecation-warning.js +69 -0
- package/dist/runtime/headless.js +8 -3
- package/dist/runtime/stream-renderer.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/skills/bundled/remember.js +2 -2
- package/dist/tui/agent-tree.js +11 -0
- package/dist/tui/ask-user-question-chips.js +1 -1
- package/dist/tui/multi-file-diff-approval.js +3 -3
- package/dist/tui/repl-render.js +42 -0
- package/package.json +2 -2
- package/test/scenarios/identity.scenario.txt +0 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUGI-VERIFY-GATE — verification command detection.
|
|
3
|
+
*
|
|
4
|
+
* Background: Codex dogfood 2026-06-04 surfaced a P0 trust failure
|
|
5
|
+
* where the Pugi engine returned `status: done` + `exitCode: 0`
|
|
6
|
+
* even after `npm test` exited non-zero on a regression the agent
|
|
7
|
+
* itself had introduced. Root cause: no layer of the dispatch
|
|
8
|
+
* pipeline knew which bash invocations were verification commands,
|
|
9
|
+
* so the engine outcome had no way to gate the final status on
|
|
10
|
+
* test/lint/build pass.
|
|
11
|
+
*
|
|
12
|
+
* This module is the deterministic, configurable allowlist of regex
|
|
13
|
+
* patterns the engine uses to recognise verification commands at
|
|
14
|
+
* dispatch time. The detection is intentionally simple (anchored on
|
|
15
|
+
* the head of the command after sudo / env-prefix stripping) so the
|
|
16
|
+
* allowlist stays auditable. False negatives are recoverable (the
|
|
17
|
+
* agent can re-run with a recognised wrapper); false positives would
|
|
18
|
+
* silently down-grade unrelated commands and are forbidden.
|
|
19
|
+
*
|
|
20
|
+
* The pattern table is exported as `VERIFICATION_PATTERNS`; callers
|
|
21
|
+
* use `detectVerificationCommand(cmd)` for the boolean + tool-tag
|
|
22
|
+
* decision. Both surfaces are pure — no I/O, no session state, no
|
|
23
|
+
* environment reads.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Canonical verification allowlist. Patterns target the head of each
|
|
27
|
+
* shell-separated component AFTER:
|
|
28
|
+
* - leading whitespace is trimmed
|
|
29
|
+
* - leading `sudo` / `time` / `env KEY=value` prefixes are stripped
|
|
30
|
+
*
|
|
31
|
+
* Pre-trim the cmd through `extractCommandHead` before matching.
|
|
32
|
+
*
|
|
33
|
+
* When extending: keep the regex anchored (`^`) so a path containing
|
|
34
|
+
* the tool name (`./scripts/npm.sh`) does not false-positive.
|
|
35
|
+
*/
|
|
36
|
+
export const VERIFICATION_PATTERNS = [
|
|
37
|
+
// ----- JavaScript / TypeScript ecosystem -----
|
|
38
|
+
// npm test / npm run test / npm run lint / npm run typecheck / npm run build
|
|
39
|
+
{ tool: 'npm-test', pattern: /^npm\s+(?:run\s+)?test\b/, category: 'test' },
|
|
40
|
+
{ tool: 'npm-lint', pattern: /^npm\s+run\s+lint\b/, category: 'lint' },
|
|
41
|
+
{ tool: 'npm-typecheck', pattern: /^npm\s+run\s+typecheck\b/, category: 'typecheck' },
|
|
42
|
+
{ tool: 'npm-build', pattern: /^npm\s+run\s+build\b/, category: 'build' },
|
|
43
|
+
// pnpm (with and without -C / --filter prefixes — match the full head)
|
|
44
|
+
{ tool: 'pnpm-test', pattern: /^pnpm(?:\s+(?:-C\s+\S+|--filter(?:\s+|=)\S+|-r))*\s+(?:run\s+)?test\b/, category: 'test' },
|
|
45
|
+
{ tool: 'pnpm-lint', pattern: /^pnpm(?:\s+(?:-C\s+\S+|--filter(?:\s+|=)\S+|-r))*\s+(?:run\s+)?lint\b/, category: 'lint' },
|
|
46
|
+
{ tool: 'pnpm-typecheck', pattern: /^pnpm(?:\s+(?:-C\s+\S+|--filter(?:\s+|=)\S+|-r))*\s+(?:run\s+)?typecheck\b/, category: 'typecheck' },
|
|
47
|
+
{ tool: 'pnpm-build', pattern: /^pnpm(?:\s+(?:-C\s+\S+|--filter(?:\s+|=)\S+|-r))*\s+(?:run\s+)?build\b/, category: 'build' },
|
|
48
|
+
// yarn
|
|
49
|
+
{ tool: 'yarn-test', pattern: /^yarn\s+(?:run\s+)?test\b/, category: 'test' },
|
|
50
|
+
{ tool: 'yarn-lint', pattern: /^yarn\s+(?:run\s+)?lint\b/, category: 'lint' },
|
|
51
|
+
{ tool: 'yarn-typecheck', pattern: /^yarn\s+(?:run\s+)?typecheck\b/, category: 'typecheck' },
|
|
52
|
+
{ tool: 'yarn-build', pattern: /^yarn\s+(?:run\s+)?build\b/, category: 'build' },
|
|
53
|
+
// Direct test-runner invocations (npx and bare).
|
|
54
|
+
{ tool: 'jest', pattern: /^(?:npx\s+)?jest\b/, category: 'test' },
|
|
55
|
+
{ tool: 'vitest', pattern: /^(?:npx\s+)?vitest\b/, category: 'test' },
|
|
56
|
+
{ tool: 'mocha', pattern: /^(?:npx\s+)?mocha\b/, category: 'test' },
|
|
57
|
+
{ tool: 'tsc-typecheck', pattern: /^(?:npx\s+)?tsc\b(?=.*--noEmit|\s*$)/, category: 'typecheck' },
|
|
58
|
+
{ tool: 'eslint', pattern: /^(?:npx\s+)?eslint\b/, category: 'lint' },
|
|
59
|
+
{ tool: 'node-test', pattern: /^node\s+--test\b/, category: 'test' },
|
|
60
|
+
// ----- Python -----
|
|
61
|
+
{ tool: 'pytest', pattern: /^(?:python\s+-m\s+)?pytest\b/, category: 'test' },
|
|
62
|
+
{ tool: 'python-unittest', pattern: /^python\s+-m\s+unittest\b/, category: 'test' },
|
|
63
|
+
{ tool: 'ruff', pattern: /^ruff\s+check\b/, category: 'lint' },
|
|
64
|
+
{ tool: 'mypy', pattern: /^mypy\b/, category: 'typecheck' },
|
|
65
|
+
// ----- Rust -----
|
|
66
|
+
{ tool: 'cargo-test', pattern: /^cargo\s+test\b/, category: 'test' },
|
|
67
|
+
{ tool: 'cargo-check', pattern: /^cargo\s+check\b/, category: 'typecheck' },
|
|
68
|
+
{ tool: 'cargo-clippy', pattern: /^cargo\s+clippy\b/, category: 'lint' },
|
|
69
|
+
{ tool: 'cargo-build', pattern: /^cargo\s+build\b/, category: 'build' },
|
|
70
|
+
// ----- Go -----
|
|
71
|
+
{ tool: 'go-test', pattern: /^go\s+test\b/, category: 'test' },
|
|
72
|
+
{ tool: 'go-vet', pattern: /^go\s+vet\b/, category: 'lint' },
|
|
73
|
+
{ tool: 'go-build', pattern: /^go\s+build\b/, category: 'build' },
|
|
74
|
+
// ----- Elixir -----
|
|
75
|
+
{ tool: 'mix-test', pattern: /^mix\s+test\b/, category: 'test' },
|
|
76
|
+
// ----- Ruby -----
|
|
77
|
+
{ tool: 'rspec', pattern: /^(?:bundle\s+exec\s+)?rspec\b/, category: 'test' },
|
|
78
|
+
{ tool: 'rubocop', pattern: /^(?:bundle\s+exec\s+)?rubocop\b/, category: 'lint' },
|
|
79
|
+
// ----- Java / Kotlin / Gradle / Maven -----
|
|
80
|
+
{ tool: 'gradle-test', pattern: /^(?:\.\/)?gradlew?\s+test\b/, category: 'test' },
|
|
81
|
+
{ tool: 'gradle-build', pattern: /^(?:\.\/)?gradlew?\s+build\b/, category: 'build' },
|
|
82
|
+
{ tool: 'maven-test', pattern: /^mvn\s+test\b/, category: 'test' },
|
|
83
|
+
{ tool: 'maven-verify', pattern: /^mvn\s+verify\b/, category: 'test' },
|
|
84
|
+
// ----- C/C++ / Make -----
|
|
85
|
+
{ tool: 'make-test', pattern: /^make\s+(?:test|check)\b/, category: 'test' },
|
|
86
|
+
{ tool: 'ctest', pattern: /^ctest\b/, category: 'test' },
|
|
87
|
+
];
|
|
88
|
+
const SHELL_SEPARATORS = /\s*(?:&&|\|\||;|\|)\s*/;
|
|
89
|
+
const ENV_ASSIGN = /^[A-Z_][A-Z0-9_]*=\S+$/;
|
|
90
|
+
/**
|
|
91
|
+
* Strip leading `sudo` / `time` / `env A=1 B=2` noise so the verb is
|
|
92
|
+
* the first non-prefix token. Returns the stripped head as a single
|
|
93
|
+
* normalised string. Pure — no side effects.
|
|
94
|
+
*
|
|
95
|
+
* We do NOT strip generic env-variable assignments like `CI=1` that
|
|
96
|
+
* the operator typed inline (e.g. `CI=1 pnpm test`) because the
|
|
97
|
+
* regex allowlist anchors `pnpm` — matching the head after stripping
|
|
98
|
+
* `CI=1` is precisely the intent.
|
|
99
|
+
*/
|
|
100
|
+
export function extractCommandHead(component) {
|
|
101
|
+
let head = component.trim();
|
|
102
|
+
// sudo / time wrappers
|
|
103
|
+
while (true) {
|
|
104
|
+
if (head.startsWith('sudo ')) {
|
|
105
|
+
head = head.slice(5).trimStart();
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (head.startsWith('time ')) {
|
|
109
|
+
head = head.slice(5).trimStart();
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// env A=1 B=2 prefix (inline env assignments before the verb).
|
|
113
|
+
// Peel one token at a time so `FOO=bar BAZ=qux pnpm test` resolves to `pnpm test`.
|
|
114
|
+
const firstToken = head.split(/\s+/, 1)[0] ?? '';
|
|
115
|
+
if (firstToken !== '' && ENV_ASSIGN.test(firstToken)) {
|
|
116
|
+
head = head.slice(firstToken.length).trimStart();
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
return head;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Detect whether a shell command runs a verification step. The
|
|
125
|
+
* predicate scans every `&&` / `;` / `||` / `|`-separated component
|
|
126
|
+
* and returns the first match — a compound command like
|
|
127
|
+
* `cd packages/foo && pnpm test` is correctly flagged on the
|
|
128
|
+
* trailing component.
|
|
129
|
+
*
|
|
130
|
+
* The check is intentionally optimistic: it does not parse `if`,
|
|
131
|
+
* `for`, or function bodies. Operators wrapping verification inside
|
|
132
|
+
* a script (e.g. `./scripts/test.sh`) opt out of the gate; that is
|
|
133
|
+
* recorded in the unverifiedReason as `no_verification_command_run`
|
|
134
|
+
* downstream.
|
|
135
|
+
*/
|
|
136
|
+
export function detectVerificationCommand(cmd) {
|
|
137
|
+
if (typeof cmd !== 'string' || cmd.trim() === '') {
|
|
138
|
+
return { isVerification: false, tool: null, matchedComponent: '' };
|
|
139
|
+
}
|
|
140
|
+
const components = cmd.split(SHELL_SEPARATORS);
|
|
141
|
+
for (const raw of components) {
|
|
142
|
+
const head = extractCommandHead(raw);
|
|
143
|
+
if (head === '')
|
|
144
|
+
continue;
|
|
145
|
+
for (const entry of VERIFICATION_PATTERNS) {
|
|
146
|
+
if (entry.pattern.test(head)) {
|
|
147
|
+
return {
|
|
148
|
+
isVerification: true,
|
|
149
|
+
tool: entry.tool,
|
|
150
|
+
matchedComponent: raw.trim(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { isVerification: false, tool: null, matchedComponent: '' };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Phrases the agent uses to dispute ownership of a verification
|
|
159
|
+
* failure. When ANY of these phrases appears in the final assistant
|
|
160
|
+
* text AND the agent mutated files in the same module as a failing
|
|
161
|
+
* test, the outcome's `regressionOwnershipDispute` flag is set so a
|
|
162
|
+
* downstream reviewer can decide whether to escalate.
|
|
163
|
+
*
|
|
164
|
+
* The list is case-insensitive at match time. Punctuation around the
|
|
165
|
+
* phrase is allowed because `.includes()` looks for the substring,
|
|
166
|
+
* not word boundaries (an agent that writes "this is a pre-existing
|
|
167
|
+
* test bug" still trips the flag).
|
|
168
|
+
*/
|
|
169
|
+
export const REGRESSION_DISPUTE_PHRASES = [
|
|
170
|
+
'pre-existing',
|
|
171
|
+
'preexisting',
|
|
172
|
+
'pre existing',
|
|
173
|
+
'not from my changes',
|
|
174
|
+
'not related to my changes',
|
|
175
|
+
'unrelated test failure',
|
|
176
|
+
'unrelated to my changes',
|
|
177
|
+
'unrelated failure',
|
|
178
|
+
'not my change',
|
|
179
|
+
];
|
|
180
|
+
/**
|
|
181
|
+
* Tail trimmer for stderr captured in verification ledger entries.
|
|
182
|
+
* Returns the last `maxBytes` of UTF-8 text, clamped at a hard 2 KB
|
|
183
|
+
* default to match the PUGI-VERIFY-GATE contract.
|
|
184
|
+
*/
|
|
185
|
+
export function tailStderr(stderr, maxBytes = 2048) {
|
|
186
|
+
if (typeof stderr !== 'string' || stderr.length === 0)
|
|
187
|
+
return '';
|
|
188
|
+
if (Buffer.byteLength(stderr, 'utf8') <= maxBytes)
|
|
189
|
+
return stderr;
|
|
190
|
+
// Approximate cap by character index — accurate enough for stderr
|
|
191
|
+
// tails that are overwhelmingly ASCII test output.
|
|
192
|
+
const slice = stderr.slice(-maxBytes);
|
|
193
|
+
return slice;
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=verification-patterns.js.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi MCP orchestrator config (Trust Sprint item 7).
|
|
3
|
+
*
|
|
4
|
+
* BEFORE this module the customer had to wire three independent gates
|
|
5
|
+
* to actually run the orchestrator surface successfully:
|
|
6
|
+
*
|
|
7
|
+
* PUGI_MCP_EXEC_ENABLED=1 (env) — gates pugi.run + pugi.dispatch
|
|
8
|
+
* --allow-bash (flag) — surfaces the bash permission class
|
|
9
|
+
* PUGI_MCP_PUGI_BIN=... (env) — points the dispatcher at a binary
|
|
10
|
+
*
|
|
11
|
+
* Missing any one of the three produced an opaque "tool refused" or
|
|
12
|
+
* "binary not found" error that nobody could correlate back to the
|
|
13
|
+
* three-knob configuration. Codex deep-research 2026-06-04 logged this
|
|
14
|
+
* as a discipline gap.
|
|
15
|
+
*
|
|
16
|
+
* AFTER this module the customer flips ONE switch:
|
|
17
|
+
*
|
|
18
|
+
* PUGI_MCP_ORCHESTRATOR=1
|
|
19
|
+
*
|
|
20
|
+
* Or one flag:
|
|
21
|
+
*
|
|
22
|
+
* pugi mcp serve --orchestrator-bundle
|
|
23
|
+
*
|
|
24
|
+
* Either form:
|
|
25
|
+
* - enables exec gating on pugi.run + pugi.dispatch,
|
|
26
|
+
* - turns on the bash permission class (equivalent to --allow-bash),
|
|
27
|
+
* - auto-resolves `pugi` from PATH (or the local dev binary when
|
|
28
|
+
* invoked from a monorepo checkout).
|
|
29
|
+
*
|
|
30
|
+
* The legacy multi-knob configuration continues to work as deprecated
|
|
31
|
+
* aliases. When the legacy combo is observed at boot we emit a stderr
|
|
32
|
+
* warning so operators know they should migrate.
|
|
33
|
+
*
|
|
34
|
+
* Scope rule: this module is ADDITIVE. It does not modify
|
|
35
|
+
* orchestrator-tools.ts or the dispatch handler (other agent owns
|
|
36
|
+
* those files in PUGI-VERIFY-GATE).
|
|
37
|
+
*/
|
|
38
|
+
import { existsSync } from 'node:fs';
|
|
39
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { execFileSync } from 'node:child_process';
|
|
42
|
+
import { warnDeprecation } from '../../runtime/deprecation-warning.js';
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the effective orchestrator configuration from inputs.
|
|
45
|
+
*
|
|
46
|
+
* Single source of truth for the gate-collapse logic. `runtime/commands/
|
|
47
|
+
* mcp.ts` consumes the result; tests call this function directly.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveOrchestratorConfig(input) {
|
|
50
|
+
const env = input.env;
|
|
51
|
+
const bundleViaEnv = env.PUGI_MCP_ORCHESTRATOR === '1';
|
|
52
|
+
const bundleViaFlag = input.bundleFlag;
|
|
53
|
+
const bundleEnabled = bundleViaEnv || bundleViaFlag;
|
|
54
|
+
const execViaLegacyEnv = env.PUGI_MCP_EXEC_ENABLED === '1';
|
|
55
|
+
const publishViaLegacyEnv = env.PUGI_MCP_PUBLISH_ENABLED === '1';
|
|
56
|
+
const deployViaLegacyEnv = env.PUGI_MCP_DEPLOY_ENABLED === '1';
|
|
57
|
+
const bashViaFlag = input.bashFlag;
|
|
58
|
+
// Bundle implies exec + bash. Publish + deploy stay opt-in even under
|
|
59
|
+
// bundle because they cross network boundaries (npm publish, ssh
|
|
60
|
+
// deploy) — operator should still flip them deliberately.
|
|
61
|
+
const execEnabled = bundleEnabled || execViaLegacyEnv;
|
|
62
|
+
const publishEnabled = publishViaLegacyEnv;
|
|
63
|
+
const deployEnabled = deployViaLegacyEnv;
|
|
64
|
+
const bashAllowed = bundleEnabled || bashViaFlag;
|
|
65
|
+
// Emit deprecation warning the first time we see the OLD multi-flag
|
|
66
|
+
// combo without the bundle flag. We only warn if the operator clearly
|
|
67
|
+
// intended the orchestrator surface (at least one legacy env set) so
|
|
68
|
+
// bare engine-surface runs stay silent.
|
|
69
|
+
if (!bundleEnabled && execViaLegacyEnv) {
|
|
70
|
+
warnDeprecation('PUGI_MCP_EXEC_ENABLED', 'PUGI_MCP_ORCHESTRATOR=1', 'Single flag enables exec + bash + auto-resolves the pugi binary in one step.');
|
|
71
|
+
}
|
|
72
|
+
const { pugiBin, pugiBinSource } = resolvePugiBin({
|
|
73
|
+
envOverride: env.PUGI_MCP_PUGI_BIN ?? null,
|
|
74
|
+
workspaceRoot: input.workspaceRoot,
|
|
75
|
+
resolveBinary: input.resolveBinary ?? defaultBinaryResolver,
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
bundleEnabled,
|
|
79
|
+
execEnabled,
|
|
80
|
+
publishEnabled,
|
|
81
|
+
deployEnabled,
|
|
82
|
+
bashAllowed,
|
|
83
|
+
pugiBin,
|
|
84
|
+
source: {
|
|
85
|
+
bundleViaEnv,
|
|
86
|
+
bundleViaFlag,
|
|
87
|
+
execViaLegacyEnv,
|
|
88
|
+
publishViaLegacyEnv,
|
|
89
|
+
deployViaLegacyEnv,
|
|
90
|
+
bashViaFlag,
|
|
91
|
+
pugiBinSource,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the `pugi` binary path. Layered fallback so operators get a
|
|
97
|
+
* working dispatcher whether they `npm i -g @pugi/cli`-ed it (PATH),
|
|
98
|
+
* baked it into env (CI), or are running from a monorepo checkout
|
|
99
|
+
* (where `pugi` is not on PATH but `dist/index.js` exists).
|
|
100
|
+
*/
|
|
101
|
+
function resolvePugiBin(input) {
|
|
102
|
+
if (input.envOverride && input.envOverride.length > 0) {
|
|
103
|
+
return { pugiBin: input.envOverride, pugiBinSource: 'env' };
|
|
104
|
+
}
|
|
105
|
+
const fromPath = input.resolveBinary('pugi');
|
|
106
|
+
if (fromPath && fromPath.length > 0) {
|
|
107
|
+
return { pugiBin: fromPath, pugiBinSource: 'path-lookup' };
|
|
108
|
+
}
|
|
109
|
+
const devBinary = findMonorepoDevBinary(input.workspaceRoot);
|
|
110
|
+
if (devBinary) {
|
|
111
|
+
return { pugiBin: devBinary, pugiBinSource: 'monorepo-dev-binary' };
|
|
112
|
+
}
|
|
113
|
+
// Last-resort fallback. The downstream `execFile` will throw ENOENT
|
|
114
|
+
// with a path readable error; better that than us swallowing the
|
|
115
|
+
// intent of "operator wanted orchestrator mode" silently.
|
|
116
|
+
return { pugiBin: 'pugi', pugiBinSource: 'fallback' };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Walk up from the workspace root looking for the monorepo pugi-cli
|
|
120
|
+
* checkout. Used when an operator runs `pugi mcp serve --orchestrator-
|
|
121
|
+
* bundle` from inside a freshly-cloned `pugi-io/pugi` repo without a
|
|
122
|
+
* global install. Skipped for non-monorepo workspaces (returns null
|
|
123
|
+
* quickly when the expected marker files are absent).
|
|
124
|
+
*/
|
|
125
|
+
function findMonorepoDevBinary(workspaceRoot) {
|
|
126
|
+
const candidates = [];
|
|
127
|
+
// Common shapes: workspaceRoot IS the monorepo, or workspaceRoot is
|
|
128
|
+
// a sibling sub-app of pugi-cli. Limit the walk to two levels up.
|
|
129
|
+
let current = workspaceRoot;
|
|
130
|
+
for (let i = 0; i < 4; i += 1) {
|
|
131
|
+
candidates.push(resolve(current, 'apps/pugi-cli/dist/index.js'));
|
|
132
|
+
candidates.push(resolve(current, 'apps/pugi-cli/bin/run.js'));
|
|
133
|
+
const parent = dirname(current);
|
|
134
|
+
if (parent === current)
|
|
135
|
+
break;
|
|
136
|
+
current = parent;
|
|
137
|
+
}
|
|
138
|
+
for (const candidate of candidates) {
|
|
139
|
+
if (existsSync(candidate))
|
|
140
|
+
return candidate;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Default `which`-backed binary resolver. Mirrors the pattern used by
|
|
146
|
+
* `resolveExecutablePath` in `runtime/commands/mcp.ts`. Returns null
|
|
147
|
+
* on any failure — the caller falls back to the next layer.
|
|
148
|
+
*/
|
|
149
|
+
function defaultBinaryResolver(command) {
|
|
150
|
+
if (isAbsolute(command)) {
|
|
151
|
+
return existsSync(command) ? command : null;
|
|
152
|
+
}
|
|
153
|
+
if (command.includes('/') || command.includes('\\'))
|
|
154
|
+
return null;
|
|
155
|
+
try {
|
|
156
|
+
const out = execFileSync('/usr/bin/which', [command], {
|
|
157
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
158
|
+
encoding: 'utf8',
|
|
159
|
+
timeout: 5000,
|
|
160
|
+
}).trim();
|
|
161
|
+
return out.length > 0 ? out : null;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Compose a human-readable diagnostic line per resolved config. Used
|
|
169
|
+
* by `pugi mcp serve --orchestrator*` startup output and `pugi doctor`
|
|
170
|
+
* so operators can verify which gates ended up live.
|
|
171
|
+
*/
|
|
172
|
+
export function describeOrchestratorConfig(config) {
|
|
173
|
+
const lines = [];
|
|
174
|
+
lines.push(`bundle: ${config.bundleEnabled ? 'on' : 'off'}`);
|
|
175
|
+
lines.push(`exec: ${config.execEnabled ? 'on' : 'off'}`);
|
|
176
|
+
lines.push(`publish: ${config.publishEnabled ? 'on' : 'off'}`);
|
|
177
|
+
lines.push(`deploy: ${config.deployEnabled ? 'on' : 'off'}`);
|
|
178
|
+
lines.push(`bash surfaced: ${config.bashAllowed ? 'on' : 'off'}`);
|
|
179
|
+
lines.push(`pugi binary: ${config.pugiBin} (source: ${config.source.pugiBinSource})`);
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Exported for tests so the dev-binary fallback can be exercised
|
|
184
|
+
* without invoking the full resolver. Kept internal to the module
|
|
185
|
+
* otherwise.
|
|
186
|
+
*/
|
|
187
|
+
export const _internalForTests = {
|
|
188
|
+
findMonorepoDevBinary,
|
|
189
|
+
resolvePugiBin,
|
|
190
|
+
currentModuleDir: () => dirname(fileURLToPath(import.meta.url)),
|
|
191
|
+
};
|
|
192
|
+
//# sourceMappingURL=orchestrator-config.js.map
|
|
@@ -358,8 +358,15 @@ export function buildOrchestratorTools(ctx) {
|
|
|
358
358
|
: ctx.workspaceRoot;
|
|
359
359
|
const timeoutMs = optionalNumber(args, 'timeoutMs', 180000);
|
|
360
360
|
const started = Date.now();
|
|
361
|
+
// PUGI-VERIFY-GATE: dispatch the child with `--json` so we
|
|
362
|
+
// can parse its structured outcome envelope. The CLI's JSON
|
|
363
|
+
// mode includes verified / verificationCommands /
|
|
364
|
+
// verificationFailures / unverifiedReason /
|
|
365
|
+
// regressionOwnershipDispute. The MCP response now carries
|
|
366
|
+
// those fields through alongside the honest exit code so
|
|
367
|
+
// callers see the gate state, not just a "ran" boolean.
|
|
361
368
|
try {
|
|
362
|
-
const { stdout, stderr } = await execImpl(ctx.pugiBin, [command, prompt, '--no-tty'], {
|
|
369
|
+
const { stdout, stderr } = await execImpl(ctx.pugiBin, [command, prompt, '--no-tty', '--json'], {
|
|
363
370
|
cwd,
|
|
364
371
|
timeout: timeoutMs,
|
|
365
372
|
maxBuffer: 8 * 1024 * 1024,
|
|
@@ -370,13 +377,33 @@ export function buildOrchestratorTools(ctx) {
|
|
|
370
377
|
// `pugi login` state when both are present.
|
|
371
378
|
env: dispatchEnv(),
|
|
372
379
|
});
|
|
380
|
+
// Codex dogfood 2026-06-04: the prior implementation
|
|
381
|
+
// hardcoded `exitCode: 0` on the happy execImpl path even
|
|
382
|
+
// when the child surfaced a verification failure through
|
|
383
|
+
// `--json`. The child's `--json` envelope is the source of
|
|
384
|
+
// truth — parse it and mirror `verified` / `status` back
|
|
385
|
+
// to the MCP caller. The execImpl-level "no throw" signal
|
|
386
|
+
// is no longer trusted as "exit 0".
|
|
387
|
+
const parsed = parseDispatchEnvelope(stdout);
|
|
388
|
+
const dispatchExitCode = resolveDispatchExitCode(parsed);
|
|
373
389
|
return JSON.stringify({
|
|
374
390
|
command,
|
|
375
391
|
cwd,
|
|
376
|
-
|
|
392
|
+
// CRITICAL: derived from parsed envelope, not constant 0.
|
|
393
|
+
exitCode: dispatchExitCode,
|
|
377
394
|
durationMs: Date.now() - started,
|
|
378
395
|
stdout: clamp(stdout, 16 * 1024),
|
|
379
396
|
stderr: clamp(stderr, 4 * 1024),
|
|
397
|
+
...(parsed
|
|
398
|
+
? {
|
|
399
|
+
status: parsed.status,
|
|
400
|
+
verified: parsed.verified,
|
|
401
|
+
verificationCommands: parsed.verificationCommands,
|
|
402
|
+
verificationFailures: parsed.verificationFailures,
|
|
403
|
+
unverifiedReason: parsed.unverifiedReason,
|
|
404
|
+
regressionOwnershipDispute: parsed.regressionOwnershipDispute,
|
|
405
|
+
}
|
|
406
|
+
: {}),
|
|
380
407
|
});
|
|
381
408
|
}
|
|
382
409
|
catch (err) {
|
|
@@ -386,15 +413,35 @@ export function buildOrchestratorTools(ctx) {
|
|
|
386
413
|
// ENOENT"`. Operators need to distinguish "pugi binary missing"
|
|
387
414
|
// from "pugi ran and exited 1 silently."
|
|
388
415
|
const stderrText = e.stderr || e.message || '';
|
|
416
|
+
// PUGI-VERIFY-GATE: even on the throw path, parse stdout
|
|
417
|
+
// when present so the verification gate state surfaces.
|
|
418
|
+
// The child's CLI exits non-zero for failed / blocked /
|
|
419
|
+
// needs_verification, which puts execImpl on this path.
|
|
420
|
+
const parsed = parseDispatchEnvelope(e.stdout ?? '');
|
|
421
|
+
const dispatchExitCode = typeof e.code === 'number'
|
|
422
|
+
? e.code
|
|
423
|
+
: parsed
|
|
424
|
+
? resolveDispatchExitCode(parsed)
|
|
425
|
+
: 1;
|
|
389
426
|
return JSON.stringify({
|
|
390
427
|
command,
|
|
391
428
|
cwd,
|
|
392
|
-
exitCode:
|
|
429
|
+
exitCode: dispatchExitCode,
|
|
393
430
|
durationMs: Date.now() - started,
|
|
394
431
|
stdout: clamp(e.stdout ?? '', 16 * 1024),
|
|
395
432
|
stderr: clamp(stderrText, 4 * 1024),
|
|
396
433
|
...(e.signal ? { signal: e.signal } : {}),
|
|
397
434
|
...(e.killed ? { killed: true } : {}),
|
|
435
|
+
...(parsed
|
|
436
|
+
? {
|
|
437
|
+
status: parsed.status,
|
|
438
|
+
verified: parsed.verified,
|
|
439
|
+
verificationCommands: parsed.verificationCommands,
|
|
440
|
+
verificationFailures: parsed.verificationFailures,
|
|
441
|
+
unverifiedReason: parsed.unverifiedReason,
|
|
442
|
+
regressionOwnershipDispute: parsed.regressionOwnershipDispute,
|
|
443
|
+
}
|
|
444
|
+
: {}),
|
|
398
445
|
});
|
|
399
446
|
}
|
|
400
447
|
},
|
|
@@ -659,4 +706,101 @@ export const ORCHESTRATOR_TOOLS_MODULE_FILE = (() => {
|
|
|
659
706
|
return '';
|
|
660
707
|
}
|
|
661
708
|
})();
|
|
709
|
+
/**
|
|
710
|
+
* Try to extract the JSON envelope from the child CLI's stdout.
|
|
711
|
+
* The CLI prints a single JSON object on the trailing line when
|
|
712
|
+
* `--json` is passed; older builds may interleave status events on
|
|
713
|
+
* stderr but always emit the final JSON on stdout. Scan from the
|
|
714
|
+
* end of stdout backwards looking for the first balanced JSON
|
|
715
|
+
* object so a mixed stdout (e.g. with leading banner) still
|
|
716
|
+
* parses.
|
|
717
|
+
*
|
|
718
|
+
* Returns null on any parse failure; the caller falls back to
|
|
719
|
+
* legacy behaviour (no verification fields surfaced).
|
|
720
|
+
*/
|
|
721
|
+
export function parseDispatchEnvelope(stdout) {
|
|
722
|
+
if (typeof stdout !== 'string' || stdout.trim() === '')
|
|
723
|
+
return null;
|
|
724
|
+
const trimmed = stdout.trim();
|
|
725
|
+
// Fast path: stdout is a single JSON object (most common).
|
|
726
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
727
|
+
try {
|
|
728
|
+
const parsed = JSON.parse(trimmed);
|
|
729
|
+
return normaliseEnvelope(parsed);
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
// fall through to multi-line scan
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// Slow path: scan trailing lines for the last JSON-looking line.
|
|
736
|
+
const lines = trimmed.split('\n');
|
|
737
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
738
|
+
const line = lines[i]?.trim();
|
|
739
|
+
if (!line || !line.startsWith('{') || !line.endsWith('}'))
|
|
740
|
+
continue;
|
|
741
|
+
try {
|
|
742
|
+
const parsed = JSON.parse(line);
|
|
743
|
+
return normaliseEnvelope(parsed);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// try the next line up
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
function normaliseEnvelope(raw) {
|
|
752
|
+
if (typeof raw['status'] !== 'string')
|
|
753
|
+
return null;
|
|
754
|
+
const result = { status: raw['status'] };
|
|
755
|
+
if (typeof raw['verified'] === 'boolean')
|
|
756
|
+
result.verified = raw['verified'];
|
|
757
|
+
if (Array.isArray(raw['verificationCommands'])) {
|
|
758
|
+
result.verificationCommands = raw['verificationCommands'].filter((item) => typeof item === 'string');
|
|
759
|
+
}
|
|
760
|
+
if (Array.isArray(raw['verificationFailures'])) {
|
|
761
|
+
const failures = [];
|
|
762
|
+
for (const item of raw['verificationFailures']) {
|
|
763
|
+
if (item && typeof item === 'object') {
|
|
764
|
+
const r = item;
|
|
765
|
+
if (typeof r['command'] === 'string' &&
|
|
766
|
+
typeof r['exitCode'] === 'number') {
|
|
767
|
+
failures.push({
|
|
768
|
+
command: r['command'],
|
|
769
|
+
exitCode: r['exitCode'],
|
|
770
|
+
tailStderr: typeof r['tailStderr'] === 'string' ? r['tailStderr'] : '',
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
result.verificationFailures = failures;
|
|
776
|
+
}
|
|
777
|
+
if (typeof raw['unverifiedReason'] === 'string') {
|
|
778
|
+
result.unverifiedReason = raw['unverifiedReason'];
|
|
779
|
+
}
|
|
780
|
+
if (typeof raw['regressionOwnershipDispute'] === 'boolean') {
|
|
781
|
+
result.regressionOwnershipDispute = raw['regressionOwnershipDispute'];
|
|
782
|
+
}
|
|
783
|
+
return result;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Honest exit code derivation from the parsed envelope. Mirrors
|
|
787
|
+
* `resolveEngineExitCode` in `cli.ts` so the MCP wrapper's
|
|
788
|
+
* propagation matches what the child CLI actually exits with — a
|
|
789
|
+
* test can assert on either surface and see consistent codes.
|
|
790
|
+
*/
|
|
791
|
+
export function resolveDispatchExitCode(envelope) {
|
|
792
|
+
if (envelope === null)
|
|
793
|
+
return 0;
|
|
794
|
+
if (envelope.status === 'needs_verification')
|
|
795
|
+
return 2;
|
|
796
|
+
if (envelope.unverifiedReason === 'verification_command_failed')
|
|
797
|
+
return 1;
|
|
798
|
+
if (envelope.status === 'done')
|
|
799
|
+
return 0;
|
|
800
|
+
if (envelope.status === 'failed')
|
|
801
|
+
return 1;
|
|
802
|
+
if (envelope.status === 'blocked')
|
|
803
|
+
return 1;
|
|
804
|
+
return 1;
|
|
805
|
+
}
|
|
662
806
|
//# sourceMappingURL=orchestrator-tools.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensure `.gitignore` covers Pugi's local persistence directories.
|
|
3
|
+
*
|
|
4
|
+
* Triple-review P1 (2026-06-04, ): commands that write under
|
|
5
|
+
* `.pugi/` must guarantee a `.gitignore` entry exists BEFORE the first
|
|
6
|
+
* write. Otherwise the first customer run of e.g. `pugi retro` in a
|
|
7
|
+
* fresh repo leaves `.pugi/retros/` and any future `.pugi/settings.json`
|
|
8
|
+
* (secret store) tracked by git on the next `git add -A`.
|
|
9
|
+
*
|
|
10
|
+
* Extracted from `runtime/cli.ts` so any command that mutates `.pugi/`
|
|
11
|
+
* can share the same guarantee without depending on the heavy cli.ts
|
|
12
|
+
* module graph.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
export const DEFAULT_PUGI_GITIGNORE_MARKERS = [
|
|
17
|
+
'.pugi/',
|
|
18
|
+
'.claude/worktrees/',
|
|
19
|
+
];
|
|
20
|
+
const EQUIVALENTS = {
|
|
21
|
+
'.pugi/': ['.pugi/', '/.pugi/', '.pugi'],
|
|
22
|
+
'.claude/worktrees/': ['.claude/worktrees/', '/.claude/worktrees/', '.claude/worktrees'],
|
|
23
|
+
};
|
|
24
|
+
export function ensurePugiGitIgnore(cwd, created, skipped, markers = DEFAULT_PUGI_GITIGNORE_MARKERS) {
|
|
25
|
+
const gitignorePath = resolve(cwd, '.gitignore');
|
|
26
|
+
if (!existsSync(gitignorePath)) {
|
|
27
|
+
writeFileSync(gitignorePath, `${markers.join('\n')}\n`, {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
mode: 0o600,
|
|
30
|
+
});
|
|
31
|
+
created.push(gitignorePath);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const current = readFileSync(gitignorePath, 'utf8');
|
|
35
|
+
const lines = current.split('\n').map((line) => line.trim());
|
|
36
|
+
const toAppend = [];
|
|
37
|
+
for (const marker of markers) {
|
|
38
|
+
const variants = EQUIVALENTS[marker] ?? [marker];
|
|
39
|
+
const present = variants.some((variant) => lines.includes(variant));
|
|
40
|
+
if (!present)
|
|
41
|
+
toAppend.push(marker);
|
|
42
|
+
}
|
|
43
|
+
if (toAppend.length === 0) {
|
|
44
|
+
skipped.push(gitignorePath);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const trailing = current.endsWith('\n') ? '' : '\n';
|
|
48
|
+
const next = `${current}${trailing}${toAppend.join('\n')}\n`;
|
|
49
|
+
writeFileSync(gitignorePath, next, { encoding: 'utf8' });
|
|
50
|
+
created.push(`${gitignorePath} (+${toAppend.join(', ')})`);
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=pugi-gitignore.js.map
|