@evoclock/pi-agentic-driver 1.1.2 → 2.0.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 (114) hide show
  1. package/.agentic-driver/loop-until-done.defaults.json +5 -0
  2. package/README.md +302 -41
  3. package/config/merge-policy.v1.example.json +4 -0
  4. package/config/relay-authority.v1.example.json +7 -0
  5. package/config/treehouse.v1.json +4 -0
  6. package/docs/durable-driver-api.md +23 -0
  7. package/docs/durable-persistence.md +32 -0
  8. package/docs/merge-policy.md +45 -0
  9. package/docs/relay-authority.md +9 -0
  10. package/extensions/aidr.ts +3 -0
  11. package/extensions/harvest-lessons.ts +8 -0
  12. package/extensions/herdr-dispatch.ts +32 -3
  13. package/extensions/loop-until-done.ts +7 -0
  14. package/extensions/recovery-evidence.ts +3 -3
  15. package/extensions/treehouse-recycle.ts +47 -0
  16. package/janus/README.md +2 -4
  17. package/janus/janus/config.ts +2 -2
  18. package/janus/janus/providers.ts +2 -4
  19. package/janus/tests/providers.test.ts +2 -2
  20. package/{extensions → lib}/task-store-adapter.ts +1 -5
  21. package/package.json +158 -4
  22. package/scripts/enforcement/approval_ledger_pi.js +135 -0
  23. package/scripts/enforcement/attended_authority_guard.js +113 -0
  24. package/scripts/enforcement/board_claim_bridge_pi.js +10 -10
  25. package/scripts/enforcement/durable_assignment_pi.js +488 -0
  26. package/scripts/enforcement/durable_driver_pi.d.ts +51 -0
  27. package/scripts/enforcement/durable_driver_pi.js +254 -0
  28. package/scripts/enforcement/herdr_async_dispatch_pi.js +86 -20
  29. package/scripts/enforcement/herdr_async_seam_pi.js +90 -3
  30. package/scripts/enforcement/herdr_communication_pi.js +126 -12
  31. package/scripts/enforcement/herdr_lifecycle_pi.js +730 -81
  32. package/scripts/enforcement/linux_microvm_cutover_pi.js +3 -3
  33. package/scripts/enforcement/linux_microvm_remote_fixture.sh +0 -6
  34. package/scripts/enforcement/loop_until_done_guide.md +13 -0
  35. package/scripts/enforcement/loop_until_done_pi.js +214 -0
  36. package/scripts/enforcement/merge_policy.js +181 -0
  37. package/scripts/enforcement/outward_effect_pi.js +156 -0
  38. package/scripts/enforcement/recovery_evidence_review_pi.js +31 -3
  39. package/scripts/enforcement/storage_owner_lock_pi.js +66 -0
  40. package/scripts/enforcement/task_board_core_pi.js +13 -100
  41. package/scripts/enforcement/task_board_hash_pi.js +86 -0
  42. package/scripts/enforcement/treehouse_recycle_pi.js +145 -0
  43. package/scripts/harvest_lessons.js +219 -0
  44. package/scripts/relay/record_approval.mjs +10 -0
  45. package/scripts/relay/send_request.mjs +26 -0
  46. package/skills/architect-pi/SKILL.md +44 -0
  47. package/skills/arena-pi/SKILL.md +46 -0
  48. package/skills/automate-me-pi/SKILL.md +37 -0
  49. package/skills/benchmark-checklist-pi/SKILL.md +35 -0
  50. package/skills/blast-radius-pi/SKILL.md +24 -0
  51. package/skills/bro-pi/SKILL.md +17 -0
  52. package/skills/correct-pi/SKILL.md +36 -0
  53. package/skills/create-verification-skill-pi/LICENSE +21 -0
  54. package/skills/create-verification-skill-pi/SKILL.md +24 -0
  55. package/skills/decision-to-spec/SKILL.md +5 -5
  56. package/skills/figure-it-out-pi/SKILL.md +37 -0
  57. package/skills/harvest-lessons/SKILL.md +56 -0
  58. package/skills/harvest-lessons/harvest.example.json +8 -0
  59. package/skills/how/LICENSE +24 -0
  60. package/skills/how/WORKFLOW_BOUNDARIES.md +19 -0
  61. package/skills/how-pi/SKILL.md +33 -0
  62. package/skills/in-your-own-words-pi/SKILL.md +15 -0
  63. package/skills/interrogate-pi/SKILL.md +35 -0
  64. package/skills/ketchup-pi/SKILL.md +21 -0
  65. package/skills/maintain-verification-skill-pi/SKILL.md +37 -0
  66. package/skills/make-bot-ui-pi/SKILL.md +33 -0
  67. package/skills/no-comments-pi/SKILL.md +33 -0
  68. package/skills/poteto-help-pi/SKILL.md +50 -0
  69. package/skills/poteto-mode-pi/LICENSE +21 -0
  70. package/skills/poteto-mode-pi/SKILL.md +41 -0
  71. package/skills/poteto-mode-pi/playbooks.md +95 -0
  72. package/skills/principle-attack-the-premise-pi/SKILL.md +17 -0
  73. package/skills/principle-boundary-discipline-pi/SKILL.md +19 -0
  74. package/skills/principle-build-the-lever-pi/SKILL.md +19 -0
  75. package/skills/principle-encode-lessons-in-structure-pi/SKILL.md +17 -0
  76. package/skills/principle-exhaust-the-design-space-pi/SKILL.md +17 -0
  77. package/skills/principle-experience-first-pi/SKILL.md +17 -0
  78. package/skills/principle-explain-the-number-pi/SKILL.md +21 -0
  79. package/skills/principle-fix-root-causes-pi/SKILL.md +21 -0
  80. package/skills/principle-foundational-thinking-pi/SKILL.md +21 -0
  81. package/skills/principle-guard-the-context-window-pi/SKILL.md +19 -0
  82. package/skills/principle-laziness-protocol-pi/SKILL.md +19 -0
  83. package/skills/principle-make-operations-idempotent-pi/SKILL.md +21 -0
  84. package/skills/principle-migrate-callers-then-delete-legacy-apis-pi/SKILL.md +19 -0
  85. package/skills/principle-minimize-reader-load-pi/SKILL.md +19 -0
  86. package/skills/principle-model-the-domain-pi/SKILL.md +19 -0
  87. package/skills/principle-never-block-on-the-human-pi/SKILL.md +19 -0
  88. package/skills/principle-outcome-oriented-execution-pi/SKILL.md +19 -0
  89. package/skills/principle-prove-it-works-pi/SKILL.md +19 -0
  90. package/skills/principle-redesign-from-first-principles-pi/SKILL.md +19 -0
  91. package/skills/principle-separate-before-serializing-shared-state-pi/SKILL.md +19 -0
  92. package/skills/principle-sequence-verifiable-units-pi/SKILL.md +19 -0
  93. package/skills/principle-subtract-before-you-add-pi/SKILL.md +19 -0
  94. package/skills/principle-test-behavior-not-implementation-pi/SKILL.md +21 -0
  95. package/skills/principle-type-system-discipline-pi/SKILL.md +19 -0
  96. package/skills/public-pr-wording/SKILL.md +69 -0
  97. package/skills/public-pr-wording/pr_wording_config.example.json +22 -0
  98. package/skills/public-pr-wording/pr_wording_lint.py +249 -0
  99. package/skills/public-pr-wording/pr_wording_rules.json +60 -0
  100. package/skills/pulse/SKILL.md +6 -0
  101. package/skills/recall-pi/SKILL.md +29 -0
  102. package/skills/reflect-pi/SKILL.md +27 -0
  103. package/skills/setup-pstack-pi/SKILL.md +23 -0
  104. package/skills/setup-pstack-pi/assets/pstack-model-roles.v1.example.json +7 -0
  105. package/skills/sharpen-pi/SKILL.md +19 -0
  106. package/skills/show-me-your-work-pi/SKILL.md +27 -0
  107. package/skills/swarm-pi/SKILL.md +27 -0
  108. package/skills/tasks/SKILL.md +8 -2
  109. package/skills/tdd-pi/SKILL.md +21 -0
  110. package/skills/teach-pi/SKILL.md +21 -0
  111. package/skills/technical-writing-pi/SKILL.md +27 -0
  112. package/skills/typescript-best-practices-pi/SKILL.md +21 -0
  113. package/skills/unslop-pi/SKILL.md +27 -0
  114. package/skills/why-pi/SKILL.md +37 -0
@@ -208,9 +208,9 @@ export async function createEvidenceOverlay({ done, lines, tui }) {
208
208
  }
209
209
 
210
210
  /**
211
- * Registers ONLY the owner-initiated slash command. No model-callable tool is
212
- * registered here, and no raw-evidence tool may be added later: the full
213
- * evidence travels exclusively through the native TUI overlay. The trusted
211
+ * Registers the owner-initiated full-evidence command and a model-callable
212
+ * metadata-only summary. No raw-evidence tool is registered: full evidence
213
+ * travels exclusively through the native TUI overlay. The trusted
214
214
  * root is snapshotted once at registration so a later in-process environment
215
215
  * mutation cannot redirect review to a different store mid-session; the
216
216
  * snapshot still honors the trusted host context that existed at load time.
@@ -219,6 +219,34 @@ export async function registerRecoveryEvidenceReview(pi, _options = {}) {
219
219
  if (typeof pi?.registerCommand !== "function" || REGISTRATIONS.has(pi)) return;
220
220
  REGISTRATIONS.add(pi);
221
221
  const registeredRoot = resolveReviewEvidenceRoot();
222
+ if (typeof pi.registerTool === "function") pi.registerTool({
223
+ name: "agentic_recovery_evidence_summary",
224
+ label: "Recovery Evidence Summary",
225
+ description: "Read-only counts of up to 50 recent recovery records. No evidence content, identifiers, references, or authorization is returned.",
226
+ parameters: { type: "object", additionalProperties: false, properties: {} },
227
+ async execute(_toolCallId, params) {
228
+ const shape = (value) => ({ schema: "agentic-driver.recovery-evidence-summary.v1", nonAuthorizing: true, ...value });
229
+ let summary;
230
+ if (!params || typeof params !== "object" || Array.isArray(params) || Object.keys(params).length !== 0) {
231
+ summary = shape({ status: "invalid-arguments" });
232
+ } else {
233
+ let listing;
234
+ try { listing = listReviewableReferences({ root: registeredRoot, limit: REVIEW_LIST_LIMIT }); }
235
+ catch { listing = { ok: false }; }
236
+ if (!listing.ok || !Array.isArray(listing.references)
237
+ || listing.references.some((ref) => !["requested", "terminal"].includes(ref.phase))) {
238
+ summary = shape({ status: listing.code === "evidence-store-missing" ? "store-missing" : "unavailable" });
239
+ } else {
240
+ summary = shape({ status: "available", observed: listing.references.length,
241
+ requested: listing.references.filter((ref) => ref.phase === "requested").length,
242
+ terminal: listing.references.filter((ref) => ref.phase === "terminal").length,
243
+ limit: REVIEW_LIST_LIMIT, possiblyMore: listing.references.length === REVIEW_LIST_LIMIT,
244
+ verification: "metadata-only" });
245
+ }
246
+ }
247
+ return { content: [{ type: "text", text: JSON.stringify(summary) }], details: summary };
248
+ },
249
+ });
222
250
  const notify = (context, message, type) => {
223
251
  if (typeof context?.ui?.notify === "function") context.ui.notify(message, type);
224
252
  };
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ import { randomUUID } from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { hostname } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ function processStart(pid) {
10
+ return execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 2000 }).trim();
11
+ }
12
+
13
+ export function claimSeamLock(directory) {
14
+ const lock = join(directory, "owner.lock");
15
+ const gate = join(directory, "owner-reclaim.lock");
16
+ const owner = { pid: process.pid, hostname: hostname(), start: processStart(process.pid), token: randomUUID() };
17
+ if (!owner.start || existsSync(gate)) throw new Error("storage ownership or reclamation unknown; reconcile manually");
18
+ let heldGate = false;
19
+ let stale;
20
+ try {
21
+ try { mkdirSync(lock, { mode: 0o700 }); }
22
+ catch (error) {
23
+ if (error?.code !== "EEXIST") throw error;
24
+ try { mkdirSync(gate, { mode: 0o700 }); }
25
+ catch { throw new Error("storage reclamation in progress or uncertain; reconcile manually"); }
26
+ heldGate = true;
27
+ let previous;
28
+ try { previous = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")); }
29
+ catch { throw new Error("storage owner unknown; reconcile lock manually"); }
30
+ if (previous.hostname !== owner.hostname || !Number.isSafeInteger(previous.pid) || previous.pid < 1 ||
31
+ typeof previous.start !== "string" || !previous.start) throw new Error("storage owner unverified");
32
+ let dead = false;
33
+ try { process.kill(previous.pid, 0); }
34
+ catch (failure) {
35
+ if (failure.code !== "ESRCH") throw new Error("storage owner liveness unknown");
36
+ dead = true;
37
+ }
38
+ if (!dead) {
39
+ let observed;
40
+ try { observed = processStart(previous.pid); }
41
+ catch { throw new Error("storage owner start time unknown"); }
42
+ if (!observed || observed === previous.start) throw new Error("storage owner is still active");
43
+ }
44
+ stale = join(directory, `owner-stale-${randomUUID()}`);
45
+ try { renameSync(lock, stale); }
46
+ catch { throw new Error("storage owner changed during reclamation; reconcile manually"); }
47
+ mkdirSync(lock, { mode: 0o700 });
48
+ }
49
+ if (!heldGate && existsSync(gate)) throw new Error("storage reclamation raced admission; reconcile manually");
50
+ writeFileSync(join(lock, "owner.json"), JSON.stringify(owner), { flag: "wx", mode: 0o600 });
51
+ if (stale) {
52
+ unlinkSync(join(stale, "owner.json"));
53
+ rmdirSync(stale);
54
+ }
55
+ return { path: lock, token: owner.token };
56
+ } finally {
57
+ if (heldGate) rmdirSync(gate);
58
+ }
59
+ }
60
+
61
+ export function releaseSeamLock(lease) {
62
+ const owner = JSON.parse(readFileSync(join(lease.path, "owner.json"), "utf8"));
63
+ if (owner.token !== lease.token) throw new Error("storage owner changed; refusing to release another owner");
64
+ unlinkSync(join(lease.path, "owner.json"));
65
+ rmdirSync(lease.path);
66
+ }
@@ -11,7 +11,7 @@
11
11
  // hashes; presentation is excluded from the hash; every disagreement between
12
12
  // surfaces fails closed.
13
13
 
14
- import { createHash, createHmac, randomBytes } from "node:crypto";
14
+ import { createHmac, randomBytes } from "node:crypto";
15
15
  import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync, unlinkSync, chmodSync, statSync as fsStatSync } from "node:fs";
16
16
  import { execFileSync } from "node:child_process";
17
17
  import { dirname, join } from "node:path";
@@ -20,6 +20,14 @@ import { dirname, join } from "node:path";
20
20
  // are the pure router libraries; the board writer never mutates them.
21
21
  import { isDeadRoute } from "./router_replacement_pi.js";
22
22
  import { getRouteDecisionIdByDigest } from "./router_store_pi.js";
23
+ import {
24
+ nfc, canonicalJson, canonicalJsonString, sha256Hex, sanitizeFreeText,
25
+ encodeFieldText, decodeFieldText, hashPayload, computeCardHash, computeSpecHash,
26
+ } from "./task_board_hash_pi.js";
27
+ export {
28
+ canonicalJson, canonicalJsonString, sha256Hex, sanitizeFreeText,
29
+ encodeFieldText, decodeFieldText, hashPayload, computeCardHash, computeSpecHash,
30
+ };
23
31
 
24
32
  // ---------------------------------------------------------------------------
25
33
  // Closed vocabularies (§1)
@@ -68,103 +76,8 @@ const HTML_ID_MARKER_RE = /<!--\s*id:\s*([^>]*?)\s*-->/g;
68
76
  const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2194}-\u{21AA}]/gu;
69
77
  const EMOJI_DATE_RE = /📅\s*(\d{4}-\d{2}-\d{2})/u;
70
78
 
71
- function nfc(value) {
72
- return typeof value === "string" ? value.normalize("NFC") : value;
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // Hash canonicalization (§1): SHA-256 over canonical JSON of the
77
- // authority-bearing fields only. Keys recursively sorted; absent optional
78
- // fields omitted (never null); strings NFC-normalized; presentation excluded.
79
- // ---------------------------------------------------------------------------
80
-
81
- export function canonicalJson(value) {
82
- if (value === null || value === undefined) return undefined;
83
- if (typeof value === "string") return nfc(value);
84
- if (typeof value === "number" || typeof value === "boolean") return value;
85
- if (Array.isArray(value)) return value.map((entry) => canonicalJson(entry));
86
- if (typeof value === "object") {
87
- const out = {};
88
- for (const key of Object.keys(value).sort()) {
89
- const normalized = canonicalJson(value[key]);
90
- if (normalized !== undefined) out[key] = normalized;
91
- }
92
- return out;
93
- }
94
- return undefined;
95
- }
96
-
97
- export function canonicalJsonString(value) {
98
- return JSON.stringify(canonicalJson(value));
99
- }
100
-
101
- export function sha256Hex(text) {
102
- return createHash("sha256").update(text, "utf8").digest("hex");
103
- }
104
-
105
- // Free-text (titles, quoted instructions) is sanitized before it is ever
106
- // serialized into a card line: [key:: value] field syntax, stray "]", and
107
- // HTML-comment syntax are stripped so a hostile string cannot alter parsing
108
- // (§6 gate 3). Sanitization is lossy by design — it fails closed.
109
- export function sanitizeFreeText(text) {
110
- return String(text ?? "").normalize("NFC")
111
- .replace(/<!--[\s\S]*?-->/g, " ")
112
- .replace(/<!--|-->/g, " ")
113
- .replace(/\[[A-Za-z][A-Za-z0-9_-]*::[^\]]*\]?/g, " ")
114
- .replace(/\]/g, ")")
115
- .replace(/\s+/g, " ").trim();
116
- }
117
-
118
- // Spec/DoD text travels base64url-encoded inside a [key:: value] field: the
119
- // encoding round-trips byte-for-byte (so the dispatch gate can recompute the
120
- // hash) and cannot inject field or HTML-comment syntax.
121
- export function encodeFieldText(text) {
122
- return Buffer.from(String(text ?? ""), "utf8").toString("base64url");
123
- }
124
-
125
- // F2: only the canonical base64url encoding is accepted. The decoded bytes
126
- // are re-encoded and compared to the original string exactly, so a permissive
127
- // decoder cannot smuggle non-canonical input ('***' and friends decode to
128
- // nothing usable and are rejected).
129
- export function decodeFieldText(encoded) {
130
- if (typeof encoded !== "string" || encoded === "") return null;
131
- try {
132
- const decoded = Buffer.from(encoded, "base64url").toString("utf8");
133
- if (Buffer.from(decoded, "utf8").toString("base64url") !== encoded) return null;
134
- return decoded;
135
- } catch {
136
- return null;
137
- }
138
- }
139
-
140
- // The authority-bearing field set. The authority-source reference is NOT
141
- // hash-bearing: it is recorded alongside the card, outside this payload.
142
- // §1 scope = paths + capability classes + explicitly unchanged paths +
143
- // repository identity (for user-level cards) — all hash-bearing.
144
- const AUTHORITY_FIELDS = Object.freeze([
145
- "cardId", "lane", "flags", "priority", "dependencies", "base",
146
- "specHash", "dodHash", "stoppingPoint", "scope", "unchangedPaths",
147
- "capabilities", "repositories",
148
- ]);
149
-
150
- export function hashPayload(card) {
151
- const payload = {};
152
- for (const field of AUTHORITY_FIELDS) {
153
- const value = card[field];
154
- if (value === undefined || value === null) continue;
155
- if (Array.isArray(value) && value.length === 0) continue;
156
- payload[field] = value;
157
- }
158
- return payload;
159
- }
160
-
161
- export function computeCardHash(card) {
162
- return sha256Hex(canonicalJsonString(hashPayload(card)));
163
- }
164
-
165
- export function computeSpecHash(specText) {
166
- return sha256Hex(nfc(String(specText)));
167
- }
79
+ // Hash/text primitives live in task_board_hash_pi.js; the public board-core
80
+ // exports above preserve every caller's existing API.
168
81
 
169
82
  // ---------------------------------------------------------------------------
170
83
  // Title stripping / normalization (§2): fields, emoji, and the ID marker are
@@ -182,7 +95,7 @@ export function stripTitle(rawTitle) {
182
95
 
183
96
  // ---------------------------------------------------------------------------
184
97
  // Parsers (§2). One tokenizer handles both surface shapes: Obsidian Kanban
185
- // Markdown ([id:: ...] Dataview marker) and vogelkop TASKS.md
98
+ // Markdown ([id:: ...] Dataview marker) and external TASKS.md
186
99
  // (<!-- id: ... --> HTML-comment marker). Unknown ## headings are rejected;
187
100
  // [-] cancelled checkboxes map to #cancelled; indented continuation lines are
188
101
  // description text.
@@ -379,7 +292,7 @@ function safeJsonParse(text) {
379
292
  }
380
293
  }
381
294
 
382
- const CAPABILITY_REGISTRY_PATH = ".vogelkop/capabilities.json";
295
+ const CAPABILITY_REGISTRY_PATH = ".agentic-driver/capabilities.json";
383
296
 
384
297
  function capabilityRegistryError(registryPath, reason) {
385
298
  return Object.assign(new Error(`capability registry is invalid at ${registryPath}: ${reason}`), {
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 evoclock
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Canonical, authority-bearing board payload and free-text encodings.
5
+ // Both surface parsers and the trusted writer use the same pure primitives.
6
+ import { createHash } from "node:crypto";
7
+
8
+ export function nfc(value) {
9
+ return typeof value === "string" ? value.normalize("NFC") : value;
10
+ }
11
+
12
+ // Keys recursively sorted; absent optional fields omitted; strings normalized.
13
+ export function canonicalJson(value) {
14
+ if (value === null || value === undefined) return undefined;
15
+ if (typeof value === "string") return nfc(value);
16
+ if (typeof value === "number" || typeof value === "boolean") return value;
17
+ if (Array.isArray(value)) return value.map((entry) => canonicalJson(entry));
18
+ if (typeof value === "object") {
19
+ const out = {};
20
+ for (const key of Object.keys(value).sort()) {
21
+ const normalized = canonicalJson(value[key]);
22
+ if (normalized !== undefined) out[key] = normalized;
23
+ }
24
+ return out;
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ export function canonicalJsonString(value) {
30
+ return JSON.stringify(canonicalJson(value));
31
+ }
32
+
33
+ export function sha256Hex(text) {
34
+ return createHash("sha256").update(text, "utf8").digest("hex");
35
+ }
36
+
37
+ // Sanitize field and HTML-comment syntax before serializing free text.
38
+ export function sanitizeFreeText(text) {
39
+ return String(text ?? "").normalize("NFC")
40
+ .replace(/<!--[\s\S]*?-->/g, " ")
41
+ .replace(/<!--|-->/g, " ")
42
+ .replace(/\[[A-Za-z][A-Za-z0-9_-]*::[^\]]*\]?/g, " ")
43
+ .replace(/\]/g, ")")
44
+ .replace(/\s+/g, " ").trim();
45
+ }
46
+
47
+ export function encodeFieldText(text) {
48
+ return Buffer.from(String(text ?? ""), "utf8").toString("base64url");
49
+ }
50
+
51
+ export function decodeFieldText(encoded) {
52
+ if (typeof encoded !== "string" || encoded === "") return null;
53
+ try {
54
+ const decoded = Buffer.from(encoded, "base64url").toString("utf8");
55
+ if (Buffer.from(decoded, "utf8").toString("base64url") !== encoded) return null;
56
+ return decoded;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ // Authority-source reference and presentation fields are outside this hash.
63
+ const AUTHORITY_FIELDS = Object.freeze([
64
+ "cardId", "lane", "flags", "priority", "dependencies", "base",
65
+ "specHash", "dodHash", "stoppingPoint", "scope", "unchangedPaths",
66
+ "capabilities", "repositories",
67
+ ]);
68
+
69
+ export function hashPayload(card) {
70
+ const payload = {};
71
+ for (const field of AUTHORITY_FIELDS) {
72
+ const value = card[field];
73
+ if (value === undefined || value === null) continue;
74
+ if (Array.isArray(value) && value.length === 0) continue;
75
+ payload[field] = value;
76
+ }
77
+ return payload;
78
+ }
79
+
80
+ export function computeCardHash(card) {
81
+ return sha256Hex(canonicalJsonString(hashPayload(card)));
82
+ }
83
+
84
+ export function computeSpecHash(specText) {
85
+ return sha256Hex(nfc(String(specText)));
86
+ }
@@ -0,0 +1,145 @@
1
+ // Treehouse owns the pool. This scanner only returns settled, clean, pushed
2
+ // leases; it never removes worktrees or checks out branches itself.
3
+ import { execFile } from "node:child_process";
4
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { promisify } from "node:util";
8
+
9
+ const TREEHOUSE_CONFIG = JSON.parse(readFileSync(new URL("../../config/treehouse.v1.json", import.meta.url), "utf8"));
10
+ export const TREEHOUSE_VERSION = TREEHOUSE_CONFIG.version;
11
+ export const TREEHOUSE_ROOT = TREEHOUSE_CONFIG.root ?? join(homedir(), ".treehouse");
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ export async function commandRunner(file, args, cwd) {
15
+ const { stdout } = await execFileAsync(file, args, { cwd, encoding: "utf8", timeout: 15000, maxBuffer: 2 * 1024 * 1024 });
16
+ return stdout.trim();
17
+ }
18
+
19
+ function git(run, cwd, ...args) { return run("git", args, cwd); }
20
+
21
+ /** Runs only from the main repository checkout; never scans another pool. */
22
+ export async function recycleTreehouse({ root, run = commandRunner, current = process.cwd(), shouldContinue = () => true }) {
23
+ const report = [];
24
+ const repo = realpathSync(root);
25
+ if (realpathSync(await git(run, repo, "rev-parse", "--show-toplevel")) !== repo) throw new Error("Treehouse scan requires the repository root");
26
+ const versions = [...(await run("treehouse", ["--version"], repo)).matchAll(/(?:^|\s)(v\d+\.\d+\.\d+)(?=\s|$)/g)];
27
+ if (versions.length !== 1 || versions[0][1] !== TREEHOUSE_VERSION) throw new Error("Treehouse version does not match the pinned version");
28
+ const remote = await git(run, repo, "remote", "get-url", "origin");
29
+ const match = remote.match(/^(?:https:\/\/github\.com\/|git@github\.com:)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/);
30
+ if (!match) throw new Error("unrecognized GitHub origin; refusing Treehouse recycling");
31
+ const repository = match[1];
32
+ const slots = JSON.parse(await run("treehouse", ["status", "--json"], repo));
33
+ if (!Array.isArray(slots)) throw new Error("invalid Treehouse status");
34
+ const here = realpathSync(current);
35
+ for (const slot of slots) {
36
+ if (!shouldContinue()) break;
37
+ if (slot?.status !== "leased" || !slot.lease_id || !slot.lease_holder ||
38
+ !slot.branch || !Array.isArray(slot.processes) || slot.processes.length || !slot.path) continue;
39
+ // Never return the checkout containing this process, even if a process probe misses it.
40
+ let path;
41
+ try { path = realpathSync(slot.path); } catch { continue; }
42
+ if (here === path || here.startsWith(path + "/") || path === repo) continue;
43
+ try {
44
+ if (realpathSync(await git(run, path, "rev-parse", "--show-toplevel")) !== path ||
45
+ await git(run, path, "branch", "--show-current") !== slot.branch ||
46
+ await git(run, path, "status", "--porcelain", "--untracked-files=all", "--ignored=matching")) continue;
47
+ const head = await git(run, path, "rev-parse", "HEAD");
48
+ // A merged PR with the exact head proves this checkout was integrated.
49
+ // A closed but unmerged PR never authorizes a return. Pagination keeps
50
+ // older merged heads from being hidden by a result cap.
51
+ const pages = await run("gh", ["api", "--method", "GET", `repos/${repository}/pulls`,
52
+ "-f", "state=closed", "-f", `head=${repository.split("/")[0]}:${slot.branch}`,
53
+ "-f", "per_page=100", "--paginate", "--jq",
54
+ '[.[] | {headRefName:.head.ref,headRefOid:.head.sha,owner:.head.repo.owner.login,mergedAt:.merged_at}]'], repo);
55
+ const prs = pages.split("\n").filter(Boolean).flatMap(page => {
56
+ const entries = JSON.parse(page);
57
+ if (!Array.isArray(entries)) throw new Error("invalid paginated PR response");
58
+ return entries;
59
+ });
60
+ if (!prs.some(pr => pr.headRefName === slot.branch &&
61
+ pr.headRefOid === head && pr.owner === repository.split("/")[0] &&
62
+ typeof pr.mergedAt === "string" && Number.isFinite(Date.parse(pr.mergedAt)))) continue;
63
+ // A concurrent new commit, dirty write, or lease change is caught again by
64
+ // Treehouse's conditional return. Never use --force or --all.
65
+ if (await git(run, path, "rev-parse", "HEAD") !== head ||
66
+ await git(run, path, "status", "--porcelain", "--untracked-files=all", "--ignored=matching") ||
67
+ !shouldContinue()) continue;
68
+ await run("treehouse", ["return", path, "--if-lease-id", slot.lease_id,
69
+ "--if-lease-holder", slot.lease_holder], repo);
70
+ report.push({ branch: slot.branch, status: "returned" });
71
+ } catch (error) {
72
+ report.push({ branch: slot.branch, status: "held", reason: String(error.message) });
73
+ }
74
+ }
75
+ return report;
76
+ }
77
+
78
+ const defaultReadDir = (path) => readdirSync(path, { withFileTypes: true });
79
+
80
+ function containsGitWorktree(dir, readDir, depth = 2) {
81
+ if (depth < 0) return false;
82
+ if (existsSync(join(dir, ".git"))) return true;
83
+ let entries;
84
+ try { entries = readDir(dir); } catch { return false; }
85
+ return entries.some(entry => entry.isDirectory() && containsGitWorktree(join(dir, entry.name), readDir, depth - 1));
86
+ }
87
+
88
+ /** Finds the main checkout owning one slot worktree via its git common dir. */
89
+ async function repoForPool(pool, run, readDir) {
90
+ let slots;
91
+ try { slots = readDir(pool); } catch { return undefined; }
92
+ for (const slot of slots) {
93
+ if (!slot.isDirectory()) continue;
94
+ let children;
95
+ try { children = readDir(join(pool, slot.name)); } catch { continue; }
96
+ for (const child of children) {
97
+ if (!child.isDirectory() || !existsSync(join(pool, slot.name, child.name, ".git"))) continue;
98
+ const worktree = join(pool, slot.name, child.name);
99
+ const common = await git(run, worktree, "rev-parse", "--path-format=absolute", "--git-common-dir");
100
+ return dirname(realpathSync(common));
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ /**
107
+ * Sweeps every Treehouse pool under the treehouse root: managed pools are
108
+ * recycled per owning repository; directories holding plain git worktrees
109
+ * outside any pool are flagged as strays and never touched.
110
+ */
111
+ export async function recycleAllPools({ treehouseRoot = TREEHOUSE_ROOT, run = commandRunner, current = process.cwd(), shouldContinue = () => true, readDir = defaultReadDir }) {
112
+ const report = [];
113
+ let entries;
114
+ try { entries = readDir(treehouseRoot); } catch (error) {
115
+ return [{ pool: treehouseRoot, status: "held", reason: String(error.message) }];
116
+ }
117
+ const repos = new Map();
118
+ for (const entry of entries) {
119
+ if (!entry.isDirectory() || !shouldContinue()) continue;
120
+ const pool = join(treehouseRoot, entry.name);
121
+ if (existsSync(join(pool, "treehouse-state.json"))) {
122
+ try {
123
+ const repo = await repoForPool(pool, run, readDir);
124
+ if (repo && !repos.has(repo)) repos.set(repo, pool);
125
+ } catch (error) {
126
+ report.push({ pool, status: "held", reason: String(error.message) });
127
+ }
128
+ } else if (containsGitWorktree(pool, readDir)) {
129
+ // A plain git worktree under the Treehouse root is invisible to every
130
+ // pool command; flag it so a human can salvage and remove it by hand.
131
+ report.push({ pool, status: "stray", reason: "git worktree outside any Treehouse pool" });
132
+ }
133
+ }
134
+ for (const [repo, pool] of repos) {
135
+ if (!shouldContinue()) break;
136
+ try {
137
+ for (const item of await recycleTreehouse({ root: repo, run, current, shouldContinue })) {
138
+ report.push({ pool, ...item });
139
+ }
140
+ } catch (error) {
141
+ report.push({ pool, status: "held", reason: String(error.message) });
142
+ }
143
+ }
144
+ return report;
145
+ }