gentle-pi 3.1.0 → 3.2.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.
Files changed (35) hide show
  1. package/assets/orchestrator-delegation.md +6 -4
  2. package/assets/orchestrator-memory.md +1 -1
  3. package/assets/sdd-orchestrator-workflow.md +1 -1
  4. package/docs/gentle-shell.md +1 -1
  5. package/docs/readme-reference.md +43 -18
  6. package/extensions/gentle-agents.ts +22 -1
  7. package/extensions/gentle-ai.ts +33 -138
  8. package/extensions/pi-pretty.ts +13 -1
  9. package/lib/background-subagents-policy.ts +148 -0
  10. package/lib/native-review-cli.ts +9 -0
  11. package/lib/review-candidate-view.ts +27 -6
  12. package/lib/sdd-preflight.ts +5 -19
  13. package/package.json +1 -1
  14. package/runtime/native-review-cli.mjs +9 -0
  15. package/scripts/gentle-ai-installer.mjs +10 -10
  16. package/scripts/verify-package-files.mjs +2 -2
  17. package/skills/chained-pr/SKILL.md +2 -1
  18. package/skills/work-unit-commits/SKILL.md +9 -0
  19. package/tests/background-subagents-default-mode.test.ts +105 -0
  20. package/tests/gentle-agents.test.ts +14 -1
  21. package/tests/gentle-ai-binary.test.ts +1 -1
  22. package/tests/gentle-ai-installer.test.ts +47 -47
  23. package/tests/gentle-theme.test.ts +2 -0
  24. package/tests/native-review-capability-contract.test.ts +14 -1
  25. package/tests/odd-routing-contract.test.ts +49 -2
  26. package/tests/package-manifest.test.ts +6 -6
  27. package/tests/pi-pretty.test.ts +5 -0
  28. package/tests/review-base-ref-hint.test.ts +39 -0
  29. package/tests/review-candidate-view.test.ts +55 -0
  30. package/tests/runtime-harness.mjs +12 -10
  31. package/tests/sdd-managed-runtime-settlement.test.ts +2 -2
  32. package/tests/sdd-preflight-rpc-input.test.ts +29 -29
  33. package/tests/sdd-preflight.test.ts +12 -7
  34. package/tests/windows-hidden-processes.test.ts +2 -2
  35. package/themes/Gentleman-Cute.json +1 -1
@@ -0,0 +1,148 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { gentlePiConfigHome } from "./agent-home.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Background subagents policy — project > global > env > default off
7
+ //
8
+ // Pure resolver, extracted from extensions/gentle-ai.ts so the runtime side
9
+ // (extensions/gentle-agents.ts) can read the effective policy without
10
+ // importing the pi extension surface. No pi imports belong in this file.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type BackgroundSubagentsPolicy = "on" | "off";
14
+
15
+ /** Which of the four sources decided the effective policy. */
16
+ export type BackgroundSubagentsSource =
17
+ | "project_file"
18
+ | "global_file"
19
+ | "environment"
20
+ | "default";
21
+
22
+ export interface BackgroundSubagentsResolution {
23
+ policy: BackgroundSubagentsPolicy;
24
+ source: BackgroundSubagentsSource;
25
+ /** The deciding file was present but failed the strict decode. */
26
+ malformed: boolean;
27
+ projectFile: string;
28
+ globalFile: string;
29
+ projectFileExists: boolean;
30
+ globalFileExists: boolean;
31
+ /** The raw env value, reported even when it is unrecognized and inert. */
32
+ envValue: string | undefined;
33
+ }
34
+
35
+ export interface LoadBackgroundSubagentsOptions {
36
+ /** Override the config home directory (used in tests to avoid touching ~/.pi). */
37
+ gentlePiConfigHome?: string;
38
+ /** Override the environment lookup (used in tests). */
39
+ env?: Record<string, string | undefined>;
40
+ }
41
+
42
+ export const BACKGROUND_SUBAGENTS_SCHEMA = "gentle-pi.background-subagents/v1";
43
+ export const BACKGROUND_SUBAGENTS_FILE = "background-subagents.json";
44
+
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ /**
50
+ * Strict decode of {"schema":"gentle-pi.background-subagents/v1","policy":"on"|"off"}.
51
+ * Any malformed shape (bad JSON, wrong schema, unknown keys, invalid policy)
52
+ * returns undefined so the caller fails closed to "off".
53
+ */
54
+ export function parseBackgroundSubagentsPolicyFile(
55
+ raw: string,
56
+ ): BackgroundSubagentsPolicy | undefined {
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ if (!isRecord(parsed)) return undefined;
64
+ if (parsed.schema !== BACKGROUND_SUBAGENTS_SCHEMA) return undefined;
65
+ if (parsed.policy !== "on" && parsed.policy !== "off") return undefined;
66
+ if (Object.keys(parsed).length !== 2) return undefined;
67
+ return parsed.policy;
68
+ }
69
+
70
+ /**
71
+ * Resolve the background-subagents policy AND the source that decided it.
72
+ *
73
+ * Resolution order (first hit wins, mirroring loadRuntimeGuardrailsConfig):
74
+ * 1. Project file `${cwd}/.pi/gentle-ai/background-subagents.json`
75
+ * 2. Global file `${configHome}/background-subagents.json`
76
+ * (configHome honors GENTLE_PI_CONFIG_HOME, default ~/.pi/gentle-ai)
77
+ * 3. Env var GENTLE_PI_BACKGROUND_SUBAGENTS ("on" | "off")
78
+ * 4. Default "off"
79
+ *
80
+ * A present-but-malformed file fails closed to "off" instead of falling
81
+ * through to a lower-priority source, and it stays attributed to that file:
82
+ * "off decided by a broken project file" and "off by default" are different
83
+ * situations, and only the first one is a mistake to fix.
84
+ *
85
+ * Four sources with first-hit-wins is exactly the shape that makes an edit
86
+ * look like it did nothing, so the deciding source is part of the result
87
+ * rather than something a caller has to re-derive.
88
+ */
89
+ export function resolveBackgroundSubagentsPolicy(
90
+ cwd: string,
91
+ options: LoadBackgroundSubagentsOptions = {},
92
+ ): BackgroundSubagentsResolution {
93
+ const env = options.env ?? process.env;
94
+ const envValue = env.GENTLE_PI_BACKGROUND_SUBAGENTS;
95
+ let projectFile = "";
96
+ let globalFile = "";
97
+ try {
98
+ const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome();
99
+ projectFile = join(cwd, ".pi", "gentle-ai", BACKGROUND_SUBAGENTS_FILE);
100
+ globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE);
101
+ const projectFileExists = existsSync(projectFile);
102
+ const globalFileExists = existsSync(globalFile);
103
+ const locations = { projectFile, globalFile, projectFileExists, globalFileExists, envValue };
104
+ for (const [source, path, present] of [
105
+ ["project_file", projectFile, projectFileExists],
106
+ ["global_file", globalFile, globalFileExists],
107
+ ] as const) {
108
+ if (!present) continue;
109
+ let decoded: BackgroundSubagentsPolicy | undefined;
110
+ try {
111
+ decoded = parseBackgroundSubagentsPolicyFile(readFileSync(path, "utf8"));
112
+ } catch {
113
+ // Unreadable is indistinguishable from unusable at this layer, and
114
+ // both must fail closed on the file that claimed the decision.
115
+ decoded = undefined;
116
+ }
117
+ return decoded === undefined
118
+ ? { policy: "off", source, malformed: true, ...locations }
119
+ : { policy: decoded, source, malformed: false, ...locations };
120
+ }
121
+ if (envValue === "on" || envValue === "off") {
122
+ return { policy: envValue, source: "environment", malformed: false, ...locations };
123
+ }
124
+ return { policy: "off", source: "default", malformed: false, ...locations };
125
+ } catch {
126
+ return {
127
+ policy: "off",
128
+ source: "default",
129
+ malformed: false,
130
+ projectFile,
131
+ globalFile,
132
+ projectFileExists: false,
133
+ globalFileExists: false,
134
+ envValue,
135
+ };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The effective policy alone, for callers that do not report a source.
141
+ * It delegates so the loader and the resolver can never disagree.
142
+ */
143
+ export function loadBackgroundSubagentsPolicy(
144
+ cwd: string,
145
+ options: LoadBackgroundSubagentsOptions = {},
146
+ ): BackgroundSubagentsPolicy {
147
+ return resolveBackgroundSubagentsPolicy(cwd, options).policy;
148
+ }
@@ -986,6 +986,15 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
986
986
  // and hint remain dark because neither is proven to reach the negotiated
987
987
  // START path Pi consumes.
988
988
  "3.0.1": 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 }),
989
+ // v3.1.0 changed the ODD orchestrator contract only (gentle-ai #4714).
990
+ // Ground-truthed by diffing contracts/review-integration/v2 and
991
+ // contracts/review-provider-contract between the v3.0.2 and v3.1.0 tags
992
+ // in the gentle-ai source tree: zero bytes changed (provider contract
993
+ // stays 1.2.0). Neither change touches the closed START/STATUS fields
994
+ // this row negotiates, so it repeats 3.0.1 exactly. riskEvidence and hint
995
+ // remain dark because neither is proven to reach the negotiated START
996
+ // path Pi consumes.
997
+ "3.1.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 }),
989
998
  });
990
999
 
991
1000
  export interface NativeReviewProcessDiagnostics {
@@ -659,6 +659,8 @@ function isFullCommitId(selector: string): boolean {
659
659
  return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(selector);
660
660
  }
661
661
 
662
+ export const BASE_REF_ACCEPTED_FORMS = "HEAD, a full 40- or 64-character commit id, or a ref name (branch, tag, remote, or refs/...); abbreviated commit ids are not accepted";
663
+
662
664
  function explicitBaseRefCandidates(cwd: string, selector: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): string[] {
663
665
  if (selector === "HEAD" || isFullCommitId(selector)) return [selector];
664
666
  const refs = new Set(git(cwd, ["for-each-ref", "--format=%(refname)"], env, executor).split("\n").filter((ref) => ref.length > 0));
@@ -719,8 +721,27 @@ function resolveEmptyTree(cwd: string, env: NodeJS.ProcessEnv, executor: Candida
719
721
  return git(cwd, ["mktree"], env, executor);
720
722
  }
721
723
 
724
+ // Resolves `<selector>^{commit}` to a commit id. For an explicit (caller-
725
+ // supplied) baseRef, a nonzero exit is reported as base-ref-unresolvable with
726
+ // the accepted forms rather than as a generic git failure: the selector may
727
+ // be well-formed (a full commit id, a resolvable ref) and still not name a
728
+ // commit, e.g. a full hex id that names a tree object. Timeouts and output-
729
+ // limit failures still propagate as real git failures through
730
+ // probeCandidateGit, the same way isUnbornSymbolicHead handles them. The
731
+ // implicit default (HEAD, no baseRef) keeps the original fail-closed
732
+ // git-failure behavior, since HEAD not resolving signals repository
733
+ // corruption rather than a caller mistake.
734
+ function resolveCommitSelector(cwd: string, selector: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor, explicit: boolean): string {
735
+ const arguments_ = ["rev-parse", "--verify", "--end-of-options", `${selector}^{commit}`];
736
+ if (!explicit) return git(cwd, arguments_, env, executor);
737
+ const probe = probeCandidateGit(cwd, arguments_, env, executor);
738
+ if (probe.status !== 0) throw new CandidateViewError(`candidate base reference is unresolvable; it does not name a commit; accepted forms: ${BASE_REF_ACCEPTED_FORMS}`, "base-ref-unresolvable");
739
+ return probe.stdout;
740
+ }
741
+
722
742
  function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): ResolvedCandidateBase {
723
743
  const selector = baseRef ?? "HEAD";
744
+ const explicit = baseRef !== undefined;
724
745
  // An unborn repository has a symbolic HEAD pointing at a branch with no
725
746
  // commits yet. Its review base is Git's repository-native empty tree, not a
726
747
  // missing or malformed commit. Only the default/HEAD selector is entitled to
@@ -729,21 +750,21 @@ function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: Nod
729
750
  return { commit: "HEAD", tree: resolveEmptyTree(cwd, env, executor) };
730
751
  }
731
752
  try {
732
- if (baseRef !== undefined) {
753
+ if (explicit) {
733
754
  const candidates = explicitBaseRefCandidates(cwd, selector, env, executor);
734
- if (candidates.length > 1) throw new CandidateViewError("candidate base reference is ambiguous", "base-ref-ambiguous");
735
- if (candidates.length === 0) throw new CandidateViewError("candidate base reference is unresolvable", "base-ref-unresolvable");
755
+ if (candidates.length > 1) throw new CandidateViewError(`candidate base reference is ambiguous (matches more than one ref); accepted forms: ${BASE_REF_ACCEPTED_FORMS}`, "base-ref-ambiguous");
756
+ if (candidates.length === 0) throw new CandidateViewError(`candidate base reference is unresolvable; accepted forms: ${BASE_REF_ACCEPTED_FORMS}`, "base-ref-unresolvable");
736
757
  }
737
- const firstCommit = git(cwd, ["rev-parse", "--verify", "--end-of-options", `${selector}^{commit}`], env, executor);
758
+ const firstCommit = resolveCommitSelector(cwd, selector, env, executor, explicit);
738
759
  const tree = git(cwd, ["rev-parse", "--verify", "--end-of-options", `${firstCommit}^{tree}`], env, executor);
739
- const confirmedCommit = git(cwd, ["rev-parse", "--verify", "--end-of-options", `${selector}^{commit}`], env, executor);
760
+ const confirmedCommit = resolveCommitSelector(cwd, selector, env, executor, explicit);
740
761
  if (firstCommit !== confirmedCommit) throw new CandidateViewError("candidate base reference moved during resolution", "base-ref-moved");
741
762
  const confirmedTree = git(cwd, ["rev-parse", "--verify", "--end-of-options", `${confirmedCommit}^{tree}`], env, executor);
742
763
  if (tree !== confirmedTree) throw new CandidateViewError("candidate base tree changed during resolution", "base-ref-moved");
743
764
  return { commit: confirmedCommit, tree: confirmedTree };
744
765
  } catch (error) {
745
766
  if (error instanceof CandidateViewError && (error.diagnostics !== undefined || error.reason === "base-ref-ambiguous" || error.reason === "base-ref-moved" || error.reason === "base-ref-unresolvable")) throw error;
746
- throw new CandidateViewError("candidate base reference is unresolvable", "base-ref-unresolvable");
767
+ throw new CandidateViewError(`candidate base reference is unresolvable; accepted forms: ${BASE_REF_ACCEPTED_FORMS}`, "base-ref-unresolvable");
747
768
  }
748
769
  }
749
770
 
@@ -835,26 +835,12 @@ export function installPackageAssets(
835
835
  }, lockOptions);
836
836
  }
837
837
 
838
- function hasAffirmativeSddIntent(text: string): boolean {
839
- // Natural-language routing must not depend on a closed list of complete
840
- // phrases. An SDD mention becomes an invocation only with an imperative,
841
- // request, or first-person intent marker; a neutral statement such as
842
- // "I use SDD sometimes" remains ordinary conversation.
843
- if (!/\bsdd\b/i.test(text)) return false;
844
- return /(?:\b(?:please|por\s+favor)\b|\b(?:want|need|would\s+like|let'?s|quiero|queremos|necesito|quisiera|me\s+gustar[ií]a|vamos|vayamos|hagamos|usemos)\b|^(?:use|run|start|build|create|implement|handle|make|usa|usá|corre|corré|arranca|arrancá|inicia|iniciá|empeza|empezá|hacelo|hazlo|hacerlo)\b)/i.test(text);
845
- }
846
-
838
+ // Input interception is intentionally syntax-only. Natural-language SDD intent
839
+ // belongs to the parent/orchestrator; dispatch and before_agent_start gates run
840
+ // or reuse preflight when an SDD action is actually attempted. This keeps mere
841
+ // mentions side-effect free without trying to encode language in a regex.
847
842
  export function isSddPreflightTrigger(text: string): boolean {
848
- const trimmed = text.trim();
849
- if (/^\/(?:gentle-)?sdd(?:[-:][^\s]*)?(?:\s|$)/i.test(trimmed)) return true;
850
- if (/[??]\s*$/.test(trimmed)) return false;
851
- if (
852
- /(?:\b(?:don't|do\s+not|never)\b|\bnot\s+(?:want|need|plan(?:ning)?|intend|use|using)\b)[^.!?\n]{0,80}\bsdd\b/i.test(trimmed) ||
853
- /\b(?:sin\s+usar|no\s+(?:quiero|queremos|necesito|necesitamos|quisiera|quisiéramos|vamos\s+a|pienso|planeo|usar))\b[^.!?\n]{0,80}\bsdd\b/i.test(trimmed)
854
- ) {
855
- return false;
856
- }
857
- return hasAffirmativeSddIntent(trimmed);
843
+ return /^\/(?:gentle-)?sdd(?:[-:][^\s]*)?(?:\s|$)/i.test(text.trim());
858
844
  }
859
845
 
860
846
  export function sddPreflightSessionKey(ctx: ExtensionContext): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "3.1.0",
3
+ "version": "3.2.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",
@@ -987,6 +987,15 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
987
987
  // and hint remain dark because neither is proven to reach the negotiated
988
988
  // START path Pi consumes.
989
989
  "3.0.1": 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 }),
990
+ // v3.1.0 changed the ODD orchestrator contract only (gentle-ai #4714).
991
+ // Ground-truthed by diffing contracts/review-integration/v2 and
992
+ // contracts/review-provider-contract between the v3.0.2 and v3.1.0 tags
993
+ // in the gentle-ai source tree: zero bytes changed (provider contract
994
+ // stays 1.2.0). Neither change touches the closed START/STATUS fields
995
+ // this row negotiates, so it repeats 3.0.1 exactly. riskEvidence and hint
996
+ // remain dark because neither is proven to reach the negotiated START
997
+ // path Pi consumes.
998
+ "3.1.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 }),
990
999
  });
991
1000
 
992
1001
 
@@ -36,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
36
36
  // version check below) derives from this constant instead of repeating the
37
37
  // literal, so a pin bump cannot leave a stale copy behind. See
38
38
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
39
- export const INSTALLER_VERSION = "3.0.1";
39
+ export const INSTALLER_VERSION = "3.1.0";
40
40
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
41
41
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
42
42
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -45,10 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
45
45
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai";
46
46
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v3";
47
47
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
48
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.0.1`
48
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.1.0`
49
49
  // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
50
- // tag resolves to commit 2594581e, the published v3.0.1 release head.
51
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:r2z8yf0CdL0ZPPkMEKI3km6SitjOIC/DQDvtipc1Igs=";
50
+ // tag resolves to commit cfc415ce, the published v3.1.0 release head.
51
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:CrZlui5N8/RSmxvn/7usKe6q1EvRt7y6dyfEwjstmJw=";
52
52
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
53
53
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
54
54
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -67,7 +67,7 @@ export class GentleAiInstallerError extends Error {
67
67
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
68
68
  // sentinel digest can never match a real SHA-256, so installation fails closed,
69
69
  // and verify-package-files.mjs refuses to pack/publish while any digest below
70
- // still holds it. The v3.0.1 digests are pinned from the published release:
70
+ // still holds it. The v3.1.0 digests are pinned from the published release:
71
71
  // archive sha256 values verified against the minisign-signed checksums.txt and
72
72
  // freshly computed hashes; binary sha256 values computed from the extracted
73
73
  // executables.
@@ -109,15 +109,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
109
109
  }
110
110
 
111
111
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
112
- // distributing unsigned Windows builds in c4b764d0, so v3.0.1 publishes signed
112
+ // distributing unsigned Windows builds in c4b764d0, so v3.1.0 publishes signed
113
113
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
114
114
  // exact-tag Go SumDB source-build path below; restore archive rows only when
115
115
  // upstream ships signed Windows assets.
116
116
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
117
- "darwin/amd64": asset("gentle-ai_3.0.1_darwin_amd64.tar.gz", "fce899fbdada3781624cc03aa6adaf9b21041915240ef092ff89f222151c9efd", "01dec1aea73a0e70d44432d021bdafc9309382f597ce247f58e6c523caed1345", "gentle-ai"),
118
- "darwin/arm64": asset("gentle-ai_3.0.1_darwin_arm64.tar.gz", "17f12008a056e43042daa6d72b37923a2a071f0d52cca80606410eb096caa9c2", "74c0f14c9806a786ae2cd78989d8ca95e0c01e6c7cc3b80c738f284c1064212e", "gentle-ai"),
119
- "linux/amd64": asset("gentle-ai_3.0.1_linux_amd64.tar.gz", "920ad99189a655452006e96a7b25f1e04d745973e36b75130a3b4a16c53deda8", "201b79d4e6e7551cdfde3ad44e56ad0638173b1093f87fc1156a24652fa24448", "gentle-ai"),
120
- "linux/arm64": asset("gentle-ai_3.0.1_linux_arm64.tar.gz", "e54a588c4ce5adfd9ded4c59c6cc5c1eb369fd88f8c54fa892f2cceb7fdbac8d", "9dbd481f5854c568411a8030fdd2192a23ff011270f8994ae181655786dca638", "gentle-ai"),
117
+ "darwin/amd64": asset("gentle-ai_3.1.0_darwin_amd64.tar.gz", "613f0e11adeebb421daae4c68cb9f207f55c559a70595549ff25f988559226e4", "98340df0102825072431a2c0373ea4d9db1bacafced234f51fb58661ed3d731f", "gentle-ai"),
118
+ "darwin/arm64": asset("gentle-ai_3.1.0_darwin_arm64.tar.gz", "bfcbf8df2682fcf1535b26c604e8dbb445df0ca00a651de204fdc2d013dfe472", "3cdc9689ea0d71186b896341b4181e2df13a82b64d236a26a3273171150d802f", "gentle-ai"),
119
+ "linux/amd64": asset("gentle-ai_3.1.0_linux_amd64.tar.gz", "dc55c44a2eb46212a38eca0dfd4d778481ec37e765f40d5a0752d03c28e1ee49", "70e335d25809a0d358c12f48b2f0d1da00741e725584ceeb8c1318c60d0a6e9e", "gentle-ai"),
120
+ "linux/arm64": asset("gentle-ai_3.1.0_linux_arm64.tar.gz", "3a89d5f5a549004cc2b01949014ed59f9c28e1ff0c9958531bb539504286e407", "6cf9f20fc390b13e2b9b427ca1c2df404d1bbb9248201f9a592c7f0a37ef5416", "gentle-ai"),
121
121
  });
122
122
 
123
123
  // A pinned asset is either a signed archive or, for a prerelease pin only,
@@ -339,7 +339,7 @@ async function main() {
339
339
  });
340
340
 
341
341
  if (driftedContracts.length > 0) {
342
- console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.0.1 runtime's vendored Gentle AI contract artifacts:");
342
+ console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.1.0 runtime's vendored Gentle AI contract artifacts:");
343
343
  for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
344
344
  process.exit(1);
345
345
  }
@@ -384,7 +384,7 @@ async function main() {
384
384
  process.exit(1);
385
385
  }
386
386
 
387
- console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.0.1 runtime).`);
387
+ console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.1.0 runtime).`);
388
388
  }
389
389
 
390
390
  const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -9,7 +9,7 @@ metadata:
9
9
 
10
10
  ## Activation Contract
11
11
 
12
- Load this skill when a planned PR may exceed **400 changed lines**, SDD forecasts `400-line budget risk: High` or `Chained PRs recommended: Yes`, or the user asks for chained/stacked PRs, review slices, or reviewer-load control.
12
+ Load this skill when a planned PR may exceed **400 changed lines**, SDD forecasts `400-line budget risk: High` or `Chained PRs recommended: Yes`, an ODD feature's forecast or running authored changed-line count from work-unit commits exceeds about 400, or the user asks for chained/stacked PRs, review slices, or reviewer-load control.
13
13
 
14
14
  ## Hard Rules
15
15
 
@@ -34,6 +34,7 @@ Load this skill when a planned PR may exceed **400 changed lines**, SDD forecast
34
34
  | Generated/vendor/migration diff cannot split cleanly | Ask maintainer for `size:exception`. |
35
35
  | No cohesive split fits the budget after one slicing pass | Stop; deliver the best split, report the overage and why it cannot shrink further, and recommend `size:exception`. |
36
36
  | SDD provides `delivery_strategy` | Follow it before apply/PR creation. |
37
+ | ODD provides `delivery_strategy` and `chain_strategy` | Follow them before the next work-unit commit or PR creation. |
37
38
 
38
39
  ## Execution Steps
39
40
 
@@ -18,6 +18,7 @@ Use it for:
18
18
  - Turning a large change into chained or stacked PRs.
19
19
  - Keeping reviewer cognitive load healthy.
20
20
  - Applying SDD tasks without accidentally producing a PR above 400 changed lines.
21
+ - Closing an ODD task with a work-unit commit.
21
22
 
22
23
  ## Critical Rules
23
24
 
@@ -76,6 +77,14 @@ Each SDD work unit should map cleanly to a commit or PR with:
76
77
  - verification in the same unit,
77
78
  - rollback that does not remove unrelated work.
78
79
 
80
+ ## ODD Relationship
81
+
82
+ Every ODD task closes with at least one work-unit commit:
83
+
84
+ - The native review candidate is that commit, or the PR slice it belongs to when review is deferred, evaluated against the previous reviewed boundary.
85
+ - The running authored line count from work-unit commits feeds the same delivery-strategy vocabulary as SDD.
86
+ - The feature document records the commit identity and, once a delivery strategy applies, the slice boundaries.
87
+
79
88
  ## Commands
80
89
 
81
90
  ```bash
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AGENT_MODE } from "../lib/agents-config.ts";
4
+ import { resolveDefaultSubagentMode } from "../extensions/gentle-agents.ts";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // resolveDefaultSubagentMode: the runtime default for subagent_run when
8
+ // neither an explicit params.mode nor an agent-defined mode was given.
9
+ //
10
+ // Background is a runtime default only when the background-subagents policy
11
+ // is "on" AND the parent can receive background results. Print mode exits
12
+ // before a parent session exists to deliver a background result to, so it
13
+ // must keep the configured default even when the policy is on (see the
14
+ // `ctx.mode === "print"` guard in gentle-agents.ts `launch`).
15
+ // ---------------------------------------------------------------------------
16
+
17
+ test("policy on + interactive parent -> background", () => {
18
+ assert.equal(
19
+ resolveDefaultSubagentMode({
20
+ configuredDefault: AGENT_MODE.TASK,
21
+ policy: "on",
22
+ parentMode: "interactive",
23
+ }),
24
+ AGENT_MODE.BACKGROUND,
25
+ );
26
+ });
27
+
28
+ test("policy on + rpc parent -> background", () => {
29
+ assert.equal(
30
+ resolveDefaultSubagentMode({
31
+ configuredDefault: AGENT_MODE.TASK,
32
+ policy: "on",
33
+ parentMode: "rpc",
34
+ }),
35
+ AGENT_MODE.BACKGROUND,
36
+ );
37
+ });
38
+
39
+ test("policy on + print parent -> configured default (task), never background", () => {
40
+ assert.equal(
41
+ resolveDefaultSubagentMode({
42
+ configuredDefault: AGENT_MODE.TASK,
43
+ policy: "on",
44
+ parentMode: "print",
45
+ }),
46
+ AGENT_MODE.TASK,
47
+ );
48
+ });
49
+
50
+ test("policy off -> configured default regardless of parent mode", () => {
51
+ for (const parentMode of ["interactive", "rpc", "print", undefined]) {
52
+ assert.equal(
53
+ resolveDefaultSubagentMode({
54
+ configuredDefault: AGENT_MODE.TASK,
55
+ policy: "off",
56
+ parentMode,
57
+ }),
58
+ AGENT_MODE.TASK,
59
+ `parentMode ${String(parentMode)} must not change an off policy`,
60
+ );
61
+ }
62
+ });
63
+
64
+ test("configured default background + policy off -> background (the configured default wins when the policy is off)", () => {
65
+ assert.equal(
66
+ resolveDefaultSubagentMode({
67
+ configuredDefault: AGENT_MODE.BACKGROUND,
68
+ policy: "off",
69
+ parentMode: "interactive",
70
+ }),
71
+ AGENT_MODE.BACKGROUND,
72
+ );
73
+ });
74
+
75
+ test("configured default background + policy on + interactive -> background (both agree)", () => {
76
+ assert.equal(
77
+ resolveDefaultSubagentMode({
78
+ configuredDefault: AGENT_MODE.BACKGROUND,
79
+ policy: "on",
80
+ parentMode: "interactive",
81
+ }),
82
+ AGENT_MODE.BACKGROUND,
83
+ );
84
+ });
85
+
86
+ test("parentMode undefined with policy on is treated as not print -> background", () => {
87
+ assert.equal(
88
+ resolveDefaultSubagentMode({
89
+ configuredDefault: AGENT_MODE.TASK,
90
+ policy: "on",
91
+ parentMode: undefined,
92
+ }),
93
+ AGENT_MODE.BACKGROUND,
94
+ );
95
+ });
96
+
97
+ // Explicit request mode always wins over the resolved default: this is
98
+ // asserted at the wiring site (extensions/gentle-agents.ts `run` and
99
+ // `continue` tools), not inside this pure helper, which has no notion of
100
+ // "explicit" at all — it is called only when params.mode and agent.mode are
101
+ // both absent. tests/agents-integration.test.ts does not currently exercise
102
+ // subagent_run with a fake child process, so that wiring-level assertion is
103
+ // not covered by an automated test in this change; it is covered by reading
104
+ // the call site, where `params.mode ?? agent.mode ?? resolveDefaultSubagentMode(...)`
105
+ // short-circuits on any explicit value before this helper is ever invoked.
@@ -83,9 +83,22 @@ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-")));
83
83
  const activeSessionTeardowns = new Set<() => Promise<void>>();
84
84
  const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown()));
85
85
  afterEach(stopActiveSessions);
86
+ // subagent_run's default mode now reads the background-subagents policy
87
+ // in-process (gentle-pi#background-subagents-default-mode), which falls
88
+ // back to the real ~/.pi/gentle-ai/background-subagents.json when
89
+ // GENTLE_PI_CONFIG_HOME is unset. Point it at an empty scratch directory so
90
+ // this file's expectations never depend on the developer's own global
91
+ // policy file (a real "on" file on the runner's machine would otherwise
92
+ // flip every unrelated fixture's default mode to background).
93
+ const previousGentlePiConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
94
+ process.env.GENTLE_PI_CONFIG_HOME = join(root, "gentle-ai-config-home");
86
95
  after(async () => {
87
96
  try { await stopActiveSessions(); }
88
- finally { rmSync(root, { recursive: true, force: true }); }
97
+ finally {
98
+ if (previousGentlePiConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
99
+ else process.env.GENTLE_PI_CONFIG_HOME = previousGentlePiConfigHome;
100
+ rmSync(root, { recursive: true, force: true });
101
+ }
89
102
  });
90
103
  const home = join(root, "home");
91
104
  const cwd = join(root, "project");
@@ -97,7 +97,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
97
97
  method: "go-sumdb-source-build",
98
98
  package: "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
99
99
  module: "github.com/gentleman-programming/gentle-ai/v3",
100
- tag: "v3.0.1",
100
+ tag: "v3.1.0",
101
101
  architecture: process.arch === "x64" ? "x64" : "arm64",
102
102
  binarySha256: createHash("sha256").update(binary).digest("hex"),
103
103
  moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,