mandrel 2.31.0 → 2.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +13 -17
- package/.agents/agents/acceptance-critic.md +1 -2
- package/.agents/docs/SDLC.md +4 -4
- package/.agents/docs/agentrc-reference.json +61 -57
- package/.agents/docs/configuration.md +274 -227
- package/.agents/docs/execution-reference.md +13 -14
- package/.agents/docs/quality-gates.md +195 -23
- package/.agents/instructions.md +2 -5
- package/.agents/rules/git-conventions-reference.md +27 -27
- package/.agents/rules/git-conventions.md +4 -2
- package/.agents/rules/known-tooling-behavior.md +66 -30
- package/.agents/rules/testing-standards.md +35 -71
- package/.agents/runtime-deps.json +0 -1
- package/.agents/schemas/agentrc.schema.json +1939 -1400
- package/.agents/schemas/lifecycle/README.md +21 -14
- package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
- package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
- package/.agents/scripts/README.md +7 -29
- package/.agents/scripts/apply-quality-bootstrap.js +27 -34
- package/.agents/scripts/bootstrap.js +28 -26
- package/.agents/scripts/check-baseline-drift.js +73 -13
- package/.agents/scripts/check-baseline-scope.js +362 -0
- package/.agents/scripts/check-dead-exports.js +9 -1
- package/.agents/scripts/check-gherkin-corpus.js +508 -0
- package/.agents/scripts/check-knip-entries.js +136 -0
- package/.agents/scripts/check-lifecycle-lint.js +36 -112
- package/.agents/scripts/check-schema-references.js +1 -1
- package/.agents/scripts/diagnose-friction.js +7 -4
- package/.agents/scripts/generate-config-docs.js +263 -171
- package/.agents/scripts/install-matrix-assert.js +0 -1
- package/.agents/scripts/lib/ITicketingProvider.js +0 -58
- package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
- package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
- package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +0 -1
- package/.agents/scripts/lib/baselines/envelope.js +41 -60
- package/.agents/scripts/lib/baselines/git-base.js +30 -37
- package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
- package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
- package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
- package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
- package/.agents/scripts/lib/bdd-step-index.js +326 -0
- package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
- package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
- package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
- package/.agents/scripts/lib/checks/loop-health.js +9 -37
- package/.agents/scripts/lib/child-exec.js +193 -0
- package/.agents/scripts/lib/cli/standard-args.js +1 -1
- package/.agents/scripts/lib/cli-args.js +64 -0
- package/.agents/scripts/lib/close-validation/gates.js +2 -2
- package/.agents/scripts/lib/close-validation/runner.js +3 -3
- package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
- package/.agents/scripts/lib/config/commands.js +3 -5
- package/.agents/scripts/lib/config/explain.js +5 -7
- package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
- package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
- package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
- package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
- package/.agents/scripts/lib/config/gates/index.js +5 -2
- package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
- package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
- package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
- package/.agents/scripts/lib/config/gates/shared.js +58 -9
- package/.agents/scripts/lib/config/github.js +0 -1
- package/.agents/scripts/lib/config/limits.js +3 -48
- package/.agents/scripts/lib/config/qa.js +105 -0
- package/.agents/scripts/lib/config/temp-paths.js +6 -5
- package/.agents/scripts/lib/config-settings-schema-delivery.js +237 -56
- package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
- package/.agents/scripts/lib/config-settings-schema.js +386 -39
- package/.agents/scripts/lib/crap-baseline-join.js +126 -9
- package/.agents/scripts/lib/crap-utils.js +84 -520
- package/.agents/scripts/lib/dead-exports-knip.js +79 -10
- package/.agents/scripts/lib/degraded-mode.js +2 -2
- package/.agents/scripts/lib/doc-tiers.js +3 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
- package/.agents/scripts/lib/fs-walk.js +52 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
- package/.agents/scripts/lib/git-utils.js +16 -36
- package/.agents/scripts/lib/knip-entry-sync.js +469 -0
- package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
- package/.agents/scripts/lib/observability/signal-validator.js +5 -85
- package/.agents/scripts/lib/observability/signals-writer.js +19 -62
- package/.agents/scripts/lib/observability/source-classifier.js +5 -7
- package/.agents/scripts/lib/observability/terse-result.js +3 -3
- package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
- package/.agents/scripts/lib/orchestration/code-review.js +2 -2
- package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
- package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
- package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +6 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +0 -1
- package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
- package/.agents/scripts/lib/orchestration/resolve-stories.js +5 -15
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
- package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +59 -25
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
- package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
- package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
- package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
- package/.agents/scripts/lib/signals/detectors/common.js +1 -1
- package/.agents/scripts/lib/signals/index.js +8 -6
- package/.agents/scripts/lib/signals/schema.js +20 -25
- package/.agents/scripts/lib/signals/write.js +8 -8
- package/.agents/scripts/lib/story-body/story-body.js +12 -59
- package/.agents/scripts/lib/temp-retention.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
- package/.agents/scripts/lib/ticket-body-sections.js +4 -5
- package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
- package/.agents/scripts/lib/worktree-manager.js +2 -3
- package/.agents/scripts/lint-label-vocabulary.js +2 -24
- package/.agents/scripts/pr-watch-with-update.js +7 -5
- package/.agents/scripts/providers/github/cache.js +2 -2
- package/.agents/scripts/providers/github/comments.js +6 -28
- package/.agents/scripts/providers/github/compose.js +0 -15
- package/.agents/scripts/providers/github/errors.js +10 -27
- package/.agents/scripts/providers/github/request-helpers.js +1 -2
- package/.agents/scripts/providers/github/sub-issues.js +10 -218
- package/.agents/scripts/providers/github.js +4 -7
- package/.agents/scripts/prune-baseline-orphans.js +181 -0
- package/.agents/scripts/resolve-stories.js +0 -2
- package/.agents/scripts/run-lint.js +61 -61
- package/.agents/scripts/run-test-profile.js +6 -6
- package/.agents/scripts/run-verify.js +48 -30
- package/.agents/scripts/single-story-close.js +20 -0
- package/.agents/scripts/single-story-init.js +12 -35
- package/.agents/scripts/update-dead-exports-baseline.js +321 -0
- package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
- package/.agents/skills/skills.index.json +1 -11
- package/.agents/workflows/audit-documentation.md +5 -6
- package/.agents/workflows/audit-to-stories.md +2 -2
- package/.agents/workflows/helpers/audit-lens-core.md +11 -12
- package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
- package/.agents/workflows/helpers/code-review.md +3 -8
- package/.agents/workflows/helpers/deliver-reference.md +2 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +27 -16
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
- package/.agents/workflows/mandrel-update.md +10 -10
- package/.agents/workflows/qa-assist.md +15 -20
- package/.agents/workflows/qa-explore.md +9 -8
- package/README.md +1 -1
- package/docs/CHANGELOG.md +42 -0
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
- package/package.json +12 -3
- package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
- package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
- package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
- package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
- package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
- package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
- package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
- package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
- package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
- package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
- package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
- package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
- package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
- package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
- package/.agents/scripts/check-gherkin-placeholders.js +0 -663
- package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
- package/.agents/scripts/lib/audit-suite/cli.js +0 -64
- package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
- package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
- package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
- package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
- package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
- package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
- package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
- package/.agents/scripts/lib/crap-baseline-index.js +0 -46
- package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
- package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
- package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
- package/.agents/scripts/lib/mutation/config-detector.js +0 -119
- package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
- package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
- package/.agents/scripts/lib/observability/active-story-env.js +0 -170
- package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
- package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
- package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
- package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
- package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
- package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
- package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
- package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
- package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
- package/.agents/scripts/lib/planning-corpus.js +0 -37
- package/.agents/scripts/lib/qa/coverage-report.js +0 -181
- package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
- package/.agents/scripts/lib/signals/detectors/index.js +0 -14
- package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
- package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
- package/.agents/scripts/lib/signals/read.js +0 -268
- package/.agents/scripts/lib/signals/span-tree.js +0 -291
- package/.agents/scripts/lib/story-lifecycle.js +0 -194
- package/.agents/scripts/lib/story-plan.js +0 -379
- package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
- package/.agents/scripts/lib/util/phase-timer.js +0 -163
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
- package/.agents/scripts/lint-baseline.js +0 -507
- package/.agents/scripts/providers/github/prs.js +0 -103
- package/.agents/scripts/signals-view.js +0 -309
- package/.agents/scripts/story-plan.js +0 -370
- package/.agents/scripts/sync-branch-from-base.js +0 -149
- package/.agents/scripts/validate-docs-freshness.js +0 -314
- package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
- package/.agents/workflows/helpers/signals.md +0 -112
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Retry detector — pure module (Epic #1721 / Story #1768 / Task #1773).
|
|
3
|
-
*
|
|
4
|
-
* Scans a `traces.ndjson` file for repeated **failed** Bash invocations
|
|
5
|
-
* keyed by a stable command identity, and emits one `kind: 'retry'`
|
|
6
|
-
* SignalEvent per identity whose failure count strictly exceeds the
|
|
7
|
-
* configured threshold. Pure: takes a file path in, returns events out.
|
|
8
|
-
* Emission to disk is the caller's job.
|
|
9
|
-
*
|
|
10
|
-
* ## Identity rule
|
|
11
|
-
*
|
|
12
|
-
* The detector groups failed Bash trace records by:
|
|
13
|
-
*
|
|
14
|
-
* 1. `details.normalizedHash` when present (canonical form — collapses
|
|
15
|
-
* whitespace, strips benign flags like `--no-color` / `--quiet`,
|
|
16
|
-
* and treats `npm test` ≡ `npm run test`). Set by
|
|
17
|
-
* `lib/observability/tool-trace-hook.js` (Story #1768 / Task #1775).
|
|
18
|
-
* 2. `details.targetHash` as the fallback when `normalizedHash` is
|
|
19
|
-
* absent (e.g. legacy traces, or a Bash event whose command was
|
|
20
|
-
* empty / non-string and the normaliser declined to emit a hash).
|
|
21
|
-
*
|
|
22
|
-
* Records that have neither hash are skipped — without a stable key we
|
|
23
|
-
* cannot group repeats. The grouping is by the chosen identity hash, so
|
|
24
|
-
* `npm test` and `npm run test` (different `targetHash`, identical
|
|
25
|
-
* `normalizedHash`) collapse into one bucket.
|
|
26
|
-
*
|
|
27
|
-
* ## Failure rule
|
|
28
|
-
*
|
|
29
|
-
* A trace record is treated as **failed** when its `details.exitCode`
|
|
30
|
-
* is a number and not `0`. As of Epic #4406 / Story #4413 the tool-trace
|
|
31
|
-
* hook captures `details.exitCode` for Bash `PostToolUse` events, so this
|
|
32
|
-
* detector fires on real deliveries; records without an `exitCode` field
|
|
33
|
-
* (non-Bash tools, or tools that report no exit code) are ignored, which
|
|
34
|
-
* matches the decision in the parent Epic body that retry only counts
|
|
35
|
-
* non-zero-exit commands.
|
|
36
|
-
*
|
|
37
|
-
* Successful runs after failures **do not** cancel the count — failure-
|
|
38
|
-
* count is monotonic per identity. This matches the Epic's intent: once
|
|
39
|
-
* a command has failed N times, the friction signal has happened, even
|
|
40
|
-
* if a later attempt succeeded. The detector is observational, not a
|
|
41
|
-
* status check.
|
|
42
|
-
*
|
|
43
|
-
* ## Tool filter
|
|
44
|
-
*
|
|
45
|
-
* Only trace records whose `emitter.tool === 'Bash'` participate. Edit /
|
|
46
|
-
* Write / Read / Grep / Glob events are not retries — those belong to
|
|
47
|
-
* other detectors (rework for file-edit churn). The tool name is read
|
|
48
|
-
* from `emitter.tool` first and falls back to `details.tool` (see
|
|
49
|
-
* `common.extractTool`).
|
|
50
|
-
*
|
|
51
|
-
* ## Privacy contract
|
|
52
|
-
*
|
|
53
|
-
* Identity hashes are sha256 strings produced by the hook before any
|
|
54
|
-
* raw value reaches disk (see `lib/observability/tool-trace-hook.js`
|
|
55
|
-
* `hashTarget`). The detector never sees plaintext commands and never
|
|
56
|
-
* reverses a hash. Emitted signals carry `details.commandHash` — the
|
|
57
|
-
* same `sha256:<hex>` string that drove the grouping — so downstream
|
|
58
|
-
* surfaces can dedupe across a Story without ever resolving back to a
|
|
59
|
-
* raw command string.
|
|
60
|
-
*
|
|
61
|
-
* ## Threshold semantics
|
|
62
|
-
*
|
|
63
|
-
* `failureCount > threshold` (strictly greater than). An identity with
|
|
64
|
-
* exactly `threshold` failures does NOT emit. This matches the
|
|
65
|
-
* `delivery.signals.retry.repeatCount` config surface (Epic #1720) —
|
|
66
|
-
* the configured value is the maximum tolerated repeat count, not the
|
|
67
|
-
* trigger count.
|
|
68
|
-
*
|
|
69
|
-
* ## Robustness
|
|
70
|
-
*
|
|
71
|
-
* - Missing `tracesPath` file → returns `[]`. Never throws.
|
|
72
|
-
* - Malformed JSON lines → silently skipped (consistent with
|
|
73
|
-
* `lib/signals/read.js`).
|
|
74
|
-
* - Non-trace records → ignored. The file may legitimately interleave
|
|
75
|
-
* other kinds in future.
|
|
76
|
-
* - Non-Bash trace records → ignored.
|
|
77
|
-
*
|
|
78
|
-
* @module lib/signals/detectors/retry
|
|
79
|
-
*/
|
|
80
|
-
|
|
81
|
-
import { createReadStream } from 'node:fs';
|
|
82
|
-
import fs from 'node:fs/promises';
|
|
83
|
-
import { createInterface } from 'node:readline';
|
|
84
|
-
|
|
85
|
-
import { extractTool, validateDetectorArgs } from './common.js';
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Documented argv-normalisation rules — emitted verbatim onto every
|
|
89
|
-
* retry signal so the downstream renderer can show the operator which
|
|
90
|
-
* paraphrases collapsed. Kept as a frozen array so callers cannot
|
|
91
|
-
* mutate the canonical list.
|
|
92
|
-
*
|
|
93
|
-
* Mirrors the rule list in `lib/observability/tool-trace-hook.js`
|
|
94
|
-
* `normaliseBashCommand` — keep these in sync when extending the
|
|
95
|
-
* normaliser.
|
|
96
|
-
*
|
|
97
|
-
* @type {ReadonlyArray<string>}
|
|
98
|
-
*/
|
|
99
|
-
export const NORMALIZATION_RULES = Object.freeze([
|
|
100
|
-
'collapse-whitespace',
|
|
101
|
-
'strip-benign-flags:--no-color,--quiet',
|
|
102
|
-
'npm-test-equiv-npm-run-test',
|
|
103
|
-
]);
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Resolve the identity key used to group a Bash trace record. Prefers
|
|
107
|
-
* `details.normalizedHash` (canonical form, collapses paraphrases),
|
|
108
|
-
* falls back to `details.targetHash` (raw-command hash). Returns `null`
|
|
109
|
-
* when neither is present — the caller skips those records.
|
|
110
|
-
*
|
|
111
|
-
* @param {object} rec
|
|
112
|
-
* @returns {string|null}
|
|
113
|
-
*/
|
|
114
|
-
function resolveIdentity(rec) {
|
|
115
|
-
const normalized = rec?.details?.normalizedHash;
|
|
116
|
-
if (typeof normalized === 'string' && normalized.length > 0) {
|
|
117
|
-
return normalized;
|
|
118
|
-
}
|
|
119
|
-
const target = rec?.details?.targetHash;
|
|
120
|
-
if (typeof target === 'string' && target.length > 0) {
|
|
121
|
-
return target;
|
|
122
|
-
}
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Decide whether a trace record represents a failed invocation. As of
|
|
128
|
-
* Epic #4406 / Story #4413 the tool-trace hook records `details.exitCode`
|
|
129
|
-
* for Bash `PostToolUse` events, so the field is present on real Bash
|
|
130
|
-
* traces (and still set directly by tests). A record counts as failed
|
|
131
|
-
* when `details.exitCode` is a number and not zero. Anything else
|
|
132
|
-
* (missing field, null, non-number, zero) is NOT a failure and is ignored
|
|
133
|
-
* entirely — the detector only counts non-zero-exit commands per the
|
|
134
|
-
* parent Epic.
|
|
135
|
-
*
|
|
136
|
-
* @param {object} rec
|
|
137
|
-
* @returns {boolean}
|
|
138
|
-
*/
|
|
139
|
-
function isFailedBash(rec) {
|
|
140
|
-
const code = rec?.details?.exitCode;
|
|
141
|
-
return typeof code === 'number' && code !== 0;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Stream `tracesPath` line-by-line and accumulate per-identity failure
|
|
146
|
-
* counts. Returns a `Map<identityHash, count>`. Missing file → empty map.
|
|
147
|
-
*
|
|
148
|
-
* @param {string} tracesPath
|
|
149
|
-
* @returns {Promise<Map<string, number>>}
|
|
150
|
-
*/
|
|
151
|
-
async function tallyFailuresByIdentity(tracesPath) {
|
|
152
|
-
const counts = new Map();
|
|
153
|
-
|
|
154
|
-
// Existence check before opening the stream — `createReadStream`
|
|
155
|
-
// defers ENOENT until the first read, which leaves the iterator in a
|
|
156
|
-
// bad state on some Node versions. `fs.access` short-circuits cleanly.
|
|
157
|
-
try {
|
|
158
|
-
await fs.access(tracesPath);
|
|
159
|
-
} catch {
|
|
160
|
-
return counts;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const stream = createReadStream(tracesPath, { encoding: 'utf8' });
|
|
164
|
-
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
for await (const rawLine of rl) {
|
|
168
|
-
if (rawLine.length === 0) continue;
|
|
169
|
-
let parsed;
|
|
170
|
-
try {
|
|
171
|
-
parsed = JSON.parse(rawLine);
|
|
172
|
-
} catch {
|
|
173
|
-
// Mirrors lib/signals/read.js — partial-write races are common.
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
if (parsed == null || typeof parsed !== 'object') continue;
|
|
177
|
-
if (parsed.kind !== 'trace') continue;
|
|
178
|
-
|
|
179
|
-
const tool = extractTool(parsed);
|
|
180
|
-
if (tool !== 'Bash') continue;
|
|
181
|
-
|
|
182
|
-
if (!isFailedBash(parsed)) continue;
|
|
183
|
-
|
|
184
|
-
const identity = resolveIdentity(parsed);
|
|
185
|
-
if (identity == null) continue;
|
|
186
|
-
|
|
187
|
-
counts.set(identity, (counts.get(identity) ?? 0) + 1);
|
|
188
|
-
}
|
|
189
|
-
} finally {
|
|
190
|
-
rl.close();
|
|
191
|
-
if (!stream.destroyed) stream.destroy();
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return counts;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Detect retries against `tracesPath`. Returns a `kind: 'retry'`
|
|
199
|
-
* SignalEvent for every identity whose failure count strictly exceeds
|
|
200
|
-
* `threshold`. Pure — emission to disk is the caller's responsibility.
|
|
201
|
-
*
|
|
202
|
-
* The detector takes `threshold` as a function arg and never reaches
|
|
203
|
-
* for `getSignals(config).retry.repeatCount`. Resolving the threshold
|
|
204
|
-
* from config lives at the call site so this module stays import-graph-
|
|
205
|
-
* free of `lib/config/*`.
|
|
206
|
-
*
|
|
207
|
-
* @param {object} args
|
|
208
|
-
* @param {string} args.tracesPath — absolute path to a `traces.ndjson`.
|
|
209
|
-
* @param {number} args.epicId — positive integer Epic ID.
|
|
210
|
-
* @param {number} args.storyId — positive integer Story ID.
|
|
211
|
-
* @param {number|null} [args.taskId] — positive integer Task ID, or null.
|
|
212
|
-
* @param {number} args.threshold — the maximum tolerated failure count;
|
|
213
|
-
* identities with `failureCount > threshold` emit. MUST be a
|
|
214
|
-
* non-negative integer.
|
|
215
|
-
* @param {() => string} [args.nowFn] — optional clock seam returning the
|
|
216
|
-
* ISO-8601 `ts` stamped onto every emitted SignalEvent. Defaults to
|
|
217
|
-
* `() => new Date().toISOString()`. Inject a fixed-return function in
|
|
218
|
-
* tests to make the emitted `ts` deterministic. MUST, when provided, be
|
|
219
|
-
* a function.
|
|
220
|
-
* @returns {Promise<object[]>} array of SignalEvent objects conforming
|
|
221
|
-
* to `.agents/schemas/signal-event.schema.json`.
|
|
222
|
-
*/
|
|
223
|
-
export async function detectRetry(args) {
|
|
224
|
-
const { tracesPath, epicId, storyId, taskId, threshold, nowFn } =
|
|
225
|
-
validateDetectorArgs(args, { fnName: 'detectRetry' });
|
|
226
|
-
|
|
227
|
-
const counts = await tallyFailuresByIdentity(tracesPath);
|
|
228
|
-
|
|
229
|
-
// Stable order: sort by identity hash ascending so the same input
|
|
230
|
-
// always yields the same emission sequence (eases snapshot tests and
|
|
231
|
-
// downstream deduping).
|
|
232
|
-
const offenders = [];
|
|
233
|
-
for (const [identity, count] of counts) {
|
|
234
|
-
if (count > threshold) offenders.push([identity, count]);
|
|
235
|
-
}
|
|
236
|
-
offenders.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
237
|
-
|
|
238
|
-
const ts = nowFn();
|
|
239
|
-
return offenders.map(([commandHash, failureCount]) => ({
|
|
240
|
-
ts,
|
|
241
|
-
kind: 'retry',
|
|
242
|
-
emitter: { tool: 'retry-detector' },
|
|
243
|
-
epicId,
|
|
244
|
-
storyId,
|
|
245
|
-
taskId,
|
|
246
|
-
details: {
|
|
247
|
-
commandHash,
|
|
248
|
-
failureCount,
|
|
249
|
-
threshold,
|
|
250
|
-
normalizationRules: [...NORMALIZATION_RULES],
|
|
251
|
-
},
|
|
252
|
-
}));
|
|
253
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Rework detector — pure module (Epic #1721 / Story #1771 / Task #1774).
|
|
3
|
-
*
|
|
4
|
-
* Scans a `traces.ndjson` file for repeated edits against the same target
|
|
5
|
-
* (keyed by hashed `details.targetHash`) and emits one `kind: 'rework'`
|
|
6
|
-
* SignalEvent per offending target whose edit count strictly exceeds the
|
|
7
|
-
* configured threshold. Pure: takes a file path in, returns events out.
|
|
8
|
-
* Emission to disk is the caller's job.
|
|
9
|
-
*
|
|
10
|
-
* ## Counting rule
|
|
11
|
-
*
|
|
12
|
-
* Only file-mutating tools are counted:
|
|
13
|
-
* - `Edit`
|
|
14
|
-
* - `Write`
|
|
15
|
-
* - `MultiEdit`
|
|
16
|
-
* - `NotebookEdit`
|
|
17
|
-
*
|
|
18
|
-
* Every other tool (Read, Bash, Grep, Glob, …) is ignored. Trace records
|
|
19
|
-
* without a `details.targetHash` are also skipped — without a stable key
|
|
20
|
-
* we cannot group repeats.
|
|
21
|
-
*
|
|
22
|
-
* ## Privacy contract
|
|
23
|
-
*
|
|
24
|
-
* Trace records key off `details.targetHash` (a sha256 of the file path,
|
|
25
|
-
* see `lib/observability/tool-trace-hook.js`). The detector groups by
|
|
26
|
-
* the hash, never the raw path, so the privacy boundary established by
|
|
27
|
-
* the hook is preserved end-to-end. A future analyzer that wants to
|
|
28
|
-
* surface the offending path must resolve the hash through a separate
|
|
29
|
-
* mapping — the detector itself never touches plaintext.
|
|
30
|
-
*
|
|
31
|
-
* ## Threshold semantics
|
|
32
|
-
*
|
|
33
|
-
* `editCount > threshold` (strictly greater than). A target with exactly
|
|
34
|
-
* `threshold` edits does NOT emit; only the first edit *past* the
|
|
35
|
-
* threshold trips the detector. This matches the
|
|
36
|
-
* `delivery.signals.rework.editsPerFile` config surface (Epic #1720) —
|
|
37
|
-
* the configured value is the maximum tolerated count, not the trigger
|
|
38
|
-
* count.
|
|
39
|
-
*
|
|
40
|
-
* ## Robustness
|
|
41
|
-
*
|
|
42
|
-
* - Missing `tracesPath` file → returns `[]`. Never throws.
|
|
43
|
-
* - Malformed JSON lines → silently skipped (consistent with the
|
|
44
|
-
* `lib/signals/read.js` reader).
|
|
45
|
-
* - Non-trace records → ignored (the file may legitimately contain
|
|
46
|
-
* other kinds in future).
|
|
47
|
-
*
|
|
48
|
-
* @module lib/signals/detectors/rework
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
import { createReadStream } from 'node:fs';
|
|
52
|
-
import fs from 'node:fs/promises';
|
|
53
|
-
import { createInterface } from 'node:readline';
|
|
54
|
-
|
|
55
|
-
import { extractTool, validateDetectorArgs } from './common.js';
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Tools that mutate files. Only these contribute to the per-target edit
|
|
59
|
-
* count. Anything outside this set is ignored.
|
|
60
|
-
*
|
|
61
|
-
* @type {ReadonlySet<string>}
|
|
62
|
-
*/
|
|
63
|
-
const FILE_MUTATING_TOOLS = Object.freeze(
|
|
64
|
-
new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Stream `tracesPath` line-by-line and accumulate per-targetHash edit
|
|
69
|
-
* counts. Returns a `Map<targetHash, count>`. Missing file → empty map.
|
|
70
|
-
*
|
|
71
|
-
* @param {string} tracesPath
|
|
72
|
-
* @returns {Promise<Map<string, number>>}
|
|
73
|
-
*/
|
|
74
|
-
async function tallyEditsByTarget(tracesPath) {
|
|
75
|
-
const counts = new Map();
|
|
76
|
-
|
|
77
|
-
// Existence check before opening the stream — `createReadStream`
|
|
78
|
-
// defers ENOENT until the first read, which leaves the iterator in a
|
|
79
|
-
// bad state on some Node versions. `fs.access` short-circuits cleanly.
|
|
80
|
-
try {
|
|
81
|
-
await fs.access(tracesPath);
|
|
82
|
-
} catch {
|
|
83
|
-
return counts;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const stream = createReadStream(tracesPath, { encoding: 'utf8' });
|
|
87
|
-
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
for await (const rawLine of rl) {
|
|
91
|
-
if (rawLine.length === 0) continue;
|
|
92
|
-
let parsed;
|
|
93
|
-
try {
|
|
94
|
-
parsed = JSON.parse(rawLine);
|
|
95
|
-
} catch {
|
|
96
|
-
// Mirrors lib/signals/read.js — malformed lines are common
|
|
97
|
-
// during a partial-write race; skip silently.
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (parsed == null || typeof parsed !== 'object') continue;
|
|
101
|
-
// We only care about trace records; the file is named
|
|
102
|
-
// traces.ndjson but a future writer may interleave other kinds.
|
|
103
|
-
if (parsed.kind !== 'trace') continue;
|
|
104
|
-
|
|
105
|
-
const tool = extractTool(parsed);
|
|
106
|
-
if (tool == null || !FILE_MUTATING_TOOLS.has(tool)) continue;
|
|
107
|
-
|
|
108
|
-
const hash = parsed.details?.targetHash;
|
|
109
|
-
if (typeof hash !== 'string' || hash.length === 0) continue;
|
|
110
|
-
|
|
111
|
-
counts.set(hash, (counts.get(hash) ?? 0) + 1);
|
|
112
|
-
}
|
|
113
|
-
} finally {
|
|
114
|
-
rl.close();
|
|
115
|
-
if (!stream.destroyed) stream.destroy();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return counts;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Detect rework against `tracesPath`. Returns a `kind: 'rework'`
|
|
123
|
-
* SignalEvent for every targetHash whose edit count strictly exceeds
|
|
124
|
-
* `threshold`. Pure — emission to disk is the caller's responsibility.
|
|
125
|
-
*
|
|
126
|
-
* @param {object} args
|
|
127
|
-
* @param {string} args.tracesPath — absolute path to a `traces.ndjson`.
|
|
128
|
-
* @param {number} args.epicId — positive integer Epic ID.
|
|
129
|
-
* @param {number} args.storyId — positive integer Story ID.
|
|
130
|
-
* @param {number|null} [args.taskId] — positive integer Task ID, or null.
|
|
131
|
-
* @param {number} args.threshold — the maximum tolerated edit count;
|
|
132
|
-
* targets with `editCount > threshold` emit. MUST be a non-negative
|
|
133
|
-
* integer.
|
|
134
|
-
* @param {() => string} [args.nowFn] — optional clock seam returning the
|
|
135
|
-
* ISO-8601 `ts` stamped onto every emitted SignalEvent. Defaults to
|
|
136
|
-
* `() => new Date().toISOString()`. Inject a fixed-return function in
|
|
137
|
-
* tests to make the emitted `ts` deterministic. MUST, when provided, be
|
|
138
|
-
* a function.
|
|
139
|
-
* @returns {Promise<object[]>} array of SignalEvent objects
|
|
140
|
-
* conforming to `.agents/schemas/signal-event.schema.json`.
|
|
141
|
-
*/
|
|
142
|
-
export async function detectRework(args) {
|
|
143
|
-
const { tracesPath, epicId, storyId, taskId, threshold, nowFn } =
|
|
144
|
-
validateDetectorArgs(args, { fnName: 'detectRework' });
|
|
145
|
-
|
|
146
|
-
const counts = await tallyEditsByTarget(tracesPath);
|
|
147
|
-
|
|
148
|
-
// Stable order: sort by targetHash ascending so the same input always
|
|
149
|
-
// yields the same emission sequence (eases snapshot testing and
|
|
150
|
-
// downstream deduping).
|
|
151
|
-
const offenders = [];
|
|
152
|
-
for (const [hash, count] of counts) {
|
|
153
|
-
if (count > threshold) offenders.push([hash, count]);
|
|
154
|
-
}
|
|
155
|
-
offenders.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
156
|
-
|
|
157
|
-
const ts = nowFn();
|
|
158
|
-
return offenders.map(([targetHash, editCount]) => ({
|
|
159
|
-
ts,
|
|
160
|
-
kind: 'rework',
|
|
161
|
-
emitter: { tool: 'rework-detector' },
|
|
162
|
-
epicId,
|
|
163
|
-
storyId,
|
|
164
|
-
taskId,
|
|
165
|
-
details: { targetHash, editCount, threshold },
|
|
166
|
-
}));
|
|
167
|
-
}
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Streaming signals reader (Epic #1181 / Story #1438 / Task #1459).
|
|
3
|
-
*
|
|
4
|
-
* Provides one async-iterator entry point — `read({ run, story?, kind?,
|
|
5
|
-
* config? })` — that consumers use instead of opening the NDJSON file
|
|
6
|
-
* themselves. The reader streams line-by-line so callers never load the
|
|
7
|
-
* full file into memory (50MB+ traces stay under file size in RSS).
|
|
8
|
-
*
|
|
9
|
-
* On-disk layout (resolved via `lib/config/temp-paths.js`):
|
|
10
|
-
*
|
|
11
|
-
* <tempRoot>/run-<run>/story-<story>/signals.ndjson
|
|
12
|
-
*
|
|
13
|
-
* When `story` is omitted, the reader fans out across every
|
|
14
|
-
* `story-<id>/signals.ndjson` under `<tempRoot>/run-<run>/`. When the
|
|
15
|
-
* run directory is missing, the iterator yields nothing (consumers
|
|
16
|
-
* treat absence as "no signals yet").
|
|
17
|
-
*
|
|
18
|
-
* ## Filter semantics
|
|
19
|
-
*
|
|
20
|
-
* - `kind` — when provided, only events whose `kind` matches the
|
|
21
|
-
* argument are yielded. Filtering happens after the per-line JSON
|
|
22
|
-
* parse + envelope guard (see `lib/signals/schema.js`).
|
|
23
|
-
* - `story` — narrows to a single Story's stream; otherwise we walk
|
|
24
|
-
* every Story directory under the run.
|
|
25
|
-
*
|
|
26
|
-
* ## Warn-once policy (AC #3)
|
|
27
|
-
*
|
|
28
|
-
* Malformed JSON lines are common during a partial write race; we
|
|
29
|
-
* warn **once per process** (module-level latch) rather than per line
|
|
30
|
-
* so a corrupted tail doesn't drown the operator log. The latch
|
|
31
|
-
* carries the first offending path/line and a count for follow-up.
|
|
32
|
-
*
|
|
33
|
-
* ## Robustness
|
|
34
|
-
*
|
|
35
|
-
* The reader never throws on a missing file, a permission error, or
|
|
36
|
-
* malformed JSON — every failure path resolves to "no more events"
|
|
37
|
-
* so the analyzer can keep walking. Errors from the supplied
|
|
38
|
-
* `tempRoot` resolver propagate (those are programmer errors, not
|
|
39
|
-
* I/O conditions).
|
|
40
|
-
*
|
|
41
|
-
* @module lib/signals/read
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
import { createReadStream } from 'node:fs';
|
|
45
|
-
import fs from 'node:fs/promises';
|
|
46
|
-
import path from 'node:path';
|
|
47
|
-
import { createInterface } from 'node:readline';
|
|
48
|
-
import {
|
|
49
|
-
runArtifactPath,
|
|
50
|
-
runTempDir,
|
|
51
|
-
signalsFile,
|
|
52
|
-
storyTempDir,
|
|
53
|
-
} from '../config/temp-paths.js';
|
|
54
|
-
import { parseStoryBranch } from '../git-utils.js';
|
|
55
|
-
import { Logger } from '../Logger.js';
|
|
56
|
-
|
|
57
|
-
import { isPositiveInt } from './detectors/common.js';
|
|
58
|
-
import { EVENT_KIND_VALUES, hasCommonEnvelope } from './schema.js';
|
|
59
|
-
|
|
60
|
-
// Module-level latch. Carries `{ path, lineNumber, totalCount }` for the
|
|
61
|
-
// first malformed line we see in this process; subsequent malformed
|
|
62
|
-
// lines bump `totalCount` without emitting. Reset is intentionally
|
|
63
|
-
// not exposed — the warn-once contract is process-wide.
|
|
64
|
-
const _malformedLatch = { fired: false, totalCount: 0 };
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Test-only helper to reset the warn-once latch. Not part of the public
|
|
68
|
-
* surface — exported so the unit test can exercise per-test isolation
|
|
69
|
-
* without spawning a new Node process.
|
|
70
|
-
*
|
|
71
|
-
* @returns {void}
|
|
72
|
-
*/
|
|
73
|
-
export function __resetMalformedLatchForTests() {
|
|
74
|
-
_malformedLatch.fired = false;
|
|
75
|
-
_malformedLatch.totalCount = 0;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Snapshot of the current warn-once state. Test-only — surfaces the
|
|
80
|
-
* count of malformed lines observed so the unit suite can assert that
|
|
81
|
-
* "10 bad lines → 1 warn but count=10".
|
|
82
|
-
*
|
|
83
|
-
* @returns {{ fired: boolean, totalCount: number }}
|
|
84
|
-
*/
|
|
85
|
-
export function __getMalformedLatchForTests() {
|
|
86
|
-
return { ..._malformedLatch };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function warnOnceMalformed(targetPath, lineNumber, parseErr) {
|
|
90
|
-
_malformedLatch.totalCount += 1;
|
|
91
|
-
if (_malformedLatch.fired) return;
|
|
92
|
-
_malformedLatch.fired = true;
|
|
93
|
-
Logger.warn(
|
|
94
|
-
`signals/read: malformed JSON encountered (first at ${targetPath}:${lineNumber}: ${
|
|
95
|
-
parseErr instanceof Error ? parseErr.message : String(parseErr)
|
|
96
|
-
}); further malformed lines suppressed for this process.`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Stream one signals.ndjson file as an async iterable of parsed records.
|
|
102
|
-
* Internal helper; consumers go through `read()`.
|
|
103
|
-
*
|
|
104
|
-
* @param {string} target — absolute on-disk path
|
|
105
|
-
* @param {string | null} kindFilter — kind to filter on (null = no filter)
|
|
106
|
-
* @returns {AsyncGenerator<object>}
|
|
107
|
-
*/
|
|
108
|
-
async function* streamFile(target, kindFilter) {
|
|
109
|
-
// Existence check before opening the stream — `createReadStream`
|
|
110
|
-
// defers ENOENT until the first `read()`, which leaves the async
|
|
111
|
-
// iterator with an uncatchable error mid-flight on some Node
|
|
112
|
-
// versions. `fs.access` is cheap and gives us a clean early return.
|
|
113
|
-
try {
|
|
114
|
-
await fs.access(target);
|
|
115
|
-
} catch {
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const stream = createReadStream(target, { encoding: 'utf8' });
|
|
120
|
-
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
121
|
-
|
|
122
|
-
let lineNumber = 0;
|
|
123
|
-
try {
|
|
124
|
-
for await (const rawLine of rl) {
|
|
125
|
-
lineNumber += 1;
|
|
126
|
-
if (rawLine.length === 0) continue;
|
|
127
|
-
let parsed;
|
|
128
|
-
try {
|
|
129
|
-
parsed = JSON.parse(rawLine);
|
|
130
|
-
} catch (err) {
|
|
131
|
-
warnOnceMalformed(target, lineNumber, err);
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
if (!hasCommonEnvelope(parsed)) continue;
|
|
135
|
-
if (kindFilter != null && parsed.kind !== kindFilter) continue;
|
|
136
|
-
yield parsed;
|
|
137
|
-
}
|
|
138
|
-
} finally {
|
|
139
|
-
rl.close();
|
|
140
|
-
// `stream.destroy()` is idempotent — `rl.close()` already pulls
|
|
141
|
-
// the underlying stream down on its own, but we drop it explicitly
|
|
142
|
-
// here to satisfy the Windows file-handle leak check.
|
|
143
|
-
if (!stream.destroyed) stream.destroy();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* List every `stories/story-<id>/signals.ndjson` path under the run's
|
|
149
|
-
* `<tempRoot>/run-<run>/` directory. Returns an empty array when the
|
|
150
|
-
* run directory is missing.
|
|
151
|
-
*
|
|
152
|
-
* The returned paths are sorted by Story ID ascending so the iterator's
|
|
153
|
-
* output is stable across runs.
|
|
154
|
-
*
|
|
155
|
-
* Story #2940 nested per-Story directories under a `stories/` segment.
|
|
156
|
-
* Run-level signals continue to live at the run root.
|
|
157
|
-
*
|
|
158
|
-
* @param {number} run
|
|
159
|
-
* @param {object | undefined} config
|
|
160
|
-
* @returns {Promise<string[]>}
|
|
161
|
-
*/
|
|
162
|
-
async function listRunStorySignalsFiles(run, config) {
|
|
163
|
-
const runDir = runTempDir(run, config);
|
|
164
|
-
let runEntries;
|
|
165
|
-
try {
|
|
166
|
-
runEntries = await fs.readdir(runDir, { withFileTypes: true });
|
|
167
|
-
} catch {
|
|
168
|
-
return [];
|
|
169
|
-
}
|
|
170
|
-
let hasRunLevelSignals = false;
|
|
171
|
-
let hasStoriesDir = false;
|
|
172
|
-
for (const ent of runEntries) {
|
|
173
|
-
if (ent.isDirectory() && ent.name === 'stories') {
|
|
174
|
-
hasStoriesDir = true;
|
|
175
|
-
} else if (ent.isFile() && ent.name === 'signals.ndjson') {
|
|
176
|
-
// Story #1430 — wave-runner lifecycle signals land here.
|
|
177
|
-
hasRunLevelSignals = true;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
const storyIds = [];
|
|
181
|
-
if (hasStoriesDir) {
|
|
182
|
-
let storyEntries;
|
|
183
|
-
try {
|
|
184
|
-
storyEntries = await fs.readdir(path.join(runDir, 'stories'), {
|
|
185
|
-
withFileTypes: true,
|
|
186
|
-
});
|
|
187
|
-
} catch {
|
|
188
|
-
storyEntries = [];
|
|
189
|
-
}
|
|
190
|
-
for (const ent of storyEntries) {
|
|
191
|
-
if (!ent.isDirectory()) continue;
|
|
192
|
-
const sid = parseStoryBranch(ent.name);
|
|
193
|
-
if (sid === null || !isPositiveInt(sid)) continue;
|
|
194
|
-
storyIds.push(sid);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
storyIds.sort((a, b) => a - b);
|
|
198
|
-
// Yield run-level signals first (wave-start precedes per-Story friction),
|
|
199
|
-
// then walk the per-Story streams in ascending ID order.
|
|
200
|
-
const targets = hasRunLevelSignals
|
|
201
|
-
? [runArtifactPath(run, 'signals.ndjson', config)]
|
|
202
|
-
: [];
|
|
203
|
-
for (const sid of storyIds) {
|
|
204
|
-
targets.push(path.join(storyTempDir(run, sid, config), 'signals.ndjson'));
|
|
205
|
-
}
|
|
206
|
-
return targets;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Stream every event matching `{ run, story?, kind? }` from the
|
|
211
|
-
* configured `tempRoot`'s `run-<run>/[story-<story>/]signals.ndjson`
|
|
212
|
-
* file(s).
|
|
213
|
-
*
|
|
214
|
-
* Returns an async iterable so callers can `for await` over it without
|
|
215
|
-
* buffering. The reader is **streaming** — peak memory stays below the
|
|
216
|
-
* file size for any input.
|
|
217
|
-
*
|
|
218
|
-
* @param {{ run: number, story?: number, kind?: string, config?: object }} args
|
|
219
|
-
* @returns {AsyncGenerator<object>}
|
|
220
|
-
*
|
|
221
|
-
* @example
|
|
222
|
-
* for await (const evt of read({ run: 1181 })) { ... }
|
|
223
|
-
* for await (const evt of read({ run: 1181, story: 1438, kind: 'friction' })) { ... }
|
|
224
|
-
*/
|
|
225
|
-
export async function* read(args) {
|
|
226
|
-
if (args == null || typeof args !== 'object') {
|
|
227
|
-
throw new TypeError(
|
|
228
|
-
`signals/read: args must be an object with at minimum { run }; got ${args}`,
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
const { run, story, kind, config } = args;
|
|
232
|
-
if (!isPositiveInt(run)) {
|
|
233
|
-
throw new RangeError(
|
|
234
|
-
`signals/read: run must be a positive integer (got ${run})`,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
if (story !== undefined && !isPositiveInt(story)) {
|
|
238
|
-
throw new RangeError(
|
|
239
|
-
`signals/read: story must be a positive integer when provided (got ${story})`,
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
if (kind !== undefined && kind !== null) {
|
|
243
|
-
if (typeof kind !== 'string' || kind.length === 0) {
|
|
244
|
-
throw new TypeError(
|
|
245
|
-
`signals/read: kind must be a non-empty string when provided (got ${kind})`,
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
if (!EVENT_KIND_VALUES.has(kind)) {
|
|
249
|
-
// Unknown kinds are not technically invalid — a future detector
|
|
250
|
-
// might emit a new kind that we don't yet know about. We warn
|
|
251
|
-
// (best effort) but still let the iterator run so the consumer
|
|
252
|
-
// can adopt the new kind without code change here.
|
|
253
|
-
Logger.warn(
|
|
254
|
-
`signals/read: kind '${kind}' is not in the current EVENT_KINDS enumeration; iterating anyway.`,
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const kindFilter = kind ?? null;
|
|
260
|
-
const targets =
|
|
261
|
-
story !== undefined
|
|
262
|
-
? [signalsFile(run, story, config)]
|
|
263
|
-
: await listRunStorySignalsFiles(run, config);
|
|
264
|
-
|
|
265
|
-
for (const target of targets) {
|
|
266
|
-
yield* streamFile(target, kindFilter);
|
|
267
|
-
}
|
|
268
|
-
}
|