sylo-template-docx-writer 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yeti-Trix <131923258+Yeti-Trix@users.noreply.github.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # sylo-template-docx-writer
2
+
3
+ Fill any standard Word `.docx` template from a **template bundle** — inject AI-authored sections, images, and tables. Generalizes the former `sylo-manual-creator`; controls manuals are one template bundle. Other bundles can describe HMI manuals, robot manuals, or any docx that follows a fixed placeholder standard.
4
+
5
+ ## Enable
6
+
7
+ **Capability manager → Sylo optional packages → Template docx writer → On** (installs python-docx) → **Restart broker** → `npm run bootstrap-pi`
8
+
9
+ ## The three layers
10
+
11
+ | Layer | What | Where |
12
+ |-------|------|-------|
13
+ | **Tools** (`manual_*`) | Generic inject/stage/build/render/image/table tools — operate on placeholders + a draft.docx | This package (`extensions/index.ts` + `scripts/`) |
14
+ | **Process** | The shared workflow — pacing, placeholder→tool mapping, build order | Bundled workflow `build-docx-from-template.md` (in `sylo-workflows/shared/workflows/`) |
15
+ | **Bundle** | The template-specific data: the `.docx` skeleton, styles, section catalog, manifest | Operator-owned, git-backed: `sylo-user/docx-templates/<id>/` |
16
+
17
+ A newcomer only needs to read **`template-bundle.spec.md`** (in this skill folder) to understand what files a bundle requires, then provide a bundle (or ask the agent to help build one). The agent loads the bundled workflow on demand.
18
+
19
+ ## Template bundles (single source of truth)
20
+
21
+ A bundle is a folder under the operator's git-backed `sylo-user/docx-templates/<id>/`:
22
+
23
+ ```
24
+ <id>/
25
+ controls-template.docx # Word skeleton with {SNN_SECTION} / {S00_COVER_*} / {IMG_*} placeholders
26
+ manifest.json # template_id, required_sections, optional_sections, write_last, placeholder_format
27
+ manual-styles.json # this template's look (heading color, caption font, table borders)
28
+ section-catalog.md # what to write per slug + optional Document styles overrides
29
+ table-formats.json # optional — access/revision table markers
30
+ access-table-defaults.json # optional
31
+ ```
32
+
33
+ See **`skills/template-docx-writer/template-bundle.spec.md`** for the full file contract and manifest schema.
34
+
35
+ **Single source of truth:** `manual_start_project` points at a bundle folder (or the `.docx` inside one). The bundle's `manual-styles.json` + `section-catalog.md` are read at inject time from the path recorded in `state.json` (`template_bundle_path`). Edit the bundle once → every future project built from it picks up the change. Per-project one-off tweaks are made directly in that project's `draft.docx`, not the bundle.
36
+
37
+ **Legacy bare `.docx`:** passing a `.docx` with no sibling `manifest.json` preserves the old behavior (workspace `templates/` seeded from package defaults). Existing projects are unaffected.
38
+
39
+ ## Tools (names kept stable from manual-creator)
40
+
41
+ - `manual_start_project` — point at a bundle folder or `.docx`; records `template_bundle_path`; copies the template → `projects/<id>/draft.docx` + writes `state.json`
42
+ - `manual_discover_placeholders` — list `{TAGS}` in a docx
43
+ - `manual_render_pdf_page` — PDF page → `projects/<id>/inputs/` (optional crop)
44
+ - `manual_stage_section` / `manual_revise_section` — stage or re-stage one slug (one per call, serial)
45
+ - `manual_build_draft` — inject all staged sections
46
+ - `manual_stage_hmi_screen` / `manual_finalize_hmi` — §6 HMI path
47
+ - `manual_insert_image` / `manual_append_revision` / `manual_replace_access_table` / `manual_append_access_row`
48
+ - `manual_project_state` / `manual_list_staging` — resume / checklist
49
+
50
+ Use with **pdf-reader** for PDF sources. Pulling pictures from a source `.docx` is in **sylo-docx** (`extract_docx_images` with `output_dir` = project `inputs/`).
51
+
52
+ **Agent guidance:** Pi skill **`template-docx-writer`** (`skills/template-docx-writer/SKILL.md`) maps operator requests ("change heading color", "fix §4", "add figure") to the bundle's `manual-styles.json`, `section-catalog.md`, `state.json`, and `manual_*` tools.
@@ -0,0 +1,601 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { writeFileSync, unlinkSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { randomUUID } from 'node:crypto'
6
+ import { promisify } from 'node:util'
7
+ import { fileURLToPath } from 'node:url'
8
+ import path from 'node:path'
9
+
10
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
11
+ import { Type } from 'typebox'
12
+
13
+ const execFileAsync = promisify(execFile)
14
+
15
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
16
+ const CLI = path.join(PACKAGE_ROOT, 'scripts', 'manual_cli.py')
17
+
18
+ type ToolContentBlock = { type: 'text'; text: string }
19
+
20
+ function resolvePython(): string {
21
+ return process.platform === 'win32' ? 'python' : 'python3'
22
+ }
23
+
24
+ async function runManualCli(
25
+ args: string[],
26
+ ): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
27
+ try {
28
+ const { stdout, stderr } = await execFileAsync(resolvePython(), [CLI, ...args], {
29
+ cwd: PACKAGE_ROOT,
30
+ maxBuffer: 8 * 1024 * 1024,
31
+ windowsHide: true,
32
+ })
33
+ const trimmed = stdout.trim()
34
+ if (!trimmed) {
35
+ return { ok: false, error: stderr.trim() || 'manual_cli produced no output' }
36
+ }
37
+ const data = JSON.parse(trimmed) as unknown
38
+ const rec = data as Record<string, unknown>
39
+ if (rec.error) return { ok: false, error: String(rec.error) }
40
+ return { ok: true, data }
41
+ } catch (err) {
42
+ const message = err instanceof Error ? err.message : String(err)
43
+ return {
44
+ ok: false,
45
+ error:
46
+ `${message}\n` +
47
+ `Ensure Python deps: pip install -r packages/sylo-template-docx-writer/scripts/requirements.txt`,
48
+ }
49
+ }
50
+ }
51
+
52
+ function toolError(text: string): { content: ToolContentBlock[] } {
53
+ return { content: [{ type: 'text', text }] }
54
+ }
55
+
56
+ function ok(data: unknown, summary: string): { content: ToolContentBlock[] } {
57
+ return {
58
+ content: [
59
+ { type: 'text', text: summary },
60
+ { type: 'text', text: JSON.stringify(data, null, 2) },
61
+ ],
62
+ }
63
+ }
64
+
65
+ export default function syloTemplateDocxWriterExtension(pi: ExtensionAPI): void {
66
+ pi.registerTool({
67
+ name: 'manual_discover_placeholders',
68
+ label: 'Discover manual placeholders',
69
+ description:
70
+ 'List {PLACEHOLDER} tags in a Word template or draft.docx. Run before writing sections so the AI knows what the template expects.',
71
+ parameters: Type.Object({
72
+ docx_path: Type.String({ description: 'Path to template or draft .docx' }),
73
+ }),
74
+ async execute(_id, params) {
75
+ const docx = String(params.docx_path ?? '').trim()
76
+ if (!docx) return toolError('docx_path required.')
77
+ const r = await runManualCli(['discover', docx])
78
+ if (!r.ok) return toolError(r.error)
79
+ const data = r.data as { placeholders?: string[] }
80
+ return ok(
81
+ r.data,
82
+ `Found ${data.placeholders?.length ?? 0} placeholder(s). Draft prose in conversation, then inject after operator approval.`,
83
+ )
84
+ },
85
+ })
86
+
87
+ pi.registerTool({
88
+ name: 'manual_start_project',
89
+ label: 'Start manual project',
90
+ description:
91
+ 'Copy a Word template into projects/<id>/draft.docx, fix Strict OOXML if needed, write state.json. Seeds templates/ (section-catalog.md, manual-styles.json with controls-manual heading/caption defaults, table-formats.json) on first use of the workspace.',
92
+ parameters: Type.Object({
93
+ project_root: Type.String({
94
+ description: 'Pi cwd folder (e.g. Project Manuals) containing projects/',
95
+ }),
96
+ project_id: Type.String({ description: 'Slug e.g. 12345-EXAMPLE' }),
97
+ template_path: Type.String({ description: 'Path to controls template .docx' }),
98
+ }),
99
+ async execute(_id, params) {
100
+ const root = String(params.project_root ?? '').trim()
101
+ const id = String(params.project_id ?? '').trim()
102
+ const tpl = String(params.template_path ?? '').trim()
103
+ if (!root || !id || !tpl) return toolError('project_root, project_id, template_path required.')
104
+ const r = await runManualCli(['start', root, id, tpl])
105
+ if (!r.ok) return toolError(r.error)
106
+ const seeded = (r.data as { workspace?: { seeded_files?: string[] } })?.workspace?.seeded_files ?? []
107
+ const stylesNote = seeded.some((p) => p.includes('manual-styles.json'))
108
+ ? ' Seeded templates/manual-styles.json (controls-manual inject defaults).'
109
+ : ''
110
+ return ok(
111
+ r.data,
112
+ `Project ${id} started.${stylesNote} Edit templates/manual-styles.json to customize; re-inject after changes.`,
113
+ )
114
+ },
115
+ })
116
+
117
+ pi.registerTool({
118
+ name: 'manual_fill_cover',
119
+ label: 'Fill cover placeholders',
120
+ description:
121
+ 'Replace {S00_COVER_PROJECT_TITLE}, {S00_COVER_JOB_NUMBER}, {S00_COVER_MANUAL_TYPE} in draft.docx without changing template font/size on those lines.',
122
+ parameters: Type.Object({
123
+ draft_path: Type.String({ description: 'Path to draft.docx' }),
124
+ project_title: Type.Optional(Type.String()),
125
+ job_number: Type.Optional(Type.String()),
126
+ manual_type: Type.Optional(Type.String({ description: 'e.g. Controls Manual' })),
127
+ }),
128
+ async execute(_id, params) {
129
+ const draft = String(params.draft_path ?? '').trim()
130
+ if (!draft) return toolError('draft_path required.')
131
+ const args = ['fill-cover', draft]
132
+ const title = String(params.project_title ?? '').trim()
133
+ const job = String(params.job_number ?? '').trim()
134
+ const mtype = String(params.manual_type ?? '').trim()
135
+ if (title) args.push('--project-title', title)
136
+ if (job) args.push('--job-number', job)
137
+ if (mtype) args.push('--manual-type', mtype)
138
+ if (args.length === 2) return toolError('At least one of project_title, job_number, manual_type required.')
139
+ const r = await runManualCli(args)
140
+ if (!r.ok) return toolError(r.error)
141
+ return ok(r.data, 'Cover placeholders filled (formatting preserved).')
142
+ },
143
+ })
144
+
145
+ pi.registerTool({
146
+ name: 'manual_inject_section',
147
+ label: 'Inject manual section',
148
+ description:
149
+ 'Replace {PLACEHOLDER} in draft.docx with approved prose. Call only after operator approves text in chat. Sub-subsections use ### 4.3.1 (Heading 3), not 1.1 under 4.3.',
150
+ parameters: Type.Object({
151
+ draft_path: Type.String({ description: 'Path to projects/.../draft.docx' }),
152
+ placeholder: Type.String({ description: 'e.g. SECTION_3_CONTENT (without braces)' }),
153
+ content: Type.String({
154
+ description: 'Approved markdown; sub-subsections as ### 4.3.1 (full three-level number).',
155
+ }),
156
+ }),
157
+ async execute(_id, params) {
158
+ const draft = String(params.draft_path ?? '').trim()
159
+ const ph = String(params.placeholder ?? '').trim()
160
+ const content = String(params.content ?? '')
161
+ if (!draft || !ph) return toolError('draft_path and placeholder required.')
162
+ const tmp = join(tmpdir(), `sylo-manual-${randomUUID()}.txt`)
163
+ writeFileSync(tmp, content, 'utf8')
164
+ try {
165
+ const r = await runManualCli(['inject', draft, ph, '--content-file', tmp])
166
+ if (!r.ok) return toolError(r.error)
167
+ return ok(r.data, `Injected {${ph.toUpperCase()}} into draft.`)
168
+ } finally {
169
+ try {
170
+ unlinkSync(tmp)
171
+ } catch {
172
+ /* ignore */
173
+ }
174
+ }
175
+ },
176
+ })
177
+
178
+ pi.registerTool({
179
+ name: 'manual_insert_image',
180
+ label: 'Insert manual image',
181
+ description:
182
+ 'Replace an {IMG_...} placeholder with a screenshot or diagram. Adds a Caption line (SEQ Figure) for Word table of figures.',
183
+ parameters: Type.Object({
184
+ draft_path: Type.String(),
185
+ placeholder: Type.String({ description: 'e.g. IMG_6_1_DASHBOARD' }),
186
+ image_path: Type.String({ description: 'Path to image file (save pasted screenshots to project inputs/ first)' }),
187
+ width_inches: Type.Optional(Type.Number({ description: 'Default 6.5', minimum: 1, maximum: 8 })),
188
+ caption: Type.Optional(
189
+ Type.String({
190
+ description: 'Caption description after "Figure N:" (defaults to filename stem)',
191
+ }),
192
+ ),
193
+ }),
194
+ async execute(_id, params) {
195
+ const draft = String(params.draft_path ?? '').trim()
196
+ const ph = String(params.placeholder ?? '').trim()
197
+ const img = String(params.image_path ?? '').trim()
198
+ if (!draft || !ph || !img) return toolError('draft_path, placeholder, image_path required.')
199
+ const args = ['image', draft, ph, img]
200
+ if (typeof params.width_inches === 'number') args.push('--width', String(params.width_inches))
201
+ const cap = String(params.caption ?? '').trim()
202
+ if (cap) args.push('--caption', cap)
203
+ const r = await runManualCli(args)
204
+ if (!r.ok) return toolError(r.error)
205
+ return ok(r.data, `Image inserted at {${ph.toUpperCase()}}.`)
206
+ },
207
+ })
208
+
209
+ pi.registerTool({
210
+ name: 'manual_render_pdf_page',
211
+ label: 'Render PDF page to project inputs',
212
+ description:
213
+ 'Render one PDF page to PNG under projects/<id>/inputs/ for staging (![caption](inputs/...)). ' +
214
+ 'Optional crop: clip_norm (0–1 fractions, top-left) or clip_pt (PDF points). Requires PyMuPDF (pip with this package requirements).',
215
+ parameters: Type.Object({
216
+ pdf_path: Type.String({ description: 'Path to source PDF' }),
217
+ page: Type.Number({ description: '1-based page number', minimum: 1 }),
218
+ draft_path: Type.Optional(
219
+ Type.String({ description: 'projects/<id>/draft.docx — inputs/ is sibling folder' }),
220
+ ),
221
+ project_root: Type.Optional(
222
+ Type.String({ description: 'Workspace root (parent of projects/) if no draft_path' }),
223
+ ),
224
+ project_id: Type.Optional(Type.String()),
225
+ dpi: Type.Optional(Type.Number({ description: 'Default 150', minimum: 72, maximum: 600 })),
226
+ name: Type.Optional(Type.String({ description: 'Output filename stem' })),
227
+ clip_norm: Type.Optional(
228
+ Type.String({
229
+ description:
230
+ 'Crop region: left,top,right,bottom as 0–1 fractions of page size (e.g. 0.1,0.2,0.9,0.85)',
231
+ }),
232
+ ),
233
+ clip_pt: Type.Optional(
234
+ Type.String({
235
+ description: 'Crop region: x0,y0,x1,y1 in PDF points (top-left origin); use after measuring one full-page render',
236
+ }),
237
+ ),
238
+ }),
239
+ async execute(_id, params) {
240
+ const pdf = String(params.pdf_path ?? '').trim()
241
+ const page = Math.floor(Number(params.page))
242
+ if (!pdf || !Number.isFinite(page) || page < 1) {
243
+ return toolError('pdf_path and page (>= 1) required.')
244
+ }
245
+ const draft = String(params.draft_path ?? '').trim()
246
+ const root = String(params.project_root ?? '').trim()
247
+ const pid = String(params.project_id ?? '').trim()
248
+ if (!draft && !(root && pid)) {
249
+ return toolError('Provide draft_path or project_root + project_id.')
250
+ }
251
+ const args = ['render-pdf', pdf, '--page', String(page)]
252
+ if (draft) args.push('--draft', draft)
253
+ if (root) args.push('--project-root', root)
254
+ if (pid) args.push('--project-id', pid)
255
+ if (typeof params.dpi === 'number') args.push('--dpi', String(params.dpi))
256
+ const nm = String(params.name ?? '').trim()
257
+ if (nm) args.push('--name', nm)
258
+ const cn = String(params.clip_norm ?? '').trim()
259
+ if (cn) args.push('--clip-norm', cn)
260
+ const cp = String(params.clip_pt ?? '').trim()
261
+ if (cp) args.push('--clip-pt', cp)
262
+ const r = await runManualCli(args)
263
+ if (!r.ok) return toolError(r.error)
264
+ const data = r.data as { relative_markdown?: string }
265
+ return ok(
266
+ r.data,
267
+ `Saved ${data.relative_markdown ?? 'PNG'} — use markdown_snippet in manual_stage_section.`,
268
+ )
269
+ },
270
+ })
271
+
272
+ pi.registerTool({
273
+ name: 'manual_append_revision',
274
+ label: 'Append revision row',
275
+ description: 'Add a row to the first table in the document (revision history).',
276
+ parameters: Type.Object({
277
+ draft_path: Type.String(),
278
+ rev: Type.String(),
279
+ date: Type.String(),
280
+ description: Type.String(),
281
+ author: Type.String({ description: 'Initials or name' }),
282
+ }),
283
+ async execute(_id, params) {
284
+ const draft = String(params.draft_path ?? '').trim()
285
+ if (!draft) return toolError('draft_path required.')
286
+ const r = await runManualCli([
287
+ 'revision',
288
+ draft,
289
+ '--rev',
290
+ String(params.rev ?? ''),
291
+ '--date',
292
+ String(params.date ?? ''),
293
+ '--description',
294
+ String(params.description ?? ''),
295
+ '--by',
296
+ String(params.author ?? ''),
297
+ ])
298
+ if (!r.ok) return toolError(r.error)
299
+ return ok(r.data, 'Revision row appended.')
300
+ },
301
+ })
302
+
303
+ pi.registerTool({
304
+ name: 'manual_stage_section',
305
+ label: 'Stage manual section',
306
+ description:
307
+ 'Store one operator-approved prose section in state.json staging. Does not touch draft.docx. Body must start with # N.0 Chapter title (Heading 1). Do not add extra top-level sections without operator OK + manual_add_section.',
308
+ parameters: Type.Object({
309
+ state_path: Type.String(),
310
+ placeholder: Type.String({ description: 'Catalog slug e.g. S03_SECTION' }),
311
+ content: Type.String({
312
+ description:
313
+ 'Markdown: first line # 3.0 Machine Capacities (H1), then ##/### subsections. Terms: bullets only. No duplicate ## N.0 under same slug.',
314
+ }),
315
+ }),
316
+ async execute(_id, params) {
317
+ const sp = String(params.state_path ?? '').trim()
318
+ const ph = String(params.placeholder ?? '').trim()
319
+ const content = String(params.content ?? '')
320
+ if (!sp || !ph || !content.trim()) return toolError('state_path, placeholder, content required.')
321
+ const tmp = join(tmpdir(), `sylo-manual-${randomUUID()}.txt`)
322
+ writeFileSync(tmp, content, 'utf8')
323
+ try {
324
+ const r = await runManualCli(['stage-section', sp, ph, '--body-file', tmp])
325
+ if (!r.ok) return toolError(r.error)
326
+ return ok(r.data, `Staged {${ph.toUpperCase()}}. Move to next catalog section or build draft when ready.`)
327
+ } finally {
328
+ try {
329
+ unlinkSync(tmp)
330
+ } catch {
331
+ /* ignore */
332
+ }
333
+ }
334
+ },
335
+ })
336
+
337
+ pi.registerTool({
338
+ name: 'manual_revise_section',
339
+ label: 'Revise built section',
340
+ description:
341
+ 'Edit a section already written into draft.docx — re-stage new prose and re-inject it in place (replaces old content between hidden section markers). Use this to fix or rewrite a section without manual_start_project. Requires the section to have been built at least once. Call only after operator approves the new text.',
342
+ parameters: Type.Object({
343
+ state_path: Type.String({ description: 'projects/<id>/state.json' }),
344
+ placeholder: Type.String({ description: 'Catalog slug e.g. S09_SECTION' }),
345
+ content: Type.String({ description: 'Approved replacement section body (markdown)' }),
346
+ }),
347
+ async execute(_id, params) {
348
+ const sp = String(params.state_path ?? '').trim()
349
+ const ph = String(params.placeholder ?? '').trim()
350
+ const content = String(params.content ?? '')
351
+ if (!sp || !ph || !content.trim()) return toolError('state_path, placeholder, content required.')
352
+ const tmp = join(tmpdir(), `sylo-manual-${randomUUID()}.txt`)
353
+ writeFileSync(tmp, content, 'utf8')
354
+ try {
355
+ const r = await runManualCli(['revise', sp, ph, '--body-file', tmp])
356
+ if (!r.ok) return toolError(r.error)
357
+ return ok(r.data, `Revised {${ph.toUpperCase()}} in draft.docx.`)
358
+ } finally {
359
+ try {
360
+ unlinkSync(tmp)
361
+ } catch {
362
+ /* ignore */
363
+ }
364
+ }
365
+ },
366
+ })
367
+
368
+ pi.registerTool({
369
+ name: 'manual_add_section',
370
+ label: 'Add manual section',
371
+ description:
372
+ 'Rare: insert {SNN_SECTION} placeholder only (no H1 in Word). Operator must OK first — most manuals need zero extra chapters. After add, stage body starting with # N.0 Title. For every future project, add {SNN_SECTION} to controls-template.docx instead.',
373
+ parameters: Type.Object({
374
+ state_path: Type.String(),
375
+ section_number: Type.String({ description: 'e.g. 12.0' }),
376
+ title: Type.String({ description: 'e.g. Cybersecurity' }),
377
+ placeholder: Type.String({ description: 'e.g. S12_SECTION' }),
378
+ after_placeholder: Type.String({
379
+ description: 'Existing slug to insert after, e.g. S11_SECTION',
380
+ }),
381
+ }),
382
+ async execute(_id, params) {
383
+ const sp = String(params.state_path ?? '').trim()
384
+ const number = String(params.section_number ?? '').trim()
385
+ const title = String(params.title ?? '').trim()
386
+ const ph = String(params.placeholder ?? '').trim()
387
+ const after = String(params.after_placeholder ?? '').trim()
388
+ if (!sp || !number || !title || !ph || !after) {
389
+ return toolError('state_path, section_number, title, placeholder, after_placeholder required.')
390
+ }
391
+ const r = await runManualCli([
392
+ 'add-section',
393
+ sp,
394
+ '--number',
395
+ number,
396
+ '--title',
397
+ title,
398
+ '--placeholder',
399
+ ph,
400
+ '--after',
401
+ after,
402
+ ])
403
+ if (!r.ok) return toolError(r.error)
404
+ return ok(
405
+ r.data,
406
+ `Added section ${number} ${title} as {${ph.toUpperCase()}}. Update section-catalog.md and Word TOC (F9) when ready.`,
407
+ )
408
+ },
409
+ })
410
+
411
+ pi.registerTool({
412
+ name: 'manual_list_staging',
413
+ label: 'List staged sections',
414
+ description: 'Show which sections are staged in state.json vs still pending.',
415
+ parameters: Type.Object({
416
+ state_path: Type.String(),
417
+ }),
418
+ async execute(_id, params) {
419
+ const sp = String(params.state_path ?? '').trim()
420
+ if (!sp) return toolError('state_path required.')
421
+ const r = await runManualCli(['list-staging', sp])
422
+ if (!r.ok) return toolError(r.error)
423
+ return ok(r.data, 'Staging summary loaded.')
424
+ },
425
+ })
426
+
427
+ pi.registerTool({
428
+ name: 'manual_build_draft',
429
+ label: 'Build draft from staging',
430
+ description:
431
+ 'Inject all staged prose sections (and §6 HMI if finalized screens exist) into draft.docx. Call when operator confirms all sections are approved and staged.',
432
+ parameters: Type.Object({
433
+ state_path: Type.String(),
434
+ skip_hmi: Type.Optional(Type.Boolean({ description: 'Set true if §6 handled separately' })),
435
+ }),
436
+ async execute(_id, params) {
437
+ const sp = String(params.state_path ?? '').trim()
438
+ if (!sp) return toolError('state_path required.')
439
+ const args = ['build-draft', sp]
440
+ if (params.skip_hmi === true) args.push('--skip-hmi')
441
+ const r = await runManualCli(args)
442
+ if (!r.ok) return toolError(r.error)
443
+ return ok(r.data, 'Staged content written to draft.docx.')
444
+ },
445
+ })
446
+
447
+ pi.registerTool({
448
+ name: 'manual_stage_hmi_screen',
449
+ label: 'Stage HMI screen',
450
+ description:
451
+ 'Save one approved §6 operator screen (title, body, image path) to state.json staging. Call after each screenshot+description; does not touch draft.docx yet.',
452
+ parameters: Type.Object({
453
+ state_path: Type.String({ description: 'projects/<id>/state.json' }),
454
+ title: Type.String({ description: 'Screen name e.g. Main Dashboard' }),
455
+ body: Type.String({ description: 'Approved subsection prose' }),
456
+ image_path: Type.Optional(
457
+ Type.String({ description: 'Path to screenshot saved under projects/<id>/inputs/' }),
458
+ ),
459
+ placeholder: Type.Optional(
460
+ Type.String({ description: 'Default S06_SECTION — must match template slug' }),
461
+ ),
462
+ }),
463
+ async execute(_id, params) {
464
+ const sp = String(params.state_path ?? '').trim()
465
+ const title = String(params.title ?? '').trim()
466
+ const body = String(params.body ?? '')
467
+ if (!sp || !title || !body.trim()) return toolError('state_path, title, and body required.')
468
+ const tmp = join(tmpdir(), `sylo-hmi-${randomUUID()}.txt`)
469
+ writeFileSync(tmp, body, 'utf8')
470
+ try {
471
+ const args = ['hmi-stage', sp, '--title', title, '--body-file', tmp]
472
+ if (params.image_path) args.push('--image', String(params.image_path))
473
+ if (params.placeholder) args.push('--placeholder', String(params.placeholder))
474
+ const r = await runManualCli(args)
475
+ if (!r.ok) return toolError(r.error)
476
+ return ok(r.data, `Staged HMI screen "${title}". Ask for next screen or finalize when done.`)
477
+ } finally {
478
+ try {
479
+ unlinkSync(tmp)
480
+ } catch {
481
+ /* ignore */
482
+ }
483
+ }
484
+ },
485
+ })
486
+
487
+ pi.registerTool({
488
+ name: 'manual_finalize_hmi',
489
+ label: 'Finalize HMI section',
490
+ description:
491
+ 'Inject staged §6 HMI screens into draft.docx at {S06_SECTION}. Call when operator confirms all screens are captured.',
492
+ parameters: Type.Object({
493
+ state_path: Type.String(),
494
+ placeholder: Type.Optional(Type.String()),
495
+ width_inches: Type.Optional(Type.Number({ minimum: 1, maximum: 8 })),
496
+ }),
497
+ async execute(_id, params) {
498
+ const sp = String(params.state_path ?? '').trim()
499
+ if (!sp) return toolError('state_path required.')
500
+ const args = ['hmi-finalize', sp]
501
+ if (params.placeholder) args.push('--placeholder', String(params.placeholder))
502
+ if (typeof params.width_inches === 'number') args.push('--width', String(params.width_inches))
503
+ const r = await runManualCli(args)
504
+ if (!r.ok) return toolError(r.error)
505
+ return ok(r.data, '§6 HMI injected into draft.docx from staging.')
506
+ },
507
+ })
508
+
509
+ pi.registerTool({
510
+ name: 'manual_hmi_staging',
511
+ label: 'Read HMI staging',
512
+ description: 'List staged §6 screens in state.json (resume mid-HMI without relying on chat history).',
513
+ parameters: Type.Object({
514
+ state_path: Type.String(),
515
+ placeholder: Type.Optional(Type.String()),
516
+ }),
517
+ async execute(_id, params) {
518
+ const sp = String(params.state_path ?? '').trim()
519
+ if (!sp) return toolError('state_path required.')
520
+ const args = ['hmi-staging', sp]
521
+ if (params.placeholder) args.push('--placeholder', String(params.placeholder))
522
+ const r = await runManualCli(args)
523
+ if (!r.ok) return toolError(r.error)
524
+ const data = r.data as { screen_count?: number }
525
+ return ok(r.data, `${data.screen_count ?? 0} HMI screen(s) staged.`)
526
+ },
527
+ })
528
+
529
+ pi.registerTool({
530
+ name: 'manual_replace_access_table',
531
+ label: 'Replace access/password table',
532
+ description:
533
+ 'Optional: bulk-fill an access table already in draft (first column header System). Normally §12 is a pipe table from manual_stage_section; use this only when operator supplies JSON rows.',
534
+ parameters: Type.Object({
535
+ draft_path: Type.String(),
536
+ rows_json_path: Type.String({
537
+ description:
538
+ 'Path to JSON file: [{system, purpose, username, password, notes}, ...]. Write file in project inputs/ first.',
539
+ }),
540
+ }),
541
+ async execute(_id, params) {
542
+ const draft = String(params.draft_path ?? '').trim()
543
+ const rowsPath = String(params.rows_json_path ?? '').trim()
544
+ if (!draft || !rowsPath) return toolError('draft_path and rows_json_path required.')
545
+ const r = await runManualCli(['access-table', draft, rowsPath])
546
+ if (!r.ok) return toolError(r.error)
547
+ return ok(r.data, 'Access table replaced (§21).')
548
+ },
549
+ })
550
+
551
+ pi.registerTool({
552
+ name: 'manual_append_access_row',
553
+ label: 'Append access table row',
554
+ description: 'Add one row to the marked access table (table-formats.json id: access).',
555
+ parameters: Type.Object({
556
+ draft_path: Type.String(),
557
+ system: Type.String(),
558
+ purpose: Type.String(),
559
+ username: Type.Optional(Type.String()),
560
+ password: Type.Optional(Type.String()),
561
+ notes: Type.Optional(Type.String()),
562
+ }),
563
+ async execute(_id, params) {
564
+ const draft = String(params.draft_path ?? '').trim()
565
+ if (!draft) return toolError('draft_path required.')
566
+ const args = [
567
+ 'access-row',
568
+ draft,
569
+ '--system',
570
+ String(params.system ?? ''),
571
+ '--purpose',
572
+ String(params.purpose ?? ''),
573
+ '--username',
574
+ String(params.username ?? ''),
575
+ '--password',
576
+ String(params.password ?? ''),
577
+ '--notes',
578
+ String(params.notes ?? ''),
579
+ ]
580
+ const r = await runManualCli(args)
581
+ if (!r.ok) return toolError(r.error)
582
+ return ok(r.data, 'Access table row appended.')
583
+ },
584
+ })
585
+
586
+ pi.registerTool({
587
+ name: 'manual_project_state',
588
+ label: 'Manual project state',
589
+ description: 'Read state.json checklist (completed / pending placeholders, paths).',
590
+ parameters: Type.Object({
591
+ state_path: Type.String({ description: 'Path to projects/<id>/state.json' }),
592
+ }),
593
+ async execute(_id, params) {
594
+ const sp = String(params.state_path ?? '').trim()
595
+ if (!sp) return toolError('state_path required.')
596
+ const r = await runManualCli(['state', sp])
597
+ if (!r.ok) return toolError(r.error)
598
+ return ok(r.data, 'Project state loaded.')
599
+ },
600
+ })
601
+ }