changeledger 0.6.4 → 0.8.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.md +15 -8
- package/bin/changeledger.mjs +136 -44
- package/package.json +1 -1
- package/src/check.mjs +106 -5
- package/src/commands/agent.mjs +44 -4
- package/src/commands/context.mjs +95 -15
- package/src/commands/graduate.mjs +20 -8
- package/src/commands/view.mjs +29 -4
- package/src/contract.mjs +11 -6
- package/src/lifecycle.mjs +19 -2
- package/src/metrics.mjs +9 -6
- package/src/viewer/domain.mjs +16 -6
- package/src/viewer/public/app-state.js +15 -0
- package/src/viewer/public/app.js +115 -13
- package/src/viewer/public/styles.css +205 -14
- package/src/viewer/public/view-parts.js +52 -0
- package/templates/contract/close.md +7 -1
- package/templates/contract/core.md +33 -21
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +23 -9
- package/templates/contract/readiness.md +5 -2
- package/templates/contract/review.md +5 -0
- package/templates/contract/validation.md +16 -2
package/src/commands/context.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseChange } from '../change.mjs';
|
|
4
|
-
import { findChangeledgerDir } from '../config.mjs';
|
|
5
|
-
import { contractTemplatesDir } from '../paths.mjs';
|
|
4
|
+
import { findChangeledgerDir, loadConfig } from '../config.mjs';
|
|
5
|
+
import { contractTemplatesDir, packageRoot } from '../paths.mjs';
|
|
6
6
|
import { resolveChange } from '../repo.mjs';
|
|
7
7
|
|
|
8
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')).version;
|
|
9
|
+
const END_DELIMITER =
|
|
10
|
+
'===== CHANGELEDGER CONTEXT END — if this line is missing, the output was truncated: stop and re-run =====';
|
|
8
11
|
const MODES = ['implement', 'review', 'spec', 'release'];
|
|
9
12
|
const MODE_CONTEXT = {
|
|
10
|
-
implement: ['implement', 'delegation', '
|
|
11
|
-
review: ['review', '
|
|
13
|
+
implement: ['implement', 'delegation', 'handoff'],
|
|
14
|
+
review: ['review', 'handoff'],
|
|
12
15
|
spec: ['spec', 'delegation', 'readiness'],
|
|
13
16
|
release: ['release'],
|
|
14
17
|
};
|
|
@@ -23,30 +26,102 @@ const STATUS_CONTEXT = {
|
|
|
23
26
|
discarded: { mode: 'discarded', fragments: ['discarded'] },
|
|
24
27
|
};
|
|
25
28
|
const INCREMENTAL_NOTICE = `This incremental context extends the complete core context already read.
|
|
26
|
-
|
|
29
|
+
Its one-pass full-capture rule applies here; a partial view is invalid.`;
|
|
27
30
|
|
|
28
31
|
function fragment(name) {
|
|
29
32
|
return fs.readFileSync(path.join(contractTemplatesDir, `${name}.md`), 'utf8').trim();
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
function
|
|
33
|
-
const
|
|
35
|
+
function beginDelimiter(mode, changeId) {
|
|
36
|
+
const change = changeId ? ` — change: #${changeId}` : '';
|
|
37
|
+
return `===== CHANGELEDGER CONTEXT BEGIN — mode: ${mode}${change} — v${VERSION} =====`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolved defaults so an agent never reads `.changeledger/config.yml` raw to
|
|
41
|
+
// discover the repo's effective policy. Keep these aligned with the shipped
|
|
42
|
+
// template config and the Definition of Ready contract.
|
|
43
|
+
const DEFAULT_LANGUAGE = 'en';
|
|
44
|
+
const DEFAULT_TDD = true;
|
|
45
|
+
|
|
46
|
+
function effectiveLanguage(config) {
|
|
47
|
+
return config?.language ?? DEFAULT_LANGUAGE;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function effectiveTdd(config) {
|
|
51
|
+
const value = config?.tdd ?? DEFAULT_TDD;
|
|
52
|
+
return value ? 'on' : 'off';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The transversal policy line every composition anchors on: effective language
|
|
56
|
+
// and tdd with defaults already resolved.
|
|
57
|
+
function transversalPolicy(config) {
|
|
58
|
+
return `Effective policy: language=${effectiveLanguage(config)} — tdd=${effectiveTdd(config)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Type-specific policy for change-id contexts: adds review requirement and the
|
|
62
|
+
// active stages the type actually uses, so the agent does not infer them.
|
|
63
|
+
function changePolicyBlock(config, type) {
|
|
64
|
+
const typeConfig = config?.types?.[type] ?? {};
|
|
65
|
+
const reviewRequired = typeConfig.review_required === true ? 'yes' : 'no';
|
|
66
|
+
const stages = Array.isArray(typeConfig.stages) ? typeConfig.stages.join(', ') : '';
|
|
67
|
+
const lines = [
|
|
68
|
+
`${transversalPolicy(config)} — review_required(${type})=${reviewRequired}`,
|
|
69
|
+
`Active stages(${type})=${stages}`,
|
|
70
|
+
];
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// One line per local dependency (id, title, status); external `project:id`
|
|
75
|
+
// references stay references, never pretending local resolution.
|
|
76
|
+
function dependencyBlock(dependsOn, cwd) {
|
|
77
|
+
if (!Array.isArray(dependsOn) || dependsOn.length === 0) return undefined;
|
|
78
|
+
const lines = dependsOn.map((raw) => {
|
|
79
|
+
const dep = String(raw);
|
|
80
|
+
if (dep.includes(':')) return `- #${dep} — external reference (not resolved locally)`;
|
|
81
|
+
try {
|
|
82
|
+
const resolved = resolveChange(cwd, dep);
|
|
83
|
+
const { frontmatter } = parseChange(fs.readFileSync(resolved.file, 'utf8'));
|
|
84
|
+
return `- #${dep} — ${frontmatter.title} — ${frontmatter.status}`;
|
|
85
|
+
} catch {
|
|
86
|
+
return `- #${dep} — unresolved local dependency`;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return `## Dependencies\n\n${lines.join('\n')}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function compose(mode, fragments, options = {}) {
|
|
93
|
+
const {
|
|
94
|
+
changeText,
|
|
95
|
+
incremental = true,
|
|
96
|
+
changeId = undefined,
|
|
97
|
+
policy = undefined,
|
|
98
|
+
dependencies = undefined,
|
|
99
|
+
} = options;
|
|
100
|
+
const sections = [beginDelimiter(mode, changeId)];
|
|
34
101
|
if (incremental) sections.push(INCREMENTAL_NOTICE);
|
|
102
|
+
if (policy) sections.push(policy);
|
|
35
103
|
sections.push(...fragments.map(fragment));
|
|
104
|
+
if (dependencies) sections.push(dependencies);
|
|
36
105
|
if (changeText) sections.push('---\n\n# Selected change\n', changeText.trim());
|
|
106
|
+
sections.push(END_DELIMITER);
|
|
37
107
|
return `${sections.join('\n\n')}\n`;
|
|
38
108
|
}
|
|
39
109
|
|
|
40
110
|
function requireRepo(cwd) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
111
|
+
const dir = findChangeledgerDir(cwd);
|
|
112
|
+
if (!dir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
113
|
+
return dir;
|
|
44
114
|
}
|
|
45
115
|
|
|
46
116
|
export function buildContext(input, cwd = process.cwd()) {
|
|
47
|
-
requireRepo(cwd);
|
|
48
|
-
|
|
49
|
-
if (
|
|
117
|
+
const changeledgerDir = requireRepo(cwd);
|
|
118
|
+
const config = loadConfig(changeledgerDir);
|
|
119
|
+
if (!input) {
|
|
120
|
+
return compose('core', ['core'], { incremental: false, policy: transversalPolicy(config) });
|
|
121
|
+
}
|
|
122
|
+
if (MODES.includes(input)) {
|
|
123
|
+
return compose(input, MODE_CONTEXT[input], { policy: transversalPolicy(config) });
|
|
124
|
+
}
|
|
50
125
|
|
|
51
126
|
let resolved;
|
|
52
127
|
try {
|
|
@@ -58,10 +133,15 @@ export function buildContext(input, cwd = process.cwd()) {
|
|
|
58
133
|
}
|
|
59
134
|
|
|
60
135
|
const text = fs.readFileSync(resolved.file, 'utf8');
|
|
61
|
-
const status = parseChange(text).frontmatter
|
|
136
|
+
const { id, status, type, depends_on: dependsOn } = parseChange(text).frontmatter;
|
|
62
137
|
const selected = STATUS_CONTEXT[status];
|
|
63
138
|
if (!selected) throw new Error(`No context mapping for change status "${status}"`);
|
|
64
|
-
return compose(selected.mode, selected.fragments,
|
|
139
|
+
return compose(selected.mode, selected.fragments, {
|
|
140
|
+
changeText: text,
|
|
141
|
+
changeId: id,
|
|
142
|
+
policy: changePolicyBlock(config, type),
|
|
143
|
+
dependencies: dependencyBlock(dependsOn, cwd),
|
|
144
|
+
});
|
|
65
145
|
}
|
|
66
146
|
|
|
67
147
|
export function context(input, cwd = process.cwd(), output = console.log) {
|
|
@@ -6,6 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { mutateFileAtomic, writeFileAtomic } from '../atomic-write.mjs';
|
|
8
8
|
import { parseChange } from '../change.mjs';
|
|
9
|
+
import { assertChangeTextValid } from '../check.mjs';
|
|
9
10
|
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
|
|
10
11
|
import { nowUtc } from '../paths.mjs';
|
|
11
12
|
import { resolveChange } from '../repo.mjs';
|
|
@@ -30,11 +31,23 @@ function requireDone(changeText) {
|
|
|
30
31
|
return change;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
function requireGraduationReady(config, changeFile, changeText) {
|
|
35
|
+
const change = requireDone(changeText);
|
|
36
|
+
assertChangeTextValid(config, path.basename(changeFile), changeText);
|
|
37
|
+
return change;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
export function scaffoldSpec(id, slug, cwd = process.cwd()) {
|
|
34
|
-
const {
|
|
41
|
+
const {
|
|
42
|
+
config,
|
|
43
|
+
file: changeFile,
|
|
44
|
+
specsDir,
|
|
45
|
+
specName,
|
|
46
|
+
specFile,
|
|
47
|
+
} = graduationTarget(id, slug, cwd);
|
|
48
|
+
const change = requireGraduationReady(config, changeFile, fs.readFileSync(changeFile, 'utf8'));
|
|
35
49
|
if (fs.existsSync(specFile)) throw new Error(`Spec "${specName}" already exists`);
|
|
36
50
|
|
|
37
|
-
const change = requireDone(fs.readFileSync(changeFile, 'utf8'));
|
|
38
51
|
const seedStage =
|
|
39
52
|
change.stages.find((stage) => stage.key === 'specification') ??
|
|
40
53
|
change.stages.find((stage) => stage.key === 'proposal');
|
|
@@ -65,14 +78,15 @@ export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
|
|
|
65
78
|
if (!into) {
|
|
66
79
|
throw new Error('graduation mode required: use --new, --into, or --skip');
|
|
67
80
|
}
|
|
68
|
-
const { file: changeFile, specName, specFile } = graduationTarget(id, slug, cwd);
|
|
81
|
+
const { config, file: changeFile, specName, specFile } = graduationTarget(id, slug, cwd);
|
|
82
|
+
requireGraduationReady(config, changeFile, fs.readFileSync(changeFile, 'utf8'));
|
|
69
83
|
|
|
70
84
|
if (!fs.existsSync(specFile)) {
|
|
71
85
|
throw new Error(`Spec "${specName}" does not exist — use --new to create a scaffold`);
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
mutateFileAtomic(changeFile, (changeText) => {
|
|
75
|
-
|
|
89
|
+
requireGraduationReady(config, changeFile, changeText);
|
|
76
90
|
const specText = fs.readFileSync(specFile, 'utf8');
|
|
77
91
|
if (specText.includes(SPEC_SCAFFOLD_MARKER)) {
|
|
78
92
|
throw new Error(
|
|
@@ -91,12 +105,10 @@ export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
|
|
|
91
105
|
// Marks a done change's graduation as reviewed without creating a spec (e.g. a
|
|
92
106
|
// bug/chore with no persistent truth). Records the reason in the Log.
|
|
93
107
|
export function skipGraduation(id, reason, cwd = process.cwd()) {
|
|
94
|
-
const { file: changeFile } = resolveChange(cwd, id);
|
|
108
|
+
const { config, file: changeFile } = resolveChange(cwd, id);
|
|
95
109
|
const message = reason ? `graduation skipped: ${reason}` : 'graduation skipped';
|
|
96
110
|
mutateFileAtomic(changeFile, (text) => {
|
|
97
|
-
|
|
98
|
-
if (change.frontmatter.status !== 'done')
|
|
99
|
-
throw new Error('only done changes can be graduated/skipped');
|
|
111
|
+
requireGraduationReady(config, changeFile, text);
|
|
100
112
|
|
|
101
113
|
text = appendLog(text, nowUtc(), message);
|
|
102
114
|
return setReviewed(text, true);
|
package/src/commands/view.mjs
CHANGED
|
@@ -21,8 +21,32 @@ export {
|
|
|
21
21
|
} from '../viewer/domain.mjs';
|
|
22
22
|
export { createRequestListener, hostnameOf, isAuthorizedWrite, isLocalHost, staticFile };
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Explicit grammar: `.` selects local-only mode, a bare integer selects the
|
|
25
|
+
// port (default 4040), both may combine, and anything else is rejected
|
|
26
|
+
// instead of being silently ignored.
|
|
27
|
+
function parseViewArgs(args) {
|
|
28
|
+
let localOnly = false;
|
|
29
|
+
let port = 4040;
|
|
30
|
+
const unknown = [];
|
|
31
|
+
for (const a of args) {
|
|
32
|
+
if (a === '.') localOnly = true;
|
|
33
|
+
else if (/^\d+$/.test(a)) port = Number(a);
|
|
34
|
+
else unknown.push(a);
|
|
35
|
+
}
|
|
36
|
+
if (unknown.length) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Unknown argument(s) for "changeledger view": ${unknown.join(', ')} — usage: changeledger view [.] [port]`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return { localOnly, port };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function view(
|
|
45
|
+
args = [],
|
|
46
|
+
cwd = process.cwd(),
|
|
47
|
+
{ openBrowser: shouldOpen = true } = {},
|
|
48
|
+
) {
|
|
49
|
+
const { localOnly, port: requestedPort } = parseViewArgs(args);
|
|
26
50
|
resolveProjects(cwd, localOnly); // fail fast if local mode outside a repo
|
|
27
51
|
|
|
28
52
|
const token = crypto.randomBytes(16).toString('hex');
|
|
@@ -31,10 +55,11 @@ export async function view(args = [], cwd = process.cwd()) {
|
|
|
31
55
|
server.headersTimeout = 10_000;
|
|
32
56
|
|
|
33
57
|
const host = '127.0.0.1';
|
|
34
|
-
const port = await listen(server, host,
|
|
58
|
+
const port = await listen(server, host, requestedPort);
|
|
35
59
|
const url = `http://${host}:${port}`;
|
|
36
60
|
console.log(`ChangeLedger viewer → ${url} (Ctrl+C to stop)`);
|
|
37
|
-
openBrowser(url);
|
|
61
|
+
if (shouldOpen) openBrowser(url);
|
|
62
|
+
return server;
|
|
38
63
|
}
|
|
39
64
|
|
|
40
65
|
function listen(server, host, port, attempts = 10) {
|
package/src/contract.mjs
CHANGED
|
@@ -36,12 +36,17 @@ const LEGACY_CONTRACT_HASHES = new Set([
|
|
|
36
36
|
|
|
37
37
|
export const REFERENCE = `${MARKER}
|
|
38
38
|
> [!IMPORTANT]
|
|
39
|
-
> This repo uses **ChangeLedger**.
|
|
40
|
-
>
|
|
41
|
-
>
|
|
42
|
-
>
|
|
43
|
-
>
|
|
44
|
-
>
|
|
39
|
+
> This repo uses **ChangeLedger**. Immediately after reading this file — before
|
|
40
|
+
> planning, investigating, or acting — run \`changeledger context\` directly.
|
|
41
|
+
> On the first invocation, retain complete stdout through the \`CHANGELEDGER CONTEXT END\` line:
|
|
42
|
+
> no pipes, filters, summaries, previews or voluntary output limits. If the tool
|
|
43
|
+
> exposes an output budget, reserve enough for the whole response. A missing END
|
|
44
|
+
> after that is exceptional recovery: stop and re-run with a larger capture. If
|
|
45
|
+
> the command is unavailable, stop and restore/install ChangeLedger; do not
|
|
46
|
+
> proceed from memory.
|
|
47
|
+
>
|
|
48
|
+
> Do not create or modify files without an authorized change; the core context
|
|
49
|
+
> defines the workflow, the task contexts, and the narrow operational exception.
|
|
45
50
|
`;
|
|
46
51
|
|
|
47
52
|
export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');
|
package/src/lifecycle.mjs
CHANGED
|
@@ -20,7 +20,8 @@ export const CANONICAL_STATUSES = [
|
|
|
20
20
|
// absent and therefore rejected. `in-review` is optional by type;
|
|
21
21
|
// `in-validation` is the universal human gate before done. Review or validation
|
|
22
22
|
// may route back to in-progress, while review may also block. `discarded` is a
|
|
23
|
-
// terminal tombstone reachable only before either closing gate.
|
|
23
|
+
// terminal tombstone reachable only before either closing gate. `done` has one
|
|
24
|
+
// policy-gated human reopen edge; generic agent commands do not own it.
|
|
24
25
|
const TRANSITIONS = {
|
|
25
26
|
draft: ['approved', 'discarded'],
|
|
26
27
|
approved: ['in-progress', 'discarded'],
|
|
@@ -28,7 +29,7 @@ const TRANSITIONS = {
|
|
|
28
29
|
'in-review': ['in-validation', 'in-progress', 'blocked'],
|
|
29
30
|
'in-validation': ['done', 'in-progress'],
|
|
30
31
|
blocked: ['in-progress', 'discarded'],
|
|
31
|
-
done: [],
|
|
32
|
+
done: ['in-progress'],
|
|
32
33
|
discarded: [],
|
|
33
34
|
};
|
|
34
35
|
|
|
@@ -55,3 +56,19 @@ export function assertTransition(from, to, { type, reviewRequired = false } = {}
|
|
|
55
56
|
throw new Error(`${type} changes must be reviewed before validation — move to in-review first`);
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
|
|
60
|
+
// A lifecycle event recorded in `## Log`. `status:` lines carry an explicit
|
|
61
|
+
// origin; review/validation verdict lines imply it (the writer only emits them
|
|
62
|
+
// from in-review / in-validation). Non-lifecycle entries (owner, graduation,
|
|
63
|
+
// free notes) return null.
|
|
64
|
+
const LOG_EVENT =
|
|
65
|
+
/\*\*([^*]+)\*\*\s*—\s*(?:status:\s*([a-z-]+)\s*→\s*([a-z-]+)|(review)\s*→\s*([a-z-]+)|(validation)\s*→\s*([a-z-]+))/;
|
|
66
|
+
|
|
67
|
+
export function parseLogEvent(line) {
|
|
68
|
+
const m = line.match(LOG_EVENT);
|
|
69
|
+
if (!m) return null;
|
|
70
|
+
const at = m[1].trim();
|
|
71
|
+
if (m[2]) return { at, from: m[2], to: m[3], explicit: true };
|
|
72
|
+
if (m[4]) return { at, from: 'in-review', to: m[5], explicit: false };
|
|
73
|
+
return { at, from: 'in-validation', to: m[7], explicit: false };
|
|
74
|
+
}
|
package/src/metrics.mjs
CHANGED
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
// Everything is reconstructed from `created` plus the `## Log` status
|
|
4
4
|
// transitions. The viewer renders the result.
|
|
5
5
|
|
|
6
|
+
import { parseLogEvent } from './lifecycle.mjs';
|
|
7
|
+
|
|
6
8
|
const ACTIVE = ['approved', 'in-progress', 'in-review', 'in-validation', 'blocked'];
|
|
7
9
|
|
|
8
10
|
function logBody(change) {
|
|
9
11
|
return (change.stages ?? []).find((s) => s.key === 'log')?.body ?? '';
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
// Same event grammar as `changeledger check`'s sequence validation — one shared
|
|
15
|
+
// parser so metrics and validation cannot drift (20260630-225210 CR5).
|
|
12
16
|
function logTransition(line) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
return { at: m[1].trim(), state: m[2].trim() };
|
|
17
|
+
const event = parseLogEvent(line);
|
|
18
|
+
return event ? { at: event.at, state: event.to } : null;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
// The moment a change reached `done`: the last lifecycle transition to done, or
|
|
@@ -39,8 +42,8 @@ function transitions(change) {
|
|
|
39
42
|
return events;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
// Time spent in each state: [{ state, ms }].
|
|
43
|
-
//
|
|
45
|
+
// Time spent in each state: [{ state, ms }]. A provisional `done` interval is
|
|
46
|
+
// retained in the event stream but excluded from active-state duration.
|
|
44
47
|
export function statusTimeline(change, now) {
|
|
45
48
|
const events = transitions(change);
|
|
46
49
|
if (!events.length) return [];
|
|
@@ -50,7 +53,7 @@ export function statusTimeline(change, now) {
|
|
|
50
53
|
const endIso = events[i + 1]?.at ?? now;
|
|
51
54
|
const end = Date.parse(endIso);
|
|
52
55
|
if (Number.isNaN(start) || Number.isNaN(end)) continue;
|
|
53
|
-
// `done` is
|
|
56
|
+
// `done` is outside active work, including a provisional interval before reopen.
|
|
54
57
|
if (events[i].state === 'done') continue;
|
|
55
58
|
segs.push({ state: events[i].state, ms: Math.max(0, end - start) });
|
|
56
59
|
}
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -3,8 +3,13 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { parseDocument } from 'yaml';
|
|
5
5
|
import { mutateFileAtomic } from '../atomic-write.mjs';
|
|
6
|
+
import { parseChange } from '../change.mjs';
|
|
6
7
|
import { checkRepo } from '../check.mjs';
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
reopen as applyReopen,
|
|
10
|
+
status as applyStatusCmd,
|
|
11
|
+
validation as applyValidation,
|
|
12
|
+
} from '../commands/agent.mjs';
|
|
8
13
|
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
|
|
9
14
|
import {
|
|
10
15
|
buildMigration,
|
|
@@ -14,7 +19,7 @@ import {
|
|
|
14
19
|
import { computeMetrics } from '../metrics.mjs';
|
|
15
20
|
import { nowUtc } from '../paths.mjs';
|
|
16
21
|
import { listProjects, remove, update } from '../registry.mjs';
|
|
17
|
-
import { loadRepo, loadRepoWithConfig } from '../repo.mjs';
|
|
22
|
+
import { loadRepo, loadRepoWithConfig, resolveChange } from '../repo.mjs';
|
|
18
23
|
import { parseYaml } from '../yaml.mjs';
|
|
19
24
|
|
|
20
25
|
// Serializes a loaded repo into the flat shape the UI consumes.
|
|
@@ -115,10 +120,12 @@ export function changeStatus(projects, { project, id, status, reason }) {
|
|
|
115
120
|
// the UI is bypassable.
|
|
116
121
|
let current;
|
|
117
122
|
try {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
current = change.frontmatter.status;
|
|
123
|
+
const { file } = resolveChange(proj.path, id);
|
|
124
|
+
current = parseChange(fs.readFileSync(file, 'utf8')).frontmatter.status;
|
|
121
125
|
} catch (e) {
|
|
126
|
+
if (/^No change with id /.test(e.message)) {
|
|
127
|
+
return { code: 404, body: { error: `no change with id "${id}"` } };
|
|
128
|
+
}
|
|
122
129
|
return { code: 400, body: { error: e.message } };
|
|
123
130
|
}
|
|
124
131
|
try {
|
|
@@ -128,11 +135,14 @@ export function changeStatus(projects, { project, id, status, reason }) {
|
|
|
128
135
|
applyValidation(id, 'pass', {}, proj.path);
|
|
129
136
|
} else if (current === 'in-validation' && status === 'in-progress') {
|
|
130
137
|
applyValidation(id, 'fail', { reason }, proj.path);
|
|
138
|
+
} else if (current === 'done' && status === 'in-progress') {
|
|
139
|
+
applyReopen(id, reason, proj.path);
|
|
131
140
|
} else {
|
|
132
141
|
return {
|
|
133
142
|
code: 403,
|
|
134
143
|
body: {
|
|
135
|
-
error:
|
|
144
|
+
error:
|
|
145
|
+
'the viewer only allows draft → approved, in-validation → done|in-progress, and eligible done → in-progress',
|
|
136
146
|
},
|
|
137
147
|
};
|
|
138
148
|
}
|
|
@@ -2,6 +2,8 @@ export const VIEWER_STATE_KEY = 'changeledger.viewer-state.v1';
|
|
|
2
2
|
|
|
3
3
|
const VALID_VIEWS = new Set(['board', 'table', 'graph', 'specs', 'metrics', 'projects']);
|
|
4
4
|
const VALID_SORT_KEYS = new Set(['id', 'title', 'type', 'status', 'progress', 'deps']);
|
|
5
|
+
const VALID_DETAIL_MODES = new Set(['side', 'floating']);
|
|
6
|
+
const VALID_DETAIL_SIZES = new Set(['compact', 'wide', 'full']);
|
|
5
7
|
let storage = null;
|
|
6
8
|
|
|
7
9
|
const emptyProjectFilters = () => ({
|
|
@@ -31,6 +33,8 @@ export const state = {
|
|
|
31
33
|
projectsList: [],
|
|
32
34
|
localOnly: false,
|
|
33
35
|
globalMode: false,
|
|
36
|
+
detailMode: 'side',
|
|
37
|
+
detailSize: 'wide',
|
|
34
38
|
};
|
|
35
39
|
|
|
36
40
|
function currentProjectFilters() {
|
|
@@ -71,6 +75,8 @@ export function serializeViewerState() {
|
|
|
71
75
|
sortKey: state.sortKey,
|
|
72
76
|
sortDir: state.sortDir,
|
|
73
77
|
projects: state.projectFilters,
|
|
78
|
+
detailMode: state.detailMode,
|
|
79
|
+
detailSize: state.detailSize,
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -99,6 +105,8 @@ export function restoreViewerState(storageLike) {
|
|
|
99
105
|
if (typeof snapshot.text === 'string') state.filters.text = snapshot.text;
|
|
100
106
|
if (typeof snapshot.sortKey === 'string') state.sortKey = snapshot.sortKey;
|
|
101
107
|
if (snapshot.sortDir === 1 || snapshot.sortDir === -1) state.sortDir = snapshot.sortDir;
|
|
108
|
+
state.detailMode = VALID_DETAIL_MODES.has(snapshot.detailMode) ? snapshot.detailMode : 'side';
|
|
109
|
+
state.detailSize = VALID_DETAIL_SIZES.has(snapshot.detailSize) ? snapshot.detailSize : 'wide';
|
|
102
110
|
if (
|
|
103
111
|
snapshot.projects &&
|
|
104
112
|
typeof snapshot.projects === 'object' &&
|
|
@@ -224,3 +232,10 @@ export function toggleGlobalMode() {
|
|
|
224
232
|
persistViewerState();
|
|
225
233
|
return state.globalMode;
|
|
226
234
|
}
|
|
235
|
+
|
|
236
|
+
export function setDetailPresentation(mode, size) {
|
|
237
|
+
if (VALID_DETAIL_MODES.has(mode)) state.detailMode = mode;
|
|
238
|
+
if (VALID_DETAIL_SIZES.has(size)) state.detailSize = size;
|
|
239
|
+
persistViewerState();
|
|
240
|
+
return { mode: state.detailMode, size: state.detailSize };
|
|
241
|
+
}
|