taphound 0.2.0-dev.11 → 0.2.0-dev.13

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/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/assets/skills/taphound-case-suite/SKILL.md +8 -6
  4. package/assets/skills/taphound-case-suite/scripts/ledger.mjs +84 -19
  5. package/assets/skills/taphound-journey-brief-author/SKILL.md +8 -3
  6. package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.md +3 -1
  7. package/assets/skills/taphound-journey-brief-author/prompts/brief-author-role.zh-CN.md +1 -1
  8. package/assets/skills/taphound-journey-generator/SKILL.md +21 -5
  9. package/assets/skills/taphound-journey-generator/prompts/consume-journey-brief.md +3 -1
  10. package/assets/skills/taphound-journey-generator/prompts/generate-step.md +9 -0
  11. package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +80 -10
  12. package/assets/skills/taphound-verify-change/references/preserve.md +15 -7
  13. package/assets/skills/taphound-verify-change/scripts/ui-refactor.mjs +1 -1
  14. package/dist/adapters/appium/appium-ui-snapshot-provider.d.ts +8 -0
  15. package/dist/adapters/appium/appium-ui-snapshot-provider.js +123 -54
  16. package/dist/adapters/filesystem/diagnostics-journal.d.ts +17 -0
  17. package/dist/adapters/filesystem/diagnostics-journal.js +130 -0
  18. package/dist/application/diagnostics/diagnostics-exporter.d.ts +29 -0
  19. package/dist/application/diagnostics/diagnostics-exporter.js +283 -0
  20. package/dist/application/diagnostics/ui-capture-telemetry.d.ts +16 -0
  21. package/dist/application/diagnostics/ui-capture-telemetry.js +81 -0
  22. package/dist/application/generation/proposed-step-validator.js +5 -1
  23. package/dist/application/journey/journey-check-service.d.ts +7 -1
  24. package/dist/application/journey/journey-check-service.js +16 -1
  25. package/dist/application/report/report-writer.d.ts +5 -0
  26. package/dist/application/report/report-writer.js +20 -0
  27. package/dist/application/runtime/verify-runtime.js +32 -2
  28. package/dist/application/ui/observed-ui-snapshot-provider.d.ts +15 -0
  29. package/dist/application/ui/observed-ui-snapshot-provider.js +66 -0
  30. package/dist/application/ui/ui-stability-probe.d.ts +6 -0
  31. package/dist/application/ui/ui-stability-probe.js +21 -5
  32. package/dist/application/wait/idle-waiter.js +29 -6
  33. package/dist/cli/commands/diagnose.d.ts +3 -0
  34. package/dist/cli/commands/diagnose.js +84 -0
  35. package/dist/cli/commands/generation/session-commands.js +4 -1
  36. package/dist/cli/commands/journey.js +3 -1
  37. package/dist/cli/commands/verify.js +55 -3
  38. package/dist/cli/dependencies.d.ts +11 -0
  39. package/dist/cli/dependencies.js +58 -5
  40. package/dist/cli/diagnostics-recorder.d.ts +31 -0
  41. package/dist/cli/diagnostics-recorder.js +82 -0
  42. package/dist/cli/main.d.ts +1 -1
  43. package/dist/cli/main.js +58 -1
  44. package/dist/cli/program.js +3 -1
  45. package/dist/domain/contract.d.ts +6 -6
  46. package/dist/domain/diagnostics.d.ts +1396 -0
  47. package/dist/domain/diagnostics.js +193 -0
  48. package/dist/domain/failure.js +1 -1
  49. package/dist/domain/report.d.ts +11 -3
  50. package/dist/domain/report.js +1 -0
  51. package/dist/domain/verify-receipt.d.ts +17 -0
  52. package/dist/domain/verify-receipt.js +17 -0
  53. package/dist/domain/workspace.d.ts +11 -0
  54. package/dist/domain/workspace.js +14 -0
  55. package/dist/ports/diagnostics.d.ts +15 -0
  56. package/dist/ports/diagnostics.js +1 -0
  57. package/package.json +4 -3
  58. package/scripts/feedback-pack.mjs +399 -0
package/README.md CHANGED
@@ -90,7 +90,7 @@ taphound init --agent claude,codex,cursor,droid
90
90
  - [Principles](https://github.com/caikaidev/TapHound/blob/main/docs/principles.md) · [Workflow Skills and development scenarios](https://github.com/caikaidev/TapHound/blob/main/docs/workflow-skills.md) · [Agent integration](https://github.com/caikaidev/TapHound/blob/main/docs/agent-integration.md) · [Journey Generator guide](https://github.com/caikaidev/TapHound/blob/main/docs/journey-generator-guide.md)
91
91
  - [Acceptance Contracts](https://github.com/caikaidev/TapHound/blob/main/docs/contract-schema.md) · [Baselines & regression](https://github.com/caikaidev/TapHound/blob/main/docs/checkpoint-regression.md) · [Failure classification](https://github.com/caikaidev/TapHound/blob/main/docs/failure-classification.md)
92
92
  - [Semantic Anchors](https://github.com/caikaidev/TapHound/blob/main/docs/semantic-anchor.md) · [Capability matrix](https://github.com/caikaidev/TapHound/blob/main/docs/capability-matrix.md) · [`observe`](https://github.com/caikaidev/TapHound/blob/main/docs/observe.md)
93
- - [Runtime backends](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [Local development & testing](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [Releasing](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md)
93
+ - [Runtime backends](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [Local development & testing](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [Releasing](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md) · [Diagnostics and feedback](https://github.com/caikaidev/TapHound/blob/main/docs/diagnostics.md)
94
94
 
95
95
  ## Current Limitations
96
96
 
package/README.zh-CN.md CHANGED
@@ -90,7 +90,7 @@ taphound init --agent claude,codex,cursor,droid
90
90
  - [设计原则](https://github.com/caikaidev/TapHound/blob/main/docs/principles.md) · [Workflow Skill 与开发场景](https://github.com/caikaidev/TapHound/blob/main/docs/workflow-skills.md) · [Agent 集成](https://github.com/caikaidev/TapHound/blob/main/docs/agent-integration.md) · [Journey Generator 指南](https://github.com/caikaidev/TapHound/blob/main/docs/journey-generator-guide.md)
91
91
  - [Acceptance Contract](https://github.com/caikaidev/TapHound/blob/main/docs/contract-schema.md) · [Baseline 与回归对比](https://github.com/caikaidev/TapHound/blob/main/docs/checkpoint-regression.md) · [失败分类](https://github.com/caikaidev/TapHound/blob/main/docs/failure-classification.md)
92
92
  - [Semantic Anchor](https://github.com/caikaidev/TapHound/blob/main/docs/semantic-anchor.md) · [能力矩阵](https://github.com/caikaidev/TapHound/blob/main/docs/capability-matrix.md) · [`observe`](https://github.com/caikaidev/TapHound/blob/main/docs/observe.md)
93
- - [运行时后端](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [本地开发与测试](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [发布](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md)
93
+ - [运行时后端](https://github.com/caikaidev/TapHound/blob/main/docs/architecture/runtime-backend.md) · [本地开发与测试](https://github.com/caikaidev/TapHound/blob/main/docs/local-testing.md) · [发布](https://github.com/caikaidev/TapHound/blob/main/docs/releasing.md) · [诊断与反馈](https://github.com/caikaidev/TapHound/blob/main/docs/diagnostics.md)
94
94
 
95
95
  ## 当前限制
96
96
 
@@ -53,11 +53,13 @@ Use `node <skill>/scripts/ledger.mjs help` for the helper contract.
53
53
 
54
54
  ## Durable suite directory
55
55
 
56
- The caller chooses an explicit project-relative suite directory. A normal
57
- layout is:
56
+ A Suite always lives in `.taphound/suites/<suite-id>/` under the project
57
+ root; the helper derives that directory from the Suite ID and rejects any
58
+ other location (and a Brief anywhere but its Case directory) with
59
+ `CASE_SUITE_LOCATION`:
58
60
 
59
61
  ```text
60
- doc/development/<suite-id>/
62
+ .taphound/suites/<suite-id>/
61
63
  ├── cases.json # frozen user-approved Case catalog
62
64
  ├── case-ledger.json # mutable, revisioned orchestration state
63
65
  ├── STATUS.md # generated human view; never the Source of Truth
@@ -95,8 +97,7 @@ Create an input from `templates/suite-input.example.json`, then:
95
97
 
96
98
  ```bash
97
99
  node <skill>/scripts/ledger.mjs init \
98
- --input /tmp/taphound-suite-input.json \
99
- --out <project>/doc/development/<suite-id>
100
+ --input /tmp/taphound-suite-input.json
100
101
  ```
101
102
 
102
103
  Initialization canonicalizes `projectRoot`, validates dependency cycles and
@@ -215,7 +216,8 @@ Dispatch exactly one Case to `taphound-journey-brief-author` with:
215
216
  - exact catalog `sourceText` as `caseGoal`;
216
217
  - explicit context paths only;
217
218
  - output
218
- `<suite>/briefs/<case-id>/taphound-journey-brief.md`.
219
+ `.taphound/suites/<suite-id>/briefs/<case-id>/taphound-journey-brief.md`
220
+ (the only path `briefReady` accepts).
219
221
 
220
222
  After authoring, compute its exact hash and transition:
221
223
 
@@ -7,7 +7,11 @@ import {
7
7
  import {
8
8
  dirname, isAbsolute, join, relative, resolve, sep
9
9
  } from "node:path";
10
+ import { fileURLToPath } from "node:url";
10
11
 
12
+ // Suites are committed TapHound project material; the layout mirrors
13
+ // SUITES_DIR in src/domain/workspace.ts.
14
+ const SUITES_DIR = ".taphound/suites";
11
15
  const CATALOG = "cases.json";
12
16
  const LEDGER = "case-ledger.json";
13
17
  const STATUS = "STATUS.md";
@@ -82,23 +86,36 @@ function canonicalHash(value) {
82
86
  return digest(JSON.stringify(canonical(value)));
83
87
  }
84
88
 
85
- function exactKeys(value, required, optional = []) {
89
+ // `contract` names the object and, for command inputs, the shipped template
90
+ // so a rejected field points at the shape the command accepts.
91
+ function exactKeys(value, required, optional = [], contract = undefined) {
92
+ const where = contract === undefined ? "" : ` in ${contract.name}`;
93
+ const hint = () => {
94
+ const fields = [...required, ...optional.map((key) => `${key}?`)];
95
+ return `; allowed fields: ${fields.join(", ")}${
96
+ contract?.template === undefined
97
+ ? ""
98
+ : `; see ${contract.template}`
99
+ }`;
100
+ };
86
101
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
87
- fail("CASE_SUITE_INVALID", "Expected a JSON object");
102
+ fail("CASE_SUITE_INVALID", `Expected a JSON object${where}`);
88
103
  }
89
104
  const allowed = new Set([...required, ...optional]);
90
105
  for (const key of Object.keys(value)) {
91
106
  if (!allowed.has(key)) {
92
- fail("CASE_SUITE_INVALID", `Unknown field "${key}"`);
107
+ fail("CASE_SUITE_INVALID", `Unknown field "${key}"${where}${hint()}`);
93
108
  }
94
109
  }
95
110
  for (const key of required) {
96
111
  if (!(key in value)) {
97
- fail("CASE_SUITE_INVALID", `Missing required field "${key}"`);
112
+ fail("CASE_SUITE_INVALID", `Missing required field "${key}"${where}${hint()}`);
98
113
  }
99
114
  }
100
115
  }
101
116
 
117
+ const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), "..", "templates");
118
+
102
119
  function nonempty(value, label, max = 1000) {
103
120
  if (typeof value !== "string") {
104
121
  fail("CASE_SUITE_INVALID", `${label} must be a string`);
@@ -125,6 +142,14 @@ function assertSha(value, label) {
125
142
  }
126
143
  }
127
144
 
145
+ function suiteDirectory(projectRoot, suiteId) {
146
+ return join(projectRoot, ...SUITES_DIR.split("/"), suiteId);
147
+ }
148
+
149
+ function suiteBriefPath(suiteId, caseId) {
150
+ return `${SUITES_DIR}/${suiteId}/briefs/${caseId}/taphound-journey-brief.md`;
151
+ }
152
+
128
153
  function inside(root, path) {
129
154
  const rel = relative(root, path);
130
155
  return rel === "" || (
@@ -459,6 +484,13 @@ async function loadSuite(suitePath) {
459
484
  if (projectRoot !== catalog.projectRoot || !inside(projectRoot, root)) {
460
485
  fail("CASE_SUITE_INVALID", "Suite must stay beneath canonical projectRoot");
461
486
  }
487
+ if (root !== suiteDirectory(projectRoot, catalog.suiteId)) {
488
+ fail(
489
+ "CASE_SUITE_LOCATION",
490
+ `Suite ${catalog.suiteId} must live in ${SUITES_DIR}/${catalog.suiteId}, `
491
+ + `found ${relative(projectRoot, root)}; initialize it there`
492
+ );
493
+ }
462
494
  const ledger = parseLedger(await json(ledgerPath, LEDGER), catalog);
463
495
  const catalogSha256 = digest(catalogBytes);
464
496
  if (ledger.catalog.sha256 !== catalogSha256) {
@@ -599,7 +631,8 @@ function parseSuiteInput(value) {
599
631
  exactKeys(
600
632
  value,
601
633
  ["version", "suiteId", "title", "projectRoot", "cases"],
602
- ["deviceSerial", "contextPath"]
634
+ ["deviceSerial", "contextPath"],
635
+ { name: "init input", template: join(TEMPLATES, "suite-input.example.json") }
603
636
  );
604
637
  if (value.version !== 1 || !suiteIdPattern.test(value.suiteId ?? "")
605
638
  || !isAbsolute(value.projectRoot ?? "")
@@ -629,9 +662,13 @@ function parseSuiteInput(value) {
629
662
  async function init(inputPath, outputPath) {
630
663
  const input = parseSuiteInput(await json(resolve(inputPath), "Suite input"));
631
664
  const projectRoot = await realpath(input.projectRoot);
632
- const output = resolve(outputPath);
633
- if (!inside(projectRoot, output) || output === projectRoot) {
634
- fail("CASE_SUITE_INVALID", "Suite output must be a new directory inside projectRoot");
665
+ const output = suiteDirectory(projectRoot, input.suiteId);
666
+ if (outputPath !== undefined && resolve(outputPath) !== output) {
667
+ fail(
668
+ "CASE_SUITE_LOCATION",
669
+ `Suite directory must be ${SUITES_DIR}/${input.suiteId} under projectRoot `
670
+ + `(${output}); omit --out to use it: ${outputPath}`
671
+ );
635
672
  }
636
673
  await access(output).then(
637
674
  () => fail("CASE_SUITE_EXISTS", "Suite output already exists"),
@@ -708,7 +745,8 @@ function parseTransition(value) {
708
745
  exactKeys(
709
746
  value,
710
747
  ["version", "expectedRevision", "caseId", "from", "to", "reason"],
711
- ["brief", "generation", "failure", "nextAction", "completion"]
748
+ ["brief", "generation", "failure", "nextAction", "completion"],
749
+ { name: "transition input", template: join(TEMPLATES, "transition.example.json") }
712
750
  );
713
751
  if (value.version !== 1 || !caseIdPattern.test(value.caseId ?? "")
714
752
  || !statuses.has(value.from) || !statuses.has(value.to)) {
@@ -861,10 +899,12 @@ async function transition(suitePath, inputPath) {
861
899
  const briefFile = await regularProjectFile(
862
900
  suite.projectRoot, request.brief, `${entry.id}.brief`
863
901
  );
864
- if (!briefFile.relativePath.endsWith(
865
- `/briefs/${entry.id}/taphound-journey-brief.md`
866
- )) {
867
- fail("CASE_SUITE_INVALID", "Brief path must be Case-specific");
902
+ const expectedBrief = suiteBriefPath(suite.catalog.suiteId, entry.id);
903
+ if (briefFile.relativePath !== expectedBrief) {
904
+ fail(
905
+ "CASE_SUITE_LOCATION",
906
+ `Brief for ${entry.id} must be ${expectedBrief}: ${briefFile.relativePath}`
907
+ );
868
908
  }
869
909
  }
870
910
  if (request.to === "generating") {
@@ -958,10 +998,18 @@ async function transition(suitePath, inputPath) {
958
998
  }
959
999
 
960
1000
  function parseFlowRecord(value) {
961
- exactKeys(value, [
962
- "version", "expectedRevision", "name", "path", "sha256",
963
- "exitActivity", "journey", "resolutionManifest", "report"
964
- ]);
1001
+ exactKeys(
1002
+ value,
1003
+ [
1004
+ "version", "expectedRevision", "name", "path", "sha256",
1005
+ "exitActivity", "journey", "resolutionManifest", "report"
1006
+ ],
1007
+ [],
1008
+ {
1009
+ name: "record-flow input",
1010
+ template: join(TEMPLATES, "base-flow-record.example.json")
1011
+ }
1012
+ );
965
1013
  if (value.version !== 1 || !flowNamePattern.test(value.name ?? "")) {
966
1014
  fail("CASE_SUITE_INVALID", "Invalid Base Flow record");
967
1015
  }
@@ -1154,12 +1202,26 @@ function help() {
1154
1202
  "TapHound Case Suite Ledger",
1155
1203
  "",
1156
1204
  "Commands:",
1157
- " init --input <json> --out <suite-directory>",
1205
+ " init --input <json> [--out <project>/.taphound/suites/<suite-id>]",
1158
1206
  " validate --suite <suite-directory>",
1159
1207
  " status --suite <suite-directory> [--case <case-id>]",
1160
1208
  " transition --suite <suite-directory> --input <json>",
1161
1209
  " record-flow --suite <suite-directory> --input <json>",
1162
- " recover-lock --suite <suite-directory>"
1210
+ " recover-lock --suite <suite-directory>",
1211
+ "",
1212
+ `Input templates live in ${TEMPLATES}: suite-input.example.json (init),`,
1213
+ "transition.example.json (transition), base-flow-record.example.json",
1214
+ "(record-flow). Unknown or missing fields are rejected with the allowed list.",
1215
+ "",
1216
+ `Layout: a Suite lives in <projectRoot>/${SUITES_DIR}/<suite-id>/ and each`,
1217
+ "Case Brief in its briefs/<case-id>/taphound-journey-brief.md. Other",
1218
+ "locations fail with CASE_SUITE_LOCATION.",
1219
+ "",
1220
+ "Ledger revision: every successful transition or record-flow increments",
1221
+ "ledger.revision by exactly 1. Pass the current value as expectedRevision",
1222
+ "(read it from `status`); a stale value fails without writing. It is",
1223
+ "unrelated to TapHound generation session revisions (observe +1, step +3",
1224
+ "with its post-action observation), which the ledger never stores."
1163
1225
  ].join("\n");
1164
1226
  }
1165
1227
 
@@ -1172,6 +1234,9 @@ async function main() {
1172
1234
  let output;
1173
1235
  if (command === "init") {
1174
1236
  const input = options(argv, ["--input", "--out"]);
1237
+ if (input.input === undefined) {
1238
+ fail("CASE_SUITE_USAGE", "init requires --input");
1239
+ }
1175
1240
  output = await init(input.input, input.out);
1176
1241
  } else if (command === "validate") {
1177
1242
  const input = options(argv, ["--suite"]);
@@ -104,7 +104,7 @@ Orchestrator (lean context)
104
104
  | contextOnly | no | `false` | Run only the Context lifecycle (ensure/refresh/regenerate); skip Brief authoring |
105
105
  | observeSnapshot | no | — | Pre-captured `taphound observe --json` result |
106
106
  | device | no | doctor auto-selects | Device serial |
107
- | output | no | `.taphound/journeys/taphound-journey-brief.md` | Brief output path (relative to project) |
107
+ | output | no | `.taphound/briefs/<caseId>/taphound-journey-brief.md` | Brief output path (relative to project); must be under `.taphound/briefs/` or `.taphound/suites/<suite-id>/briefs/` |
108
108
 
109
109
  **Hard rule**: NEVER search for or assume files named `plan.md`,
110
110
  `requirement.md`, or any convention. Read ONLY files the caller explicitly
@@ -114,13 +114,18 @@ passes via `contextPaths`. If no `contextPaths` are supplied, work from
114
114
  ## Output
115
115
 
116
116
  For a Brief run, the Skill writes a `taphound-journey-brief.md` at the
117
- `output` path and returns a structured JSON summary:
117
+ `output` path and returns a structured JSON summary. Briefs are committed
118
+ TapHound material: write them only under `.taphound/briefs/<caseId>/`
119
+ (standalone) or `.taphound/suites/<suite-id>/briefs/<caseId>/` (Case Suite),
120
+ never elsewhere in the project (for example `doc/` or the project root).
121
+ `generation start --brief` rejects any other location with `BRIEF_INVALID`.
122
+ Without a `caseId`, use a short kebab-case name of the Goal as the directory.
118
123
 
119
124
  ```json
120
125
  {
121
126
  "status": "authored",
122
127
  "caseId": "CASE-002",
123
- "path": ".taphound/journeys/taphound-journey-brief.md",
128
+ "path": ".taphound/briefs/CASE-002/taphound-journey-brief.md",
124
129
  "sha256": "<exact-byte-hash>",
125
130
  "edgesVerified": 2,
126
131
  "edgesNeedsObservation": 1
@@ -43,7 +43,9 @@ The orchestrator dispatches your task with:
43
43
  - **observeSnapshot** (optional): Pre-captured `taphound observe --json`
44
44
  result. Use it directly; do NOT call `taphound observe` when provided.
45
45
  - **output** (optional): Brief output path, defaults to
46
- `.taphound/journeys/taphound-journey-brief.md` (relative to project).
46
+ `.taphound/briefs/<caseId>/taphound-journey-brief.md` (relative to
47
+ project). It must stay under `.taphound/briefs/` or
48
+ `.taphound/suites/<suite-id>/briefs/`; refuse any other location.
47
49
 
48
50
  ## Output
49
51
 
@@ -31,7 +31,7 @@ generate`/`refresh`/`rehash`/`validate`/`status`/`list`、`taphound observe`。
31
31
  | contextPaths | 否 | 显式文档路径数组,只读这些 |
32
32
  | contextOnly | 否 | 为 `true` 时只运行 Context 生命周期(Phase 0),返回 Context 摘要 JSON,不写 Brief |
33
33
  | observeSnapshot | 否 | 预采集的 `taphound observe --json` 结果;提供则直接用,不再调 observe |
34
- | output | 否 | Brief 输出路径,默认 `.taphound/journeys/taphound-journey-brief.md` |
34
+ | output | 否 | Brief 输出路径,默认 `.taphound/briefs/<caseId>/taphound-journey-brief.md`;只能位于 `.taphound/briefs/` 或 `.taphound/suites/<suite-id>/briefs/` 下,拒绝其他位置 |
35
35
 
36
36
  ## 输出
37
37
 
@@ -86,7 +86,8 @@ risk confirmation, recovery, or final Replay rules.
86
86
 
87
87
  `journeyBrief` is the Skill-level handoff for one Journey Case. When present,
88
88
  it carries `{path, sha256}` pointing to a project-relative
89
- `taphound-journey-brief.md`. Bind the same path into Core with
89
+ `taphound-journey-brief.md` under `.taphound/briefs/` or
90
+ `.taphound/suites/<suite-id>/briefs/`. Bind the same path into Core with
90
91
  `generation start --brief <path>`: Core reads the file itself, computes the
91
92
  SHA-256 (never trust an agent-supplied hash), and persists `sourceBrief` in
92
93
  the session and the exported meta sidecar, so `journey check` reports
@@ -264,12 +265,18 @@ them with `MANUAL_STEP_REQUIRED`.
264
265
  node <skill>/scripts/envelope.mjs bind \
265
266
  --input <draft-envelope-path> \
266
267
  --from <previous-observe-or-step-output-path> \
267
- --out <envelope-path>
268
+ --out <envelope-path> \
269
+ --project <project>
268
270
  ```
269
271
  The draft envelope needs only `version` and `proposal` (binding may be
270
272
  omitted or stale); `bind` fills `proposal.binding` from the preceding
271
273
  observe output, step output, or raw binding, adds `snapshotRef` when
272
- absent, and validates the result offline. The helper contract:
274
+ absent, and validates the result offline. It also compares
275
+ `proposal.activity.before` with the bound snapshot's Activity (read
276
+ from `snapshotRef` under `--project`) and fails with
277
+ `ENVELOPE_ACTIVITY_MISMATCH`, naming the snapshot Activity, before any
278
+ device work. `activityCheck: "unverified"` in its output means no
279
+ snapshot was readable; Core still enforces the check on `step`. The helper contract:
273
280
  `node <skill>/scripts/envelope.mjs help`. The resulting shape:
274
281
  ```json
275
282
  {
@@ -375,10 +382,12 @@ the index with `taphound knowledge rehash --project <project> --json` and
375
382
  taphound journey check \
376
383
  --project <project> \
377
384
  --context .taphound/context/project-context.json \
385
+ --journey .taphound/journeys/<name>.json \
378
386
  --json
379
387
  ```
380
- The newly published Journey must classify as `fresh`. `journey check`
381
- audits every committed Journey under `.taphound/journeys` by comparing
388
+ The newly published Journey must classify as `fresh`. `--journey`
389
+ limits the audit to that Journey; without it `journey check` audits every
390
+ committed Journey under `.taphound/journeys` by comparing
382
391
  its sidecar bindings (project, config, and `contextSelection` module
383
392
  hashes, plus the Brief content hash when `sourceBrief` is bound) against
384
393
  the live project. `--strict` exits `1` when any Journey
@@ -488,6 +497,13 @@ the index with `taphound knowledge rehash --project <project> --json` and
488
497
  session's selected module shards, stop and report a Context coverage gap.
489
498
  Do not add modules after start because `contextSelection` is bound to the
490
499
  authoritative session.
500
+ - When TapHound itself misbehaves (a crash, a result that contradicts the
501
+ device, or unexplained slowness), run
502
+ `taphound diagnose export --project <project>` and give the user the printed
503
+ bundle path to attach to their report. The bundle is redacted; do not add
504
+ paths, screenshots, or log excerpts to the report yourself.
505
+ - `UI_SNAPSHOT_FAILED` exits `3`: it is an environment failure, not
506
+ evidence about the app. Rerun before diagnosing the Journey.
491
507
  - Repeated `UI_SNAPSHOT_FAILED` ("UIAutomator dump failed") on a slow or
492
508
  busy device usually means the dump deadline is too tight, not that the
493
509
  device is broken. Raise `ui.snapshotTimeoutMs` in `.taphound/config.json`
@@ -11,7 +11,9 @@ Use this prompt only when the caller supplied `journeyBrief`.
11
11
  ## Validation
12
12
 
13
13
  1. Resolve the path beneath the project root without following a symlink
14
- outside it. Its basename must be `taphound-journey-brief.md`.
14
+ outside it. Its basename must be `taphound-journey-brief.md`, and it must
15
+ live under `.taphound/briefs/` or `.taphound/suites/<suite-id>/briefs/`
16
+ (Core rejects other locations with `BRIEF_INVALID`).
15
17
  2. Compute SHA-256 over the exact file bytes and compare it with the binding.
16
18
  3. Require YAML frontmatter values:
17
19
  - `schemaVersion: 2`
@@ -102,6 +102,15 @@ the observe result).
102
102
  varying middle can identify the event, use `match: "regex"` and anchor
103
103
  the stable words instead of the varying text.
104
104
  - `activity`: a specific Activity should be foregrounded.
105
+ - **Screens that load asynchronously**: idle detection only proves the
106
+ layout tree stopped changing. A full-screen spinner or skeleton is a
107
+ static tree, so the post-action snapshot can be the loading state. When
108
+ an action opens a screen that loads, prefer an `element` expect on a
109
+ control of the loaded screen: Core polls until it appears and returns
110
+ that settled layout as the next snapshot. When the step needs a `logcat`
111
+ expect instead, follow it with a `wait` step whose `element` expect names
112
+ the loaded screen's control, rather than proposing the next action
113
+ against a loading snapshot.
105
114
  - Do not add expectations you cannot verify from source code or Context.
106
115
  - Do not invent log patterns that don't exist in the source.
107
116
 
@@ -9,7 +9,7 @@
9
9
  // schema changes, update this script and its test together.
10
10
  import process from "node:process";
11
11
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
12
- import { dirname, resolve } from "node:path";
12
+ import { dirname, join, resolve } from "node:path";
13
13
 
14
14
  const sha256Pattern = /^[a-f\d]{64}$/;
15
15
  const generationIdPattern = /^[A-Za-z\d](?:[A-Za-z\d._-]*[A-Za-z\d])?$/;
@@ -370,6 +370,14 @@ function validateEnvelope(envelope) {
370
370
 
371
371
  // A bind source is one observe output, one step output, or a raw binding
372
372
  // object. The binding fields are copied verbatim; nothing is invented.
373
+ const BIND_SOURCE_HINT = "bind --from expects the unmodified stdout of "
374
+ + "`taphound generation observe --json` (status \"observed\" with "
375
+ + "generationId, baseRevision, snapshotHash, snapshotRef) or of a succeeded "
376
+ + "`taphound generation step --json` (status \"succeeded\" with nextBinding "
377
+ + "and nextSnapshotRef); save it with `> file` instead of assembling a "
378
+ + "subset. A bare binding {generationId, baseRevision, snapshotHash} is also "
379
+ + "accepted";
380
+
373
381
  function readBindingFromSource(source) {
374
382
  if (!isPlainObject(source)) {
375
383
  fail("ENVELOPE_INVALID", "bind source must be a JSON object");
@@ -448,7 +456,36 @@ async function readJsonFile(path, label) {
448
456
  }
449
457
  }
450
458
 
451
- async function bind(inputPath, fromPath, outPath) {
459
+ // The Activity the bound snapshot was captured on, taken from the bind source
460
+ // (full observe/step output), the inline envelope snapshot, or the
461
+ // Store-owned snapshot file under the project root. Undefined when none of
462
+ // them is readable; Core still enforces the check on `generation step`.
463
+ async function boundSnapshotActivity(source, envelope, snapshotRef, projectRoot) {
464
+ const inline = [
465
+ ["bind source snapshot", source.snapshot],
466
+ ["bind source nextSnapshot", source.nextSnapshot],
467
+ ["envelope.snapshot", envelope.snapshot]
468
+ ];
469
+ for (const [from, snapshot] of inline) {
470
+ if (isPlainObject(snapshot) && typeof snapshot.activity === "string") {
471
+ return { activity: snapshot.activity, from };
472
+ }
473
+ }
474
+ const ref = envelope.snapshotRef ?? snapshotRef;
475
+ if (typeof ref !== "string" || !snapshotRefPattern.test(ref)) {
476
+ return undefined;
477
+ }
478
+ try {
479
+ const snapshot = JSON.parse(await readFile(join(projectRoot, ref), "utf8"));
480
+ return isPlainObject(snapshot) && typeof snapshot.activity === "string"
481
+ ? { activity: snapshot.activity, from: ref }
482
+ : undefined;
483
+ } catch {
484
+ return undefined;
485
+ }
486
+ }
487
+
488
+ async function bind(inputPath, fromPath, outPath, projectRoot) {
452
489
  const envelope = await readJsonFile(inputPath, "envelope input");
453
490
  exactKeys(
454
491
  "envelope input",
@@ -460,7 +497,16 @@ async function bind(inputPath, fromPath, outPath) {
460
497
  fail("ENVELOPE_INVALID", "envelope.version must be 1");
461
498
  }
462
499
  const source = await readJsonFile(fromPath, "bind source");
463
- const { binding, snapshotRef } = readBindingFromSource(source);
500
+ let bindingSource;
501
+ try {
502
+ bindingSource = readBindingFromSource(source);
503
+ } catch (error) {
504
+ if (error?.code === "ENVELOPE_INVALID") {
505
+ fail("ENVELOPE_INVALID", `${error.message}. ${BIND_SOURCE_HINT}`);
506
+ }
507
+ throw error;
508
+ }
509
+ const { binding, snapshotRef } = bindingSource;
464
510
  const proposal = isPlainObject(envelope.proposal)
465
511
  ? { ...envelope.proposal, binding }
466
512
  : undefined;
@@ -477,6 +523,23 @@ async function bind(inputPath, fromPath, outPath) {
477
523
  : { snapshot: envelope.snapshot })
478
524
  };
479
525
  validateEnvelope(bound);
526
+ const snapshotActivity = await boundSnapshotActivity(
527
+ source,
528
+ bound,
529
+ snapshotRef,
530
+ projectRoot
531
+ );
532
+ const before = proposal.activity?.before;
533
+ if (snapshotActivity !== undefined && before !== snapshotActivity.activity) {
534
+ fail(
535
+ "ENVELOPE_ACTIVITY_MISMATCH",
536
+ `proposal.activity.before ${String(before)} does not match the bound snapshot Activity ${
537
+ snapshotActivity.activity
538
+ } (from ${snapshotActivity.from}); set activity.before to ${
539
+ snapshotActivity.activity
540
+ }`
541
+ );
542
+ }
480
543
  if (outPath !== undefined) {
481
544
  const absolute = resolve(outPath);
482
545
  await mkdir(dirname(absolute), { recursive: true });
@@ -488,7 +551,8 @@ async function bind(inputPath, fromPath, outPath) {
488
551
  exitCode: 0,
489
552
  path: outPath,
490
553
  binding,
491
- ...(snapshotRef === undefined ? {} : { snapshotRef })
554
+ ...(snapshotRef === undefined ? {} : { snapshotRef }),
555
+ activityCheck: snapshotActivity === undefined ? "unverified" : "matched"
492
556
  })}\n`);
493
557
  return;
494
558
  }
@@ -515,16 +579,22 @@ function help() {
515
579
  "Commands:",
516
580
  " validate --input <envelope.json>",
517
581
  " Validate one generation step envelope offline (no device, no session).",
518
- " bind --input <envelope.json> --from <observe-or-step-output.json> [--out <path>]",
582
+ " bind --input <envelope.json> --from <observe-or-step-output.json> [--out <path>] [--project <root>]",
519
583
  " Fill proposal.binding (and snapshotRef when absent) from the preceding",
520
584
  " observe output, step output, or raw binding, then validate.",
585
+ " It also checks proposal.activity.before against the bound snapshot's",
586
+ " Activity (from the full output, the inline snapshot, or the snapshot",
587
+ " file under --project, default the current directory) and fails with",
588
+ " ENVELOPE_ACTIVITY_MISMATCH naming the snapshot Activity. With --out,",
589
+ " activityCheck reports \"matched\" or \"unverified\" (no readable snapshot).",
521
590
  "",
522
591
  "bind writes the bound envelope to --out; without --out the bound envelope",
523
592
  "itself is the single stdout JSON value.",
524
593
  "",
525
- "Revision rule: each accepted step advances the session revision by 2 and",
526
- "each observe by 1, so one observe-then-step cycle needs baseRevision + 3.",
527
- "Prefer binding from the latest output instead of computing revisions."
594
+ "Revision rule: observe advances the session revision by 1. A succeeded",
595
+ "step advances it by 2 for the step plus 1 for the post-action observation",
596
+ "behind nextBinding, so consecutive steps are 3 apart. Never compute",
597
+ "revisions: bind from the latest observe or step output."
528
598
  ].join("\n");
529
599
  }
530
600
 
@@ -541,11 +611,11 @@ async function main() {
541
611
  return;
542
612
  }
543
613
  if (command === "bind") {
544
- const input = options(argv, ["--input", "--from", "--out"]);
614
+ const input = options(argv, ["--input", "--from", "--out", "--project"]);
545
615
  if (input.input === undefined || input.from === undefined) {
546
616
  fail("ENVELOPE_USAGE", "bind requires --input and --from");
547
617
  }
548
- await bind(input.input, input.from, input.out);
618
+ await bind(input.input, input.from, input.out, resolve(input.project ?? "."));
549
619
  return;
550
620
  }
551
621
  fail("ENVELOPE_USAGE", `Unknown command ${command}`);
@@ -256,13 +256,16 @@ supported semantic element or structured event. Such a Case stays `PAUSED`,
256
256
  never silently weaker.
257
257
 
258
258
  The helper also requires a machine-generated process receipt because a report
259
- file alone cannot establish an independent CLI invocation:
259
+ file alone cannot establish an independent CLI invocation. `taphound verify
260
+ --journey` writes it as `receipt.json` beside the published `report.json` and
261
+ prints its path as `receiptPath` in the `--json` output:
260
262
 
261
263
  ```json
262
264
  {
263
265
  "version": 1,
264
266
  "argv": [
265
267
  "verify", "--project", "/absolute/project",
268
+ "--config", "/absolute/project/.taphound/config.json",
266
269
  "--journey", "/absolute/project/.taphound/journeys/forward.json",
267
270
  "--device", "emulator-5554", "--policy-from-meta", "--json"
268
271
  ],
@@ -273,9 +276,13 @@ file alone cannot establish an independent CLI invocation:
273
276
  }
274
277
  ```
275
278
 
276
- The workflow runner must capture actual argv, exit status, and resulting
277
- digests. An agent must not write a success receipt merely because a report
278
- exists.
279
+ `argv` is the normalized invocation TapHound ran: absolute project, config,
280
+ and Journey paths plus the selected device serial. Pass the `receiptPath`
281
+ that `verify` printed; never write or edit a receipt by hand. A missing
282
+ `receiptPath` (the receipt could not be written, reported on stderr) means the
283
+ evidence is unavailable, so rerun `verify`. Pass absolute, symlink-free
284
+ `--project` and `--journey` paths so the recorded paths match the helper's
285
+ checks.
279
286
 
280
287
  **A, historical worktree:**
281
288
 
@@ -288,7 +295,8 @@ exists.
288
295
  --device <serial> --policy-from-meta --json
289
296
  ```
290
297
 
291
- 2. Record its process receipt. Prepare with a private input:
298
+ 2. Take `reportPath` and `receiptPath` from that `verify --json` output.
299
+ Prepare with a private input:
292
300
 
293
301
  ```json
294
302
  {
@@ -306,7 +314,7 @@ exists.
306
314
  "journeyPath": "/absolute/old-project/.taphound/journeys/forward.json",
307
315
  "metaPath": "/absolute/old-project/.taphound/journeys/forward.meta.json",
308
316
  "reportPath": "/absolute/old-project/.taphound/build/runs/<run>/report.json",
309
- "receiptPath": "/absolute/old-project/.taphound/build/workflows/<case>/receipt.json"
317
+ "receiptPath": "/absolute/old-project/.taphound/build/runs/<run>/receipt.json"
310
318
  }
311
319
  }
312
320
  ```
@@ -332,7 +340,7 @@ exists.
332
340
  goal/scenario against B's current Project Context and UI, but copy every
333
341
  observable exactly into its deterministic expectations. Finalize it, then
334
342
  launch a separate strict `verify` process on the installed refactored APK
335
- and record the same receipt shape.
343
+ and keep the `reportPath` and `receiptPath` it prints.
336
344
  3. Compare only after that independent Replay:
337
345
 
338
346
  ```
@@ -449,7 +449,7 @@ async function compare(handoff, projectPath, journeyPath, reportPath, receiptPat
449
449
  await checkReceipt(receiptFile, project, journeyFile, reportFile,
450
450
  data.manifest.base.device, {
451
451
  journey: journeyHash(journey), report: await fileHash(reportFile)
452
- }, report.status === "failed" ? 4 : 0);
452
+ }, report.status === "failed" ? 1 : 0);
453
453
  const covered = checkReplay(project, data.manifest.base.device,
454
454
  data.caseData, journey, meta, report, reportFile);
455
455
  if (report.runId === data.manifest.base.runId) {
@@ -15,6 +15,12 @@ export interface AppiumHttpClient {
15
15
  export interface AppiumProviderOptions {
16
16
  endpoint?: string | undefined;
17
17
  mapTestTagToResourceId?: boolean | undefined;
18
+ /** Called after each attempt to recreate a degraded session. */
19
+ onSessionRecovery?: ((succeeded: boolean) => void) | undefined;
20
+ }
21
+ export declare class AppiumHttpError extends Error {
22
+ readonly status: number;
23
+ constructor(status: number);
18
24
  }
19
25
  export declare class FetchAppiumHttpClient implements AppiumHttpClient {
20
26
  private readonly endpoint;
@@ -28,7 +34,9 @@ export declare class AppiumUiSnapshotProviderFactory implements UiSnapshotProvid
28
34
  private readonly endpoint;
29
35
  private readonly http;
30
36
  private readonly settings;
37
+ private readonly onSessionRecovery;
31
38
  constructor(runner: ProcessRunner, http?: AppiumHttpClient, options?: AppiumProviderOptions);
32
39
  probe(timeoutMs?: number): Promise<boolean>;
33
40
  open(options: OpenUiSnapshotProviderOptions): Promise<UiSnapshotProvider>;
41
+ private createSession;
34
42
  }