@intentius/chant 0.88.0 → 0.90.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/dist/cli/handlers/serve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +20 -6
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +13 -5
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
- package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
- package/dist/cli/mcp/workspace-tools.d.ts +53 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +42 -2
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/vitest.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +35 -3
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +4 -1
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/source-block.d.ts +85 -0
- package/dist/workspace/source-block.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/serve.ts +11 -1
- package/src/cli/main.test.ts +7 -0
- package/src/cli/main.ts +40 -1
- package/src/cli/mcp/docs-parity.test.ts +20 -2
- package/src/cli/mcp/server.ts +32 -6
- package/src/cli/mcp/types.ts +15 -2
- package/src/cli/mcp/workspace-plugins.ts +123 -0
- package/src/cli/mcp/workspace-tools.test.ts +198 -0
- package/src/cli/mcp/workspace-tools.ts +405 -0
- package/src/cli/registry.ts +2 -0
- package/src/cli/serve-mcp-workspace.test.ts +142 -0
- package/src/op/op-verb-class.ts +6 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
- package/src/workspace/conformance/index.mjs +3 -0
- package/src/workspace/conformance/index.ts +185 -7
- package/src/workspace/conformance/vitest.ts +17 -8
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/records-amend.schema.json +2 -1
- package/src/workspace/records-close.schema.json +2 -1
- package/src/workspace/records-new.schema.json +4 -1
- package/src/workspace/records-review.schema.json +2 -1
- package/src/workspace/records-write.ts +85 -7
- package/src/workspace/records.schema.json +1 -0
- package/src/workspace/records.ts +28 -0
- package/src/workspace/source-block.test.ts +167 -0
- package/src/workspace/source-block.ts +129 -0
|
@@ -117,7 +117,8 @@
|
|
|
117
117
|
"asset-stale",
|
|
118
118
|
"record-supersedes-pending",
|
|
119
119
|
"record-no-evidence",
|
|
120
|
-
"review-undigested"
|
|
120
|
+
"review-undigested",
|
|
121
|
+
"source-transcript-drift"
|
|
121
122
|
]
|
|
122
123
|
},
|
|
123
124
|
"message": {
|
|
@@ -161,6 +162,8 @@
|
|
|
161
162
|
"record-id-unallocatable",
|
|
162
163
|
"record-path-unmatched",
|
|
163
164
|
"record-sign-failed",
|
|
165
|
+
"source-harvest-not-proposed",
|
|
166
|
+
"record-state-not-initial",
|
|
164
167
|
"record-unparseable",
|
|
165
168
|
"record-schema-invalid",
|
|
166
169
|
"record-id-duplicate",
|
|
@@ -67,6 +67,8 @@ export const NEW_ERROR_CODES = [
|
|
|
67
67
|
"record-id-unallocatable",
|
|
68
68
|
"record-path-unmatched",
|
|
69
69
|
"record-sign-failed",
|
|
70
|
+
"source-harvest-not-proposed",
|
|
71
|
+
"record-state-not-initial",
|
|
70
72
|
...RECORD_REASON_CODES,
|
|
71
73
|
] as const satisfies readonly ReasonCode[];
|
|
72
74
|
|
|
@@ -354,7 +356,7 @@ export async function readAll(o: Opened, source: RecordSource): Promise<RecordEn
|
|
|
354
356
|
const subjectKind = await loadRecordKind(resolve(dirname(o.loaded.file), o.loaded.kind.session.subjects.kind), o.root);
|
|
355
357
|
subjects = { records: (await readRecords(subjectKind, { root: o.root, source })).records, reviews: subjectKind.kind.reviews?.field ?? "reviews" };
|
|
356
358
|
}
|
|
357
|
-
return (await readRecords(o.loaded, { root: o.root, source, assets, ...(subjects ? { subjects } : {}) })).records;
|
|
359
|
+
return (await readRecords(o.loaded, { root: o.root, source, assets, workspaceRoot: o.workspaceRoot, ...(subjects ? { subjects } : {}) })).records;
|
|
358
360
|
}
|
|
359
361
|
|
|
360
362
|
/** `base` with the file at `path` holding `text`, added to its directory when new. */
|
|
@@ -441,6 +443,24 @@ function refuseRevisionFields(kind: LoadedRecordKind["kind"], fields: Record<str
|
|
|
441
443
|
}
|
|
442
444
|
}
|
|
443
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Refuse a harvested record in any state but the kind's first (#2708): a
|
|
448
|
+
* record whose source block says `via: "harvest"` came out of a transcript
|
|
449
|
+
* after the fact, so it is a proposal, and a person decides it.
|
|
450
|
+
*/
|
|
451
|
+
function refuseHarvestedDecision(kind: LoadedRecordKind["kind"], fields: Record<string, unknown>): void {
|
|
452
|
+
if (!kind.source || !kind.states || kind.stateField === undefined) return;
|
|
453
|
+
const block = fields[kind.source.field];
|
|
454
|
+
if (block === null || typeof block !== "object" || Array.isArray(block) || (block as Record<string, unknown>).via !== "harvest") return;
|
|
455
|
+
const opens = kind.states[0];
|
|
456
|
+
const state = fields[kind.stateField];
|
|
457
|
+
if (state === opens) return;
|
|
458
|
+
throw new RecordWriteError(
|
|
459
|
+
"source-harvest-not-proposed",
|
|
460
|
+
`${kind.source.field}.via is harvest, and a harvested ${kind.name} opens ${opens}, not ${JSON.stringify(state ?? null)}: write it with ${kind.stateField} "${opens}", and let the person who decides it move it on with chant workspace records amend or review`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
444
464
|
/**
|
|
445
465
|
* `text`, the record `data` holds, with its author sealed (#2688): an ssh
|
|
446
466
|
* signature by the key `sign` names over the record id, the digest of
|
|
@@ -594,14 +614,71 @@ export interface NewRecordOptions {
|
|
|
594
614
|
sign?: string | true;
|
|
595
615
|
}
|
|
596
616
|
|
|
617
|
+
/**
|
|
618
|
+
* A write made through a channel other than the command line (#2707), such
|
|
619
|
+
* as `chant serve mcp`. The CLI never sets one.
|
|
620
|
+
*/
|
|
621
|
+
export interface WriteChannel {
|
|
622
|
+
/**
|
|
623
|
+
* Laid over the kind's source block (#2708) when the kind declares one:
|
|
624
|
+
* `via` and `client`. A new record gets the block, made when the fields
|
|
625
|
+
* give none; an amendment only when its fields set the block.
|
|
626
|
+
*/
|
|
627
|
+
source: Record<string, unknown>;
|
|
628
|
+
/** A new record opens in the kind's first state: filled in when the fields give no state, and refused with record-state-not-initial when they give another. */
|
|
629
|
+
opensInitial?: boolean;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** What a channel's write adds to the command line's: who the author is, and how it came. */
|
|
633
|
+
export interface ChannelOptions {
|
|
634
|
+
/** The record's author, written to the kind's `reviews.decider` field: the person or agent that decided. */
|
|
635
|
+
by?: string;
|
|
636
|
+
through?: WriteChannel;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** `fields` with the channel's author and source laid over them (#2707). `isNew` for `records new`. */
|
|
640
|
+
function applyChannel(o: Opened, fields: Record<string, unknown>, opts: ChannelOptions, isNew: boolean, flag: string): Record<string, unknown> {
|
|
641
|
+
const { kind } = o.loaded;
|
|
642
|
+
const out = { ...fields };
|
|
643
|
+
if (opts.by !== undefined) {
|
|
644
|
+
if (!kind.reviews) throw new RecordWriteError("write-usage-invalid", `by names a record's author, the kind's reviews.decider field, and the ${kind.name} kind declares no reviews`);
|
|
645
|
+
if (opts.by.trim() === "") throw new RecordWriteError("write-usage-invalid", "by needs the name of the person or agent that decided");
|
|
646
|
+
const f = kind.reviews.decider;
|
|
647
|
+
if (out[f] !== undefined && out[f] !== null && out[f] !== opts.by) {
|
|
648
|
+
throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${f} to ${JSON.stringify(out[f])}, and by names ${JSON.stringify(opts.by)}: give one author`);
|
|
649
|
+
}
|
|
650
|
+
out[f] = opts.by;
|
|
651
|
+
}
|
|
652
|
+
const through = opts.through;
|
|
653
|
+
if (!through) return out;
|
|
654
|
+
if (isNew && through.opensInitial && kind.states && kind.stateField !== undefined) {
|
|
655
|
+
const first = kind.states[0];
|
|
656
|
+
const state = out[kind.stateField];
|
|
657
|
+
if (state === undefined) out[kind.stateField] = first;
|
|
658
|
+
else if (state !== first) {
|
|
659
|
+
throw new RecordWriteError(
|
|
660
|
+
"record-state-not-initial",
|
|
661
|
+
`a new ${kind.name} opens ${first}, and the fields give ${kind.stateField} ${JSON.stringify(state)}: write it ${first}, and let the person who decides it move it on with a review or an amendment`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (kind.source && (isNew || kind.source.field in out)) {
|
|
666
|
+
const f = kind.source.field;
|
|
667
|
+
const block = out[f];
|
|
668
|
+
if (block === undefined || block === null) out[f] = { ...through.source };
|
|
669
|
+
else if (typeof block === "object" && !Array.isArray(block)) out[f] = { ...(block as Record<string, unknown>), ...through.source };
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
|
|
597
674
|
/** `records new`: write one new record from validated fields, sealed by its author with `sign`. */
|
|
598
|
-
export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
|
|
675
|
+
export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promise<NewDocument> {
|
|
599
676
|
try {
|
|
600
677
|
if (opts.prefix !== undefined && !/^[A-Za-z][A-Za-z0-9]*$/.test(opts.prefix)) {
|
|
601
678
|
throw new RecordWriteError("write-usage-invalid", `--prefix takes letters and digits, starting with a letter, not ${JSON.stringify(opts.prefix)}`);
|
|
602
679
|
}
|
|
603
|
-
const fields = parseFields(opts.fields, "--from");
|
|
604
680
|
const o = await open(opts.kind, opts.cwd);
|
|
681
|
+
const fields = applyChannel(o, parseFields(opts.fields, "--from"), opts, true, "--from");
|
|
605
682
|
const { kind, schema } = o.loaded;
|
|
606
683
|
refuseSealField(o, fields, "--from");
|
|
607
684
|
refuseRevisionFields(kind, fields, {}, "--from");
|
|
@@ -617,6 +694,7 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
|
|
|
617
694
|
if (taken) throw new RecordWriteError("record-id-taken", `id ${given} is already used by ${taken.path}; ids are never reused, so leave ${idField} out to have the next one allocated`);
|
|
618
695
|
id = given;
|
|
619
696
|
}
|
|
697
|
+
refuseHarvestedDecision(kind, fields);
|
|
620
698
|
// A session records the commit it opened at (#2693): HEAD now, or null before the first commit.
|
|
621
699
|
const opened = kind.session?.openedRev ? { [kind.session.openedRev]: headCommit(o.root) } : {};
|
|
622
700
|
const full = { ...fields, ...opened, [idField]: id };
|
|
@@ -676,10 +754,10 @@ export interface AmendRecordOptions {
|
|
|
676
754
|
* changes only the reviews leaves the digest, and the seal, as they were.
|
|
677
755
|
* `--sign` with nothing to change seals the record as it is.
|
|
678
756
|
*/
|
|
679
|
-
export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocument> {
|
|
757
|
+
export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Promise<AmendDocument> {
|
|
680
758
|
try {
|
|
681
|
-
const given = parseFields(opts.fields, "--set");
|
|
682
759
|
const o = await open(opts.kind, opts.cwd);
|
|
760
|
+
const given = applyChannel(o, parseFields(opts.fields, "--set"), opts, false, "--set");
|
|
683
761
|
const { kind } = o.loaded;
|
|
684
762
|
refuseSealField(o, given, "--set");
|
|
685
763
|
const before = await readAll(o, o.source);
|
|
@@ -922,7 +1000,7 @@ function usage(schema: string, message: string): WriteFailure<"write-usage-inval
|
|
|
922
1000
|
* message the verb has always given; several are refused, since a write
|
|
923
1001
|
* never guesses which kind it means.
|
|
924
1002
|
*/
|
|
925
|
-
function declaredWriteKind(schema: string, cwd: string, missing: string): string | WriteFailure<"write-usage-invalid"> {
|
|
1003
|
+
export function declaredWriteKind(schema: string, cwd: string, missing: string): string | WriteFailure<"write-usage-invalid"> {
|
|
926
1004
|
let kinds: ReturnType<typeof declaredKindFiles>;
|
|
927
1005
|
try {
|
|
928
1006
|
kinds = declaredKindFiles(cwd);
|
|
@@ -978,7 +1056,7 @@ function readInput(schema: string, flag: string, value: string | undefined, cwd:
|
|
|
978
1056
|
* The session kind `records close` goes through when none is named (#2693):
|
|
979
1057
|
* the one session kind the declaration names.
|
|
980
1058
|
*/
|
|
981
|
-
async function declaredSessionKind(schema: string, cwd: string): Promise<string | WriteFailure<"write-usage-invalid">> {
|
|
1059
|
+
export async function declaredSessionKind(schema: string, cwd: string): Promise<string | WriteFailure<"write-usage-invalid">> {
|
|
982
1060
|
const kinds = (await findSessionKinds(cwd)).map((k) => k.file);
|
|
983
1061
|
if (kinds.length === 1) return kinds[0];
|
|
984
1062
|
if (kinds.length === 0) return usage(schema, "--kind <session kind file> is required: the declaration names no session kind");
|
package/src/workspace/records.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type { ReasonCode } from "./reason-codes";
|
|
|
29
29
|
import { checkPins, pinEntries, type AssetPin } from "./record-assets";
|
|
30
30
|
import { joinSessions, type SessionCitation } from "./record-sessions";
|
|
31
31
|
import type { RecordSource } from "./record-source";
|
|
32
|
+
import { sourceBlock, sourceBlockProblems, transcriptDrift } from "./source-block";
|
|
32
33
|
import type { WorkspaceTree } from "./tree";
|
|
33
34
|
import type { DecisionWork, WorkLink, WorkWarningCode } from "./work";
|
|
34
35
|
|
|
@@ -84,6 +85,12 @@ export const RECORD_WARNING_CODES = [
|
|
|
84
85
|
* amendment does not stop it counting (#2672).
|
|
85
86
|
*/
|
|
86
87
|
"review-undigested",
|
|
88
|
+
/**
|
|
89
|
+
* The kind's source block pins a transcript by hash, the file it names can
|
|
90
|
+
* be read here, and its bytes hash to something else: it is not the
|
|
91
|
+
* transcript the record means (#2708).
|
|
92
|
+
*/
|
|
93
|
+
"source-transcript-drift",
|
|
87
94
|
] as const satisfies readonly ReasonCode[];
|
|
88
95
|
export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
|
|
89
96
|
|
|
@@ -299,6 +306,15 @@ export const recordKindSchema = z
|
|
|
299
306
|
* `withdrawn_on`. Optional.
|
|
300
307
|
*/
|
|
301
308
|
reviews: z.object({ field: z.string().min(1), decider: z.string().min(1) }).strict().optional(),
|
|
309
|
+
/**
|
|
310
|
+
* The front-matter object that says where a record came from, opted in
|
|
311
|
+
* to the stored source block (#2708): `via`, `client`, `harness`,
|
|
312
|
+
* `model`, `session`, `turns` and `transcript`, beside the kind's own
|
|
313
|
+
* fields in the same object. With it, those fields are checked for their
|
|
314
|
+
* shapes, a write with `via: "harvest"` must open in the kind's first
|
|
315
|
+
* state, and `records` warns `source-transcript-drift`. Optional.
|
|
316
|
+
*/
|
|
317
|
+
source: z.object({ field: z.string().min(1) }).strict().optional(),
|
|
302
318
|
/**
|
|
303
319
|
* A review-session kind (#2673, #2650 C10): the front-matter list of the
|
|
304
320
|
* verdicts a session produced, the field that seals a closed session, and
|
|
@@ -1076,6 +1092,8 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
1076
1092
|
const match = new RegExp(kind.location.match);
|
|
1077
1093
|
const validate = await compileSchema(loaded.schema, loaded.refs);
|
|
1078
1094
|
const workspaceRoot = options.workspaceRoot ?? ".";
|
|
1095
|
+
// Where a relative transcript path resolves (#2708): the workspace root in the working tree.
|
|
1096
|
+
const workspaceDir = workspaceRoot === "." ? options.root : resolve(options.root, ...workspaceRoot.split("/"));
|
|
1079
1097
|
|
|
1080
1098
|
const entries: RecordEntry[] = [];
|
|
1081
1099
|
const texts = new Map<string, string>();
|
|
@@ -1127,6 +1145,16 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
1127
1145
|
if (!result.ok) {
|
|
1128
1146
|
entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
|
|
1129
1147
|
}
|
|
1148
|
+
if (kind.source) {
|
|
1149
|
+
// The stored source block (#2708): its proposal fields' shapes, and the transcript it pins.
|
|
1150
|
+
const block = sourceBlock(fm.value, kind.source.field);
|
|
1151
|
+
if (block) {
|
|
1152
|
+
const problems = sourceBlockProblems(block, kind.source.field);
|
|
1153
|
+
if (problems.length > 0) entry.reasons.push({ code: "record-schema-invalid", message: problems.join("; ") });
|
|
1154
|
+
const drift = transcriptDrift(block, kind.source.field, workspaceDir);
|
|
1155
|
+
if (drift) entry.warnings.push({ code: "source-transcript-drift", message: drift });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1130
1158
|
if (kind.pins) {
|
|
1131
1159
|
const cited = fm.value[kind.pins.field];
|
|
1132
1160
|
if (Array.isArray(cited) && cited.length === 0) {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A record's stored source block (#2708): the decision schema documents it,
|
|
3
|
+
* records new writes it and records returns it unchanged, a harvested record
|
|
4
|
+
* opens proposed, and records warns source-transcript-drift when the pinned
|
|
5
|
+
* transcript is here and hashes to something else.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
14
|
+
import { parseFrontMatter, type RecordEntry } from "./records";
|
|
15
|
+
import { queryRecords } from "./records-cli";
|
|
16
|
+
import { newRecord, renderRecord, stableJson } from "./records-write";
|
|
17
|
+
import { sourceBlockProblems, transcriptDrift, transcriptFile } from "./source-block";
|
|
18
|
+
|
|
19
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
20
|
+
const DECISIONS = join(REPO, "docs", "design", "decisions");
|
|
21
|
+
const KIND = "decisions/decision.kind.mjs";
|
|
22
|
+
|
|
23
|
+
type Data = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
const SAMPLE = (() => {
|
|
26
|
+
const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
|
|
27
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
28
|
+
return fm.value;
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
/** A proposed decision like ws-003, with `over` laid on top. */
|
|
32
|
+
function proposal(over: Data = {}): Data {
|
|
33
|
+
const d: Data = { ...structuredClone(SAMPLE), state: "proposed", choice: null, decided_by: null, decided_on: null, reviews: [], ...over };
|
|
34
|
+
delete d.id;
|
|
35
|
+
return d;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sha = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
39
|
+
const TRANSCRIPT = '{"turn":1,"role":"user","text":"which way do we deploy?"}\n';
|
|
40
|
+
|
|
41
|
+
const HARNESS = {
|
|
42
|
+
via: "mcp",
|
|
43
|
+
client: { name: "claude-code", version: "2.1.0" },
|
|
44
|
+
harness: "claude-code",
|
|
45
|
+
model: "claude-opus-5-5",
|
|
46
|
+
session: { id: "0f4c2a", record: "S-0002" },
|
|
47
|
+
turns: { from: 12, to: 18 },
|
|
48
|
+
transcript: { path: "transcripts/0f4c2a.jsonl", sha256: sha(TRANSCRIPT) },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let dir: string;
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-source-block-")));
|
|
54
|
+
mkdirSync(join(dir, "decisions"));
|
|
55
|
+
mkdirSync(join(dir, "transcripts"));
|
|
56
|
+
cpSync(join(DECISIONS, "decision.kind.mjs"), join(dir, "decisions", "decision.kind.mjs"));
|
|
57
|
+
cpSync(join(DECISIONS, "decision.schema.json"), join(dir, "decisions", "decision.schema.json"));
|
|
58
|
+
writeFileSync(join(dir, "decisions", "ws-001-one.md"), renderRecord({ ...proposal({ title: "One" }), id: "ws-001" }, "\n# One\n"));
|
|
59
|
+
writeFileSync(join(dir, "transcripts", "0f4c2a.jsonl"), TRANSCRIPT);
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
62
|
+
|
|
63
|
+
const code = (doc: object) => ("error" in doc ? (doc as { error: { code: string } }).error.code : "ok");
|
|
64
|
+
|
|
65
|
+
async function read(): Promise<RecordEntry[]> {
|
|
66
|
+
const doc = await queryRecords({ kind: KIND, cwd: dir });
|
|
67
|
+
if (!("records" in doc)) throw new Error(JSON.stringify(doc));
|
|
68
|
+
return doc.records;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function write(source: unknown, over: Data = {}) {
|
|
72
|
+
return newRecord({ kind: KIND, fields: JSON.stringify(proposal({ title: "Two", source, ...over })), cwd: dir });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("the decision schema documents the block", () => {
|
|
76
|
+
test("each form takes the proposal fields, and a harness form needs via", async () => {
|
|
77
|
+
for (const source of [
|
|
78
|
+
HARNESS,
|
|
79
|
+
{ ...HARNESS, issue: "INTENTIUS/chant#2708" },
|
|
80
|
+
{ issue: "INTENTIUS/chant#2708", row: "Source", revision: null, ...HARNESS },
|
|
81
|
+
{ kind: "workspace", member: "app", ...HARNESS, session: "S-0001" },
|
|
82
|
+
{ via: "cli" },
|
|
83
|
+
]) {
|
|
84
|
+
const doc = await write(source, { title: `T ${Math.random()}` });
|
|
85
|
+
expect(code(doc), JSON.stringify(source)).toBe("ok");
|
|
86
|
+
}
|
|
87
|
+
const { via: _via, ...noVia } = HARNESS;
|
|
88
|
+
for (const source of [
|
|
89
|
+
noVia,
|
|
90
|
+
{ ...HARNESS, via: "email" },
|
|
91
|
+
{ ...HARNESS, transcript: { path: "a", uri: "file:///a", sha256: sha("a") } },
|
|
92
|
+
{ ...HARNESS, transcript: { path: "a", sha256: "ABC" } },
|
|
93
|
+
{ ...HARNESS, turns: { from: 3 } },
|
|
94
|
+
{ ...HARNESS, session: {} },
|
|
95
|
+
{ ...HARNESS, client: { version: "1" } },
|
|
96
|
+
{ ...HARNESS, row: "a row without an issue" },
|
|
97
|
+
]) {
|
|
98
|
+
const doc = await write(source);
|
|
99
|
+
expect(code(doc), JSON.stringify(source)).toBe("record-schema-invalid");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("chant checks the shapes the schema can't: turns that end before they start", async () => {
|
|
104
|
+
const doc = await write({ ...HARNESS, turns: { from: 18, to: 12 } });
|
|
105
|
+
expect(doc).toMatchObject({ error: { code: "record-schema-invalid", message: expect.stringContaining("/source/turns ends before it starts") } });
|
|
106
|
+
expect(sourceBlockProblems({ ...HARNESS, turns: { from: 18, to: 12 } }, "source")).toEqual(["/source/turns ends before it starts"]);
|
|
107
|
+
expect(sourceBlockProblems({ issue: "o/r#1", row: "anything the kind says" }, "source")).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("records new and records", () => {
|
|
112
|
+
test("records new --from with a source block writes it, and records --json returns it unchanged", async () => {
|
|
113
|
+
const doc = await write(HARNESS);
|
|
114
|
+
expect(code(doc)).toBe("ok");
|
|
115
|
+
const written = (await read()).find((r) => r.id === "ws-002");
|
|
116
|
+
expect(stableJson(written?.data?.source)).toBe(stableJson(HARNESS));
|
|
117
|
+
expect(written?.valid).toBe(true);
|
|
118
|
+
expect(written?.warnings.map((w) => w.code)).not.toContain("source-transcript-drift");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a harvested record opens proposed; any other state is refused with a remedy", async () => {
|
|
122
|
+
const decided = await write(
|
|
123
|
+
{ ...HARNESS, via: "harvest" },
|
|
124
|
+
{ state: "decided", choice: SAMPLE.choice, decided_by: "alice", decided_on: "2026-09-25" },
|
|
125
|
+
);
|
|
126
|
+
expect(decided).toMatchObject({ error: { code: "source-harvest-not-proposed", message: expect.stringContaining('write it with state "proposed"') } });
|
|
127
|
+
const withdrawn = await write({ ...HARNESS, via: "harvest" }, { state: "withdrawn" });
|
|
128
|
+
expect(code(withdrawn)).toBe("source-harvest-not-proposed");
|
|
129
|
+
expect(code(await write({ ...HARNESS, via: "harvest" }))).toBe("ok");
|
|
130
|
+
// Only a harvest opens proposed: a decision written at the CLI may still be decided in one go.
|
|
131
|
+
const cli = await write({ via: "cli" }, { title: "Three", state: "decided", choice: SAMPLE.choice, decided_by: "alice", decided_on: "2026-09-25" });
|
|
132
|
+
expect(code(cli)).toBe("ok");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("source-transcript-drift", () => {
|
|
137
|
+
test("warns when the transcript is here and its bytes changed, and not when they match or it is missing", async () => {
|
|
138
|
+
expect(code(await write(HARNESS))).toBe("ok");
|
|
139
|
+
const drift = async () => (await read()).find((r) => r.id === "ws-002")!.warnings.filter((w) => w.code === "source-transcript-drift");
|
|
140
|
+
expect(await drift()).toEqual([]);
|
|
141
|
+
writeFileSync(join(dir, "transcripts", "0f4c2a.jsonl"), TRANSCRIPT + '{"turn":2}\n');
|
|
142
|
+
const warned = await drift();
|
|
143
|
+
expect(warned).toHaveLength(1);
|
|
144
|
+
expect(warned[0].message).toContain("transcripts/0f4c2a.jsonl is pinned at sha256");
|
|
145
|
+
rmSync(join(dir, "transcripts", "0f4c2a.jsonl"));
|
|
146
|
+
expect(await drift()).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("reads an absolute path, ~/ and file: URIs, and never fetches another URI", () => {
|
|
150
|
+
const file = join(dir, "transcripts", "0f4c2a.jsonl");
|
|
151
|
+
const bad = "0".repeat(64);
|
|
152
|
+
expect(transcriptDrift({ transcript: { path: file, sha256: sha(TRANSCRIPT) } }, "source", "/nowhere")).toBeUndefined();
|
|
153
|
+
expect(transcriptDrift({ transcript: { path: file, sha256: bad } }, "source", "/nowhere")).toContain("is pinned at sha256 000000000000");
|
|
154
|
+
expect(transcriptDrift({ transcript: { uri: pathToFileURL(file).href, sha256: bad } }, "source", "/nowhere")).toContain("file:");
|
|
155
|
+
expect(transcriptDrift({ transcript: { uri: "https://example.com/t.jsonl", sha256: bad } }, "source", dir)).toBeUndefined();
|
|
156
|
+
expect(transcriptFile({ path: "~/t.jsonl" }, dir)).toMatch(/\/t\.jsonl$/);
|
|
157
|
+
expect(transcriptFile({ path: "t.jsonl" }, dir)).toBe(join(dir, "t.jsonl"));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a kind that does not opt in is not checked", async () => {
|
|
161
|
+
const kindFile = join(dir, "decisions", "decision.kind.mjs");
|
|
162
|
+
writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace(/\n\s*source: \{ field: "source" \},/, ""));
|
|
163
|
+
expect(code(await write({ ...HARNESS, turns: { from: 18, to: 12 } }))).toBe("ok");
|
|
164
|
+
writeFileSync(join(dir, "transcripts", "0f4c2a.jsonl"), "changed");
|
|
165
|
+
expect((await read()).flatMap((r) => r.warnings.map((w) => w.code))).not.toContain("source-transcript-drift");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A record's stored source block (#2708): where a proposal came from.
|
|
3
|
+
*
|
|
4
|
+
* chant's provenance (`trust/provenance.ts`) is computed from git: who
|
|
5
|
+
* committed a record, and with which key. It says nothing about the harness,
|
|
6
|
+
* model and session a decision was proposed in, or the conversation behind it.
|
|
7
|
+
* A record kind that declares `source: { field }` opts in to a stored block
|
|
8
|
+
* holding that, next to whatever else its source field says (a decision's
|
|
9
|
+
* issue row or workspace member, a work item's finding):
|
|
10
|
+
*
|
|
11
|
+
* - `via`: how the record reached the workspace, `cli`, `mcp` or `harvest`;
|
|
12
|
+
* - `client`: the MCP client's `clientInfo` (`name`, `version`), or the CLI;
|
|
13
|
+
* - `harness`: the harness's id, such as `claude-code` or `codex`;
|
|
14
|
+
* - `model`: the model id the harness reports;
|
|
15
|
+
* - `session`: the harness's session or conversation id, as a string, or as
|
|
16
|
+
* `{ id, record }` with the chant session record it was held in (#2697);
|
|
17
|
+
* - `turns`: the turn range the decision was made in, `{ from, to }`;
|
|
18
|
+
* - `transcript`: `{ path | uri, sha256 }`, pinning a transcript by hash and
|
|
19
|
+
* never copying it, the way evidence pins a file.
|
|
20
|
+
*
|
|
21
|
+
* Every field is optional, and the block is data about the proposal, not
|
|
22
|
+
* trust: nothing here raises or lowers a record's standing. The rules are
|
|
23
|
+
* three. The fields that are present must have these shapes
|
|
24
|
+
* (`record-schema-invalid` otherwise, beside the kind's own schema). A record
|
|
25
|
+
* written with `via: "harvest"` must open in the kind's first state, since a
|
|
26
|
+
* harvest proposes and a person decides (`source-harvest-not-proposed`, a
|
|
27
|
+
* write refusal). And `records` warns `source-transcript-drift` when the
|
|
28
|
+
* pinned transcript can be read here and its bytes hash to something else.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import { sha256Hex } from "../content-digest";
|
|
37
|
+
|
|
38
|
+
/** How a record reached the workspace. Closed. */
|
|
39
|
+
export const SOURCE_VIAS = ["cli", "mcp", "harvest"] as const;
|
|
40
|
+
export type SourceVia = (typeof SOURCE_VIAS)[number];
|
|
41
|
+
|
|
42
|
+
const text = z.string().min(1);
|
|
43
|
+
const sha256 = z.string().regex(/^[0-9a-f]{64}$/, "must be the lowercase hex SHA-256 of the transcript's bytes");
|
|
44
|
+
|
|
45
|
+
/** The proposal fields of a source block. Other fields of the block are the kind's, and left to its schema. */
|
|
46
|
+
export const sourceBlockSchema = z.object({
|
|
47
|
+
via: z.enum(SOURCE_VIAS).optional(),
|
|
48
|
+
client: z.object({ name: text, version: text.optional(), title: text.optional() }).strict().optional(),
|
|
49
|
+
harness: text.optional(),
|
|
50
|
+
model: text.optional(),
|
|
51
|
+
session: z
|
|
52
|
+
.union([
|
|
53
|
+
text,
|
|
54
|
+
z.null(),
|
|
55
|
+
z
|
|
56
|
+
.object({ id: text.optional(), record: text.optional() })
|
|
57
|
+
.strict()
|
|
58
|
+
.refine((s) => s.id !== undefined || s.record !== undefined, { message: "names neither the harness's session id nor a chant session record" }),
|
|
59
|
+
])
|
|
60
|
+
.optional(),
|
|
61
|
+
turns: z
|
|
62
|
+
.object({ from: z.number().int().min(0), to: z.number().int().min(0) })
|
|
63
|
+
.strict()
|
|
64
|
+
.refine((t) => t.to >= t.from, { message: "ends before it starts" })
|
|
65
|
+
.optional(),
|
|
66
|
+
transcript: z
|
|
67
|
+
.union([z.object({ path: text, sha256 }).strict(), z.object({ uri: text, sha256 }).strict()])
|
|
68
|
+
.optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export type SourceBlock = z.infer<typeof sourceBlockSchema>;
|
|
72
|
+
|
|
73
|
+
/** The source block of `data`, when `field` holds an object. */
|
|
74
|
+
export function sourceBlock(data: Record<string, unknown> | null, field: string): Record<string, unknown> | undefined {
|
|
75
|
+
const v = data?.[field];
|
|
76
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What is wrong with the proposal fields of `block`, as `<path> <message>` lines. Empty when nothing is. */
|
|
80
|
+
export function sourceBlockProblems(block: Record<string, unknown>, field: string): string[] {
|
|
81
|
+
const parsed = sourceBlockSchema.safeParse(block);
|
|
82
|
+
if (parsed.success) return [];
|
|
83
|
+
return parsed.error.issues.map((i) => `/${[field, ...i.path].join("/")} ${i.message}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The file a transcript pin names, when it can be read here: a `path`
|
|
88
|
+
* (absolute, `~/` from the home directory, or else from the workspace root)
|
|
89
|
+
* or a `file:` URI. Any other URI is not fetched, so it is never reachable.
|
|
90
|
+
*/
|
|
91
|
+
export function transcriptFile(transcript: { path?: string; uri?: string }, workspaceDir: string): string | undefined {
|
|
92
|
+
if (transcript.path !== undefined) {
|
|
93
|
+
const p = transcript.path;
|
|
94
|
+
if (p === "~" || p.startsWith("~/")) return join(homedir(), p.slice(1));
|
|
95
|
+
return isAbsolute(p) ? p : resolve(workspaceDir, p);
|
|
96
|
+
}
|
|
97
|
+
if (transcript.uri !== undefined && transcript.uri.startsWith("file:")) {
|
|
98
|
+
try {
|
|
99
|
+
return fileURLToPath(transcript.uri);
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The `source-transcript-drift` message for `block`, or undefined: when its
|
|
109
|
+
* transcript pin is well formed, the file it names can be read here, and its
|
|
110
|
+
* bytes hash to something other than the pin. A transcript that can't be
|
|
111
|
+
* read says nothing either way.
|
|
112
|
+
*/
|
|
113
|
+
export function transcriptDrift(block: Record<string, unknown>, field: string, workspaceDir: string): string | undefined {
|
|
114
|
+
const parsed = sourceBlockSchema.shape.transcript.safeParse(block.transcript);
|
|
115
|
+
if (!parsed.success || parsed.data === undefined) return undefined;
|
|
116
|
+
const t = parsed.data as { path?: string; uri?: string; sha256: string };
|
|
117
|
+
const file = transcriptFile(t, workspaceDir);
|
|
118
|
+
if (file === undefined) return undefined;
|
|
119
|
+
let bytes: Uint8Array;
|
|
120
|
+
try {
|
|
121
|
+
bytes = readFileSync(file);
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const actual = sha256Hex(bytes);
|
|
126
|
+
if (actual === t.sha256) return undefined;
|
|
127
|
+
const named = t.path ?? t.uri!;
|
|
128
|
+
return `${field}.transcript ${named} is pinned at sha256 ${t.sha256.slice(0, 12)}, and the file here hashes to ${actual.slice(0, 12)}: it is not the transcript the record means`;
|
|
129
|
+
}
|