gentle-pi 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -4
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +2 -1
- package/assets/orchestrator.md +6 -10
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/review-integration.md +1 -1
- package/extensions/gentle-ai.ts +530 -71
- package/lib/agent-home.ts +8 -0
- package/lib/native-review-cli.ts +15 -0
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +34 -5
- package/lib/sdd-preflight.ts +2 -2
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +15 -0
- package/runtime/review-integration-v2.mjs +34 -5
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +14 -13
- package/skills/_shared/review-ledger-contract.md +6 -0
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +46 -46
- package/tests/gentle-ai.test.ts +3 -2
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-review-capability-contract.test.ts +18 -1
- package/tests/orchestrator-budget.test.ts +2 -2
- package/tests/orchestrator-rdd-ownership.test.ts +1 -1
- package/tests/package-manifest.test.ts +123 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-contract-prompt.test.ts +139 -0
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +211 -9
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/runtime-harness.mjs +1 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Pi Subagents resolves its global directory as `PI_CODING_AGENT_DIR || ~/.pi/agent`,
|
|
5
|
+
// so an empty value must fall through here too or the two homes diverge again.
|
|
6
|
+
export function resolveGentlePiAgentHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
7
|
+
return env.GENTLE_PI_AGENT_HOME || env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
8
|
+
}
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -789,6 +789,21 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
789
789
|
// the columns match the 2.5.0-rc.3 row. riskEvidence and hint stay dark:
|
|
790
790
|
// still not proven to reach the negotiated START path Pi reads.
|
|
791
791
|
"2.5.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
792
|
+
// Ground-truthed against the published v2.6.0 binary installed from its
|
|
793
|
+
// signed archive: `review capabilities` on the v2 lane advertises
|
|
794
|
+
// capabilities/v2.5 (protocol minor 5) and answers status/v7, consent/v3,
|
|
795
|
+
// and the `start/v4` continuation, all of which the decoders already read.
|
|
796
|
+
// `review mode status --json` still answers `gentle-ai.rdd-mode-status/v1`
|
|
797
|
+
// and `review validate --gate pre-commit` still answers
|
|
798
|
+
// `gentle-ai.review-gate-result/v1` carrying `delivery`, both verified
|
|
799
|
+
// against the binary. Declining the consent/v3 prompt during `review start`
|
|
800
|
+
// still returns `risk_evidence` only on that blocking envelope, never on
|
|
801
|
+
// the negotiated `start/v4` continuation, so riskEvidence and hint stay
|
|
802
|
+
// dark for the same reason they have on every row since 2.2.0: still not
|
|
803
|
+
// proven to reach the negotiated START path Pi reads. The closed fields Pi
|
|
804
|
+
// consumes did not change between 2.5.0 and 2.6.0, so the columns match
|
|
805
|
+
// the 2.5.0 row.
|
|
806
|
+
"2.6.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
792
807
|
});
|
|
793
808
|
|
|
794
809
|
export interface NativeReviewProcessDiagnostics {
|
|
@@ -31,6 +31,8 @@ export const PI_RUNTIME_IDENTITY = "pi";
|
|
|
31
31
|
export const SUPPORTED_PROVIDER_CONTRACT_MAJOR = 1;
|
|
32
32
|
// `runtimes` became part of the manifest in contract 1.1.0.
|
|
33
33
|
export const RUNTIMES_REQUIRED_SINCE_MINOR = 1;
|
|
34
|
+
// `orchestration` became part of the manifest in contract 1.2.0.
|
|
35
|
+
export const ORCHESTRATION_REQUIRED_SINCE_MINOR = 2;
|
|
34
36
|
// Exactly the provider roles gentle-pi supports, in the manifest's strict order.
|
|
35
37
|
export const PROVIDER_CONTRACT_ROLE_IDS = ["lens", "refuter", "targeted-validator"] as const;
|
|
36
38
|
// Every mandatory capability a role may declare. An unknown mandatory
|
|
@@ -42,6 +44,13 @@ const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
|
42
44
|
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
|
|
43
45
|
const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
44
46
|
const BUNDLE_ENTRY_COUNT = 8;
|
|
47
|
+
// Generous fixed safety bound on raw archive/tree entries read BEFORE the
|
|
48
|
+
// manifest itself is parsed (the exact expected count, base entries plus one
|
|
49
|
+
// per `orchestration` runtime, is known only after manifest decode). Real
|
|
50
|
+
// contracts register a handful of runtimes; this bound only rules out an
|
|
51
|
+
// absurd smuggled inventory, it is not the exact-count check.
|
|
52
|
+
const MAX_ORCHESTRATION_ENTRIES = 64;
|
|
53
|
+
const MAX_RAW_BUNDLE_ENTRIES = BUNDLE_ENTRY_COUNT + MAX_ORCHESTRATION_ENTRIES;
|
|
45
54
|
// setuid/setgid/sticky, any execute bit, or world-writable content is never
|
|
46
55
|
// acceptable for a data-only contract bundle.
|
|
47
56
|
const FORBIDDEN_MODE_BITS = 0o7113;
|
|
@@ -84,7 +93,13 @@ export interface VerifiedProviderContractBundle {
|
|
|
84
93
|
/** Present exactly when the manifest declares the runtime registry (>= 1.1.0). */
|
|
85
94
|
readonly runtimes: readonly string[] | undefined;
|
|
86
95
|
readonly roles: readonly VerifiedProviderRole[];
|
|
87
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Verified runtime-bound review execution contract text, runtime identity
|
|
98
|
+
* -> UTF-8 `orchestration/<runtime>.md` contents. Empty before contract
|
|
99
|
+
* 1.2.0, where `orchestration` does not exist in the manifest.
|
|
100
|
+
*/
|
|
101
|
+
readonly orchestration: ReadonlyMap<string, string>;
|
|
102
|
+
/** Canonical path -> exact verified bytes for all entries (base eight plus one per orchestration runtime). */
|
|
88
103
|
readonly entries: ReadonlyMap<string, Buffer>;
|
|
89
104
|
/** Canonical path -> SHA-256 of the exact verified bytes. */
|
|
90
105
|
readonly entrySha256: ReadonlyMap<string, string>;
|
|
@@ -306,6 +321,23 @@ function parseManifestRole(value: unknown, index: number): ManifestRole {
|
|
|
306
321
|
};
|
|
307
322
|
}
|
|
308
323
|
|
|
324
|
+
interface ManifestOrchestrationEntry {
|
|
325
|
+
readonly runtime: string;
|
|
326
|
+
readonly file: ManifestFileReference;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseOrchestrationEntry(value: unknown, index: number): ManifestOrchestrationEntry {
|
|
330
|
+
const where = `manifest.orchestration[${index}]`;
|
|
331
|
+
const object = requireObject(value, where);
|
|
332
|
+
requireExactKeys(object, ["runtime", "file"], ["runtime", "file"], where);
|
|
333
|
+
const runtime = requireString(object.runtime, `${where}.runtime`);
|
|
334
|
+
if (!RUNTIME_IDENTITY_PATTERN.test(runtime)) invalid(`${where}.runtime ${JSON.stringify(runtime)} is not a runtime identity`);
|
|
335
|
+
const file = parseFileReference(object.file, `${where}.file`);
|
|
336
|
+
const expectedPath = `orchestration/${runtime}.md`;
|
|
337
|
+
if (file.path !== expectedPath) invalid(`${where}.file.path must be ${JSON.stringify(expectedPath)}`);
|
|
338
|
+
return { runtime, file };
|
|
339
|
+
}
|
|
340
|
+
|
|
309
341
|
interface ParsedManifest {
|
|
310
342
|
readonly contractSemver: string;
|
|
311
343
|
readonly major: number;
|
|
@@ -315,13 +347,14 @@ interface ParsedManifest {
|
|
|
315
347
|
readonly runtimes: readonly string[] | undefined;
|
|
316
348
|
readonly readme: ManifestFileReference;
|
|
317
349
|
readonly roles: readonly ManifestRole[];
|
|
350
|
+
readonly orchestration: readonly ManifestOrchestrationEntry[];
|
|
318
351
|
}
|
|
319
352
|
|
|
320
353
|
function parseManifest(payload: Buffer): ParsedManifest {
|
|
321
354
|
const object = decodeStrictManifestObject(payload);
|
|
322
355
|
requireExactKeys(
|
|
323
356
|
object,
|
|
324
|
-
["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles"],
|
|
357
|
+
["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles", "orchestration"],
|
|
325
358
|
["schema", "contract_semver", "transport_capability", "readme", "roles"],
|
|
326
359
|
"manifest",
|
|
327
360
|
);
|
|
@@ -365,6 +398,33 @@ function parseManifest(payload: Buffer): ParsedManifest {
|
|
|
365
398
|
invalid(`manifest.runtimes does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
366
399
|
}
|
|
367
400
|
|
|
401
|
+
const orchestrationRequired = minor >= ORCHESTRATION_REQUIRED_SINCE_MINOR;
|
|
402
|
+
let orchestration: readonly ManifestOrchestrationEntry[] = [];
|
|
403
|
+
if (Object.prototype.hasOwnProperty.call(object, "orchestration")) {
|
|
404
|
+
if (!orchestrationRequired) {
|
|
405
|
+
invalid(`manifest.orchestration is not part of contract ${contractSemver}; it exists from 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0`);
|
|
406
|
+
}
|
|
407
|
+
if (!Array.isArray(object.orchestration) || object.orchestration.length === 0) {
|
|
408
|
+
invalid("manifest.orchestration must be a non-empty array");
|
|
409
|
+
}
|
|
410
|
+
const parsedOrchestration = object.orchestration.map((entry, index) => parseOrchestrationEntry(entry, index));
|
|
411
|
+
for (let position = 0; position < parsedOrchestration.length; position += 1) {
|
|
412
|
+
const entry = parsedOrchestration[position] as ManifestOrchestrationEntry;
|
|
413
|
+
if (position > 0 && (parsedOrchestration[position - 1] as ManifestOrchestrationEntry).runtime >= entry.runtime) {
|
|
414
|
+
invalid("manifest.orchestration must be strictly sorted by runtime and unique");
|
|
415
|
+
}
|
|
416
|
+
if (runtimes === undefined || !runtimes.includes(entry.runtime)) {
|
|
417
|
+
invalid(`manifest.orchestration[${position}].runtime ${JSON.stringify(entry.runtime)} is not registered in manifest.runtimes`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!parsedOrchestration.some((entry) => entry.runtime === PI_RUNTIME_IDENTITY)) {
|
|
421
|
+
invalid(`manifest.orchestration does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
422
|
+
}
|
|
423
|
+
orchestration = parsedOrchestration;
|
|
424
|
+
} else if (orchestrationRequired) {
|
|
425
|
+
invalid(`manifest.orchestration is required from contract 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0 (got ${contractSemver} without it)`);
|
|
426
|
+
}
|
|
427
|
+
|
|
368
428
|
if (!Array.isArray(object.roles)) invalid("manifest.roles must be an array");
|
|
369
429
|
const roles = object.roles.map((role, index) => parseManifestRole(role, index));
|
|
370
430
|
if (roles.length !== PROVIDER_CONTRACT_ROLE_IDS.length) {
|
|
@@ -385,6 +445,7 @@ function parseManifest(payload: Buffer): ParsedManifest {
|
|
|
385
445
|
runtimes,
|
|
386
446
|
readme: parseFileReference(object.readme, "manifest.readme"),
|
|
387
447
|
roles,
|
|
448
|
+
orchestration,
|
|
388
449
|
};
|
|
389
450
|
}
|
|
390
451
|
|
|
@@ -467,8 +528,8 @@ export function bundleTreeSha256(entrySha256: ReadonlyMap<string, string>): stri
|
|
|
467
528
|
|
|
468
529
|
/** Verifies a complete in-memory bundle inventory. The single trust anchor. */
|
|
469
530
|
export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<string, Buffer>): VerifiedProviderContractBundle {
|
|
470
|
-
if (rawEntries.size
|
|
471
|
-
invalid(`bundle inventory has ${rawEntries.size} files;
|
|
531
|
+
if (rawEntries.size > MAX_RAW_BUNDLE_ENTRIES) {
|
|
532
|
+
invalid(`bundle inventory has ${rawEntries.size} files; at most ${MAX_RAW_BUNDLE_ENTRIES} are ever expected`);
|
|
472
533
|
}
|
|
473
534
|
for (const [name, payload] of rawEntries) {
|
|
474
535
|
if (!isCanonicalBundlePath(name)) invalid(`bundle path ${JSON.stringify(name)} is unsafe`);
|
|
@@ -478,6 +539,14 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
478
539
|
if (manifestPayload === undefined) invalid("bundle is missing manifest.json");
|
|
479
540
|
const manifest = parseManifest(manifestPayload);
|
|
480
541
|
|
|
542
|
+
// The exact expected inventory size is only known once the manifest is
|
|
543
|
+
// decoded: base entries plus exactly one `orchestration/<runtime>.md` per
|
|
544
|
+
// registered orchestration runtime.
|
|
545
|
+
const expectedEntryCount = BUNDLE_ENTRY_COUNT + manifest.orchestration.length;
|
|
546
|
+
if (rawEntries.size !== expectedEntryCount) {
|
|
547
|
+
invalid(`bundle inventory has ${rawEntries.size} files; exactly ${expectedEntryCount} are expected`);
|
|
548
|
+
}
|
|
549
|
+
|
|
481
550
|
verifyFileReference(rawEntries, manifest.readme, "README.md");
|
|
482
551
|
const expected = new Set(["README.md", "manifest.json"]);
|
|
483
552
|
for (const role of manifest.roles) {
|
|
@@ -485,6 +554,13 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
485
554
|
expected.add(`schemas/${role.id}.schema.json`);
|
|
486
555
|
expected.add(`vectors/${role.id}.json`);
|
|
487
556
|
}
|
|
557
|
+
const orchestration = new Map<string, string>();
|
|
558
|
+
for (const entry of manifest.orchestration) {
|
|
559
|
+
const path = `orchestration/${entry.runtime}.md`;
|
|
560
|
+
const payload = verifyFileReference(rawEntries, entry.file, path);
|
|
561
|
+
orchestration.set(entry.runtime, payload.toString("utf8"));
|
|
562
|
+
expected.add(path);
|
|
563
|
+
}
|
|
488
564
|
for (const name of rawEntries.keys()) {
|
|
489
565
|
if (!expected.has(name)) invalid(`bundle has unexpected file ${JSON.stringify(name)}`);
|
|
490
566
|
}
|
|
@@ -514,6 +590,7 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
514
590
|
vectorPath: role.vector.path,
|
|
515
591
|
vectorSha256: role.vector.sha256,
|
|
516
592
|
})),
|
|
593
|
+
orchestration,
|
|
517
594
|
entries,
|
|
518
595
|
entrySha256,
|
|
519
596
|
treeSha256: bundleTreeSha256(entrySha256),
|
|
@@ -566,7 +643,7 @@ export function verifyProviderContractBundleTree(directory: string): VerifiedPro
|
|
|
566
643
|
}
|
|
567
644
|
if (!isCanonicalBundlePath(relativeName)) invalid(`bundle tree path ${JSON.stringify(relativeName)} is unsafe`);
|
|
568
645
|
if (details.size > MAX_FILE_BYTES) invalid(`bundle tree entry ${JSON.stringify(relativeName)} exceeds its size limit`);
|
|
569
|
-
if (entries.size >=
|
|
646
|
+
if (entries.size >= MAX_RAW_BUNDLE_ENTRIES) invalid(`bundle tree has more than ${MAX_RAW_BUNDLE_ENTRIES} files`);
|
|
570
647
|
entries.set(relativeName, readFileSync(absolute));
|
|
571
648
|
}
|
|
572
649
|
};
|
|
@@ -628,7 +705,7 @@ function readTarEntries(expanded: Buffer): Map<string, Buffer> {
|
|
|
628
705
|
if (size > MAX_FILE_BYTES) invalid(`tar entry ${JSON.stringify(name)} exceeds its size limit`);
|
|
629
706
|
totalBytes += size;
|
|
630
707
|
if (totalBytes > MAX_BUNDLE_BYTES) invalid("tar entries exceed the bundle size limit");
|
|
631
|
-
if (entries.size >=
|
|
708
|
+
if (entries.size >= MAX_RAW_BUNDLE_ENTRIES) invalid(`tar has more than ${MAX_RAW_BUNDLE_ENTRIES} entries`);
|
|
632
709
|
const padding = (TAR_BLOCK_SIZE - (size % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE;
|
|
633
710
|
if (expanded.length - offset < size + padding) invalid("tar stream ends before a complete entry");
|
|
634
711
|
entries.set(name, Buffer.from(expanded.subarray(offset, offset + size)));
|
|
@@ -696,6 +773,11 @@ export function generateProviderContractBaselines(bundle: VerifiedProviderContra
|
|
|
696
773
|
mandatory_capabilities: mandatory,
|
|
697
774
|
runtimes: bundle.runtimes === undefined ? null : [...bundle.runtimes],
|
|
698
775
|
pi_registered: piRuntimeRegistration(bundle).registered,
|
|
776
|
+
orchestration: [...bundle.orchestration.keys()].sort().map((runtime) => ({
|
|
777
|
+
runtime,
|
|
778
|
+
path: `orchestration/${runtime}.md`,
|
|
779
|
+
sha256: bundle.entrySha256.get(`orchestration/${runtime}.md`),
|
|
780
|
+
})),
|
|
699
781
|
};
|
|
700
782
|
return new Map([
|
|
701
783
|
[PROVIDER_ROLES_BASELINE_FILE, `${JSON.stringify(roles, null, 2)}\n`],
|
package/lib/review-host-relay.ts
CHANGED
|
@@ -284,7 +284,19 @@ export interface ReviewHostRelayResult {
|
|
|
284
284
|
readonly submission: string;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/** Opaque materialize-and-review result, not yet submitted to the provider. */
|
|
288
|
+
export interface ReviewHostRelayPreparedResult {
|
|
289
|
+
/** Copy-safe request snapshot captured before materialization starts. */
|
|
290
|
+
readonly request: ReviewHostRelayRequest;
|
|
291
|
+
readonly promptByteLength: number;
|
|
292
|
+
readonly resultByteLength: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const preparedResultBytes = new WeakMap<ReviewHostRelayPreparedResult, Buffer>();
|
|
296
|
+
|
|
287
297
|
export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayResult>;
|
|
298
|
+
export type ReviewHostRelayPreparationRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayPreparedResult>;
|
|
299
|
+
export type ReviewHostRelaySubmissionRunner = (prepared: ReviewHostRelayPreparedResult) => Promise<ReviewHostRelayResult>;
|
|
288
300
|
|
|
289
301
|
const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
|
|
290
302
|
|
|
@@ -442,38 +454,54 @@ function assertTokens(name: string, tokens: readonly string[]): void {
|
|
|
442
454
|
}
|
|
443
455
|
}
|
|
444
456
|
|
|
457
|
+
function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): ReviewHostRelayRequest {
|
|
458
|
+
assertTokens("capture", request.captureArgumentTokens);
|
|
459
|
+
// The completing form is validated before any process launches: a materialize
|
|
460
|
+
// slot without a provider-owned submission is a typed contract mismatch,
|
|
461
|
+
// never a synthesized invocation.
|
|
462
|
+
resolveReviewHostRelaySubmission(request.submission);
|
|
463
|
+
const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary();
|
|
464
|
+
if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
|
|
465
|
+
const environment = Object.freeze({ ...(request.environment ?? process.env) }) as NodeJS.ProcessEnv;
|
|
466
|
+
const submission = request.submission === undefined ? undefined : Object.freeze({
|
|
467
|
+
operationToken: request.submission.operationToken,
|
|
468
|
+
argumentTokens: Object.freeze([...request.submission.argumentTokens]),
|
|
469
|
+
values: Object.freeze(request.submission.values.map((value) => Object.freeze({ ...value }))),
|
|
470
|
+
});
|
|
471
|
+
return Object.freeze({
|
|
472
|
+
...request,
|
|
473
|
+
captureArgumentTokens: Object.freeze([...request.captureArgumentTokens]),
|
|
474
|
+
...(submission === undefined ? {} : { submission }),
|
|
475
|
+
gentleAiExecutable,
|
|
476
|
+
environment,
|
|
477
|
+
gentleAiTimeoutMs: request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS,
|
|
478
|
+
targetCwd: request.targetCwd ?? process.cwd(),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
445
482
|
/**
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
* a failure; the caller re-queries negotiated STATUS instead of retrying.
|
|
483
|
+
* Materializes one provider-bound reviewer prompt and runs its opaque Pi
|
|
484
|
+
* subprocess. It does not submit anything, so independent reviewer work can
|
|
485
|
+
* finish before the caller performs provider-ordered admission.
|
|
450
486
|
*/
|
|
451
|
-
export async function
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
const gentleAiEnvironment = { ...baseEnvironment, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
|
|
463
|
-
const gentleAiTimeoutMs = request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS;
|
|
464
|
-
const targetCwd = request.targetCwd ?? process.cwd();
|
|
465
|
-
|
|
466
|
-
// (a) Materialize the Go-issued opaque prompt. This invocation is also the
|
|
467
|
-
// capability detection: an old binary's unknown-flag refusal proves the
|
|
468
|
-
// relay surface is absent, and the provider's handshake refusal surfaces
|
|
469
|
-
// verbatim. No version sniffing.
|
|
487
|
+
export async function prepareReviewHostRelaySlot(
|
|
488
|
+
request: ReviewHostRelayRequest,
|
|
489
|
+
reviewer: typeof runOpaquePiReviewer = runOpaquePiReviewer,
|
|
490
|
+
): Promise<ReviewHostRelayPreparedResult> {
|
|
491
|
+
// Copy mutable transport configuration before the first async boundary. The
|
|
492
|
+
// supplied AbortSignal intentionally stays live across materialize, reviewer,
|
|
493
|
+
// and submit, preserving the established cancellation behavior.
|
|
494
|
+
const preparedRequest = snapshotReviewHostRelayRequest(request);
|
|
495
|
+
|
|
496
|
+
// The provider materializes the opaque prompt and detects whether this relay
|
|
497
|
+
// surface is available. No version sniffing or prompt reconstruction occurs.
|
|
470
498
|
let materialized: ProcessCapture;
|
|
471
499
|
try {
|
|
472
|
-
materialized = await collectGentleAiProcess(
|
|
473
|
-
cwd: targetCwd
|
|
474
|
-
env:
|
|
475
|
-
timeoutMs: gentleAiTimeoutMs
|
|
476
|
-
...(
|
|
500
|
+
materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review", "capture-result", ...preparedRequest.captureArgumentTokens], {
|
|
501
|
+
cwd: preparedRequest.targetCwd!,
|
|
502
|
+
env: { ...preparedRequest.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT },
|
|
503
|
+
timeoutMs: preparedRequest.gentleAiTimeoutMs!,
|
|
504
|
+
...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
|
|
477
505
|
});
|
|
478
506
|
} catch (error) {
|
|
479
507
|
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", `gentle-ai prompt materialization could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -481,45 +509,91 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
481
509
|
if (materialized.exitCode !== 0 || materialized.timedOut) {
|
|
482
510
|
const stderr = materialized.stderr.toString("utf8");
|
|
483
511
|
const refusal = classifyReviewHostRelayRefusal(stderr);
|
|
484
|
-
const timing = { elapsedMs: materialized.elapsedMs, timeoutMs: gentleAiTimeoutMs };
|
|
512
|
+
const timing = { elapsedMs: materialized.elapsedMs, timeoutMs: preparedRequest.gentleAiTimeoutMs! };
|
|
485
513
|
if (refusal === "unknown-flag") {
|
|
486
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, {
|
|
514
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, {
|
|
515
|
+
exitCode: materialized.exitCode,
|
|
516
|
+
stderr,
|
|
517
|
+
timedOut: materialized.timedOut,
|
|
518
|
+
...timing,
|
|
519
|
+
});
|
|
487
520
|
}
|
|
488
521
|
if (refusal === "handshake") {
|
|
489
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, {
|
|
522
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, {
|
|
523
|
+
exitCode: materialized.exitCode,
|
|
524
|
+
stderr,
|
|
525
|
+
timedOut: materialized.timedOut,
|
|
526
|
+
...timing,
|
|
527
|
+
});
|
|
490
528
|
}
|
|
491
529
|
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", materialized.timedOut
|
|
492
|
-
? `gentle-ai prompt materialization exceeded its ${gentleAiTimeoutMs}ms bound after ${materialized.elapsedMs}ms`
|
|
530
|
+
? `gentle-ai prompt materialization exceeded its ${preparedRequest.gentleAiTimeoutMs!}ms bound after ${materialized.elapsedMs}ms`
|
|
493
531
|
: "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
|
|
494
532
|
}
|
|
495
533
|
const promptBytes = materialized.stdout;
|
|
496
534
|
if (promptBytes.length === 0) {
|
|
497
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", {
|
|
535
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", {
|
|
536
|
+
exitCode: 0,
|
|
537
|
+
stderr: materialized.stderr.toString("utf8"),
|
|
538
|
+
elapsedMs: materialized.elapsedMs,
|
|
539
|
+
timeoutMs: preparedRequest.gentleAiTimeoutMs!,
|
|
540
|
+
});
|
|
498
541
|
}
|
|
499
542
|
// The reviewer bound is derived from the prompt the provider actually
|
|
500
|
-
// materialized
|
|
501
|
-
//
|
|
502
|
-
const piTimeoutMs =
|
|
543
|
+
// materialized. The explicit request timeout is a test seam that wins over
|
|
544
|
+
// both the user-owned environment override and the scale-derived bound.
|
|
545
|
+
const piTimeoutMs = preparedRequest.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, preparedRequest.environment);
|
|
503
546
|
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// the established relay boundary.
|
|
547
|
+
// The pure adapter owns the fresh isolated Pi process. Its input and output
|
|
548
|
+
// are opaque bytes; this coordinator only maps transport failures.
|
|
507
549
|
let piResult: OpaquePiReviewerResult;
|
|
508
550
|
try {
|
|
509
|
-
piResult = await
|
|
510
|
-
...(
|
|
511
|
-
environment:
|
|
551
|
+
piResult = await reviewer(promptBytes, {
|
|
552
|
+
...(preparedRequest.piExecutable === undefined ? {} : { piExecutable: preparedRequest.piExecutable }),
|
|
553
|
+
environment: preparedRequest.environment,
|
|
512
554
|
timeoutMs: piTimeoutMs,
|
|
513
|
-
...(
|
|
555
|
+
...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
|
|
514
556
|
});
|
|
515
557
|
} catch (error) {
|
|
516
558
|
throw relayPiTransportError(error, promptBytes.length, piTimeoutMs);
|
|
517
559
|
}
|
|
518
|
-
const
|
|
560
|
+
const prepared = Object.freeze({
|
|
561
|
+
request: preparedRequest,
|
|
562
|
+
promptByteLength: promptBytes.length,
|
|
563
|
+
resultByteLength: piResult.stdoutByteLength,
|
|
564
|
+
});
|
|
565
|
+
preparedResultBytes.set(prepared, Buffer.from(piResult.stdout));
|
|
566
|
+
return prepared;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Starts every reviewer before awaiting any result. If one or more reviewers
|
|
571
|
+
* fail, it rejects only after every started transport has settled and reports
|
|
572
|
+
* the earliest failed request in provider order.
|
|
573
|
+
*/
|
|
574
|
+
export async function runReviewHostRelayReviewerGroup(
|
|
575
|
+
requests: readonly ReviewHostRelayRequest[],
|
|
576
|
+
prepare: ReviewHostRelayPreparationRunner = prepareReviewHostRelaySlot,
|
|
577
|
+
): Promise<readonly ReviewHostRelayPreparedResult[]> {
|
|
578
|
+
if (requests.length === 0) {
|
|
579
|
+
throw new TypeError("Pi host relay reviewer group requires at least one provider-bound request");
|
|
580
|
+
}
|
|
581
|
+
const settled = await Promise.allSettled(requests.map(async (request) => await prepare(request)));
|
|
582
|
+
const failed = settled.find((result) => result.status === "rejected");
|
|
583
|
+
if (failed?.status === "rejected") throw failed.reason;
|
|
584
|
+
return settled.map((result) => (result as PromiseFulfilledResult<ReviewHostRelayPreparedResult>).value);
|
|
585
|
+
}
|
|
519
586
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
587
|
+
/**
|
|
588
|
+
* Submits one already-reviewed opaque result through the exact provider-owned
|
|
589
|
+
* completing form. Only the provider-declared artifact slot is substituted.
|
|
590
|
+
*/
|
|
591
|
+
export async function submitReviewHostRelayPreparedResult(prepared: ReviewHostRelayPreparedResult): Promise<ReviewHostRelayResult> {
|
|
592
|
+
const resultBytes = preparedResultBytes.get(prepared);
|
|
593
|
+
if (resultBytes === undefined) throw new TypeError("Pi host relay requires a recognized prepared result");
|
|
594
|
+
const { request } = prepared;
|
|
595
|
+
assertTokens("capture", request.captureArgumentTokens);
|
|
596
|
+
const submissionBinding = resolveReviewHostRelaySubmission(request.submission);
|
|
523
597
|
const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-"));
|
|
524
598
|
let primaryFailure = false;
|
|
525
599
|
try {
|
|
@@ -528,14 +602,16 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
528
602
|
await writeFile(resultFile, resultBytes, { mode: 0o600 });
|
|
529
603
|
await chmod(resultFile, 0o600);
|
|
530
604
|
const submitTokens = submissionBinding.argumentTokens.map((token, index) =>
|
|
531
|
-
index === submissionBinding.substitutionLocation
|
|
605
|
+
index === submissionBinding.substitutionLocation
|
|
606
|
+
? token.split(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT).join(resultFile)
|
|
607
|
+
: token,
|
|
532
608
|
);
|
|
533
609
|
let submission: ProcessCapture;
|
|
534
610
|
try {
|
|
535
|
-
submission = await collectGentleAiProcess(
|
|
536
|
-
cwd: targetCwd
|
|
537
|
-
env:
|
|
538
|
-
timeoutMs: gentleAiTimeoutMs
|
|
611
|
+
submission = await collectGentleAiProcess(request.gentleAiExecutable!, ["review", submissionBinding.operationToken, ...submitTokens], {
|
|
612
|
+
cwd: request.targetCwd!,
|
|
613
|
+
env: { ...request.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT },
|
|
614
|
+
timeoutMs: request.gentleAiTimeoutMs!,
|
|
539
615
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
540
616
|
});
|
|
541
617
|
} catch (error) {
|
|
@@ -543,20 +619,22 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
543
619
|
}
|
|
544
620
|
if (submission.exitCode !== 0 || submission.timedOut || submission.stdout.length === 0) {
|
|
545
621
|
const stderr = submission.stderr.toString("utf8");
|
|
546
|
-
const details = { exitCode: submission.exitCode, stderr, timedOut: submission.timedOut, elapsedMs: submission.elapsedMs, timeoutMs: gentleAiTimeoutMs };
|
|
547
|
-
// A typed admission refusal
|
|
548
|
-
//
|
|
549
|
-
// signal, untyped exit) stays unknown pending STATUS.
|
|
622
|
+
const details = { exitCode: submission.exitCode, stderr, timedOut: submission.timedOut, elapsedMs: submission.elapsedMs, timeoutMs: request.gentleAiTimeoutMs! };
|
|
623
|
+
// A typed admission refusal proves the provider consumed no slot.
|
|
624
|
+
// Every other launched submission stays unknown pending fresh STATUS.
|
|
550
625
|
if (isReviewHostRelayAdmissionRefusal(submission, stderr)) {
|
|
551
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", stderr.trim(), {
|
|
626
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", stderr.trim(), {
|
|
627
|
+
...details,
|
|
628
|
+
mutationOutcome: "none",
|
|
629
|
+
});
|
|
552
630
|
}
|
|
553
631
|
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", submission.timedOut
|
|
554
|
-
? `gentle-ai capture submission exceeded its ${gentleAiTimeoutMs}ms bound after ${submission.elapsedMs}ms`
|
|
632
|
+
? `gentle-ai capture submission exceeded its ${request.gentleAiTimeoutMs!}ms bound after ${submission.elapsedMs}ms`
|
|
555
633
|
: "gentle-ai refused the relayed capture submission", details);
|
|
556
634
|
}
|
|
557
635
|
return {
|
|
558
|
-
promptByteLength:
|
|
559
|
-
resultByteLength:
|
|
636
|
+
promptByteLength: prepared.promptByteLength,
|
|
637
|
+
resultByteLength: prepared.resultByteLength,
|
|
560
638
|
submission: submission.stdout.toString("utf8"),
|
|
561
639
|
};
|
|
562
640
|
} catch (error) {
|
|
@@ -576,3 +654,11 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
576
654
|
}
|
|
577
655
|
}
|
|
578
656
|
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Compatibility one-binding path: materialize → opaque Pi adapter → submit.
|
|
660
|
+
* It preserves the established API and its typed failure behavior exactly.
|
|
661
|
+
*/
|
|
662
|
+
export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise<ReviewHostRelayResult> {
|
|
663
|
+
return await submitReviewHostRelayPreparedResult(await prepareReviewHostRelaySlot(request));
|
|
664
|
+
}
|
|
@@ -183,6 +183,19 @@ const CAPABILITIES_SCHEMA_IDENTITIES: Readonly<Record<string, { protocolMinor: n
|
|
|
183
183
|
requiredMandatoryFeatures: REQUIRED_MANDATORY_FEATURES_V23,
|
|
184
184
|
optionalFeatureFloor: 14,
|
|
185
185
|
}),
|
|
186
|
+
// Ground-truthed against the published v2.6.0 binary: capabilities/v2.5
|
|
187
|
+
// advertises the same required surface as v2.4 (status/v6 is still
|
|
188
|
+
// advertised for compatibility) plus the new status/v7 schema, which is a
|
|
189
|
+
// superset-checked addition, not a requirement -- decodeReviewStatusV3
|
|
190
|
+
// accepts v7 as an additive extension of v6, so the required-schema floor
|
|
191
|
+
// stays unchanged. The v2.6.0 binary advertised 15 optional features
|
|
192
|
+
// (floor stays at 14, its established minimum).
|
|
193
|
+
"gentle-ai.review-integration.capabilities/v2.5": Object.freeze({
|
|
194
|
+
protocolMinor: 5,
|
|
195
|
+
requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON_V23, "gentle-ai.review-integration.capabilities/v2.5", "gentle-ai.review-integration.consent/v3", "gentle-ai.review-integration.start/v4", "gentle-ai.review-integration.status/v6", "gentle-ai.review-intended-untracked-selection/v1"]),
|
|
196
|
+
requiredMandatoryFeatures: REQUIRED_MANDATORY_FEATURES_V23,
|
|
197
|
+
optionalFeatureFloor: 14,
|
|
198
|
+
}),
|
|
186
199
|
});
|
|
187
200
|
const OPTIONAL_FEATURE_NAMES = Object.freeze([
|
|
188
201
|
"base_ref_workspace_overlay",
|
|
@@ -569,6 +582,8 @@ export interface ReviewStatusV3 {
|
|
|
569
582
|
repositoryContext?: ReviewRepositoryContextV2;
|
|
570
583
|
/** status/v5 only: the provider-owned request mirrored by the targeted-validator collect input. */
|
|
571
584
|
validationRequest?: ReviewTargetedValidationRequestV1;
|
|
585
|
+
/** status/v7 only: the eligible untracked-path inventory digest, absent on the `staged` projection. */
|
|
586
|
+
eligibleUntrackedInventory?: string;
|
|
572
587
|
raw: Readonly<Record<string, unknown>>;
|
|
573
588
|
}
|
|
574
589
|
|
|
@@ -1761,15 +1776,19 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
|
|
|
1761
1776
|
// Additive forward acceptance: status/v5 (gentle-ai main; ground-truthed
|
|
1762
1777
|
// against a live capture and the vendored status-v5.schema.json) is the v3
|
|
1763
1778
|
// key set plus the optional forecast and the v5-only next_transition
|
|
1764
|
-
// surfaces. status/v6 adds the intended-untracked selection;
|
|
1765
|
-
//
|
|
1779
|
+
// surfaces. status/v6 adds the intended-untracked selection; status/v7
|
|
1780
|
+
// (gentle-ai v2.6.0, advertised through capabilities/v2.5 alongside v6)
|
|
1781
|
+
// adds only the top-level optional `eligible_untracked_inventory` digest,
|
|
1782
|
+
// so it is decoded on the v6 surface. v3 keeps rejecting every v5/v6/v7-only
|
|
1783
|
+
// field.
|
|
1766
1784
|
const schema = typeof value === "object" && value !== null ? (value as Record<string, unknown>).schema : undefined;
|
|
1767
|
-
const
|
|
1785
|
+
const v7 = schema === "gentle-ai.review-integration.status/v7";
|
|
1786
|
+
const v6 = v7 || schema === "gentle-ai.review-integration.status/v6";
|
|
1768
1787
|
const v5 = v6 || schema === "gentle-ai.review-integration.status/v5";
|
|
1769
1788
|
const body = exactRecord(value, "status", [
|
|
1770
1789
|
"schema", "contract", "operation", "applicability", "action", "replayability", "target_identity", "projection", "repair", "candidates",
|
|
1771
|
-
], ["authority", "frozen", "action_disposition", "eligibility", "next_transition", "authority_target_identity", ...(v5 ? ["receipt", "forecast", "repository_context", "validation_request"] : [])]);
|
|
1772
|
-
requireIdentity(body, v6 ? "gentle-ai.review-integration.status/v6" : v5 ? "gentle-ai.review-integration.status/v5" : "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS);
|
|
1790
|
+
], ["authority", "frozen", "action_disposition", "eligibility", "next_transition", "authority_target_identity", ...(v5 ? ["receipt", "forecast", "repository_context", "validation_request"] : []), ...(v7 ? ["eligible_untracked_inventory"] : [])]);
|
|
1791
|
+
requireIdentity(body, v7 ? "gentle-ai.review-integration.status/v7" : v6 ? "gentle-ai.review-integration.status/v6" : v5 ? "gentle-ai.review-integration.status/v5" : "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS);
|
|
1773
1792
|
|
|
1774
1793
|
const applicability = enumeration(body.applicability, ["current_target", "unrelated", "ambiguous", "corrupted"] as const, "status.applicability");
|
|
1775
1794
|
let receipt: ReviewStatusReceiptV1 | undefined;
|
|
@@ -1851,6 +1870,15 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
|
|
|
1851
1870
|
};
|
|
1852
1871
|
}
|
|
1853
1872
|
|
|
1873
|
+
// status/v7 top-level optional digest (gentle-ai v2.6.0): resolves #4066's
|
|
1874
|
+
// closed loop where `sdd-attempt finish` named a digest status never
|
|
1875
|
+
// published. Absent on the `staged` projection, which never resolves an
|
|
1876
|
+
// inventory, so it stays structurally optional rather than a required v7
|
|
1877
|
+
// field.
|
|
1878
|
+
const eligibleUntrackedInventory = v7 && body.eligible_untracked_inventory !== undefined
|
|
1879
|
+
? sha256(body.eligible_untracked_inventory, "status.eligible_untracked_inventory")
|
|
1880
|
+
: undefined;
|
|
1881
|
+
|
|
1854
1882
|
return {
|
|
1855
1883
|
contract: REVIEW_INTEGRATION_CONTRACT,
|
|
1856
1884
|
applicability,
|
|
@@ -1869,6 +1897,7 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
|
|
|
1869
1897
|
...(forecast === undefined ? {} : { forecast }),
|
|
1870
1898
|
...(repositoryContext === undefined ? {} : { repositoryContext }),
|
|
1871
1899
|
...(validationRequest === undefined ? {} : { validationRequest }),
|
|
1900
|
+
...(eligibleUntrackedInventory === undefined ? {} : { eligibleUntrackedInventory }),
|
|
1872
1901
|
raw: body,
|
|
1873
1902
|
};
|
|
1874
1903
|
}
|
package/lib/sdd-preflight.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { resolveGentlePiAgentHome } from "./agent-home.ts";
|
|
7
7
|
import type { SddArtifactStore } from "./sdd-status.ts";
|
|
8
8
|
|
|
9
9
|
export type { SddArtifactStore };
|
|
@@ -19,7 +19,7 @@ const LEGACY_MANAGED_ASSET_MANIFESTS = Object.freeze([
|
|
|
19
19
|
]);
|
|
20
20
|
|
|
21
21
|
function gentlePiAgentHome(): string {
|
|
22
|
-
return
|
|
22
|
+
return resolveGentlePiAgentHome();
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export type SddExecutionMode = "interactive" | "auto";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -790,6 +790,21 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
790
790
|
// the columns match the 2.5.0-rc.3 row. riskEvidence and hint stay dark:
|
|
791
791
|
// still not proven to reach the negotiated START path Pi reads.
|
|
792
792
|
"2.5.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
793
|
+
// Ground-truthed against the published v2.6.0 binary installed from its
|
|
794
|
+
// signed archive: `review capabilities` on the v2 lane advertises
|
|
795
|
+
// capabilities/v2.5 (protocol minor 5) and answers status/v7, consent/v3,
|
|
796
|
+
// and the `start/v4` continuation, all of which the decoders already read.
|
|
797
|
+
// `review mode status --json` still answers `gentle-ai.rdd-mode-status/v1`
|
|
798
|
+
// and `review validate --gate pre-commit` still answers
|
|
799
|
+
// `gentle-ai.review-gate-result/v1` carrying `delivery`, both verified
|
|
800
|
+
// against the binary. Declining the consent/v3 prompt during `review start`
|
|
801
|
+
// still returns `risk_evidence` only on that blocking envelope, never on
|
|
802
|
+
// the negotiated `start/v4` continuation, so riskEvidence and hint stay
|
|
803
|
+
// dark for the same reason they have on every row since 2.2.0: still not
|
|
804
|
+
// proven to reach the negotiated START path Pi reads. The closed fields Pi
|
|
805
|
+
// consumes did not change between 2.5.0 and 2.6.0, so the columns match
|
|
806
|
+
// the 2.5.0 row.
|
|
807
|
+
"2.6.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
793
808
|
});
|
|
794
809
|
|
|
795
810
|
|