plan-verify 0.0.0-stage → 0.1.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/.github/workflows/ci.yml +48 -0
- package/README.md +1431 -2
- package/assets/icon.png +0 -0
- package/assets/logo-mark.png +0 -0
- package/assets/logo.png +0 -0
- package/examples/checkout.ears.md +74 -0
- package/examples/order-always.ears.md +3 -0
- package/examples/order-awkward.ears.md +8 -0
- package/examples/order-bad.json +1 -0
- package/examples/order-chain.ears.md +9 -0
- package/examples/order-chosen.ears.md +7 -0
- package/examples/order-clear.ears.md +8 -0
- package/examples/order-combined.ears.md +10 -0
- package/examples/order-conflict.ears.md +8 -0
- package/examples/order-covered.ears.md +9 -0
- package/examples/order-duplicate.ears.md +8 -0
- package/examples/order-fail.json +5 -0
- package/examples/order-misuse.ears.md +7 -0
- package/examples/order-negation.ears.md +7 -0
- package/examples/order-pass.json +5 -0
- package/examples/order-quantity.ears.md +8 -0
- package/examples/order-redundant.ears.md +9 -0
- package/examples/order-repair.ears.md +9 -0
- package/examples/order-repeat.ears.md +13 -0
- package/examples/order-same.ears.md +3 -0
- package/examples/order-status.ears.md +7 -0
- package/examples/order-unanswered.ears.md +8 -0
- package/examples/order-unhandled.ears.md +8 -0
- package/examples/order-unmeasured.ears.md +10 -0
- package/examples/order-unused.ears.md +13 -0
- package/examples/order-vacuous.ears.md +9 -0
- package/examples/order-vague.ears.md +7 -0
- package/examples/order-wide.ears.md +5 -0
- package/examples/order-wording.ears.md +8 -0
- package/examples/order.ears.md +7 -0
- package/examples/payment-if.ears.md +7 -0
- package/examples/plan-to-problems/README.md +54 -0
- package/examples/plan-to-problems/expected-problems.md +25 -0
- package/examples/plan-to-problems/generate.mjs +186 -0
- package/examples/plan-to-problems/generated.ears.md +27 -0
- package/examples/plan-to-problems/lint.json +576 -0
- package/examples/plan-to-problems/plan.md +5 -0
- package/examples/plan-to-problems/planted.txt +5 -0
- package/examples/plan-to-problems/problems.md +112 -0
- package/examples/status-placed.ears.md +3 -0
- package/package.json +25 -4
- package/scripts/install-scriptc.sh +16 -0
- package/skills/plan-verify/SKILL.md +1515 -0
- package/src/check.ts +250 -0
- package/src/decide.ts +1324 -0
- package/src/ears.ts +1520 -0
- package/src/jsonvalue.ts +228 -0
- package/src/lint.ts +1245 -0
- package/src/main.ts +273 -0
- package/src/policy.ts +148 -0
- package/src/repair.ts +567 -0
- package/src/report.ts +329 -0
- package/src/scenarios.ts +149 -0
- package/src/smt.ts +97 -0
- package/src/solver.ts +868 -0
- package/src/spec.ts +307 -0
- package/src/term.ts +331 -0
- package/src/text.ts +95 -0
- package/test/fixtures/malformed.ears.md +3 -0
- package/test/fixtures/unknown-phrase.ears.md +5 -0
- package/test/golden/check-fail-json.exit +1 -0
- package/test/golden/check-fail-json.stderr +0 -0
- package/test/golden/check-fail-json.stdout +13 -0
- package/test/golden/check-fail.exit +1 -0
- package/test/golden/check-fail.stderr +0 -0
- package/test/golden/check-fail.stdout +7 -0
- package/test/golden/check-pass-json.exit +1 -0
- package/test/golden/check-pass-json.stderr +0 -0
- package/test/golden/check-pass-json.stdout +6 -0
- package/test/golden/check-pass.exit +1 -0
- package/test/golden/check-pass.stderr +0 -0
- package/test/golden/check-pass.stdout +3 -0
- package/test/golden/emit-smt-json-usage.exit +1 -0
- package/test/golden/emit-smt-json-usage.stderr +0 -0
- package/test/golden/emit-smt-json-usage.stdout +4 -0
- package/test/golden/emit-smt-json.exit +1 -0
- package/test/golden/emit-smt-json.stderr +0 -0
- package/test/golden/emit-smt-json.stdout +5 -0
- package/test/golden/emit-smt.exit +1 -0
- package/test/golden/emit-smt.stderr +0 -0
- package/test/golden/emit-smt.stdout +10 -0
- package/test/golden/help.exit +1 -0
- package/test/golden/help.stderr +0 -0
- package/test/golden/help.stdout +8 -0
- package/test/golden/lint-awkward.exit +1 -0
- package/test/golden/lint-awkward.stderr +0 -0
- package/test/golden/lint-awkward.stdout +29 -0
- package/test/golden/lint-chain.exit +1 -0
- package/test/golden/lint-chain.stderr +0 -0
- package/test/golden/lint-chain.stdout +56 -0
- package/test/golden/lint-checkout.exit +1 -0
- package/test/golden/lint-checkout.stderr +0 -0
- package/test/golden/lint-checkout.stdout +301 -0
- package/test/golden/lint-clean.exit +1 -0
- package/test/golden/lint-clean.stderr +0 -0
- package/test/golden/lint-clean.stdout +9 -0
- package/test/golden/lint-clear.exit +1 -0
- package/test/golden/lint-clear.stderr +0 -0
- package/test/golden/lint-clear.stdout +9 -0
- package/test/golden/lint-conflict-json.exit +1 -0
- package/test/golden/lint-conflict-json.stderr +0 -0
- package/test/golden/lint-conflict-json.stdout +80 -0
- package/test/golden/lint-conflict.exit +1 -0
- package/test/golden/lint-conflict.stderr +0 -0
- package/test/golden/lint-conflict.stdout +41 -0
- package/test/golden/lint-duplicate.exit +1 -0
- package/test/golden/lint-duplicate.stderr +0 -0
- package/test/golden/lint-duplicate.stdout +14 -0
- package/test/golden/lint-gap.exit +1 -0
- package/test/golden/lint-gap.stderr +0 -0
- package/test/golden/lint-gap.stdout +12 -0
- package/test/golden/lint-misuse.exit +1 -0
- package/test/golden/lint-misuse.stderr +0 -0
- package/test/golden/lint-misuse.stdout +18 -0
- package/test/golden/lint-negation.exit +1 -0
- package/test/golden/lint-negation.stderr +0 -0
- package/test/golden/lint-negation.stdout +13 -0
- package/test/golden/lint-quantity.exit +1 -0
- package/test/golden/lint-quantity.stderr +0 -0
- package/test/golden/lint-quantity.stdout +41 -0
- package/test/golden/lint-redundant.exit +1 -0
- package/test/golden/lint-redundant.stderr +0 -0
- package/test/golden/lint-redundant.stdout +14 -0
- package/test/golden/lint-repair-json.exit +1 -0
- package/test/golden/lint-repair-json.stderr +0 -0
- package/test/golden/lint-repair-json.stdout +127 -0
- package/test/golden/lint-repair.exit +1 -0
- package/test/golden/lint-repair.stderr +0 -0
- package/test/golden/lint-repair.stdout +54 -0
- package/test/golden/lint-repeat.exit +1 -0
- package/test/golden/lint-repeat.stderr +0 -0
- package/test/golden/lint-repeat.stdout +48 -0
- package/test/golden/lint-status.exit +1 -0
- package/test/golden/lint-status.stderr +0 -0
- package/test/golden/lint-status.stdout +44 -0
- package/test/golden/lint-unanswered.exit +1 -0
- package/test/golden/lint-unanswered.stderr +0 -0
- package/test/golden/lint-unanswered.stdout +12 -0
- package/test/golden/lint-unhandled.exit +1 -0
- package/test/golden/lint-unhandled.stderr +0 -0
- package/test/golden/lint-unhandled.stdout +12 -0
- package/test/golden/lint-unmeasured.exit +1 -0
- package/test/golden/lint-unmeasured.stderr +0 -0
- package/test/golden/lint-unmeasured.stdout +13 -0
- package/test/golden/lint-unused-json.exit +1 -0
- package/test/golden/lint-unused-json.stderr +0 -0
- package/test/golden/lint-unused-json.stdout +37 -0
- package/test/golden/lint-unused.exit +1 -0
- package/test/golden/lint-unused.stderr +0 -0
- package/test/golden/lint-unused.stdout +25 -0
- package/test/golden/lint-vacuous-json.exit +1 -0
- package/test/golden/lint-vacuous-json.stderr +0 -0
- package/test/golden/lint-vacuous-json.stdout +27 -0
- package/test/golden/lint-vacuous.exit +1 -0
- package/test/golden/lint-vacuous.stderr +0 -0
- package/test/golden/lint-vacuous.stdout +19 -0
- package/test/golden/lint-vague.exit +1 -0
- package/test/golden/lint-vague.stderr +0 -0
- package/test/golden/lint-vague.stdout +21 -0
- package/test/golden/lint-wide.exit +1 -0
- package/test/golden/lint-wide.stderr +0 -0
- package/test/golden/lint-wide.stdout +9 -0
- package/test/golden/lint-wording.exit +1 -0
- package/test/golden/lint-wording.stderr +0 -0
- package/test/golden/lint-wording.stdout +21 -0
- package/test/golden/malformed-scenario.exit +1 -0
- package/test/golden/malformed-scenario.stderr +1 -0
- package/test/golden/malformed-scenario.stdout +0 -0
- package/test/golden/malformed-spec.exit +1 -0
- package/test/golden/malformed-spec.stderr +1 -0
- package/test/golden/malformed-spec.stdout +0 -0
- package/test/golden/policy-counterexample.exit +1 -0
- package/test/golden/policy-counterexample.stderr +0 -0
- package/test/golden/policy-counterexample.stdout +13 -0
- package/test/golden/policy-covered.exit +1 -0
- package/test/golden/policy-covered.stderr +0 -0
- package/test/golden/policy-covered.stdout +19 -0
- package/test/golden/policy-holds.exit +1 -0
- package/test/golden/policy-holds.stderr +0 -0
- package/test/golden/policy-holds.stdout +3 -0
- package/test/golden/policy-json.exit +1 -0
- package/test/golden/policy-json.stderr +0 -0
- package/test/golden/policy-json.stdout +34 -0
- package/test/golden/policy-status.exit +1 -0
- package/test/golden/policy-status.stderr +0 -0
- package/test/golden/policy-status.stdout +16 -0
- package/test/golden/policy-vacuous.exit +1 -0
- package/test/golden/policy-vacuous.stderr +0 -0
- package/test/golden/policy-vacuous.stdout +3 -0
- package/test/golden/policy-wide.exit +1 -0
- package/test/golden/policy-wide.stderr +0 -0
- package/test/golden/policy-wide.stdout +15 -0
- package/test/golden/policy.exit +1 -0
- package/test/golden/policy.stderr +0 -0
- package/test/golden/policy.stdout +19 -0
- package/test/golden/scenarios-json.exit +1 -0
- package/test/golden/scenarios-json.stderr +0 -0
- package/test/golden/scenarios-json.stdout +75 -0
- package/test/golden/scenarios-status.exit +1 -0
- package/test/golden/scenarios-status.stderr +0 -0
- package/test/golden/scenarios-status.stdout +24 -0
- package/test/golden/scenarios.exit +1 -0
- package/test/golden/scenarios.stderr +0 -0
- package/test/golden/scenarios.stdout +26 -0
- package/test/golden/unit.exit +1 -0
- package/test/golden/unit.stderr +0 -0
- package/test/golden/unit.stdout +2 -0
- package/test/golden/unknown-phrase.exit +1 -0
- package/test/golden/unknown-phrase.stderr +1 -0
- package/test/golden/unknown-phrase.stdout +0 -0
- package/test/golden/usage.exit +1 -0
- package/test/golden/usage.stderr +9 -0
- package/test/golden/usage.stdout +0 -0
- package/test/run-all.ts +499 -0
- package/test/soundness.ts +375 -0
- package/test/unit.ts +1352 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft an EARS spec from a product plan.
|
|
3
|
+
* Node 18 or later. No packages. Copy this file next to the inputs and run it there.
|
|
4
|
+
*
|
|
5
|
+
* The key is read only from process.env.XAI_API_KEY and is never printed.
|
|
6
|
+
* XAI_MODEL overrides the default model.
|
|
7
|
+
*
|
|
8
|
+
* node generate.mjs <skill.md> <plan.md> <out.ears.md> [repair-error.txt previous.ears.md]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const ENDPOINT = "https://api.x.ai/v1/chat/completions";
|
|
14
|
+
const DEFAULT_MODEL = "grok-4.7";
|
|
15
|
+
|
|
16
|
+
const key = readKey();
|
|
17
|
+
|
|
18
|
+
main().then(
|
|
19
|
+
() => {},
|
|
20
|
+
(err) => {
|
|
21
|
+
const message = err instanceof Error ? err.message : "request failed";
|
|
22
|
+
console.error("error: " + scrub(message));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
function readKey() {
|
|
28
|
+
const value = process.env.XAI_API_KEY;
|
|
29
|
+
if (value === undefined || value.length === 0) return "";
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
if (args.length !== 3 && args.length !== 5) usage();
|
|
36
|
+
if (key.length === 0) {
|
|
37
|
+
console.error("error: XAI_API_KEY is not set");
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const skill = readText(args[0]);
|
|
41
|
+
const plan = readText(args[1]);
|
|
42
|
+
const outPath = args[2];
|
|
43
|
+
let user = taskText(plan);
|
|
44
|
+
if (args.length === 5) {
|
|
45
|
+
const failure = readText(args[3]);
|
|
46
|
+
const previous = readText(args[4]);
|
|
47
|
+
user = repairText(plan, failure, previous);
|
|
48
|
+
}
|
|
49
|
+
const spec = await complete(skill, user);
|
|
50
|
+
writeFileSync(outPath, spec, "utf8");
|
|
51
|
+
console.error("wrote " + outPath);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function taskText(plan) {
|
|
55
|
+
return (
|
|
56
|
+
"You have no tools and cannot run the CLI. Do not describe what you will do. " +
|
|
57
|
+
"Translate the plan below into ONE complete plan-verify spec file (schema declarations plus EARS requirements) exactly as the skill describes. " +
|
|
58
|
+
"Translate every rule in the plan faithfully, even rules that contradict each other or look wrong; do not fix, drop, merge or resolve anything, because the verifier must find the problems. " +
|
|
59
|
+
"Reply with only the spec file inside a single fenced code block.\n\nPlan:\n" +
|
|
60
|
+
plan
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function usage() {
|
|
65
|
+
console.error(
|
|
66
|
+
"usage: node generate.mjs <skill.md> <plan.md> <out.ears.md> [repair-error.txt previous.ears.md]",
|
|
67
|
+
);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readText(path) {
|
|
72
|
+
try {
|
|
73
|
+
return readFileSync(path, "utf8");
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : "read failed";
|
|
76
|
+
console.error("error: cannot read " + path);
|
|
77
|
+
console.error(scrub(message));
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function repairText(plan, failure, previous) {
|
|
83
|
+
return (
|
|
84
|
+
taskText(plan) +
|
|
85
|
+
"\n\nThe previous spec failed to parse. Return only a corrected spec.\n\nParser error:\n" +
|
|
86
|
+
failure +
|
|
87
|
+
"\n\nPrevious spec:\n" +
|
|
88
|
+
previous
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function modelName() {
|
|
93
|
+
const chosen = process.env.XAI_MODEL;
|
|
94
|
+
if (chosen === undefined || chosen.length === 0) return DEFAULT_MODEL;
|
|
95
|
+
return chosen;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function complete(skill, user) {
|
|
99
|
+
const response = await fetch(ENDPOINT, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: {
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
Authorization: "Bearer " + key,
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
model: modelName(),
|
|
107
|
+
// A single buffered reply was dropped before it finished. Read the stream instead.
|
|
108
|
+
stream: true,
|
|
109
|
+
messages: [
|
|
110
|
+
{ role: "system", content: skill },
|
|
111
|
+
{ role: "user", content: user },
|
|
112
|
+
],
|
|
113
|
+
}),
|
|
114
|
+
signal: AbortSignal.timeout(600000),
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
const raw = await response.text();
|
|
118
|
+
console.error("error: model request failed with status " + String(response.status));
|
|
119
|
+
console.error(scrub(raw));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const text = await readStream(response);
|
|
123
|
+
if (text.length === 0) {
|
|
124
|
+
console.error("error: the model returned no spec text");
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
return extractSpec(text);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readStream(response) {
|
|
131
|
+
const decoder = new TextDecoder();
|
|
132
|
+
let buffer = "";
|
|
133
|
+
let text = "";
|
|
134
|
+
for await (const chunk of response.body) {
|
|
135
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
136
|
+
let index = buffer.indexOf("\n");
|
|
137
|
+
while (index >= 0) {
|
|
138
|
+
const line = buffer.slice(0, index).trim();
|
|
139
|
+
buffer = buffer.slice(index + 1);
|
|
140
|
+
index = buffer.indexOf("\n");
|
|
141
|
+
if (!line.startsWith("data:")) continue;
|
|
142
|
+
const data = line.slice(5).trim();
|
|
143
|
+
if (data === "[DONE]") continue;
|
|
144
|
+
try {
|
|
145
|
+
const event = JSON.parse(data);
|
|
146
|
+
const delta = event.choices && event.choices[0] && event.choices[0].delta;
|
|
147
|
+
if (delta && typeof delta.content === "string") text += delta.content;
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return text;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function extractSpec(text) {
|
|
155
|
+
const fenced = firstFence(text);
|
|
156
|
+
const body = fenced.length > 0 ? fenced : text;
|
|
157
|
+
return trimOuter(body) + "\n";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function firstFence(text) {
|
|
161
|
+
const open = text.indexOf("```");
|
|
162
|
+
if (open < 0) return "";
|
|
163
|
+
const lineEnd = text.indexOf("\n", open);
|
|
164
|
+
if (lineEnd < 0) return "";
|
|
165
|
+
const close = text.indexOf("```", lineEnd + 1);
|
|
166
|
+
if (close < 0) return "";
|
|
167
|
+
return text.slice(lineEnd + 1, close);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function trimOuter(text) {
|
|
171
|
+
let start = 0;
|
|
172
|
+
let end = text.length;
|
|
173
|
+
if (start < end && text.charCodeAt(start) === 0xfeff) start++;
|
|
174
|
+
while (start < end && isOuterSpace(text.charCodeAt(start))) start++;
|
|
175
|
+
while (end > start && isOuterSpace(text.charCodeAt(end - 1))) end--;
|
|
176
|
+
return text.slice(start, end);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isOuterSpace(code) {
|
|
180
|
+
return code === 32 || code === 9 || code === 10 || code === 13;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function scrub(text) {
|
|
184
|
+
if (typeof text !== "string" || key.length === 0) return typeof text === "string" ? text : "";
|
|
185
|
+
return text.split(key).join("[redacted]");
|
|
186
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Fall desk
|
|
2
|
+
|
|
3
|
+
event borrow_requested: Bool "A member asks to take a copy home." phrase "a member asks to take a copy home"
|
|
4
|
+
event renew_requested: Bool "A member asks to renew a copy they already have." phrase "a member asks to renew a copy"
|
|
5
|
+
event copy_returned: Bool "A member brings a copy back." phrase "a member brings a copy back"
|
|
6
|
+
state copy_in_building: Bool "That copy is still in the building." phrase "the copy is in the building"
|
|
7
|
+
state copy_on_loan: Bool "That copy is already out on loan." phrase "the copy is out on loan"
|
|
8
|
+
state fine_owed: Bool "A member still owes a fine." phrase "a fine is owed"
|
|
9
|
+
state loan_overdue: Bool "The loan has passed its due date." phrase "the loan is overdue"
|
|
10
|
+
input loan_days: Int 1..21 "How many days the desk will set. Loans are only 1 to 21 days, and those are the only lengths the desk will set." phrase "the loan length"
|
|
11
|
+
output loan_allowed: Bool "Whether the loan was allowed." phrase "allow the loan" outcome "the loan was allowed" / "the loan was not allowed"
|
|
12
|
+
output renewal_allowed: Bool "Whether the renewal was allowed." phrase "allow the renewal" outcome "the renewal was allowed" / "the renewal was not allowed"
|
|
13
|
+
output reminder_sent: Bool "Whether a reminder went out." phrase "send a reminder" outcome "a reminder was sent" / "a reminder was not sent"
|
|
14
|
+
output loan_closed: Bool "Whether the loan was closed." phrase "close the loan" outcome "the loan was closed" / "the loan was not closed"
|
|
15
|
+
|
|
16
|
+
R1 While the copy is in the building, when a member asks to take a copy home, the Fall Desk shall allow the loan.
|
|
17
|
+
R2 While the copy is out on loan, when a member asks to take a copy home, the Fall Desk shall not allow the loan.
|
|
18
|
+
R3 While the copy is out on loan, when a member asks to take a copy home, the Fall Desk shall allow the loan.
|
|
19
|
+
R4 When not a member asks to take a copy home, the Fall Desk shall not allow the loan.
|
|
20
|
+
R5 While a fine is owed, when a member asks to renew a copy, the Fall Desk shall not allow the renewal.
|
|
21
|
+
R6 While a fine is owed, when a member asks to renew a copy, the Fall Desk shall allow the renewal.
|
|
22
|
+
R7 While a fine is not owed, when a member asks to renew a copy, the Fall Desk shall allow the renewal.
|
|
23
|
+
R8 While the loan is overdue, the Fall Desk shall send a reminder.
|
|
24
|
+
R9 When a member asks to take a copy home and the loan length is at least 40, the Fall Desk shall allow the loan.
|
|
25
|
+
R10 When a member brings a copy back, the Fall Desk shall close the loan.
|
|
26
|
+
R11 When a member brings a copy back and the loan is overdue, the Fall Desk shall close the loan.
|
|
27
|
+
R12 When not a member brings a copy back, the Fall Desk shall not close the loan.
|