shapeup-sdlc 1.7.0 → 3.0.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.
Files changed (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +93 -11
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +325 -0
  42. package/kernel/report/facts.mjs +347 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +69 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1313 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -0,0 +1,66 @@
1
+ // probe t0 — "has this scope already reached T0-green in this round?"
2
+ //
3
+ // CONTRACT. A bounded, read-only query over the verdict artifacts on disk. Prints
4
+ // `{green, path, round, scope_id}` on stdout; exits 0 when green, 1 when not, 2 on a bad argv.
5
+ // Writes nothing.
6
+ //
7
+ // WHY IT IS A SUBCOMMAND AND NOT AN INLINE SNIPPET. The control plane has no filesystem of its
8
+ // own, so the alternative is a `node -e` blob assembled inside the workflow script. Such a blob is
9
+ // untestable (a workflow script cannot be imported), and it is the one command shape no permission
10
+ // rule can match — the grant covers this entry point, not arbitrary evaluated source.
11
+ //
12
+ // WHY IT READS ARTIFACTS AND NOT A LEDGER. It answers the question a RESUMED round asks: an
13
+ // attempt loop that was killed mid-round must not re-do a scope whose T0 already went green, and
14
+ // the only durable evidence of that is the verdict artifact the evaluator is required to cite.
15
+
16
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { runArgs } from "../lib/argv.mjs";
19
+ import { verdictsDir } from "../lib/paths.mjs";
20
+
21
+ /**
22
+ * The newest green T0 verdict for one scope in one round.
23
+ *
24
+ * @param {string} cwd - Project root.
25
+ * @param {string} slug - Feature slug.
26
+ * @param {string} scopeId - Scope contract id.
27
+ * @param {number} round - Build round.
28
+ * @returns {{green: boolean, path: (string|null)}} `path` is the artifact a later EVAL can cite.
29
+ */
30
+ export function greenVerdict(cwd, slug, scopeId, round) {
31
+ const dir = verdictsDir(cwd, slug);
32
+ if (!existsSync(dir)) return { green: false, path: null };
33
+ // Newest first: an attempt retried after a red one writes a higher trial ordinal at the same
34
+ // (round, attempt) address, and the LAST verdict is the one that stands.
35
+ for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort().reverse()) {
36
+ const p = join(dir, f);
37
+ try {
38
+ const b = JSON.parse(readFileSync(p, "utf8"));
39
+ if (b.scope_id === scopeId && b.round === round && b.overall === "green") return { green: true, path: p };
40
+ } catch { /* a torn artifact proves nothing; keep looking */ }
41
+ }
42
+ return { green: false, path: null };
43
+ }
44
+
45
+ export const ARGV_SPEC = {
46
+ usage: "harness.mjs probe t0 --slug <slug> --scope <scope-id> --round N [--cwd <dir>]",
47
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
48
+ slug: { type: "str", required: true },
49
+ scope: { type: "str", required: true },
50
+ round: { type: "int", min: 1, required: true },
51
+ cwd: { type: "path" },
52
+ };
53
+
54
+ /**
55
+ * Report whether one scope is already T0-green for one round.
56
+ *
57
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
58
+ * @returns {void} Exits 0 when green, 1 when not — the shape a caller can branch on without parsing.
59
+ */
60
+ export function cli(rawArgv) {
61
+ const args = runArgs(ARGV_SPEC, rawArgv);
62
+ const cwd = resolve(args.cwd || process.cwd());
63
+ const { green, path } = greenVerdict(cwd, args.slug, args.scope, args.round);
64
+ console.log(JSON.stringify({ green, path, scope_id: args.scope, round: args.round }));
65
+ process.exit(green ? 0 : 1);
66
+ }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Board derivation (pure-skill architecture v1.0, plan §8.2).
2
+ // Board derivation the planner's graph math, as code a model never re-derives.
3
3
  //
4
4
  // The mechanical half of the old ba-pitch-analyzer Phase 7b + v3.3 link-field rules — pure
5
5
  // graph math a model should never re-derive (and gets wrong: hand-authored inverses drift asymmetric
@@ -11,25 +11,27 @@
11
11
  // • Appetite Guard arithmetic (--appetite-hours N → overflow flag; the HAMMER *pause* on
12
12
  // overflow is an orchestrator gate, never resolved here)
13
13
  // • board-vs-T0 drift check (a FINISHED scope whose tasks still read `ready`) when scope
14
- // contracts name their tasks — flag, never fix
14
+ // contracts anchor use cases the board's tasks name back — flag, never fix
15
+ // • board-vs-dispatch reconciliation (BOARD-UNDISPATCHED, Phase 3.5 / S6): a scope with a
16
+ // done-marked task but no dispatched-and-answered order anywhere for it — a cut, never
17
+ // dispatched scope cannot read `done` — flag, never fix
15
18
  //
16
19
  // Zero dependencies. Usage:
17
- // node skills/ba-pitch-analyzer/scripts/board-derive.mjs --slug <slug> [--cwd <dir>] [--write]
20
+ // node kernel/harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write]
18
21
  // [--appetite-hours N]
19
22
  // Prints a JSON report; exit 0 (drift/overflow are flags for the caller's gate, not errors).
20
23
 
21
24
  import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
22
25
  import { resolve, join } from "node:path";
23
- import { isMain } from "../../tech-lead/scripts/lib/is-main.mjs";
24
- import { runArgs } from "../../tech-lead/scripts/lib/argv.mjs";
25
- import { tasksDir, scopesDir, hillDir } from "../../tech-lead/scripts/lib/paths.mjs";
26
- import { readAllContracts, splitFrontmatter, SCOPE_CONTRACT } from "../../tech-lead/scripts/lib/contract-md.mjs";
26
+ import { runArgs } from "../lib/argv.mjs";
27
+ import { tasksDir, scopesDir, hillDir, ordersDir, resultsDir } from "../lib/paths.mjs";
28
+ import { readAllContracts, splitFrontmatter, tasksForScope, SCOPE_CONTRACT } from "../lib/contract.mjs";
27
29
 
28
30
  /**
29
31
  * Read a list field from a frontmatter string, inline `[a, b]` or YAML block sequence alike.
30
32
  *
31
33
  * The SECOND instance of the same defect, in a second parser. This repo had two hand-rolled
32
- * frontmatter readers: `contract-md.mjs` for the committed contracts, and this one for the board.
34
+ * frontmatter readers: `lib/contract.mjs` for the committed contracts, and this one for the board.
33
35
  * It was fixed in the first, and this one still silently dropped a block sequence — so a board
34
36
  * written as
35
37
  * use_case_refs:
@@ -61,7 +63,7 @@ const listField = (fm, key) => {
61
63
  * Parse every TASK-*.md in a board directory into structured task records.
62
64
  * @param {string} tasksDir - Absolute path to the LOCAL tasks directory.
63
65
  * @returns {Array<{file:string, id:string, type:string, status:string, hours:number, pkg:string,
64
- * depends_on:string[], unlocks:string[], use_case_refs:string[], body:string}>} One record per
66
+ * depends_on:string[], unlocks:string[], use_case_refs:string[], scope_id:string, body:string}>} One record per
65
67
  * task file; [] when the directory does not exist.
66
68
  */
67
69
  export function parseBoard(tasksDir) {
@@ -81,6 +83,7 @@ export function parseBoard(tasksDir) {
81
83
  depends_on: listField(fm, "depends_on"),
82
84
  unlocks: listField(fm, "unlocks"),
83
85
  use_case_refs: listField(fm, "use_case_refs"),
86
+ scope_id: (fm.match(/^scope_id:\s*(\S+)/im) || [, ""])[1].trim(),
84
87
  body,
85
88
  };
86
89
  });
@@ -136,24 +139,89 @@ export function criticalPath(tasks) {
136
139
  }
137
140
 
138
141
  /**
139
- * Flag board-vs-T0 drift: FINISHED scopes whose named tasks are not yet done (flag only, never fix).
140
- * @param {Array<{id:string, status:string}>} tasks - The parsed board.
141
- * @param {Array<{scope_id:string, tasks?:string[], finished?:boolean}>} scopes - Scope facts.
142
+ * Flag board-vs-T0 drift: FINISHED scopes whose tasks are not yet done (flag only, never fix).
143
+ *
144
+ * The scope's tasks are joined through its committed `use_cases`, not read off a task-id list the
145
+ * contract used to carry — see {@link tasksForScope}. A scope that anchors no use case contributes
146
+ * nothing here rather than a false clean bill: "no anchor" is SCOPE-ANCHOR's finding to report.
147
+ *
148
+ * @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
149
+ * @param {Array<{scope_id:string, use_cases?:string[], finished?:boolean}>} scopes - Scope facts.
142
150
  * @returns {Array<{scope_id:string, task_id:string, status:string}>} One entry per drifting task; [] when none.
143
151
  */
144
152
  export function driftCheck(tasks, scopes) {
145
- const byId = Object.fromEntries(tasks.map((t) => [t.id, t]));
146
153
  const drift = [];
147
154
  for (const s of scopes) {
148
- if (!Array.isArray(s.tasks) || !s.finished) continue;
149
- for (const id of s.tasks) {
150
- const t = byId[id];
151
- if (t && t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: id, status: t.status });
155
+ if (!s.finished) continue;
156
+ for (const t of tasksForScope(tasks, s)) {
157
+ if (t.status !== "done") drift.push({ scope_id: s.scope_id, task_id: t.id, status: t.status });
152
158
  }
153
159
  }
154
160
  return drift;
155
161
  }
156
162
 
163
+ /**
164
+ * Read every dispatched-and-answered order's scope id (Phase 3.5 / S6).
165
+ *
166
+ * "Dispatched and answered" is filename presence in both directories, the same completion signal
167
+ * `sandbox-guard`'s `liveOrders()` already uses — a result join on content would let a malformed
168
+ * result count as an answer. The scope an order belongs to comes from `payload.scope_contract.
169
+ * scope_id`, not the filename: a non-BUILD order's suffix puts the operation before the scope id
170
+ * (`compileOrder`'s `scopedRoundSuffix`), so filename-prefix matching would miss it.
171
+ *
172
+ * @param {string} orders - Absolute path to the run's `orders/` directory.
173
+ * @param {string} results - Absolute path to the run's `results/` directory.
174
+ * @returns {Set<string>} scope_ids with at least one dispatched order answered by a same-named
175
+ * result file; empty when neither directory has anything to read.
176
+ */
177
+ function answeredScopeIds(orders, results) {
178
+ const answered = new Set();
179
+ if (!existsSync(orders)) return answered;
180
+ const done = new Set(existsSync(results) ? readdirSync(results) : []);
181
+ for (const f of readdirSync(orders)) {
182
+ if (!f.endsWith(".json") || !done.has(f)) continue;
183
+ try {
184
+ const sid = JSON.parse(readFileSync(join(orders, f), "utf8"))?.payload?.scope_contract?.scope_id;
185
+ if (sid) answered.add(sid);
186
+ } catch { /* an unreadable order answers nothing — spec-lint reports unreadable contracts, not this */ }
187
+ }
188
+ return answered;
189
+ }
190
+
191
+ /**
192
+ * Flag a scope whose board tasks read done but has no dispatched-and-answered order on disk
193
+ * (Phase 3.5 / S6) — the defect that motivated this phase: a scope cut before it was ever
194
+ * dispatched can still read `done` on the board because nothing compared the board's rows to what
195
+ * was actually dispatched, though both the scope contract and the order/result trail already exist
196
+ * on disk. A sibling to {@link driftCheck} (a FINISHED scope whose tasks are NOT done) — this is
197
+ * the opposite direction (done tasks, no dispatch trail) and both must keep running.
198
+ *
199
+ * @param {Array<{id:string, status:string, use_case_refs?:string[]}>} tasks - The parsed board.
200
+ * @param {Array<{scope_id:string, use_cases?:string[]}>} scopes - Scope contracts.
201
+ * @param {string} orders - Absolute path to the run's `orders/` directory.
202
+ * @param {string} results - Absolute path to the run's `results/` directory.
203
+ * @returns {Array<{rule:string, level:string, scope_id:string, detail:string}>} One finding per
204
+ * undispatched-but-done scope, in this codebase's `{rule, level, detail}` finding shape
205
+ * (`kernel/verify/spec.mjs`); [] when every done-marked scope has a dispatch trail.
206
+ */
207
+ export function undispatchedCheck(tasks, scopes, orders, results) {
208
+ const answered = answeredScopeIds(orders, results);
209
+ const findings = [];
210
+ for (const s of scopes) {
211
+ // Joined through the contract's committed `use_cases`, same as driftCheck — the two read the
212
+ // scope↔task relation from one function so they cannot disagree about which tasks a scope owns.
213
+ const doneTaskId = tasksForScope(tasks, s).find((t) => t.status === "done")?.id;
214
+ if (doneTaskId && !answered.has(s.scope_id)) {
215
+ findings.push({
216
+ rule: "BOARD-UNDISPATCHED", level: "red", scope_id: s.scope_id,
217
+ detail: `${doneTaskId} reads done for scope ${s.scope_id}, but no order under orders/ has a ` +
218
+ `matching result under results/ for this scope — a cut, never-dispatched scope cannot read done`,
219
+ });
220
+ }
221
+ }
222
+ return findings;
223
+ }
224
+
157
225
  /**
158
226
  * Derive the full board report (unlocks, hours, packages, critical path, appetite overflow, drift).
159
227
  * @param {{cwd:string, slug:string, appetiteHours?:(number|null)}} opts - Working root, feature
@@ -170,7 +238,8 @@ export function derive({ cwd, slug, appetiteHours = null }) {
170
238
  const packages = {};
171
239
  for (const t of tasks) packages[t.pkg || "(none)"] = (packages[t.pkg || "(none)"] || 0) + 1;
172
240
 
173
- // Scope facts for the drift check: contract `tasks` list + committed hill shard phase.
241
+ // Scope facts for the drift check: the contract (its `use_cases` carry the task join)
242
+ // + the committed hill shard's phase.
174
243
  const hillRoot = hillDir(cwd, slug);
175
244
  const scopes = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT).map(({ contract }) => {
176
245
  const shard = join(hillRoot, `${contract.scope_id}.yml`);
@@ -194,6 +263,7 @@ export function derive({ cwd, slug, appetiteHours = null }) {
194
263
  unlocks,
195
264
  unlocks_stale: tasks.filter((t) => JSON.stringify([...t.unlocks].sort()) !== JSON.stringify(unlocks[t.id])).map((t) => t.id),
196
265
  drift: driftCheck(tasks, scopes),
266
+ undispatched: undispatchedCheck(tasks, scopes, ordersDir(cwd, slug), resultsDir(cwd, slug)),
197
267
  _tasks: tasks,
198
268
  };
199
269
  }
@@ -220,9 +290,9 @@ export function writeUnlocks(report) {
220
290
  return written;
221
291
  }
222
292
 
223
- /** The typed argv contract (see `skills/tech-lead/scripts/lib/argv.mjs`). */
293
+ /** The typed argv contract (see `kernel/lib/argv.mjs`). */
224
294
  export const ARGV_SPEC = {
225
- usage: "board-derive.mjs --slug <slug> [--cwd <dir>] [--write] [--appetite-hours N]",
295
+ usage: "harness.mjs reduce board --slug <slug> [--cwd <dir>] [--write] [--appetite-hours N]",
226
296
  _: { arity: 0, max: 0, name: "(no positional operands)" },
227
297
  slug: { type: "str", required: true },
228
298
  cwd: { type: "path" },
@@ -230,9 +300,15 @@ export const ARGV_SPEC = {
230
300
  "appetite-hours": { type: "num", min: 0 },
231
301
  };
232
302
 
233
- const isMainModule = isMain(import.meta.url);
234
- if (isMainModule) {
235
- const args = runArgs(ARGV_SPEC);
303
+ /**
304
+ * Derive the task board from the scope contracts, and optionally write it.
305
+ *
306
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
307
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
308
+ * call `process.exit()` with the subcommand's documented code rather than returning.
309
+ */
310
+ export async function cli(rawArgv) {
311
+ const args = runArgs(ARGV_SPEC, rawArgv);
236
312
  const cwd = resolve(args.cwd || process.cwd());
237
313
  const report = derive({ cwd, slug: args.slug, appetiteHours: args.appetiteHours ?? null });
238
314
  if (args.write) report.unlocks_written = writeUnlocks(report);