@event4u/agent-config 1.14.0 → 1.15.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 (106) hide show
  1. package/.agent-src/commands/agent-handoff.md +1 -1
  2. package/.agent-src/commands/bug-fix.md +2 -2
  3. package/.agent-src/commands/chat-history-checkpoint.md +2 -2
  4. package/.agent-src/commands/chat-history-clear.md +1 -1
  5. package/.agent-src/commands/chat-history-resume.md +2 -2
  6. package/.agent-src/commands/chat-history.md +2 -2
  7. package/.agent-src/commands/check-current-md.md +43 -32
  8. package/.agent-src/commands/commit-in-chunks.md +43 -23
  9. package/.agent-src/commands/compress.md +34 -2
  10. package/.agent-src/commands/feature-roadmap.md +2 -2
  11. package/.agent-src/commands/fix-portability.md +2 -2
  12. package/.agent-src/commands/onboard.md +14 -5
  13. package/.agent-src/commands/optimize-augmentignore.md +9 -0
  14. package/.agent-src/commands/refine-ticket.md +9 -7
  15. package/.agent-src/commands/review-changes.md +35 -8
  16. package/.agent-src/commands/roadmap-create.md +13 -2
  17. package/.agent-src/commands/roadmap-execute.md +9 -7
  18. package/.agent-src/commands/set-cost-profile.md +8 -0
  19. package/.agent-src/commands/sync-agent-settings.md +9 -0
  20. package/.agent-src/commands/tests-execute.md +2 -3
  21. package/.agent-src/rules/artifact-engagement-recording.md +1 -1
  22. package/.agent-src/rules/augment-portability.md +56 -37
  23. package/.agent-src/rules/chat-history-cadence.md +109 -0
  24. package/.agent-src/rules/chat-history-ownership.md +123 -0
  25. package/.agent-src/rules/chat-history-visibility.md +96 -0
  26. package/.agent-src/rules/cli-output-handling.md +1 -1
  27. package/.agent-src/rules/command-suggestion.md +3 -2
  28. package/.agent-src/rules/commit-policy.md +44 -34
  29. package/.agent-src/rules/direct-answers.md +1 -1
  30. package/.agent-src/rules/language-and-tone.md +19 -15
  31. package/.agent-src/rules/non-destructive-by-default.md +18 -18
  32. package/.agent-src/rules/roadmap-progress-sync.md +133 -74
  33. package/.agent-src/rules/role-mode-adherence.md +1 -1
  34. package/.agent-src/rules/size-enforcement.md +2 -1
  35. package/.agent-src/rules/user-interaction.md +28 -4
  36. package/.agent-src/scripts/update_roadmap_progress.py +56 -4
  37. package/.agent-src/skills/blade-ui/SKILL.md +29 -10
  38. package/.agent-src/skills/command-writing/SKILL.md +15 -4
  39. package/.agent-src/skills/existing-ui-audit/SKILL.md +24 -9
  40. package/.agent-src/skills/fe-design/SKILL.md +20 -15
  41. package/.agent-src/skills/file-editor/SKILL.md +9 -0
  42. package/.agent-src/skills/livewire/SKILL.md +26 -7
  43. package/.agent-src/skills/refine-ticket/SKILL.md +30 -24
  44. package/.agent-src/skills/roadmap-management/SKILL.md +22 -16
  45. package/.agent-src/skills/skill-writing/SKILL.md +3 -3
  46. package/.agent-src/skills/upstream-contribute/SKILL.md +2 -2
  47. package/.agent-src/templates/agent-settings.md +1 -1
  48. package/.agent-src/templates/roadmaps.md +9 -8
  49. package/.agent-src/templates/scripts/memory_lookup.py +1 -1
  50. package/.agent-src/templates/scripts/work_engine/__init__.py +2 -2
  51. package/.agent-src/templates/scripts/work_engine/cli.py +64 -461
  52. package/.agent-src/templates/scripts/work_engine/cli_args.py +116 -0
  53. package/.agent-src/templates/scripts/work_engine/delivery_state.py +3 -3
  54. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +1 -1
  55. package/.agent-src/templates/scripts/work_engine/directives/backend/implement.py +1 -1
  56. package/.agent-src/templates/scripts/work_engine/directives/backend/memory.py +1 -1
  57. package/.agent-src/templates/scripts/work_engine/directives/backend/plan.py +1 -1
  58. package/.agent-src/templates/scripts/work_engine/directives/backend/report.py +1 -1
  59. package/.agent-src/templates/scripts/work_engine/dispatcher.py +1 -1
  60. package/.agent-src/templates/scripts/work_engine/emitters.py +43 -0
  61. package/.agent-src/templates/scripts/work_engine/errors.py +19 -0
  62. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +76 -0
  63. package/.agent-src/templates/scripts/work_engine/input_builders.py +163 -0
  64. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +34 -2
  65. package/.agent-src/templates/scripts/work_engine/persona_policy.py +1 -1
  66. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +1 -1
  67. package/.agent-src/templates/scripts/work_engine/state_io.py +202 -0
  68. package/.claude-plugin/marketplace.json +1 -1
  69. package/AGENTS.md +6 -4
  70. package/CHANGELOG.md +83 -8
  71. package/README.md +24 -23
  72. package/docs/MIGRATION.md +122 -0
  73. package/docs/architecture.md +83 -34
  74. package/docs/contracts/STABILITY.md +95 -0
  75. package/docs/contracts/adr-chat-history-split.md +132 -0
  76. package/docs/contracts/adr-command-suggestion.md +146 -0
  77. package/docs/contracts/adr-implement-ticket-runtime.md +122 -0
  78. package/docs/contracts/adr-product-ui-track.md +384 -0
  79. package/docs/contracts/adr-prompt-driven-execution.md +187 -0
  80. package/docs/contracts/agent-memory-contract.md +149 -0
  81. package/docs/contracts/artifact-engagement-flow.md +262 -0
  82. package/docs/contracts/command-clusters.md +126 -0
  83. package/docs/contracts/command-suggestion-flow.md +148 -0
  84. package/docs/contracts/implement-ticket-flow.md +628 -0
  85. package/docs/contracts/linear-ai-rules-inclusion.md +143 -0
  86. package/docs/contracts/linear-ai-three-layers.md +131 -0
  87. package/docs/contracts/rule-interactions.md +107 -0
  88. package/docs/contracts/rule-interactions.yml +142 -0
  89. package/docs/contracts/ui-stack-extension.md +236 -0
  90. package/docs/contracts/ui-track-flow.md +338 -0
  91. package/docs/getting-started.md +2 -2
  92. package/docs/installation.md +42 -6
  93. package/docs/migrations/commands-1.15.0.md +112 -0
  94. package/docs/ui-track-mental-model.md +121 -0
  95. package/package.json +1 -1
  96. package/scripts/build_linear_digest.py +4 -4
  97. package/scripts/check_portability.py +2 -0
  98. package/scripts/check_public_links.py +185 -0
  99. package/scripts/check_references.py +1 -0
  100. package/scripts/lint_no_new_atomic_commands.py +179 -0
  101. package/scripts/lint_rule_interactions.py +149 -0
  102. package/scripts/memory_lookup.py +1 -1
  103. package/scripts/release.py +297 -64
  104. package/scripts/skill_linter.py +14 -0
  105. package/scripts/update_counts.py +10 -0
  106. package/.agent-src/rules/chat-history.md +0 -200
@@ -8,7 +8,7 @@ directive plus numbered questions — on BLOCKED/PARTIAL.
8
8
  The script never edits code, runs tests, or opens pull requests.
9
9
  All of that is delegated to the agent via ``@agent-directive:``
10
10
  markers per
11
- ``agents/contexts/implement-ticket-flow.md#agent-directives``. The
11
+ ``docs/contracts/implement-ticket-flow.md#agent-directives``. The
12
12
  agent executes the directive, writes the resulting slice back to
13
13
  the state file, and re-invokes this script to resume.
14
14
 
@@ -20,11 +20,17 @@ boundary; before dispatch it is projected into a ``DeliveryState``
20
20
  the step handlers understand. After dispatch the mutations are
21
21
  mirrored back, and the file is rewritten in the **same** wire format
22
22
  it was loaded with — Goldens captured against v0 stay v0 byte-for-
23
- byte, while flows that already store v1 round-trip as v1. The
24
- dispatcher selects the directive set via
25
- :func:`work_engine.dispatcher.select_directive_set`, defaulting to
26
- ``"backend"`` so v0 callers behave exactly as they did before R1
27
- Phase 4.
23
+ byte, while flows that already store v1 round-trip as v1.
24
+
25
+ Layout (post P2.3 of ``road-to-post-pr29-optimize.md``): this file
26
+ is a thin orchestrator. The argument parser, state I/O, file-input
27
+ builders, hook bootstrap, and stdout/stderr emitters live in their
28
+ own leaf modules under ``work_engine`` — see ``cli_args``,
29
+ ``state_io``, ``input_builders``, ``hook_bootstrap``, ``emitters``,
30
+ ``errors``. Public names (``main``, ``DEFAULT_STATE_FILE``) and the
31
+ private monkeypatch surface (``_build_hook_registry``,
32
+ ``_CLIError``, ``_load_or_build``, …) are re-exported here so
33
+ existing imports and patch targets continue to resolve.
28
34
 
29
35
  Exit codes:
30
36
 
@@ -37,50 +43,43 @@ Exit codes:
37
43
  """
38
44
  from __future__ import annotations
39
45
 
40
- import argparse
41
- import json
42
46
  import sys
43
47
  from pathlib import Path
44
- from typing import Any, Sequence
45
-
46
- from . import state as _state_module
47
- from .delivery_state import DeliveryState, Outcome
48
+ from typing import Sequence
49
+
50
+ from .cli_args import (
51
+ DEFAULT_STATE_FILE,
52
+ LEGACY_STATE_FILE,
53
+ _build_parser,
54
+ _FMT_V0,
55
+ _FMT_V1,
56
+ )
57
+ from .delivery_state import Outcome
48
58
  from .dispatcher import (
49
59
  assert_kind_supported,
50
60
  dispatch,
51
61
  load_directive_set,
52
62
  select_directive_set,
53
63
  )
54
- from .hooks import HookContext, HookEvent, HookHalt, HookRegistry, HookRunner
55
- from .hooks.builtin import (
56
- ChatHistoryAppendHook,
57
- ChatHistoryHaltAppendHook,
58
- ChatHistoryHeartbeatHook,
59
- ChatHistoryTurnCheckHook,
60
- DirectiveSetGuardHook,
61
- HaltSurfaceAuditHook,
62
- StateShapeValidationHook,
63
- TraceHook,
64
+ from .emitters import _emit, _emit_halt
65
+ from .errors import _CLIError
66
+ from .hook_bootstrap import _build_hook_registry, _register_chat_history_hooks
67
+ from .hooks import HookContext, HookEvent, HookRunner
68
+ from .input_builders import (
69
+ _build_from_diff_file,
70
+ _build_from_file_file,
71
+ _build_from_prompt_file,
72
+ _load_or_build,
73
+ )
74
+ from .state_io import (
75
+ _load,
76
+ _maybe_raise_legacy_hint,
77
+ _read_json,
78
+ _save,
79
+ _sync_back,
80
+ _to_delivery,
81
+ _to_v0_dict,
64
82
  )
65
- from .hooks.settings import HookSettings, load_hook_settings
66
- from .intent import populate_routing
67
- from .migration.v0_to_v1 import migrate_payload
68
- from .resolvers.diff import DiffResolverError, build_envelope as _build_diff_envelope
69
- from .resolvers.file import FileResolverError, build_envelope as _build_file_envelope
70
- from .resolvers.prompt import PromptResolverError, build_envelope as _build_prompt_envelope
71
- from .state import Input, SchemaError, WorkState
72
-
73
- DEFAULT_STATE_FILE = Path(".implement-ticket-state.json")
74
- """State file used when ``--state-file`` is not passed."""
75
-
76
- _FMT_V0 = "v0"
77
- _FMT_V1 = "v1"
78
- """Wire-format markers carried alongside the loaded :class:`WorkState`.
79
-
80
- Format-preserving roundtrip: ``_load`` records which shape it parsed,
81
- ``_save`` rewrites in that same shape. v0 in → v0 out (Goldens stay
82
- byte-equal); v1 in → v1 out (future flows produced by the migration
83
- tool or a fresh v1 init keep their envelope fields)."""
84
83
 
85
84
 
86
85
  def main(argv: Sequence[str] | None = None) -> int:
@@ -170,423 +169,27 @@ def main(argv: Sequence[str] | None = None) -> int:
170
169
  return 0 if final is Outcome.SUCCESS else 1
171
170
 
172
171
 
173
- def _build_hook_registry(args: argparse.Namespace) -> HookRegistry:
174
- """Build the CLI-side :class:`HookRegistry` for one ``main()`` run.
175
-
176
- Reads ``hooks.*`` from ``.agent-settings.yml`` and registers the
177
- enabled hooks. The master switch ``hooks.enabled`` defaults to
178
- ``False`` when the block (or the file) is missing — the registry
179
- stays empty and golden replay flows are byte-stable.
180
-
181
- ``--no-hooks`` on the CLI forces an empty registry regardless of
182
- settings, which is the explicit escape hatch golden-replay test
183
- harnesses can use.
184
- """
185
- registry = HookRegistry()
186
- if getattr(args, "no_hooks", False):
187
- return registry
188
-
189
- settings_path = getattr(args, "hooks_config", None)
190
- settings = load_hook_settings(settings_path)
191
- if not settings.enabled:
192
- return registry
193
-
194
- if settings.trace:
195
- TraceHook().register(registry)
196
- if settings.halt_surface_audit:
197
- HaltSurfaceAuditHook().register(registry)
198
- if settings.state_shape_validation:
199
- StateShapeValidationHook().register(registry)
200
- if settings.directive_set_guard:
201
- DirectiveSetGuardHook().register(registry)
202
- if settings.chat_history_enabled:
203
- _register_chat_history_hooks(registry, settings)
204
-
205
- return registry
206
-
207
-
208
- def _register_chat_history_hooks(
209
- registry: HookRegistry, settings: HookSettings,
210
- ) -> None:
211
- """Register the four chat-history hooks bound to the configured script."""
212
- script = Path(settings.chat_history_script)
213
- ChatHistoryTurnCheckHook(script).register(registry)
214
- ChatHistoryAppendHook(script).register(registry)
215
- ChatHistoryHaltAppendHook(script).register(registry)
216
- ChatHistoryHeartbeatHook(script).register(registry)
217
-
218
-
219
- def _emit_halt(halt: HookHalt) -> int:
220
- """Render a :class:`HookHalt` surface to stderr and return exit 2.
221
-
222
- Per the P3 halt branch table, every CLI-layer halt yields exit code
223
- ``2`` regardless of which event fired it. State persistence is
224
- governed by *where* in ``main`` the halt is detected: the call site
225
- decides whether ``_save`` already ran. This helper is the single
226
- place that formats the surface so the wire output stays consistent.
227
- """
228
- if halt.surface:
229
- for line in halt.surface:
230
- print(line, file=sys.stderr)
231
- else:
232
- print(f"halt: {halt.reason}", file=sys.stderr)
233
- return 2
234
-
235
-
236
- def _build_parser() -> argparse.ArgumentParser:
237
- parser = argparse.ArgumentParser(
238
- prog="implement-ticket",
239
- description="Run one dispatch cycle of the /implement-ticket flow.",
240
- )
241
- parser.add_argument(
242
- "--state-file",
243
- type=Path,
244
- default=DEFAULT_STATE_FILE,
245
- help=f"Path to persisted state JSON (default: {DEFAULT_STATE_FILE}).",
246
- )
247
- parser.add_argument(
248
- "--ticket-file",
249
- type=Path,
250
- default=None,
251
- help="JSON file carrying the ticket payload; used only when the "
252
- "state file does not exist yet.",
253
- )
254
- parser.add_argument(
255
- "--prompt-file",
256
- type=Path,
257
- default=None,
258
- help="Plain-text file carrying the raw user prompt; builds an "
259
- "input.kind='prompt' envelope. Mutually exclusive with "
260
- "--ticket-file. Used only when the state file does not exist yet.",
261
- )
262
- parser.add_argument(
263
- "--diff-file",
264
- type=Path,
265
- default=None,
266
- help="Plain-text file carrying a unified diff payload; builds an "
267
- "input.kind='diff' envelope routed through the UI-improve "
268
- "directive set. Mutually exclusive with --ticket-file / "
269
- "--prompt-file / --file-file. Used only when the state file does "
270
- "not exist yet.",
271
- )
272
- parser.add_argument(
273
- "--file-file",
274
- type=Path,
275
- default=None,
276
- help="Plain-text file carrying a single path reference (one line); "
277
- "builds an input.kind='file' envelope routed through the UI-improve "
278
- "directive set. Mutually exclusive with --ticket-file / "
279
- "--prompt-file / --diff-file. Used only when the state file does "
280
- "not exist yet.",
281
- )
282
- parser.add_argument(
283
- "--persona",
284
- type=str,
285
- default=None,
286
- help="Persona name (senior-engineer | qa | advisory). Only honoured "
287
- "when the state file does not exist yet; ignored on resume so a "
288
- "mid-flight persona switch cannot silently change behaviour.",
289
- )
290
- parser.add_argument(
291
- "--no-hooks",
292
- action="store_true",
293
- default=False,
294
- help="Disable every lifecycle hook for this run. Use in golden-"
295
- "replay test harnesses so a future settings change cannot "
296
- "silently invalidate captured outputs.",
297
- )
298
- parser.add_argument(
299
- "--hooks-config",
300
- type=Path,
301
- default=None,
302
- help="Override the path to the agent-settings file used to resolve "
303
- "the hooks.* block. Defaults to ./.agent-settings.yml.",
304
- )
305
- return parser
306
-
307
-
308
- def _load_or_build(
309
- state_file: Path,
310
- args: argparse.Namespace,
311
- ) -> tuple[WorkState, str]:
312
- """Return the WorkState to dispatch against plus its wire format.
313
-
314
- Either loaded from ``state_file`` (format-preserving) or freshly
315
- built from ``--ticket-file`` (R1), ``--prompt-file`` (R2),
316
- ``--diff-file`` (R3) or ``--file-file`` (R3). Fresh ticket files
317
- default to v0 wire format so that newly captured Goldens stay
318
- byte-equal with the pre-Phase-4 baseline; the prompt / diff / file
319
- paths emit v1 directly (v0 has no envelope concept for these
320
- kinds). v1 round-trips for state files already on disk in v1 shape.
321
- """
322
- if state_file.exists():
323
- return _load(state_file)
324
- inputs = [
325
- ("--ticket-file", args.ticket_file),
326
- ("--prompt-file", args.prompt_file),
327
- ("--diff-file", args.diff_file),
328
- ("--file-file", args.file_file),
329
- ]
330
- supplied = [name for name, value in inputs if value is not None]
331
- if len(supplied) > 1:
332
- raise _CLIError(
333
- f"{', '.join(supplied)} are mutually exclusive; pass exactly "
334
- "one when building an initial state.",
335
- )
336
- if not supplied:
337
- raise _CLIError(
338
- f"No state file at {state_file} and no --ticket-file, "
339
- "--prompt-file, --diff-file, or --file-file given; cannot "
340
- "build an initial state.",
341
- )
342
- if args.prompt_file is not None:
343
- return _build_from_prompt_file(args), _FMT_V1
344
- if args.diff_file is not None:
345
- return _build_from_diff_file(args), _FMT_V1
346
- if args.file_file is not None:
347
- return _build_from_file_file(args), _FMT_V1
348
- ticket = _read_json(args.ticket_file)
349
- if not isinstance(ticket, dict):
350
- raise _CLIError(
351
- f"--ticket-file must carry a JSON object; got {type(ticket).__name__}.",
352
- )
353
- work = WorkState(input=Input(kind="ticket", data=ticket))
354
- if args.persona:
355
- work.persona = args.persona
356
- populate_routing(work)
357
- return work, _FMT_V0
358
-
359
-
360
- def _build_from_prompt_file(args: argparse.Namespace) -> WorkState:
361
- """Read ``--prompt-file`` as raw text and wrap it in a prompt envelope.
362
-
363
- The file is read verbatim (UTF-8) and handed to the prompt resolver,
364
- which validates non-emptiness and returns the canonical
365
- ``Input(kind="prompt", data={raw, reconstructed_ac, assumptions})``
366
- envelope. Persona is honoured the same way as the ticket path.
367
- """
368
- try:
369
- raw = args.prompt_file.read_text(encoding="utf-8")
370
- except OSError as exc:
371
- raise _CLIError(f"Cannot read {args.prompt_file}: {exc}") from exc
372
- try:
373
- envelope = _build_prompt_envelope(raw)
374
- except PromptResolverError as exc:
375
- raise _CLIError(f"--prompt-file is not a valid prompt: {exc}") from exc
376
- work = WorkState(input=envelope)
377
- if args.persona:
378
- work.persona = args.persona
379
- populate_routing(work)
380
- return work
381
-
382
-
383
- def _build_from_diff_file(args: argparse.Namespace) -> WorkState:
384
- """Read ``--diff-file`` as raw text and wrap it in a diff envelope.
385
-
386
- The file is read verbatim (UTF-8) and handed to the diff resolver,
387
- which validates the unified-diff header heuristic and returns the
388
- canonical
389
- ``Input(kind="diff", data={raw, reconstructed_ac, assumptions})``
390
- envelope. ``populate_routing`` then routes the envelope to the
391
- UI-improve directive set without running the prose classifier — see
392
- :mod:`work_engine.intent.classify` for the routing contract.
393
- """
394
- try:
395
- raw = args.diff_file.read_text(encoding="utf-8")
396
- except OSError as exc:
397
- raise _CLIError(f"Cannot read {args.diff_file}: {exc}") from exc
398
- try:
399
- envelope = _build_diff_envelope(raw)
400
- except DiffResolverError as exc:
401
- raise _CLIError(f"--diff-file is not a valid diff: {exc}") from exc
402
- work = WorkState(input=envelope)
403
- if args.persona:
404
- work.persona = args.persona
405
- populate_routing(work)
406
- return work
407
-
408
-
409
- def _build_from_file_file(args: argparse.Namespace) -> WorkState:
410
- """Read ``--file-file`` as a single-line path and wrap it in a file envelope.
411
-
412
- The file is read verbatim (UTF-8); the first non-empty line is taken
413
- as the path reference and handed to the file resolver, which
414
- validates path shape (non-empty, NUL-free, not a URL) and returns
415
- the canonical
416
- ``Input(kind="file", data={path, reconstructed_ac, assumptions})``
417
- envelope. Trailing whitespace and additional lines are ignored —
418
- the resolver treats the file's content as the path itself, not as
419
- structured payload.
420
- """
421
- try:
422
- raw = args.file_file.read_text(encoding="utf-8")
423
- except OSError as exc:
424
- raise _CLIError(f"Cannot read {args.file_file}: {exc}") from exc
425
- path = raw.strip().splitlines()[0] if raw.strip() else ""
426
- try:
427
- envelope = _build_file_envelope(path)
428
- except FileResolverError as exc:
429
- raise _CLIError(
430
- f"--file-file does not carry a valid path: {exc}",
431
- ) from exc
432
- work = WorkState(input=envelope)
433
- if args.persona:
434
- work.persona = args.persona
435
- populate_routing(work)
436
- return work
437
-
438
-
439
- def _load(state_file: Path) -> tuple[WorkState, str]:
440
- """Load ``state_file`` and tag it with the wire format detected."""
441
- data = _read_json(state_file)
442
- if not isinstance(data, dict):
443
- raise _CLIError(
444
- f"State file {state_file} must carry a JSON object; "
445
- f"got {type(data).__name__}.",
446
- )
447
-
448
- # v1 declares ``version``; v0 has none. Anything else is invalid.
449
- if data.get("version") == _state_module.SCHEMA_VERSION:
450
- try:
451
- return _state_module.from_dict(data), _FMT_V1
452
- except SchemaError as exc:
453
- raise _CLIError(f"State file shape is invalid: {exc}") from exc
454
- if "version" in data:
455
- raise _CLIError(
456
- f"State file shape is invalid: unsupported version "
457
- f"{data.get('version')!r}; expected {_state_module.SCHEMA_VERSION}",
458
- )
459
- if "ticket" not in data:
460
- raise _CLIError(
461
- "State file shape is invalid: missing 'ticket' (v0) or "
462
- "'version' (v1) — file is neither shape.",
463
- )
464
- try:
465
- migrated = migrate_payload(data)
466
- return _state_module.from_dict(migrated), _FMT_V0
467
- except SchemaError as exc:
468
- raise _CLIError(f"State file shape is invalid: {exc}") from exc
469
-
470
-
471
- def _to_delivery(work: WorkState) -> DeliveryState:
472
- """Project ``work`` into a ``DeliveryState`` for handler dispatch.
473
-
474
- R1 P4 S1 (Option A2): handlers continue to consume ``DeliveryState``
475
- with ``state.ticket``; the ``WorkState`` wrapper exists at the CLI
476
- boundary so the dispatcher's directive-set selection has a v1
477
- state object to read ``directive_set`` from. Mutable containers
478
- (``memory``, ``changes``, ``outcomes``, ``questions``) are passed
479
- by reference — in-place mutations land on both objects without an
480
- explicit sync. Reassignments (``state.plan = …``, ``state.report
481
- = …``) are mirrored back by :func:`_sync_back`.
482
- """
483
- return DeliveryState(
484
- ticket=work.input.data,
485
- persona=work.persona,
486
- memory=work.memory,
487
- plan=work.plan,
488
- changes=work.changes,
489
- tests=work.tests,
490
- verify=work.verify,
491
- outcomes=work.outcomes,
492
- questions=work.questions,
493
- report=work.report,
494
- ui_audit=work.ui_audit,
495
- ui_design=work.ui_design,
496
- ui_review=work.ui_review,
497
- ui_polish=work.ui_polish,
498
- contract=work.contract,
499
- stitch=work.stitch,
500
- stack=work.stack,
501
- )
502
-
503
-
504
- def _sync_back(work: WorkState, delivery: DeliveryState) -> None:
505
- """Mirror handler mutations from ``delivery`` back into ``work``.
506
-
507
- Container fields are shared by reference (see :func:`_to_delivery`)
508
- so the assignment is a no-op for those — we still mirror them
509
- defensively to cover the case where a handler reassigned the
510
- attribute (``state.memory = [new_list]``) instead of mutating in
511
- place.
512
- """
513
- work.input.data = delivery.ticket
514
- work.persona = delivery.persona
515
- work.memory = delivery.memory
516
- work.plan = delivery.plan
517
- work.changes = delivery.changes
518
- work.tests = delivery.tests
519
- work.verify = delivery.verify
520
- work.outcomes = delivery.outcomes
521
- work.questions = delivery.questions
522
- work.report = delivery.report
523
- work.ui_audit = delivery.ui_audit
524
- work.ui_design = delivery.ui_design
525
- work.ui_review = delivery.ui_review
526
- work.ui_polish = delivery.ui_polish
527
- work.contract = delivery.contract
528
- work.stitch = delivery.stitch
529
- work.stack = delivery.stack
530
-
531
-
532
- def _save(state_file: Path, work: WorkState, fmt: str) -> None:
533
- """Persist ``work`` in the wire format it was loaded with.
534
-
535
- v1 emits the canonical envelope via :func:`work_engine.state.to_dict`;
536
- v0 emits the legacy flat shape that ``DeliveryState.asdict`` used
537
- to produce, byte-identical to the pre-Phase-4 output so the
538
- Golden Transcript replay stays green.
539
- """
540
- state_file.parent.mkdir(parents=True, exist_ok=True)
541
- payload = _state_module.to_dict(work) if fmt == _FMT_V1 else _to_v0_dict(work)
542
- state_file.write_text(
543
- json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
544
- encoding="utf-8",
545
- )
546
-
547
-
548
- def _to_v0_dict(work: WorkState) -> dict[str, Any]:
549
- """Serialise ``work`` in the legacy v0 wire format.
550
-
551
- Field order matches ``DeliveryState`` declaration order so
552
- pre-Phase-4 state files round-trip byte-equal.
553
- """
554
- return {
555
- "ticket": work.input.data,
556
- "persona": work.persona,
557
- "memory": work.memory,
558
- "plan": work.plan,
559
- "changes": work.changes,
560
- "tests": work.tests,
561
- "verify": work.verify,
562
- "outcomes": work.outcomes,
563
- "questions": work.questions,
564
- "report": work.report,
565
- }
566
-
567
-
568
- def _read_json(path: Path):
569
- try:
570
- raw = path.read_text(encoding="utf-8")
571
- except OSError as exc:
572
- raise _CLIError(f"Cannot read {path}: {exc}") from exc
573
- try:
574
- return json.loads(raw)
575
- except json.JSONDecodeError as exc:
576
- raise _CLIError(f"Invalid JSON in {path}: {exc}") from exc
577
-
578
-
579
- def _emit(work: WorkState, final: Outcome, halting: str | None) -> None:
580
- if final is Outcome.SUCCESS:
581
- print(work.report)
582
- return
583
- print(f"[halt] outcome={final.value} step={halting or '(none)'}")
584
- for line in work.questions:
585
- print(line)
586
-
587
-
588
- class _CLIError(Exception):
589
- """Raised on configuration or I/O problems. Converted to exit code 2."""
590
-
591
-
592
- __all__ = ["DEFAULT_STATE_FILE", "main"]
172
+ __all__ = [
173
+ "DEFAULT_STATE_FILE",
174
+ "LEGACY_STATE_FILE",
175
+ "_CLIError",
176
+ "_FMT_V0",
177
+ "_FMT_V1",
178
+ "_build_from_diff_file",
179
+ "_build_from_file_file",
180
+ "_build_from_prompt_file",
181
+ "_build_hook_registry",
182
+ "_build_parser",
183
+ "_emit",
184
+ "_emit_halt",
185
+ "_load",
186
+ "_load_or_build",
187
+ "_maybe_raise_legacy_hint",
188
+ "_read_json",
189
+ "_register_chat_history_hooks",
190
+ "_save",
191
+ "_sync_back",
192
+ "_to_delivery",
193
+ "_to_v0_dict",
194
+ "main",
195
+ ]
@@ -0,0 +1,116 @@
1
+ """Argument parser and state-file constants for the CLI entry point.
2
+
3
+ Extracted from ``cli.py`` in P2.3 of
4
+ ``road-to-post-pr29-optimize.md``. Behaviour-preserving: the parser
5
+ shape, default values, help strings and exit-code semantics are
6
+ byte-identical to the pre-split version. The constants moved here
7
+ so the parser default and the legacy-file detector both reference
8
+ a single source of truth.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from pathlib import Path
14
+
15
+ DEFAULT_STATE_FILE = Path(".work-state.json")
16
+ """State file used when ``--state-file`` is not passed.
17
+
18
+ Renamed from ``.implement-ticket-state.json`` in 1.15.0 alongside the
19
+ ``implement_ticket → work_engine`` package move. The legacy filename is
20
+ still recognised on load (see :data:`LEGACY_STATE_FILE` below) so that
21
+ existing checkouts surface a clear migration message instead of a
22
+ silent "no state file" error."""
23
+
24
+ LEGACY_STATE_FILE = Path(".implement-ticket-state.json")
25
+ """Pre-1.15.0 default state file. Detected only as a migration hint;
26
+ never written to. See ``docs/MIGRATION.md``."""
27
+
28
+ _FMT_V0 = "v0"
29
+ _FMT_V1 = "v1"
30
+ """Wire-format markers carried alongside the loaded :class:`WorkState`.
31
+
32
+ Format-preserving roundtrip: ``_load`` records which shape it parsed,
33
+ ``_save`` rewrites in that same shape. v0 in → v0 out (Goldens stay
34
+ byte-equal); v1 in → v1 out (future flows produced by the migration
35
+ tool or a fresh v1 init keep their envelope fields)."""
36
+
37
+
38
+ def _build_parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(
40
+ prog="implement-ticket",
41
+ description="Run one dispatch cycle of the /implement-ticket flow.",
42
+ )
43
+ parser.add_argument(
44
+ "--state-file",
45
+ type=Path,
46
+ default=DEFAULT_STATE_FILE,
47
+ help=f"Path to persisted state JSON (default: {DEFAULT_STATE_FILE}).",
48
+ )
49
+ parser.add_argument(
50
+ "--ticket-file",
51
+ type=Path,
52
+ default=None,
53
+ help="JSON file carrying the ticket payload; used only when the "
54
+ "state file does not exist yet.",
55
+ )
56
+ parser.add_argument(
57
+ "--prompt-file",
58
+ type=Path,
59
+ default=None,
60
+ help="Plain-text file carrying the raw user prompt; builds an "
61
+ "input.kind='prompt' envelope. Mutually exclusive with "
62
+ "--ticket-file. Used only when the state file does not exist yet.",
63
+ )
64
+ parser.add_argument(
65
+ "--diff-file",
66
+ type=Path,
67
+ default=None,
68
+ help="Plain-text file carrying a unified diff payload; builds an "
69
+ "input.kind='diff' envelope routed through the UI-improve "
70
+ "directive set. Mutually exclusive with --ticket-file / "
71
+ "--prompt-file / --file-file. Used only when the state file does "
72
+ "not exist yet.",
73
+ )
74
+ parser.add_argument(
75
+ "--file-file",
76
+ type=Path,
77
+ default=None,
78
+ help="Plain-text file carrying a single path reference (one line); "
79
+ "builds an input.kind='file' envelope routed through the UI-improve "
80
+ "directive set. Mutually exclusive with --ticket-file / "
81
+ "--prompt-file / --diff-file. Used only when the state file does "
82
+ "not exist yet.",
83
+ )
84
+ parser.add_argument(
85
+ "--persona",
86
+ type=str,
87
+ default=None,
88
+ help="Persona name (senior-engineer | qa | advisory). Only honoured "
89
+ "when the state file does not exist yet; ignored on resume so a "
90
+ "mid-flight persona switch cannot silently change behaviour.",
91
+ )
92
+ parser.add_argument(
93
+ "--no-hooks",
94
+ action="store_true",
95
+ default=False,
96
+ help="Disable every lifecycle hook for this run. Use in golden-"
97
+ "replay test harnesses so a future settings change cannot "
98
+ "silently invalidate captured outputs.",
99
+ )
100
+ parser.add_argument(
101
+ "--hooks-config",
102
+ type=Path,
103
+ default=None,
104
+ help="Override the path to the agent-settings file used to resolve "
105
+ "the hooks.* block. Defaults to ./.agent-settings.yml.",
106
+ )
107
+ return parser
108
+
109
+
110
+ __all__ = [
111
+ "DEFAULT_STATE_FILE",
112
+ "LEGACY_STATE_FILE",
113
+ "_FMT_V0",
114
+ "_FMT_V1",
115
+ "_build_parser",
116
+ ]