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,379 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* story-plan.js — helpers for `/plan`.
|
|
3
|
-
*
|
|
4
|
-
* Pure functions used by `.agents/scripts/story-plan.js` (the CLI).
|
|
5
|
-
* Kept side-effect-free so the CLI stays a thin orchestrator and these
|
|
6
|
-
* helpers are easy to unit-test.
|
|
7
|
-
*
|
|
8
|
-
* Surfaces:
|
|
9
|
-
* - DEFAULT_REFINE_THRESHOLD — seed length below which refinement
|
|
10
|
-
* is auto-suggested.
|
|
11
|
-
* - REQUIRED_SECTIONS — canonical section headings the body
|
|
12
|
-
* must carry itself. Story #4874: the
|
|
13
|
-
* `## Acceptance` / `## Verify` sections
|
|
14
|
-
* are deliberately NOT among them —
|
|
15
|
-
* those lists are the ticket's top-level
|
|
16
|
-
* machine contract and persist
|
|
17
|
-
* synthesizes their sections, exactly as
|
|
18
|
-
* the story-author prompt instructs.
|
|
19
|
-
* - rankDuplicateCandidates({ seed, openStories, maxResults })
|
|
20
|
-
* — Jaccard-overlap ranking of open
|
|
21
|
-
* Stories whose titles fuzzy-match
|
|
22
|
-
* the seed.
|
|
23
|
-
* - validateStoryBody(body, contract)
|
|
24
|
-
* — schema-light shape check against the
|
|
25
|
-
* canonical Story-body contract: `##
|
|
26
|
-
* Goal` / `## Changes` present, no
|
|
27
|
-
* `Epic:` reference, and an
|
|
28
|
-
* acceptance/verify contract resolvable
|
|
29
|
-
* from the top-level arrays or the body.
|
|
30
|
-
* - buildContextEnvelope(opts) — assemble the context envelope the
|
|
31
|
-
* host LLM consumes.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { readFile } from 'node:fs/promises';
|
|
35
|
-
import path from 'node:path';
|
|
36
|
-
import { overlapScore, tokenize } from './duplicate-search.js';
|
|
37
|
-
import { parse as parseStoryBody } from './story-body/story-body.js';
|
|
38
|
-
|
|
39
|
-
export const DEFAULT_REFINE_THRESHOLD = 200;
|
|
40
|
-
export const DEFAULT_DUPLICATE_MAX_RESULTS = 5;
|
|
41
|
-
export const DEFAULT_DUPLICATE_MIN_SCORE = 0.15;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The sections the authored body must carry itself. Story #4874 reconciled
|
|
45
|
-
* this list with the story-author prompt: the prompt tells the author to
|
|
46
|
-
* write `acceptance[]` / `verify[]` **once** at the ticket's top level and
|
|
47
|
-
* omit the matching body sections, so demanding those sections here made a
|
|
48
|
-
* prompt-faithful body unpersistable and cost a re-author round.
|
|
49
|
-
*/
|
|
50
|
-
export const REQUIRED_SECTIONS = ['Goal', 'Changes'];
|
|
51
|
-
|
|
52
|
-
/** The contract lists persist synthesizes into the body from the top level. */
|
|
53
|
-
const CONTRACT_FIELDS = /** @type {const} */ (['acceptance', 'verify']);
|
|
54
|
-
|
|
55
|
-
const EPIC_REF_PATTERN = /^\s*Epic:\s*#\d+/m;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Rank open Stories by title-overlap with the seed. Reuses the same
|
|
59
|
-
* tokenize + Jaccard primitives that `duplicate-search.js` exposes for
|
|
60
|
-
* Epic-level dedupe — Stories are a different ticket type but the
|
|
61
|
-
* scoring shape is the same.
|
|
62
|
-
*
|
|
63
|
-
* @param {{ seed: string, openStories: Array<{ id:number, title:string, url?:string, body?:string }>, maxResults?: number, minScore?: number }} opts
|
|
64
|
-
* @returns {Array<{ id:number, title:string, url?:string, score:number }>}
|
|
65
|
-
*/
|
|
66
|
-
export function rankDuplicateCandidates({
|
|
67
|
-
seed,
|
|
68
|
-
openStories,
|
|
69
|
-
maxResults = DEFAULT_DUPLICATE_MAX_RESULTS,
|
|
70
|
-
minScore = DEFAULT_DUPLICATE_MIN_SCORE,
|
|
71
|
-
}) {
|
|
72
|
-
if (!seed || typeof seed !== 'string') {
|
|
73
|
-
throw new Error('rankDuplicateCandidates: seed must be a non-empty string');
|
|
74
|
-
}
|
|
75
|
-
if (!Array.isArray(openStories)) {
|
|
76
|
-
throw new Error('rankDuplicateCandidates: openStories must be an array');
|
|
77
|
-
}
|
|
78
|
-
const seedTokens = tokenize(seed);
|
|
79
|
-
if (seedTokens.size === 0) return [];
|
|
80
|
-
|
|
81
|
-
const ranked = [];
|
|
82
|
-
for (const story of openStories) {
|
|
83
|
-
if (!story || typeof story.title !== 'string') continue;
|
|
84
|
-
const corpus = `${story.title}\n${story.body ?? ''}`;
|
|
85
|
-
const candTokens = tokenize(corpus);
|
|
86
|
-
const score = overlapScore(seedTokens, candTokens);
|
|
87
|
-
if (score >= minScore) {
|
|
88
|
-
ranked.push({
|
|
89
|
-
id: story.id,
|
|
90
|
-
title: story.title,
|
|
91
|
-
url: story.url,
|
|
92
|
-
score: Number(score.toFixed(4)),
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
ranked.sort((a, b) => b.score - a.score);
|
|
97
|
-
return ranked.slice(0, maxResults);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Decide whether the seed warrants a Phase-2 idea-refinement pass.
|
|
102
|
-
* The default rule: refine when the seed is shorter than
|
|
103
|
-
* `DEFAULT_REFINE_THRESHOLD` chars. Operator overrides via
|
|
104
|
-
* `--refine` / `--no-refine` short-circuit the heuristic.
|
|
105
|
-
*
|
|
106
|
-
* @param {{ seed:string, override?:'on'|'off'|null, threshold?:number }} opts
|
|
107
|
-
* @returns {{ refine:boolean, reason:string }}
|
|
108
|
-
*/
|
|
109
|
-
export function shouldRefine({
|
|
110
|
-
seed,
|
|
111
|
-
override = null,
|
|
112
|
-
threshold = DEFAULT_REFINE_THRESHOLD,
|
|
113
|
-
}) {
|
|
114
|
-
if (override === 'on') return { refine: true, reason: 'operator-forced-on' };
|
|
115
|
-
if (override === 'off')
|
|
116
|
-
return { refine: false, reason: 'operator-forced-off' };
|
|
117
|
-
if (typeof seed !== 'string' || seed.trim().length === 0) {
|
|
118
|
-
return { refine: true, reason: 'empty-seed' };
|
|
119
|
-
}
|
|
120
|
-
if (seed.trim().length < threshold) {
|
|
121
|
-
return {
|
|
122
|
-
refine: true,
|
|
123
|
-
reason: `seed-shorter-than-${threshold}-chars`,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
return { refine: false, reason: `seed-meets-${threshold}-char-threshold` };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Collect the violations for one contract list (`acceptance` / `verify`).
|
|
131
|
-
*
|
|
132
|
-
* The list may be authored at the ticket's top level (the shape the
|
|
133
|
-
* story-author prompt prescribes) or written into the body as a section —
|
|
134
|
-
* either satisfies the contract, and the top level is preferred because
|
|
135
|
-
* persist synthesizes the section from it. Only two shapes are violations:
|
|
136
|
-
* the list is nowhere, or it is in both places and the two disagree (fail
|
|
137
|
-
* closed rather than guess which is authoritative, mirroring
|
|
138
|
-
* `plan-persist`'s `syncContractFieldFromTopLevel`).
|
|
139
|
-
*
|
|
140
|
-
* @param {'acceptance'|'verify'} field
|
|
141
|
-
* @param {unknown} bodyList The parsed body's section entries.
|
|
142
|
-
* @param {unknown} topLevel The ticket's top-level array.
|
|
143
|
-
* @returns {string[]}
|
|
144
|
-
*/
|
|
145
|
-
function collectContractErrors(field, bodyList, topLevel) {
|
|
146
|
-
const inBody = (Array.isArray(bodyList) ? bodyList : []).map(String);
|
|
147
|
-
const inTicket = (Array.isArray(topLevel) ? topLevel : []).map(String);
|
|
148
|
-
if (inBody.length === 0 && inTicket.length === 0) {
|
|
149
|
-
return [
|
|
150
|
-
`${field} must list at least one entry — author it as the ticket's top-level ${field}[] array; persist synthesizes the "## ${field[0].toUpperCase()}${field.slice(1)}" section from it`,
|
|
151
|
-
];
|
|
152
|
-
}
|
|
153
|
-
if (
|
|
154
|
-
inBody.length > 0 &&
|
|
155
|
-
inTicket.length > 0 &&
|
|
156
|
-
(inBody.length !== inTicket.length ||
|
|
157
|
-
inBody.some((v, i) => v !== inTicket[i]))
|
|
158
|
-
) {
|
|
159
|
-
return [
|
|
160
|
-
`${field} disagrees between the body section and the top-level ${field}[] array — author it once, at the top level`,
|
|
161
|
-
];
|
|
162
|
-
}
|
|
163
|
-
return [];
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Schema-light validator for a standalone-Story body, stated against the
|
|
168
|
-
* same authoring shape the story-author prompt prescribes (Story #4874).
|
|
169
|
-
*
|
|
170
|
-
* It parses the body with the canonical Story-body parser and asserts:
|
|
171
|
-
* a non-empty `## Goal`, at least one `## Changes` entry, no leaking
|
|
172
|
-
* `Epic: #N` reference (the standalone contract), and an acceptance +
|
|
173
|
-
* verify contract resolvable from the ticket's top-level arrays **or** the
|
|
174
|
-
* body's own sections. It deliberately does not demand the `## Acceptance`
|
|
175
|
-
* / `## Verify` sections of the author — persist synthesizes them.
|
|
176
|
-
*
|
|
177
|
-
* @param {string} body
|
|
178
|
-
* @param {{ acceptance?: string[], verify?: string[] }} [contract]
|
|
179
|
-
* The ticket's top-level contract arrays, when the caller has them.
|
|
180
|
-
* @returns {{ ok:boolean, errors:string[] }}
|
|
181
|
-
*/
|
|
182
|
-
export function validateStoryBody(body, contract = {}) {
|
|
183
|
-
if (typeof body !== 'string' || body.trim().length === 0) {
|
|
184
|
-
return { ok: false, errors: ['body is empty'] };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
let parsed;
|
|
188
|
-
try {
|
|
189
|
-
parsed = parseStoryBody(body).body;
|
|
190
|
-
} catch (err) {
|
|
191
|
-
return {
|
|
192
|
-
ok: false,
|
|
193
|
-
errors: [`body is not a parseable Story body: ${err.message}`],
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const errors = [];
|
|
198
|
-
if (typeof parsed.goal !== 'string' || parsed.goal.trim() === '') {
|
|
199
|
-
errors.push('missing required section: "## Goal"');
|
|
200
|
-
}
|
|
201
|
-
if (!Array.isArray(parsed.changes) || parsed.changes.length === 0) {
|
|
202
|
-
errors.push('missing required section: "## Changes"');
|
|
203
|
-
}
|
|
204
|
-
if (EPIC_REF_PATTERN.test(body)) {
|
|
205
|
-
errors.push(
|
|
206
|
-
'body contains an "Epic: #N" reference — standalone Stories must not link to an Epic',
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
for (const field of CONTRACT_FIELDS) {
|
|
210
|
-
errors.push(
|
|
211
|
-
...collectContractErrors(field, parsed[field], contract?.[field]),
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return { ok: errors.length === 0, errors };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Render the canonical `## Acceptance` / `## Verify` sections into a body
|
|
220
|
-
* that omits them, from the ticket's top-level arrays (Story #4874).
|
|
221
|
-
*
|
|
222
|
-
* This is the synthesis the story-author prompt promises: the author writes
|
|
223
|
-
* each list once at top level, and the persisted GitHub issue still reads as
|
|
224
|
-
* a complete executable document. A body that already carries a section is
|
|
225
|
-
* returned untouched — `validateStoryBody` has already refused the case
|
|
226
|
-
* where the two disagree.
|
|
227
|
-
*
|
|
228
|
-
* @param {string} body
|
|
229
|
-
* @param {{ acceptance?: string[], verify?: string[] }} [contract]
|
|
230
|
-
* @returns {string}
|
|
231
|
-
*/
|
|
232
|
-
export function synthesizeContractSections(body, contract = {}) {
|
|
233
|
-
let parsed;
|
|
234
|
-
try {
|
|
235
|
-
parsed = parseStoryBody(body).body;
|
|
236
|
-
} catch {
|
|
237
|
-
return body;
|
|
238
|
-
}
|
|
239
|
-
const blocks = [];
|
|
240
|
-
if (
|
|
241
|
-
(parsed.acceptance ?? []).length === 0 &&
|
|
242
|
-
Array.isArray(contract.acceptance) &&
|
|
243
|
-
contract.acceptance.length > 0
|
|
244
|
-
) {
|
|
245
|
-
const items = contract.acceptance
|
|
246
|
-
.map((a, i) => `- [ ] AC-${i + 1}: ${a}`)
|
|
247
|
-
.join('\n');
|
|
248
|
-
blocks.push(`## Acceptance\n${items}`);
|
|
249
|
-
}
|
|
250
|
-
if (
|
|
251
|
-
(parsed.verify ?? []).length === 0 &&
|
|
252
|
-
Array.isArray(contract.verify) &&
|
|
253
|
-
contract.verify.length > 0
|
|
254
|
-
) {
|
|
255
|
-
blocks.push(
|
|
256
|
-
`## Verify\n${contract.verify.map((v) => `- ${v}`).join('\n')}`,
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
if (blocks.length === 0) return body;
|
|
260
|
-
return `${body.trimEnd()}\n\n${blocks.join('\n\n')}\n`;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Load the body-template file from disk. Resolves relative to a project
|
|
265
|
-
* root so callers (CLI, tests) can pin the lookup deterministically.
|
|
266
|
-
*
|
|
267
|
-
* @param {string} projectRoot
|
|
268
|
-
* @returns {Promise<string>}
|
|
269
|
-
*/
|
|
270
|
-
export async function loadBodyTemplate(projectRoot) {
|
|
271
|
-
const tpl = path.join(
|
|
272
|
-
projectRoot,
|
|
273
|
-
'.agents',
|
|
274
|
-
'templates',
|
|
275
|
-
'single-story-body.md',
|
|
276
|
-
);
|
|
277
|
-
return readFile(tpl, 'utf8');
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Assemble the context envelope the host LLM consumes to author a draft
|
|
282
|
-
* Story body. Pure — no I/O beyond what the caller passed in.
|
|
283
|
-
*
|
|
284
|
-
* @param {{
|
|
285
|
-
* seed: string,
|
|
286
|
-
* refine: { refine:boolean, reason:string },
|
|
287
|
-
* persona: string,
|
|
288
|
-
* bodyTemplate: string,
|
|
289
|
-
* duplicateCandidates: Array<object>,
|
|
290
|
-
* techStack?: string|null,
|
|
291
|
-
* corpusContext?: { docsDigest: string|null, relevantSections: Array<object> }|null,
|
|
292
|
-
* maxResults?: number,
|
|
293
|
-
* }} opts
|
|
294
|
-
*/
|
|
295
|
-
export function buildContextEnvelope({
|
|
296
|
-
seed,
|
|
297
|
-
refine,
|
|
298
|
-
bodyTemplate,
|
|
299
|
-
duplicateCandidates,
|
|
300
|
-
techStack = null,
|
|
301
|
-
corpusContext = null,
|
|
302
|
-
maxResults = DEFAULT_DUPLICATE_MAX_RESULTS,
|
|
303
|
-
}) {
|
|
304
|
-
return {
|
|
305
|
-
kind: 'story-plan-context',
|
|
306
|
-
version: 1,
|
|
307
|
-
seed,
|
|
308
|
-
refine,
|
|
309
|
-
bodyTemplate,
|
|
310
|
-
requiredSections: [...REQUIRED_SECTIONS],
|
|
311
|
-
duplicateCandidates: {
|
|
312
|
-
maxResults,
|
|
313
|
-
candidates: duplicateCandidates,
|
|
314
|
-
},
|
|
315
|
-
techStack,
|
|
316
|
-
corpusContext,
|
|
317
|
-
deliverContract: {
|
|
318
|
-
workflow: '.agents/workflows/helpers/deliver-story.md',
|
|
319
|
-
requiredLabels: ['type::story'],
|
|
320
|
-
forbidden: ['Epic: #N references in the body'],
|
|
321
|
-
},
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Extract the "Tech Stack" `##` section from a markdown document.
|
|
327
|
-
*
|
|
328
|
-
* Tolerates a numbered / decorated heading (`## 1. Tech Stack`,
|
|
329
|
-
* `## Tech Stack`, etc.) and a section that is the final `##` in the
|
|
330
|
-
* file (the terminator matches the next `##` heading **or** end-of-file).
|
|
331
|
-
* Returns the matched section text (re-headed to a clean `## Tech Stack`)
|
|
332
|
-
* or `null` when no Tech Stack heading is present.
|
|
333
|
-
*
|
|
334
|
-
* @param {string} content
|
|
335
|
-
* @returns {string|null}
|
|
336
|
-
*/
|
|
337
|
-
function extractTechStackSection(content) {
|
|
338
|
-
const match = content.match(
|
|
339
|
-
/^##\s+(?:\d+[.)]\s+)?Tech Stack\s*$([\s\S]*?)(?=^##\s+|(?![\s\S]))/m,
|
|
340
|
-
);
|
|
341
|
-
return match ? `## Tech Stack${match[1]}`.trim() : null;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Resolve the project's Tech Stack inventory for the host LLM, in order:
|
|
346
|
-
*
|
|
347
|
-
* 1. A dedicated `docs/tech-stack.md` when present (the emerging
|
|
348
|
-
* single-ownership convention — WHAT in tech-stack.md, HOW in
|
|
349
|
-
* architecture.md, WHY in the ADRs). Its full body is returned.
|
|
350
|
-
* 2. Otherwise, the `## Tech Stack` section of `docs/architecture.md`,
|
|
351
|
-
* tolerating a numbered/decorated heading and a final-section
|
|
352
|
-
* heading (no following `##` required).
|
|
353
|
-
*
|
|
354
|
-
* Returns `null` when neither source yields an inventory.
|
|
355
|
-
*
|
|
356
|
-
* @param {string} projectRoot
|
|
357
|
-
* @returns {Promise<string|null>}
|
|
358
|
-
*/
|
|
359
|
-
export async function readTechStackSummary(projectRoot) {
|
|
360
|
-
const dedicatedPath = path.join(projectRoot, 'docs', 'tech-stack.md');
|
|
361
|
-
try {
|
|
362
|
-
const dedicated = await readFile(dedicatedPath, 'utf8');
|
|
363
|
-
const trimmed = dedicated.trim();
|
|
364
|
-
if (trimmed) {
|
|
365
|
-
return trimmed;
|
|
366
|
-
}
|
|
367
|
-
} catch {
|
|
368
|
-
// No dedicated tech-stack.md — fall through to architecture.md.
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const archPath = path.join(projectRoot, 'docs', 'architecture.md');
|
|
372
|
-
let content;
|
|
373
|
-
try {
|
|
374
|
-
content = await readFile(archPath, 'utf8');
|
|
375
|
-
} catch {
|
|
376
|
-
return null;
|
|
377
|
-
}
|
|
378
|
-
return extractTechStackSection(content);
|
|
379
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/util/phase-timer-state.js — Cross-process persistence for phase-timer
|
|
3
|
-
*
|
|
4
|
-
* A Story Mode run spans two Node processes — `story-init` creates the
|
|
5
|
-
* timer and opens the `implement` phase; the operator (or a headless
|
|
6
|
-
* `claude -p` sub-agent) does the work; `story-close` reads the
|
|
7
|
-
* timer back, appends `lint` / `test` / `close` / `api-sync` marks, and
|
|
8
|
-
* emits the `phase-timings` structured comment.
|
|
9
|
-
*
|
|
10
|
-
* Snapshots land in the main-repo `.git/` (which every process in the story
|
|
11
|
-
* lifecycle can see — init writes from the main checkout, close invokes with
|
|
12
|
-
* `--cwd <main-repo>` per the worktree contract). `.git/` is deliberately
|
|
13
|
-
* chosen over the worktree root so the file is never accidentally checked
|
|
14
|
-
* into the story branch and never ships in a PR.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import fs from 'node:fs';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
|
|
20
|
-
function filePath(mainCwd, storyId) {
|
|
21
|
-
return path.join(mainCwd, '.git', `story-${storyId}-phase-timer.json`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Persist the current timer snapshot to disk. Writes atomically via a
|
|
26
|
-
* temp-file rename so a crash mid-write cannot leave a truncated JSON file
|
|
27
|
-
* that the close process would fail to parse.
|
|
28
|
-
*
|
|
29
|
-
* @param {{ snapshot: () => object }} timer
|
|
30
|
-
* @param {{ mainCwd: string, storyId: number }} opts
|
|
31
|
-
*/
|
|
32
|
-
export function savePhaseTimerState(timer, { mainCwd, storyId }) {
|
|
33
|
-
const target = filePath(mainCwd, storyId);
|
|
34
|
-
const tmp = `${target}.tmp`;
|
|
35
|
-
fs.writeFileSync(tmp, JSON.stringify(timer.snapshot(), null, 2), 'utf8');
|
|
36
|
-
fs.renameSync(tmp, target);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Load a timer snapshot from disk if present. Returns `null` when the file
|
|
41
|
-
* is missing or unparseable — both cases are treated as "no prior timer",
|
|
42
|
-
* which lets close fall back to creating a fresh timer and emitting only
|
|
43
|
-
* the phases it observes.
|
|
44
|
-
*
|
|
45
|
-
* @param {{ mainCwd: string, storyId: number }} opts
|
|
46
|
-
* @returns {object | null}
|
|
47
|
-
*/
|
|
48
|
-
export function loadPhaseTimerState({ mainCwd, storyId }) {
|
|
49
|
-
const p = filePath(mainCwd, storyId);
|
|
50
|
-
if (!fs.existsSync(p)) return null;
|
|
51
|
-
try {
|
|
52
|
-
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
53
|
-
} catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Remove the on-disk snapshot once the close has consumed it. Missing file
|
|
60
|
-
* is not an error — the close path may run without a prior init (e.g.
|
|
61
|
-
* operator invoking close manually after an init failure).
|
|
62
|
-
*
|
|
63
|
-
* @param {{ mainCwd: string, storyId: number }} opts
|
|
64
|
-
*/
|
|
65
|
-
export function clearPhaseTimerState({ mainCwd, storyId }) {
|
|
66
|
-
const p = filePath(mainCwd, storyId);
|
|
67
|
-
try {
|
|
68
|
-
fs.unlinkSync(p);
|
|
69
|
-
} catch (err) {
|
|
70
|
-
if (err.code !== 'ENOENT') throw err;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { Logger } from '../Logger.js';
|
|
2
|
-
/**
|
|
3
|
-
* lib/util/phase-timer.js — Per-phase wall-clock timer for the Story lifecycle.
|
|
4
|
-
*
|
|
5
|
-
* A single Story Mode run spans two Node processes (`story-init` →
|
|
6
|
-
* implement → `story-close`), so the timer supports snapshot/restore so
|
|
7
|
-
* close can pick up where init left off. Phase names are drawn from a fixed
|
|
8
|
-
* enum (tech spec #555 §Data Models) so the emitted `[phase-timing]` log
|
|
9
|
-
* lines and the `phase-timings` structured comment share one schema.
|
|
10
|
-
*
|
|
11
|
-
* Semantics
|
|
12
|
-
* ---------
|
|
13
|
-
* - `mark(name)` closes the current open phase (if any) at `now()` and opens
|
|
14
|
-
* a new phase at the same instant. The just-closed phase's
|
|
15
|
-
* `elapsedMs = now() - openedAt` is appended to the ordered `phases` list
|
|
16
|
-
* and a single `[phase-timing]` log line is emitted for it.
|
|
17
|
-
* - `finish()` closes the current open phase at `now()` and returns the
|
|
18
|
-
* full summary `{ storyId, totalMs, phases: [{ name, elapsedMs }, ...] }`.
|
|
19
|
-
* Idempotent — subsequent calls return the cached summary.
|
|
20
|
-
* - `snapshot()` returns a plain-object that `createPhaseTimer({ restore })`
|
|
21
|
-
* can round-trip. Used by `story-init` to hand the timer state to
|
|
22
|
-
* `story-close` via a small on-disk file in the main repo `.git/`.
|
|
23
|
-
*
|
|
24
|
-
* Invariants
|
|
25
|
-
* ----------
|
|
26
|
-
* - Unknown phase names throw. The enum is frozen at `ALLOWED_PHASE_NAMES`.
|
|
27
|
-
* - `mark()` after `finish()` throws — caller bug, not runtime drift.
|
|
28
|
-
* - `totalMs` is wall clock from `createdAt` to `finish()` time, not a sum
|
|
29
|
-
* of `phases[].elapsedMs`. Gaps before the first `mark()` (or between
|
|
30
|
-
* restore and the next mark) are therefore captured in `totalMs` even
|
|
31
|
-
* though they are not attributable to a named phase.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
export const ALLOWED_PHASE_NAMES = Object.freeze([
|
|
35
|
-
'worktree-create',
|
|
36
|
-
'bootstrap',
|
|
37
|
-
'install',
|
|
38
|
-
'implement',
|
|
39
|
-
'lint',
|
|
40
|
-
'test',
|
|
41
|
-
'close',
|
|
42
|
-
'api-sync',
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
|
-
const ALLOWED_SET = new Set(ALLOWED_PHASE_NAMES);
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* @typedef {typeof ALLOWED_PHASE_NAMES[number]} PhaseName
|
|
49
|
-
* @typedef {{ name: PhaseName, elapsedMs: number }} PhaseEntry
|
|
50
|
-
* @typedef {{
|
|
51
|
-
* storyId: number,
|
|
52
|
-
* totalMs: number,
|
|
53
|
-
* phases: PhaseEntry[],
|
|
54
|
-
* }} PhaseTimingSummary
|
|
55
|
-
* @typedef {{
|
|
56
|
-
* storyId: number,
|
|
57
|
-
* createdAt: number,
|
|
58
|
-
* phases: PhaseEntry[],
|
|
59
|
-
* current: { name: PhaseName, openedAt: number } | null,
|
|
60
|
-
* finished: PhaseTimingSummary | null,
|
|
61
|
-
* }} PhaseTimerSnapshot
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Create a phase timer for a single Story lifecycle.
|
|
66
|
-
*
|
|
67
|
-
* @param {number} storyId - Numeric Story id; baked into log lines and summary.
|
|
68
|
-
* @param {{
|
|
69
|
-
* now?: () => number,
|
|
70
|
-
* logger?: (line: string) => void,
|
|
71
|
-
* restore?: PhaseTimerSnapshot,
|
|
72
|
-
* }} [opts]
|
|
73
|
-
* @returns {{
|
|
74
|
-
* mark: (name: PhaseName) => void,
|
|
75
|
-
* finish: () => PhaseTimingSummary,
|
|
76
|
-
* snapshot: () => PhaseTimerSnapshot,
|
|
77
|
-
* }}
|
|
78
|
-
*/
|
|
79
|
-
export function createPhaseTimer(storyId, opts = {}) {
|
|
80
|
-
const now = opts.now ?? Date.now;
|
|
81
|
-
const logger = opts.logger ?? ((line) => Logger.info(line));
|
|
82
|
-
|
|
83
|
-
let createdAt;
|
|
84
|
-
let phases;
|
|
85
|
-
let current;
|
|
86
|
-
let finished;
|
|
87
|
-
let timerStoryId;
|
|
88
|
-
|
|
89
|
-
if (opts.restore) {
|
|
90
|
-
const snap = opts.restore;
|
|
91
|
-
timerStoryId = snap.storyId;
|
|
92
|
-
createdAt = snap.createdAt;
|
|
93
|
-
phases = Array.isArray(snap.phases)
|
|
94
|
-
? snap.phases.map((p) => ({ ...p }))
|
|
95
|
-
: [];
|
|
96
|
-
current = snap.current ? { ...snap.current } : null;
|
|
97
|
-
finished = snap.finished
|
|
98
|
-
? {
|
|
99
|
-
...snap.finished,
|
|
100
|
-
phases: snap.finished.phases.map((p) => ({ ...p })),
|
|
101
|
-
}
|
|
102
|
-
: null;
|
|
103
|
-
} else {
|
|
104
|
-
timerStoryId = storyId;
|
|
105
|
-
createdAt = now();
|
|
106
|
-
phases = [];
|
|
107
|
-
current = null;
|
|
108
|
-
finished = null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function closeCurrent(endAt) {
|
|
112
|
-
if (!current) return;
|
|
113
|
-
const elapsedMs = Math.max(0, endAt - current.openedAt);
|
|
114
|
-
const entry = { name: current.name, elapsedMs };
|
|
115
|
-
phases.push(entry);
|
|
116
|
-
logger(
|
|
117
|
-
`[phase-timing] story=${timerStoryId} phase=${entry.name} elapsedMs=${elapsedMs}`,
|
|
118
|
-
);
|
|
119
|
-
current = null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function mark(name) {
|
|
123
|
-
if (finished) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`createPhaseTimer: cannot mark('${name}') after finish() on story #${timerStoryId}`,
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
if (!ALLOWED_SET.has(name)) {
|
|
129
|
-
throw new Error(
|
|
130
|
-
`createPhaseTimer: unknown phase '${name}'. Allowed: ${ALLOWED_PHASE_NAMES.join(', ')}`,
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
const t = now();
|
|
134
|
-
closeCurrent(t);
|
|
135
|
-
current = { name, openedAt: t };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function finish() {
|
|
139
|
-
if (finished) return finished;
|
|
140
|
-
const t = now();
|
|
141
|
-
closeCurrent(t);
|
|
142
|
-
finished = {
|
|
143
|
-
storyId: timerStoryId,
|
|
144
|
-
totalMs: Math.max(0, t - createdAt),
|
|
145
|
-
phases: phases.map((p) => ({ ...p })),
|
|
146
|
-
};
|
|
147
|
-
return finished;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function snapshot() {
|
|
151
|
-
return {
|
|
152
|
-
storyId: timerStoryId,
|
|
153
|
-
createdAt,
|
|
154
|
-
phases: phases.map((p) => ({ ...p })),
|
|
155
|
-
current: current ? { ...current } : null,
|
|
156
|
-
finished: finished
|
|
157
|
-
? { ...finished, phases: finished.phases.map((p) => ({ ...p })) }
|
|
158
|
-
: null,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return { mark, finish, snapshot };
|
|
163
|
-
}
|