mandrel 2.30.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/ci-remediation.md +44 -1
- 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/push.js +17 -2
- 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 +65 -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 +2 -12
- package/.agents/skills/stack/qa/playwright/SKILL.md +48 -0
- 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 +49 -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,229 +0,0 @@
|
|
|
1
|
-
// .agents/scripts/lib/orchestration/lifecycle/ledger-writer.js
|
|
2
|
-
/**
|
|
3
|
-
* LedgerWriter — append-only NDJSON persister for the lifecycle bus.
|
|
4
|
-
*
|
|
5
|
-
* Registered as the first named listener on every event by the runner
|
|
6
|
-
* factory. Each successful emit produces exactly one `emitted` + one
|
|
7
|
-
* `completed` line; a listener throw produces `emitted` + `failed`.
|
|
8
|
-
*
|
|
9
|
-
* The writer is a thin wrapper around `fs.appendFileSync` because:
|
|
10
|
-
* - the bus is strictly sequential, so a synchronous append cannot race;
|
|
11
|
-
* - resume semantics depend on the ledger being on disk before any
|
|
12
|
-
* downstream listener runs (`emitted` lands before listener `N`
|
|
13
|
-
* executes; a crash mid-listener leaves an `emitted`-without-
|
|
14
|
-
* `completed` line that resume detects);
|
|
15
|
-
* - synchronous I/O makes failure modes obvious (a thrown EACCES is
|
|
16
|
-
* observable immediately and propagates through the bus).
|
|
17
|
-
*
|
|
18
|
-
* Secret denylist: payload keys named in `SECRET_KEY_DENY_LIST` are
|
|
19
|
-
* stripped before write. This is defense-in-depth: payloads should never
|
|
20
|
-
* carry secrets to begin with, but the strip means a future contributor
|
|
21
|
-
* who accidentally adds `{ token: ... }` to an event payload can't leak
|
|
22
|
-
* it to the on-disk ledger (which we treat as an artifact safe to attach
|
|
23
|
-
* to PR comments).
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
27
|
-
import path from 'node:path';
|
|
28
|
-
import { runTempDir } from '../../config/temp-paths.js';
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Static deny-list pulled from the Tech Spec § Security & Privacy. Keys
|
|
32
|
-
* are matched case-insensitively against own enumerable keys of the
|
|
33
|
-
* top-level payload AND of any nested object.
|
|
34
|
-
*/
|
|
35
|
-
export const SECRET_KEY_DENY_LIST = Object.freeze([
|
|
36
|
-
'token',
|
|
37
|
-
'password',
|
|
38
|
-
'secret',
|
|
39
|
-
'apikey',
|
|
40
|
-
'webhookurl',
|
|
41
|
-
]);
|
|
42
|
-
|
|
43
|
-
const SECRET_DENY_SET = new Set(SECRET_KEY_DENY_LIST);
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Recursively strip deny-listed keys from an arbitrary value. Arrays and
|
|
47
|
-
* primitives are passed through; objects produce a new object with the
|
|
48
|
-
* deny-listed keys omitted. The original input is not mutated.
|
|
49
|
-
*
|
|
50
|
-
* Cyclic structures aren't expected in event payloads (the schema layer
|
|
51
|
-
* forbids them by construction), so we don't carry a `seen` set.
|
|
52
|
-
*/
|
|
53
|
-
export function stripSecrets(value) {
|
|
54
|
-
if (Array.isArray(value)) {
|
|
55
|
-
return value.map(stripSecrets);
|
|
56
|
-
}
|
|
57
|
-
if (value && typeof value === 'object') {
|
|
58
|
-
const out = {};
|
|
59
|
-
for (const [k, v] of Object.entries(value)) {
|
|
60
|
-
if (SECRET_DENY_SET.has(k.toLowerCase())) continue;
|
|
61
|
-
out[k] = stripSecrets(v);
|
|
62
|
-
}
|
|
63
|
-
return out;
|
|
64
|
-
}
|
|
65
|
-
return value;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Produce the canonical ISO-8601 wall-clock string for a ledger record.
|
|
70
|
-
* Pulled out so tests can fake `Date.now()` without mocking the global.
|
|
71
|
-
*/
|
|
72
|
-
function nowIso(now = Date.now) {
|
|
73
|
-
return new Date(now()).toISOString();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* LedgerWriter binds to a single Epic's temp directory. The runner
|
|
78
|
-
* constructs one per Epic run (and reuses it across resume).
|
|
79
|
-
*
|
|
80
|
-
* Usage:
|
|
81
|
-
* const writer = new LedgerWriter({ epicId: 2172, tempRoot: 'temp' });
|
|
82
|
-
* writer.register(bus);
|
|
83
|
-
*/
|
|
84
|
-
export class LedgerWriter {
|
|
85
|
-
/**
|
|
86
|
-
* @param {object} opts
|
|
87
|
-
* @param {number} opts.epicId
|
|
88
|
-
* @param {string} opts.tempRoot - absolute or repo-relative path; the
|
|
89
|
-
* writer resolves `temp/run-<id>/lifecycle.ndjson` underneath.
|
|
90
|
-
* @param {() => number} [opts.now] - injectable clock for tests.
|
|
91
|
-
*/
|
|
92
|
-
constructor(opts) {
|
|
93
|
-
if (!opts || !Number.isInteger(opts.epicId) || opts.epicId <= 0) {
|
|
94
|
-
throw new TypeError(
|
|
95
|
-
'LedgerWriter: opts.epicId must be a positive integer',
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
if (typeof opts.tempRoot !== 'string' || opts.tempRoot.length === 0) {
|
|
99
|
-
throw new TypeError(
|
|
100
|
-
'LedgerWriter: opts.tempRoot must be a non-empty string',
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
this._epicId = opts.epicId;
|
|
104
|
-
this._tempRoot = opts.tempRoot;
|
|
105
|
-
this._epicDir = runTempDir(this._epicId, {
|
|
106
|
-
project: { paths: { tempRoot: this._tempRoot } },
|
|
107
|
-
});
|
|
108
|
-
this._ledgerPath = path.join(this._epicDir, 'lifecycle.ndjson');
|
|
109
|
-
this._now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Resolved on-disk path for the NDJSON ledger. Exposed for tests and
|
|
114
|
-
* for the TraceLogger which renders the companion markdown from it.
|
|
115
|
-
*/
|
|
116
|
-
get ledgerPath() {
|
|
117
|
-
return this._ledgerPath;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Directory housing the ledger and (later) the companion markdown.
|
|
122
|
-
*/
|
|
123
|
-
get epicDir() {
|
|
124
|
-
return this._epicDir;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Ensure the epic-scoped temp directory exists before every append.
|
|
129
|
-
*
|
|
130
|
-
* The naive optimization (cache "ensured" after the first call) is
|
|
131
|
-
* unsound when a listener moves the ledger directory mid-handler —
|
|
132
|
-
* which is exactly what the Cleaner does on Wave 8 (Story #2259):
|
|
133
|
-
* it renames `temp/run-<id>/` under `archive/` between the listener
|
|
134
|
-
* body and the bus's `onCompleted` hook fire-time, and the next
|
|
135
|
-
* append (the `completed` record for the outer event) would land
|
|
136
|
-
* in a vanished directory. `mkdirSync({recursive: true})` is
|
|
137
|
-
* idempotent and cheap (microseconds), so we call it every time and
|
|
138
|
-
* keep the writer robust to its own directory being moved.
|
|
139
|
-
*/
|
|
140
|
-
_ensureDir() {
|
|
141
|
-
mkdirSync(this._epicDir, { recursive: true });
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Append one NDJSON record. Internal — callers should use the bus
|
|
146
|
-
* listener registered by `register()`.
|
|
147
|
-
*/
|
|
148
|
-
_appendRecord(record) {
|
|
149
|
-
this._ensureDir();
|
|
150
|
-
appendFileSync(this._ledgerPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Build the `emitted` record for a given context. Exposed for tests so
|
|
155
|
-
* the deny-list strip can be asserted directly.
|
|
156
|
-
*/
|
|
157
|
-
buildEmitted({ event, seqId, payload }) {
|
|
158
|
-
return {
|
|
159
|
-
kind: 'emitted',
|
|
160
|
-
seqId,
|
|
161
|
-
ts: nowIso(this._now),
|
|
162
|
-
event,
|
|
163
|
-
payload: stripSecrets(payload ?? {}),
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
buildCompleted({ event, seqId }) {
|
|
168
|
-
return {
|
|
169
|
-
kind: 'completed',
|
|
170
|
-
seqId,
|
|
171
|
-
ts: nowIso(this._now),
|
|
172
|
-
event,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
buildFailed({ event, seqId, listener, error }) {
|
|
177
|
-
const errorRecord = {
|
|
178
|
-
name: error?.name ? String(error.name) : 'Error',
|
|
179
|
-
message: error?.message ? String(error.message) : String(error),
|
|
180
|
-
};
|
|
181
|
-
if (typeof error?.stack === 'string' && error.stack.length > 0) {
|
|
182
|
-
errorRecord.stack = error.stack;
|
|
183
|
-
}
|
|
184
|
-
return {
|
|
185
|
-
kind: 'failed',
|
|
186
|
-
seqId,
|
|
187
|
-
ts: nowIso(this._now),
|
|
188
|
-
event,
|
|
189
|
-
listener,
|
|
190
|
-
error: errorRecord,
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Register this writer against the supplied bus by installing the
|
|
196
|
-
* privileged `onEmitted` / `onCompleted` / `onFailed` hooks. The
|
|
197
|
-
* writer is NOT a regular listener — it uses the privileged seam so
|
|
198
|
-
* `emitted` lands BEFORE any listener side effect (resume contract)
|
|
199
|
-
* and `completed` / `failed` land AFTER the listener phase.
|
|
200
|
-
*/
|
|
201
|
-
register(bus) {
|
|
202
|
-
if (
|
|
203
|
-
!bus ||
|
|
204
|
-
typeof bus.onEmitted !== 'function' ||
|
|
205
|
-
typeof bus.onCompleted !== 'function' ||
|
|
206
|
-
typeof bus.onFailed !== 'function'
|
|
207
|
-
) {
|
|
208
|
-
throw new TypeError(
|
|
209
|
-
'LedgerWriter.register: bus must expose onEmitted/onCompleted/onFailed hooks',
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
bus.onEmitted(({ event, seqId, payload }) => {
|
|
213
|
-
this._appendRecord(this.buildEmitted({ event, seqId, payload }));
|
|
214
|
-
});
|
|
215
|
-
bus.onCompleted(({ event, seqId }) => {
|
|
216
|
-
this._appendRecord(this.buildCompleted({ event, seqId }));
|
|
217
|
-
});
|
|
218
|
-
bus.onFailed(({ event, seqId, listener, error }) => {
|
|
219
|
-
this._appendRecord(this.buildFailed({ event, seqId, listener, error }));
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Factory wrapper for symmetry with `createBus()`.
|
|
226
|
-
*/
|
|
227
|
-
export function createLedgerWriter(opts) {
|
|
228
|
-
return new LedgerWriter(opts);
|
|
229
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# Lifecycle Listeners
|
|
2
|
-
|
|
3
|
-
Each listener in this directory subscribes to one or more lifecycle bus
|
|
4
|
-
events and performs a single side effect. The full close-tail roster and
|
|
5
|
-
event taxonomy live in
|
|
6
|
-
[`docs/LIFECYCLE.md`](https://github.com/dsj1984/mandrel/blob/main/docs/LIFECYCLE.md)
|
|
7
|
-
— that document is the SSOT. This README only indexes the **files that still
|
|
8
|
-
live in this folder**.
|
|
9
|
-
|
|
10
|
-
## Files here
|
|
11
|
-
|
|
12
|
-
- [`watcher.js`](./watcher.js) — the CI-poll loop (`watchPrToTerminal`)
|
|
13
|
-
driven by `pr-watch-with-update.js`.
|
|
14
|
-
|
|
15
|
-
`merge-watcher.js` was deleted in Story #4545: the Epic-era `MergeWatcher`
|
|
16
|
-
listener had no production caller after the v2.0.0 Story-only cutover. The
|
|
17
|
-
poll defaults and `deriveChecksStatus` the live close path did import from it
|
|
18
|
-
now live in
|
|
19
|
-
[`lib/orchestration/merge-poll.js`](../../merge-poll.js), a home the close
|
|
20
|
-
path owns.
|
|
21
|
-
|
|
22
|
-
Other close-tail side effects (ledger write, finalize/PR open, automerge
|
|
23
|
-
arm, branch cleanup, label transition) are owned by the Story delivery
|
|
24
|
-
path (`helpers/deliver-story` / `single-story-close.js`) rather than a
|
|
25
|
-
`buildDefaultListenerChain` factory in this directory.
|
|
26
|
-
|
|
27
|
-
## Idempotency contract
|
|
28
|
-
|
|
29
|
-
Listeners MUST be idempotent on `(event, seqId)`. The bus may invoke a
|
|
30
|
-
listener twice for the same seqId during the resume window (when an
|
|
31
|
-
`emitted` ledger line landed but the matching `completed` did not). The
|
|
32
|
-
canonical pattern is a per-instance `Set<seqId>` checked at the top of
|
|
33
|
-
the listener body; the second invocation returns early without mutating
|
|
34
|
-
external state.
|
|
35
|
-
|
|
36
|
-
## Side-effect firewall
|
|
37
|
-
|
|
38
|
-
Listeners MAY:
|
|
39
|
-
|
|
40
|
-
- read tickets via the injected `provider`,
|
|
41
|
-
- write tickets via the injected `transitionTicketState`,
|
|
42
|
-
- upsert structured comments via the injected `upsertStructuredComment`,
|
|
43
|
-
- append to per-run ledger / signals files under `tempRoot`.
|
|
44
|
-
|
|
45
|
-
Listeners MUST NOT:
|
|
46
|
-
|
|
47
|
-
- `bus.emit()` from inside a listener body (sequential mediator
|
|
48
|
-
contract — the bus cannot re-enter safely),
|
|
49
|
-
- import runner state directly,
|
|
50
|
-
- mutate cross-cutting globals.
|
|
51
|
-
|
|
52
|
-
Trace observers (`bus.on('*', fn)`) live under
|
|
53
|
-
`lib/orchestration/lifecycle/trace-logger.js` and are subject to the
|
|
54
|
-
same firewall, plus a stricter no-IO rule.
|
|
@@ -1,344 +0,0 @@
|
|
|
1
|
-
// .agents/scripts/lib/orchestration/lifecycle/trace-logger.js
|
|
2
|
-
/**
|
|
3
|
-
* TraceLogger — renders the human-readable `lifecycle.md` companion
|
|
4
|
-
* from the canonical NDJSON ledger.
|
|
5
|
-
*
|
|
6
|
-
* The companion is a strict projection of the ledger: re-rendering the
|
|
7
|
-
* same ledger produces byte-identical Markdown (modulo wall-clock `ts`
|
|
8
|
-
* formatting). Editing the companion does NOT affect resume; only the
|
|
9
|
-
* NDJSON ledger is canonical. This is repeatability AC #12.
|
|
10
|
-
*
|
|
11
|
-
* `render(ledger)` is the pure function consumers should call.
|
|
12
|
-
* `TraceLogger.register(bus, writerLedgerPath)` installs the wildcard
|
|
13
|
-
* observer + the on-write side that keeps the companion in sync after
|
|
14
|
-
* every emit, but it does so by re-reading the NDJSON file and calling
|
|
15
|
-
* `render()` — there is no in-memory drift.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import path from 'node:path';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Format an ISO-8601 timestamp as HH:MM:SS for the per-event line. The
|
|
23
|
-
* `(durationMs)` chunk is computed from the gap between `emitted` and
|
|
24
|
-
* `completed` (or `failed`) of the same seqId.
|
|
25
|
-
*/
|
|
26
|
-
function formatClock(iso) {
|
|
27
|
-
// The ledger record schema requires ISO date-time strings (validated
|
|
28
|
-
// up-stream); a defensive `Date` parse here is just for resilience.
|
|
29
|
-
const d = new Date(iso);
|
|
30
|
-
if (Number.isNaN(d.getTime())) return '??:??:??';
|
|
31
|
-
const hh = String(d.getUTCHours()).padStart(2, '0');
|
|
32
|
-
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
|
33
|
-
const ss = String(d.getUTCSeconds()).padStart(2, '0');
|
|
34
|
-
return `${hh}:${mm}:${ss}`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Phase header derived from event name. The companion is grouped by
|
|
39
|
-
* phase so operators can scan to the section that interests them. The
|
|
40
|
-
* mapping is stable across runs.
|
|
41
|
-
*/
|
|
42
|
-
const PHASE_BY_PREFIX = Object.freeze({
|
|
43
|
-
'story.dispatch': 'Waves',
|
|
44
|
-
'story.merged': 'Waves',
|
|
45
|
-
'story.blocked': 'Waves',
|
|
46
|
-
'pr.created': 'Finalize',
|
|
47
|
-
'notification.emitted': 'Notifications',
|
|
48
|
-
'checkpoint.written': 'Checkpoint',
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
function phaseFor(eventName) {
|
|
52
|
-
// Match longest prefix first so `story.dispatch.start` resolves before
|
|
53
|
-
// `story.dispatch` would match an unrelated `story.*` block.
|
|
54
|
-
const keys = Object.keys(PHASE_BY_PREFIX).sort((a, b) => b.length - a.length);
|
|
55
|
-
for (const k of keys) {
|
|
56
|
-
if (eventName === k || eventName.startsWith(`${k}.`)) {
|
|
57
|
-
return PHASE_BY_PREFIX[k];
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return 'Other';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Render the payload summary chunk for a per-event line. We keep it
|
|
65
|
-
* short: keys + scalar values, no nested object dumps (the canonical
|
|
66
|
-
* NDJSON ledger is the place to recover full payloads). This matches
|
|
67
|
-
* the Tech Spec spec: "payload-summary".
|
|
68
|
-
*/
|
|
69
|
-
function summarizePayload(payload) {
|
|
70
|
-
if (!payload || typeof payload !== 'object') return '';
|
|
71
|
-
const parts = [];
|
|
72
|
-
for (const [k, v] of Object.entries(payload)) {
|
|
73
|
-
if (v == null) continue;
|
|
74
|
-
if (
|
|
75
|
-
typeof v === 'string' ||
|
|
76
|
-
typeof v === 'number' ||
|
|
77
|
-
typeof v === 'boolean'
|
|
78
|
-
) {
|
|
79
|
-
parts.push(`${k}=${v}`);
|
|
80
|
-
} else if (Array.isArray(v)) {
|
|
81
|
-
parts.push(`${k}=[${v.length}]`);
|
|
82
|
-
} else if (typeof v === 'object') {
|
|
83
|
-
const keys = Object.keys(v);
|
|
84
|
-
parts.push(`${k}={${keys.length}}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return parts.join(' ');
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Parse an NDJSON ledger string into an array of records. Blank lines
|
|
92
|
-
* and trailing whitespace are tolerated; malformed lines throw with
|
|
93
|
-
* line number so the operator can locate the corruption.
|
|
94
|
-
*/
|
|
95
|
-
export function parseLedger(text) {
|
|
96
|
-
const out = [];
|
|
97
|
-
const lines = String(text || '').split('\n');
|
|
98
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
99
|
-
const line = lines[i].trim();
|
|
100
|
-
if (line.length === 0) continue;
|
|
101
|
-
try {
|
|
102
|
-
out.push(JSON.parse(line));
|
|
103
|
-
} catch (_err) {
|
|
104
|
-
throw new Error(
|
|
105
|
-
`lifecycle ledger: malformed JSON on line ${i + 1}: ${line.slice(0, 80)}`,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Pure render of a parsed ledger to Markdown. Same input → byte-identical
|
|
114
|
-
* output (modulo `ts` field formatting, which is wall-clock by design).
|
|
115
|
-
*
|
|
116
|
-
* Layout (mirroring Tech Spec § Human-readable companion):
|
|
117
|
-
* # Lifecycle — epic <id>
|
|
118
|
-
*
|
|
119
|
-
* ## <Phase>
|
|
120
|
-
* HH:MM:SS event.name (durationMs) payload-summary
|
|
121
|
-
* ...
|
|
122
|
-
*
|
|
123
|
-
* ## Summary
|
|
124
|
-
* - Events: N
|
|
125
|
-
* - Failed: N
|
|
126
|
-
* - …
|
|
127
|
-
*/
|
|
128
|
-
/**
|
|
129
|
-
* Index the ledger records into the two maps `render` needs: the `emitted`
|
|
130
|
-
* record per seqId and the terminal (`completed`/`failed`) record per seqId.
|
|
131
|
-
* Story #4075 — extracted from `render` so the orchestrating body stays flat.
|
|
132
|
-
*/
|
|
133
|
-
function indexLedgerRecords(records) {
|
|
134
|
-
const emittedBySeq = new Map();
|
|
135
|
-
const terminalBySeq = new Map();
|
|
136
|
-
for (const rec of records) {
|
|
137
|
-
if (!rec || typeof rec !== 'object') continue;
|
|
138
|
-
if (rec.kind === 'emitted') emittedBySeq.set(rec.seqId, rec);
|
|
139
|
-
else if (rec.kind === 'completed' || rec.kind === 'failed')
|
|
140
|
-
terminalBySeq.set(rec.seqId, rec);
|
|
141
|
-
}
|
|
142
|
-
return { emittedBySeq, terminalBySeq };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Compute the `(durationMs)` / `(pending)` chunk for one emitted event,
|
|
147
|
-
* given its terminal record (or undefined when still in flight).
|
|
148
|
-
*/
|
|
149
|
-
export function formatDurationChunk(emit, terminal) {
|
|
150
|
-
if (!terminal) return '(pending)';
|
|
151
|
-
const start = new Date(emit.ts).getTime();
|
|
152
|
-
const end = new Date(terminal.ts).getTime();
|
|
153
|
-
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
|
|
154
|
-
return `(${end - start}ms)`;
|
|
155
|
-
}
|
|
156
|
-
return '';
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Render a single per-event line for the phase section.
|
|
161
|
-
*/
|
|
162
|
-
function formatEventLine(emit, terminal) {
|
|
163
|
-
const failedMarker =
|
|
164
|
-
terminal && terminal.kind === 'failed' ? ' ⚠️ FAILED' : '';
|
|
165
|
-
const parts = [
|
|
166
|
-
formatClock(emit.ts),
|
|
167
|
-
emit.event,
|
|
168
|
-
formatDurationChunk(emit, terminal),
|
|
169
|
-
summarizePayload(emit.payload),
|
|
170
|
-
].filter(Boolean);
|
|
171
|
-
return parts.join(' ') + failedMarker;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Group emitted events into ordered phase buckets, each carrying its
|
|
176
|
-
* rendered per-event lines. Phase order is first-seen by ascending seqId.
|
|
177
|
-
*/
|
|
178
|
-
function buildPhaseLines(emittedBySeq, terminalBySeq) {
|
|
179
|
-
const phaseOrder = [];
|
|
180
|
-
const phaseLines = new Map();
|
|
181
|
-
for (const emit of [...emittedBySeq.values()].sort(
|
|
182
|
-
(a, b) => a.seqId - b.seqId,
|
|
183
|
-
)) {
|
|
184
|
-
const phase = phaseFor(emit.event);
|
|
185
|
-
if (!phaseLines.has(phase)) {
|
|
186
|
-
phaseLines.set(phase, []);
|
|
187
|
-
phaseOrder.push(phase);
|
|
188
|
-
}
|
|
189
|
-
phaseLines
|
|
190
|
-
.get(phase)
|
|
191
|
-
.push(formatEventLine(emit, terminalBySeq.get(emit.seqId)));
|
|
192
|
-
}
|
|
193
|
-
return { phaseOrder, phaseLines };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Compute the wall-clock span (`maxEnd - minStart`) of a single phase, or
|
|
198
|
-
* `null` when no finite span can be derived.
|
|
199
|
-
*/
|
|
200
|
-
function computePhaseSpanMs(phase, emittedBySeq, terminalBySeq) {
|
|
201
|
-
let minStart = Infinity;
|
|
202
|
-
let maxEnd = -Infinity;
|
|
203
|
-
for (const emit of emittedBySeq.values()) {
|
|
204
|
-
if (phaseFor(emit.event) !== phase) continue;
|
|
205
|
-
const start = new Date(emit.ts).getTime();
|
|
206
|
-
if (Number.isFinite(start) && start < minStart) minStart = start;
|
|
207
|
-
const terminal = terminalBySeq.get(emit.seqId);
|
|
208
|
-
if (terminal) {
|
|
209
|
-
const end = new Date(terminal.ts).getTime();
|
|
210
|
-
if (Number.isFinite(end) && end > maxEnd) maxEnd = end;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
return Number.isFinite(minStart) && Number.isFinite(maxEnd)
|
|
214
|
-
? maxEnd - minStart
|
|
215
|
-
: null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Build the trailing `## Summary` block lines.
|
|
220
|
-
*/
|
|
221
|
-
function buildSummaryLines(phaseOrder, emittedBySeq, terminalBySeq) {
|
|
222
|
-
const totalEvents = emittedBySeq.size;
|
|
223
|
-
const failedCount = [...terminalBySeq.values()].filter(
|
|
224
|
-
(r) => r.kind === 'failed',
|
|
225
|
-
).length;
|
|
226
|
-
const phaseDurations = [];
|
|
227
|
-
for (const phase of phaseOrder) {
|
|
228
|
-
const spanMs = computePhaseSpanMs(phase, emittedBySeq, terminalBySeq);
|
|
229
|
-
if (spanMs !== null) phaseDurations.push(` - ${phase}: ${spanMs}ms`);
|
|
230
|
-
}
|
|
231
|
-
const lines = [
|
|
232
|
-
'## Summary',
|
|
233
|
-
'',
|
|
234
|
-
`- Events: ${totalEvents}`,
|
|
235
|
-
`- Completed: ${totalEvents - failedCount}`,
|
|
236
|
-
`- Failed: ${failedCount}`,
|
|
237
|
-
];
|
|
238
|
-
if (phaseDurations.length > 0) {
|
|
239
|
-
lines.push('- Phase durations:', ...phaseDurations);
|
|
240
|
-
}
|
|
241
|
-
lines.push('');
|
|
242
|
-
return lines;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export function render(ledger, opts = {}) {
|
|
246
|
-
const records = Array.isArray(ledger) ? ledger : parseLedger(ledger);
|
|
247
|
-
const { emittedBySeq, terminalBySeq } = indexLedgerRecords(records);
|
|
248
|
-
const { phaseOrder, phaseLines } = buildPhaseLines(
|
|
249
|
-
emittedBySeq,
|
|
250
|
-
terminalBySeq,
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
const epicId = opts.epicId ? `epic ${opts.epicId}` : 'epic';
|
|
254
|
-
const lines = [`# Lifecycle — ${epicId}`, ''];
|
|
255
|
-
for (const phase of phaseOrder) {
|
|
256
|
-
lines.push(`## ${phase}`, '', ...phaseLines.get(phase), '');
|
|
257
|
-
}
|
|
258
|
-
lines.push(...buildSummaryLines(phaseOrder, emittedBySeq, terminalBySeq));
|
|
259
|
-
return lines.join('\n');
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* TraceLogger wires `render()` against a live bus + ledger file. It is
|
|
264
|
-
* a wildcard observer: it does not mutate state under orchestration, it
|
|
265
|
-
* only re-renders the companion markdown on every event.
|
|
266
|
-
*
|
|
267
|
-
* The wildcard-firewall rule (Tech Spec § Bus contract) requires that
|
|
268
|
-
* trace observers do NOT import any module that mutates GitHub state,
|
|
269
|
-
* the worktree, or the filesystem outside `temp/run-<id>/`. This
|
|
270
|
-
* module satisfies that constraint: the only filesystem writes are to
|
|
271
|
-
* the companion path under the same temp directory the ledger lives
|
|
272
|
-
* in.
|
|
273
|
-
*/
|
|
274
|
-
export class TraceLogger {
|
|
275
|
-
/**
|
|
276
|
-
* @param {object} opts
|
|
277
|
-
* @param {string} opts.ledgerPath - absolute path to the NDJSON ledger
|
|
278
|
-
* the bus is writing (matches `LedgerWriter.ledgerPath`).
|
|
279
|
-
* @param {number} [opts.epicId] - included in the companion header.
|
|
280
|
-
*/
|
|
281
|
-
constructor(opts) {
|
|
282
|
-
if (
|
|
283
|
-
!opts ||
|
|
284
|
-
typeof opts.ledgerPath !== 'string' ||
|
|
285
|
-
opts.ledgerPath.length === 0
|
|
286
|
-
) {
|
|
287
|
-
throw new TypeError('TraceLogger: opts.ledgerPath is required');
|
|
288
|
-
}
|
|
289
|
-
this._ledgerPath = opts.ledgerPath;
|
|
290
|
-
this._companionPath = path.join(
|
|
291
|
-
path.dirname(this._ledgerPath),
|
|
292
|
-
'lifecycle.md',
|
|
293
|
-
);
|
|
294
|
-
this._epicId = opts.epicId ?? null;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
get companionPath() {
|
|
298
|
-
return this._companionPath;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Re-render the companion from the on-disk ledger. Idempotent.
|
|
303
|
-
*/
|
|
304
|
-
rerender() {
|
|
305
|
-
let text;
|
|
306
|
-
try {
|
|
307
|
-
text = readFileSync(this._ledgerPath, 'utf8');
|
|
308
|
-
} catch (err) {
|
|
309
|
-
if (err && err.code === 'ENOENT') return; // ledger not yet written
|
|
310
|
-
throw err;
|
|
311
|
-
}
|
|
312
|
-
const markdown = render(text, { epicId: this._epicId });
|
|
313
|
-
writeFileSync(this._companionPath, markdown, 'utf8');
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Register as a wildcard observer. After every emit, re-read the
|
|
318
|
-
* ledger and re-render the companion.
|
|
319
|
-
*
|
|
320
|
-
* The companion render is a best-effort projection: a failed render
|
|
321
|
-
* (malformed ledger, transient write error, full disk) MUST NOT abort
|
|
322
|
-
* the in-flight bus emit. Because wildcard listeners run inside the
|
|
323
|
-
* `emit()` try/catch (see `bus.js`), an unguarded throw here would
|
|
324
|
-
* short-circuit the emit and propagate to the orchestration caller.
|
|
325
|
-
* We instead log the failure to stderr and swallow it, degrading to a
|
|
326
|
-
* stale `lifecycle.md` companion — the canonical NDJSON ledger is
|
|
327
|
-
* unaffected, so resume and downstream consumers are not broken.
|
|
328
|
-
*/
|
|
329
|
-
register(bus) {
|
|
330
|
-
if (!bus || typeof bus.on !== 'function') {
|
|
331
|
-
throw new TypeError('TraceLogger.register: bus must expose .on()');
|
|
332
|
-
}
|
|
333
|
-
bus.on('*', () => {
|
|
334
|
-
try {
|
|
335
|
-
this.rerender();
|
|
336
|
-
} catch (err) {
|
|
337
|
-
const message = err && err.message ? err.message : String(err);
|
|
338
|
-
process.stderr.write(
|
|
339
|
-
`[TraceLogger] companion rerender failed (degrading to stale lifecycle.md): ${message}\n`,
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
}
|