@smartmemory/compose 0.2.30-beta → 0.2.32-beta
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/bin/compose.js +26 -0
- package/contracts/feature-json.schema.json +2 -1
- package/lib/feature-writer.js +4 -0
- package/lib/tracker/github-api.js +8 -0
- package/lib/xref-push.js +179 -0
- package/lib/xref-sync.js +4 -1
- package/package.json +1 -1
package/bin/compose.js
CHANGED
|
@@ -120,6 +120,7 @@ if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
|
120
120
|
console.log(' roadmap migrate Extract ROADMAP.md entries into feature.json files')
|
|
121
121
|
console.log(' roadmap check Verify feature.json and ROADMAP.md are in sync')
|
|
122
122
|
console.log(' roadmap xref-sync Pull-reconcile feature.json external links to live state')
|
|
123
|
+
console.log(' roadmap xref-push Push-write GitHub trackers to match expect= (dry-run; --apply to write)')
|
|
123
124
|
console.log(' items List vision items from local state (no server)')
|
|
124
125
|
console.log(' items show <id> Show detail for a specific vision item')
|
|
125
126
|
console.log(' triage Analyze a feature and recommend build profile')
|
|
@@ -1154,6 +1155,31 @@ if (cmd === 'roadmap') {
|
|
|
1154
1155
|
process.exit(0)
|
|
1155
1156
|
}
|
|
1156
1157
|
|
|
1158
|
+
// compose roadmap xref-push — write GitHub trackers to match feature.json
|
|
1159
|
+
// expect= declared intent (COMP-ROADMAP-XREF-PUSH v1). Dry-run by default,
|
|
1160
|
+
// per-ref push:true opt-in, --apply to mutate. Degrade-skip, never guesses.
|
|
1161
|
+
if (subcmd === 'xref-push') {
|
|
1162
|
+
const { pushExternalRefs } = await import('../lib/xref-push.js')
|
|
1163
|
+
const { root: cwd } = resolveCwdWithWorkspace(args)
|
|
1164
|
+
const apply = args.includes('--apply')
|
|
1165
|
+
const res = await pushExternalRefs(cwd, { apply })
|
|
1166
|
+
const verb = apply ? 'wrote' : 'would write'
|
|
1167
|
+
if (res.pushed.length === 0) {
|
|
1168
|
+
console.log(`No external trackers to push (${res.scanned} push-opted link(s) checked, ${res.unchanged} already in sync).`)
|
|
1169
|
+
} else {
|
|
1170
|
+
console.log(`${apply ? 'Pushed' : 'Would push'} ${res.pushed.length} external tracker(s):`)
|
|
1171
|
+
for (const s of res.pushed) console.log(` ${s.code} github ${s.target}: ${s.from} → ${s.to} (${verb})`)
|
|
1172
|
+
}
|
|
1173
|
+
if (res.skipped.length > 0) {
|
|
1174
|
+
console.log(`\nSkipped ${res.skipped.length} link(s):`)
|
|
1175
|
+
for (const s of res.skipped) console.log(` ${s.code} github ${s.target}: ${s.reason}`)
|
|
1176
|
+
}
|
|
1177
|
+
if (!apply && res.pushed.length > 0) {
|
|
1178
|
+
console.log(`\nDry-run — pass --apply to write these changes to GitHub.`)
|
|
1179
|
+
}
|
|
1180
|
+
process.exit(0)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1157
1183
|
// compose roadmap graph — generate a self-contained dependency-graph HTML
|
|
1158
1184
|
// from feature.json + deps.yaml + frontmatter (COMP-ROADMAP-GRAPH-1).
|
|
1159
1185
|
if (subcmd === 'graph') {
|
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"repo": { "type": "string" },
|
|
77
77
|
"issue": { "type": "integer", "minimum": 1 },
|
|
78
78
|
"url": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9+\\-.]*://" },
|
|
79
|
-
"expect": { "type": "string" }
|
|
79
|
+
"expect": { "type": "string" },
|
|
80
|
+
"push": { "type": "boolean" }
|
|
80
81
|
},
|
|
81
82
|
"allOf": [
|
|
82
83
|
{
|
package/lib/feature-writer.js
CHANGED
|
@@ -841,6 +841,9 @@ function validateExternalArgs(args) {
|
|
|
841
841
|
if (args.expect != null && !XREF_GITHUB_EXPECT.has(args.expect)) {
|
|
842
842
|
throw new Error(`feature-writer: external github expect must be open|closed, got "${args.expect}"`);
|
|
843
843
|
}
|
|
844
|
+
if (args.push != null && typeof args.push !== 'boolean') {
|
|
845
|
+
throw new Error(`feature-writer: external github push must be boolean, got "${args.push}"`);
|
|
846
|
+
}
|
|
844
847
|
} else if (p === 'local') {
|
|
845
848
|
if (!args.repo || !args.to_code) {
|
|
846
849
|
throw new Error('feature-writer: external local link requires repo + to_code');
|
|
@@ -919,6 +922,7 @@ async function linkFeatureExternal(cwd, args) {
|
|
|
919
922
|
if (args.to_code != null) entry.to_code = args.to_code;
|
|
920
923
|
if (args.url != null) entry.url = args.url;
|
|
921
924
|
if (args.expect != null) entry.expect = args.expect;
|
|
925
|
+
if (args.push != null) entry.push = args.push;
|
|
922
926
|
if (args.note) entry.note = args.note;
|
|
923
927
|
|
|
924
928
|
if (matchIdx !== -1) links[matchIdx] = entry;
|
|
@@ -50,6 +50,14 @@ export class GitHubApi {
|
|
|
50
50
|
*/
|
|
51
51
|
async getIssueResult(number) { return this._req('GET', `/repos/${this.repo}/issues/${number}`); }
|
|
52
52
|
async updateIssue(number, patch) { return (await this._req('PATCH', `/repos/${this.repo}/issues/${number}`, patch)).body; }
|
|
53
|
+
/**
|
|
54
|
+
* PATCH /repos/:repo/issues/:number — status-returning sibling of updateIssue().
|
|
55
|
+
* Returns { status, body, headers }; does NOT throw on 4xx (mirrors
|
|
56
|
+
* getIssueResult()). Used by COMP-ROADMAP-XREF-PUSH to distinguish a real write
|
|
57
|
+
* from 403/404/422 so a failed write degrades to a skip, never a false success.
|
|
58
|
+
* updateIssue() untouched.
|
|
59
|
+
*/
|
|
60
|
+
async updateIssueResult(number, patch) { return this._req('PATCH', `/repos/${this.repo}/issues/${number}`, patch); }
|
|
53
61
|
async searchFeatureIssues() {
|
|
54
62
|
return (await this._req('GET', `/search/issues?q=repo:${this.repo}+label:compose-feature`)).body.items ?? [];
|
|
55
63
|
}
|
package/lib/xref-push.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xref-push.js — COMP-ROADMAP-XREF-PUSH v1 (PUSH reconciliation).
|
|
3
|
+
*
|
|
4
|
+
* The write-side counterpart to xref-sync.js (PULL). Pull rewrites the LOCAL
|
|
5
|
+
* citation's `expect=` to match EXTERNAL reality; Push does the inverse — it
|
|
6
|
+
* writes the EXTERNAL tracker (a GitHub issue's open/closed state) to match the
|
|
7
|
+
* locally-declared `expect=` intent. Because this mutates a system outside the
|
|
8
|
+
* repo it is dry-run by default, opt-in per ref (`push: true` on the link), and
|
|
9
|
+
* degrades (never writes, never guesses) on any resolution or write failure —
|
|
10
|
+
* exactly mirroring xref-sync's degrade posture.
|
|
11
|
+
*
|
|
12
|
+
* Operates on the structured `links[].kind === 'external'` carrier and NEVER
|
|
13
|
+
* mutates feature.json: `expect` is the unchanged source of intent. Resolution
|
|
14
|
+
* and the external write are both injectable for testability (no network in
|
|
15
|
+
* tests). github provider only in v1.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdirSync, existsSync, readFileSync } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import { loadFeaturesDir } from './project-paths.js';
|
|
21
|
+
|
|
22
|
+
const GITHUB_STATES = new Set(['open', 'closed']);
|
|
23
|
+
|
|
24
|
+
/** Is `s` a writable GitHub issue state? */
|
|
25
|
+
export function isGithubState(s) {
|
|
26
|
+
return GITHUB_STATES.has(s);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pure mirror of reconcileExpect (xref-sync.js): should the external be written
|
|
31
|
+
* to match `ref.expect`, given its current `liveState`?
|
|
32
|
+
*
|
|
33
|
+
* @param {{expect: string|null}} ref
|
|
34
|
+
* @param {string|null} liveState resolved current external state, or null if unresolved
|
|
35
|
+
* @returns {{action: 'none'|'write', from?: string, to?: string}}
|
|
36
|
+
*/
|
|
37
|
+
export function planPush(ref, liveState) {
|
|
38
|
+
if (!ref.expect) return { action: 'none' }; // no declared intent
|
|
39
|
+
if (liveState == null) return { action: 'none' }; // unresolved → leave alone
|
|
40
|
+
if (ref.expect === liveState) return { action: 'none' }; // idempotent
|
|
41
|
+
return { action: 'write', from: liveState, to: ref.expect };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a GitHubApi for `link`, or return a degrade reason. `transport`/`auth`
|
|
46
|
+
* are injectable (mirrors feature-validator's githubTransport/githubAuth) so the
|
|
47
|
+
* real resolve/write degrade paths are testable without network.
|
|
48
|
+
* @returns {{gh}|{skipped: true, reason: string}}
|
|
49
|
+
*/
|
|
50
|
+
async function makeClient(link, { transport = null, auth } = {}) {
|
|
51
|
+
let GitHubApi;
|
|
52
|
+
try { ({ GitHubApi } = await import('./tracker/github-api.js')); }
|
|
53
|
+
catch (e) { return { skipped: true, reason: `github client unavailable: ${e.message}` }; }
|
|
54
|
+
const a = auth ?? { token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN };
|
|
55
|
+
try {
|
|
56
|
+
return { gh: new GitHubApi({ repo: link.repo, auth: a }, transport) };
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { skipped: true, reason: (e && e.message) || 'no GitHub auth' };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a github link to its current state. Mirrors xref-sync's defaultResolve
|
|
64
|
+
* degrade semantics AND additionally rejects pull-request-backed refs (a state
|
|
65
|
+
* PATCH would close/reopen a PR — GitHub's Issues API treats PRs as issues).
|
|
66
|
+
* Returns { state } on success, or { skipped, reason } on any degrade.
|
|
67
|
+
*/
|
|
68
|
+
export async function defaultResolve(link, opts = {}) {
|
|
69
|
+
if (!link.repo || link.issue == null) return { skipped: true, reason: 'incomplete github ref' };
|
|
70
|
+
const c = await makeClient(link, opts);
|
|
71
|
+
if (c.skipped) return c;
|
|
72
|
+
try {
|
|
73
|
+
const r = await c.gh.getIssueResult(link.issue);
|
|
74
|
+
if (r.status === 404) return { skipped: true, reason: `target ${link.repo}#${link.issue} missing (404)` };
|
|
75
|
+
if (r.status < 200 || r.status >= 300) return { skipped: true, reason: `HTTP ${r.status}` };
|
|
76
|
+
if (r.body && r.body.pull_request) {
|
|
77
|
+
return { skipped: true, reason: `${link.repo}#${link.issue} is a pull request, not an issue` };
|
|
78
|
+
}
|
|
79
|
+
const state = r.body && r.body.state;
|
|
80
|
+
if (!isGithubState(state)) return { skipped: true, reason: 'no parseable issue state' };
|
|
81
|
+
return { state };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { skipped: true, reason: e && e.rateLimit ? 'rate limit' : (e && e.message) || 'resolution error' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write a github link's issue to `toState`. Uses updateIssueResult (status-
|
|
89
|
+
* returning) so a non-2xx response degrades to a skip rather than a false
|
|
90
|
+
* success. Returns { ok: true } or { skipped, reason }.
|
|
91
|
+
*/
|
|
92
|
+
export async function defaultWrite(link, toState, opts = {}) {
|
|
93
|
+
if (!link.repo || link.issue == null) return { skipped: true, reason: 'incomplete github ref' };
|
|
94
|
+
const c = await makeClient(link, opts);
|
|
95
|
+
if (c.skipped) return c;
|
|
96
|
+
try {
|
|
97
|
+
const r = await c.gh.updateIssueResult(link.issue, { state: toState });
|
|
98
|
+
if (r.status < 200 || r.status >= 300) return { skipped: true, reason: `write HTTP ${r.status}` };
|
|
99
|
+
return { ok: true };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return { skipped: true, reason: e && e.rateLimit ? 'rate limit' : (e && e.message) || 'write error' };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Push-write every eligible feature.json external link's target to match its
|
|
107
|
+
* declared `expect`. Eligibility: github provider, `push === true`, and a valid
|
|
108
|
+
* github `expect` state. Dry-run unless `apply` is true. NEVER mutates feature.json.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} cwd
|
|
111
|
+
* @param {object} [opts]
|
|
112
|
+
* @param {boolean} [opts.apply] perform writes (default false = dry-run)
|
|
113
|
+
* @param {string} [opts.featuresDir]
|
|
114
|
+
* @param {(link: object) => Promise<{state?: string, skipped?: boolean, reason?: string}>} [opts.resolve]
|
|
115
|
+
* @param {(link: object, toState: string) => Promise<{ok?: boolean, skipped?: boolean, reason?: string}>} [opts.write]
|
|
116
|
+
* @param {object} [opts.githubTransport] injectable transport for the default resolve/write (tests)
|
|
117
|
+
* @param {object} [opts.githubAuth] injectable auth for the default resolve/write (tests)
|
|
118
|
+
* @returns {Promise<{pushed: Array, skipped: Array, unchanged: number, scanned: number}>}
|
|
119
|
+
*/
|
|
120
|
+
export async function pushExternalRefs(cwd, opts = {}) {
|
|
121
|
+
const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
|
|
122
|
+
const clientOpts = { transport: opts.githubTransport ?? null, auth: opts.githubAuth };
|
|
123
|
+
const resolve = opts.resolve ?? ((link) => defaultResolve(link, clientOpts));
|
|
124
|
+
const write = opts.write ?? ((link, toState) => defaultWrite(link, toState, clientOpts));
|
|
125
|
+
const apply = opts.apply === true;
|
|
126
|
+
const dir = join(cwd, featuresDir);
|
|
127
|
+
|
|
128
|
+
const pushed = [];
|
|
129
|
+
const skipped = [];
|
|
130
|
+
let unchanged = 0;
|
|
131
|
+
let scanned = 0;
|
|
132
|
+
|
|
133
|
+
if (!existsSync(dir)) return { pushed, skipped, unchanged, scanned };
|
|
134
|
+
|
|
135
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
136
|
+
if (!entry.isDirectory()) continue;
|
|
137
|
+
const fjPath = join(dir, entry.name, 'feature.json');
|
|
138
|
+
if (!existsSync(fjPath)) continue;
|
|
139
|
+
let fj;
|
|
140
|
+
try { fj = JSON.parse(readFileSync(fjPath, 'utf8')); } catch { continue; }
|
|
141
|
+
if (!Array.isArray(fj.links)) continue;
|
|
142
|
+
|
|
143
|
+
for (const link of fj.links) {
|
|
144
|
+
if (!link || link.kind !== 'external') continue;
|
|
145
|
+
// v1: github only, opt-in only, valid target state only.
|
|
146
|
+
if (link.provider !== 'github' || link.push !== true) continue;
|
|
147
|
+
scanned++;
|
|
148
|
+
if (!isGithubState(link.expect)) {
|
|
149
|
+
skipped.push({ code: fj.code, target: targetLabel(link), reason: `malformed expect "${link.expect}" (want open|closed)` });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const r = await resolve(link);
|
|
154
|
+
if (r.skipped) {
|
|
155
|
+
skipped.push({ code: fj.code, target: targetLabel(link), reason: r.reason });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const verdict = planPush(link, r.state ?? null);
|
|
159
|
+
if (verdict.action !== 'write') { unchanged++; continue; }
|
|
160
|
+
|
|
161
|
+
if (!apply) {
|
|
162
|
+
pushed.push({ code: fj.code, target: targetLabel(link), from: verdict.from, to: verdict.to });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const w = await write(link, verdict.to);
|
|
166
|
+
if (w.skipped) {
|
|
167
|
+
skipped.push({ code: fj.code, target: targetLabel(link), reason: w.reason });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
pushed.push({ code: fj.code, target: targetLabel(link), from: verdict.from, to: verdict.to });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { pushed, skipped, unchanged, scanned };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function targetLabel(link) {
|
|
178
|
+
return `${link.repo}#${link.issue}`;
|
|
179
|
+
}
|
package/lib/xref-sync.js
CHANGED
|
@@ -130,7 +130,10 @@ export async function syncExternalRefs(cwd, opts = {}) {
|
|
|
130
130
|
for (const link of fj.links) {
|
|
131
131
|
if (!link || link.kind !== 'external') continue;
|
|
132
132
|
// Only resolvable providers that carry an explicit expectation can drift.
|
|
133
|
-
|
|
133
|
+
// Push-managed links (push:true, COMP-ROADMAP-XREF-PUSH) are owned by the
|
|
134
|
+
// write side — pulling them would clobber the declared intent before push
|
|
135
|
+
// runs, so a link is either pull-managed or push-managed, never both.
|
|
136
|
+
if (!RESOLVABLE.has(link.provider) || !link.expect || link.push === true) continue;
|
|
134
137
|
scanned++;
|
|
135
138
|
|
|
136
139
|
const r = await resolve(link, cwd, featuresDir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.32-beta",
|
|
4
4
|
"description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|