@davesheffer/hunch 1.29.0 → 1.31.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 +9 -0
- package/dist/cli/automaticReviewMemory.js +124 -0
- package/dist/cli/index.js +80 -10
- package/dist/cli/invocation.js +9 -0
- package/dist/cli/reviewMemory.js +34 -0
- package/dist/cli/reviewMemoryProvider.js +40 -0
- package/dist/cli/serve.js +44 -0
- package/dist/constitution/experimentRunner.js +3 -1
- package/dist/core/automaticReviewMemory.js +141 -0
- package/dist/core/reviewMemory.js +100 -0
- package/dist/core/stateContract.js +9 -0
- package/dist/core/stateRecords.js +30 -0
- package/dist/extractors/diff.js +26 -21
- package/dist/extractors/git.js +7 -1
- package/dist/extractors/languages.js +8 -0
- package/dist/mcp/server.js +53 -44
- package/dist/store/replay.js +153 -0
- package/dist/store/stateBinding.js +160 -12
- package/dist/synthesis/cliAdapter.js +168 -0
- package/dist/synthesis/initiator.js +58 -0
- package/dist/synthesis/provider.js +84 -48
- package/dist/synthesis/synthesize.js +37 -16
- package/package.json +3 -1
- package/server.json +2 -2
|
@@ -26,7 +26,7 @@ import { decisionId } from "../core/ids.js";
|
|
|
26
26
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
27
27
|
import { captureConflicts, isLive } from "../core/topics.js";
|
|
28
28
|
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
29
|
-
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, } from "../core/stateContract.js";
|
|
29
|
+
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } from "../core/stateContract.js";
|
|
30
30
|
/** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
|
|
31
31
|
export class StateRefusal extends Error {
|
|
32
32
|
code;
|
|
@@ -81,7 +81,9 @@ export function capabilities(store) {
|
|
|
81
81
|
}
|
|
82
82
|
// ---- homing --------------------------------------------------------------------------------
|
|
83
83
|
const granted = (principal, scope) => principal.grants.some((g) => scopePath(g) === scopePath(scope));
|
|
84
|
-
|
|
84
|
+
/** Where a scope's records and ledger live in this store (exported for the replay check, which
|
|
85
|
+
* must read the SAME home the write verb wrote — never a second routing rule). */
|
|
86
|
+
export function stateHomeFor(store, scope) {
|
|
85
87
|
const own = partitionOf(store);
|
|
86
88
|
if (scopePath(scope) === scopePath(own)) {
|
|
87
89
|
// The store IS this partition: its capture home (public `.hunch/`, or the overlay in shared mode).
|
|
@@ -104,6 +106,62 @@ const recordScope = (record, repo) => {
|
|
|
104
106
|
function refOf(facet, record, scope) {
|
|
105
107
|
return { facet, id: record.id, record_hash: stateHash(record), scope };
|
|
106
108
|
}
|
|
109
|
+
/** Follow `merged_into` to the entity that stands for this one now (cycle-safe, bounded). */
|
|
110
|
+
function survivorOf(byId, entity) {
|
|
111
|
+
let current = entity;
|
|
112
|
+
const seen = new Set([current.id]);
|
|
113
|
+
while (current.lifecycle === "retired" && current.merged_into) {
|
|
114
|
+
const next = byId.get(current.merged_into);
|
|
115
|
+
if (!next || seen.has(next.id))
|
|
116
|
+
break;
|
|
117
|
+
seen.add(next.id);
|
|
118
|
+
current = next;
|
|
119
|
+
}
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
/** The entities in the principal's grants that stand for an external key or an entity id — active
|
|
123
|
+
* ones directly, retired-and-merged ones through the survivor they name. */
|
|
124
|
+
function entityIndex(store, principal, repo) {
|
|
125
|
+
const byId = new Map();
|
|
126
|
+
for (const e of store.recs("entities"))
|
|
127
|
+
if (granted(principal, recordScope(e, repo)))
|
|
128
|
+
byId.set(e.id, e);
|
|
129
|
+
const survivor = (e) => survivorOf(byId, e);
|
|
130
|
+
const byKey = new Map();
|
|
131
|
+
const bySubject = new Map();
|
|
132
|
+
for (const e of byId.values()) {
|
|
133
|
+
const stands = e.lifecycle === "active" ? e : (e.lifecycle === "retired" && e.merged_into ? survivor(e) : null);
|
|
134
|
+
if (!stands || stands.lifecycle !== "active")
|
|
135
|
+
continue;
|
|
136
|
+
for (const ref of e.refs) {
|
|
137
|
+
if (!byKey.has(externalKey(ref)) || e.lifecycle === "active")
|
|
138
|
+
byKey.set(externalKey(ref), stands);
|
|
139
|
+
if (!bySubject.has(subjectOfRef(ref)) || e.lifecycle === "active")
|
|
140
|
+
bySubject.set(subjectOfRef(ref), stands);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { byKey, bySubject, byId, survivor };
|
|
144
|
+
}
|
|
145
|
+
/** The names one subject is filed under: itself, the entity that stands for it (through merges),
|
|
146
|
+
* every entity merged into that one, and every key any of them carries. Explicit refs only. */
|
|
147
|
+
function subjectAliases(store, principal, repo, subject) {
|
|
148
|
+
const aliases = new Set([subject]);
|
|
149
|
+
const { bySubject, byId, survivor } = entityIndex(store, principal, repo);
|
|
150
|
+
const named = bySubject.get(subject) ?? byId.get(subject);
|
|
151
|
+
if (!named)
|
|
152
|
+
return aliases;
|
|
153
|
+
const stands = survivor(named);
|
|
154
|
+
if (stands.lifecycle !== "active")
|
|
155
|
+
return aliases;
|
|
156
|
+
for (const e of byId.values()) {
|
|
157
|
+
if (e.id !== stands.id && survivor(e).id !== stands.id)
|
|
158
|
+
continue;
|
|
159
|
+
aliases.add(e.id);
|
|
160
|
+
for (const ref of e.refs)
|
|
161
|
+
aliases.add(subjectOfRef(ref));
|
|
162
|
+
}
|
|
163
|
+
return aliases;
|
|
164
|
+
}
|
|
107
165
|
/** read — the system-of-record answer for a subject, under the delivery envelope's receipt.
|
|
108
166
|
* Grants are the first predicate on every candidate; a matching record in a scope the
|
|
109
167
|
* principal lacks is NAMED in denied_scopes and never described. */
|
|
@@ -133,6 +191,11 @@ export function readState(store, input) {
|
|
|
133
191
|
denied.set(scopePath(s), s);
|
|
134
192
|
if (request.subject !== undefined) {
|
|
135
193
|
const subject = request.subject;
|
|
194
|
+
// Subject identity by external reference: a read for an external record's key (`event:26904`,
|
|
195
|
+
// `customer:Site:7`) also finds what is filed under the entity that carries that ref, and a
|
|
196
|
+
// read for the entity id finds what was filed under its keys — one explicit hop, grants first.
|
|
197
|
+
const aliases = subjectAliases(store, request.principal, repo, subject);
|
|
198
|
+
const isSubject = (s) => s !== undefined && aliases.has(s);
|
|
136
199
|
const current = [];
|
|
137
200
|
const inForce = [];
|
|
138
201
|
const done = [];
|
|
@@ -173,7 +236,7 @@ export function readState(store, input) {
|
|
|
173
236
|
}
|
|
174
237
|
if (facets.has("receipts"))
|
|
175
238
|
for (const r of store.recs("receipts")) {
|
|
176
|
-
const targets = r.id === subject || r.invalidates.
|
|
239
|
+
const targets = r.id === subject || r.invalidates.some(isSubject) || isSubject(subjectOfRef(r.target));
|
|
177
240
|
if (!targets)
|
|
178
241
|
continue;
|
|
179
242
|
const scope = admit("receipts", r);
|
|
@@ -183,12 +246,12 @@ export function readState(store, input) {
|
|
|
183
246
|
done.push(keep("receipts", r, scope));
|
|
184
247
|
dependsOn.push(...(r.rests_on ?? []));
|
|
185
248
|
}
|
|
186
|
-
if (r.invalidates.
|
|
249
|
+
if (r.invalidates.some(isSubject))
|
|
187
250
|
invalidatedBy.add(r.id);
|
|
188
251
|
}
|
|
189
252
|
if (facets.has("commitments"))
|
|
190
253
|
for (const c of store.recs("commitments")) {
|
|
191
|
-
if (c.subject
|
|
254
|
+
if (!isSubject(c.subject) && c.id !== subject)
|
|
192
255
|
continue;
|
|
193
256
|
const scope = admit("commitments", c);
|
|
194
257
|
if (!scope)
|
|
@@ -202,7 +265,7 @@ export function readState(store, input) {
|
|
|
202
265
|
}
|
|
203
266
|
if (facets.has("derived"))
|
|
204
267
|
for (const d of store.recs("derived")) {
|
|
205
|
-
if (d.subject
|
|
268
|
+
if (!isSubject(d.subject) && d.id !== subject)
|
|
206
269
|
continue;
|
|
207
270
|
const scope = admit("derived", d);
|
|
208
271
|
if (!scope)
|
|
@@ -214,7 +277,7 @@ export function readState(store, input) {
|
|
|
214
277
|
}
|
|
215
278
|
if (facets.has("entities"))
|
|
216
279
|
for (const e of store.recs("entities")) {
|
|
217
|
-
if (e.id
|
|
280
|
+
if (!isSubject(e.id))
|
|
218
281
|
continue;
|
|
219
282
|
const scope = admit("entities", e);
|
|
220
283
|
if (!scope)
|
|
@@ -224,7 +287,7 @@ export function readState(store, input) {
|
|
|
224
287
|
}
|
|
225
288
|
if (facets.has("relationships"))
|
|
226
289
|
for (const r of store.recs("relationships")) {
|
|
227
|
-
if (r.from
|
|
290
|
+
if (!isSubject(r.from) && !isSubject(r.to))
|
|
228
291
|
continue;
|
|
229
292
|
const scope = admit("relationships", r);
|
|
230
293
|
if (!scope)
|
|
@@ -407,6 +470,54 @@ function assertClosedBy(store, principal, commitment) {
|
|
|
407
470
|
}
|
|
408
471
|
return commitment.closed_by;
|
|
409
472
|
}
|
|
473
|
+
/** one-entity-per-external-ref. An entity: no other active entity in the partition may carry one
|
|
474
|
+
* of its external keys (the incumbent is named; merge/split are explicit, later). A commitment or
|
|
475
|
+
* derived statement: its subject may not be the external key of a record an entity already
|
|
476
|
+
* carries — the entity's id is the subject, and the refusal names it (the writer re-derives;
|
|
477
|
+
* ids derive from the subject, so nothing is rewritten under it). A subject no entity claims
|
|
478
|
+
* stays a free-form key: explicit refs only, no guessing. */
|
|
479
|
+
function assertExternalIdentity(store, principal, scope, facet, record) {
|
|
480
|
+
const repo = partitionOf(store);
|
|
481
|
+
const inPartition = (e) => scopePath(recordScope(e, repo)) === scopePath(scope);
|
|
482
|
+
if (facet === "entities") {
|
|
483
|
+
const entity = record;
|
|
484
|
+
if (entity.merged_into !== undefined) {
|
|
485
|
+
const target = store.recs("entities").find((e) => e.id === entity.merged_into);
|
|
486
|
+
if (!target || !inPartition(target))
|
|
487
|
+
throw new StateRefusal("conflict", `merged_into ${entity.merged_into} is not an entity on record in ${scopePath(scope)}: a merge names a survivor that exists — write it first`, { incumbent_id: entity.merged_into, reason: "merge survivor absent" });
|
|
488
|
+
if (!granted(principal, recordScope(target, repo)))
|
|
489
|
+
throw new StateRefusal("outside-grants", `merged_into ${entity.merged_into} is outside the principal's grants`);
|
|
490
|
+
if (target.lifecycle !== "active")
|
|
491
|
+
throw new StateRefusal("conflict", `merged_into ${entity.merged_into} is ${target.lifecycle}${target.merged_into ? ` (merged into ${target.merged_into})` : ""}: the survivor of a merge is an active entity — merge into the one that stands now`, { incumbent_id: target.merged_into ?? target.id, reason: "merge survivor not active" });
|
|
492
|
+
}
|
|
493
|
+
if (entity.lifecycle !== "active")
|
|
494
|
+
return;
|
|
495
|
+
const keys = new Set(entity.refs.map(externalKey));
|
|
496
|
+
for (const other of store.recs("entities")) {
|
|
497
|
+
if (other.id === entity.id || other.lifecycle !== "active" || !inPartition(other))
|
|
498
|
+
continue;
|
|
499
|
+
const shared = other.refs.map(externalKey).find((k) => keys.has(k));
|
|
500
|
+
if (shared)
|
|
501
|
+
throw new StateRefusal("conflict", `${shared} is already carried by entity ${other.id} in ${scopePath(scope)}: one external record is one entity — write under ${other.id}, or retire it first (merge and split are explicit)`, { incumbent_id: other.id, reason: "one-entity-per-external-ref" });
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (facet !== "commitments" && facet !== "derived")
|
|
506
|
+
return;
|
|
507
|
+
const subject = record.subject;
|
|
508
|
+
const { bySubject, byId, survivor } = entityIndex(store, principal, repo);
|
|
509
|
+
const byKey = bySubject.get(subject);
|
|
510
|
+
if (byKey && byKey.id !== subject && inPartition(byKey)) {
|
|
511
|
+
throw new StateRefusal("identity", `subject ${subject} is the external key of entity ${byKey.id} in ${scopePath(scope)}: the entity's id is the subject — re-derive with subject ${byKey.id}`, { incumbent_id: byKey.id, reason: "subject is an entity's external key" });
|
|
512
|
+
}
|
|
513
|
+
const named = byId.get(subject);
|
|
514
|
+
if (named && named.lifecycle === "retired" && named.merged_into && inPartition(named)) {
|
|
515
|
+
const stands = survivor(named);
|
|
516
|
+
if (stands.id !== named.id && stands.lifecycle === "active") {
|
|
517
|
+
throw new StateRefusal("identity", `subject ${subject} was merged into ${stands.id}: new state goes under the survivor — re-derive with subject ${stands.id}`, { incumbent_id: stands.id, reason: "subject was merged" });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
410
521
|
/** Top-level fields whose canonical hash differs between two records, sorted. */
|
|
411
522
|
function differingFields(a, b) {
|
|
412
523
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
@@ -488,13 +599,14 @@ export function writeState(store, input, opts = {}) {
|
|
|
488
599
|
catch (e) {
|
|
489
600
|
throw new StateRefusal(/grants/.test(e.message) ? "outside-grants" : "malformed", e.message);
|
|
490
601
|
}
|
|
491
|
-
const { home, hunchDir, isPrivate } =
|
|
602
|
+
const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
|
|
492
603
|
const now = (opts.now ?? (() => new Date()))().toISOString();
|
|
493
604
|
const facet = request.facet;
|
|
494
605
|
if (!ENTITY_KINDS.includes(facet))
|
|
495
606
|
throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
|
|
496
607
|
const record = normalizeRecord(facet, request.scope, request.record, request.principal);
|
|
497
608
|
const id = record.id;
|
|
609
|
+
assertExternalIdentity(store, request.principal, request.scope, facet, record);
|
|
498
610
|
/** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
|
|
499
611
|
const hash = stateHash(record);
|
|
500
612
|
const ledger = readLedger(hunchDir, request.scope);
|
|
@@ -531,8 +643,42 @@ export function writeState(store, input, opts = {}) {
|
|
|
531
643
|
if (!ok)
|
|
532
644
|
throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
|
|
533
645
|
}
|
|
534
|
-
//
|
|
646
|
+
// human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
|
|
647
|
+
// Allowed for an agent: a replay (the same facts, the tier downgrade aside), a derived statement
|
|
648
|
+
// written back stale with the external cause that moved (the writer's currentness duty), a
|
|
649
|
+
// commitment closed by a receipt on record (a fact that happened) — both keep the human's
|
|
650
|
+
// provenance on the record. Everything else on a human-confirmed incumbent — in place or by
|
|
651
|
+
// supersession — is refused with the incumbent named.
|
|
535
652
|
let supersedes = request.supersedes ?? null;
|
|
653
|
+
if (request.principal.kind !== "human") {
|
|
654
|
+
const guard = (incumbent, how) => {
|
|
655
|
+
if (!incumbent || !isHumanConfirmed(incumbent))
|
|
656
|
+
return null;
|
|
657
|
+
const incumbentId = String(incumbent.id);
|
|
658
|
+
const changed = how === "overwrite" ? differingFields(incumbent, record).filter((f) => f !== "provenance") : ["a new record"];
|
|
659
|
+
if (how === "overwrite") {
|
|
660
|
+
if (changed.length === 0)
|
|
661
|
+
return "replay";
|
|
662
|
+
const staleWithCause = facet === "derived" && incumbent.state === "current" && record.state === "stale" && request.cause?.kind === "external"
|
|
663
|
+
&& changed.every((f) => f === "state" || f === "valid_to");
|
|
664
|
+
const closedByReceipt = facet === "commitments" && !!record.closed_by && record.status === "done"
|
|
665
|
+
&& changed.every((f) => f === "status" || f === "closed_by" || f === "valid_to");
|
|
666
|
+
if (staleWithCause || closedByReceipt)
|
|
667
|
+
return "keep-provenance";
|
|
668
|
+
}
|
|
669
|
+
throw new StateRefusal("conflict", `${incumbentId} was confirmed by a human; ${request.principal.kind === "agent" ? "an agent" : "a service"} principal may not ${how} it (differs in: ${changed.join(", ")}). A human writes the change, or the agent leaves the record as the human left it.`, { incumbent_id: incumbentId, reason: "human-confirmed incumbent" });
|
|
670
|
+
};
|
|
671
|
+
const verdict = guard(existing, "overwrite");
|
|
672
|
+
if (verdict === "replay") {
|
|
673
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now);
|
|
674
|
+
return result("replayed");
|
|
675
|
+
}
|
|
676
|
+
if (verdict === "keep-provenance")
|
|
677
|
+
record.provenance = existing.provenance;
|
|
678
|
+
if (supersedes && supersedes !== id)
|
|
679
|
+
guard(store.getRec(facet, supersedes), "supersede");
|
|
680
|
+
}
|
|
681
|
+
// one-live-decision-per-topic — refuse with the incumbent named; supersession is explicit.
|
|
536
682
|
if (facet === "decisions") {
|
|
537
683
|
const d = record;
|
|
538
684
|
if (d.topic && d.status === "accepted") {
|
|
@@ -580,6 +726,8 @@ export function writeState(store, input, opts = {}) {
|
|
|
580
726
|
// ledger says so, and names the external pointer that moved when the writer gives one.
|
|
581
727
|
const invalidated = facet === "derived" && !!existing && existing.state === "current" && record.state === "stale";
|
|
582
728
|
const invalidates = facet === "receipts" ? record.invalidates : [];
|
|
729
|
+
// An entity leaving service is a `retired` change (a merge names the survivor in the record).
|
|
730
|
+
const retired = facet === "entities" && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
|
|
583
731
|
const subject = subjectOf(facet, record);
|
|
584
732
|
if (supersedes) {
|
|
585
733
|
const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
|
|
@@ -588,7 +736,7 @@ export function writeState(store, input, opts = {}) {
|
|
|
588
736
|
changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
|
|
589
737
|
}
|
|
590
738
|
}
|
|
591
|
-
changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
|
|
739
|
+
changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
|
|
592
740
|
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now);
|
|
593
741
|
store.reindex();
|
|
594
742
|
return result(supersedes ? "superseded" : existing ? "updated" : "created");
|
|
@@ -600,7 +748,7 @@ export function subscribeState(store, input) {
|
|
|
600
748
|
const request = SubscribeRequestSchema.parse(input);
|
|
601
749
|
if (!granted(request.principal, request.scope))
|
|
602
750
|
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
603
|
-
const { hunchDir } =
|
|
751
|
+
const { hunchDir } = stateHomeFor(store, request.scope);
|
|
604
752
|
const ledger = readLedger(hunchDir, request.scope);
|
|
605
753
|
const facets = request.facets ? new Set(request.facets) : null;
|
|
606
754
|
const subjects = request.subjects ? new Set(request.subjects) : null;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Extensible local CLI transport. Executables/arguments come only from explicit user config. */
|
|
2
|
+
import spawn from "cross-spawn";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { assertInitiatorProvider, initiatorChildEnv } from "./initiator.js";
|
|
9
|
+
const adapterSchema = z.object({
|
|
10
|
+
name: z.string().regex(/^[a-z][a-z0-9-]{0,59}$/),
|
|
11
|
+
command: z.string().min(1).max(1000).refine(s => !/[\r\n\0]/.test(s)),
|
|
12
|
+
args: z.array(z.string().max(2000).refine(s => !/[\r\n\0]/.test(s))).max(40),
|
|
13
|
+
protocol: z.enum(["stdin", "acp"]),
|
|
14
|
+
probe_args: z.array(z.string().max(200).refine(s => !/[\r\n\0]/.test(s))).max(10).default(["--version"]),
|
|
15
|
+
timeout_ms: z.number().int().min(1000).max(600000).default(120000),
|
|
16
|
+
}).strict();
|
|
17
|
+
export function readAgentCliConfig(file) {
|
|
18
|
+
if (statSync(file).size > 64 * 1024)
|
|
19
|
+
throw new Error("CLI adapter config exceeds 64 KiB");
|
|
20
|
+
const adapters = z.array(adapterSchema).max(20).parse(JSON.parse(readFileSync(file, "utf8")));
|
|
21
|
+
if (new Set(adapters.map(a => a.name)).size !== adapters.length)
|
|
22
|
+
throw new Error("duplicate CLI adapter names");
|
|
23
|
+
return adapters;
|
|
24
|
+
}
|
|
25
|
+
/** ACP (Kimi and other agents) or plain stdin → JSON stdout for any user-configured CLI. */
|
|
26
|
+
export async function runAgentCli(adapterInput, prompt) {
|
|
27
|
+
const adapter = adapterSchema.parse(adapterInput);
|
|
28
|
+
assertInitiatorProvider(adapter.name);
|
|
29
|
+
const parent = realpathSync(tmpdir());
|
|
30
|
+
const cwd = mkdtempSync(join(parent, "hunch-agent-provider-"));
|
|
31
|
+
const child = spawn(adapter.command, adapter.args, { cwd, env: initiatorChildEnv(), windowsHide: true, stdio: "pipe" });
|
|
32
|
+
const stop = () => {
|
|
33
|
+
if (process.platform === "win32" && child.pid) {
|
|
34
|
+
execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }, () => { });
|
|
35
|
+
}
|
|
36
|
+
child.kill();
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
return await new Promise((resolve, reject) => {
|
|
40
|
+
let done = false;
|
|
41
|
+
let buffer = "";
|
|
42
|
+
let output = "";
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
let sequence = 0;
|
|
45
|
+
let sessionId;
|
|
46
|
+
const waiting = new Map();
|
|
47
|
+
const finish = (error) => {
|
|
48
|
+
if (done)
|
|
49
|
+
return;
|
|
50
|
+
done = true;
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
if (error)
|
|
53
|
+
reject(error);
|
|
54
|
+
else
|
|
55
|
+
resolve(output.trim());
|
|
56
|
+
};
|
|
57
|
+
const timer = setTimeout(() => { finish(new Error("CLI provider timed out")); stop(); }, adapter.timeout_ms);
|
|
58
|
+
const send = (message) => child.stdin.write(JSON.stringify(message) + "\n");
|
|
59
|
+
const request = (method, params, callback) => {
|
|
60
|
+
const id = ++sequence;
|
|
61
|
+
waiting.set(id, callback);
|
|
62
|
+
send({ jsonrpc: "2.0", id, method, params });
|
|
63
|
+
};
|
|
64
|
+
const receive = (line) => {
|
|
65
|
+
const message = JSON.parse(line);
|
|
66
|
+
if (message.method && message.id !== undefined) {
|
|
67
|
+
// No permission approvals, filesystem reads/writes or shell services are granted.
|
|
68
|
+
if (message.method === "session/request_permission") {
|
|
69
|
+
send({ jsonrpc: "2.0", id: message.id, result: { outcome: { outcome: "cancelled" } } });
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unavailable in review-only client" } });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (message.method === "session/update") {
|
|
76
|
+
const params = message.params;
|
|
77
|
+
if (params?.sessionId === sessionId && params.update?.sessionUpdate === "agent_message_chunk"
|
|
78
|
+
&& params.update.content?.type === "text" && typeof params.update.content.text === "string")
|
|
79
|
+
output += params.update.content.text;
|
|
80
|
+
}
|
|
81
|
+
else if (typeof message.id === "number" && waiting.has(message.id)) {
|
|
82
|
+
const callback = waiting.get(message.id);
|
|
83
|
+
waiting.delete(message.id);
|
|
84
|
+
if (message.error || !message.result || typeof message.result !== "object")
|
|
85
|
+
throw new Error("ACP request failed");
|
|
86
|
+
callback(message.result);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
child.on("error", () => finish(new Error("CLI provider could not start")));
|
|
90
|
+
child.stdin.on("error", () => finish(new Error("CLI provider input closed")));
|
|
91
|
+
child.stderr.on("data", () => { }); // drain without collecting credentials or raw prompts
|
|
92
|
+
child.stdout.setEncoding("utf8");
|
|
93
|
+
child.stdout.on("data", (chunk) => {
|
|
94
|
+
if (done)
|
|
95
|
+
return;
|
|
96
|
+
bytes += Buffer.byteLength(chunk);
|
|
97
|
+
if (bytes > 2 * 1024 * 1024) {
|
|
98
|
+
finish(new Error("CLI provider exceeded output budget"));
|
|
99
|
+
stop();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (adapter.protocol === "stdin") {
|
|
103
|
+
output += chunk;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
buffer += chunk;
|
|
107
|
+
try {
|
|
108
|
+
let newline;
|
|
109
|
+
while ((newline = buffer.indexOf("\n")) >= 0 && !done) {
|
|
110
|
+
const line = buffer.slice(0, newline).trim();
|
|
111
|
+
buffer = buffer.slice(newline + 1);
|
|
112
|
+
if (line)
|
|
113
|
+
receive(line);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
finish(new Error("Invalid ACP response"));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
child.on("close", code => {
|
|
121
|
+
if (adapter.protocol === "stdin" && code === 0)
|
|
122
|
+
finish();
|
|
123
|
+
else
|
|
124
|
+
finish(new Error("CLI provider exited before completing review"));
|
|
125
|
+
});
|
|
126
|
+
if (adapter.protocol === "stdin")
|
|
127
|
+
child.stdin.end(prompt);
|
|
128
|
+
else
|
|
129
|
+
request("initialize", { protocolVersion: 1, clientInfo: { name: "hunch-review-memory", version: "1" },
|
|
130
|
+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true } }, initialized => {
|
|
131
|
+
if (initialized.protocolVersion !== 1)
|
|
132
|
+
throw new Error("Unsupported ACP protocol");
|
|
133
|
+
request("session/new", { cwd, mcpServers: [] }, session => {
|
|
134
|
+
if (typeof session.sessionId !== "string" || !session.sessionId)
|
|
135
|
+
throw new Error("Missing ACP session");
|
|
136
|
+
sessionId = session.sessionId;
|
|
137
|
+
request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, result => {
|
|
138
|
+
if (result.stopReason !== "end_turn")
|
|
139
|
+
throw new Error("ACP turn did not complete");
|
|
140
|
+
finish();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
stop();
|
|
148
|
+
// Only remove the directory created for this invocation, after checking its resolved boundary.
|
|
149
|
+
const rel = relative(parent, realpathSync(cwd));
|
|
150
|
+
if (!isAbsolute(rel) && rel.startsWith("hunch-agent-provider-") && !rel.includes("/") && !rel.includes("\\")) {
|
|
151
|
+
try {
|
|
152
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
catch { /* transient Windows handles; never affect review result */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function discoverAgentClis(configured = [], initiator) {
|
|
159
|
+
const kimi = adapterSchema.parse({ name: "kimi-cli", command: "kimi", args: ["acp"], protocol: "acp" });
|
|
160
|
+
const adapters = [...configured, ...(configured.some(a => a.name === kimi.name) ? [] : [kimi])];
|
|
161
|
+
return adapters.filter(adapter => !initiator || adapter.name === initiator).filter(adapter => {
|
|
162
|
+
const result = spawn.sync(adapter.command, adapter.probe_args, {
|
|
163
|
+
cwd: tmpdir(), encoding: "utf8", windowsHide: true, timeout: 5000, maxBuffer: 64 * 1024,
|
|
164
|
+
});
|
|
165
|
+
return !result.error && result.status === 0;
|
|
166
|
+
}).map(adapter => ({ name: adapter.name, draftProse: prompt => runAgentCli(adapter, prompt) }));
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=cliAdapter.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const context = new AsyncLocalStorage();
|
|
3
|
+
const aliases = { claude: "claude-cli", codex: "codex-cli", cursor: "cursor-agent", kimi: "kimi-cli", ollama: "openai-compat" };
|
|
4
|
+
export function normalizeInitiator(name) {
|
|
5
|
+
const normalized = aliases[name] ?? name;
|
|
6
|
+
if (!/^[a-z][a-z0-9-]{0,59}$/.test(normalized) || ["auto", "available", "deterministic"].includes(normalized)) {
|
|
7
|
+
throw new Error("Initiator must identify one concrete agent provider.");
|
|
8
|
+
}
|
|
9
|
+
return normalized;
|
|
10
|
+
}
|
|
11
|
+
export function detectInitiator(env = process.env) {
|
|
12
|
+
if (env.HUNCH_INITIATOR === "unknown")
|
|
13
|
+
return { provider: null, source: "unknown" };
|
|
14
|
+
if (env.HUNCH_INITIATOR)
|
|
15
|
+
return { provider: normalizeInitiator(env.HUNCH_INITIATOR), source: "explicit" };
|
|
16
|
+
const names = new Set();
|
|
17
|
+
if (env.CODEX_THREAD_ID || env.CODEX_SESSION_ID)
|
|
18
|
+
names.add("codex-cli");
|
|
19
|
+
if (env.CLAUDECODE === "1")
|
|
20
|
+
names.add("claude-cli");
|
|
21
|
+
return { provider: names.size === 1 ? [...names][0] : null,
|
|
22
|
+
source: names.size === 1 ? "environment" : names.size > 1 ? "ambiguous" : "unknown" };
|
|
23
|
+
}
|
|
24
|
+
export function currentInitiator(env = process.env) {
|
|
25
|
+
return context.getStore() ?? detectInitiator(env);
|
|
26
|
+
}
|
|
27
|
+
export function withInitiator(initiator, work) {
|
|
28
|
+
return context.run(Object.freeze({ ...initiator }), work);
|
|
29
|
+
}
|
|
30
|
+
/** Bind the MCP client, not the process that happened to start the server. Unknown clients stay unknown. */
|
|
31
|
+
export function initiatorFromClient(name) {
|
|
32
|
+
const lower = name?.toLowerCase() ?? "";
|
|
33
|
+
const providers = [
|
|
34
|
+
[/\bclaude(?:[ _-]code)?\b/, "claude-cli"], [/\bcodex\b/, "codex-cli"],
|
|
35
|
+
[/\bcursor\b/, "cursor-agent"], [/\bkimi\b/, "kimi-cli"],
|
|
36
|
+
];
|
|
37
|
+
const matched = providers.filter(([pattern]) => pattern.test(lower));
|
|
38
|
+
return { provider: matched.length === 1 ? matched[0][1] : null,
|
|
39
|
+
source: matched.length > 1 ? "ambiguous" : "client" };
|
|
40
|
+
}
|
|
41
|
+
/** Freeze the operation's origin before spawning Git hooks or other deferred children. */
|
|
42
|
+
export function initiatorChildEnv(env = process.env) {
|
|
43
|
+
const origin = currentInitiator(env);
|
|
44
|
+
return { ...env, HUNCH_INITIATOR: origin.provider ?? "unknown" };
|
|
45
|
+
}
|
|
46
|
+
export function assertInitiatorProvider(provider) {
|
|
47
|
+
const origin = currentInitiator();
|
|
48
|
+
if (origin.provider && origin.provider !== provider)
|
|
49
|
+
throw new Error(`Initiator ${origin.provider} cannot launch ${provider}; refusing an account switch.`);
|
|
50
|
+
if (origin.source === "ambiguous")
|
|
51
|
+
throw new Error("Ambiguous initiating agent; refusing to launch another provider.");
|
|
52
|
+
if (origin.source === "client" && !origin.provider)
|
|
53
|
+
throw new Error("Unknown initiating MCP client; refusing to launch another provider.");
|
|
54
|
+
if (!origin.provider && (context.getStore() || process.env.HUNCH_INITIATOR === "unknown")) {
|
|
55
|
+
throw new Error("Unknown initiating event; refusing to launch another provider.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=initiator.js.map
|