shapeup-sdlc 1.6.2
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/.claude/settings.local.example.json +20 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.env.shapeup.example +14 -0
- package/AGENTS.md +133 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +72 -0
- package/bin/init.mjs +329 -0
- package/commands/build.md +14 -0
- package/commands/eval.md +15 -0
- package/commands/hammer.md +12 -0
- package/commands/orient.md +11 -0
- package/commands/qa.md +12 -0
- package/commands/retro.md +13 -0
- package/commands/scopes.md +14 -0
- package/commands/shape.md +12 -0
- package/commands/ship.md +53 -0
- package/commands/wire.md +11 -0
- package/hooks/anti-rationalization.mjs +244 -0
- package/hooks/compact-snapshot.mjs +47 -0
- package/hooks/gate-deadline.mjs +151 -0
- package/hooks/gate-intake.mjs +110 -0
- package/hooks/gate-l2.mjs +161 -0
- package/hooks/gate-zerowork.mjs +264 -0
- package/hooks/hooks.json +118 -0
- package/hooks/lib/decision.mjs +183 -0
- package/hooks/safety-spine.mjs +296 -0
- package/hooks/sandbox-guard.mjs +172 -0
- package/hooks/session-rehydrate.mjs +109 -0
- package/hooks/slop-cleaner.mjs +176 -0
- package/oracles/_shared.mjs +46 -0
- package/oracles/http-oracle.mjs +155 -0
- package/oracles/index.mjs +36 -0
- package/oracles/process-oracle.mjs +146 -0
- package/oracles/snapshot-oracle.mjs +119 -0
- package/oracles/test-oracle.mjs +138 -0
- package/package.json +49 -0
- package/skills/advisor-protocol/SKILL.md +171 -0
- package/skills/ba-pitch-analyzer/SKILL.md +175 -0
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
- package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
- package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
- package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
- package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
- package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
- package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
- package/skills/coach/SKILL.md +202 -0
- package/skills/orient/SKILL.md +262 -0
- package/skills/qa-edge-hunter/SKILL.md +369 -0
- package/skills/scope-architect/SKILL.md +111 -0
- package/skills/scope-hammer/SKILL.md +186 -0
- package/skills/shapeup/SKILL.md +403 -0
- package/skills/shapeup/resources/breadboard-reflection.md +88 -0
- package/skills/shapeup/resources/breadboarding.md +334 -0
- package/skills/shapeup/resources/context-compaction.md +240 -0
- package/skills/shapeup/resources/framing-doc.md +92 -0
- package/skills/shapeup/resources/kickoff-doc.md +95 -0
- package/skills/shapeup/resources/shaping.md +194 -0
- package/skills/shapeup/resources/spike.md +86 -0
- package/skills/solution-architect/SKILL.md +129 -0
- package/skills/spec-evaluator/README.md +93 -0
- package/skills/spec-evaluator/SKILL.md +213 -0
- package/skills/spec-evaluator/references/anti-leniency.md +50 -0
- package/skills/spec-evaluator/references/dimension-contract.md +126 -0
- package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
- package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
- package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
- package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
- package/skills/spec-evaluator/references/dimensions/security.md +60 -0
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
- package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
- package/skills/spec-evaluator/references/probing.md +177 -0
- package/skills/spec-evaluator/references/report-schema.md +101 -0
- package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
- package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
- package/skills/task-executor/SKILL.md +194 -0
- package/skills/tech-lead/README.md +71 -0
- package/skills/tech-lead/SKILL.md +459 -0
- package/skills/tech-lead/references/delegation.md +254 -0
- package/skills/tech-lead/references/gates.md +379 -0
- package/skills/tech-lead/references/invocation.md +45 -0
- package/skills/tech-lead/references/ledger-schema.md +214 -0
- package/skills/tech-lead/references/round-protocol.md +184 -0
- package/skills/tech-lead/references/state-model.md +66 -0
- package/skills/tech-lead/references/tiny-lane.md +52 -0
- package/skills/tech-lead/schemas/domain.schema.json +2294 -0
- package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
- package/skills/tech-lead/schemas/work-order.schema.json +21 -0
- package/skills/tech-lead/schemas/work-result.schema.json +40 -0
- package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
- package/skills/tech-lead/scripts/budget-check.mjs +156 -0
- package/skills/tech-lead/scripts/compile-order.mjs +518 -0
- package/skills/tech-lead/scripts/fit-check.mjs +196 -0
- package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
- package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
- package/skills/tech-lead/scripts/init-run.mjs +326 -0
- package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
- package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
- package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
- package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
- package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
- package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
- package/skills/tech-lead/scripts/ship-report.mjs +275 -0
- package/skills/tech-lead/scripts/stats.mjs +423 -0
- package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
- package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
- package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
- package/skills/translator/README.md +66 -0
- package/skills/translator/SKILL.md +258 -0
- package/skills/translator/references/preservation-rules.md +102 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Anti-rationalization — advisory Stop hook (v1.2, absorb-audit P2).
|
|
3
|
+
//
|
|
4
|
+
// When the session's final message claims completion ("done", "all tests pass", "ready to
|
|
5
|
+
// ship") while the harness's own mechanical facts disagree (unfinished board tasks, a red T0
|
|
6
|
+
// verdict, unanswered escalates), this hook says so — to the user, out loud, with the facts.
|
|
7
|
+
//
|
|
8
|
+
// ADVISORY ONLY, by architectural invariant: "QA is a level-up, not a gate." This hook exits
|
|
9
|
+
// 0 always and emits at most { systemMessage } — never { decision: "block" }, never exit 2.
|
|
10
|
+
// A blocking Stop hook would be a second gate behind the single judge (spec-evaluator).
|
|
11
|
+
//
|
|
12
|
+
// Harness-scoped: fires only when a run is actually active (.shapeup/active-scope
|
|
13
|
+
// exists, or some .shapeup/*/harness-run.md is mid-build). An always-on nag on
|
|
14
|
+
// non-harness work is exactly the annoyance that gets hooks disabled.
|
|
15
|
+
//
|
|
16
|
+
// Contract: Stop stdin JSON { cwd, stop_hook_active, last_assistant_message, transcript_path }.
|
|
17
|
+
|
|
18
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
|
|
21
|
+
import { localDir, localRoot, activeScope } from "../skills/tech-lead/scripts/lib/paths.mjs";
|
|
22
|
+
|
|
23
|
+
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
24
|
+
|
|
25
|
+
function readJSON(p) {
|
|
26
|
+
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function frontmatter(text) {
|
|
30
|
+
const m = /^---\n([\s\S]*?)\n---/.exec(text || "");
|
|
31
|
+
if (!m) return {};
|
|
32
|
+
const fm = {};
|
|
33
|
+
for (const line of m[1].split("\n")) {
|
|
34
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line.trim());
|
|
35
|
+
if (kv) fm[kv[1]] = kv[2].replace(/^['"]|['"]$/g, "");
|
|
36
|
+
}
|
|
37
|
+
return fm;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MID_RUN = new Set(["orienting", "mapping", "building", "evaluating"]);
|
|
41
|
+
|
|
42
|
+
/** The active harness slug, or null when no run is in progress. */
|
|
43
|
+
export function activeSlug(cwd) {
|
|
44
|
+
const pointer = readJSON(activeScope(cwd));
|
|
45
|
+
if (pointer?.slug) return pointer.slug;
|
|
46
|
+
const root = localDir(cwd);
|
|
47
|
+
if (!existsSync(root)) return null;
|
|
48
|
+
for (const entry of readdirSync(root)) {
|
|
49
|
+
const runPath = join(root, entry, "harness-run.md");
|
|
50
|
+
if (!existsSync(runPath)) continue;
|
|
51
|
+
try {
|
|
52
|
+
const fm = frontmatter(readFileSync(runPath, "utf8"));
|
|
53
|
+
if (MID_RUN.has(fm.status)) return entry;
|
|
54
|
+
} catch { /* unreadable run file → not this one */ }
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Does the text claim the work is finished — or promise that it is about to be?
|
|
61
|
+
*
|
|
62
|
+
* The past-tense half is the original detector. The future-tense half was added after the SDD
|
|
63
|
+
* harness benchmark produced a transcript this hook should have caught and structurally could
|
|
64
|
+
* not: the session ended on "The tech-lead skill is orchestrating the full harness. It will: 1…".
|
|
65
|
+
* A promise at the END of a session is a completion claim wearing different grammar — the run is
|
|
66
|
+
* over, and the thing it says it will do is never going to happen. Matching only past tense meant
|
|
67
|
+
* the emptiest failures were the least detectable, which is backwards.
|
|
68
|
+
*
|
|
69
|
+
* (The zero-work case — dispatched and never started — belongs to `gate-zerowork.mjs`, which
|
|
70
|
+
* blocks on a mechanical absence rather than on phrasing. This one covers narration INSIDE a run
|
|
71
|
+
* that did start.)
|
|
72
|
+
*/
|
|
73
|
+
const FUTURE_CLAIM = /\b(will (?:now )?(?:run|orchestrate|execute|proceed|begin|start)|is orchestrating|I'?ll (?:now )?(?:run|start|begin|orchestrate)|going to (?:run|orchestrate|execute))\b/i;
|
|
74
|
+
|
|
75
|
+
export function detectClaim(text) {
|
|
76
|
+
if (!text || typeof text !== "string") return null;
|
|
77
|
+
const past = /\b(done|complete(?:d)?|finished|shipped|ready to ship|all (?:tests|tasks) pass(?:ing|ed)?|everything works)\b/i.exec(text);
|
|
78
|
+
if (past) return past[1];
|
|
79
|
+
const future = FUTURE_CLAIM.exec(text);
|
|
80
|
+
return future ? future[0] : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Is this claim a promise about work still to come, rather than an assertion that work is done?
|
|
85
|
+
* The two are checked against DIFFERENT facts — see contradictions().
|
|
86
|
+
* @param {string} claim - The matched claim fragment from detectClaim().
|
|
87
|
+
* @returns {boolean} True for a future-tense promise.
|
|
88
|
+
*/
|
|
89
|
+
export function isFutureClaim(claim) {
|
|
90
|
+
return typeof claim === "string" && FUTURE_CLAIM.test(claim);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read-only mechanical facts about the run — the evidence the claim is checked against. */
|
|
94
|
+
export function gatherFacts(cwd, slug) {
|
|
95
|
+
const root = localRoot(cwd, slug);
|
|
96
|
+
const facts = { unfinished: [], red_t0: null, open_escalates: 0, run_status: null, final_verdict: null };
|
|
97
|
+
|
|
98
|
+
const tasksDir = join(root, "tasks");
|
|
99
|
+
if (existsSync(tasksDir)) {
|
|
100
|
+
for (const f of readdirSync(tasksDir)) {
|
|
101
|
+
if (!/^TASK-.*\.md$/.test(f)) continue;
|
|
102
|
+
try {
|
|
103
|
+
const fm = frontmatter(readFileSync(join(tasksDir, f), "utf8"));
|
|
104
|
+
if (fm.status && fm.status !== "done") facts.unfinished.push(fm.id || f.replace(/\.md$/, ""));
|
|
105
|
+
} catch { /* unreadable task file → no fact */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const verdictsDir = join(root, "t0", "verdicts");
|
|
110
|
+
if (existsSync(verdictsDir)) {
|
|
111
|
+
let latest = null;
|
|
112
|
+
for (const f of readdirSync(verdictsDir)) {
|
|
113
|
+
const m = /^r(\d+)-a(\d+)\.json$/.exec(f);
|
|
114
|
+
if (!m) continue;
|
|
115
|
+
const key = [Number(m[1]), Number(m[2])];
|
|
116
|
+
if (!latest || key[0] > latest.key[0] || (key[0] === latest.key[0] && key[1] > latest.key[1])) {
|
|
117
|
+
latest = { key, file: f };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (latest) {
|
|
121
|
+
const verdict = readJSON(join(verdictsDir, latest.file));
|
|
122
|
+
if (verdict?.overall === "red") facts.red_t0 = latest.file.replace(/\.json$/, "");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const escDir = join(root, "escalates");
|
|
127
|
+
if (existsSync(escDir)) {
|
|
128
|
+
facts.open_escalates = readdirSync(escDir).filter((f) => f.endsWith(".json")).length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const runPath = join(root, "harness-run.md");
|
|
132
|
+
if (existsSync(runPath)) {
|
|
133
|
+
try {
|
|
134
|
+
const fm = frontmatter(readFileSync(runPath, "utf8"));
|
|
135
|
+
facts.run_status = fm.status || null;
|
|
136
|
+
facts.final_verdict = fm.final_verdict || null;
|
|
137
|
+
} catch { /* no fact */ }
|
|
138
|
+
}
|
|
139
|
+
return facts;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The facts that contradict a claim, as human-readable fragments.
|
|
144
|
+
*
|
|
145
|
+
* TENSE DECIDES WHICH FACTS COUNT, and getting this wrong is what makes a hook get disabled.
|
|
146
|
+
* An unfinished board, a red T0 and unanswered escalates all contradict "it is done". NONE of
|
|
147
|
+
* them contradict "I am about to run the evaluator" — they are that sentence's PREMISE. Checking
|
|
148
|
+
* a promise against them meant every healthy build round ended with the hook announcing that the
|
|
149
|
+
* turn's own plan "disagrees with the facts", naming as evidence the very work the plan exists to
|
|
150
|
+
* do. This file's header calls an always-on nag "exactly the annoyance that gets hooks disabled",
|
|
151
|
+
* and the sibling future-tense detector in gate-zerowork.mjs is marked advisory for the same
|
|
152
|
+
* reason. So a promise is contradicted by one thing only: a run that is already CLOSED, where the
|
|
153
|
+
* promised work is never going to happen.
|
|
154
|
+
*
|
|
155
|
+
* The zero-work case that motivated future-tense detection — a session that dispatches, narrates
|
|
156
|
+
* "it will: 1…", and stops having written nothing — is gate-zerowork.mjs's, which blocks on a
|
|
157
|
+
* mechanical absence rather than on phrasing. It is not this hook's to double-report.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} claim - The matched claim fragment from detectClaim().
|
|
160
|
+
* @param {object} facts - Mechanical facts from gatherFacts().
|
|
161
|
+
* @returns {string[]} Human-readable contradiction fragments; empty means the claim stands.
|
|
162
|
+
*/
|
|
163
|
+
export function contradictions(claim, facts) {
|
|
164
|
+
const out = [];
|
|
165
|
+
|
|
166
|
+
if (isFutureClaim(claim)) {
|
|
167
|
+
if (facts.run_status === "shipped" || facts.run_status === "escalated") {
|
|
168
|
+
out.push(`run status is already "${facts.run_status}" — the run is closed, so the promised work cannot happen in it`);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (facts.unfinished.length > 0) {
|
|
174
|
+
const named = facts.unfinished.slice(0, 5).join(", ");
|
|
175
|
+
out.push(`${facts.unfinished.length} board task(s) not done (${named}${facts.unfinished.length > 5 ? ", …" : ""})`);
|
|
176
|
+
}
|
|
177
|
+
if (facts.red_t0) out.push(`latest T0 verdict ${facts.red_t0} is red`);
|
|
178
|
+
if (facts.open_escalates > 0) out.push(`${facts.open_escalates} escalate(s) unanswered`);
|
|
179
|
+
if (facts.final_verdict === "fail") out.push("harness-run records final_verdict: fail");
|
|
180
|
+
if (/ship/i.test(claim || "") && MID_RUN.has(facts.run_status)) out.push(`run status is still "${facts.run_status}"`);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function lastAssistantFromTranscript(transcriptPath) {
|
|
185
|
+
try {
|
|
186
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
187
|
+
if (statSync(transcriptPath).size > 20 * 1024 * 1024) return null; // stay cheap
|
|
188
|
+
const lines = readFileSync(transcriptPath, "utf8").trim().split("\n");
|
|
189
|
+
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 64; i--) {
|
|
190
|
+
let entry;
|
|
191
|
+
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
192
|
+
const msg = entry?.message;
|
|
193
|
+
if (entry?.type === "assistant" || msg?.role === "assistant") {
|
|
194
|
+
const content = msg?.content ?? entry?.content;
|
|
195
|
+
if (typeof content === "string") return content;
|
|
196
|
+
if (Array.isArray(content)) {
|
|
197
|
+
return content.filter((b) => b?.type === "text").map((b) => b.text).join("\n");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch { /* unreadable transcript → no claim source */ }
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function main() {
|
|
206
|
+
await runHook("anti-rationalization", async () => {
|
|
207
|
+
const raw = await readStdin();
|
|
208
|
+
let p;
|
|
209
|
+
/** Stay silent — with the reason on the record (hooks/lib/decision.mjs). */
|
|
210
|
+
const defer = (reason, rule) => settle({ verdict: "allow", event: "Stop", cwd: p?.cwd, reason, rule });
|
|
211
|
+
try { p = JSON.parse(raw || "{}"); }
|
|
212
|
+
catch (e) { settle({ verdict: "error", event: "Stop", reason: `unparseable payload: ${e.message}` }); }
|
|
213
|
+
|
|
214
|
+
if (p.stop_hook_active) defer("stop_hook_active — never participate in a stop-hook loop", "loop-guard");
|
|
215
|
+
|
|
216
|
+
const cwd = p.cwd || process.cwd();
|
|
217
|
+
const slug = activeSlug(cwd);
|
|
218
|
+
if (!slug) defer("no active run — nothing to check the claim against", "no-run");
|
|
219
|
+
|
|
220
|
+
const message = typeof p.last_assistant_message === "string" && p.last_assistant_message
|
|
221
|
+
? p.last_assistant_message
|
|
222
|
+
: lastAssistantFromTranscript(p.transcript_path);
|
|
223
|
+
const claim = detectClaim(message);
|
|
224
|
+
if (!claim) defer("the final message makes no completion claim", "no-claim");
|
|
225
|
+
|
|
226
|
+
const facts = gatherFacts(cwd, slug);
|
|
227
|
+
const contra = contradictions(claim, facts);
|
|
228
|
+
if (contra.length === 0) defer(`claim "${claim}" agrees with the run facts — inspected and permitted`, "claim-supported");
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
verdict: "allow", event: "Stop", cwd, subject: slug, rule: "claim-contradicted", emit: true,
|
|
232
|
+
reason: `claim "${claim}" contradicted by ${contra.length} fact(s) — advisory note emitted, not a block`,
|
|
233
|
+
payload: {
|
|
234
|
+
systemMessage:
|
|
235
|
+
`anti-rationalization (advisory): the last message claims "${claim}" but run "${slug}" facts disagree — ` +
|
|
236
|
+
`${contra.join("; ")}. Not blocking (QA is a level-up, not a gate) — verify against the board and T0 before shipping.`,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (isMain(import.meta.url)) {
|
|
243
|
+
main();
|
|
244
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Compact snapshot — PreCompact hook (v1.2, absorb-audit P4).
|
|
3
|
+
//
|
|
4
|
+
// PreCompact provably CANNOT inject context (no additionalContext/systemMessage channel), so
|
|
5
|
+
// this hook is a pure side effect: when a harness run is mid-flight, freeze a RunSnapshot to
|
|
6
|
+
// .shapeup/<slug>/run-snapshot.json before the conversation is compacted. It is the
|
|
7
|
+
// audit anchor ("what did the files say the moment the summary was made?") and the fallback
|
|
8
|
+
// hooks/session-rehydrate.mjs reads if live derivation ever throws post-compact.
|
|
9
|
+
//
|
|
10
|
+
// Never blocks compaction: the whole body is fail-open, exit 0 always.
|
|
11
|
+
//
|
|
12
|
+
// Contract: PreCompact stdin JSON { cwd, trigger: "manual"|"auto" }.
|
|
13
|
+
|
|
14
|
+
// RECEIPTS (v1.5). This hook is the sharpest case for `hooks/lib/decision.mjs`: across 1.2M
|
|
15
|
+
// benchmark tokens it was scored `Unfired` — 0 `PreCompact` events observed — and that score was
|
|
16
|
+
// UNOBTAINABLE, because "never had to fire" and "never ran" produced identical evidence (exit 0,
|
|
17
|
+
// no output). With a decision row per invocation the two become separable facts.
|
|
18
|
+
|
|
19
|
+
import { deriveSnapshot, writeSnapshot } from "../skills/tech-lead/scripts/run-snapshot.mjs";
|
|
20
|
+
import { isMain } from "../skills/tech-lead/scripts/lib/is-main.mjs";
|
|
21
|
+
import { runHook, readStdin } from "./lib/decision.mjs";
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
await runHook("compact-snapshot", async () => {
|
|
25
|
+
const raw = await readStdin();
|
|
26
|
+
let p;
|
|
27
|
+
try { p = JSON.parse(raw || "{}"); }
|
|
28
|
+
catch (e) { return { verdict: "error", event: "PreCompact", reason: `unparseable payload: ${e.message}` }; }
|
|
29
|
+
const cwd = p.cwd || process.cwd();
|
|
30
|
+
try {
|
|
31
|
+
const snapshot = deriveSnapshot(cwd);
|
|
32
|
+
if (!snapshot) return { verdict: "allow", event: "PreCompact", cwd, reason: "no active run — nothing to freeze" };
|
|
33
|
+
writeSnapshot(cwd, snapshot);
|
|
34
|
+
return {
|
|
35
|
+
verdict: "allow", event: "PreCompact", cwd, rule: "snapshot-written",
|
|
36
|
+
subject: snapshot.slug ?? null, reason: "RunSnapshot frozen before compaction",
|
|
37
|
+
};
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// A snapshot failure must never block compaction — but it is now a fact rather than silence.
|
|
40
|
+
return { verdict: "error", event: "PreCompact", cwd, reason: `snapshot failed: ${e.message}` };
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (isMain(import.meta.url)) {
|
|
46
|
+
main();
|
|
47
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GATE D — DEADLINE. PreToolUse hook. The wall-clock breaker, enforced rather than requested.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS (measured, and it corrects an earlier diagnosis).
|
|
5
|
+
//
|
|
6
|
+
// SDD harness benchmark, F3 (Sonnet 5): this harness was killed at the declared 1800 s cap and
|
|
7
|
+
// published as a DNF — no acceptance, no cost, no turns, a dash in every column. The natural
|
|
8
|
+
// reading was "it stalled at a gate". Re-reading the retained transcript says the opposite:
|
|
9
|
+
//
|
|
10
|
+
// 327 assistant turns · 262 tool calls · 130 work calls · 37 file writes
|
|
11
|
+
// 19 gate markers, last gate L3 · narration_ratio 0.047 · stall_signals 0
|
|
12
|
+
//
|
|
13
|
+
// It was working — the least talkative shapeup run in the matrix — and it was still working when
|
|
14
|
+
// the clock ran out. Both existing breakers count EVENTS (`round_budget` per round,
|
|
15
|
+
// `attempt_budget` per T0 attempt), so neither can observe that a single round has been running
|
|
16
|
+
// for twenty-nine minutes. The run burned its whole budget with both breakers untouched.
|
|
17
|
+
//
|
|
18
|
+
// The cost of that is not the DNF row. It is that an externally killed run ships NOTHING, not
|
|
19
|
+
// even the scopes that were already green. A breaker that trips from the inside routes to GATE H
|
|
20
|
+
// instead: census, baseline comparison, ship the part that works. Same clock, different ending.
|
|
21
|
+
//
|
|
22
|
+
// WHAT IT DENIES, AND WHAT IT DELIBERATELY DOES NOT. Past the deadline this denies dispatches
|
|
23
|
+
// that START NEW WORK — `task-executor`. It never denies `spec-evaluator`, `scope-hammer`,
|
|
24
|
+
// `qa-edge-hunter` or `advisor-protocol`, because a run past its deadline must still be able to
|
|
25
|
+
// judge, hammer and close. A breaker that also blocked the exit would strand the run with green
|
|
26
|
+
// scopes it could not ship, which is the failure it exists to prevent.
|
|
27
|
+
//
|
|
28
|
+
// Non-regression: with no `wall_clock_budget_s` in the receipt (the default) this defers
|
|
29
|
+
// instantly. Everything ambiguous — no receipt, unparseable payload, missing timestamp — fails
|
|
30
|
+
// open, because a gate that blocks legitimate runs gets disabled, and a disabled gate enforces
|
|
31
|
+
// nothing.
|
|
32
|
+
//
|
|
33
|
+
// Contract: PreToolUse stdin JSON { tool_name, tool_input:{skill_name|skill, skill_args|args}, cwd }.
|
|
34
|
+
// Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
|
|
35
|
+
|
|
36
|
+
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
import { evaluateBudget } from "../skills/tech-lead/scripts/budget-check.mjs";
|
|
39
|
+
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
40
|
+
import { localDir, localRoot } from "../skills/tech-lead/scripts/lib/paths.mjs";
|
|
41
|
+
|
|
42
|
+
await runHook("gate-deadline", async () => {
|
|
43
|
+
/** Fail-open, with the reason on the record (hooks/lib/decision.mjs). */
|
|
44
|
+
const defer = (reason, rule) => settle({ verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, reason, rule });
|
|
45
|
+
|
|
46
|
+
// Only these start new build work. Everything else in the harness is how a run ENDS, and must
|
|
47
|
+
// stay reachable after the deadline.
|
|
48
|
+
const STARTS_NEW_WORK = new Set(["task-executor"]);
|
|
49
|
+
|
|
50
|
+
const raw = await readStdin();
|
|
51
|
+
|
|
52
|
+
let p;
|
|
53
|
+
try { p = JSON.parse(raw || "{}"); }
|
|
54
|
+
catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
|
|
55
|
+
|
|
56
|
+
if (p.tool_name !== "Skill") defer(`not a Skill call (${p.tool_name ?? "no tool_name"}) — out of scope`);
|
|
57
|
+
|
|
58
|
+
const skillRaw = p.tool_input?.skill_name ?? p.tool_input?.skill ?? "";
|
|
59
|
+
const skill = String(skillRaw).split(":").pop();
|
|
60
|
+
if (!STARTS_NEW_WORK.has(skill)) defer(`Skill(${skill || "?"}) does not start new build work — a run past its deadline must still be able to close`, "not-new-work");
|
|
61
|
+
|
|
62
|
+
const cwd = p.cwd || process.cwd();
|
|
63
|
+
|
|
64
|
+
/** The active run's receipt, or null. Mirrors budget-check.mjs's discovery. */
|
|
65
|
+
function findRun() {
|
|
66
|
+
const root = localDir(cwd);
|
|
67
|
+
if (!existsSync(root)) return null;
|
|
68
|
+
let slug = null;
|
|
69
|
+
try { slug = JSON.parse(readFileSync(join(root, "active-scope"), "utf8"))?.slug || null; } catch { /* scan instead */ }
|
|
70
|
+
const candidates = slug ? [slug] : (() => { try { return readdirSync(root); } catch { return []; } })();
|
|
71
|
+
for (const entry of candidates) {
|
|
72
|
+
const f = join(root, entry, "receipt.json");
|
|
73
|
+
if (!existsSync(f)) continue;
|
|
74
|
+
try { return { slug: entry, receipt: JSON.parse(readFileSync(f, "utf8")) }; } catch { /* not a run */ }
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const run = findRun();
|
|
80
|
+
if (!run) defer("no run receipt — nothing to time", "no-run");
|
|
81
|
+
|
|
82
|
+
const budget = Number(run.receipt.config?.wall_clock_budget_s || 0) || null;
|
|
83
|
+
if (!budget) defer("no wall_clock_budget_s in the receipt — breaker off (the default)", "breaker-off");
|
|
84
|
+
|
|
85
|
+
const startedAt = Date.parse(run.receipt.started_at || "");
|
|
86
|
+
if (Number.isNaN(startedAt)) defer("receipt has no parseable started_at — no clock, no claim", "no-clock");
|
|
87
|
+
|
|
88
|
+
const state = evaluateBudget((Date.now() - startedAt) / 1000, budget);
|
|
89
|
+
if (state.status !== "trip") defer(`within budget (${state.status}) — inspected and permitted`, "within-budget");
|
|
90
|
+
|
|
91
|
+
// STICKY TRIP. The first version of this hook denied and said what to do instead; the orchestrator
|
|
92
|
+
// then re-dispatched task-executor and was denied again, THIRTEEN times in one measured run. Each
|
|
93
|
+
// denial costs a turn out of the budget the breaker exists to protect, so a re-triable denial
|
|
94
|
+
// actively makes the failure it is preventing worse.
|
|
95
|
+
//
|
|
96
|
+
// The fix is to make the trip a FACT rather than an event: record it once, count the retries, and
|
|
97
|
+
// escalate the language so the second denial cannot read like the first. A model that ignored
|
|
98
|
+
// "route to GATE H" phrased gently is not helped by receiving the identical text again.
|
|
99
|
+
const tripPath = join(localRoot(cwd, run.slug), "deadline-tripped.json");
|
|
100
|
+
let denials = 0;
|
|
101
|
+
try { denials = JSON.parse(readFileSync(tripPath, "utf8"))?.denials || 0; } catch { /* first trip */ }
|
|
102
|
+
denials += 1;
|
|
103
|
+
try {
|
|
104
|
+
writeFileSync(tripPath, JSON.stringify({
|
|
105
|
+
type: "deadline-trip", slug: run.slug, denials,
|
|
106
|
+
first_tripped_at: denials === 1 ? new Date().toISOString() : undefined,
|
|
107
|
+
elapsed_s: state.elapsed_s, budget_s: budget,
|
|
108
|
+
}, null, 2) + "\n");
|
|
109
|
+
} catch { /* recording is best-effort; the denial still stands */ }
|
|
110
|
+
|
|
111
|
+
const repeat = denials > 1
|
|
112
|
+
? [
|
|
113
|
+
"",
|
|
114
|
+
`⛔ THIS IS DENIAL #${denials}. Re-dispatching task-executor will be denied every time, and each`,
|
|
115
|
+
"attempt spends wall-clock the run does not have. STOP TRYING TO BUILD. The only forward move is",
|
|
116
|
+
"GATE H, below. If you have already dispatched scope-hammer, wait for it and close the run.",
|
|
117
|
+
]
|
|
118
|
+
: [];
|
|
119
|
+
|
|
120
|
+
const reason = [
|
|
121
|
+
`✋ GATE D — DEADLINE BREAKER. Run "${run.slug}" has used ${state.elapsed_s}s of its ${budget}s wall-clock budget.`,
|
|
122
|
+
"",
|
|
123
|
+
"Dispatching task-executor would start work this run cannot finish. Denied — not because the work",
|
|
124
|
+
"is wrong, but because a run that is killed from outside ships nothing, including the scopes that",
|
|
125
|
+
"are already green.",
|
|
126
|
+
"",
|
|
127
|
+
"Go to GATE H instead:",
|
|
128
|
+
"",
|
|
129
|
+
" Skill(scope-hammer, \"--breaker deadline --feature " + run.slug + "\")",
|
|
130
|
+
"",
|
|
131
|
+
"Scope-hammer runs the must-have census, compares the shippable subset against the BASELINE (never",
|
|
132
|
+
"against the ideal), and produces a cut list. Ship what is green; the rest becomes a raw idea for",
|
|
133
|
+
"the next Betting Table.",
|
|
134
|
+
"",
|
|
135
|
+
"spec-evaluator, scope-hammer, qa-edge-hunter and advisor-protocol are all still permitted — a run",
|
|
136
|
+
"past its deadline must still be able to judge, hammer, and close.",
|
|
137
|
+
...repeat,
|
|
138
|
+
].join("\n");
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
verdict: "deny", event: "PreToolUse", tool: "Skill", subject: skill, rule: "deadline-tripped",
|
|
142
|
+
reason: `wall-clock budget of ${budget}s exhausted — routing to GATE H`,
|
|
143
|
+
payload: {
|
|
144
|
+
hookSpecificOutput: {
|
|
145
|
+
hookEventName: "PreToolUse",
|
|
146
|
+
permissionDecision: "deny",
|
|
147
|
+
permissionDecisionReason: reason,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GATE L0.0 — intake precondition. PreToolUse hook.
|
|
3
|
+
//
|
|
4
|
+
// Denies a `tech-lead` dispatch that carries no pitch, no spec folder, and no requirement text.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS (measured, not theorized). On the SDD harness benchmark
|
|
7
|
+
// (`sdd-harness-bench`, feature F2, Haiku 4.5, n=3, zero variance) the orchestrator was reached as
|
|
8
|
+
//
|
|
9
|
+
// Skill(tech-lead, args: "--unattended")
|
|
10
|
+
//
|
|
11
|
+
// — the flag survived the hand-off, the requirement text did not. With nothing to orchestrate,
|
|
12
|
+
// the run printed eleven gate names, a confident plan, and wrote no code, scoring 29% against a
|
|
13
|
+
// hidden acceptance suite while *looking* like a successful run. The same harness invoked with
|
|
14
|
+
// --pitch/--spec scored 100%, n=3. A stronger model happened to inline the text and recover; a
|
|
15
|
+
// cheaper one did not, three times out of three.
|
|
16
|
+
//
|
|
17
|
+
// That is the "agent claims done" failure this project exists to prevent, happening at the
|
|
18
|
+
// project's own front door. Every other invariant that matters here lives in the runtime; this one
|
|
19
|
+
// was living in a prompt, and a prompt is exactly what gets dropped on a hand-off. So it becomes a
|
|
20
|
+
// hook, like GATE L2.
|
|
21
|
+
//
|
|
22
|
+
// Design mirrors gate-l2.mjs deliberately:
|
|
23
|
+
// • Scope — only `Skill` → tech-lead. Anything else defers instantly.
|
|
24
|
+
// • Fail-CLOSED only on a provably empty intake, with an actionable re-invocation in the reason.
|
|
25
|
+
// • Fail-OPEN on anything ambiguous (unparseable payload, unknown arg shape, an envelope
|
|
26
|
+
// --order). A gate that blocks legitimate runs gets disabled, and a disabled gate enforces
|
|
27
|
+
// nothing.
|
|
28
|
+
//
|
|
29
|
+
// Contract: PreToolUse stdin JSON { tool_name, tool_input:{skill_name|skill, skill_args|args}, ... }
|
|
30
|
+
// Deny via { hookSpecificOutput: { hookEventName, permissionDecision:"deny", permissionDecisionReason } }.
|
|
31
|
+
|
|
32
|
+
// RECEIPTS (v1.5). This gate was scored `No effect` — built, verified on 10 cases, and the
|
|
33
|
+
// benchmark re-run still 4/14 — because the hook CORRECTLY never fired and the cause was elsewhere.
|
|
34
|
+
// That distinction was unprovable from a hook that answers "inspected and permitted" and "never
|
|
35
|
+
// ran" with the same silence. Every decision below is now recorded (hooks/lib/decision.mjs).
|
|
36
|
+
|
|
37
|
+
import { runHook, readStdin, settle } from "./lib/decision.mjs";
|
|
38
|
+
|
|
39
|
+
await runHook("gate-intake", async () => {
|
|
40
|
+
/** Fail-open, with the reason on the record. */
|
|
41
|
+
const defer = (reason, rule) => settle({ verdict: "allow", event: "PreToolUse", tool: p?.tool_name ?? null, reason, rule });
|
|
42
|
+
|
|
43
|
+
const raw = await readStdin();
|
|
44
|
+
|
|
45
|
+
let p;
|
|
46
|
+
try { p = JSON.parse(raw || "{}"); }
|
|
47
|
+
catch (e) { settle({ verdict: "error", event: "PreToolUse", reason: `unparseable payload: ${e.message}` }); }
|
|
48
|
+
|
|
49
|
+
if (p.tool_name !== "Skill") defer(`not a Skill call (${p.tool_name ?? "no tool_name"}) — out of scope`);
|
|
50
|
+
|
|
51
|
+
// The Skill tool's field names differ by surface: `skill_name`/`skill_args` in the hook payload,
|
|
52
|
+
// `skill`/`args` in the model-facing stream. Accept both — assuming one was already wrong once.
|
|
53
|
+
const skillRaw = p.tool_input?.skill_name ?? p.tool_input?.skill ?? "";
|
|
54
|
+
const args = String(p.tool_input?.skill_args ?? p.tool_input?.args ?? "");
|
|
55
|
+
|
|
56
|
+
// Plugin skills arrive namespaced, e.g. "shapeup-sdlc-plugin:tech-lead".
|
|
57
|
+
const skill = String(skillRaw).split(":").pop();
|
|
58
|
+
if (skill !== "tech-lead") defer(`Skill(${skill}) is not the orchestrator — out of scope`);
|
|
59
|
+
|
|
60
|
+
// The envelope port supplies its own intake; validate-envelope.mjs owns that path.
|
|
61
|
+
if (/--order\b/.test(args)) defer("envelope dispatch — validate-envelope owns this path", "--order");
|
|
62
|
+
|
|
63
|
+
// Intake is satisfied by ANY of: a pitch path, a spec folder, or free requirement text.
|
|
64
|
+
const hasPitch = /--pitch\s+\S/.test(args);
|
|
65
|
+
const hasSpec = /--spec\s+\S/.test(args);
|
|
66
|
+
const hasResume = /--from\s+\S/.test(args); // resuming an existing run has its state on disk
|
|
67
|
+
|
|
68
|
+
// Free text = anything left once flags and their values are removed.
|
|
69
|
+
// Every flag that TAKES A VALUE must be listed here. A flag whose value is not stripped reads as
|
|
70
|
+
// free requirement text, and the gate then defers on an empty intake — the exact dispatch it
|
|
71
|
+
// exists to deny. This is a live failure mode, not a hypothetical: adding `--gate-answers ci` and
|
|
72
|
+
// `--wall-clock-budget 2400` silently blinded the gate, and the very next benchmark run reached
|
|
73
|
+
// tech-lead as `args:"--unattended --gate-answers ci --wall-clock-budget 2400"` — no requirement
|
|
74
|
+
// text at all — and was waved straight through, because "ci" and "2400" counted as the spec.
|
|
75
|
+
// Adding a valued flag anywhere in the harness means adding it here, and structural test §39
|
|
76
|
+
// enforces exactly that against commands/ship.md.
|
|
77
|
+
const VALUED_FLAGS = /--(pitch|spec|from|lens|rounds|attempts|orch-model|exec-model|eval-model|qa-model|feature|task|gate-answers|wall-clock-budget|slug|auto-level|max-rounds|intake-file|intake-text|spec-folder|cwd|out|by|preset|file|order)\s+\S+/g;
|
|
78
|
+
const BARE_FLAGS = /--[a-z0-9-]+/g;
|
|
79
|
+
const freeText = args.replace(VALUED_FLAGS, " ").replace(BARE_FLAGS, " ").trim();
|
|
80
|
+
|
|
81
|
+
if (hasPitch || hasSpec || hasResume || freeText.length > 0) {
|
|
82
|
+
const via = hasPitch ? "--pitch" : hasSpec ? "--spec" : hasResume ? "--from" : "free requirement text";
|
|
83
|
+
defer(`intake resolvable via ${via} — inspected and permitted`, via);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Provably empty intake → deny, and say exactly how to fix it.
|
|
87
|
+
const reason = [
|
|
88
|
+
"✋ GATE L0.0 — NO INTAKE. tech-lead was dispatched with no pitch, no spec folder, and no",
|
|
89
|
+
"requirement text; only flags survived the hand-off (args: " + JSON.stringify(args.slice(0, 80)) + ").",
|
|
90
|
+
"",
|
|
91
|
+
"An orchestrator with no spec has nothing to orchestrate. Proceeding would print the gate list",
|
|
92
|
+
"and build nothing — output that reads like a successful run and contains no work.",
|
|
93
|
+
"",
|
|
94
|
+
"Re-dispatch with the requirement text or an on-disk spec:",
|
|
95
|
+
" Skill(tech-lead, \"<the full requirement text>\")",
|
|
96
|
+
" Skill(tech-lead, \"--pitch <shaping.md> --spec <spec/>\")",
|
|
97
|
+
].join("\n");
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
verdict: "deny", event: "PreToolUse", tool: "Skill", subject: "tech-lead", rule: "empty-intake",
|
|
101
|
+
reason: "no pitch, no spec folder, and no requirement text survived the hand-off",
|
|
102
|
+
payload: {
|
|
103
|
+
hookSpecificOutput: {
|
|
104
|
+
hookEventName: "PreToolUse",
|
|
105
|
+
permissionDecision: "deny",
|
|
106
|
+
permissionDecisionReason: reason,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
});
|