aw-spec-cli 0.1.9 → 0.1.11

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 (58) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +17 -5
  2. package/assets/.appweaver/orchestrator/README.md +1 -1
  3. package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +2 -2
  4. package/assets/.appweaver/orchestrator/adapters/google_antigravity.py +28 -6
  5. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +13 -1
  6. package/assets/.appweaver/orchestrator/orchestrator.py +126 -39
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +5 -0
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -0
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1 -0
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +888 -141
  11. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +826 -16
  12. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +780 -12
  13. package/assets/.appweaver/stack-profiles/java-spring.json +3 -3
  14. package/assets/.appweaver/templates/spec-template.md +73 -62
  15. package/assets/.appweaver/templates/story-template.md +107 -0
  16. package/assets/guardrails/technology/java-spring/ai-rules.md +505 -0
  17. package/assets/guardrails/technology/java-spring/api-versioning.md +782 -0
  18. package/assets/guardrails/technology/java-spring/authorization.md +618 -0
  19. package/assets/guardrails/technology/java-spring/clean-architecture.md +960 -0
  20. package/assets/guardrails/technology/java-spring/coding-standards.md +821 -0
  21. package/assets/guardrails/technology/java-spring/dependency-policy.md +91 -0
  22. package/assets/guardrails/technology/java-spring/domain-driven-design.md +467 -0
  23. package/assets/guardrails/technology/java-spring/domain_abstraction.md +424 -0
  24. package/assets/guardrails/technology/java-spring/folder-structure.md +1537 -0
  25. package/assets/guardrails/technology/java-spring/global_exception_handler.md +247 -0
  26. package/assets/guardrails/technology/java-spring/logging.md +215 -0
  27. package/assets/guardrails/technology/java-spring/modular-monolith.md +77 -0
  28. package/assets/guardrails/technology/java-spring/persistence.md +316 -0
  29. package/assets/guardrails/technology/java-spring/problem_details.md +98 -0
  30. package/assets/guardrails/technology/java-spring/result_mapping.md +48 -0
  31. package/assets/guardrails/technology/java-spring/technology-constitution.md +168 -0
  32. package/assets/guardrails/technology/java-spring/testing.md +601 -0
  33. package/assets/guardrails/technology/java-spring/zero-trust.md +210 -0
  34. package/dist/index.js +0 -0
  35. package/dist/utils/config.d.ts +22 -0
  36. package/dist/utils/config.d.ts.map +1 -0
  37. package/dist/utils/config.js +76 -0
  38. package/dist/utils/config.js.map +1 -0
  39. package/dist/utils/copy-assets.d.ts.map +1 -1
  40. package/dist/utils/copy-assets.js +4 -11
  41. package/dist/utils/copy-assets.js.map +1 -1
  42. package/dist/utils/paths.d.ts.map +1 -1
  43. package/dist/utils/paths.js +11 -7
  44. package/dist/utils/paths.js.map +1 -1
  45. package/package.json +2 -1
  46. package/assets/guardrails/technology/java-spring/README.md +0 -62
  47. package/dist/commands/update.d.ts +0 -3
  48. package/dist/commands/update.d.ts.map +0 -1
  49. package/dist/commands/update.js +0 -142
  50. package/dist/commands/update.js.map +0 -1
  51. package/dist/utils/manifest.d.ts +0 -31
  52. package/dist/utils/manifest.d.ts.map +0 -1
  53. package/dist/utils/manifest.js +0 -91
  54. package/dist/utils/manifest.js.map +0 -1
  55. package/dist/utils/update-engine.d.ts +0 -52
  56. package/dist/utils/update-engine.d.ts.map +0 -1
  57. package/dist/utils/update-engine.js +0 -203
  58. package/dist/utils/update-engine.js.map +0 -1
@@ -35,10 +35,22 @@ transport profile (`agent.json`) and their thin `/appweaver` launcher.
35
35
 
36
36
  1. **Context management is the orchestrator's** — as in `workflows/aw-spec-demo.orch.yaml`.
37
37
  `build_context` REPLACES the per-request envelope; `load_standards`
38
- maintains the sticky system prefix. No agent ever assembles its own context.
38
+ maintains the sticky system prefix. Per-step `context:` is THIS request only
39
+ and is never appended onto the envelope. No agent ever assembles its own context.
40
+ `agent_author` / `agent_query` / `agent_review` get **no file tools by
41
+ default**: the inlined payload is the entire world. A step whose task names
42
+ files by PATH rather than inlining them declares `tools:` and gets exactly
43
+ that read-only set — the brownfield reversals do, because a legacy tree is
44
+ handed over as a file LIST and cannot fit a payload. `agent_generate` always
45
+ carries tools (it writes the code), and still receives only the
46
+ interpreter-assembled system + user blocks.
39
47
  2. **Execution follows any of the 4 strategies** (`--strategy`, per-step
40
48
  `strategy:` override): `sdk`, `sdk-apply`, `spawn`, `agent` — they only
41
- govern WHO fulfills a generation, never what goes into it.
49
+ govern WHO fulfills a generation, never what goes into it. `agent` isolation
50
+ is best-effort (same conversation); `sdk` / `spawn` / `sdk-apply` generation
51
+ are isolated. Python scripts (`execute_script`) are deterministic only —
52
+ they never MODEL-author spec or code content (a few, like `aw-inventory.py`
53
+ and `aw-rev-constitution.py`, do generate documents — deterministically).
42
54
  3. **The checkpoint channel is strategy-independent** (D1): `ask`,
43
55
  `user_select` and `dispatch_hooks` suspend via `--checkpoint` under EVERY
44
56
  strategy, so the launching agent can relay to the human. Converted-skill
@@ -83,9 +95,9 @@ the raw object, not its JSON string.
83
95
  | `execute_script` | engine | F1 — run a `scripts:` ref (`subcommand:` + `args:`; sh/ps1 via `--os`); stdout's last line = JSON, bound via `outputs[]`; `on_error: continue` tolerated; cached across resumes; runs for real even in mock transport |
84
96
  | `verify_context` | engine | F2 — gates: `required_files` (+`allow_missing`), `require_non_empty` (+`on_empty[_message]`), `require_true` (+`on_false[_message]`), `require_value`+`equals` (+`on_mismatch[_message]`). `stop` raises a terminal stop |
85
97
  | `agent_generate` | fresh agent | demo contract (code work): agent DECIDES file paths per the loaded standards, writes with its tools, reports `files[]` in the trailing ```json fence. `sdk-apply` = read-only generation + invoking agent applies |
86
- | `agent_review` | fresh agent | read-only review of `review_of_var`/`review_of` files → `{pass, issues}` |
87
- | `agent_author` | fresh agent | F5 — **authored artifact** (decl `llm_generate`): the INTERPRETER owns `output_target: {type: file, path}`. Generation is READ-ONLY under every strategy; the ENGINE writes the returned content (sdk-apply degrades to sdk). Optional `context:` (per-request extras), `include_standards: false` (narrow payload) |
88
- | `agent_query` | fresh agent | F6 — generic read-only structured JSON (decl `llm_query`): `schema:` shapes the required fence, `outputs[]` binds it, `mock:` supplies the mock-transport result; one malformed-JSON retry on fresh strategies |
98
+ | `agent_review` | fresh agent | payload-only review of `review_of_var`/`review_of` files (inlined into THIS request, never sticky) → `{pass, issues}` |
99
+ | `agent_author` | fresh agent | F5 — **authored artifact** (decl `llm_generate`): the INTERPRETER owns `output_target: {type: file, path}`. Generation is payload-only under every strategy (mapped `context:` + sticky envelope + standards are the entire world) and never writes — the ENGINE writes the returned content (sdk-apply degrades to sdk). Optional `context:` (per-request extras), `include_standards: false` (narrow payload), `tools:` (read-only opt-in for a task that names files by path) |
100
+ | `agent_query` | fresh agent | F6 — generic payload-only structured JSON (decl `llm_query`): `schema:` shapes the required fence, `outputs[]` binds it, `mock:` supplies the mock-transport result; one malformed-JSON retry on fresh strategies. Same tools contract as `agent_author` — none unless the step declares `tools:` |
89
101
  | `ask` | human (via launcher) | F7 — question: `prompt`, `options`/`options_from`/`options_from_variable`, `multi`, `answer_key`, `answer_var` (default `answer`). Sources in order: cache → `--answer k=v` → `--non-interactive` (first option; all when multi) → checkpoint suspend `ask` → stdin |
90
102
  | `user_select` | human (via launcher) | F7 — pick ONE from `options_from_variable`; `preselect_from` (direct-argument fast path), `single_option_confirm`; `--select stepid=v`; binds `selection` + `outputs[]` |
91
103
  | `dispatch_hooks` | invoking agent | F8 — suspend kind `hook` with the `hooks_from_variable` list (slash commands of the LAUNCHING agent); logged-only without a checkpoint; never re-fires across resumes |
@@ -116,7 +116,7 @@ per step in the workflow YAML (`strategy:` on `agent_generate`/`agent_review`).
116
116
  | `sdk` | isolated SDK instance | SDK instance (tools) | 1 | guaranteed |
117
117
  | `sdk-apply` | isolated SDK instance (read-only) | **invoking agent** (suspend/resume) | 1 + you | generation clean; apply local |
118
118
  | `spawn` | fresh CLI process (profile) | same process | N cold | guaranteed |
119
- | `agent` | invoking agent (suspend/resume) | invoking agent | **0** | best-effort |
119
+ | `agent` | invoking agent (suspend/resume) | invoking agent | **0** | best-effort (payload is interpreter-assembled; the invoking conversation can still add around it) |
120
120
 
121
121
  ```bash
122
122
  # Strategy 1 — SDK generates + implements (needs: pip install claude-agent-sdk)
@@ -52,10 +52,10 @@ _PERMISSION_MAP = {"acceptEdits": "acceptEdits",
52
52
 
53
53
 
54
54
  async def generate(request):
55
+ allowed = [t.strip() for t in (request.get("tools") or "").split(",") if t.strip()]
55
56
  kwargs = dict(
56
57
  system_prompt=request["system"],
57
- allowed_tools=[t.strip() for t in (request.get("tools") or "").split(",")
58
- if t.strip()],
58
+ allowed_tools=allowed, # empty = payload-only; do not fall back to the SDK default set
59
59
  permission_mode=_PERMISSION_MAP.get(request["permission_mode"], "default"),
60
60
  setting_sources=[], # isolation: no CLAUDE.md / settings / hooks
61
61
  )
@@ -10,8 +10,12 @@ Contract (see orchestrator.py `_run_sdk`):
10
10
  request keys:
11
11
  system interpreter-assembled system block (standards + rules)
12
12
  user interpreter-assembled user block (context + task)
13
- tools the workflow's neutral tools list (informational here;
14
- Antigravity governs capability via policies instead)
13
+ tools the workflow's neutral tools list. Empty selects the
14
+ payload-only posture (deny every file tool, no workspace);
15
+ non-empty selects the permission_mode policy chain plus
16
+ the project workspace. The individual NAMES are neutral
17
+ (Read/Glob/…) and are not mapped one-to-one — Antigravity
18
+ governs capability by policy, so the mode picks the set.
15
19
  permission_mode neutral mode: readonly | acceptEdits |
16
20
  bypassPermissions | default
17
21
  model model name or None
@@ -49,6 +53,11 @@ from google.antigravity.hooks import policy
49
53
  _READONLY_TOOLS = ("view_file", "list_directory", "search_directory",
50
54
  "find_file", "finish", "ask_question")
51
55
 
56
+ # the two that touch no file: how the session ENDS its turn and how it asks.
57
+ # A payload-only request still needs these — denying every tool including
58
+ # `finish` leaves the agent no way to say it is done.
59
+ _CONTROL_TOOLS = ("finish", "ask_question")
60
+
52
61
 
53
62
  def _policies(permission_mode):
54
63
  """Neutral permission modes → Antigravity policy chains."""
@@ -61,12 +70,25 @@ def _policies(permission_mode):
61
70
 
62
71
 
63
72
  async def generate(request):
73
+ tools = [t.strip() for t in (request.get("tools") or "").split(",") if t.strip()]
74
+ # An empty allowlist is a payload-only author/query/review: the interpreter
75
+ # already inlined every file the model may see. Granting the project
76
+ # workspace (or view_file) would let the isolated session wander off the
77
+ # mapped payload. Only the control tools survive the deny — the file tools
78
+ # are the point of the deny, but a session that cannot call `finish` cannot
79
+ # end its turn. A step that DECLARES tools (the brownfield reversals, which
80
+ # are handed the slice's source as a path list) takes the branch below and
81
+ # gets the workspace its readonly policy chain needs.
82
+ if not tools:
83
+ policies = [policy.deny_all()] + [policy.allow(t) for t in _CONTROL_TOOLS]
84
+ workspaces = []
85
+ else:
86
+ policies = _policies(request["permission_mode"])
87
+ workspaces = [os.getcwd()]
64
88
  config = LocalAgentConfig(
65
89
  system_instructions=request["system"],
66
- policies=_policies(request["permission_mode"]),
67
- # a fresh SDK session has no workspace by default; grant the project
68
- # root so the isolated generation can read/write the files it decides
69
- workspaces=[os.getcwd()],
90
+ policies=policies,
91
+ workspaces=workspaces,
70
92
  model=request.get("model") or None,
71
93
  )
72
94
  async with Agent(config=config) as agent:
@@ -1,7 +1,19 @@
1
1
  # =============================================================================
2
2
  # delta-spec (orchestrator module) — author the SPEC-side DELTA of a change
3
3
  #
4
- # Included by /appweaver specify on its DELTA branch (spec.md already exists).
4
+ # ORPHANED — INCLUDED BY NO WORKFLOW. specify v3.0.0 removed the delta branch this module
5
+ # was written for: a later requirement is now an edit to the living spec.md plus a computed
6
+ # Change History row, not a delta document merged back later. Nothing else includes it, so
7
+ # NOTHING AUTHORS A DELTA-SPEC TODAY — the delta-spec.md that cr-setup still scaffolds for
8
+ # every change is an untouched template copy, which is why specify's delta_open guard tests
9
+ # whether one was authored rather than whether a change is open.
10
+ #
11
+ # Kept, not deleted, because the sibling half is still live: /appweaver design includes
12
+ # delta-design on its own delta branch, and finalize-delta / analyze-slice / close-delta
13
+ # still run. Retiring this file is a decision about the delta FAMILY, not about this module.
14
+ #
15
+ # What follows describes it as it was written to be included by specify.
16
+ #
5
17
  # This is the SPEC half of the former standalone `cr` capture, relocated here: a
6
18
  # later requirement is a new user story on the SAME aggregate. It opens the change
7
19
  # (cr-setup — single-active-change guarded; also scaffolds delta-spec AND delta-design)
@@ -318,7 +318,11 @@ class Orchestrator:
318
318
  "Perform this task YOURSELF, in this conversation. Read "
319
319
  "system_path + user_path and treat them as your ONLY standards "
320
320
  "and context (ignore anything else you know about this "
321
- "session). Use the listed tools within the permission mode. "
321
+ "session). " + (
322
+ "Use the listed tools within the permission mode. "
323
+ if tools else
324
+ "You have NO tools: the payload is the entire world for "
325
+ "this request, so do not open other project files. ") +
322
326
  "Write your full final reply — ending with the required "
323
327
  "```json fence — to response_path, then re-run the resume "
324
328
  "command. Do not edit the checkpoint state file."),
@@ -515,8 +519,13 @@ class Orchestrator:
515
519
  argv += [sysmode["flag"], system]
516
520
  elif sysmode == "prepend":
517
521
  prompt_user = system + "\n\n---\n\n" + user
518
- if tools and prof.get("tools_flag"):
519
- argv += [prof["tools_flag"], tools]
522
+ # Always pass the allowlist when the CLI has a tools flag — omitting it
523
+ # lets the CLI fall back to its default set (Write/Bash/…), which is
524
+ # how a read-only author/query leaked the rest of the repo. An empty
525
+ # string is a real allowlist of nothing: the inlined payload is the
526
+ # entire world.
527
+ if prof.get("tools_flag") is not None:
528
+ argv += [prof["tools_flag"], tools or ""]
520
529
  approval = prof.get("approval", {})
521
530
  argv += approval.get(permission_mode, approval.get("default", []))
522
531
  argv += prof.get("isolation", {}).get(self.opts.isolation, [])
@@ -686,10 +695,39 @@ class Orchestrator:
686
695
  sysn = len(_norm_files(self.envelope["system"]))
687
696
  self.emit(f"[build_context] system={sysn} file(s) context={len(ctx)} file(s)")
688
697
 
698
+ def _step_extra_context(self, step):
699
+ """Per-step `context:` files, THIS request only — never sticky. The
700
+ workflow maps what the model may see; the envelope is the sticky set
701
+ `build_context` replaced, and this list is the extra the step named."""
702
+ p = step.get("parameters") or step
703
+ extra = self.resolve_value(p.get("context")) or []
704
+ return [c for c in extra if isinstance(c, (str, dict))]
705
+
706
+ def _step_read_tools(self, step):
707
+ """The read tools an `agent_author` / `agent_query` step declares.
708
+
709
+ The default is NONE — those two actions are payload-only, and the
710
+ interpreter inlines every file the workflow mapped. A step whose task
711
+ names files by PATH rather than inlining them declares `tools:` and gets
712
+ exactly that set. The brownfield reversals are the reason it exists:
713
+ adopt hands the model the slice's `source_files` as a LIST and tells it
714
+ to read the code, which cannot be inlined (a legacy tree is far larger
715
+ than any payload) — with no tools those steps authored a spec, a design
716
+ and a source-map from a list of filenames, and the fidelity check that
717
+ exists to catch exactly that fabrication could not read the code either.
718
+ Declaring it in the workflow keeps the dependency where a reader can see
719
+ it; the permission mode stays readonly either way."""
720
+ p = step.get("parameters") or step
721
+ v = p.get("tools", "")
722
+ if isinstance(v, (list, tuple)):
723
+ v = ",".join(str(x) for x in v)
724
+ return (self.resolve(str(v)) or "").strip()
725
+
689
726
  def act_agent_generate(self, step):
690
727
  tools = step.get("tools", "Read,Write,Edit,Bash")
691
728
  permission_mode = step.get("permission_mode", "acceptEdits")
692
729
  task = self.resolve(step.get("task", ""))
730
+ extra = self._step_extra_context(step)
693
731
  strat = self._effective_strategy(step)
694
732
  # checkpoint replay: this step was already fulfilled (any strategy) —
695
733
  # bind its cached structured result instead of executing again.
@@ -724,8 +762,8 @@ class Orchestrator:
724
762
  '\n\nWhen done, end your reply with a fenced block listing the paths YOU '
725
763
  'chose to write:\n```json\n{"status":"ok","files":["<path you wrote>"],'
726
764
  '"summary":"<one line>"}\n```')
727
- system, sys_files = self._assemble_system()
728
- user, ctx_files = self._assemble_user(instr)
765
+ system, sys_files = self._assemble_system(step.get("include_standards", True))
766
+ user, ctx_files = self._assemble_user(instr, extra=extra)
729
767
  self._print_envelope("agent_generate", system, sys_files, user, ctx_files, tools, strat)
730
768
 
731
769
  if self.opts.transport == "mock":
@@ -760,10 +798,14 @@ class Orchestrator:
760
798
  instr = task + "\n\nFiles under review:\n" + "\n".join(f"- {r}" for r in review_of)
761
799
  instr += ('\n\nEnd your reply with a fenced block:\n'
762
800
  '```json\n{"pass":true,"issues":[]}\n```')
763
- # review is read-only: put the files to review INTO context, tools=Read only
764
- # (mutate BEFORE the checkpoint-cache branch so a replayed run rebuilds
765
- # the exact same envelope state for the steps that follow)
766
- self.envelope["context"] = list(self.envelope["context"]) + review_of
801
+ # Review files are inlined into THIS request only. They used to be
802
+ # appended onto the sticky envelope, so every later generation in the
803
+ # same run inherited them — context the workflow never mapped.
804
+ extra = list(review_of) + self._step_extra_context(step)
805
+ # Payload-only like author/query, and the same opt-in: a review whose
806
+ # files are inlined needs nothing, but the escape hatch has to exist on
807
+ # all three or a `tools:` added here later would be silently ignored.
808
+ tools = self._step_read_tools(step)
767
809
  strat = self._effective_strategy(step)
768
810
  ck = self._key(step)
769
811
  if self.checkpoint and ck in self.cache:
@@ -772,22 +814,23 @@ class Orchestrator:
772
814
  self._bind(step, structured)
773
815
  self.emit(f"[agent_review] (cached) result={json.dumps(structured)[:200]}")
774
816
  return
775
- system, sys_files = self._assemble_system()
776
- user, ctx_files = self._assemble_user(instr)
777
- self._print_envelope("agent_review", system, sys_files, user, ctx_files, "Read", strat)
817
+ system, sys_files = self._assemble_system(step.get("include_standards", True))
818
+ user, ctx_files = self._assemble_user(instr, extra=extra)
819
+ # No file tools by default: the files under review are already in the payload.
820
+ self._print_envelope("agent_review", system, sys_files, user, ctx_files, tools, strat)
778
821
 
779
822
  if self.opts.transport == "mock":
780
823
  # mock: pass if every reviewed file exists
781
824
  ok = all(os.path.exists(r) for r in review_of)
782
825
  structured = {"pass": ok, "issues": ([] if ok else ["missing output file(s)"])}
783
826
  elif strat == "agent":
784
- self._suspend("agent_review", ck, system, user, "Read", "readonly")
827
+ self._suspend("agent_review", ck, system, user, tools, "readonly")
785
828
  elif strat in ("sdk", "sdk-apply"):
786
829
  # review has no apply phase — sdk-apply degrades to a plain sdk read
787
- structured, _ = self._run_sdk(system, user, "Read", "readonly",
830
+ structured, _ = self._run_sdk(system, user, tools, "readonly",
788
831
  model=step.get("model"))
789
832
  else: # spawn
790
- structured, _ = self._run_fresh(system, user, "Read", "acceptEdits",
833
+ structured, _ = self._run_fresh(system, user, tools, "readonly",
791
834
  model=step.get("model"))
792
835
  self.last_result = structured
793
836
  if self.checkpoint:
@@ -963,7 +1006,8 @@ class Orchestrator:
963
1006
  if not target:
964
1007
  raise Stop(f"step {step.get('id')}: agent_author needs output_target.path")
965
1008
  task = self.resolve(p.get("task") or p.get("prompt") or "")
966
- extra = [c for c in (self.resolve_value(p.get("context")) or []) if isinstance(c, (str, dict))]
1009
+ extra = self._step_extra_context(step)
1010
+ tools = self._step_read_tools(step)
967
1011
  strat = self._effective_strategy(step)
968
1012
  ck = self._key(step)
969
1013
  if self.checkpoint and ck in self.cache:
@@ -974,28 +1018,38 @@ class Orchestrator:
974
1018
  self.emit(f"[agent_author] (cached) rewrote {target}")
975
1019
  return
976
1020
  instr = task + (
977
- f"\n\nAuthor the COMPLETE content of the file `{target}`. You have "
978
- "no write tools — return the content itself. Reply with ONLY the "
1021
+ f"\n\nAuthor the COMPLETE content of the file `{target}`. " + (
1022
+ f"Your tools are read-only ({tools}), and they are for the files "
1023
+ "this task names by PATH — everything else you need is already in "
1024
+ "the payload. "
1025
+ if tools else
1026
+ "You have no tools — the payload is the entire world for this "
1027
+ "request. ") +
1028
+ "Return the content itself. Reply with ONLY the "
979
1029
  "full file content: no commentary, no surrounding code fence.")
980
1030
  system, sys_files = self._assemble_system(p.get("include_standards", True))
981
1031
  user, ctx_files = self._assemble_user(instr, extra=extra)
982
- self._print_envelope("agent_author", system, sys_files, user, ctx_files, "Read", strat)
1032
+ self._print_envelope("agent_author", system, sys_files, user, ctx_files, tools, strat)
983
1033
  if self.opts.transport == "mock":
984
1034
  content = self._mock_author(target, task)
985
1035
  elif strat == "agent":
986
- self._suspend("author", ck, system, user, "Read", "readonly",
1036
+ self._suspend("author", ck, system, user, tools, "readonly",
987
1037
  instructions=(
988
1038
  "Author this artifact YOURSELF, in this conversation. "
989
1039
  "Read system_path + user_path as your ONLY standards "
990
- "and context. Write the COMPLETE file content — the "
991
- "content ONLY, no commentary, no code fence — to "
992
- "response_path, then re-run the resume command. Do "
993
- "NOT write the target file yourself; the interpreter "
994
- "does that."),
1040
+ "and context" + (
1041
+ ", and open ONLY the files the task names by path. "
1042
+ if tools else
1043
+ " — do not open other project files. ") +
1044
+ "Write "
1045
+ "the COMPLETE file content — the content ONLY, no "
1046
+ "commentary, no code fence — to response_path, then "
1047
+ "re-run the resume command. Do NOT write the target "
1048
+ "file yourself; the interpreter does that."),
995
1049
  extra={"target": target})
996
1050
  else:
997
1051
  run = self._run_sdk if strat in ("sdk", "sdk-apply") else self._run_fresh
998
- _, raw = run(system, user, "Read", "readonly", model=step.get("model"))
1052
+ _, raw = run(system, user, tools, "readonly", model=step.get("model"))
999
1053
  content = _strip_fence(raw)
1000
1054
  if not content.strip():
1001
1055
  raise Stop(f"step {step.get('id')}: agent_author returned empty content")
@@ -1012,16 +1066,16 @@ class Orchestrator:
1012
1066
  f"({len(content)} chars)")
1013
1067
 
1014
1068
  # -- F6: structured queries --------------------------------------------------
1015
- def _structured_fresh(self, step, strat, system, user):
1069
+ def _structured_fresh(self, step, strat, system, user, tools=""):
1016
1070
  """spawn/sdk executor for JSON-returning steps, with ONE malformed-JSON
1017
1071
  retry (agent strategy instead re-suspends with a note on resume)."""
1018
1072
  run = self._run_sdk if strat in ("sdk", "sdk-apply") else self._run_fresh
1019
- structured, _ = run(system, user, "Read", "readonly", model=step.get("model"))
1073
+ structured, _ = run(system, user, tools, "readonly", model=step.get("model"))
1020
1074
  if not structured:
1021
1075
  self.emit(f"[retry] {step.get('id')}: no parseable ```json fence — asking once more")
1022
1076
  user2 = user + ("\n\nYour previous reply could not be parsed. Return "
1023
1077
  "ONLY the required ```json fenced block, nothing else.")
1024
- structured, _ = run(system, user2, "Read", "readonly", model=step.get("model"))
1078
+ structured, _ = run(system, user2, tools, "readonly", model=step.get("model"))
1025
1079
  if not structured:
1026
1080
  raise Stop(f"step {step.get('id')}: fresh agent returned no parseable JSON")
1027
1081
  return structured
@@ -1033,7 +1087,8 @@ class Orchestrator:
1033
1087
  specialization of this. `mock:` supplies the mock-transport result."""
1034
1088
  p = step.get("parameters") or step
1035
1089
  task = self.resolve(p.get("task") or p.get("prompt") or "")
1036
- extra = [c for c in (self.resolve_value(p.get("context")) or []) if isinstance(c, (str, dict))]
1090
+ extra = self._step_extra_context(step)
1091
+ tools = self._step_read_tools(step)
1037
1092
  strat = self._effective_strategy(step)
1038
1093
  ck = self._key(step)
1039
1094
  if self.checkpoint and ck in self.cache:
@@ -1044,18 +1099,24 @@ class Orchestrator:
1044
1099
  return
1045
1100
  schema = p.get("schema")
1046
1101
  hint = json.dumps(schema) if isinstance(schema, (dict, list)) else (schema or "{ … }")
1047
- instr = task + ("\n\nDo not modify any files. End your reply with a "
1102
+ instr = task + ("\n\nDo not modify any files. " + (
1103
+ f"Your tools are read-only ({tools}), and they are for the "
1104
+ "files this task names by PATH — everything else you need is "
1105
+ "already in the payload. "
1106
+ if tools else
1107
+ "The payload is the entire world for this request. ") +
1108
+ "End your reply with a "
1048
1109
  "fenced block containing ONLY the JSON result, shaped "
1049
1110
  "like:\n```json\n" + hint + "\n```")
1050
1111
  system, sys_files = self._assemble_system(p.get("include_standards", True))
1051
1112
  user, ctx_files = self._assemble_user(instr, extra=extra)
1052
- self._print_envelope("agent_query", system, sys_files, user, ctx_files, "Read", strat)
1113
+ self._print_envelope("agent_query", system, sys_files, user, ctx_files, tools, strat)
1053
1114
  if self.opts.transport == "mock":
1054
1115
  structured = self.resolve_value(p.get("mock")) or {}
1055
1116
  elif strat == "agent":
1056
- self._suspend("agent_query", ck, system, user, "Read", "readonly")
1117
+ self._suspend("agent_query", ck, system, user, tools, "readonly")
1057
1118
  else:
1058
- structured = self._structured_fresh(step, strat, system, user)
1119
+ structured = self._structured_fresh(step, strat, system, user, tools)
1059
1120
  self.last_result = structured
1060
1121
  if self.checkpoint:
1061
1122
  self.cache[ck] = structured
@@ -1065,17 +1126,43 @@ class Orchestrator:
1065
1126
 
1066
1127
  # -- F7: HITL (D1 — the checkpoint is the interaction channel under EVERY
1067
1128
  # strategy; --answer/--select/--non-interactive prefill for headless runs)
1068
- def _prompt_choice(self, prompt, options):
1129
+ def _prompt_choice(self, prompt, options, multi=False):
1069
1130
  print(prompt)
1070
1131
  for i, o in enumerate(options, 1):
1071
1132
  print(f" {i}) {o}")
1133
+ if multi:
1134
+ print(" (pick any: numbers or names, comma-separated \u2014 "
1135
+ "'all', or blank for none)")
1072
1136
  try:
1073
1137
  raw = input("> ").strip()
1074
1138
  except EOFError:
1075
1139
  return None
1076
- if raw.isdigit() and 1 <= int(raw) <= len(options):
1077
- return options[int(raw) - 1]
1078
- return raw if raw in options else None
1140
+ if not multi:
1141
+ if raw.isdigit() and 1 <= int(raw) <= len(options):
1142
+ return options[int(raw) - 1]
1143
+ return raw if raw in options else None
1144
+ # A multi-select answer is a SET, and a set has no single-token spelling. Read by
1145
+ # the single-select parser, "1,3" was neither a number in range nor an option, so it
1146
+ # returned None and the run stopped as "question cancelled" \u2014 leaving exactly two
1147
+ # reachable answers, "the first one" and "abort". act_ask splits the string it gets
1148
+ # back on commas, so returning one is all that is needed here.
1149
+ if raw.lower() in ("all", "*"):
1150
+ return ",".join(str(o) for o in options)
1151
+ if raw.lower() in ("", "none"):
1152
+ return ""
1153
+ picked = []
1154
+ for tok in re.split(r"\s*,\s*|\s+", raw):
1155
+ if not tok:
1156
+ continue
1157
+ if tok.isdigit() and 1 <= int(tok) <= len(options):
1158
+ v = options[int(tok) - 1]
1159
+ elif tok in options:
1160
+ v = tok
1161
+ else:
1162
+ return None # an unrecognised token is a typo, not a silent omission
1163
+ if v not in picked:
1164
+ picked.append(v)
1165
+ return ",".join(str(o) for o in picked)
1079
1166
 
1080
1167
  def act_ask(self, step):
1081
1168
  """F7 — single/multi-select question. Answer sources, in order: replay
@@ -1113,7 +1200,7 @@ class Orchestrator:
1113
1200
  (" (comma-separated)" if multi else "") +
1114
1201
  " to response_path, then re-run the resume command.")})
1115
1202
  if raw is None:
1116
- raw = self._prompt_choice(prompt, options) if options else \
1203
+ raw = self._prompt_choice(prompt, options, multi) if options else \
1117
1204
  input(prompt + " ").strip()
1118
1205
  if raw is None:
1119
1206
  raise Stop("question cancelled")
@@ -360,6 +360,7 @@ workflow:
360
360
  - { path: .appweaver/memory/constitution.ui.md, optional: true } # the frontend lane's UX-* base — absent on a backend-only repo, which onboard leaves unwritten by design
361
361
  - id: reverse_spec
362
362
  action: agent_author
363
+ tools: Read # the code arrives as a PATH LIST, not inlined — a legacy tree does not fit a payload
363
364
  include_standards: false # the technical/arch sticky set is for design, never the spec
364
365
  output_target: { type: file, path: "{{spec_file}}" }
365
366
  task: |
@@ -435,6 +436,7 @@ workflow:
435
436
  - { path: .appweaver/memory/constitution.technical.md, optional: true } # technology base ONLY — no domain
436
437
  - id: reverse_design
437
438
  action: agent_author
439
+ tools: Read # grounded in the CODE, which arrives as {{source_files}} paths
438
440
  include_standards: true # the technical/architecture sticky set (reference only — the CODE wins)
439
441
  output_target: { type: file, path: "{{design_doc}}" }
440
442
  task: |
@@ -499,6 +501,7 @@ workflow:
499
501
  - id: reverse_ui_design
500
502
  condition: "{{ui_lane}} == true"
501
503
  action: agent_author
504
+ tools: Read # same, for the lane's surface ({{ui_source_files}})
502
505
  include_standards: true # the lane's sticky technical set (reference only — the CODE wins)
503
506
  output_target: { type: file, path: "{{ui_design_doc}}" }
504
507
  task: |
@@ -545,6 +548,7 @@ workflow:
545
548
  - { path: "{{concept_path}}" } # both file lists to map
546
549
  - id: source_map
547
550
  action: agent_author
551
+ tools: Read # one row per legacy artefact; the artefacts are paths in the concept
548
552
  include_standards: false
549
553
  output_target: { type: file, path: "{{feature_dir}}/source-map.md" }
550
554
  task: |
@@ -576,6 +580,7 @@ workflow:
576
580
  # → written to an Adopt Review report and closed OUT OF BAND (Brownfield B1·R).
577
581
  - id: fidelity
578
582
  action: agent_query
583
+ tools: Read # the check is spec/design vs CODE; with no code it degrades to spec vs design and certifies fabrication as ok
579
584
  context:
580
585
  - "{{spec_file}}"
581
586
  - "{{design_doc}}"
@@ -188,6 +188,7 @@ workflow:
188
188
  - { path: "{{design_file}}" }
189
189
  - id: scan_existing_tests
190
190
  action: agent_query
191
+ tools: Read,Glob,Grep # no test list is supplied — it globs the tree; empty results make characterize write duplicate golden-masters
191
192
  include_standards: false
192
193
  schema:
193
194
  duplicates: [ { behaviour: "<observable behaviour>", spec_id: "<US-/FR-/BR- id>", existing_test: "<file:test-name>", seam: "<endpoint|handler|domain|persistence>", source_file: "<production file it covers>" } ]
@@ -844,6 +844,7 @@ workflow:
844
844
  - { path: "{{bundle_dir}}/architecture/frontend.md", optional: true } # SECONDARY (shallow): the OKF as-built frontend note — corroborate, don't rely on alone
845
845
  - id: author_ui
846
846
  action: agent_author
847
+ tools: Read # EVIDENCE = THE UI CODE ITSELF; the inventory inlines the file LISTS, not the files
847
848
  condition: "{{ui_lane}} == true"
848
849
  include_standards: false
849
850
  output_target: { type: file, path: "{{constitution_ui_file}}" }