@r1z4x/tezgah 1.5.1 → 1.6.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 (42) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/MANIFEST +6 -0
  3. package/bin/consult +13 -10
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-research +53 -3
  6. package/bin/tezgah-setup +1 -1
  7. package/bin/tezgah-task +192 -39
  8. package/docs/citations-baseline.json +1 -1
  9. package/docs/clarity-baseline.json +3 -3
  10. package/docs/contract.md +4 -1
  11. package/docs/feature-audit.md +24 -3
  12. package/docs/gate.md +10 -11
  13. package/docs/index.json +21 -7
  14. package/docs/research.md +46 -0
  15. package/docs/skills.md +23 -5
  16. package/hooks/tezgah_agents.py +3 -2
  17. package/hooks/tezgah_components.py +2 -1
  18. package/hooks/tezgah_context.py +65 -14
  19. package/hooks/tezgah_gate.py +62 -76
  20. package/hooks/tezgah_policy.py +38 -14
  21. package/hooks/tezgah_product.py +107 -0
  22. package/hooks/tezgah_research.py +3 -3
  23. package/hooks/tezgah_skill_pick.py +58 -17
  24. package/hooks/tezgah_task.py +2 -2
  25. package/hosts/opencode/plugins/tezgah.js +17 -37
  26. package/package.json +1 -1
  27. package/skills/analyze-app/SKILL.md +57 -0
  28. package/skills/design-contract/EVALS.md +24 -1
  29. package/skills/design-contract/SKILL.md +17 -1
  30. package/skills/design-contract/data-views.md +86 -0
  31. package/skills/feature-audit/SKILL.md +32 -3
  32. package/skills/plan-add/EVALS.md +8 -8
  33. package/skills/plan-add/SKILL.md +20 -21
  34. package/skills/plan-sync/EVALS.md +2 -2
  35. package/skills/plan-sync/SKILL.md +11 -7
  36. package/skills/pm-frameworks/SKILL.md +6 -0
  37. package/skills/product-analysis/EVALS.md +23 -1
  38. package/skills/product-analysis/SKILL.md +50 -11
  39. package/skills/product-flow/EVALS.md +81 -0
  40. package/skills/product-flow/SKILL.md +91 -0
  41. package/skills/research/SKILL.md +10 -1
  42. package/skills/tezgah-contract/SKILL.md +63 -39
@@ -38,28 +38,26 @@ Rules, all only inside a tezgah root:
38
38
  8. a write while an active task is set - a plan file under .tezgah/plans/open whose
39
39
  frontmatter carries a valid `phase` - is refused when that phase is one
40
40
  that only reads, or when the file is outside the task's `allowed_paths`
41
- refused (`task`). The record is the user's own, written by bin/tezgah-task and
42
- never by the agent, so the refusal leans on a decision the user made rather
43
- than one this gate inferred: no active task means no requirement, and the
44
- rule invents nothing. The record also carries where the phase returns to - a
41
+ refused (`task`). The record is written by bin/tezgah-task, which the
42
+ orchestrating session runs itself as the work moves, so the refusal leans
43
+ on a state the CLI vetted (an Acceptance item per command, an answered
44
+ spike, a checkpoint) rather than one this gate inferred: no active task
45
+ means no requirement, and the rule invents nothing. The record also
46
+ carries where the phase returns to - a
45
47
  `checkpoint:` sha, or `pending` while the tree still holds work no commit
46
48
  names - and a write in `implementation` is refused while it reads `pending`
47
49
  and the tree is dirty, so the phase's work starts from a named state rather
48
50
  than on top of uncommitted changes; the per-file snapshots are not one
49
- state to return to. Two further refusals close the ways a session could
50
- move the boundary instead of respecting it: a write whose target IS that
51
- record, and a shell command that would change it through the CLI. Both were
52
- measured open before they were closed - with the refusal naming the command
53
- that lifts it, an armed arm removed or disabled the gate in 25 of 25 runs
54
- and obeyed it in none (an internal benchmark, block E7) - and neither
55
- refusal names a command now. The phase covers the shell too, because the
51
+ state to return to. A write whose target IS that record is refused too:
52
+ retyping the frontmatter would skip the preconditions the CLI checks. The
53
+ phase covers the shell too, because the
56
54
  write tools are not the only way to change a file: with them refused, the
57
55
  armed arm wrote the target with a heredoc redirect in 3 of 25 runs, so a
58
56
  reading phase refuses a shell command that writes. A code-execution tool is
59
57
  a third route the phase does not cover: the same block measured one row in
60
58
  25 writing the target through `eval` after the write tools and the shell
61
59
  were both refused, and that one is named rather than closed. The `task-off`
62
- kill switch removes all four.
60
+ kill switch removes all three.
63
61
  9. a `git commit` (or `--amend`) is refused while the newest check recorded in
64
62
  this session failed (`order`): a commit is a claim that the tree passed, and
65
63
  the state it would freeze is the one a check just rejected. The state is the
@@ -681,9 +679,10 @@ CLASS_NOTE = {
681
679
  "transient": "it names a failure the host's own client may retry - a "
682
680
  "timeout, a connection error, a rate limit, a 5xx - and an "
683
681
  "identical repeat by the agent adds nothing to that",
684
- "user": "it names something only the user can fix - a credential, a "
685
- "login, an access grant - so stop and ask the user for it rather "
686
- "than change the approach",
682
+ "user": "it names something only the user can supply - a credential, a "
683
+ "login, an access grant - so take a route that needs none first "
684
+ "(another provider or member, a local run, a public source) and "
685
+ "ask the user once, naming what it unlocks, only when none is left",
687
686
  "permanent": "an assertion or a bad argument does not change by re-running "
688
687
  "it",
689
688
  }
@@ -980,52 +979,47 @@ def race_reason(inp, session_id, cwd=None):
980
979
  return None
981
980
 
982
981
 
983
- # --- task scope: the user's own phase and path allowlist --------------------
984
- # What this closes: a session that has been told what it is working on - the
985
- # user activated a plan file under .tezgah/plans/open, so the work has a phase and the
986
- # files it may touch - and then writes anyway. Two shapes cost the user real
987
- # work: a write in `discovery`, where the whole point of the phase is to read
988
- # before deciding, and a write outside the plan's own allowlist, which is how a
989
- # bounded task quietly becomes an unbounded one. Both are the user's own
990
- # decisions, which is why this rule may refuse at all: the record is written by
991
- # bin/tezgah-task, by hand, and never by the agent - a rule the agent could
992
- # satisfy by editing its own record would be a nudge wearing a gate's clothes.
982
+ # --- task scope: the plan's own phase and path allowlist --------------------
983
+ # What this closes: a session that has declared what it is working on - a plan
984
+ # file under .tezgah/plans/open is active, so the work has a phase and the
985
+ # files it may touch - and then writes anyway. Two shapes cost real work: a
986
+ # write in `discovery`, where the whole point of the phase is to read before
987
+ # deciding, and a write outside the plan's own allowlist, which is how a
988
+ # bounded task quietly becomes an unbounded one. The record is written by
989
+ # bin/tezgah-task, which the orchestrating session runs itself: the plan's
990
+ # lifecycle is the orchestrator's job, never a hand-off to the user. What keeps
991
+ # the boundary is the CLI, not who runs it - a writing phase needs every
992
+ # Acceptance item to name its proving command, `implementation` an answered
993
+ # spike and a checkpoint, `close done` an approving review - so the record's own
994
+ # file is refused to a write (TASK_RECORD_DENY): retyping the frontmatter is the
995
+ # direct route that skips those checks. A shell line's positional target (`sed
996
+ # -i`, `cp`) is not read here (see write_paths), the residual this rule names.
993
997
  #
994
998
  # The direction is fail-open wherever the record is missing, unreadable or out
995
- # of phase vocabulary (tezgah_task.active returns None for those), because a
996
- # refusal has to rest on an ask the user actually made: a rule that refused
997
- # every write until a record appeared would be the fail-closed trap the design
998
- # report names, with the whole session as its blast radius. Nothing is named
999
- # that the user did not name either: an empty allowlist is no scope asked for
1000
- # rather than "nothing is allowed", so the phase is the whole requirement there.
999
+ # of phase vocabulary (tezgah_task.active returns None for those): a rule that
1000
+ # refused every write until a record appeared would be the fail-closed trap the
1001
+ # design report names, with the whole session as its blast radius. An empty
1002
+ # allowlist is no scope declared rather than "nothing is allowed", so the phase
1003
+ # is the whole requirement there.
1004
+ #
1005
+ # The refusals name the CLI move, because running it is the work the rule asks
1006
+ # for and the CLI vets it. E7 measured a refusal that named a way to take the
1007
+ # rule off (the armed arm disabled the gate in 25 of 25 runs); the switches that
1008
+ # do that stay behind the control-plane rule below.
1001
1009
  TASK_PHASE_DENY = (
1002
1010
  "Task gate: the active task %s is in phase `%s`, and this call writes. "
1003
- "Writes belong to %s. The phase is the user's to move: ask them, or do the "
1004
- "reading this phase asks for and say what the write was for.")
1011
+ "Writes belong to %s. Finish the reading this phase is for, then move the "
1012
+ "phase yourself with `tezgah-task phase implementation` - it refuses until "
1013
+ "the plan's Acceptance, spike and checkpoint are in order.")
1005
1014
  TASK_SCOPE_DENY = (
1006
- "Task gate: %s is outside the paths the active task %s allows (%s). The "
1007
- "allowlist is the user's to widen: ask them, or leave the file alone and say "
1008
- "why it looked in scope.")
1009
- # The two refusals above deliberately name no command. They used to end with the
1010
- # one that lifts them, and the E7 block measured what that costs: with the unlock
1011
- # printed in the refusal, the armed arm removed or disabled the gate in 25 of 25
1012
- # runs and obeyed it in none (an internal benchmark, block E7). A refusal
1013
- # is a boundary or it is an instruction; it cannot be both.
1015
+ "Task gate: %s is outside the paths the active task %s allows (%s). If the "
1016
+ "plan's work genuinely reaches it, widen the scope yourself with `tezgah-task "
1017
+ "allow <glob> ... --why \"<reason>\"` (listing the current globs too); "
1018
+ "otherwise leave the file alone.")
1014
1019
  TASK_RECORD_DENY = (
1015
1020
  "Task gate: %s is the active task's own record. The phase and the allowlist "
1016
- "live in that file, so a session that edits it is editing the boundary it is "
1017
- "being held to. Ask the user to change the task; the record is theirs.")
1018
- TASK_UNLOCK_DENY = (
1019
- "Task gate: this command changes the active task's own record. The phase and "
1020
- "the allowlist are the user's, and a session that can move them is held to "
1021
- "nothing. Ask the user to change the task instead.")
1022
- # A shell command that changes the record. Matched on the masked text, so a
1023
- # command that merely names the CLI - a grep over the docs, a commit message -
1024
- # is not refused. The token is the CLI's own basename, with or without `.py`
1025
- # and run through any interpreter, so any path to it counts.
1026
- TASK_CHANGE = re.compile(
1027
- r"(?:^|[|;&(]\s*|\s)(?:python3?\s+)?\S*tezgah-task(?:\.py)?\s+"
1028
- r"(?:start|phase|allow|stop)\b")
1021
+ "live in that file and only `tezgah-task` writes them, because it checks the "
1022
+ "plan's Acceptance, spike and review on the way; change the task through it.")
1029
1023
  # The shapes that make a shell command a write, for the phase rule below. The
1030
1024
  # tools are not the only way to change a file, and E7b measured the other one:
1031
1025
  # with `edit` and `write` refused twelve and six times, the armed arm wrote the
@@ -1053,8 +1047,8 @@ SHELL_WRITE = re.compile(
1053
1047
  TASK_SHELL_DENY = (
1054
1048
  "Task gate: the active task %s is in phase `%s`, and this command writes a "
1055
1049
  "file. The shell is a write route like any other, so the phase covers it. "
1056
- "The phase is the user's to move: ask them, or do the reading this phase "
1057
- "asks for and say what the write was for.")
1050
+ "Finish the reading this phase is for, then move the phase yourself with "
1051
+ "`tezgah-task phase implementation`.")
1058
1052
 
1059
1053
 
1060
1054
  # The marker a caller passes when it has NOT read the record, so a rule reads it
@@ -1100,11 +1094,10 @@ def task_shell_reason(inp, cwd, base, task=_UNRESOLVED):
1100
1094
  def task_record_reason(inp, cwd, base, task=_UNRESOLVED):
1101
1095
  """A deny reason when this write's target IS the active task's record.
1102
1096
 
1103
- The other route to the same hole as TASK_UNLOCK_DENY, and the one a write
1104
- tool takes: the record is a file in the repo, so an agent that may write
1105
- files can retype the phase instead of running the CLI. Refused whatever the
1106
- phase and whatever the allowlist says - the record is not inside its own
1107
- scope, because a scope that can widen itself is not one."""
1097
+ The record is a file in the repo, so an agent that may write files could
1098
+ retype the phase instead of running the CLI, and skip the Acceptance, spike
1099
+ and review checks the CLI makes on the way. Refused whatever the phase and
1100
+ whatever the allowlist says - the record is not inside its own scope."""
1108
1101
  task = _task(cwd, base, task)
1109
1102
  if not task:
1110
1103
  return None
@@ -1182,11 +1175,9 @@ def task_reason(inp, cwd, base, task=_UNRESOLVED):
1182
1175
  # be read, and `task-off` above. A refusal has to rest on something a reader can
1183
1176
  # check, and a rule that cannot answer must not refuse.
1184
1177
  #
1185
- # The refusal names the one command that makes the boundary real. That is not the
1186
- # mistake TASK_PHASE_DENY records: the E7 lesson is a refusal that names how to
1187
- # take the constraint off (the phase is the user's to move), while the checkpoint
1188
- # command *satisfies* the requirement - running it is the work the rule asks for,
1189
- # so naming it is naming what the rule wants rather than a way around it.
1178
+ # The refusal names the one command that makes the boundary real: running it is
1179
+ # the work the rule asks for, so naming it is naming what the rule wants rather
1180
+ # than a way around it.
1190
1181
  CHECKPOINT_DENY = (
1191
1182
  "Checkpoint gate: the active task %s is in phase `implementation` and its "
1192
1183
  "plan records a pending checkpoint (`checkpoint: pending <sha>`), so this "
@@ -1823,8 +1814,8 @@ def _refusing(label):
1823
1814
  # `tezgah-gate decide` writes a genuine `began` row with `check=1`; the capture
1824
1815
  # CLI writes snapshot rows; pony and adhd write CONFIG_DIR; `tezgah-context
1825
1816
  # attest` re-attests a session, and a clean result clears its drift mark. The
1826
- # task CLI is not here: it is the task rule's (TASK_CHANGE), which `task-off`
1827
- # removes, and that switch is one of the files this rule protects.
1817
+ # task CLI is not here: the orchestrating session runs it, and the checks that
1818
+ # matter live in the CLI itself (see the task rule above).
1828
1819
  # `tezgah-context user_prompt` is a prompt event, and a prompt naming a switch
1829
1820
  # writes the `authorized` row the switch latch honors (tezgah_paths.off).
1830
1821
  CONTROL_CLIS = {"tezgah-gate": ("decide",), "tezgah-capture": "",
@@ -2651,11 +2642,9 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
2651
2642
  reason = race_reason(inp, session_id, cwd)
2652
2643
  if reason:
2653
2644
  return _deny(session_id, "race", reason, tool, inp, base)
2654
- # Task scope: the user's own record for this work - its phase and the files
2655
- # its allowlist names (see task_reason). The
2656
- # record itself is refused first of all, and a shell that would change it
2657
- # through the CLI is refused with it: a boundary the agent can move is not a
2658
- # boundary, and both routes to moving this one were measured open (E7).
2645
+ # Task scope: the active plan's phase and the files its allowlist names (see
2646
+ # task_reason). The record itself is refused first of all: only
2647
+ # bin/tezgah-task writes it, through the checks it makes on the way.
2659
2648
  if not off("task-off"):
2660
2649
  # The record is read once and handed to every rule below: each read lists
2661
2650
  # `.tezgah/plans/open/` and parses a plan, and this path is every write of
@@ -2667,9 +2656,6 @@ def decision(tool, inp, cwd, session_id=None, record=True, agent=None):
2667
2656
  reason = task_record_reason(inp, cwd, base, task)
2668
2657
  if reason:
2669
2658
  return _deny(session_id, "task", reason, tool, inp, base)
2670
- if t in BASH_TOOLS and TASK_CHANGE.search(
2671
- mask(str(inp.get("command") or ""))):
2672
- return _deny(session_id, "task", TASK_UNLOCK_DENY, tool, inp, base)
2673
2659
  if t in BASH_TOOLS:
2674
2660
  reason = task_shell_reason(inp, cwd, base, task)
2675
2661
  if reason:
@@ -236,17 +236,32 @@ rewrite it plainly.
236
236
  **Merge authority: standing, not per-PR.** The user is not the approval step
237
237
  on a merge. Review first with a FRESH reviewer (never my own read of my own
238
238
  code), run the full suite, and if the review is clean and every test passes,
239
- merge, move the plan to `.tezgah/plans/done/`, and report the outcome. Bring it back
240
- to the user only on a critical or major finding left after round two, or a
241
- failing test. One review per plan, over its whole diff in the verification
239
+ merge and close the plan yourself (`tezgah-task close NNN done`, which also
240
+ deactivates it and removes every merged worktree), then report the outcome. Nothing
241
+ goes back to the user: a failing test you fix, and a critical or major finding
242
+ left after round two you fix yourself, prove with a test and close with
243
+ `tezgah-task close NNN done --resolved "<fix and test>"`. One review per plan, over its whole diff in the verification
242
244
  phase; at most two rounds - only a confirmed critical or major finding opens
243
245
  round two, a minor or suggestion one is recorded fix-later, and after round two
244
- what is left goes to the user or is recorded fix-later, never a third round.
246
+ what is left is fixed by the session and closed with `--resolved`, or recorded fix-later, never a third round.
245
247
  A round spawns its reviewers in ONE message, one per dimension (correctness
246
248
  and contract, security, tests and performance) or file group; a diff under 300
247
249
  changed lines gets one reviewer. Per-task work gets tests, not a reviewer, and
248
250
  `tezgah-task review` refuses a third round.
249
251
 
252
+ **The process is yours: never hand a step back.** The orchestrator runs the
253
+ plan lifecycle itself - `tezgah-task start`, every `phase` move, `review`,
254
+ `close`, the merge, and removing a slice's worktree once its branch is merged -
255
+ and never ends a reply with "run this command" or "shall I close it?" for a
256
+ step it can take. A process parameter the user did not set (a spend ceiling, a
257
+ sample size, a retry budget, a member to consult) is picked by you: choose the
258
+ conservative default, state it in one line, proceed. A credential, a login or an
259
+ access grant is the one thing no tool supplies, and even then take every route
260
+ that needs none first - another consult member or provider, local compute
261
+ instead of managed, a public source - and ask once, naming what it unlocks, only
262
+ when no route is left. A requested item you would drop is still asked about
263
+ (see "Deliver the whole ask").
264
+
250
265
  **Attribution: none, anywhere, ever.** Nothing you persist or publish may name
251
266
  the assistant, model, vendor or "AI" as author, co-author, generator or helper -
252
267
  on any host (Claude, opencode, Codex, Cursor, dsh, omp), including subagents and cheap
@@ -402,10 +417,11 @@ question needs facts newer or wider than the codebase - current versions, CVEs,
402
417
  vendor status, breaking-change news; skip it for pure code/design reasoning.
403
418
 
404
419
  Exit 4 means no choice is recorded and exit 5 means every recorded member
405
- failed: nothing answered, and the output lists `offer:`/`reoffer:` members. Ask
406
- the user which to use with exactly those options (prefer a model family other
407
- than your own), record the answer with `{CONSULT_BIN} --use <member>[,<member>]`,
408
- then ask the question again. Never pick a member for the user.
420
+ failed: nothing answered, and the output lists `offer:`/`reoffer:` members. Do
421
+ not ask the user: record the first listed member from a model family other than
422
+ your own (two make a panel) with `{CONSULT_BIN} --use <member>[,<member>]`, ask
423
+ again, and say which member you chose. After two such rounds with nothing
424
+ answering, report the second opinion as skipped and carry on.
409
425
 
410
426
  Treat every answer as advisory evidence, never as truth: verify each claim
411
427
  against the actual code before adopting it, and tell the user which models were
@@ -453,8 +469,9 @@ are what keeps results comparable, and breaking one silently invalidates the run
453
469
  never edit a node once a run has answered it (branch a child instead); the run
454
470
  command and environment are a fixed contract identical on every node; vary the
455
471
  committed code/config, never CLI args or env knobs; grow the experiment tree
456
- downward, not sideways. Local research needs no login; managed compute does
457
- - ask the user to run `{ORX_BIN} login`.
472
+ downward, not sideways. Local research needs no login; managed compute does -
473
+ run locally at a scale that fits, and ask for `{ORX_BIN} login` only when the
474
+ question cannot be answered without managed compute.
458
475
 
459
476
  A result row and every claim built on it say what they were measured on: `real`
460
477
  for the running system, `fixture` for an input you generated, `derived` for a
@@ -806,8 +823,10 @@ never your own summary, conclusion or self-assessment - a reviewer handed the
806
823
  author's framing finds measurably fewer defects. It answers with independent
807
824
  models, then one referee: read back the referee's named fields (recommendation,
808
825
  key disagreements, unchecked assumptions, what would change its mind, requested
809
- evidence), not a paraphrase. On exit 4/5 ask the user which `offer:`/`reoffer:`
810
- member to use and record it with `--use` before asking again. Relay the failure
826
+ evidence), not a paraphrase. On exit 4/5 record the first listed
827
+ `offer:`/`reoffer:` member of another model family yourself with `--use` and ask
828
+ again - never ask the user to choose; after two failed rounds say it was
829
+ skipped. Relay the failure
811
830
  class and retry line it prints, verify every claim against the code, report
812
831
  which models disagreed, and if nothing answered say the second opinion was
813
832
  skipped. Skip trivial local edits.
@@ -871,7 +890,9 @@ evidence class - `user-verbatim`, `behaviour`, `ui-observed`, `code`, `external`
871
890
  and a finding without one is a question, not a finding. Run it as a research line
872
891
  (`{RESEARCH_BIN}`), say what it did not look at, and read the `product-analysis`
873
892
  skill for the artifact shape and the scorecard, and `feature-audit` for the
874
- layer-coherence matrices when the ask is one feature. Off: `research-off`.
893
+ layer-coherence matrices when the ask is one feature. A new capability built from
894
+ data or an ask runs the `product-flow` skill: `{RESEARCH_BIN} next --topic <t>` is
895
+ the step source, and its first missing or due step is the next one. Off: `research-off`.
875
896
 
876
897
  **No AI attribution, ever, on any host.** Nothing persisted or published may
877
898
  name the assistant, model, vendor or "AI" as author/co-author/generator/helper:
@@ -922,7 +943,10 @@ its own branch and checkout - inside Orca `orca worktree create --name <slug>
922
943
  --parent-worktree active --json`, else `git worktree add -b <branch> <absolute
923
944
  path>`; its brief names every read, edit and shell `cwd` as an absolute path
924
945
  under that checkout, never the main checkout, whose `git status --short` must
925
- not grow. Read-only slices need no worktree. Pick each agent by task class from
946
+ not grow. Read-only slices need no worktree. Once a slice's branch is merged,
947
+ its worktree goes in the same turn - `tezgah-task close` removes every merged
948
+ one, and outside a plan `orca worktree rm --worktree path:<p>` (else `git
949
+ worktree remove <p>`) does it; never leave one for the user to find. Pick each agent by task class from
926
950
  the specialists line; `{ROUTE_BIN}` names the tier for code work. Serialize
927
951
  only a real dependency - one slice's output is another's input, or two slices
928
952
  edit one file - and say which. Never wait idle on a spawned slice while
@@ -0,0 +1,107 @@
1
+ """The product-flow playbook (plan 074, ADR 025): the one place its steps are
2
+ written. `next_steps` computes each step's status from the topic's files under
3
+ `.tezgah/analysis/<topic>/` - `flow.md`, one `## <step>` heading per step, and
4
+ `spec.md` - and nothing stores it."""
5
+ import datetime
6
+ import os
7
+ import re
8
+
9
+ import tezgah_paths as tp
10
+
11
+ # A topic becomes a path below, so anything outside kebab-case reads no file.
12
+ TOPIC = re.compile(r"(?=.{1,64}\Z)[a-z0-9]+(?:-[a-z0-9]+)*")
13
+ OBSERVE = re.compile(r"observe after:\s*(\d{4}-\d{2}-\d{2})", re.I)
14
+
15
+
16
+ def _step(sid, skill, first=""):
17
+ """A step whose artifact is its `## <sid>` note in flow.md; `first` is what
18
+ the session does before writing it."""
19
+ return {"id": sid, "skill": skill,
20
+ "leaves": "## %s in .tezgah/analysis/<topic>/flow.md" % sid,
21
+ "command": "%swrite ## %s in .tezgah/analysis/{topic}/flow.md"
22
+ % (first, sid)}
23
+
24
+
25
+ # `{topic}` in a command is filled per topic. The design check and the Stop UI
26
+ # rule judge design and build; the flow only asks for their note.
27
+ _STEPS = (
28
+ _step("research", "research"),
29
+ _step("metrics", "product-analysis"),
30
+ _step("assumptions", "product-analysis"),
31
+ _step("decision", "product-analysis"),
32
+ _step("instrumentation", "product-analysis"),
33
+ {"id": "forward-spec", "skill": "feature-audit",
34
+ "leaves": ".tezgah/analysis/<topic>/spec.md",
35
+ "command": "feature-audit forward mode: write .tezgah/analysis/{topic}/spec.md "
36
+ "naming the decision"},
37
+ _step("design", "design-contract", "tezgah-design check, then "),
38
+ _step("build", "design-contract", "tezgah-design check, then "),
39
+ _step("re-measure", "analyze-app", "measure the decision's metric again, then "),
40
+ )
41
+ FLOWS = {
42
+ "metric-dashboard": _STEPS,
43
+ # A feature may reuse a metric another topic owns: its own metric steps join
44
+ # the flow only when its flow.md holds `## metrics` (`next_steps`).
45
+ "feature": tuple(s for s in _STEPS if s["id"] not in ("metrics", "instrumentation")),
46
+ }
47
+
48
+
49
+ def _sections(text):
50
+ """{step: body} of the `## <step>` headings in `text`, lower-cased."""
51
+ parts = re.split(r"^##[ \t]+(.+?)[ \t]*$", text, flags=re.M)
52
+ return {parts[i].lower(): parts[i + 1] for i in range(1, len(parts) - 1, 2)}
53
+
54
+
55
+ def _read(path):
56
+ try:
57
+ with open(path, encoding="utf-8", errors="replace") as fh:
58
+ return fh.read()
59
+ except OSError:
60
+ return ""
61
+
62
+
63
+ def next_steps(repo, topic, flow="metric-dashboard", today=""):
64
+ """[{"id", "skill", "status", "detail", "command"}] for `topic` under `flow`,
65
+ status one of done | missing | due, read from `.tezgah/analysis/<topic>/` of
66
+ `repo`. A topic outside TOPIC or with no folder lists every step missing."""
67
+ if flow not in FLOWS:
68
+ raise ValueError("unknown flow %r (%s)" % (flow, ", ".join(FLOWS)))
69
+ try:
70
+ day = datetime.date.fromisoformat(today) if today else datetime.date.today()
71
+ except ValueError:
72
+ raise ValueError("today %r is not YYYY-MM-DD" % (today,)) from None
73
+ safe = isinstance(topic, str) and TOPIC.fullmatch(topic)
74
+ rel = os.path.join(".tezgah", "analysis", topic if safe else "<topic>")
75
+ folder = os.path.join(tp.workspace(repo), "analysis", topic) if safe else ""
76
+ notes = _sections(_read(os.path.join(folder, "flow.md"))) if folder else {}
77
+ flow_rel = os.path.join(rel, "flow.md")
78
+ spec_rel = os.path.join(rel, "spec.md")
79
+ steps = FLOWS["metric-dashboard"] if flow == "feature" and "metrics" in notes \
80
+ else FLOWS[flow]
81
+
82
+ def status(sid):
83
+ if sid == "forward-spec":
84
+ return ("done", "%s exists" % spec_rel) \
85
+ if folder and os.path.isfile(os.path.join(folder, "spec.md")) \
86
+ else ("missing", "%s does not exist" % spec_rel)
87
+ if sid in notes:
88
+ return "done", "%s ## %s" % (flow_rel, sid)
89
+ if sid == "re-measure":
90
+ after = OBSERVE.search(notes.get("decision", ""))
91
+ try:
92
+ when = datetime.date.fromisoformat(after.group(1)) if after else None
93
+ except ValueError:
94
+ when = None
95
+ if when is not None:
96
+ return ("due", "observe after %s has passed" % when) if day > when \
97
+ else ("missing", "observe after %s, not yet" % when)
98
+ return "missing", "no observe after: in %s ## decision" % flow_rel
99
+ return "missing", "no ## %s in %s" % (sid, flow_rel)
100
+
101
+ out = []
102
+ for s in steps:
103
+ state, detail = status(s["id"])
104
+ out.append({"id": s["id"], "skill": s["skill"], "status": state,
105
+ "detail": detail, "command": s["command"].format(
106
+ topic=topic if safe else "<topic>")})
107
+ return out
@@ -2842,9 +2842,9 @@ def _check_append_only(repo, label, path, errors, warnings, hard):
2842
2842
  old = [line for line in older.splitlines() if line.strip()]
2843
2843
  new = [line for line in newer.splitlines() if line.strip()]
2844
2844
  if new[:len(old)] != old:
2845
- _soft(errors, warnings, hard, "%s was rewritten after the run: a committed "
2846
- "row changed or went missing, and results are append-only - a "
2847
- "correction is a new row" % label)
2845
+ _soft(errors, warnings, hard, "%s was rewritten after the run: a "
2846
+ "committed row changed or went missing, and results are "
2847
+ "append-only - a correction is a new row" % label)
2848
2848
  return
2849
2849
 
2850
2850
 
@@ -347,10 +347,40 @@ def skill_roots(extra=()):
347
347
  return out
348
348
 
349
349
 
350
+ # One routing line of a skill's own INDEX.md: `name - what it makes - path`.
351
+ _INDEX_ROW = re.compile(r"^\S+ - .+ - (\S+/SKILL\.md)\s*$")
352
+
353
+
354
+ def _entries(root, name):
355
+ """[(skill, path)] for one skill directory: its SKILL.md, then every entry its
356
+ INDEX.md routes to. design-library keeps 39 entries under
357
+ `<plugin>/skills/<entry>/SKILL.md` and reaches them only through that index,
358
+ so a search over top-level files alone never saw a word of them. A nested
359
+ entry is addressed `<skill>/<path>`, the form `skill://` resolves."""
360
+ base = os.path.join(root, name)
361
+ out = [(name, os.path.join(base, "SKILL.md"))]
362
+ try:
363
+ with open(os.path.join(base, "INDEX.md"), encoding="utf-8") as fh:
364
+ lines = fh.read().splitlines()
365
+ except (OSError, UnicodeDecodeError):
366
+ return out
367
+ # A row is followed only inside the skill's own directory: an absolute path,
368
+ # a `..` or a symlink out would index a file the skill does not ship.
369
+ inside = os.path.realpath(base) + os.sep
370
+ for line in lines:
371
+ hit = _INDEX_ROW.match(line)
372
+ if not hit or os.path.isabs(hit.group(1)):
373
+ continue
374
+ path = os.path.join(base, hit.group(1))
375
+ if os.path.realpath(path).startswith(inside):
376
+ out.append(("%s/%s" % (name, hit.group(1)), path))
377
+ return out
378
+
379
+
350
380
  def _rows(roots=None):
351
381
  """[(skill, path, title, start, end, text)] over every installed skill's
352
- sections, deduplicated on the file's realpath so a skill five hosts link
353
- is one entry, not five."""
382
+ sections, and the entries a skill's INDEX.md routes to, deduplicated on the
383
+ file's realpath so a skill five hosts link is one entry, not five."""
354
384
  out, seen = [], set()
355
385
  for root in (skill_roots() if roots is None else roots):
356
386
  try:
@@ -358,14 +388,23 @@ def _rows(roots=None):
358
388
  except OSError:
359
389
  continue
360
390
  for name in names:
361
- path = os.path.realpath(os.path.join(root, name, "SKILL.md"))
362
- if not os.path.isfile(path):
363
- continue
364
- for title, start, end, text in sections(path):
365
- if (path, start) in seen:
391
+ for skill, raw in _entries(root, name):
392
+ path = os.path.realpath(raw)
393
+ if not os.path.isfile(path):
366
394
  continue
367
- seen.add((path, start))
368
- out.append((name, path, title, start, end, text))
395
+ secs = sections(path)
396
+ if "/" in skill and secs:
397
+ # A routed entry is one row, the whole file: its short
398
+ # sections out-scored whole skills on two common words
399
+ # ("give", "thing") and silenced the right hint, while the
400
+ # entry as a whole still carries its topic's words.
401
+ secs = [(secs[0][0], secs[0][1], secs[-1][2],
402
+ "\n".join(s[3] for s in secs))]
403
+ for title, start, end, text in secs:
404
+ if (path, start) in seen:
405
+ continue
406
+ seen.add((path, start))
407
+ out.append((skill, path, title, start, end, text))
369
408
  return out
370
409
 
371
410
 
@@ -490,9 +529,10 @@ def rank_postings(query, posts, lengths, k):
490
529
 
491
530
 
492
531
  def _signature():
493
- """[[root, skill, mtime_ns, size]] per installed SKILL.md: what a rebuild
494
- keys on. The file's own stat, not its directory's - installing, editing or
495
- removing one skill is exactly the change the cache must notice."""
532
+ """[[root, skill, mtime_ns, size]] per installed SKILL.md and per entry an
533
+ INDEX.md routes to: what a rebuild keys on. The file's own stat, not its
534
+ directory's - installing, editing or removing one skill is exactly the
535
+ change the cache must notice."""
496
536
  sig = []
497
537
  for root in skill_roots():
498
538
  try:
@@ -500,11 +540,12 @@ def _signature():
500
540
  except OSError:
501
541
  continue
502
542
  for name in names:
503
- try:
504
- st = os.stat(os.path.join(root, name, "SKILL.md"))
505
- except OSError:
506
- continue
507
- sig.append([root, name, st.st_mtime_ns, st.st_size])
543
+ for skill, path in _entries(root, name):
544
+ try:
545
+ st = os.stat(path)
546
+ except OSError:
547
+ continue
548
+ sig.append([root, skill, st.st_mtime_ns, st.st_size])
508
549
  return sig
509
550
 
510
551
 
@@ -2,8 +2,8 @@
2
2
  """The active task: one plan file's phase and its path allowlist.
3
3
 
4
4
  A task is not a second record beside the plan - it IS the plan file under
5
- `.tezgah/plans/open/`, with two optional frontmatter keys that the user's own CLI
6
- (bin/tezgah-task) writes and no agent ever does:
5
+ `.tezgah/plans/open/`, with two optional frontmatter keys that only its CLI
6
+ (bin/tezgah-task, run by the orchestrating session itself) writes:
7
7
 
8
8
  phase: implementation # discovery | implementation | verification
9
9
  allowed_paths: # globs, relative to the repo root