@dev-loops/core 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -1
- package/src/claude/asset-generation.mjs +26 -0
- package/src/config/config.mjs +50 -2
- package/src/config/extension-defaults.yaml +32 -1
- package/src/loop/async-start-contract.mjs +9 -2
- package/src/loop/plan-file-intake-contract.mjs +56 -0
- package/src/loop/plan-file-promote-contract.mjs +234 -0
- package/src/loop/plan-file-refine-contract.mjs +229 -0
- package/src/loop/pr-gate-coordination.mjs +160 -9
- package/src/loop/run-context.mjs +11 -4
- package/src/loop/spike-exit-contract.mjs +138 -0
- package/src/loop/spike-intake-contract.mjs +52 -0
- package/src/loop/ui-e2e-scoping.mjs +162 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spike-mode exit (P2 of the spike-mode track #965).
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 (#964) shipped the spike intake state machine
|
|
5
|
+
* (`evaluateSpikeIntakeState` → SPIKE_INTAKE_STATE). A spike becomes exitable
|
|
6
|
+
* only once it carries a Recommendation (`spike_ready_for_exit`). This module is
|
|
7
|
+
* the exit decision: from that ready state, the operator picks a disposition —
|
|
8
|
+
* - DISCARD — the recommendation is "don't pursue"; drop the spike with ZERO
|
|
9
|
+
* tracker artifacts (the findings doc is the whole record).
|
|
10
|
+
* - GRADUATE — promote the exploration into a #947-consumable plan file
|
|
11
|
+
* (Status/Objective/In scope/Explicit non-goals) built from the
|
|
12
|
+
* spike's Question/Approach/Findings/Recommendation, which then
|
|
13
|
+
* enters the existing local-first plan→PR promotion path (#952).
|
|
14
|
+
*
|
|
15
|
+
* Pure: no fs/network/gh, no `scripts/` import (mirrors spike-intake-contract
|
|
16
|
+
* and plan-file-promote-contract). The CLI (`scripts/refine/exit-spike.mjs`)
|
|
17
|
+
* owns all I/O. Fail-closed on an unknown disposition or a non-ready state so
|
|
18
|
+
* the CLI makes zero mutation on those paths.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { SPIKE_INTAKE_STATE } from "./spike-intake-contract.mjs";
|
|
22
|
+
|
|
23
|
+
/** Dispositions the operator can choose at a ready spike's exit. */
|
|
24
|
+
export const SPIKE_EXIT_DISPOSITION = Object.freeze({
|
|
25
|
+
/** Drop the spike with no tracker artifact (recommendation: don't pursue). */
|
|
26
|
+
DISCARD: "discard",
|
|
27
|
+
/** Promote into a plan file consumable by the #947 local-first flow. */
|
|
28
|
+
GRADUATE: "graduate",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Actions the exit decision can return (1:1 with the eligible dispositions). */
|
|
32
|
+
export const SPIKE_EXIT_ACTION = Object.freeze({
|
|
33
|
+
DISCARD: "discard",
|
|
34
|
+
GRADUATE: "graduate",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const DISPOSITION_TO_ACTION = Object.freeze({
|
|
38
|
+
[SPIKE_EXIT_DISPOSITION.DISCARD]: SPIKE_EXIT_ACTION.DISCARD,
|
|
39
|
+
[SPIKE_EXIT_DISPOSITION.GRADUATE]: SPIKE_EXIT_ACTION.GRADUATE,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pure exit-eligibility decision.
|
|
44
|
+
*
|
|
45
|
+
* Eligible ONLY from `spike_ready_for_exit` (a Recommendation has been reached).
|
|
46
|
+
* Any other state — in-progress or ambiguous — fails closed with
|
|
47
|
+
* `not_ready_for_exit` and no action; the CLI must make zero tracker mutation.
|
|
48
|
+
* An unrecognized disposition fails closed with `unknown_disposition`.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} facts
|
|
51
|
+
* @param {string} facts.spikeIntakeState one of SPIKE_INTAKE_STATE values (from evaluateSpikeIntakeState)
|
|
52
|
+
* @param {string} facts.disposition one of SPIKE_EXIT_DISPOSITION values
|
|
53
|
+
* @returns {{ ok: boolean, action?: string, reason?: string, spikeIntakeState?: string | null }}
|
|
54
|
+
*/
|
|
55
|
+
export function evaluateSpikeExit({ spikeIntakeState, disposition } = {}) {
|
|
56
|
+
// The ready gate: an exit decision is only meaningful once a recommendation
|
|
57
|
+
// exists. Fail closed otherwise — never guess an exit for an in-progress or
|
|
58
|
+
// malformed spike.
|
|
59
|
+
if (spikeIntakeState !== SPIKE_INTAKE_STATE.SPIKE_READY_FOR_EXIT) {
|
|
60
|
+
return { ok: false, reason: "not_ready_for_exit", spikeIntakeState: spikeIntakeState ?? null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Own-property check: a bare index lookup would also match inherited
|
|
64
|
+
// Object.prototype keys (`toString`, `__proto__`, `constructor`), letting an
|
|
65
|
+
// unknown disposition resolve to a truthy value and bypass the fail-closed
|
|
66
|
+
// contract. Require a string that is an own key of the map.
|
|
67
|
+
if (typeof disposition !== "string" || !Object.hasOwn(DISPOSITION_TO_ACTION, disposition)) {
|
|
68
|
+
return { ok: false, reason: "unknown_disposition", spikeIntakeState };
|
|
69
|
+
}
|
|
70
|
+
const action = DISPOSITION_TO_ACTION[disposition];
|
|
71
|
+
|
|
72
|
+
return { ok: true, action, spikeIntakeState };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a #947-consumable plan-file body from a ready spike's sections.
|
|
77
|
+
*
|
|
78
|
+
* The emitted body carries the four base authoring sections the plan-file
|
|
79
|
+
* format requires (Status / Objective / In scope / Explicit non-goals — see
|
|
80
|
+
* scripts/refine/validate-plan-file.mjs), so it passes `validatePlanFile` and
|
|
81
|
+
* enters the existing local-first plan→PR promotion path (#952) unchanged.
|
|
82
|
+
*
|
|
83
|
+
* The spike sections map onto the plan as: the Question + Approach become the
|
|
84
|
+
* Objective's context, the Recommendation becomes the In-scope work, the
|
|
85
|
+
* Findings record the evidence, and a fixed non-goal keeps the plan from
|
|
86
|
+
* re-opening the (now-concluded) exploration. Status starts as Draft.
|
|
87
|
+
*
|
|
88
|
+
* Idempotent and pure: same input → identical output, no side effects. Fails
|
|
89
|
+
* closed (throws) on an empty required section so a graduate exit cannot emit a
|
|
90
|
+
* plan that the validator would reject.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} sections
|
|
93
|
+
* @param {string} sections.question
|
|
94
|
+
* @param {string} sections.approach
|
|
95
|
+
* @param {string} sections.findings
|
|
96
|
+
* @param {string} sections.recommendation
|
|
97
|
+
* @returns {string} markdown plan-file body
|
|
98
|
+
*/
|
|
99
|
+
export function buildGraduatedPlanBody({ question, approach, findings, recommendation } = {}) {
|
|
100
|
+
const q = String(question ?? "").trim();
|
|
101
|
+
const a = String(approach ?? "").trim();
|
|
102
|
+
const f = String(findings ?? "").trim();
|
|
103
|
+
const r = String(recommendation ?? "").trim();
|
|
104
|
+
if (q.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty question");
|
|
105
|
+
if (a.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty approach");
|
|
106
|
+
if (f.length === 0) throw new Error("buildGraduatedPlanBody requires non-empty findings");
|
|
107
|
+
if (r.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty recommendation");
|
|
108
|
+
|
|
109
|
+
return [
|
|
110
|
+
"# Graduated spike plan",
|
|
111
|
+
"",
|
|
112
|
+
"## Status",
|
|
113
|
+
"",
|
|
114
|
+
"Draft (graduated from a spike). Needs refinement before promotion.",
|
|
115
|
+
"",
|
|
116
|
+
"## Objective",
|
|
117
|
+
"",
|
|
118
|
+
`Act on the spike's recommendation. The spike asked: ${q}`,
|
|
119
|
+
"",
|
|
120
|
+
"Approach explored:",
|
|
121
|
+
"",
|
|
122
|
+
a,
|
|
123
|
+
"",
|
|
124
|
+
"## In scope",
|
|
125
|
+
"",
|
|
126
|
+
r,
|
|
127
|
+
"",
|
|
128
|
+
"Supporting findings from the spike:",
|
|
129
|
+
"",
|
|
130
|
+
f,
|
|
131
|
+
"",
|
|
132
|
+
"## Explicit non-goals",
|
|
133
|
+
"",
|
|
134
|
+
"- Re-running the spike's exploration; that question is concluded.",
|
|
135
|
+
"- Work beyond the recommendation above.",
|
|
136
|
+
"",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spike-mode intake state machine.
|
|
3
|
+
*
|
|
4
|
+
* A `--spike` startup hands the dev-loop a time-boxed, exploratory artifact that
|
|
5
|
+
* lives outside the tracker — startable from a local question with no GitHub
|
|
6
|
+
* issue. A spike is NOT a plan-needing-refinement, so its states are distinct
|
|
7
|
+
* from PLAN_FILE_INTAKE_STATE: it classifies how far the exploration has
|
|
8
|
+
* progressed toward an exit decision, not how far a plan has progressed toward
|
|
9
|
+
* promotion.
|
|
10
|
+
*
|
|
11
|
+
* This evaluator mirrors `evaluatePlanFileIntakeState`: a frozen enum plus a
|
|
12
|
+
* pure, deterministic function with no GitHub or filesystem side effects — the
|
|
13
|
+
* caller supplies the section-presence facts it has already read.
|
|
14
|
+
*
|
|
15
|
+
* The two non-ambiguous states are the seam phase 2 (#965) consumes for its
|
|
16
|
+
* discard/graduate exits:
|
|
17
|
+
* - SPIKE_IN_PROGRESS — exploration ongoing (no recommendation yet); a
|
|
18
|
+
* discard exit can drop it with no artifact.
|
|
19
|
+
* - SPIKE_READY_FOR_EXIT — a recommendation has been reached; phase 2 routes
|
|
20
|
+
* this to graduate (promote into a plan/PR) or
|
|
21
|
+
* discard (recommendation is "don't pursue").
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const SPIKE_INTAKE_STATE = Object.freeze({
|
|
25
|
+
/** Valid spike artifact; the Recommendation is not yet reached. */
|
|
26
|
+
SPIKE_IN_PROGRESS: "spike_in_progress",
|
|
27
|
+
/** Valid spike artifact carrying a Recommendation; an exit decision can be made. */
|
|
28
|
+
SPIKE_READY_FOR_EXIT: "spike_ready_for_exit",
|
|
29
|
+
/** Inputs are malformed or unusable; fail closed. */
|
|
30
|
+
AMBIGUOUS_FAIL_CLOSED: "ambiguous_fail_closed",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pure intake-state classifier.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} facts
|
|
37
|
+
* @param {boolean} facts.baseSectionsValid whether the spike's exploration scaffold (Question/Approach/Findings) is present and non-empty. Recommendation is NOT part of this fact — it is the separate exit-marker carried by `hasRecommendation`, so that a scaffold-valid spike without a Recommendation classifies as in-progress rather than failing closed.
|
|
38
|
+
* @param {boolean} facts.hasRecommendation whether a non-empty Recommendation section is present (the exit-marker that flips in-progress → ready-for-exit)
|
|
39
|
+
* @returns {{ state: string }} one of SPIKE_INTAKE_STATE values
|
|
40
|
+
*/
|
|
41
|
+
export function evaluateSpikeIntakeState({ baseSectionsValid, hasRecommendation } = {}) {
|
|
42
|
+
// A malformed spike artifact (missing/empty base sections) is unusable intake
|
|
43
|
+
// input; fail closed instead of guessing an exit.
|
|
44
|
+
if (baseSectionsValid !== true) {
|
|
45
|
+
return { state: SPIKE_INTAKE_STATE.AMBIGUOUS_FAIL_CLOSED };
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
state: hasRecommendation === true
|
|
49
|
+
? SPIKE_INTAKE_STATE.SPIKE_READY_FOR_EXIT
|
|
50
|
+
: SPIKE_INTAKE_STATE.SPIKE_IN_PROGRESS,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// UI e2e auto-scoping (issue #976).
|
|
2
|
+
//
|
|
3
|
+
// Deterministic, path-triggered criterion: a PR that adds or modifies a
|
|
4
|
+
// *rendered* HTML artifact (a presentation deck, an article page, or the
|
|
5
|
+
// inspect-run viewer's served page/component) MUST run the shared UI e2e
|
|
6
|
+
// assertions (mobile + desktop) AND have that artifact registered in the e2e
|
|
7
|
+
// suite (DECK_REGISTRY / ARTICLE_REGISTRY / VIEWER_REGISTRY). Inclusion is
|
|
8
|
+
// triggered by the changed-file set, never by a human annotating the PR.
|
|
9
|
+
//
|
|
10
|
+
// This module is the testable core of that criterion: classify changed paths
|
|
11
|
+
// → rendered-artifact set → check each is registered → fail closed if a
|
|
12
|
+
// rendered artifact changed with no registered/passing coverage.
|
|
13
|
+
|
|
14
|
+
// Explicit path globs for rendered artifacts. Kept conservative and explicit
|
|
15
|
+
// (issue #976 scope discipline): only artifacts that render to a page/component.
|
|
16
|
+
export const RENDERED_ARTIFACT_GLOBS = Object.freeze([
|
|
17
|
+
"docs/articles/*.html",
|
|
18
|
+
"docs/presentations/*.html",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// The inspect-run viewer is served from a component, not a static .html file,
|
|
22
|
+
// so its trigger is the served-page source (matches the existing
|
|
23
|
+
// inspect-run-viewer-ci-changes.mjs trigger seam).
|
|
24
|
+
export const VIEWER_SOURCE_PATHS = Object.freeze([
|
|
25
|
+
"scripts/loop/inspect-run-viewer.mjs",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Registered artifacts — keyed by FULL repo-relative path (not basename), so
|
|
29
|
+
// docs/articles/X.html and docs/presentations/X.html (which share basenames,
|
|
30
|
+
// e.g. introducing-dev-loops.html) are DISTINCT and can never alias onto each
|
|
31
|
+
// other. Mirrors the registries' actual on-disk locations:
|
|
32
|
+
// decks → DECK_REGISTRY served from docs/presentations/<deck>
|
|
33
|
+
// articles→ ARTICLE_REGISTRY served from docs/articles/<file>
|
|
34
|
+
// Note: kept as an explicit list here rather than importing the harness
|
|
35
|
+
// (which pulls @playwright/test into core); the ui-e2e-scoping.test.mjs sync
|
|
36
|
+
// test fails if a registry entry is added without updating this list, so it
|
|
37
|
+
// can't silently drift.
|
|
38
|
+
export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
|
|
39
|
+
"docs/presentations/introducing-dev-loops.html",
|
|
40
|
+
"docs/presentations/dev-loops-deep-dive.html",
|
|
41
|
+
"docs/articles/introducing-dev-loops.html",
|
|
42
|
+
"docs/articles/dev-loops-deep-dive.html",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export const VIEWER_ARTIFACT_ID = "inspect-run-viewer";
|
|
46
|
+
|
|
47
|
+
// CI check names that constitute the shared UI e2e coverage. The detect layer
|
|
48
|
+
// reads these from the statusCheckRollup to set uiE2ePassed. Note: a plain
|
|
49
|
+
// name match against the rollup is enough; the gate only needs to know whether
|
|
50
|
+
// the suite passed for this head. Each rendered-artifact family has a stable CI
|
|
51
|
+
// job whose name appears here; an absent check is unknown → fails closed.
|
|
52
|
+
export const UI_E2E_CHECK_NAMES = Object.freeze(["viewer-smoke", "deck-smoke", "article-smoke"]);
|
|
53
|
+
|
|
54
|
+
function normalizePath(filePath) {
|
|
55
|
+
return String(filePath ?? "").trim().replace(/^\.\/+/u, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Match a single explicit "dir/*.ext" glob (one path segment, no recursion).
|
|
59
|
+
function matchesGlob(normalizedPath, glob) {
|
|
60
|
+
const [dir, file] = [glob.slice(0, glob.lastIndexOf("/")), glob.slice(glob.lastIndexOf("/") + 1)];
|
|
61
|
+
if (!file.startsWith("*.")) return normalizedPath === glob;
|
|
62
|
+
const ext = file.slice(1); // ".html"
|
|
63
|
+
if (!normalizedPath.startsWith(`${dir}/`)) return false;
|
|
64
|
+
const rest = normalizedPath.slice(dir.length + 1);
|
|
65
|
+
return rest.length > 0 && !rest.includes("/") && rest.endsWith(ext);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Classify one changed path into a rendered-artifact descriptor, or null.
|
|
69
|
+
// A descriptor carries the path, a stable `id` (the deck filename or the
|
|
70
|
+
// viewer id) and whether that id is registered in the e2e suite.
|
|
71
|
+
export function classifyRenderedArtifactPath(filePath) {
|
|
72
|
+
const normalized = normalizePath(filePath);
|
|
73
|
+
if (normalized.length === 0) return null;
|
|
74
|
+
|
|
75
|
+
if (VIEWER_SOURCE_PATHS.includes(normalized)) {
|
|
76
|
+
return { path: normalized, kind: "viewer", id: VIEWER_ARTIFACT_ID, registered: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const glob of RENDERED_ARTIFACT_GLOBS) {
|
|
80
|
+
if (matchesGlob(normalized, glob)) {
|
|
81
|
+
// Key registration on the FULL repo-relative path so an article and a
|
|
82
|
+
// deck that share a basename are distinct artifacts. id is the full path
|
|
83
|
+
// too, so the fail-closed reason names the exact file to register.
|
|
84
|
+
return {
|
|
85
|
+
path: normalized,
|
|
86
|
+
kind: normalized.startsWith("docs/articles/") ? "article" : "deck",
|
|
87
|
+
id: normalized,
|
|
88
|
+
registered: REGISTERED_ARTIFACT_PATHS.includes(normalized),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Deterministic UI e2e scoping check.
|
|
97
|
+
*
|
|
98
|
+
* @param {string[]} changedPaths - PR changed-file paths.
|
|
99
|
+
* @param {{ uiE2ePassed?: boolean|null }} [coverage]
|
|
100
|
+
* uiE2ePassed: whether the shared UI e2e suite passed for this head.
|
|
101
|
+
* null/undefined means "not run / unknown" → fails closed.
|
|
102
|
+
* @returns {{
|
|
103
|
+
* required: boolean,
|
|
104
|
+
* artifacts: Array<{path,kind,id,registered}>,
|
|
105
|
+
* unregistered: string[],
|
|
106
|
+
* satisfied: boolean,
|
|
107
|
+
* reason: string|null,
|
|
108
|
+
* }}
|
|
109
|
+
*/
|
|
110
|
+
export function evaluateUiE2eScoping(changedPaths = [], { uiE2ePassed = null } = {}) {
|
|
111
|
+
const artifacts = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const p of Array.isArray(changedPaths) ? changedPaths : []) {
|
|
114
|
+
const descriptor = classifyRenderedArtifactPath(p);
|
|
115
|
+
if (descriptor && !seen.has(descriptor.path)) {
|
|
116
|
+
seen.add(descriptor.path);
|
|
117
|
+
artifacts.push(descriptor);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const required = artifacts.length > 0;
|
|
122
|
+
if (!required) {
|
|
123
|
+
return { required: false, artifacts, unregistered: [], satisfied: true, reason: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fail closed: any touched rendered artifact that is not registered blocks
|
|
127
|
+
// and names itself so the fix is unambiguous (register it in the suite).
|
|
128
|
+
const unregistered = artifacts.filter((a) => !a.registered).map((a) => a.id);
|
|
129
|
+
if (unregistered.length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
required: true,
|
|
132
|
+
artifacts,
|
|
133
|
+
unregistered,
|
|
134
|
+
satisfied: false,
|
|
135
|
+
reason:
|
|
136
|
+
`UI e2e coverage is required: this PR changes rendered artifact(s) ` +
|
|
137
|
+
`${unregistered.join(", ")} that are not registered in the shared UI e2e suite ` +
|
|
138
|
+
`(DECK_REGISTRY or ARTICLE_REGISTRY in test/playwright/harness/deck-fit-harness.mjs, ` +
|
|
139
|
+
`or VIEWER_REGISTRY in test/playwright/harness/inspect-run-viewer-harness.mjs). ` +
|
|
140
|
+
`Register the artifact and add a spec that runs ` +
|
|
141
|
+
`the mobile + desktop assertions before this gate can pass.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// All touched artifacts are registered — coverage must have actually passed.
|
|
146
|
+
if (uiE2ePassed !== true) {
|
|
147
|
+
const touched = artifacts.map((a) => a.id).join(", ");
|
|
148
|
+
return {
|
|
149
|
+
required: true,
|
|
150
|
+
artifacts,
|
|
151
|
+
unregistered: [],
|
|
152
|
+
satisfied: false,
|
|
153
|
+
reason:
|
|
154
|
+
`UI e2e coverage is required: this PR changes rendered artifact(s) ${touched}, ` +
|
|
155
|
+
`but the shared UI e2e suite (mobile + desktop) has not passed for this head ` +
|
|
156
|
+
`(uiE2ePassed=${String(uiE2ePassed)}). Run the UI/mobile e2e loop and let it pass ` +
|
|
157
|
+
`before this gate can proceed.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { required: true, artifacts, unregistered: [], satisfied: true, reason: null };
|
|
162
|
+
}
|