@nimiplatform/nimi-coding 0.1.0 → 0.2.1
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/CHANGELOG.md +19 -0
- package/CODE_OF_CONDUCT.md +28 -0
- package/CONTRIBUTING.md +45 -0
- package/README.md +371 -344
- package/README.zh-CN.md +307 -0
- package/SECURITY.md +26 -0
- package/adapters/oh-my-codex/README.md +8 -9
- package/cli/commands/audit-sweep.mjs +10 -10
- package/cli/commands/classify-spec-tree.mjs +5 -0
- package/cli/commands/closeout.mjs +3 -0
- package/cli/commands/generate-spec-derived-docs.mjs +20 -0
- package/cli/commands/generate-spec-migration-plan.mjs +30 -0
- package/cli/commands/start.mjs +5 -1
- package/cli/commands/surface-validator-command.mjs +49 -0
- package/cli/commands/sweep-design.mjs +295 -0
- package/cli/commands/sweep.mjs +22 -0
- package/cli/commands/sync.mjs +132 -0
- package/cli/commands/topic-formatters.mjs +8 -8
- package/cli/commands/validate-ai-governance.mjs +167 -46
- package/cli/commands/validate-domain-admission.mjs +5 -0
- package/cli/commands/validate-guidance-bodies.mjs +5 -0
- package/cli/commands/validate-placement.mjs +5 -0
- package/cli/commands/validate-projection-edges.mjs +5 -0
- package/cli/commands/validate-spec-audit.mjs +5 -1
- package/cli/commands/validate-table-family.mjs +5 -0
- package/cli/commands/validate-tracked-output-admission.mjs +5 -0
- package/cli/constants.mjs +5 -49
- package/cli/help.mjs +33 -11
- package/cli/index.mjs +20 -2
- package/cli/lib/audit-sweep-runtime/admissions.mjs +38 -29
- package/cli/lib/audit-sweep-runtime/audit-validity.mjs +8 -0
- package/cli/lib/audit-sweep-runtime/chunks.mjs +11 -11
- package/cli/lib/audit-sweep-runtime/closeout.mjs +8 -8
- package/cli/lib/audit-sweep-runtime/codex-auditor-evidence.mjs +3 -3
- package/cli/lib/audit-sweep-runtime/codex-auditor.mjs +10 -10
- package/cli/lib/audit-sweep-runtime/common.mjs +7 -7
- package/cli/lib/audit-sweep-runtime/format.mjs +3 -3
- package/cli/lib/audit-sweep-runtime/ingest.mjs +8 -8
- package/cli/lib/audit-sweep-runtime/inventory-spec-chunks.mjs +24 -27
- package/cli/lib/audit-sweep-runtime/inventory.mjs +58 -18
- package/cli/lib/audit-sweep-runtime/ledger.mjs +1 -1
- package/cli/lib/audit-sweep-runtime/p0p1-profile.mjs +2 -2
- package/cli/lib/audit-sweep-runtime/remediation.mjs +6 -6
- package/cli/lib/audit-sweep-runtime/rerun.mjs +6 -6
- package/cli/lib/audit-sweep-runtime/status.mjs +1 -1
- package/cli/lib/audit-sweep-runtime/validators.mjs +2 -2
- package/cli/lib/authority-convergence.mjs +397 -2
- package/cli/lib/blueprint-audit.mjs +5 -5
- package/cli/lib/closeout.mjs +126 -3
- package/cli/lib/contracts.mjs +21 -17
- package/cli/lib/handoff.mjs +29 -11
- package/cli/lib/high-risk-admission.mjs +60 -11
- package/cli/lib/high-risk-decision.mjs +31 -2
- package/cli/lib/high-risk-ingest.mjs +5 -1
- package/cli/lib/high-risk-review.mjs +5 -1
- package/cli/lib/internal/contracts-parse.mjs +195 -24
- package/cli/lib/internal/contracts-validators.mjs +3 -2
- package/cli/lib/internal/doctor-bootstrap-surface.mjs +82 -35
- package/cli/lib/internal/doctor-delegated-surface.mjs +1 -1
- package/cli/lib/internal/doctor-finalize.mjs +12 -8
- package/cli/lib/internal/doctor-inspectors.mjs +34 -1
- package/cli/lib/internal/governance/ai/ai-context-budget-core.mjs +74 -12
- package/cli/lib/internal/governance/ai/ai-structure-budget-core.mjs +24 -6
- package/cli/lib/internal/governance/ai/check-agents-freshness.mjs +18 -23
- package/cli/lib/internal/surface-taxonomy-validators.mjs +931 -0
- package/cli/lib/internal/validators-spec.mjs +229 -20
- package/cli/lib/sweep-design-runtime/common.mjs +246 -0
- package/cli/lib/sweep-design-runtime/engine.mjs +733 -0
- package/cli/lib/sweep-design-runtime/fix-topic.mjs +414 -0
- package/cli/lib/sweep-design-runtime/lifecycle.mjs +54 -0
- package/cli/lib/sweep-design-runtime/results.mjs +324 -0
- package/cli/lib/sweep-design.mjs +8 -0
- package/cli/lib/sync.mjs +143 -0
- package/cli/lib/topic-artifacts.mjs +186 -0
- package/cli/lib/topic-authority-coverage.mjs +73 -0
- package/cli/lib/topic-closeout.mjs +560 -0
- package/cli/lib/topic-common.mjs +404 -0
- package/cli/lib/topic-decisions.mjs +332 -0
- package/cli/lib/topic-draft-packets.mjs +126 -7
- package/cli/lib/topic-execution.mjs +515 -0
- package/cli/lib/topic-goal.mjs +112 -33
- package/cli/lib/topic-ledger.mjs +281 -0
- package/cli/lib/topic-lifecycle-artifacts.mjs +173 -0
- package/cli/lib/topic-root-validation.mjs +288 -0
- package/cli/lib/topic-runner-commands.mjs +174 -0
- package/cli/lib/topic-runner-deferral.mjs +532 -0
- package/cli/lib/topic-runner-stale-gates.mjs +114 -0
- package/cli/lib/topic-runner-validation.mjs +138 -0
- package/cli/lib/topic-runner.mjs +109 -154
- package/cli/lib/topic-scaffold.mjs +252 -0
- package/cli/lib/topic-waves.mjs +403 -0
- package/cli/lib/topic.mjs +81 -93
- package/cli/lib/value-helpers.mjs +6 -1
- package/cli/seeds/bootstrap.mjs +96 -20
- package/cli/seeds/seed-policy.yaml +67 -0
- package/config/bootstrap.yaml +1 -1
- package/config/skill-manifest.yaml +4 -2
- package/config/spec-generation-inputs.yaml +41 -19
- package/contracts/audit-remediation-map.schema.yaml +1 -0
- package/contracts/audit-sweep-result.yaml +4 -0
- package/contracts/domain-admission.schema.yaml +56 -0
- package/contracts/migration-inventory.schema.yaml +80 -0
- package/contracts/negative-fixtures.yaml +91 -0
- package/contracts/placement-contract.schema.yaml +163 -0
- package/contracts/projection-edge.schema.yaml +130 -0
- package/contracts/shared-enums.yaml +68 -0
- package/contracts/spec-generation-audit.schema.yaml +19 -4
- package/contracts/spec-generation-inputs.schema.yaml +130 -29
- package/contracts/spec-reconstruction-result.yaml +9 -5
- package/contracts/surface-taxonomy.schema.yaml +201 -0
- package/contracts/sweep-design-result.yaml +349 -0
- package/contracts/table-family.schema.yaml +121 -0
- package/contracts/topic-goal.schema.yaml +10 -1
- package/contracts/tracked-output-admission.schema.yaml +70 -0
- package/contracts/workflow-consumer.schema.yaml +112 -0
- package/methodology/audit-sweep-p0p1-recall.yaml +1 -1
- package/methodology/spec-reconstruction.yaml +53 -30
- package/package.json +19 -4
- package/spec/_meta/command-gating-matrix.yaml +33 -0
- package/spec/_meta/generate-drift-migration-checklist.yaml +44 -62
- package/spec/_meta/governance-routing-cutover-checklist.yaml +3 -3
- package/spec/_meta/phase2-impacted-surface-matrix.yaml +14 -14
- package/spec/_meta/spec-authority-cutover-readiness.yaml +3 -5
- package/spec/_meta/spec-tree-model.yaml +104 -36
- package/spec/bootstrap-state.yaml +36 -36
- package/spec/product-scope.yaml +13 -10
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
|
|
5
|
+
import { pathExists, readTextIfFile } from "./fs-helpers.mjs";
|
|
6
|
+
import { parseYamlText } from "./yaml-helpers.mjs";
|
|
7
|
+
import {
|
|
8
|
+
TOPIC_ID_PATTERN,
|
|
9
|
+
TOPIC_ROOT,
|
|
10
|
+
TOPIC_SLUG_PATTERN,
|
|
11
|
+
formatDate,
|
|
12
|
+
loadTopicRuntimeAuthority,
|
|
13
|
+
toPortableRelativePath,
|
|
14
|
+
} from "./topic-common.mjs";
|
|
15
|
+
|
|
16
|
+
export function titleFromSlug(slug) {
|
|
17
|
+
return slug
|
|
18
|
+
.split("-")
|
|
19
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
20
|
+
.join(" ");
|
|
21
|
+
}
|
|
22
|
+
export function deriveTopicId(slug, date = new Date()) {
|
|
23
|
+
return TOPIC_ID_PATTERN.test(slug) ? slug : `${formatDate(date)}-${slug}`;
|
|
24
|
+
}
|
|
25
|
+
export function getTopicRoot(projectRoot) {
|
|
26
|
+
return path.join(projectRoot, TOPIC_ROOT);
|
|
27
|
+
}
|
|
28
|
+
export function getTopicStateRoot(projectRoot, state) {
|
|
29
|
+
return path.join(getTopicRoot(projectRoot), state);
|
|
30
|
+
}
|
|
31
|
+
export function isTopicPathInput(value) {
|
|
32
|
+
return typeof value == "string" && (value.includes("/") || value.startsWith("."));
|
|
33
|
+
}
|
|
34
|
+
export function buildCreatePayload(options, authority) {
|
|
35
|
+
return {
|
|
36
|
+
topic_id: options.topicId,
|
|
37
|
+
state: "proposal",
|
|
38
|
+
created_at: options.today,
|
|
39
|
+
last_transition_at: options.today,
|
|
40
|
+
last_transition_reason: "topic_created_via_nimicoding_topic_create",
|
|
41
|
+
title: options.title,
|
|
42
|
+
mode: options.mode,
|
|
43
|
+
posture: options.posture,
|
|
44
|
+
design_policy: options.designPolicy,
|
|
45
|
+
parallel_truth: options.parallelTruth,
|
|
46
|
+
layering: options.layering,
|
|
47
|
+
risk: options.risk,
|
|
48
|
+
applicability: options.applicability,
|
|
49
|
+
entry_justification: options.justification,
|
|
50
|
+
execution_mode: options.executionMode,
|
|
51
|
+
selected_next_target: "topic_design_baseline",
|
|
52
|
+
current_true_close_status: "not_started",
|
|
53
|
+
forbidden_shortcuts: authority.defaultForbiddenShortcuts,
|
|
54
|
+
waves: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function buildReadme(topic) {
|
|
58
|
+
return `# ${topic.title}
|
|
59
|
+
State: \`${topic.state}\`
|
|
60
|
+
This topic was created by \`nimicoding topic create\`.
|
|
61
|
+
## Purpose
|
|
62
|
+
TODO: explain why this work needs topic-level governance rather than the ordinary non-topic path.
|
|
63
|
+
## Entry Posture
|
|
64
|
+
- mode: \`${topic.mode}\`
|
|
65
|
+
- posture: \`${topic.posture}\`
|
|
66
|
+
- design policy: \`${topic.design_policy}\`
|
|
67
|
+
- applicability: \`${topic.applicability}\`
|
|
68
|
+
- execution mode: \`${topic.execution_mode}\`
|
|
69
|
+
## Current Next Action
|
|
70
|
+
- selected_next_target: \`${topic.selected_next_target}\`
|
|
71
|
+
- TODO: freeze the first bounded wave target before admission
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
export function buildDesign(topicId) {
|
|
75
|
+
return `# Design
|
|
76
|
+
Topic: \`${topicId}\`
|
|
77
|
+
This file is the index for split design companions.
|
|
78
|
+
- TODO: add subtopic design files as the topic grows
|
|
79
|
+
- TODO: keep this file as an index rather than collapsing the whole topic into one document
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
export function buildSimpleCompanion(title, topicId, bullets) {
|
|
83
|
+
return `# ${title}
|
|
84
|
+
Topic: \`${topicId}\`
|
|
85
|
+
${bullets.map((item) => `- ${item}`).join(`
|
|
86
|
+
`)}
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
export async function writeTopicScaffold(topicDir, topic) {
|
|
90
|
+
const files = new Map([
|
|
91
|
+
["topic.yaml", YAML.stringify(topic)],
|
|
92
|
+
["README.md", buildReadme(topic)],
|
|
93
|
+
["design.md", buildDesign(topic.topic_id)],
|
|
94
|
+
["preflight.md", buildSimpleCompanion("Preflight", topic.topic_id, ["TODO", "TODO"])],
|
|
95
|
+
["waves.md", buildSimpleCompanion("Waves", topic.topic_id, ["TODO", "TODO"])],
|
|
96
|
+
[
|
|
97
|
+
"candidate-wave-plan.md",
|
|
98
|
+
buildSimpleCompanion("Candidate Wave Plan", topic.topic_id, ["TODO", "TODO"]),
|
|
99
|
+
],
|
|
100
|
+
["closeout.md", buildSimpleCompanion("Closeout", topic.topic_id, ["TODO", "TODO"])],
|
|
101
|
+
[
|
|
102
|
+
"implementation-doctrine.md",
|
|
103
|
+
buildSimpleCompanion("Implementation Doctrine", topic.topic_id, ["TODO", "TODO"]),
|
|
104
|
+
],
|
|
105
|
+
[
|
|
106
|
+
"admission-checklists.md",
|
|
107
|
+
buildSimpleCompanion("Admission Checklists", topic.topic_id, ["TODO", "TODO"]),
|
|
108
|
+
],
|
|
109
|
+
[
|
|
110
|
+
"manager-session-protocol.md",
|
|
111
|
+
buildSimpleCompanion("Manager Session Protocol", topic.topic_id, ["TODO", "TODO"]),
|
|
112
|
+
],
|
|
113
|
+
["manager-prompts.md", buildSimpleCompanion("Manager Prompts", topic.topic_id, ["TODO"])],
|
|
114
|
+
]);
|
|
115
|
+
await mkdir(topicDir, { recursive: false });
|
|
116
|
+
for (const [fileName, contents] of files.entries())
|
|
117
|
+
await writeFile(path.join(topicDir, fileName), contents, "utf8");
|
|
118
|
+
}
|
|
119
|
+
export function validateTopicSlug(value) {
|
|
120
|
+
return TOPIC_SLUG_PATTERN.test(value);
|
|
121
|
+
}
|
|
122
|
+
export function validateTopicId(value) {
|
|
123
|
+
return TOPIC_ID_PATTERN.test(value);
|
|
124
|
+
}
|
|
125
|
+
export async function findTopicDirectory(projectRoot, input = null) {
|
|
126
|
+
const authority = await loadTopicRuntimeAuthority(projectRoot),
|
|
127
|
+
topicStatePattern = authority.topicStates.join("|");
|
|
128
|
+
if (!input) {
|
|
129
|
+
const current = process.cwd(),
|
|
130
|
+
match = toPortableRelativePath(path.relative(projectRoot, current)).match(
|
|
131
|
+
new RegExp(
|
|
132
|
+
`^\\.nimi/topics/(${topicStatePattern})/(\\d{4}-\\d{2}-\\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)`,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
return match
|
|
136
|
+
? {
|
|
137
|
+
ok: true,
|
|
138
|
+
topicDir: path.join(projectRoot, ".nimi", "topics", match[1], match[2]),
|
|
139
|
+
topicId: match[2],
|
|
140
|
+
state: match[1],
|
|
141
|
+
}
|
|
142
|
+
: {
|
|
143
|
+
ok: false,
|
|
144
|
+
error:
|
|
145
|
+
"No topic id or topic path was provided, and the current working directory is not inside a topic root.",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (isTopicPathInput(input)) {
|
|
149
|
+
const topicDir = path.resolve(projectRoot, input),
|
|
150
|
+
match = toPortableRelativePath(path.relative(projectRoot, topicDir)).match(
|
|
151
|
+
new RegExp(
|
|
152
|
+
`^\\.nimi/topics/(${topicStatePattern})/(\\d{4}-\\d{2}-\\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*)$`,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
return match
|
|
156
|
+
? { ok: true, topicDir, topicId: match[2], state: match[1] }
|
|
157
|
+
: { ok: false, error: `Topic path must resolve to .nimi/topics/<state>/<topic-id>: ${input}` };
|
|
158
|
+
}
|
|
159
|
+
const matches = [];
|
|
160
|
+
for (const state of authority.topicStates) {
|
|
161
|
+
const candidate = path.join(getTopicStateRoot(projectRoot, state), input);
|
|
162
|
+
(await pathExists(candidate))?.isDirectory() &&
|
|
163
|
+
matches.push({ state, topicDir: candidate, topicId: input });
|
|
164
|
+
}
|
|
165
|
+
return matches.length === 1
|
|
166
|
+
? { ok: true, ...matches[0] }
|
|
167
|
+
: matches.length > 1
|
|
168
|
+
? {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: `Topic id resolves to multiple lifecycle roots and must be disambiguated by path: ${input}`,
|
|
171
|
+
}
|
|
172
|
+
: { ok: false, error: `Topic not found under ${TOPIC_ROOT}: ${input}` };
|
|
173
|
+
}
|
|
174
|
+
export async function resolveTopicProjectRoot(startDir) {
|
|
175
|
+
let currentDir = path.resolve(startDir);
|
|
176
|
+
for (;;) {
|
|
177
|
+
if ((await pathExists(path.join(currentDir, ".nimi")))?.isDirectory()) return currentDir;
|
|
178
|
+
const parentDir = path.dirname(currentDir);
|
|
179
|
+
if (parentDir === currentDir) return path.resolve(startDir);
|
|
180
|
+
currentDir = parentDir;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export async function loadTopicReport(projectRoot, input = null) {
|
|
184
|
+
const resolved = await findTopicDirectory(projectRoot, input);
|
|
185
|
+
if (!resolved.ok) return resolved;
|
|
186
|
+
const topicYamlPath = path.join(resolved.topicDir, "topic.yaml"),
|
|
187
|
+
topicYamlText = await readTextIfFile(topicYamlPath);
|
|
188
|
+
if (topicYamlText === null)
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: `Missing topic.yaml at ${toPortableRelativePath(path.relative(projectRoot, topicYamlPath))}`,
|
|
192
|
+
};
|
|
193
|
+
const topic = parseYamlText(topicYamlText);
|
|
194
|
+
return !topic || typeof topic != "object"
|
|
195
|
+
? {
|
|
196
|
+
ok: false,
|
|
197
|
+
error: `topic.yaml is not valid YAML at ${toPortableRelativePath(path.relative(projectRoot, topicYamlPath))}`,
|
|
198
|
+
}
|
|
199
|
+
: { ok: true, ...resolved, topicYamlPath, topicYamlText, topic };
|
|
200
|
+
}
|
|
201
|
+
export function getTopicWaves(topic) {
|
|
202
|
+
return Array.isArray(topic.waves) ? topic.waves.map((entry) => ({ ...entry })) : [];
|
|
203
|
+
}
|
|
204
|
+
export function findDeterministicNextWave(topic) {
|
|
205
|
+
const waves = getTopicWaves(topic),
|
|
206
|
+
terminalIds = new Set(
|
|
207
|
+
waves
|
|
208
|
+
.filter((entry) => ["closed", "retired", "superseded"].includes(entry.state))
|
|
209
|
+
.map((entry) => entry.wave_id),
|
|
210
|
+
),
|
|
211
|
+
ready = waves.filter(
|
|
212
|
+
(entry) =>
|
|
213
|
+
!["closed", "retired", "superseded"].includes(entry.state) &&
|
|
214
|
+
["candidate", "preflight_draft"].includes(entry.state) &&
|
|
215
|
+
(Array.isArray(entry.deps) ? entry.deps : []).every((dep) => terminalIds.has(dep)),
|
|
216
|
+
);
|
|
217
|
+
return ready.length > 0 ? ready[0] : null;
|
|
218
|
+
}
|
|
219
|
+
export async function writeTopicYaml(topicYamlPath, topic) {
|
|
220
|
+
await writeFile(topicYamlPath, YAML.stringify(topic), "utf8");
|
|
221
|
+
}
|
|
222
|
+
export async function moveTopicDirectoryForState(projectRoot, currentDir, topicId, targetState) {
|
|
223
|
+
const targetDir = path.join(getTopicStateRoot(projectRoot, targetState), topicId);
|
|
224
|
+
return currentDir === targetDir
|
|
225
|
+
? { topicDir: currentDir, topicYamlPath: path.join(currentDir, "topic.yaml") }
|
|
226
|
+
: (await mkdir(path.dirname(targetDir), { recursive: true }),
|
|
227
|
+
await rename(currentDir, targetDir),
|
|
228
|
+
{ topicDir: targetDir, topicYamlPath: path.join(targetDir, "topic.yaml") });
|
|
229
|
+
}
|
|
230
|
+
export function topicHasEnrichedShape(topic, authority) {
|
|
231
|
+
return authority.enrichedRequiredFields.every((field) => {
|
|
232
|
+
const value = topic[field];
|
|
233
|
+
return field === "selected_next_target"
|
|
234
|
+
? value === null ||
|
|
235
|
+
value === "topic_design_baseline" ||
|
|
236
|
+
(typeof value == "string" && value.length > 0)
|
|
237
|
+
: value != null && value !== "" && (!Array.isArray(value) || value.length > 0);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
export function buildTopicNow() {
|
|
241
|
+
return formatDate(new Date());
|
|
242
|
+
}
|
|
243
|
+
export function isIsoUtcTimestamp(value) {
|
|
244
|
+
if (
|
|
245
|
+
typeof value !== "string" ||
|
|
246
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
|
|
247
|
+
)
|
|
248
|
+
return false;
|
|
249
|
+
const parsed = new Date(value),
|
|
250
|
+
canonicalValue = value.includes(".") ? value : value.replace("Z", ".000Z");
|
|
251
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === canonicalValue;
|
|
252
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { loadAuthorityConvergencePolicy } from "./authority-convergence.mjs";
|
|
5
|
+
import { loadTopicRuntimeContracts } from "./contracts.mjs";
|
|
6
|
+
import { readTextIfFile } from "./fs-helpers.mjs";
|
|
7
|
+
import { fileReferencesWave } from "./topic-lifecycle-artifacts.mjs";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_TOPIC_RUNTIME_AUTHORITY,
|
|
10
|
+
PENDING_ENTRY_BLOCKER_STATES,
|
|
11
|
+
WAVE_ID_PATTERN,
|
|
12
|
+
loadTopicRuntimeAuthority,
|
|
13
|
+
toPortableRelativePath,
|
|
14
|
+
} from "./topic-common.mjs";
|
|
15
|
+
import { pendingNoteFilename, readFrontmatterObject } from "./topic-artifacts.mjs";
|
|
16
|
+
import {
|
|
17
|
+
buildTopicNow,
|
|
18
|
+
findDeterministicNextWave,
|
|
19
|
+
getTopicWaves,
|
|
20
|
+
isIsoUtcTimestamp,
|
|
21
|
+
loadTopicReport,
|
|
22
|
+
moveTopicDirectoryForState,
|
|
23
|
+
topicHasEnrichedShape,
|
|
24
|
+
writeTopicYaml,
|
|
25
|
+
} from "./topic-scaffold.mjs";
|
|
26
|
+
|
|
27
|
+
export async function collectWaveArtifactEvidence(topicDir, waveId) {
|
|
28
|
+
const files = (await readdir(topicDir, { withFileTypes: true }))
|
|
29
|
+
.filter((entry) => entry.isFile())
|
|
30
|
+
.map((entry) => entry.name);
|
|
31
|
+
return {
|
|
32
|
+
packetRefs: files.filter(
|
|
33
|
+
(name) => name.startsWith("packet-") && fileReferencesWave(name, waveId),
|
|
34
|
+
),
|
|
35
|
+
resultRefs: files.filter(
|
|
36
|
+
(name) => name.startsWith("result-") && fileReferencesWave(name, waveId),
|
|
37
|
+
),
|
|
38
|
+
closeoutRefs: files.filter(
|
|
39
|
+
(name) => name.startsWith("closeout-") && fileReferencesWave(name, waveId),
|
|
40
|
+
),
|
|
41
|
+
remediationRefs: files.filter(
|
|
42
|
+
(name) => name.includes("remediation") && fileReferencesWave(name, waveId),
|
|
43
|
+
),
|
|
44
|
+
overflowRefs: files.filter(
|
|
45
|
+
(name) => name.includes("overflow-continuation") && fileReferencesWave(name, waveId),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function loadPendingNote(topicDir) {
|
|
50
|
+
const notePath = path.join(topicDir, pendingNoteFilename()),
|
|
51
|
+
noteText = await readTextIfFile(notePath);
|
|
52
|
+
if (noteText === null)
|
|
53
|
+
return { ok: false, notePath, error: `Missing pending note artifact: ${pendingNoteFilename()}` };
|
|
54
|
+
const note = readFrontmatterObject(noteText);
|
|
55
|
+
return note
|
|
56
|
+
? { ok: true, notePath, note }
|
|
57
|
+
: { ok: false, notePath, error: "Pending note artifact frontmatter is invalid" };
|
|
58
|
+
}
|
|
59
|
+
export function getPendingEntryBlockers(topic) {
|
|
60
|
+
return getTopicWaves(topic)
|
|
61
|
+
.filter((entry) => PENDING_ENTRY_BLOCKER_STATES.has(entry.state))
|
|
62
|
+
.map((entry) => `${entry.wave_id}:${entry.state}`);
|
|
63
|
+
}
|
|
64
|
+
export async function loadTopicValidationPolicy(projectRoot) {
|
|
65
|
+
const parsed = (await loadTopicRuntimeContracts(projectRoot)).validationPolicy.data,
|
|
66
|
+
entries = Array.isArray(parsed?.topic_validation_policy?.ignore_for_default_validate)
|
|
67
|
+
? parsed.topic_validation_policy.ignore_for_default_validate
|
|
68
|
+
: [],
|
|
69
|
+
ignoredTopicIds = new Map();
|
|
70
|
+
for (const entry of entries)
|
|
71
|
+
entry &&
|
|
72
|
+
typeof entry.topic_id == "string" &&
|
|
73
|
+
entry.topic_id.length > 0 &&
|
|
74
|
+
ignoredTopicIds.set(entry.topic_id, {
|
|
75
|
+
reason: typeof entry.reason == "string" ? entry.reason : null,
|
|
76
|
+
posture: typeof entry.posture == "string" ? entry.posture : null,
|
|
77
|
+
});
|
|
78
|
+
const semantics = parsed?.topic_validation_policy?.ignored_topic_validate_semantics ?? {};
|
|
79
|
+
return {
|
|
80
|
+
ignoredTopicIds,
|
|
81
|
+
ignoredTopicValidateSemantics: {
|
|
82
|
+
status:
|
|
83
|
+
typeof semantics.status == "string"
|
|
84
|
+
? semantics.status
|
|
85
|
+
: DEFAULT_TOPIC_RUNTIME_AUTHORITY.ignoredTopicValidateSemantics.status,
|
|
86
|
+
canonicalSuccess:
|
|
87
|
+
typeof semantics.canonical_success == "boolean"
|
|
88
|
+
? semantics.canonical_success
|
|
89
|
+
: DEFAULT_TOPIC_RUNTIME_AUTHORITY.ignoredTopicValidateSemantics.canonicalSuccess,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function validateWaveId(value) {
|
|
94
|
+
return WAVE_ID_PATTERN.test(value);
|
|
95
|
+
}
|
|
96
|
+
export function normalizeDeps(value) {
|
|
97
|
+
return Array.isArray(value) ? value.map((entry) => String(entry)) : [];
|
|
98
|
+
}
|
|
99
|
+
export function validateGraphFromTopic(topic) {
|
|
100
|
+
const waves = getTopicWaves(topic),
|
|
101
|
+
checks = [],
|
|
102
|
+
warnings = [],
|
|
103
|
+
waveIds = waves.map((entry) => entry.wave_id),
|
|
104
|
+
uniqueWaveIds = new Set(waveIds);
|
|
105
|
+
checks.push({
|
|
106
|
+
id: "wave_ids_unique",
|
|
107
|
+
ok: uniqueWaveIds.size === waveIds.length,
|
|
108
|
+
reason:
|
|
109
|
+
uniqueWaveIds.size === waveIds.length
|
|
110
|
+
? "wave ids are unique"
|
|
111
|
+
: "duplicate wave ids exist in topic.yaml waves[]",
|
|
112
|
+
});
|
|
113
|
+
const invalidWaveIds = waveIds.filter((entry) => !validateWaveId(entry));
|
|
114
|
+
checks.push({
|
|
115
|
+
id: "wave_ids_valid",
|
|
116
|
+
ok: invalidWaveIds.length === 0,
|
|
117
|
+
reason:
|
|
118
|
+
invalidWaveIds.length === 0
|
|
119
|
+
? "wave ids use the canonical wave-<n>-slug shape"
|
|
120
|
+
: `invalid wave ids: ${invalidWaveIds.join(", ")}`,
|
|
121
|
+
});
|
|
122
|
+
const missingDeps = [],
|
|
123
|
+
selectedWaveIds = [],
|
|
124
|
+
retiredSelected = [];
|
|
125
|
+
for (const wave of waves) {
|
|
126
|
+
const deps = normalizeDeps(wave.deps);
|
|
127
|
+
for (const dep of deps) uniqueWaveIds.has(dep) || missingDeps.push(`${wave.wave_id}->${dep}`);
|
|
128
|
+
(wave.selected === true && selectedWaveIds.push(wave.wave_id),
|
|
129
|
+
wave.selected === true &&
|
|
130
|
+
["retired", "superseded"].includes(wave.state) &&
|
|
131
|
+
retiredSelected.push(wave.wave_id));
|
|
132
|
+
}
|
|
133
|
+
(checks.push({
|
|
134
|
+
id: "wave_dependencies_resolve",
|
|
135
|
+
ok: missingDeps.length === 0,
|
|
136
|
+
reason:
|
|
137
|
+
missingDeps.length === 0
|
|
138
|
+
? "all wave dependencies resolve inside the topic"
|
|
139
|
+
: `missing dependency refs: ${missingDeps.join(", ")}`,
|
|
140
|
+
}),
|
|
141
|
+
checks.push({
|
|
142
|
+
id: "selected_wave_unique",
|
|
143
|
+
ok: selectedWaveIds.length <= 1,
|
|
144
|
+
reason:
|
|
145
|
+
selectedWaveIds.length <= 1
|
|
146
|
+
? "selected wave is unique"
|
|
147
|
+
: `multiple selected waves exist: ${selectedWaveIds.join(", ")}`,
|
|
148
|
+
}));
|
|
149
|
+
const selectedMatchesTopicTarget =
|
|
150
|
+
selectedWaveIds.length === 0
|
|
151
|
+
? topic.selected_next_target === "topic_design_baseline" ||
|
|
152
|
+
topic.selected_next_target === null
|
|
153
|
+
: selectedWaveIds[0] === topic.selected_next_target;
|
|
154
|
+
(checks.push({
|
|
155
|
+
id: "selected_wave_matches_topic_target",
|
|
156
|
+
ok: selectedMatchesTopicTarget,
|
|
157
|
+
reason: selectedMatchesTopicTarget
|
|
158
|
+
? "selected wave matches topic.selected_next_target"
|
|
159
|
+
: `selected wave and topic.selected_next_target diverge (${selectedWaveIds[0] ?? "none"} vs ${topic.selected_next_target ?? "none"})`,
|
|
160
|
+
}),
|
|
161
|
+
checks.push({
|
|
162
|
+
id: "retired_or_superseded_not_selected",
|
|
163
|
+
ok: retiredSelected.length === 0,
|
|
164
|
+
reason:
|
|
165
|
+
retiredSelected.length === 0
|
|
166
|
+
? "retired or superseded waves are not selected"
|
|
167
|
+
: `retired/superseded waves remain selected: ${retiredSelected.join(", ")}`,
|
|
168
|
+
}));
|
|
169
|
+
const visiting = new Set(),
|
|
170
|
+
visited = new Set();
|
|
171
|
+
let cycleRef = null;
|
|
172
|
+
const waveMap = new Map(waves.map((wave) => [wave.wave_id, wave]));
|
|
173
|
+
function dfs(waveId, trail = []) {
|
|
174
|
+
if (cycleRef) return;
|
|
175
|
+
if (visiting.has(waveId)) {
|
|
176
|
+
cycleRef = [...trail, waveId].join(" -> ");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (visited.has(waveId)) return;
|
|
180
|
+
visiting.add(waveId);
|
|
181
|
+
const wave = waveMap.get(waveId);
|
|
182
|
+
if (wave) for (const dep of normalizeDeps(wave.deps)) dfs(dep, [...trail, waveId]);
|
|
183
|
+
(visiting.delete(waveId), visited.add(waveId));
|
|
184
|
+
}
|
|
185
|
+
for (const waveId of waveIds) dfs(waveId);
|
|
186
|
+
return (
|
|
187
|
+
checks.push({
|
|
188
|
+
id: "graph_acyclic",
|
|
189
|
+
ok: cycleRef === null,
|
|
190
|
+
reason:
|
|
191
|
+
cycleRef === null ? "wave graph is acyclic" : `wave graph contains a cycle: ${cycleRef}`,
|
|
192
|
+
}),
|
|
193
|
+
waves.length === 0 && warnings.push("topic has no machine wave registry yet"),
|
|
194
|
+
{ ok: checks.every((entry) => entry.ok), checks, warnings, waves }
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
export async function validateTopicGraph(projectRoot, input = null) {
|
|
198
|
+
const loaded = await loadTopicReport(projectRoot, input);
|
|
199
|
+
if (!loaded.ok) return { ok: false, error: loaded.error, checks: [], warnings: [] };
|
|
200
|
+
const { validateTopicRoot } = await import("./topic-root-validation.mjs");
|
|
201
|
+
const rootValidation = await validateTopicRoot(projectRoot, input);
|
|
202
|
+
if (!rootValidation.ok) return rootValidation;
|
|
203
|
+
const authority = await loadTopicRuntimeAuthority(projectRoot);
|
|
204
|
+
if (!topicHasEnrichedShape(loaded.topic, authority))
|
|
205
|
+
return {
|
|
206
|
+
...rootValidation,
|
|
207
|
+
ok: false,
|
|
208
|
+
checks: [
|
|
209
|
+
...rootValidation.checks,
|
|
210
|
+
{
|
|
211
|
+
id: "enriched_topic_required_for_wave_graph",
|
|
212
|
+
ok: false,
|
|
213
|
+
reason: "wave graph commands require an enriched topic root",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
warnings: rootValidation.warnings,
|
|
217
|
+
};
|
|
218
|
+
const graph = validateGraphFromTopic(loaded.topic);
|
|
219
|
+
return {
|
|
220
|
+
...rootValidation,
|
|
221
|
+
ok: rootValidation.ok && graph.ok,
|
|
222
|
+
checks: [...rootValidation.checks, ...graph.checks],
|
|
223
|
+
warnings: [...rootValidation.warnings, ...graph.warnings],
|
|
224
|
+
waveCount: graph.waves.length,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export async function validateWaveAdmission(projectRoot, input, waveId) {
|
|
228
|
+
const loaded = await loadTopicReport(projectRoot, input);
|
|
229
|
+
if (!loaded.ok) return { ok: false, error: loaded.error, checks: [], warnings: [] };
|
|
230
|
+
const graphReport = await validateTopicGraph(projectRoot, input),
|
|
231
|
+
wave = getTopicWaves(loaded.topic).find((entry) => entry.wave_id === waveId) ?? null,
|
|
232
|
+
checks = [...(graphReport.checks ?? [])],
|
|
233
|
+
warnings = [...(graphReport.warnings ?? [])];
|
|
234
|
+
if (
|
|
235
|
+
(checks.push({
|
|
236
|
+
id: "wave_exists",
|
|
237
|
+
ok: wave !== null,
|
|
238
|
+
reason: wave ? "wave exists in topic.yaml waves[]" : `wave does not exist: ${waveId}`,
|
|
239
|
+
}),
|
|
240
|
+
!wave)
|
|
241
|
+
)
|
|
242
|
+
return { ...graphReport, ok: false, checks, warnings };
|
|
243
|
+
const dispatchableState = !["retired", "superseded", "closed", "overflowed"].includes(wave.state);
|
|
244
|
+
(checks.push({
|
|
245
|
+
id: "wave_state_dispatchable",
|
|
246
|
+
ok: dispatchableState,
|
|
247
|
+
reason: dispatchableState
|
|
248
|
+
? "wave state is eligible for admission"
|
|
249
|
+
: `wave state is not admissible: ${wave.state}`,
|
|
250
|
+
}),
|
|
251
|
+
checks.push({
|
|
252
|
+
id: "wave_selected",
|
|
253
|
+
ok: wave.selected === true,
|
|
254
|
+
reason: wave.selected === true ? "wave is selected" : "wave must be selected before admission",
|
|
255
|
+
}),
|
|
256
|
+
checks.push({
|
|
257
|
+
id: "selected_target_matches_wave",
|
|
258
|
+
ok: loaded.topic.selected_next_target === waveId,
|
|
259
|
+
reason:
|
|
260
|
+
loaded.topic.selected_next_target === waveId
|
|
261
|
+
? "topic.selected_next_target matches the wave"
|
|
262
|
+
: `topic.selected_next_target does not match wave (${loaded.topic.selected_next_target ?? "none"} vs ${waveId})`,
|
|
263
|
+
}));
|
|
264
|
+
const waveMap = new Map(getTopicWaves(loaded.topic).map((entry) => [entry.wave_id, entry])),
|
|
265
|
+
unmetDeps = normalizeDeps(wave.deps).filter((dep) => waveMap.get(dep)?.state !== "closed");
|
|
266
|
+
checks.push({
|
|
267
|
+
id: "upstream_dependencies_closed",
|
|
268
|
+
ok: unmetDeps.length === 0,
|
|
269
|
+
reason:
|
|
270
|
+
unmetDeps.length === 0
|
|
271
|
+
? "all upstream dependencies are closed"
|
|
272
|
+
: `upstream dependencies are not closed: ${unmetDeps.join(", ")}`,
|
|
273
|
+
});
|
|
274
|
+
const waveStateAllowedForAdmit = ["candidate", "preflight_draft", "needs_revision"].includes(
|
|
275
|
+
wave.state,
|
|
276
|
+
);
|
|
277
|
+
return (
|
|
278
|
+
checks.push({
|
|
279
|
+
id: "wave_state_allows_preflight_admission",
|
|
280
|
+
ok: waveStateAllowedForAdmit,
|
|
281
|
+
reason: waveStateAllowedForAdmit
|
|
282
|
+
? "wave state can move to preflight_admitted"
|
|
283
|
+
: `wave state cannot move to preflight_admitted from ${wave.state}`,
|
|
284
|
+
}),
|
|
285
|
+
{ ...graphReport, ok: graphReport.ok && checks.every((entry) => entry.ok), checks, warnings }
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
export async function addWaveToTopic(projectRoot, input, wave) {
|
|
289
|
+
const loaded = await loadTopicReport(projectRoot, input);
|
|
290
|
+
if (!loaded.ok) return loaded;
|
|
291
|
+
const authority = await loadTopicRuntimeAuthority(projectRoot);
|
|
292
|
+
if (!topicHasEnrichedShape(loaded.topic, authority))
|
|
293
|
+
return { ok: false, error: "Wave commands require an enriched topic root." };
|
|
294
|
+
const waves = getTopicWaves(loaded.topic);
|
|
295
|
+
if (waves.some((entry) => entry.wave_id === wave.wave_id))
|
|
296
|
+
return { ok: false, error: `Wave already exists: ${wave.wave_id}` };
|
|
297
|
+
waves.push(wave);
|
|
298
|
+
const graphPreview = validateGraphFromTopic({ ...loaded.topic, waves }),
|
|
299
|
+
failedCheck = graphPreview.checks.find((entry) => !entry.ok);
|
|
300
|
+
return failedCheck
|
|
301
|
+
? {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: `Wave add refused: ${failedCheck.reason}`,
|
|
304
|
+
checks: graphPreview.checks,
|
|
305
|
+
warnings: graphPreview.warnings,
|
|
306
|
+
}
|
|
307
|
+
: ((loaded.topic.waves = waves),
|
|
308
|
+
await writeTopicYaml(loaded.topicYamlPath, loaded.topic),
|
|
309
|
+
{
|
|
310
|
+
ok: true,
|
|
311
|
+
topicId: loaded.topicId,
|
|
312
|
+
topicRef: toPortableRelativePath(path.relative(projectRoot, loaded.topicDir)),
|
|
313
|
+
waveId: wave.wave_id,
|
|
314
|
+
waveState: wave.state,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
export async function selectWaveInTopic(projectRoot, input, waveId) {
|
|
318
|
+
const loaded = await loadTopicReport(projectRoot, input);
|
|
319
|
+
if (!loaded.ok) return loaded;
|
|
320
|
+
const authority = await loadTopicRuntimeAuthority(projectRoot);
|
|
321
|
+
if (!topicHasEnrichedShape(loaded.topic, authority))
|
|
322
|
+
return { ok: false, error: "Wave commands require an enriched topic root." };
|
|
323
|
+
const waves = getTopicWaves(loaded.topic),
|
|
324
|
+
wave = waves.find((entry) => entry.wave_id === waveId);
|
|
325
|
+
if (!wave) return { ok: false, error: `Wave not found: ${waveId}` };
|
|
326
|
+
if (["retired", "superseded", "closed", "overflowed"].includes(wave.state))
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
error: `Wave select refused: ${waveId} is not selectable from state ${wave.state}`,
|
|
330
|
+
};
|
|
331
|
+
for (const entry of waves) entry.selected = entry.wave_id === waveId;
|
|
332
|
+
return (
|
|
333
|
+
(loaded.topic.waves = waves),
|
|
334
|
+
(loaded.topic.selected_next_target = waveId),
|
|
335
|
+
(loaded.topic.last_transition_at = buildTopicNow()),
|
|
336
|
+
(loaded.topic.last_transition_reason = `selected_${waveId}_as_next_execution_target`),
|
|
337
|
+
await writeTopicYaml(loaded.topicYamlPath, loaded.topic),
|
|
338
|
+
{
|
|
339
|
+
ok: true,
|
|
340
|
+
topicId: loaded.topicId,
|
|
341
|
+
topicRef: toPortableRelativePath(path.relative(projectRoot, loaded.topicDir)),
|
|
342
|
+
waveId,
|
|
343
|
+
selectedNextTarget: loaded.topic.selected_next_target,
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
export async function admitWaveInTopic(projectRoot, input, waveId) {
|
|
348
|
+
let validation = await validateWaveAdmission(projectRoot, input, waveId);
|
|
349
|
+
if (!validation.ok) {
|
|
350
|
+
const loadedForSelection = await loadTopicReport(projectRoot, input),
|
|
351
|
+
wavesForSelection = getTopicWaves(loadedForSelection.topic),
|
|
352
|
+
waveForSelection = wavesForSelection.find((entry) => entry.wave_id === waveId),
|
|
353
|
+
terminalIds = new Set(
|
|
354
|
+
wavesForSelection
|
|
355
|
+
.filter((entry) => ["closed", "retired", "superseded"].includes(entry.state))
|
|
356
|
+
.map((entry) => entry.wave_id),
|
|
357
|
+
),
|
|
358
|
+
depsClosed =
|
|
359
|
+
waveForSelection &&
|
|
360
|
+
(Array.isArray(waveForSelection.deps) ? waveForSelection.deps : []).every((dep) =>
|
|
361
|
+
terminalIds.has(dep),
|
|
362
|
+
),
|
|
363
|
+
canSelectForAdmission =
|
|
364
|
+
loadedForSelection.ok &&
|
|
365
|
+
(loadedForSelection.topic.selected_next_target === null ||
|
|
366
|
+
loadedForSelection.topic.selected_next_target === "topic_design_baseline") &&
|
|
367
|
+
waveForSelection &&
|
|
368
|
+
["candidate", "preflight_draft", "needs_revision"].includes(waveForSelection.state) &&
|
|
369
|
+
depsClosed;
|
|
370
|
+
if (canSelectForAdmission) {
|
|
371
|
+
const selected = await selectWaveInTopic(projectRoot, input, waveId);
|
|
372
|
+
if (!selected.ok) return selected;
|
|
373
|
+
validation = await validateWaveAdmission(projectRoot, input, waveId);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!validation.ok) return validation;
|
|
377
|
+
const loaded = await loadTopicReport(projectRoot, input),
|
|
378
|
+
waves = getTopicWaves(loaded.topic),
|
|
379
|
+
wave = waves.find((entry) => entry.wave_id === waveId);
|
|
380
|
+
((wave.state = "preflight_admitted"), (loaded.topic.waves = waves));
|
|
381
|
+
let nextState = loaded.topic.state;
|
|
382
|
+
(["proposal", "pending"].includes(loaded.topic.state) &&
|
|
383
|
+
((nextState = "ongoing"), (loaded.topic.state = nextState)),
|
|
384
|
+
(loaded.topic.last_transition_at = buildTopicNow()),
|
|
385
|
+
(loaded.topic.last_transition_reason = `wave_${waveId}_preflight_admitted`));
|
|
386
|
+
const moved = await moveTopicDirectoryForState(
|
|
387
|
+
projectRoot,
|
|
388
|
+
loaded.topicDir,
|
|
389
|
+
loaded.topicId,
|
|
390
|
+
nextState,
|
|
391
|
+
);
|
|
392
|
+
return (
|
|
393
|
+
await writeTopicYaml(moved.topicYamlPath, loaded.topic),
|
|
394
|
+
{
|
|
395
|
+
ok: true,
|
|
396
|
+
topicId: loaded.topicId,
|
|
397
|
+
topicRef: toPortableRelativePath(path.relative(projectRoot, moved.topicDir)),
|
|
398
|
+
waveId,
|
|
399
|
+
waveState: wave.state,
|
|
400
|
+
state: loaded.topic.state,
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
}
|