toolroll 0.9.32 → 0.9.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/browser/workspace.css +1 -1
  3. package/dist/browser/workspace.js +17 -17
  4. package/dist/browser-crew.js +1 -0
  5. package/dist/browser-workspace.d.ts +2 -0
  6. package/dist/cli.d.ts +1 -1
  7. package/dist/cli.js +124 -63
  8. package/dist/coding-context.js +2 -2
  9. package/dist/coding-handoff.js +2 -2
  10. package/dist/contracts/contract-test.d.ts +45 -0
  11. package/dist/contracts/contract-test.js +69 -0
  12. package/dist/contracts/contract.d.ts +59 -0
  13. package/dist/contracts/contract.js +181 -0
  14. package/dist/contracts/plan.d.ts +58 -0
  15. package/dist/contracts/plan.js +67 -0
  16. package/dist/desktop-update.d.ts +1 -1
  17. package/dist/evidence.js +2 -2
  18. package/dist/guides.js +3 -0
  19. package/dist/knowledge-cli.d.ts +31 -0
  20. package/dist/knowledge-cli.js +35 -2
  21. package/dist/knowledge-ui.d.ts +1 -1
  22. package/dist/knowledge-ui.js +7 -0
  23. package/dist/operate.js +65 -13
  24. package/dist/plan.d.ts +12 -12
  25. package/dist/plan.js +129 -72
  26. package/dist/planner-source.js +2 -1
  27. package/dist/planner.js +55 -25
  28. package/dist/project-admission.d.ts +93 -0
  29. package/dist/project-admission.js +134 -0
  30. package/dist/project-delete-ui.js +5 -2
  31. package/dist/project-delete.js +1 -1
  32. package/dist/project-knowledge.d.ts +28 -3
  33. package/dist/project-knowledge.js +64 -25
  34. package/dist/project-learning.d.ts +33 -0
  35. package/dist/project-learning.js +105 -15
  36. package/dist/project-skills.js +27 -21
  37. package/dist/runner.d.ts +10 -0
  38. package/dist/runner.js +16 -0
  39. package/dist/scope.js +4 -3
  40. package/dist/serve.d.ts +4 -0
  41. package/dist/serve.js +111 -65
  42. package/dist/store.d.ts +2 -0
  43. package/dist/store.js +4 -0
  44. package/dist/surface.js +9 -3
  45. package/dist/text-limits.d.ts +17 -0
  46. package/dist/text-limits.js +16 -0
  47. package/dist/when-html.d.ts +2 -0
  48. package/dist/when-html.js +13 -0
  49. package/package.json +3 -2
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The one way Toolroll describes a contract (docs/plans/zod-revamp.md): one Zod schema, and everything else — the
3
+ * TypeScript type, the runtime check, the JSON Schema a model gets, the text limits — derived from it.
4
+ *
5
+ * The helpers here hold the ground rules every contract shares: limits come from TEXT_LIMITS, persisted payloads are
6
+ * versioned, refusals name the path, and a model-facing schema is exactly what its JSON Schema says (no refinements or
7
+ * transforms; those checks run in plain code after parsing, with a named error).
8
+ */
9
+ import { z } from "zod";
10
+ import { limitRule, TEXT_LIMITS } from "../text-limits.js";
11
+ /**
12
+ * A string bounded by its TEXT_LIMITS entry, never a literal. The limit rule rides along as the field's description,
13
+ * so a model reads the bound before it writes. Zod counts UTF-16 code units; for a limit named in bytes this is the
14
+ * schema's necessary bound (a string within N bytes is within N code units), and the byte count itself is checked in
15
+ * plain code after parsing.
16
+ */
17
+ export function limited(field, limitKey) {
18
+ const limit = TEXT_LIMITS[limitKey];
19
+ const unit = limitKey.endsWith("Bytes") ? "bytes" : "characters";
20
+ return z.string().max(limit, { error: `over ${limit.toLocaleString("en-US")} ${unit}` }).describe(limitRule(field, limit, unit));
21
+ }
22
+ /** A versioned payload envelope: a strict object whose `version` is exactly `version`. */
23
+ export function versioned(version, shape) {
24
+ return z.strictObject({ version: z.literal(version), ...shape });
25
+ }
26
+ /** `["steps", 0, "routes", 0, "goesTo"]` as `steps[0].routes[0].goesTo`. */
27
+ export function pathOf(path) {
28
+ let out = "";
29
+ for (const part of path) {
30
+ if (typeof part === "number")
31
+ out += `[${part}]`;
32
+ else
33
+ out += out === "" ? String(part) : `.${String(part)}`;
34
+ }
35
+ return out === "" ? "payload" : out;
36
+ }
37
+ function got(input) {
38
+ if (input === null)
39
+ return "null";
40
+ if (Array.isArray(input))
41
+ return "an array";
42
+ return typeof input === "object" ? "an object" : `a ${typeof input}`;
43
+ }
44
+ function issuesOf(issue) {
45
+ const at = pathOf(issue.path);
46
+ const one = (kind, what) => ({ path: at, kind, line: `${at}: ${what}` });
47
+ switch (issue.code) {
48
+ case "unrecognized_keys":
49
+ return issue.keys.map(key => ({ path: at, kind: "unknown-key", line: `${at}: unknown key '${key}'` }));
50
+ case "invalid_type": {
51
+ const missing = "input" in issue ? issue.input === undefined : / received undefined$/.test(issue.message);
52
+ if (missing)
53
+ return [one("required", "required")];
54
+ return [one("wrong-type", `must be ${issue.expected === "array" ? "an array" : issue.expected === "object" ? "an object" : `a ${issue.expected}`}${"input" in issue ? ` (got ${got(issue.input)})` : ""}`)];
55
+ }
56
+ case "too_big":
57
+ if (issue.origin === "array" || issue.origin === "set")
58
+ return [one("too-many", `at most ${String(issue.maximum)} items`)];
59
+ return [one("too-long", issue.origin === "string" && issue.message.startsWith("over ") ? issue.message : `at most ${String(issue.maximum)}${issue.origin === "string" ? " characters" : ""}`)];
60
+ case "too_small":
61
+ if (issue.origin === "string" && Number(issue.minimum) === 1)
62
+ return [one("empty", "must not be empty")];
63
+ if (issue.origin === "array" || issue.origin === "set")
64
+ return [one("too-few", `at least ${String(issue.minimum)} item${Number(issue.minimum) === 1 ? "" : "s"}`)];
65
+ return [one("too-few", `at least ${String(issue.minimum)}`)];
66
+ case "invalid_value":
67
+ return [one("bad-value", `must be ${issue.values.length === 1 ? JSON.stringify(issue.values[0]) : `one of ${issue.values.map(value => JSON.stringify(value)).join(", ")}`}`)];
68
+ case "invalid_union":
69
+ return [one("invalid", "does not match any allowed shape")];
70
+ default:
71
+ return [one("invalid", issue.message)];
72
+ }
73
+ }
74
+ /** A Zod error as path-named issues, in the error's own order. */
75
+ export function contractIssues(error) {
76
+ return error.issues.flatMap(issuesOf);
77
+ }
78
+ /** A Zod error as path-named lines: `steps[0].routes[0].goesTo: required`, `routes[0]: unknown key 'to'`. */
79
+ export function contractError(error) {
80
+ return contractIssues(error).map(issue => issue.line);
81
+ }
82
+ /** Parse with the input reported, so a missing field reads `required` rather than a wrong type. */
83
+ export function parseContract(schema, input) {
84
+ const parsed = schema.safeParse(input, { reportInput: true });
85
+ return parsed.success ? { ok: true, value: parsed.data } : { ok: false, issues: contractIssues(parsed.error) };
86
+ }
87
+ /**
88
+ * Read a versioned payload. The current version parses as itself; an older known version (0 for a payload saved
89
+ * before it carried `version`) is upgraded by its adapter and then parsed by the same schema; a newer version is
90
+ * refused plainly; anything else is an unknown version.
91
+ */
92
+ export function readVersioned(schema, input, upgrades = {}) {
93
+ const current = schema.shape.version.value;
94
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
95
+ return { ok: false, issues: [{ path: "payload", kind: "wrong-type", line: `payload: must be an object (got ${got(input)})` }] };
96
+ }
97
+ const body = input;
98
+ const version = Object.prototype.hasOwnProperty.call(body, "version") ? body["version"] : 0;
99
+ if (version === current)
100
+ return parseContract(schema, body);
101
+ if (typeof version === "number" && Number.isInteger(version) && version > current) {
102
+ return { ok: false, issues: [{ path: "version", kind: "newer-version", line: `version: made by a newer Toolroll (version ${version}; this one reads up to ${current})` }] };
103
+ }
104
+ const upgrade = typeof version === "number" ? upgrades[version] : undefined;
105
+ if (upgrade === undefined) {
106
+ return { ok: false, issues: [{ path: "version", kind: "bad-value", line: `version: unknown version ${JSON.stringify(version)} (this Toolroll reads ${current}${Object.keys(upgrades).length === 0 ? "" : ` and ${Object.keys(upgrades).join(", ")}`})` }] };
107
+ }
108
+ return parseContract(schema, upgrade(body));
109
+ }
110
+ /** The Zod node kinds a model-facing schema may use: each says exactly what its JSON Schema says. */
111
+ const MODEL_FACING_TYPES = new Set(["string", "number", "int", "boolean", "null", "literal", "enum", "array", "object", "optional", "nullable", "union", "tuple", "record", "lazy", "readonly", "unknown", "any"]);
112
+ /** Checks that only constrain (never rewrite) and that JSON Schema can state. */
113
+ const MODEL_FACING_CHECKS = new Set(["max_length", "min_length", "length_equals", "greater_than", "less_than", "multiple_of", "number_format", "string_format", "size_equals", "max_size", "min_size"]);
114
+ /** Every node of a schema graph, refusing a refinement, transform, preprocess or unknown node by its schema path. */
115
+ function assertModelFacing(schema, at, seen) {
116
+ if (seen.has(schema))
117
+ return;
118
+ seen.add(schema);
119
+ const def = schema._zod.def;
120
+ const where = at === "" ? "the schema" : at;
121
+ if (def.type === "pipe") {
122
+ const into = def["in"]?._zod.def.type;
123
+ const out = def["out"]?._zod.def.type;
124
+ const what = into === "transform" ? "preprocess" : out === "transform" ? "transform" : "pipe";
125
+ throw new Error(`toModelSchema: ${where} uses ${what}; a model-facing schema must be JSON-Schema-exact — check it in plain code after parsing`);
126
+ }
127
+ if (def.type === "transform")
128
+ throw new Error(`toModelSchema: ${where} uses transform; a model-facing schema must be JSON-Schema-exact — check it in plain code after parsing`);
129
+ if (!MODEL_FACING_TYPES.has(def.type))
130
+ throw new Error(`toModelSchema: ${where} uses a ${def.type} node, which a model-facing schema may not use`);
131
+ for (const check of def.checks ?? []) {
132
+ const kind = check._zod.def.check;
133
+ if (kind === "custom")
134
+ throw new Error(`toModelSchema: ${where} uses refine or superRefine; a model-facing schema must be JSON-Schema-exact — check it in plain code after parsing`);
135
+ if (!MODEL_FACING_CHECKS.has(kind))
136
+ throw new Error(`toModelSchema: ${where} uses a ${kind} check, which rewrites or cannot be stated in JSON Schema`);
137
+ }
138
+ const child = (value, path) => assertModelFacing(value, path, seen);
139
+ const join = (key) => (at === "" ? key : `${at}.${key}`);
140
+ switch (def.type) {
141
+ case "object":
142
+ for (const [key, value] of Object.entries(def["shape"]))
143
+ child(value, join(key));
144
+ // A strict object's catchall is `never` (additionalProperties: false); any other catchall is a schema too.
145
+ if (def["catchall"] !== undefined && def["catchall"]._zod.def.type !== "never")
146
+ child(def["catchall"], join("*"));
147
+ break;
148
+ case "array":
149
+ child(def["element"], `${at}[]`);
150
+ break;
151
+ case "optional":
152
+ case "nullable":
153
+ case "readonly":
154
+ child(def["innerType"], at);
155
+ break;
156
+ case "union":
157
+ def["options"].forEach((option, index) => child(option, `${at}|${index}`));
158
+ break;
159
+ case "tuple":
160
+ def["items"].forEach((item, index) => child(item, `${at}[${index}]`));
161
+ if (def["rest"] !== undefined && def["rest"] !== null)
162
+ child(def["rest"], `${at}[]`);
163
+ break;
164
+ case "record":
165
+ child(def["keyType"], `${at}{key}`);
166
+ child(def["valueType"], `${at}{}`);
167
+ break;
168
+ case "lazy":
169
+ child(def["getter"](), at);
170
+ break;
171
+ }
172
+ }
173
+ /**
174
+ * The JSON Schema a model gets for `schema` (Claude's `--json-schema`). Throws when the schema uses refine,
175
+ * superRefine, transform or preprocess anywhere: what a model is told must be everything the schema checks.
176
+ */
177
+ export function toModelSchema(schema) {
178
+ assertModelFacing(schema, "", new Set());
179
+ const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { target: "draft-2020-12", unrepresentable: "throw" });
180
+ return json;
181
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The planner's handoff: one schema for the file a planner writes, the JSON Schema it is given (`--json-schema`) and
3
+ * the parser that reads it back (`parsePlan` in plan.ts). The rules JSON Schema cannot state — the plan document's
4
+ * sections, UTF-8 byte limits, control characters, duplicate criterion ids, the Proof naming every criterion and the
5
+ * silent-amendment contract check — run in plain code after parsing, each with a named error.
6
+ */
7
+ import { z } from "zod";
8
+ import { type ContractResult } from "./contract.js";
9
+ /** How many paths a plan may say it touches. */
10
+ export declare const PLAN_TOUCHES = 32;
11
+ /** One acceptance criterion: the scope's own shape (scope.ts `AcceptanceCriterion`), `how` advisory and unsigned. */
12
+ export declare const acceptanceCriterionSchema: z.ZodObject<{
13
+ id: z.ZodString;
14
+ statement: z.ZodString;
15
+ how: z.ZodOptional<z.ZodNullable<z.ZodString>>;
16
+ evidence: z.ZodArray<z.ZodEnum<{
17
+ "changed-path": "changed-path";
18
+ check: "check";
19
+ "manual-review": "manual-review";
20
+ screenshot: "screenshot";
21
+ }>>;
22
+ }, z.core.$strict>;
23
+ export declare const PLAN_VERSION = 1;
24
+ export declare const planSchema: z.ZodObject<{
25
+ version: z.ZodLiteral<1>;
26
+ goal: z.ZodString;
27
+ outOfScope: z.ZodOptional<z.ZodNullable<z.ZodString>>;
28
+ touches: z.ZodOptional<z.ZodArray<z.ZodString>>;
29
+ acceptance: z.ZodArray<z.ZodObject<{
30
+ id: z.ZodString;
31
+ statement: z.ZodString;
32
+ how: z.ZodOptional<z.ZodNullable<z.ZodString>>;
33
+ evidence: z.ZodArray<z.ZodEnum<{
34
+ "changed-path": "changed-path";
35
+ check: "check";
36
+ "manual-review": "manual-review";
37
+ screenshot: "screenshot";
38
+ }>>;
39
+ }, z.core.$strict>>;
40
+ plan: z.ZodString;
41
+ amendment: z.ZodOptional<z.ZodNullable<z.ZodString>>;
42
+ }, z.core.$strict>;
43
+ export type PlanPayload = z.infer<typeof planSchema>;
44
+ /** What the planner's `--json-schema` is: the plan schema, exactly. */
45
+ export declare const PLAN_MODEL_SCHEMA: Readonly<Record<string, unknown>>;
46
+ /**
47
+ * A plan written before plans carried `version` (every plan through 0.9.33), as version 1. The old parser read only
48
+ * the fields it knew and ignored any others, read a null goal, plan or acceptance as missing and null touches as
49
+ * none; this keeps exactly that, so every plan that parsed then parses now. Values themselves are never rewritten.
50
+ */
51
+ export declare function upgradeUnversionedPlan(body: Record<string, unknown>): Record<string, unknown>;
52
+ export declare const PLAN_UPGRADES: {
53
+ readonly 0: typeof upgradeUnversionedPlan;
54
+ };
55
+ /** The payload as version 1, upgraded when it was saved unversioned; null for anything that is not an object. */
56
+ export declare function planPayloadBody(input: unknown): Record<string, unknown> | null;
57
+ /** Read a plan payload: version 1 as itself, an unversioned one upgraded, a newer one refused plainly. */
58
+ export declare function readPlanPayload(input: unknown): ContractResult<PlanPayload>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The planner's handoff: one schema for the file a planner writes, the JSON Schema it is given (`--json-schema`) and
3
+ * the parser that reads it back (`parsePlan` in plan.ts). The rules JSON Schema cannot state — the plan document's
4
+ * sections, UTF-8 byte limits, control characters, duplicate criterion ids, the Proof naming every criterion and the
5
+ * silent-amendment contract check — run in plain code after parsing, each with a named error.
6
+ */
7
+ import { z } from "zod";
8
+ import { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "../scope.js";
9
+ import { limited, readVersioned, toModelSchema, versioned } from "./contract.js";
10
+ /** How many paths a plan may say it touches. */
11
+ export const PLAN_TOUCHES = 32;
12
+ /** One acceptance criterion: the scope's own shape (scope.ts `AcceptanceCriterion`), `how` advisory and unsigned. */
13
+ export const acceptanceCriterionSchema = z.strictObject({
14
+ id: limited("acceptance id", "acceptanceIdBytes").min(1),
15
+ statement: limited("acceptance statement", "acceptanceStatementBytes").min(1),
16
+ how: limited("acceptance how", "acceptanceHowBytes").nullable().optional(),
17
+ evidence: z.array(z.enum(EVIDENCE_KINDS)).min(1).max(EVIDENCE_KINDS.length),
18
+ });
19
+ export const PLAN_VERSION = 1;
20
+ export const planSchema = versioned(PLAN_VERSION, {
21
+ goal: limited("goal", "goal").min(1),
22
+ outOfScope: limited("outOfScope", "goal").nullable().optional(),
23
+ touches: z.array(limited("touch", "planTouch").min(1)).max(PLAN_TOUCHES).optional(),
24
+ acceptance: z.array(acceptanceCriterionSchema).min(1).max(ACCEPTANCE_LIMITS.criteria),
25
+ plan: limited("plan", "planDocumentBytes").min(1),
26
+ amendment: limited("amendment", "planAmendment").nullable().optional(),
27
+ });
28
+ /** What the planner's `--json-schema` is: the plan schema, exactly. */
29
+ export const PLAN_MODEL_SCHEMA = toModelSchema(planSchema);
30
+ const PLAN_FIELDS = ["goal", "outOfScope", "touches", "acceptance", "plan", "amendment"];
31
+ const CRITERION_FIELDS = ["id", "statement", "how", "evidence"];
32
+ function pick(body, fields) {
33
+ const out = {};
34
+ for (const field of fields) {
35
+ if (Object.prototype.hasOwnProperty.call(body, field) && body[field] !== undefined)
36
+ out[field] = body[field];
37
+ }
38
+ return out;
39
+ }
40
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
41
+ /**
42
+ * A plan written before plans carried `version` (every plan through 0.9.33), as version 1. The old parser read only
43
+ * the fields it knew and ignored any others, read a null goal, plan or acceptance as missing and null touches as
44
+ * none; this keeps exactly that, so every plan that parsed then parses now. Values themselves are never rewritten.
45
+ */
46
+ export function upgradeUnversionedPlan(body) {
47
+ const out = { version: PLAN_VERSION, ...pick(body, PLAN_FIELDS) };
48
+ for (const field of ["goal", "plan", "touches", "acceptance"]) {
49
+ if (out[field] === null)
50
+ delete out[field];
51
+ }
52
+ if (Array.isArray(out["acceptance"])) {
53
+ out["acceptance"] = out["acceptance"].map(criterion => (isRecord(criterion) ? pick(criterion, CRITERION_FIELDS) : criterion));
54
+ }
55
+ return out;
56
+ }
57
+ export const PLAN_UPGRADES = { 0: upgradeUnversionedPlan };
58
+ /** The payload as version 1, upgraded when it was saved unversioned; null for anything that is not an object. */
59
+ export function planPayloadBody(input) {
60
+ if (!isRecord(input))
61
+ return null;
62
+ return Object.prototype.hasOwnProperty.call(input, "version") ? input : upgradeUnversionedPlan(input);
63
+ }
64
+ /** Read a plan payload: version 1 as itself, an unversioned one upgraded, a newer one refused plainly. */
65
+ export function readPlanPayload(input) {
66
+ return readVersioned(planSchema, input, PLAN_UPGRADES);
67
+ }
@@ -125,13 +125,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
125
125
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
126
126
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
127
127
  export declare function desktopUpdateStatus(stateDir: string): {
128
+ error?: never;
128
129
  active: boolean;
129
130
  phase: string;
130
131
  detail: string;
131
132
  running: boolean;
132
133
  canResume: boolean;
133
134
  canCancel: boolean;
134
- error?: never;
135
135
  automaticRecovery?: never;
136
136
  recoveryAttempts?: never;
137
137
  recoveryRepairs?: never;
package/dist/evidence.js CHANGED
@@ -18,7 +18,7 @@ import { closeSync, existsSync, constants, fstatSync, mkdirSync, openSync, readd
18
18
  import { join, sep } from "node:path";
19
19
  import { namedPath } from "./names.js";
20
20
  import { LIMITS } from "./decision.js";
21
- import { PLAN_LIMITS } from "./plan.js";
21
+ import { TEXT_LIMITS } from "./text-limits.js";
22
22
  import { parseReport, REPORT_LIMITS } from "./scout-report.js";
23
23
  import { parseProof, PROOF_LIMITS } from "./proof.js";
24
24
  import { encodeBaseTreeSnapshot, parseBaseTreeSnapshot } from "./peek.js";
@@ -84,7 +84,7 @@ export const EVIDENCE_CAPS = {
84
84
  diff: 256 * 1024,
85
85
  status: 64 * 1024,
86
86
  "park-payload": LIMITS.payload,
87
- plan: PLAN_LIMITS.document,
87
+ plan: TEXT_LIMITS.planDocumentBytes,
88
88
  "terminal-diff": 256 * 1024,
89
89
  "diff-stat": 32 * 1024,
90
90
  // The enveloped base snapshot (live peek): 20k entries of path+sha+size.
package/dist/guides.js CHANGED
@@ -419,6 +419,9 @@ and crew agent reads from it before working:
419
419
  - Instructions and references change on the Knowledge page under Settings,
420
420
  or through the lead's knowledge cards. Every change keeps a revision you
421
421
  can restore.
422
+ - If a project was replaced at the same path, its saved knowledge waits:
423
+ tasks run without it until an approver applies it on the Knowledge page
424
+ or with \`toolroll knowledge apply --repo PATH --as <you> --token <t>\`.
422
425
  - Agent conclusions are not instructions: a crew agent reports; a person or
423
426
  the lead's confirmed card records.
424
427
 
@@ -89,7 +89,38 @@ export declare const KNOWLEDGE_DESCRIPTORS: readonly [{
89
89
  readonly takesValue: true;
90
90
  readonly meaning: 'environment variable containing a scoped coordinator token';
91
91
  }];
92
+ }, {
93
+ readonly action: 'apply';
94
+ readonly synopsis: "carry this project's saved knowledge from before a change forward as a new revision; an approver's --as/--token";
95
+ readonly mutation: 'identity-idempotent';
96
+ readonly takesQuery: false;
97
+ readonly flags: readonly [{
98
+ readonly name: 'repo';
99
+ readonly takesValue: true;
100
+ readonly meaning: 'admitted project checkout root';
101
+ }, {
102
+ readonly name: 'as';
103
+ readonly takesValue: true;
104
+ readonly meaning: 'approver account name';
105
+ }, {
106
+ readonly name: 'token';
107
+ readonly takesValue: true;
108
+ readonly meaning: "the approver's password (never a lead token)";
109
+ }, {
110
+ readonly name: 'json';
111
+ readonly takesValue: false;
112
+ readonly meaning: 'versioned structured response';
113
+ }, {
114
+ readonly name: 'db';
115
+ readonly takesValue: true;
116
+ readonly meaning: 'local Toolroll database';
117
+ }];
118
+ readonly audience: 'operator';
92
119
  }];
120
+ /** `knowledge apply`: the caller has verified `actor` as an approver of `repo` (never the lead). */
121
+ export declare function runKnowledgeApply(options: Map<string, string | true>, context: KnowledgeCliContext & {
122
+ actor: string | null;
123
+ }): number;
93
124
  export type KnowledgeCliContext = {
94
125
  store: Store;
95
126
  write: (line: string) => void;
@@ -6,6 +6,7 @@ import { authenticateCoordinator } from './coordinator.js';
6
6
  import { envelopeJson } from './envelope.js';
7
7
  import { repositoryContext } from './repository-context.js';
8
8
  import { namedPath } from './names.js';
9
+ import { applySavedKnowledge } from './project-knowledge.js';
9
10
  const flags = [
10
11
  { name: 'repo', takesValue: true, meaning: 'admitted project checkout root' },
11
12
  { name: 'base', takesValue: true, meaning: 'exact committed task base; defaults to current HEAD' },
@@ -14,11 +15,43 @@ const flags = [
14
15
  { name: 'token-file', takesValue: true, meaning: 'explicit scoped coordinator credential file' },
15
16
  { name: 'token-env', takesValue: true, meaning: 'environment variable containing a scoped coordinator token' },
16
17
  ];
18
+ const applyFlags = [
19
+ { name: 'repo', takesValue: true, meaning: 'admitted project checkout root' },
20
+ { name: 'as', takesValue: true, meaning: 'approver account name' },
21
+ { name: 'token', takesValue: true, meaning: "the approver's password (never a lead token)" },
22
+ { name: 'json', takesValue: false, meaning: 'versioned structured response' },
23
+ { name: 'db', takesValue: true, meaning: 'local Toolroll database' },
24
+ ];
17
25
  export const KNOWLEDGE_DESCRIPTORS = [
18
26
  { action: 'search', synopsis: 'find bounded source excerpts and relevant code symbols', mutation: 'none', takesQuery: true, flags },
19
27
  { action: 'impact', synopsis: 'inspect static imports affected by one file or unambiguous symbol', mutation: 'none', takesQuery: true, flags },
20
28
  { action: 'refresh', synopsis: 'refresh the optional local code index; never starts a model or task', mutation: 'identity-idempotent', takesQuery: false, flags },
29
+ // A person's action: the approver carries saved knowledge from before a project change forward.
30
+ { action: 'apply', synopsis: "carry this project's saved knowledge from before a change forward as a new revision; an approver's --as/--token", mutation: 'identity-idempotent', takesQuery: false, flags: applyFlags, audience: 'operator' },
21
31
  ];
32
+ /** `knowledge apply`: the caller has verified `actor` as an approver of `repo` (never the lead). */
33
+ export function runKnowledgeApply(options, context) {
34
+ const command = 'knowledge apply';
35
+ const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === 'usage' ? 2 : 3; };
36
+ for (const key of options.keys())
37
+ if (!applyFlags.some(flag => flag.name === key))
38
+ return fail('usage', `--${key} is not a knowledge apply option.`);
39
+ const repo = options.get('repo');
40
+ if (typeof repo !== 'string' || !repo.trim() || repo.length > 4096 || /[\x00-\x1f]/.test(repo))
41
+ return fail('usage', 'Use --repo with the project checkout root.');
42
+ if (context.actor === null)
43
+ return fail('unauthenticated', 'An approver of this project applies saved knowledge: --as <you> --token <password>.');
44
+ let revision;
45
+ try {
46
+ revision = applySavedKnowledge(context.store, resolve(repo), context.actor, context.now);
47
+ }
48
+ catch (error) {
49
+ return fail('failed', error instanceof Error ? error.message : 'Saved knowledge could not be applied.');
50
+ }
51
+ const message = revision === null ? 'Nothing to apply.' : `Applied saved knowledge as version ${revision}.`;
52
+ context.write(context.json ? envelopeJson({ ok: true, command, result: { revision, message } }) : message);
53
+ return 0;
54
+ }
22
55
  function lines(result) {
23
56
  return [
24
57
  `Repository context: ${result.index.status}${result.index.engine === 'text-search' ? ' · source search' : ' · code relationships'}`,
@@ -32,8 +65,8 @@ export async function runKnowledgeCommand(positional, options, context) {
32
65
  const spec = KNOWLEDGE_DESCRIPTORS.find(s => s.action === positional[0]);
33
66
  const command = spec ? `knowledge ${spec.action}` : 'knowledge';
34
67
  const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === 'usage' ? 2 : 3; };
35
- if (!spec)
36
- return fail('usage', 'Use knowledge search <query>, impact <file-or-symbol>, or refresh with --repo PATH.');
68
+ if (!spec || spec.action === 'apply')
69
+ return fail('usage', 'Use knowledge search <query>, impact <file-or-symbol>, refresh or apply with --repo PATH.');
37
70
  if (positional.length !== (spec.takesQuery ? 2 : 1))
38
71
  return fail('usage', spec.takesQuery ? 'Provide one quoted query or source path.' : 'knowledge refresh takes no query.');
39
72
  for (const key of options.keys())
@@ -1,4 +1,4 @@
1
- import type { KnowledgeDraft, KnowledgeSelection, KnowledgeView } from './project-knowledge.js';
1
+ import { type KnowledgeDraft, type KnowledgeSelection, type KnowledgeView } from './project-knowledge.js';
2
2
  export declare const KNOWLEDGE_CSS = ".knowledge{max-width:780px;min-width:0;overflow-wrap:anywhere}.knowledge .card{padding:20px;min-width:0;margin:16px 0}.knowledge h2{margin:0 0 12px}.knowledge form{display:grid;gap:12px;margin:0}.knowledge label{display:grid;gap:6px}.knowledge textarea,.knowledge input,.knowledge select{box-sizing:border-box;width:100%;min-width:0;max-width:100%}.knowledge textarea{resize:vertical;min-height:110px}.knowledge button,.knowledge summary,.knowledge .button-link{min-height:44px}.knowledge button{justify-self:start;white-space:nowrap}.knowledge summary{padding:12px 0;cursor:pointer}.knowledge pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.knowledge .knowledge-actions{display:flex;gap:12px;flex-wrap:wrap}.knowledge .meta{font-size:.86rem}.knowledge .knowledge-source{display:block}.knowledge details+details{border-top:1px solid var(--so-line)}.memory-stats{display:flex;flex-wrap:wrap;gap:.4rem}.memory-stats span{padding:.15rem .55rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}@media(max-width:600px){.knowledge .card{padding:16px}.knowledge h2{font-size:1.1rem}}";
3
3
  export declare function knowledgeHtml(view: KnowledgeView, csrf: string, canManage: boolean, draft?: KnowledgeDraft, error?: string): string;
4
4
  export declare function knowledgeContextHtml(snapshot: KnowledgeSelection | null): string;
@@ -1,3 +1,4 @@
1
+ import { STALE_KNOWLEDGE } from './project-knowledge.js';
1
2
  const e = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2
3
  const fields = (data) => Object.entries(data).map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`).join('');
3
4
  export const KNOWLEDGE_CSS = `.knowledge{max-width:780px;min-width:0;overflow-wrap:anywhere}.knowledge .card{padding:20px;min-width:0;margin:16px 0}.knowledge h2{margin:0 0 12px}.knowledge form{display:grid;gap:12px;margin:0}.knowledge label{display:grid;gap:6px}.knowledge textarea,.knowledge input,.knowledge select{box-sizing:border-box;width:100%;min-width:0;max-width:100%}.knowledge textarea{resize:vertical;min-height:110px}.knowledge button,.knowledge summary,.knowledge .button-link{min-height:44px}.knowledge button{justify-self:start;white-space:nowrap}.knowledge summary{padding:12px 0;cursor:pointer}.knowledge pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.knowledge .knowledge-actions{display:flex;gap:12px;flex-wrap:wrap}.knowledge .meta{font-size:.86rem}.knowledge .knowledge-source{display:block}.knowledge details+details{border-top:1px solid var(--so-line)}.memory-stats{display:flex;flex-wrap:wrap;gap:.4rem}.memory-stats span{padding:.15rem .55rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}@media(max-width:600px){.knowledge .card{padding:16px}.knowledge h2{font-size:1.1rem}}`;
@@ -8,6 +9,12 @@ export function knowledgeHtml(view, csrf, canManage, draft, error) {
8
9
  const referenceFields = (d) => `<label>Name<input name="title" maxlength="120" value="${e(d.title)}" required></label><label>Reference text<textarea name="content" rows="5" maxlength="12000">${e(d.content)}</textarea></label><details${d.path ? ' open' : ''}><summary>Use a project document</summary><label>Committed .md or .txt path<input name="path" value="${e(d.path)}" placeholder="docs/design-guide.md"></label><p class="meta">Uses the committed file instead of the text above. Save again to refresh it.</p></details><button type="submit">Save reference</button>`;
9
10
  const refs = view.knowledge.references.map(ref => `<details class="card"${draft?.id === ref.id ? ' open' : ''}><summary><strong>${e(ref.title)}</strong></summary>${ref.path ? `<span class="meta knowledge-source">${e(ref.path)} · ${e(ref.sourceRevision?.slice(0, 7))}</span>` : ''}<pre>${e(ref.content)}</pre>${manage ? `<details${draft?.id === ref.id ? ' open' : ''}><summary>Edit reference</summary>${form('save', referenceFields(draft?.id === ref.id ? draft : { title: ref.title, content: ref.path ? '' : ref.content, path: ref.path ?? '' }), { id: ref.id })}<details><summary>Remove reference</summary>${form('remove', '<p>Remove from future tasks. Previous run context stays available.</p><button>Remove reference</button>', { id: ref.id })}</details></details>` : ''}</details>`).join('');
10
11
  const history = view.history.map(h => `<li><a class="button-link" href="/settings/knowledge?repo=${encodeURIComponent(view.repo)}&version=${h.revision}">Version ${h.revision}</a> · ${e(h.actor)} · ${e(h.at.slice(0, 16).replace('T', ' '))} UTC</li>`).join('');
12
+ if (view.stale) {
13
+ // One notice, the saved knowledge on demand, one approver action. Editing waits until it is applied.
14
+ const saved = view.stale.knowledge;
15
+ const review = `<details><summary>Review saved knowledge</summary><pre class="knowledge-instructions">${e(saved.instructions || 'No project instructions.')}</pre>${saved.references.map(ref => `<details><summary>${e(ref.title)}</summary><pre>${e(ref.content)}</pre></details>`).join('')}</details>`;
16
+ return `<section class="knowledge knowledge-library">${error ? `<p class="problem" role="alert">${e(error)}</p>` : ''}<section class="card knowledge-stale"><p role="status">${e(STALE_KNOWLEDGE)}.</p>${review}${manage ? form('apply', '<button>Apply saved knowledge</button>') : ''}</section></section>`;
17
+ }
11
18
  const instructions = `<pre class="knowledge-instructions">${e(view.knowledge.instructions || 'No project instructions yet.')}</pre>`;
12
19
  const editor = manage ? `<details class="knowledge-editor"${draft?.instructions !== undefined ? ' open' : ''}><summary>${view.knowledge.instructions ? 'Edit instructions' : 'Add instructions'}</summary>${form('instructions', `<textarea aria-label="Project instructions" name="instructions" rows="5" maxlength="4000" placeholder="Goals, preferences, and what good work looks like…">${e(draft?.instructions ?? view.knowledge.instructions)}</textarea><button>Save instructions</button>`)}</details>` : '';
13
20
  return `<section class="knowledge knowledge-library">${error ? `<p class="problem" role="alert">${e(error)}</p>` : ''}<section class="knowledge-intro"><h2>Instructions</h2>${instructions}${editor}</section><section><h2>References</h2>${refs || '<p class="meta">Add a spec, design guide, or useful project document.</p>'}${manage ? `<details class="card"${draft?.title && !draft.id ? ' open' : ''}><summary>Add reference</summary>${form('save', referenceFields(draft?.id ? {} : draft ?? {}))}</details>` : ''}</section>${history ? `<details><summary>Change history</summary><ol>${history}</ol></details>` : ''}</section>`;