@intentius/chant 0.93.0 → 0.94.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/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
- package/dist/workspace/box-intent.d.ts +85 -0
- package/dist/workspace/box-intent.d.ts.map +1 -0
- package/dist/workspace/chant-migrations.d.ts +5 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -1
- package/dist/workspace/checks/boxes.d.ts +14 -1
- package/dist/workspace/checks/boxes.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/decision-points.schema.json +3 -3
- package/dist/workspace/declaration.d.ts +6 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +6 -1
- package/dist/workspace/intent-joins.d.ts +5 -5
- package/dist/workspace/points-cli.d.ts +2 -0
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points.d.ts +3 -5
- package/dist/workspace/points.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +8 -2
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +8 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.ts +4 -3
- package/src/cli/mcp/workspace-plugins.ts +6 -6
- package/src/cli/serve-mcp-workspace.test.ts +6 -6
- package/src/op/activities/decide.test.ts +8 -0
- package/src/op/steward-points.test.ts +51 -0
- package/src/workspace/box-intent.test.ts +205 -0
- package/src/workspace/box-intent.ts +159 -0
- package/src/workspace/chant-migrations.ts +5 -0
- package/src/workspace/check.schema.json +7 -3
- package/src/workspace/checks/boxes.test.ts +2 -1
- package/src/workspace/checks/boxes.ts +66 -0
- package/src/workspace/checks.ts +12 -1
- package/src/workspace/decision-points.schema.json +3 -3
- package/src/workspace/declaration.schema.json +6 -1
- package/src/workspace/declaration.ts +8 -0
- package/src/workspace/declared-kinds.test.ts +26 -0
- package/src/workspace/intent-joins.test.ts +5 -5
- package/src/workspace/intent-joins.ts +5 -5
- package/src/workspace/points-cli.ts +3 -0
- package/src/workspace/points.schema.json +18 -0
- package/src/workspace/points.test.ts +15 -14
- package/src/workspace/points.ts +4 -16
- package/src/workspace/read-contract.test.ts +8 -0
- package/src/workspace/reason-codes.test.ts +7 -7
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/records-cli.ts +14 -1
- package/src/workspace/records-formats.test.ts +15 -15
- package/src/workspace/records-since.test.ts +8 -7
- package/src/workspace/records.test.ts +1 -1
- package/src/workspace/status-contract.test.ts +1 -0
- package/src/workspace/status-stewards.ts +49 -3
- package/src/workspace/status.schema.json +19 -2
- package/src/workspace/status.ts +10 -0
- package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
- package/src/workspace/work.test.ts +17 -0
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* kinds without a lifecycle, a supersedes field of bare ids, schema files a
|
|
4
4
|
* schema `$ref`s, and content-addressed ids.
|
|
5
5
|
*
|
|
6
|
-
* The fixtures are shaped like
|
|
7
|
-
* evidence named by the hash of its
|
|
8
|
-
* Markdown contracts, with draft-07
|
|
9
|
-
* `defs.schema.json`), trimmed to the fields
|
|
10
|
-
* through a kind file alone: no reader code.
|
|
6
|
+
* The fixtures are shaped like a development-model plugin's records (ported
|
|
7
|
+
* from chud, jhgaylor/chud#78: units, evidence named by the hash of its
|
|
8
|
+
* bytes, sessions, driver closures and Markdown contracts, with draft-07
|
|
9
|
+
* schemas that `$ref` a shared `defs.schema.json`), trimmed to the fields
|
|
10
|
+
* that matter here. Each one reads through a kind file alone: no reader code.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { execFileSync } from "node:child_process";
|
|
@@ -29,7 +29,7 @@ const validateOutput = new Ajv2020({ strict: true, allErrors: true }).compile(sc
|
|
|
29
29
|
let root: string;
|
|
30
30
|
beforeEach(() => {
|
|
31
31
|
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-record-formats-")));
|
|
32
|
-
|
|
32
|
+
writeAcmeModel();
|
|
33
33
|
});
|
|
34
34
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
35
35
|
|
|
@@ -72,9 +72,9 @@ async function failure(kind: string): Promise<{ code: string; message: string }>
|
|
|
72
72
|
const codes = (r: { reasons: Array<{ code: string }> }) => r.reasons.map((x) => x.code);
|
|
73
73
|
const warned = (r: { warnings: Array<{ code: string }> }) => r.warnings.map((x) => x.code);
|
|
74
74
|
|
|
75
|
-
// ──
|
|
75
|
+
// ── acme's model, as kind files ──────────────────────────────────────────────
|
|
76
76
|
|
|
77
|
-
const BASE = "https://schemas.example.test/
|
|
77
|
+
const BASE = "https://schemas.example.test/acme-runtime/v0";
|
|
78
78
|
const ref = (name: string) => ({ $ref: `defs.schema.json#/definitions/${name}` });
|
|
79
79
|
|
|
80
80
|
const DEFS = {
|
|
@@ -106,7 +106,7 @@ const UNIT_SCHEMA = {
|
|
|
106
106
|
scope: { type: "array", minItems: 1, items: ref("text") },
|
|
107
107
|
base_commit: ref("gitId"),
|
|
108
108
|
result: { type: "object", required: ["commit", "ref"], properties: { commit: { type: ["string", "null"] }, ref: { type: ["string", "null"] } } },
|
|
109
|
-
// A bare hash, as
|
|
109
|
+
// A bare hash, as acme writes today, or a pin chant checks.
|
|
110
110
|
evidence: {
|
|
111
111
|
type: "array",
|
|
112
112
|
items: { anyOf: [ref("sha256"), { type: "object", required: ["path", "sha256"], properties: { path: ref("text"), sha256: ref("sha256") } }] },
|
|
@@ -178,7 +178,7 @@ const CONTRACT_SCHEMA = {
|
|
|
178
178
|
},
|
|
179
179
|
};
|
|
180
180
|
|
|
181
|
-
/** A kind file: data only, the way
|
|
181
|
+
/** A kind file: data only, the way acme's model package would ship it. */
|
|
182
182
|
function kindFile(kind: Record<string, unknown>): string {
|
|
183
183
|
return `export const recordKind = ${JSON.stringify(kind, null, 2)};\n`;
|
|
184
184
|
}
|
|
@@ -235,7 +235,7 @@ const KINDS: Record<string, Record<string, unknown>> = {
|
|
|
235
235
|
},
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
-
function
|
|
238
|
+
function writeAcmeModel(): void {
|
|
239
239
|
write("design/schemas/defs.schema.json", json(DEFS));
|
|
240
240
|
write("design/schemas/unit.schema.json", json(UNIT_SCHEMA));
|
|
241
241
|
write("design/schemas/evidence.schema.json", json(EVIDENCE_SCHEMA));
|
|
@@ -304,7 +304,7 @@ test("the output schema lists exactly the formats a kind file may name", () => {
|
|
|
304
304
|
});
|
|
305
305
|
|
|
306
306
|
describe("format json", () => {
|
|
307
|
-
test("
|
|
307
|
+
test("acme's units, sessions and driver closures read through kind files alone", async () => {
|
|
308
308
|
write("design/units/U-0001.json", json(unit("U-0001", { outcome: "done", closed_at: "2026-09-24T11:00:00.000Z" })));
|
|
309
309
|
write("design/units/U-0002.json", json(unit("U-0002")));
|
|
310
310
|
write("design/sessions/S-0001.json", json(SESSION));
|
|
@@ -407,7 +407,7 @@ describe("the write commands through a declared kind (#2680)", () => {
|
|
|
407
407
|
test.each(["unit", "evidence"])("refuse the declared %s kind with write-usage-invalid", async (kind) => {
|
|
408
408
|
write(
|
|
409
409
|
"chant.workspace.json",
|
|
410
|
-
json({ name: "
|
|
410
|
+
json({ name: "acme", schema: 1, members: [{ name: "design", dir: "design", kind: "other", because: "acme's records" }], records: [{ kind: `design/${kind}.kind.mjs` }] }),
|
|
411
411
|
);
|
|
412
412
|
write("design/units/U-0001.json", json(unit("U-0001")));
|
|
413
413
|
write("fields.json", "{}\n");
|
|
@@ -510,7 +510,7 @@ describe("kinds without a lifecycle", () => {
|
|
|
510
510
|
const contract = (id: string, status: string, supersedes?: string | string[]) =>
|
|
511
511
|
`---\nid: "${id}"\nstatus: "${status}"\n${supersedes === undefined ? "" : `supersedes: ${JSON.stringify(supersedes)}\n`}---\n\n# ${id}\n`;
|
|
512
512
|
|
|
513
|
-
test("supersedes as one id, as
|
|
513
|
+
test("supersedes as one id, as acme's contracts write it, derives supersededBy under approval ranks", async () => {
|
|
514
514
|
write("design/contracts/C-001-first.md", contract("C-001", "approved"));
|
|
515
515
|
write("design/contracts/C-002-second.md", contract("C-002", "approved", "C-001"));
|
|
516
516
|
write("design/contracts/C-003-draft.md", contract("C-003", "draft", "C-002"));
|
|
@@ -588,7 +588,7 @@ describe("schema.refs", () => {
|
|
|
588
588
|
// ── 4. Content-addressed ids ─────────────────────────────────────────────────
|
|
589
589
|
|
|
590
590
|
describe("idFrom sha256", () => {
|
|
591
|
-
test("
|
|
591
|
+
test("acme's evidence reads with the hash of its bytes as its id, in the tree and under --at", async () => {
|
|
592
592
|
const h = writeEvidence();
|
|
593
593
|
const bytes = readFileSync(join(root, "design", "evidence", `${h}.json`));
|
|
594
594
|
expect(createHash("sha256").update(bytes).digest("hex")).toBe(h);
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* and the comparison. The fixture is a repository with commits for a review
|
|
4
4
|
* session's open and close: between them the session closes with two
|
|
5
5
|
* verdicts, ref-001 gains the two reviews that name it and is ratified,
|
|
6
|
-
* a new decision
|
|
6
|
+
* a new decision (ref-004, after the reference workspace's own ref-003)
|
|
7
|
+
* supersedes ref-002, and ref-002's pin moves. Every document
|
|
7
8
|
* validates against records-since.schema.json.
|
|
8
9
|
*/
|
|
9
10
|
|
|
@@ -56,8 +57,8 @@ function sessionHistory(): { root: string; before: string; open: string; close:
|
|
|
56
57
|
const text = readFileSync(ref002, "utf-8");
|
|
57
58
|
writeFileSync(ref002, text.replace(/sha256: "[0-9a-f]{64}"/, `sha256: "${"a".repeat(64)}"`));
|
|
58
59
|
writeFileSync(
|
|
59
|
-
join(root, "decisions", "ref-
|
|
60
|
-
text.replace(/^id: .*$/m, 'id: "ref-
|
|
60
|
+
join(root, "decisions", "ref-004-a-successor.md"),
|
|
61
|
+
text.replace(/^id: .*$/m, 'id: "ref-004"').replace(/^supersedes: \[\]$/m, 'supersedes:\n - decision: "ref-002"'),
|
|
61
62
|
);
|
|
62
63
|
const close = commitAll(root, "close S-0002");
|
|
63
64
|
return { root, before, open, close };
|
|
@@ -85,11 +86,11 @@ describe("records --since (#2673)", () => {
|
|
|
85
86
|
expect(doc.at).toBe(h.close);
|
|
86
87
|
expect(doc.kind).toEqual({ name: "decision", schema: "urn:intentius:chant:decision:1", file: "decisions/decision.kind.mjs" });
|
|
87
88
|
expect(doc.changes).toEqual([
|
|
88
|
-
{ change: "new", id: "ref-
|
|
89
|
+
{ change: "new", id: "ref-004", path: "decisions/ref-004-a-successor.md", state: "decided" },
|
|
89
90
|
{ change: "state", id: "ref-001", from: "decided", to: "ratified" },
|
|
90
91
|
{ change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" },
|
|
91
92
|
{ change: "verdict", id: "ref-001", principal: "bob", verdict: "agree", index: 1, session: "S-0002" },
|
|
92
|
-
{ change: "supersession", id: "ref-
|
|
93
|
+
{ change: "supersession", id: "ref-004", supersedes: "ref-002" },
|
|
93
94
|
{ change: "pin", id: "ref-002", path: "design/screens/home.json", from: expect.stringMatching(/^[0-9a-f]{64}$/), to: "a".repeat(64) },
|
|
94
95
|
]);
|
|
95
96
|
expect(doc.summary).toEqual({ new: 1, removed: 0, state: 1, verdict: 2, supersession: 1, pin: 1 });
|
|
@@ -105,11 +106,11 @@ describe("records --since (#2673)", () => {
|
|
|
105
106
|
});
|
|
106
107
|
|
|
107
108
|
test("without --at it compares with the working tree, and a record deleted there is removed", async () => {
|
|
108
|
-
rmSync(join(h.root, "decisions", "ref-
|
|
109
|
+
rmSync(join(h.root, "decisions", "ref-004-a-successor.md"));
|
|
109
110
|
try {
|
|
110
111
|
const doc = ok(await since({ kind: DECISIONS_KIND, since: h.close, cwd: h.root }));
|
|
111
112
|
expect(doc.at).toBeNull();
|
|
112
|
-
expect(doc.changes).toEqual([{ change: "removed", id: "ref-
|
|
113
|
+
expect(doc.changes).toEqual([{ change: "removed", id: "ref-004", path: "decisions/ref-004-a-successor.md", state: "decided" }]);
|
|
113
114
|
} finally {
|
|
114
115
|
git(h.root, "checkout", "--", "decisions");
|
|
115
116
|
}
|
|
@@ -311,7 +311,7 @@ describe("a decision that originates in the workspace (#2654)", () => {
|
|
|
311
311
|
|
|
312
312
|
test("takes a session and an issue, and nothing else", async () => {
|
|
313
313
|
const base = workspaceDecision("ws-001");
|
|
314
|
-
write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "
|
|
314
|
+
write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "acme/example#77"\n'));
|
|
315
315
|
write("ws-002-b.md", base.replace('id: "ws-001"', 'id: "ws-002"').replace(' member: "app"\n', ' member: "app"\n session: null\n'));
|
|
316
316
|
write("ws-003-c.md", base.replace('id: "ws-001"', 'id: "ws-003"').replace(' member: "app"\n', ' member: "app"\n row: "Sort order"\n'));
|
|
317
317
|
write("ws-004-d.md", base.replace('id: "ws-001"', 'id: "ws-004"').replace(' member: "app"\n', ""));
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
* is the Op's own `labels.Env` or `local`;
|
|
17
17
|
* - `waiting` lists the open decision points the steward waits on (#2749):
|
|
18
18
|
* each Op whose newest run is the steward's own and stopped on a question,
|
|
19
|
-
* as the run ledger records it. The question's state now is `points`'s
|
|
19
|
+
* as the run ledger records it. The question's state now is `points`'s.
|
|
20
|
+
* A run is the steward's when its record names it (`steward`, written for
|
|
21
|
+
* a run started in the steward's turn or under `CHANT_STEWARD`), so this
|
|
22
|
+
* also covers the member's other Ops, those no steward lists, that such a
|
|
23
|
+
* run belongs to, such as an Op the steward's process starts itself;
|
|
20
24
|
* - `lease` is the local steward's own lease ref
|
|
21
25
|
* (`refs/chant/lease/[<prefix>]_stewards/<name>`), present while a
|
|
22
26
|
* `chant operator --steward` holds it or until it expires;
|
|
@@ -27,7 +31,7 @@
|
|
|
27
31
|
|
|
28
32
|
import { existsSync, realpathSync } from "node:fs";
|
|
29
33
|
import { dirname, join, relative, resolve } from "node:path";
|
|
30
|
-
import { discoverStewards } from "../op/discover";
|
|
34
|
+
import { discoverOps, discoverStewards } from "../op/discover";
|
|
31
35
|
import { stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
|
|
32
36
|
import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
|
|
33
37
|
import { readConvergeLedger, type ConvergeTickRecord } from "../lifecycle/converge-ledger";
|
|
@@ -152,7 +156,9 @@ export interface StatusSteward {
|
|
|
152
156
|
ops: StatusStewardOp[];
|
|
153
157
|
/**
|
|
154
158
|
* The open decision points the steward waits on (#2749): each Op whose
|
|
155
|
-
* newest run is the steward's own and stopped on a question.
|
|
159
|
+
* newest run is the steward's own and stopped on a question. Its declared
|
|
160
|
+
* Ops come first, in `ops` order, then any other Op of the member whose
|
|
161
|
+
* newest run names the steward, by Op name.
|
|
156
162
|
*/
|
|
157
163
|
waiting: (StatusStewardWait & { op: string; run: string })[];
|
|
158
164
|
}
|
|
@@ -325,5 +331,45 @@ export async function readMemberStewards(
|
|
|
325
331
|
waiting,
|
|
326
332
|
});
|
|
327
333
|
}
|
|
334
|
+
if (stewards.length > 0) await addUndeclaredWaits(memberDir, stewards, found, reasons);
|
|
328
335
|
return { stewards, reasons };
|
|
329
336
|
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The waiting runs of the member's Ops that no steward lists, each under the
|
|
340
|
+
* steward its record names. A steward's process can start an Op itself
|
|
341
|
+
* with `CHANT_STEWARD` set (studio#137), and the run ledger then records
|
|
342
|
+
* the run as the steward's though the declaration does not list the Op.
|
|
343
|
+
* Import failures are left out here: `discoverStewards` read the same files
|
|
344
|
+
* and reported them as `stewards-unreadable`.
|
|
345
|
+
*/
|
|
346
|
+
async function addUndeclaredWaits(
|
|
347
|
+
memberDir: string,
|
|
348
|
+
stewards: StatusSteward[],
|
|
349
|
+
found: Awaited<ReturnType<typeof discoverStewards>>["stewards"],
|
|
350
|
+
reasons: MemberStewards["reasons"],
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
const declared = new Set([...found.values()].flatMap(({ declaration }) => declaration.ops.map((op) => op.name)));
|
|
353
|
+
let ops: OpConfig[];
|
|
354
|
+
try {
|
|
355
|
+
ops = [...(await discoverOps({ cwd: memberDir })).ops.values()].map((d) => d.config).filter((op) => !declared.has(op.name));
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const byName = new Map(stewards.map((s) => [s.name, s]));
|
|
360
|
+
for (const op of ops.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
361
|
+
const opEnv = runEnvOf(op);
|
|
362
|
+
try {
|
|
363
|
+
const newest = (await readRunLedger(opEnv, op.name, { cwd: memberDir })).records.at(-1);
|
|
364
|
+
const steward = newest?.steward ? byName.get(newest.steward) : undefined;
|
|
365
|
+
if (steward && newest?.status === "waiting" && newest.point) {
|
|
366
|
+
steward.waiting.push({ op: op.name, run: newest.id, ...waitOf(newest.point) });
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
reasons.push({
|
|
370
|
+
code: "steward-runs-unreadable",
|
|
371
|
+
message: `${opEnv}/runs__${op.name}.jsonl: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -335,7 +335,7 @@
|
|
|
335
335
|
}
|
|
336
336
|
},
|
|
337
337
|
"waiting": {
|
|
338
|
-
"description": "The open decision points the steward waits on (#2749): one for each
|
|
338
|
+
"description": "The open decision points the steward waits on (#2749): one for each Op whose newest run is the steward's own (its ledger record names the steward) and stopped on a question: the steward's own Ops in ops order, then any other Op of the member, such as one the steward's process starts itself (studio#137), by name. The state is the question's when the run stopped; `chant workspace points --open` has its state now, and hud asks a person there. The steward never answers one, and runs an Op it lists again on the first round after it is answered.",
|
|
339
339
|
"type": "array",
|
|
340
340
|
"items": {
|
|
341
341
|
"allOf": [
|
|
@@ -362,10 +362,27 @@
|
|
|
362
362
|
"since": { "type": "string", "description": "When the run stopped on it." }
|
|
363
363
|
}
|
|
364
364
|
},
|
|
365
|
+
"intent": {
|
|
366
|
+
"type": "object",
|
|
367
|
+
"required": ["id", "state", "question", "choice", "answer", "decided_by", "decided_on"],
|
|
368
|
+
"properties": {
|
|
369
|
+
"id": { "type": "string", "description": "The record's id, as the box block names it." },
|
|
370
|
+
"state": { "type": ["string", "null"], "description": "The record's state as written, such as proposed or decided, or null when no decision record has the id." },
|
|
371
|
+
"question": { "type": ["string", "null"], "description": "The question the record answers." },
|
|
372
|
+
"choice": { "description": "The record's choice as written, such as { option, reason }, or null while the record is proposed." },
|
|
373
|
+
"answer": { "type": ["string", "null"], "description": "The chosen option's label, from options[]'s entry whose id is choice.option, or null while no option is chosen (#2855). Added by chant 0.94.0." },
|
|
374
|
+
"decided_by": { "type": ["string", "null"], "description": "Who decided it, or null while it is proposed." },
|
|
375
|
+
"decided_on": { "type": ["string", "null"], "description": "The day it was decided, or null while it is proposed." }
|
|
376
|
+
}
|
|
377
|
+
},
|
|
365
378
|
"box": {
|
|
366
379
|
"type": "object",
|
|
367
|
-
"required": ["capabilities", "isolation"],
|
|
380
|
+
"required": ["capabilities", "isolation", "intent"],
|
|
368
381
|
"properties": {
|
|
382
|
+
"intent": {
|
|
383
|
+
"description": "The decision record the box block names as the box's intent (#2850), or null when it names none. A box starts as a question: the record is proposed with a null choice, and decided when the person who answered it amends it. Read from the working tree, from the declared record kinds named decision. When no decision record has the id, every field but id is null and chant workspace check fails (WSP126). Added by chant 0.94.0.",
|
|
384
|
+
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/intent" }]
|
|
385
|
+
},
|
|
369
386
|
"isolation": {
|
|
370
387
|
"description": "The box's isolation resolved from its identity, its host, the member's name and its slot (#2727), or null when the block declares no host. A runtime that plants or starts the box sets these values instead of choosing its own. The same for every environment.",
|
|
371
388
|
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/isolation" }]
|
package/src/workspace/status.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { readReleasePlan, type ReleasePlan } from "../lifecycle/plan-ledger";
|
|
|
43
43
|
import { listWorkLeases, type WorkLeaseState } from "../lifecycle/work-lease";
|
|
44
44
|
import { findWorkspaceRoot } from "../project-root";
|
|
45
45
|
import { resolveBoxes, type ResolvedIsolation } from "./box-isolation";
|
|
46
|
+
import { resolveBoxIntents, unresolvedIntent, type BoxIntent } from "./box-intent";
|
|
46
47
|
import { declaredRecordKinds, readDeclaration, readerVersion, WorkspaceReadError, type Declaration, type ErrorLocation, type Member } from "./declaration";
|
|
47
48
|
import type { ReasonCode } from "./reason-codes";
|
|
48
49
|
import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
|
|
@@ -178,6 +179,13 @@ export interface StatusBox {
|
|
|
178
179
|
capabilities: { name: string; broker: string | null; scope: string[] }[];
|
|
179
180
|
/** The box's ports, state paths and cookie names, resolved from its identity, or null when the block declares no host (#2727). */
|
|
180
181
|
isolation: ResolvedIsolation | null;
|
|
182
|
+
/**
|
|
183
|
+
* The decision record the block names as the box's intent (#2850): its id,
|
|
184
|
+
* state, question, choice, answer (the chosen option's label), decided_by
|
|
185
|
+
* and decided_on. Null when the block names none; every field but the id
|
|
186
|
+
* is null when no decision record has the id (WSP126).
|
|
187
|
+
*/
|
|
188
|
+
intent: BoxIntent | null;
|
|
181
189
|
}
|
|
182
190
|
|
|
183
191
|
/**
|
|
@@ -377,6 +385,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
377
385
|
const now = query.now ?? new Date().toISOString();
|
|
378
386
|
|
|
379
387
|
const isolation = new Map(resolveBoxes(declaration).map((b) => [b.member.name, b.isolation]));
|
|
388
|
+
const intents = new Map((await resolveBoxIntents(declaration, found.dir)).map((i) => [i.member, i.record?.intent ?? unresolvedIntent(i.id)]));
|
|
380
389
|
const members: StatusMember[] = [];
|
|
381
390
|
for (const m of declaration.members) {
|
|
382
391
|
const environments: StatusEnvironment[] = [];
|
|
@@ -399,6 +408,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
399
408
|
: {
|
|
400
409
|
capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
|
|
401
410
|
isolation: isolation.get(m.name) ?? null,
|
|
411
|
+
intent: intents.get(m.name) ?? null,
|
|
402
412
|
},
|
|
403
413
|
stewards: stewards.stewards,
|
|
404
414
|
stewardReasons: stewards.reasons,
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The work kind's `ready`/`blockedBy` against
|
|
3
|
-
* #2734)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* unit is open against it; a contract that is itself met leaves the queue.
|
|
2
|
+
* The work kind's `ready`/`blockedBy` against a reference ready-queue rule
|
|
3
|
+
* (ws-055, #2734), ported from chud's `readyQueue`
|
|
4
|
+
* (packages/runtime/src/ready.mjs at jhgaylor/chud 43afcf1): a contract
|
|
5
|
+
* enters the ready set once it is approved and in force, every `depends_on`
|
|
6
|
+
* id is `met` (drivers.mjs's `memberState`), and no unit is open against it;
|
|
7
|
+
* a contract that is itself met leaves the queue.
|
|
9
8
|
*
|
|
10
9
|
* Of those rules, the work kind's `ready` and `blockedBy` (work.ts) can
|
|
11
10
|
* express two directly, with no new field:
|
|
@@ -17,22 +16,21 @@
|
|
|
17
16
|
* retired contract does.
|
|
18
17
|
* - a record that is itself already `done` (or `dropped`, or merely
|
|
19
18
|
* `in-progress`) is not `ready`: `ready` requires `state === "open"`, the
|
|
20
|
-
* same "already met/already running leaves the queue" rule
|
|
21
|
-
* states for a contract that is itself met.
|
|
19
|
+
* same "already met/already running leaves the queue" rule the reference
|
|
20
|
+
* rule states for a contract that is itself met.
|
|
22
21
|
*
|
|
23
|
-
* The rules
|
|
24
|
-
* (memberState's draft/failing cases), re-checking that a `met`
|
|
25
|
-
* newest evidence still passes, and refusing a contract while a
|
|
26
|
-
* against it -- have no equivalent in a `needs` link. The full
|
|
27
|
-
* every
|
|
22
|
+
* The rules the reference rule adds on top -- approval before a dependency
|
|
23
|
+
* counts (memberState's draft/failing cases), re-checking that a `met`
|
|
24
|
+
* dependency's newest evidence still passes, and refusing a contract while a
|
|
25
|
+
* unit is open against it -- have no equivalent in a `needs` link. The full
|
|
26
|
+
* table of every rule and its home (a `needs` link, the runner, or "not
|
|
28
27
|
* applicable") is on INTENTIUS/chant#2734.
|
|
29
28
|
*
|
|
30
|
-
* This fixture mirrors a small
|
|
31
|
-
* `queryRecords`'s `ready`/`blockedBy` against a minimal, local
|
|
32
|
-
* re-implementation of the expressible subset of
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* mismatch.
|
|
29
|
+
* This fixture mirrors a small reference contract graph as work items and
|
|
30
|
+
* checks `queryRecords`'s `ready`/`blockedBy` against a minimal, local
|
|
31
|
+
* re-implementation of the expressible subset of that rule (met depends_on,
|
|
32
|
+
* self not already met), so a regression in either the work kind's readiness
|
|
33
|
+
* formula or this reading of the reference rule shows up as a mismatch.
|
|
36
34
|
*/
|
|
37
35
|
|
|
38
36
|
import { readFileSync } from "node:fs";
|
|
@@ -63,13 +61,13 @@ function work(id: string, fields: Record<string, unknown>): string {
|
|
|
63
61
|
}
|
|
64
62
|
|
|
65
63
|
/**
|
|
66
|
-
* The expressible subset of
|
|
64
|
+
* The expressible subset of the reference rule, on a graph shaped like
|
|
67
65
|
* `depends_on`: ready once the item is `open` (not already met, not already
|
|
68
|
-
* running) and every dependency is `done` (
|
|
69
|
-
* is `dropped` never becomes done, so it blocks forever --
|
|
70
|
-
*
|
|
66
|
+
* running) and every dependency is `done` (the reference rule's "met"). A
|
|
67
|
+
* dependency that is `dropped` never becomes done, so it blocks forever --
|
|
68
|
+
* the same effect the reference rule gets from a retired dependency.
|
|
71
69
|
*/
|
|
72
|
-
function
|
|
70
|
+
function referenceReadyIsh(items: { id: string; state: string; needs: string[] }[]): { ready: Set<string>; blockedBy: Map<string, string[]> } {
|
|
73
71
|
const stateOf = new Map(items.map((i) => [i.id, i.state]));
|
|
74
72
|
const ready = new Set<string>();
|
|
75
73
|
const blockedBy = new Map<string, string[]>();
|
|
@@ -90,13 +88,13 @@ beforeAll(() => {
|
|
|
90
88
|
"decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
|
|
91
89
|
"work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
|
|
92
90
|
"work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
|
|
93
|
-
// W-001: already done, so it leaves the ready queue --
|
|
94
|
-
// contract that is itself met is done, not ready".
|
|
91
|
+
// W-001: already done, so it leaves the ready queue -- the reference
|
|
92
|
+
// rule's "a contract that is itself met is done, not ready".
|
|
95
93
|
"work/W-001-base.md": work("W-001", { state: "done", closed_on: "2026-09-24", evidence: [{ title: "shipped", url: "https://example.com/w1" }] }),
|
|
96
94
|
// W-002: needs W-001, which is done -- both rules agree this is ready.
|
|
97
95
|
"work/W-002-after-base.md": work("W-002", { needs: ["W-001"] }),
|
|
98
96
|
// W-003: needs W-004, which is dropped and so never turns done -- blocked
|
|
99
|
-
// forever, the same as a retired dependency in
|
|
97
|
+
// forever, the same as a retired dependency in the reference rule.
|
|
100
98
|
"work/W-003-needs-dropped.md": work("W-003", { needs: ["W-004"] }),
|
|
101
99
|
"work/W-004-dropped.md": work("W-004", { state: "dropped", closed_on: "2026-09-24" }),
|
|
102
100
|
// W-005: needs W-002, which is open (not done yet) -- blocked.
|
|
@@ -111,22 +109,22 @@ beforeAll(() => {
|
|
|
111
109
|
});
|
|
112
110
|
afterAll(cleanScratch);
|
|
113
111
|
|
|
114
|
-
describe("work kind ready/blockedBy vs.
|
|
112
|
+
describe("work kind ready/blockedBy vs. a reference ready-queue rule, expressible rules (#2734)", () => {
|
|
115
113
|
test("matches on every item, for the rules a needs link can express", async () => {
|
|
116
114
|
const doc = await queryRecords({ kind: WORK, cwd: root });
|
|
117
115
|
if ("error" in doc) throw new Error(doc.error.message);
|
|
118
116
|
expect(doc.summary).toEqual({ total: 7, valid: 7, invalid: 0, superseded: 0 });
|
|
119
117
|
|
|
120
118
|
const items = doc.records.map((r) => ({ id: r.id!, state: r.state!, needs: (r.data as { needs?: string[] }).needs ?? [] }));
|
|
121
|
-
const
|
|
119
|
+
const reference = referenceReadyIsh(items);
|
|
122
120
|
|
|
123
121
|
for (const r of doc.records) {
|
|
124
122
|
const id = r.id!;
|
|
125
|
-
expect(r.ready, `${id}.ready`).toBe(
|
|
123
|
+
expect(r.ready, `${id}.ready`).toBe(reference.ready.has(id));
|
|
126
124
|
expect(
|
|
127
125
|
(r.blockedBy ?? []).map((b) => b.id),
|
|
128
126
|
`${id}.blockedBy`,
|
|
129
|
-
).toEqual(
|
|
127
|
+
).toEqual(reference.blockedBy.get(id));
|
|
130
128
|
}
|
|
131
129
|
});
|
|
132
130
|
|
|
@@ -370,6 +370,13 @@ describe("writing work items on a copy of the reference workspace (#2683)", () =
|
|
|
370
370
|
expect("error" in doc && doc.error.code).toBe("record-schema-invalid");
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
test("records new refuses an ask source with no said or by (#2851)", async () => {
|
|
374
|
+
const noSaid = await newRecord({ kind: "work/work.kind.mjs", fields: fresh({ source: { ask: { by: "morgan", at: "2026-09-25T10:00:00Z", via: "hud" } } }), dryRun: true, cwd: copy });
|
|
375
|
+
expect("error" in noSaid && noSaid.error.code).toBe("record-schema-invalid");
|
|
376
|
+
const noBy = await newRecord({ kind: "work/work.kind.mjs", fields: fresh({ source: { ask: { said: "Can we add a dark mode toggle?", at: "2026-09-25T10:00:00Z", via: "hud" } } }), dryRun: true, cwd: copy });
|
|
377
|
+
expect("error" in noBy && noBy.error.code).toBe("record-schema-invalid");
|
|
378
|
+
});
|
|
379
|
+
|
|
373
380
|
test("records new with a fresh prefix takes --prefix W", async () => {
|
|
374
381
|
const doc = await newRecord({ kind: "work/work.kind.mjs", fields: fresh(), prefix: "W", dryRun: true, cwd: copy });
|
|
375
382
|
expect(doc).toMatchObject({ id: "W-004" });
|
|
@@ -388,4 +395,14 @@ describe("writing work items on a copy of the reference workspace (#2683)", () =
|
|
|
388
395
|
const doc = await reviewRecord({ kind: "work/work.kind.mjs", id: "W-003", verdict: "agree", by: "alice", cwd: copy });
|
|
389
396
|
expect("error" in doc && doc.error.code).toBe("review-unsupported");
|
|
390
397
|
});
|
|
398
|
+
|
|
399
|
+
test("records new with an ask source round-trips through records --json (#2851)", async () => {
|
|
400
|
+
const ask = { said: "Can we add a dark mode toggle to the wireframe?", by: "morgan", at: "2026-09-25T10:00:00Z", via: "hud", session: "hud-9f2" };
|
|
401
|
+
const doc = await newRecord({ kind: resolveWriteKind("work", copy), fields: fresh({ source: { ask } }), cwd: copy });
|
|
402
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
403
|
+
const read = await queryRecords({ kind: "work/work.kind.mjs", cwd: copy });
|
|
404
|
+
records.expectValid(read);
|
|
405
|
+
if ("error" in read) throw new Error(read.error.message);
|
|
406
|
+
expect(read.records.find((r) => r.id === doc.id)).toMatchObject({ valid: true, data: { source: { ask } } });
|
|
407
|
+
});
|
|
391
408
|
});
|