@smartmemory/compose 0.2.29-beta → 0.2.31-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 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
  {
@@ -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;
@@ -21,9 +21,14 @@ const STATUS_MAP = {
21
21
  };
22
22
 
23
23
  export class DanglingEdgeError extends Error {
24
- /** @param {{from:string,to:string,kind:string}[]} dangling */
24
+ /** @param {{from:string,to:string,kind:string,missing:string[]}[]} dangling */
25
25
  constructor(dangling) {
26
- const lines = dangling.map((d) => ` ${d.from} --${d.kind}--> ${d.to} (${d.to} is not a known feature)`);
26
+ const lines = dangling.map((d) => {
27
+ const missing = d.missing && d.missing.length ? d.missing : [d.to];
28
+ const what = missing.length > 1 ? `${missing.join(', ')} are not known features`
29
+ : `${missing[0]} is not a known feature`;
30
+ return ` ${d.from} --${d.kind}--> ${d.to} (${what})`;
31
+ });
27
32
  super(`roadmap-graph: refusing to emit — ${dangling.length} dangling edge(s):\n${lines.join('\n')}`);
28
33
  this.code = 'DANGLING_EDGE';
29
34
  this.dangling = dangling;
@@ -72,7 +77,10 @@ export function buildGraph({ nodes, rawEdges, knownCodes }) {
72
77
  const fromUnknown = !known.has(e.from);
73
78
  const toUnknown = !known.has(e.to);
74
79
  if (fromUnknown || toUnknown) {
75
- dangling.push({ from: e.from, to: e.to, kind: e.type });
80
+ const missing = [];
81
+ if (fromUnknown) missing.push(e.from);
82
+ if (toUnknown) missing.push(e.to);
83
+ dangling.push({ from: e.from, to: e.to, kind: e.type, missing });
76
84
  continue;
77
85
  }
78
86
  // Known but not rendered (dropped by status) -> silently drop the edge.
@@ -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
  }
@@ -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
- if (!RESOLVABLE.has(link.provider) || !link.expect) continue;
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.29-beta",
3
+ "version": "0.2.31-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",